diff --git a/apps/app/public/locales/en/common.json b/apps/app/public/locales/en/common.json index 437700540a..517f48d5c2 100644 --- a/apps/app/public/locales/en/common.json +++ b/apps/app/public/locales/en/common.json @@ -386,6 +386,12 @@ "take-action": "Take Action", "uncheck-all": "Uncheck all", "user-avatar": "User avatar", + "user-menu": { + "admin-options": "Admin Options", + "data-portal": "Data Portal Home", + "edit-page": "Edit this page", + "user-options": "User Options" + }, "verify-account": { "verified": "Account verified!", "verified-body": "Your account has been verified! Please login to start using InReach's additional features.", diff --git a/apps/app/src/pages/api/i18n/load.ts b/apps/app/src/pages/api/i18n/load.ts index ef7c7f2f68..3595249eae 100644 --- a/apps/app/src/pages/api/i18n/load.ts +++ b/apps/app/src/pages/api/i18n/load.ts @@ -91,6 +91,9 @@ export default async function handler(req: NextRequest) { // res.status(200).json(data) return new Response(JSON.stringify(data), { status: 200, + headers: { + 'Cache-Control': 's-maxage=1, public, stale-while-revalidate=3600', + }, }) } catch (error) { log.error(error) diff --git a/apps/app/src/pages/api/trpc/[trpc].ts b/apps/app/src/pages/api/trpc/[trpc].ts index 3c82ff28c3..2ca43bd389 100644 --- a/apps/app/src/pages/api/trpc/[trpc].ts +++ b/apps/app/src/pages/api/trpc/[trpc].ts @@ -55,10 +55,11 @@ export default createNextApiHandler({ const isQuery = type === 'query' if (ctx?.res && !shouldSkip && allOk && isQuery) { + console.debug('[tRPC] Setting Cache headers in response.') const ONE_DAY_IN_SECONDS = 60 * 60 * 24 return { headers: { - 'cache-control': `s-maxage=1, stale-while-revalidate=${ONE_DAY_IN_SECONDS}`, + 'Cache-Control': `s-maxage=1, public, stale-while-revalidate=${ONE_DAY_IN_SECONDS}`, }, } } diff --git a/apps/app/src/pages/org/[slug]/index.tsx b/apps/app/src/pages/org/[slug]/index.tsx index b176b4e49b..d87868b1ad 100644 --- a/apps/app/src/pages/org/[slug]/index.tsx +++ b/apps/app/src/pages/org/[slug]/index.tsx @@ -23,34 +23,13 @@ import { useSearchState } from '@weareinreach/ui/hooks/useSearchState' import { OrgPageLoading } from '@weareinreach/ui/loading-states/OrgPage' import { api } from '~app/utils/api' import { getServerSideTranslations } from '~app/utils/i18n' +import { nsFormatter } from '~app/utils/nsFormatter' const GoogleMap = dynamic(() => import('@weareinreach/ui/components/core/GoogleMap').then((mod) => mod.GoogleMap) ) -const LoadingState = () => ( - <> - - {/* Toolbar */} - - - {/* Listing Basic */} - - {/* Body */} - - {/* Tab panels */} - - - - - {/* Contact Card */} - - {/* Visit Card */} - - - - -) +const formatNS = nsFormatter(['common', 'services', 'attribute', 'phone-type']) const useStyles = createStyles((theme) => ({ tabsList: { position: 'sticky', @@ -67,11 +46,7 @@ const OrganizationPage = ({ const router = useRouter<'/org/[slug]'>() const { data, status } = api.organization.forOrgPage.useQuery({ slug }, { enabled: !!slug }) // const { query } = router - const { - t, - i18n, - ready: i18nReady, - } = useTranslation(['common', 'services', 'attribute', 'phone-type', ...(orgId ? [orgId] : [])]) + const { t, i18n, ready: i18nReady } = useTranslation(formatNS(orgId)) const [activeTab, setActiveTab] = useState('services') const [loading, setLoading] = useState(true) const { data: hasRemote } = api.service.forServiceInfoCard.useQuery( @@ -94,15 +69,14 @@ const OrganizationPage = ({ const reviewsRef = useRef(null) useEffect(() => { - if (i18nReady && data && status === 'success') { + if (i18nReady && data && status === 'success' && !router.isFallback) { setLoading(false) if (data.locations?.length > 1) setActiveTab('locations') } - }, [data, status, i18nReady]) + }, [data, status, i18nReady, router.isFallback]) useEffect(() => { - data?.id && - i18n.reloadResources(i18n.resolvedLanguage, ['common', 'services', 'attribute', 'phone-type', data.id]) + orgId && i18n.reloadResources(i18n.resolvedLanguage, formatNS(orgId)) // eslint-disable-next-line react-hooks/exhaustive-deps }, []) @@ -287,9 +261,7 @@ export const getStaticProps = async ({ if (!orgId) return { notFound: true } const [i18n] = await Promise.allSettled([ - orgId - ? getServerSideTranslations(locale, ['common', 'services', 'attribute', 'phone-type', orgId]) - : getServerSideTranslations(locale, ['common', 'services', 'attribute', 'phone-type']), + getServerSideTranslations(locale, formatNS(orgId)), ssg.organization.forOrgPage.prefetch({ slug }), ]) // await ssg.organization.getBySlug.prefetch({ slug }) diff --git a/apps/app/src/utils/i18n.ts b/apps/app/src/utils/i18n.ts index fbba3c5059..8ca284ef25 100644 --- a/apps/app/src/utils/i18n.ts +++ b/apps/app/src/utils/i18n.ts @@ -7,8 +7,6 @@ import { type Namespaces } from '@weareinreach/db/generated/namespaces' import i18nextConfig from '../../next-i18next.config.mjs' -// type ArrayElementOrSelf = T extends Array ? U[] : T[] - type Namespace = LiteralUnion type NamespaceSSR = string | string[] | undefined export const getServerSideTranslations = async ( diff --git a/apps/app/src/utils/nsFormatter.ts b/apps/app/src/utils/nsFormatter.ts new file mode 100644 index 0000000000..51f798a1a4 --- /dev/null +++ b/apps/app/src/utils/nsFormatter.ts @@ -0,0 +1,12 @@ +import compact from 'just-compact' +import { type LiteralUnion } from 'type-fest' + +import { type Namespaces } from '@weareinreach/db/generated/namespaces' + +type Namespace = LiteralUnion +export const nsFormatter = + (baseNamespaces: Namespace | Namespace[]) => (additionalNamespaces?: string | string[]) => + compact([ + ...(Array.isArray(baseNamespaces) ? baseNamespaces : [baseNamespaces]), + ...(Array.isArray(additionalNamespaces) ? additionalNamespaces : [additionalNamespaces]), + ]) diff --git a/packages/api/trpc/ssr.ts b/packages/api/trpc/ssr.ts index 310244bf8c..23678789f8 100644 --- a/packages/api/trpc/ssr.ts +++ b/packages/api/trpc/ssr.ts @@ -1,7 +1,7 @@ import { createServerSideHelpers } from '@trpc/react-query/server' import { type GetServerSidePropsContext, type NextApiRequest, type NextApiResponse } from 'next' -import { getServerSession } from '@weareinreach/auth/next-auth/get-session' +import { getServerSession } from '@weareinreach/auth/next-auth/session.server' import { type Session } from '@weareinreach/auth/next-auth/types' import { transformer } from '@weareinreach/util/transformer' import { createContextInner } from '~api/lib/context' diff --git a/packages/auth/index.browser.ts b/packages/auth/index.browser.ts new file mode 100644 index 0000000000..15901d39d0 --- /dev/null +++ b/packages/auth/index.browser.ts @@ -0,0 +1 @@ +export { checkPermissions } from './next-auth/session.browser' diff --git a/packages/auth/index.ts b/packages/auth/index.ts index e1a87cdfc1..dae3a746bf 100644 --- a/packages/auth/index.ts +++ b/packages/auth/index.ts @@ -1,9 +1,8 @@ -/* eslint-disable import/no-unused-modules */ import { type DefaultSession, type DefaultUser, type User } from 'next-auth/core/types' import { type DefaultJWT } from 'next-auth/jwt' -export { getServerSession, checkPermissions, checkServerPermissions } from './next-auth/get-session' - +export { getServerSession, checkServerPermissions } from './next-auth/session.server' +export { checkPermissions } from './next-auth/session.browser' export type { Session } from 'next-auth' export * from './lib' diff --git a/packages/auth/next-auth/session.browser.ts b/packages/auth/next-auth/session.browser.ts new file mode 100644 index 0000000000..2d344e99da --- /dev/null +++ b/packages/auth/next-auth/session.browser.ts @@ -0,0 +1,18 @@ +import { type Session } from 'next-auth' + +import { type Permission } from '@weareinreach/db/generated/permission' + +export interface CheckPermissionsParams { + session: Session | null + permissions: Permission | Permission[] + has: 'all' | 'some' +} +export const checkPermissions = ({ session, permissions, has = 'all' }: CheckPermissionsParams) => { + if (!session) return false + if (session.user.permissions.includes('root')) return true + const userPerms = new Set(session.user.permissions) + const permsToCheck = Array.isArray(permissions) ? permissions : [permissions] + return has === 'all' + ? permsToCheck.every((perm) => userPerms.has(perm)) + : permsToCheck.some((perm) => userPerms.has(perm)) +} diff --git a/packages/auth/next-auth/get-session.ts b/packages/auth/next-auth/session.server.ts similarity index 56% rename from packages/auth/next-auth/get-session.ts rename to packages/auth/next-auth/session.server.ts index a4f3691c5d..0b810dd0a7 100644 --- a/packages/auth/next-auth/get-session.ts +++ b/packages/auth/next-auth/session.server.ts @@ -1,9 +1,8 @@ import { type GetServerSidePropsContext, type NextApiRequest, type NextApiResponse } from 'next' -import { getServerSession as nextauthServerSession, type Session } from 'next-auth' - -import { type Permission } from '@weareinreach/db/generated/permission' +import { getServerSession as nextauthServerSession } from 'next-auth' import { authOptions } from './auth-options' +import { checkPermissions, type CheckPermissionsParams } from './session.browser' interface SSRContext { req: GetServerSidePropsContext['req'] @@ -18,20 +17,6 @@ type ServerContext = SSRContext | ApiContext export const getServerSession = async (ctx: ServerContext) => { return nextauthServerSession(ctx.req, ctx.res, authOptions) } -interface CheckPermissionsParams { - session: Session | null - permissions: Permission | Permission[] - has: 'all' | 'some' -} -export const checkPermissions = ({ session, permissions, has = 'all' }: CheckPermissionsParams) => { - if (!session) return false - if (session.user.permissions.includes('root')) return true - const userPerms = new Set(session.user.permissions) - const permsToCheck = Array.isArray(permissions) ? permissions : [permissions] - return has === 'all' - ? permsToCheck.every((perm) => userPerms.has(perm)) - : permsToCheck.some((perm) => userPerms.has(perm)) -} interface CheckServerPermissions extends Omit { ctx: ServerContext diff --git a/packages/auth/package.json b/packages/auth/package.json index d3bcf7fc34..a1898ab2cb 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -3,6 +3,15 @@ "version": "0.100.0", "private": true, "license": "GPL-3.0-only", + "exports": { + ".": { + "browser": "./index.browser.ts", + "default": "./index.ts" + }, + "./*": { + "default": "./*" + } + }, "main": "./index.ts", "types": "./index.ts", "scripts": { diff --git a/packages/ui/components/core/UserMenu.tsx b/packages/ui/components/core/UserMenu.tsx index 74fcfc3449..2c27295089 100644 --- a/packages/ui/components/core/UserMenu.tsx +++ b/packages/ui/components/core/UserMenu.tsx @@ -8,17 +8,16 @@ import { UnstyledButton, } from '@mantine/core' import dynamic from 'next/dynamic' +import { useRouter } from 'next/router' import { signOut, useSession } from 'next-auth/react' import { useTranslation } from 'next-i18next' +import { checkPermissions } from '@weareinreach/auth' import { Button } from '~ui/components/core/Button' import { LangPicker } from '~ui/components/core/LangPicker' import { Link } from '~ui/components/core/Link' import { useCustomVariant } from '~ui/hooks/useCustomVariant' -// import { LoginModalLauncher } from '~ui/modals/Login' -// import { SignupModalLauncher } from '~ui/modals/SignUp' -// import { UserAvatar } from './UserAvatar' // @ts-expect-error Next Dynamic doesn't like polymorphic components const LoginModalLauncher = dynamic(() => import('~ui/modals/LoginSignUp').then((mod) => mod.LoginModalLauncher) @@ -70,10 +69,31 @@ const useStyles = createStyles((theme) => ({ export const UserMenu = ({ className, classNames, styles, unstyled }: UserMenuProps) => { const { t } = useTranslation('common') const { data: session, status } = useSession() + const router = useRouter() const { classes, cx } = useStyles(undefined, { name: 'UserMenu', classNames, styles, unstyled }) const variant = useCustomVariant() const isLoading = status === 'loading' + const canAccessDataPortal = checkPermissions({ + session, + permissions: ['dataPortalBasic', 'dataPortalAdmin', 'dataPortalManager'], + has: 'some', + }) + const editablePaths: (typeof router.pathname)[] = ['/org/[slug]', '/org/[slug]/[orgLocationId]'] + const isEditablePage = editablePaths.includes(router.pathname) + const getEditPathname = (): typeof router.pathname => { + switch (router.pathname) { + case '/org/[slug]': { + return '/org/[slug]/edit' + } + case '/org/[slug]/[orgLocationId]': { + return '/org/[slug]/[orgLocationId]/edit' + } + default: { + return router.pathname + } + } + } if ((session?.user && status === 'authenticated') || isLoading) { return ( @@ -104,6 +124,25 @@ export const UserMenu = ({ className, classNames, styles, unstyled }: UserMenuPr + {canAccessDataPortal && ( + <> + {t('user-menu.admin-options')} + + {t('user-menu.data-portal')} + + {isEditablePage && ( + router.replace({ pathname: getEditPathname(), query: router.query })} + target='_self' + > + {t('user-menu.edit-page')} + + )} + + {t('user-menu.user-options')} + + )} {t('words.saved')} diff --git a/packages/ui/components/data-portal/OrganizationTable.tsx b/packages/ui/components/data-portal/OrganizationTable.tsx index 7179ad6937..ff05401af7 100644 --- a/packages/ui/components/data-portal/OrganizationTable.tsx +++ b/packages/ui/components/data-portal/OrganizationTable.tsx @@ -1,4 +1,5 @@ import { ActionIcon, createStyles, Group, rem, Text, Tooltip, useMantineTheme } from '@mantine/core' +// import { ReactTableDevtools } from '@tanstack/react-table-devtools' import { DateTime } from 'luxon' import { MantineReactTable, @@ -12,10 +13,11 @@ import { useMantineReactTable, } from 'mantine-react-table' import { type Route } from 'nextjs-routes' -import { type Dispatch, type SetStateAction, useMemo, useRef, useState } from 'react' +import { type Dispatch, type SetStateAction, useEffect, useMemo, useRef, useState } from 'react' import { type ApiOutput } from '@weareinreach/api' import { Link } from '~ui/components/core/Link' +import { useCustomVariant } from '~ui/hooks/useCustomVariant' import { Icon } from '~ui/icon' import { trpc as api } from '~ui/lib/trpcClient' @@ -29,6 +31,11 @@ const useStyles = createStyles((theme) => ({ bottomBar: { paddingTop: rem(20), }, + devTool: { + '& *': { + backgroundColor: '#132337', + }, + }, })) const getAlertBanner = ({ @@ -187,8 +194,8 @@ const RowAction = ({ row }: RowActionProps) => { export const OrganizationTable = () => { const { classes } = useStyles() + const variants = useCustomVariant() const { data, isLoading, isError, isFetching } = api.organization.forOrganizationTable.useQuery(undefined, { - placeholderData: [], select: (data) => data.map(({ locations, ...rest }) => ({ ...rest, subRows: locations })), refetchOnWindowFocus: false, }) @@ -204,14 +211,35 @@ export const OrganizationTable = () => { enableResizing: true, minSize: 250, enableColumnFilter: false, - Cell: ({ cell, row }) => - row.original.published ? ( - cell.getValue() - ) : ( - - {cell.getValue()} + Cell: ({ cell, row }) => { + const isSubRow = row.parentId !== undefined + const isPublished = row.original.published + const isExpanded = row.getIsExpanded() + const getTextVariant = () => { + switch (true) { + case !isPublished && isExpanded: { + return variants.Text.utility3darkGray + } + case !isPublished: { + return variants.Text.utility4darkGray + } + case isExpanded: { + return variants.Text.utility3 + } + default: { + return variants.Text.utility4 + } + } + } + const textVariant = getTextVariant() + + return ( + + {cell.getValue()} + {!isPublished && } - ), + ) + }, }, { accessorKey: 'lastVerified', @@ -239,6 +267,7 @@ export const OrganizationTable = () => { filterVariant: 'date-range', enableColumnFilterModes: false, size: 150, + sortingFn: 'datetime', }, { accessorKey: 'updatedAt', @@ -313,9 +342,17 @@ export const OrganizationTable = () => { const [sorting, setSorting] = useState([ { id: 'deleted', desc: false }, { id: 'published', desc: true }, - { id: 'name', desc: false }, + // { id: 'name', desc: false }, ]) const rowVirtualizerInstanceRef = useRef>(null) + useEffect(() => { + try { + //scroll to the top of the table when the sorting changes + rowVirtualizerInstanceRef.current?.scrollToIndex(0) + } catch (e) { + console.error(e) + } + }, [sorting]) // #endregion // #region Table Setup @@ -325,28 +362,37 @@ export const OrganizationTable = () => { data: data ?? [], // #endregion // #region Enable features / Table options - columnFilterDisplayMode: 'popover', - enableColumnFilterModes: true, - enableGlobalFilterModes: true, + enableColumnResizing: false, enableFacetedValues: true, - enablePagination: false, enablePinning: true, enableRowActions: true, enableRowNumbers: false, - enableRowVirtualization: true, enableExpanding: true, - enableMultiRowSelection: true, - enableRowSelection: (row) => !row.getParentRow(), + // enableMultiRowSelection: true, + // enableRowSelection: (row) => !row.getParentRow(), + enableMultiRowSelection: false, + enableRowSelection: false, enableHiding: true, - getRowId: (originalRow) => originalRow.id, - isMultiSortEvent: () => true, - maxLeafRowFilterDepth: 0, + // getRowId: (originalRow) => originalRow.id, positionGlobalFilter: 'left', rowCount: data?.length ?? 0, + // #endregion + // #region Filtering/Sorting + columnFilterDisplayMode: 'popover', + enableColumnFilterModes: true, + enableGlobalFilterModes: true, + isMultiSortEvent: () => true, + maxLeafRowFilterDepth: 0, + // #endregion + + // #region Virtualization + enablePagination: false, + enableRowVirtualization: true, rowVirtualizerInstanceRef, - rowVirtualizerProps: { overscan: 10, estimateSize: () => 56 }, + rowVirtualizerProps: { overscan: 10, estimateSize: () => 45 }, // #endregion + // #region State initialState: { columnPinning: { left: ['mrt-row-expand', 'mrt-row-select', 'mrt-row-actions', 'name'] }, @@ -372,7 +418,7 @@ export const OrganizationTable = () => { mantinePaperProps: { miw: '85%' }, mantineProgressProps: ({ isTopToolbar }) => ({ style: { display: isTopToolbar ? 'block' : 'none' } }), mantineSelectCheckboxProps: ({ row }) => ({ style: { display: row.getCanSelect() ? 'block' : 'none' } }), - mantineTableBodyProps: { mah: '60vh' }, + mantineTableContainerProps: { mah: '60vh' }, mantineTableBodyCellProps: ({ row }) => ({ sx: (theme) => ({ textDecoration: row.original.deleted ? 'line-through' : 'none', @@ -398,7 +444,12 @@ export const OrganizationTable = () => { }) // #endregion - return + return ( + <> + + {/* */} + + ) } interface ToolbarButtonsProps { diff --git a/packages/ui/components/sections/ListingBasicInfo.tsx b/packages/ui/components/sections/ListingBasicInfo.tsx index 0aba161da0..2d6d60e3c6 100644 --- a/packages/ui/components/sections/ListingBasicInfo.tsx +++ b/packages/ui/components/sections/ListingBasicInfo.tsx @@ -126,11 +126,19 @@ export const ListingBasicEdit = ({ data, location }: ListingBasicInfoProps) => { return output } - const focusedCommBadges: CustomBadgeProps[] = focusedCommunities.map(({ attribute }) => ({ - variant: 'community', - tsKey: attribute.tsKey, - icon: attribute.icon ?? '', - })) + const focusedCommBadges: CustomBadgeProps[] = focusedCommunities.length + ? focusedCommunities.map(({ attribute }) => ({ + variant: 'community', + tsKey: attribute.tsKey, + icon: attribute.icon ?? '', + })) + : [ + { + variant: 'community', + icon: '➕', + tsKey: 'Add Focused Community badge(s)', + }, + ] return (
diff --git a/packages/ui/components/sections/Navbar.tsx b/packages/ui/components/sections/Navbar.tsx index 7275c16069..08dc8b1e13 100644 --- a/packages/ui/components/sections/Navbar.tsx +++ b/packages/ui/components/sections/Navbar.tsx @@ -1,6 +1,6 @@ import { Container, createStyles, Flex, Group, rem, UnstyledButton, useMantineTheme } from '@mantine/core' import Image from 'next/image' -import { useRouter } from 'next/router' +import { type NextRouter, useRouter } from 'next/router' import { useTranslation } from 'next-i18next' import { navbarEvent } from '@weareinreach/analytics/events' @@ -120,10 +120,29 @@ const EditModeBar = () => { deletedNotification() }, }) + const getExitEditPathname = (): NextRouter['pathname'] => { + switch (router.pathname) { + case '/org/[slug]/edit': { + return '/org/[slug]' + } + case '/org/[slug]/[orgLocationId]/edit': { + return '/org/[slug]/[orgLocationId]' + } + case '/org/[slug]/[orgLocationId]/edit/[orgServiceId]': { + return '/org/[slug]/[orgLocationId]' + } + default: { + return router.pathname + } + } + } return ( - + router.replace({ pathname: getExitEditPathname(), query: router.query })} + > {t('exit.edit-mode')}