From 6f383f269399e7b3bc07f866ed3750ec366ca31a Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Thu, 9 May 2024 18:32:11 -0400 Subject: [PATCH] fix: data portal saving (#1265) # Pull Request type Please check the type of change your PR introduces: - [x] Bugfix - [ ] Feature - [ ] Code style update (formatting, renaming) - [ ] Refactoring (no functional changes, no API changes) - [ ] Build-related changes - [ ] Documentation content changes - [ ] Other (please describe): ## What is the current behavior? Issue Number: N/A ## What is the new behavior? - - - ## Does this introduce a breaking change? - [ ] Yes - [ ] No ## Other information ## Summary by CodeRabbit - **New Features** - Added a new "Edit Mode" feature to the location queries and Google Maps integration, enhancing user control over viewing and editing location-related data. - Introduced a new upsert functionality for organizational phone data management in the API, allowing for more efficient data updates. - **Bug Fixes** - Improved flow control in permission middleware functions to ensure proper access control and execution logic. - **Documentation** - Updated API documentation to reflect new features and modifications in data handling and permission checks. - **Refactor** - Enhanced error handling across various API endpoints to provide more robust and clear error feedback. - **Style** - Adjusted UI components to accommodate new features and ensure a consistent user experience. - **Tests** - Implemented additional tests to cover new functionalities and changes in API endpoints, ensuring reliability and performance. --- packages/api/lib/context.ts | 2 +- packages/api/lib/middleware/permissions.ts | 35 ++- packages/api/lib/trpc.ts | 2 +- .../location/query.forGoogleMaps.handler.ts | 77 +++--- .../location/query.forGoogleMaps.schema.ts | 1 + .../location/query.forLocationCard.handler.ts | 93 ++++--- .../location/query.forLocationCard.schema.ts | 7 +- packages/api/router/orgPhone/index.ts | 6 + .../orgPhone/mutation.upsert.handler.ts | 117 +++++++++ .../router/orgPhone/mutation.upsert.schema.ts | 35 +++ packages/api/router/orgPhone/schemas.ts | 1 + .../orgWebsite/mutation.upsert.schema.ts | 4 +- packages/api/schemas/idPrefix.ts | 1 + packages/api/schemas/nestedOps.ts | 11 +- packages/api/types/handler.ts | 2 +- packages/ui/components/core/GoogleMap.tsx | 4 +- .../data-portal/PhoneDrawer/index.tsx | 30 ++- .../ui/components/sections/LocationCard.tsx | 238 +++++++++++------- packages/ui/hooks/useGoogleMaps.ts | 5 + packages/ui/providers/GoogleMaps.tsx | 17 +- 20 files changed, 485 insertions(+), 203 deletions(-) create mode 100644 packages/api/router/orgPhone/mutation.upsert.handler.ts create mode 100644 packages/api/router/orgPhone/mutation.upsert.schema.ts diff --git a/packages/api/lib/context.ts b/packages/api/lib/context.ts index 83a1d45ee3..0dab59fcf7 100644 --- a/packages/api/lib/context.ts +++ b/packages/api/lib/context.ts @@ -22,8 +22,8 @@ export type CreateContextOptions = { export const createContextInner = (opts: CreateContextOptions) => { return { - session: opts.session, generateId, + session: opts.session, skipCache: false, req: opts.req, res: opts.res, diff --git a/packages/api/lib/middleware/permissions.ts b/packages/api/lib/middleware/permissions.ts index 46df30d0bc..25dea4aa8d 100644 --- a/packages/api/lib/middleware/permissions.ts +++ b/packages/api/lib/middleware/permissions.ts @@ -11,16 +11,19 @@ const reject = () => { export const checkPermissions = (meta: Meta | undefined, ctx: Context) => { try { /** No permissions submitted, throw error */ - if (typeof meta?.hasPerm === 'undefined') + if (typeof meta?.hasPerm === 'undefined') { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Invalid procedure configuration, missing permission requirements.', }) + } /** Check for session object, error if missing */ invariant(ctx.session?.user) /** Check if user is `root`. If so, allow. */ - if (ctx.session.user.permissions.includes('root')) return true + if (ctx.session.user.permissions.includes('root')) { + return true + } /** Check multiple permissions */ if (Array.isArray(meta.hasPerm)) { @@ -44,20 +47,26 @@ export const checkPermissions = (meta: Meta | undefined, ctx: Context) => { export const checkRole = (allowedRoles: string[], userRoles: string[]) => { for (const userRole of userRoles) { - if (allowedRoles.includes(userRole)) return true + if (allowedRoles.includes(userRole)) { + return true + } } return false } export const isAdmin = t.middleware(({ ctx, meta, next }) => { - if (!ctx.session || !ctx.session.user) return reject() + if (ctx.session === null) { + return reject() + } if ( !(checkRole(['dataAdmin', 'sysadmin', 'root'], ctx.session?.user?.roles) && checkPermissions(meta, ctx)) - ) + ) { return reject() + } return next({ ctx: { + ...ctx, session: { ...ctx.session, user: ctx.session.user }, actorId: ctx.session.user.id, skipCache: true, @@ -65,17 +74,21 @@ export const isAdmin = t.middleware(({ ctx, meta, next }) => { }) }) export const isStaff = t.middleware(({ ctx, meta, next }) => { - if (!ctx.session || !ctx.session.user) return reject() + if (ctx.session === null) { + return reject() + } if ( !( checkRole(['dataManager', 'dataAdmin', 'sysadmin', 'system', 'root'], ctx.session?.user?.roles) && checkPermissions(meta, ctx) ) - ) + ) { return reject() + } return next({ ctx: { + ...ctx, session: { ...ctx.session, user: ctx.session.user }, actorId: ctx.session.user.id, skipCache: true, @@ -84,16 +97,20 @@ export const isStaff = t.middleware(({ ctx, meta, next }) => { }) export const hasPermissions = t.middleware(({ ctx, meta, next }) => { - if (!ctx.session || !ctx.session.user) return reject() + if (ctx.session === null) { + return reject() + } - if (checkPermissions(meta, ctx)) + if (checkPermissions(meta, ctx)) { return next({ ctx: { + ...ctx, session: { ...ctx.session, user: ctx.session.user }, actorId: ctx.session.user.id, skipCache: true, }, }) + } throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Insufficient permissions' }) }) diff --git a/packages/api/lib/trpc.ts b/packages/api/lib/trpc.ts index 80058ea444..e0c7699a10 100644 --- a/packages/api/lib/trpc.ts +++ b/packages/api/lib/trpc.ts @@ -55,7 +55,7 @@ export const importHandler = async < name: string, importer: () => Promise ) => { - const nameInCache = name as keyof typeof HANDLER_CACHE + const nameInCache = name satisfies keyof typeof HANDLER_CACHE if (!HANDLER_CACHE[nameInCache]) { const importedModule = await importer() diff --git a/packages/api/router/location/query.forGoogleMaps.handler.ts b/packages/api/router/location/query.forGoogleMaps.handler.ts index 7ff1993303..d68f412d91 100644 --- a/packages/api/router/location/query.forGoogleMaps.handler.ts +++ b/packages/api/router/location/query.forGoogleMaps.handler.ts @@ -2,6 +2,7 @@ import { TRPCError } from '@trpc/server' import { getBounds, getCenterOfBounds } from 'geolib' import { prisma } from '@weareinreach/db' +import { handleError } from '~api/lib' import { globalWhere } from '~api/selects/global' import { type TRPCHandlerParams } from '~api/types/handler' @@ -21,45 +22,51 @@ const getCenter = (coords: { latitude: number; longitude: number }[]): google.ma return { lat: center.latitude, lng: center.longitude } } -const forGoogleMaps = async ({ input }: TRPCHandlerParams) => { - const result = await prisma.orgLocation.findMany({ - where: { - ...globalWhere.isPublic(), - id: { in: Array.isArray(input.locationIds) ? input.locationIds : [input.locationIds] }, - AND: [{ latitude: { not: 0 } }, { longitude: { not: 0 } }], - }, - select: { - id: true, - name: true, - latitude: true, - longitude: true, - }, - }) - if (!result.length) { - throw new TRPCError({ code: 'NOT_FOUND' }) - } - const coordsForBounds: { latitude: number; longitude: number }[] = [] +const forGoogleMaps = async ({ input, ctx }: TRPCHandlerParams) => { + try { + const { locationIds, isEditMode } = input + const canSeeAll = isEditMode && !!ctx.session?.user?.permissions + const result = await prisma.orgLocation.findMany({ + where: { + ...(!canSeeAll && globalWhere.isPublic()), + id: { in: locationIds }, + AND: [{ latitude: { not: 0 } }, { longitude: { not: 0 } }], + }, + select: { + id: true, + name: true, + latitude: true, + longitude: true, + }, + }) + if (!result.length) { + throw new TRPCError({ code: 'NOT_FOUND' }) + } + const coordsForBounds: { latitude: number; longitude: number }[] = [] - for (const { latitude, longitude } of result) { - if (latitude && longitude) { - coordsForBounds.push({ latitude, longitude }) + for (const { latitude, longitude } of result) { + if (latitude && longitude) { + coordsForBounds.push({ latitude, longitude }) + } } - } - const bounds = result.length > 1 ? getBoundary(coordsForBounds) : null - const singleLat = result.at(0)?.latitude - const singleLon = result.at(0)?.longitude + const bounds = result.length > 1 ? getBoundary(coordsForBounds) : null + const singleLat = result.at(0)?.latitude + const singleLon = result.at(0)?.longitude - const center = - result.length === 1 && singleLat && singleLon - ? ({ lat: singleLat, lng: singleLon } satisfies google.maps.LatLngLiteral) - : getCenter(coordsForBounds) - const zoom = result.length === 1 ? 17 : null + const center = + result.length === 1 && singleLat && singleLon + ? ({ lat: singleLat, lng: singleLon } satisfies google.maps.LatLngLiteral) + : getCenter(coordsForBounds) + const zoom = result.length === 1 ? 17 : null - return { - locations: result, - bounds, - center, - zoom, + return { + locations: result, + bounds, + center, + zoom, + } + } catch (e) { + return handleError(e) } } export default forGoogleMaps diff --git a/packages/api/router/location/query.forGoogleMaps.schema.ts b/packages/api/router/location/query.forGoogleMaps.schema.ts index 7de195c404..175ff9fac4 100644 --- a/packages/api/router/location/query.forGoogleMaps.schema.ts +++ b/packages/api/router/location/query.forGoogleMaps.schema.ts @@ -4,5 +4,6 @@ import { prefixedId } from '~api/schemas/idPrefix' export const ZForGoogleMapsSchema = z.object({ locationIds: prefixedId('orgLocation').array(), + isEditMode: z.boolean().optional().default(false), }) export type TForGoogleMapsSchema = z.infer diff --git a/packages/api/router/location/query.forLocationCard.handler.ts b/packages/api/router/location/query.forLocationCard.handler.ts index 69673c490e..28014ba130 100644 --- a/packages/api/router/location/query.forLocationCard.handler.ts +++ b/packages/api/router/location/query.forLocationCard.handler.ts @@ -1,56 +1,67 @@ import { prisma } from '@weareinreach/db' +import { handleError } from '~api/lib/errorHandler' import { globalWhere } from '~api/selects/global' import { type TRPCHandlerParams } from '~api/types/handler' import { type TForLocationCardSchema } from './query.forLocationCard.schema' -const forLocationCard = async ({ input }: TRPCHandlerParams) => { - const result = await prisma.orgLocation.findUniqueOrThrow({ - where: { - id: input, - ...globalWhere.isPublic(), - }, - select: { - id: true, - name: true, - street1: true, - street2: true, - city: true, - postCode: true, - latitude: true, - longitude: true, - notVisitable: true, - country: { select: { cca2: true } }, - govDist: { select: { abbrev: true, tsKey: true, tsNs: true } }, - phones: { - where: { phone: globalWhere.isPublic() }, - select: { phone: { select: { primary: true, number: true, country: { select: { cca2: true } } } } }, +const forLocationCard = async ({ input, ctx }: TRPCHandlerParams) => { + try { + const { id, isEditMode } = input + + const canSeeAll = isEditMode && !!ctx.session?.user?.permissions + + const result = await prisma.orgLocation.findUniqueOrThrow({ + where: { + id, + ...(!canSeeAll && globalWhere.isPublic()), }, - attributes: { select: { attribute: { select: { tsNs: true, tsKey: true, icon: true } } } }, - services: { - select: { - service: { - select: { - services: { select: { tag: { select: { primaryCategory: { select: { tsKey: true } } } } } }, + select: { + id: true, + name: true, + street1: true, + street2: true, + city: true, + postCode: true, + latitude: true, + longitude: true, + notVisitable: true, + country: { select: { cca2: true } }, + govDist: { select: { abbrev: true, tsKey: true, tsNs: true } }, + phones: { + ...(!canSeeAll && { where: { phone: globalWhere.isPublic() } }), + select: { phone: { select: { primary: true, number: true, country: { select: { cca2: true } } } } }, + }, + attributes: { select: { attribute: { select: { tsNs: true, tsKey: true, icon: true } } } }, + services: { + select: { + service: { + select: { + services: { select: { tag: { select: { primaryCategory: { select: { tsKey: true } } } } } }, + }, }, }, }, }, - }, - }) + }) - const transformed = { - ...result, - country: result.country.cca2, - phones: result.phones.map(({ phone }) => ({ ...phone, country: phone.country.cca2 })), - attributes: result.attributes.map(({ attribute }) => attribute), - services: [ - ...new Set( - result.services.flatMap(({ service }) => service.services.map(({ tag }) => tag.primaryCategory.tsKey)) - ), - ], - } + const transformed = { + ...result, + country: result.country.cca2, + phones: result.phones.map(({ phone }) => ({ ...phone, country: phone.country.cca2 })), + attributes: result.attributes.map(({ attribute }) => attribute), + services: [ + ...new Set( + result.services.flatMap(({ service }) => + service.services.map(({ tag }) => tag.primaryCategory.tsKey) + ) + ), + ], + } - return transformed + return transformed + } catch (err) { + return handleError(err) + } } export default forLocationCard diff --git a/packages/api/router/location/query.forLocationCard.schema.ts b/packages/api/router/location/query.forLocationCard.schema.ts index 2653390114..3c743dc779 100644 --- a/packages/api/router/location/query.forLocationCard.schema.ts +++ b/packages/api/router/location/query.forLocationCard.schema.ts @@ -1,6 +1,9 @@ -import { type z } from 'zod' +import { z } from 'zod' import { prefixedId } from '~api/schemas/idPrefix' -export const ZForLocationCardSchema = prefixedId('orgLocation') +export const ZForLocationCardSchema = z.object({ + id: prefixedId('orgLocation'), + isEditMode: z.boolean().optional().default(false), +}) export type TForLocationCardSchema = z.infer diff --git a/packages/api/router/orgPhone/index.ts b/packages/api/router/orgPhone/index.ts index 1bd2a9f06e..fcc8a790ed 100644 --- a/packages/api/router/orgPhone/index.ts +++ b/packages/api/router/orgPhone/index.ts @@ -68,4 +68,10 @@ export const orgPhoneRouter = defineRouter({ ) return handler(opts) }), + upsert: permissionedProcedure('updatePhone') + .input(schema.ZUpsertSchema) + .mutation(async (opts) => { + const handler = await importHandler(namespaced('upsert'), () => import('./mutation.upsert.handler')) + return handler(opts) + }), }) diff --git a/packages/api/router/orgPhone/mutation.upsert.handler.ts b/packages/api/router/orgPhone/mutation.upsert.handler.ts new file mode 100644 index 0000000000..cc4d83db6f --- /dev/null +++ b/packages/api/router/orgPhone/mutation.upsert.handler.ts @@ -0,0 +1,117 @@ +import { upsertSingleKey } from '@weareinreach/crowdin/api' +import { + generateId, + generateNestedFreeText, + generateNestedFreeTextUpsert, + getAuditedClient, + Prisma, +} from '@weareinreach/db' +import { handleError } from '~api/lib/errorHandler' +import { connectOne, connectOneRequired, createOne } from '~api/schemas/nestedOps' +import { type TRPCHandlerParams } from '~api/types/handler' + +import { type Create, type TUpsertSchema } from './mutation.upsert.schema' + +type CreateData = Pick< + Create, + 'number' | 'deleted' | 'ext' | 'locationOnly' | 'primary' | 'published' | 'serviceOnly' +> +const upsert = async ({ ctx, input }: TRPCHandlerParams) => { + try { + const prisma = getAuditedClient(ctx.actorId) + const { operation, id: passedId, countryId, description: desc, orgId, phoneTypeId, ...data } = input + + const isCreateData = (op: 'create' | 'update', inputData: typeof data): inputData is CreateData => + op === 'create' + const isCreate = isCreateData(operation, data) + const id = passedId ?? generateId('orgPhone') + + const generateDescription = (): GeneratedDescription | undefined => { + if (!desc || !orgId) { + return undefined + } + if (isCreate) { + const nestedDesc = generateNestedFreeText({ + orgId, + text: desc, + type: 'phoneDesc', + itemId: id, + }) + const crowdinArgs = { + key: nestedDesc.create.tsKey.create.key, + text: nestedDesc.create.tsKey.create.text, + } + return { + crowdinArgs, + prisma: Prisma.validator()(nestedDesc), + } + } else { + const nestedDesc = generateNestedFreeTextUpsert({ + orgId, + text: desc, + type: 'phoneDesc', + itemId: id, + }) + const crowdinArgs = { + key: nestedDesc.upsert.create.tsKey.create.key, + text: nestedDesc.upsert.create.tsKey.create.text, + } + return { + crowdinArgs, + prisma: Prisma.validator()(nestedDesc), + } + } + } + const description = generateDescription() + + const result = await prisma.$transaction(async (tx) => { + if (description) { + const crowdin = await upsertSingleKey({ + isDatabaseString: true, + ...description.crowdinArgs, + }) + if (description.prisma.create?.tsKey?.create) { + description.prisma.create.tsKey.create.crowdinId = crowdin.id + } + } + + const txnResult = + isCreate && countryId + ? await tx.orgPhone.create({ + data: { + id, + ...data, + description: description?.prisma, + country: connectOneRequired(countryId, 'id'), + phoneType: connectOne(phoneTypeId, 'id'), + organization: createOne(orgId, 'organizationId'), + }, + }) + : await tx.orgPhone.update({ + where: { id }, + data: { + ...data, + country: connectOne(countryId, 'id'), + phoneType: connectOne(phoneTypeId, 'id'), + description: description?.prisma, + }, + }) + return txnResult + }) + return result + } catch (error) { + return handleError(error) + } +} +export default upsert +type CrowdinData = { + key: string + text: string +} + +type GeneratedDescription = { + crowdinArgs: CrowdinData + prisma: + | Prisma.FreeTextCreateNestedOneWithoutOrgPhoneInput + | Prisma.FreeTextUpdateOneWithoutOrgPhoneNestedInput +} diff --git a/packages/api/router/orgPhone/mutation.upsert.schema.ts b/packages/api/router/orgPhone/mutation.upsert.schema.ts new file mode 100644 index 0000000000..58b307c3bb --- /dev/null +++ b/packages/api/router/orgPhone/mutation.upsert.schema.ts @@ -0,0 +1,35 @@ +import { z } from 'zod' + +import { prefixedId } from '~api/schemas/idPrefix' + +const base = z + .object({ + id: prefixedId('orgPhone'), + orgId: prefixedId('organization'), + number: z.string(), + ext: z.string().nullable(), + primary: z.boolean(), + published: z.boolean(), + deleted: z.boolean(), + countryId: prefixedId('country'), + phoneTypeId: prefixedId('phoneType').nullable(), + locationOnly: z.boolean(), + serviceOnly: z.boolean(), + description: z.string().nullable(), + }) + .partial() + +const create = z + .object({ + operation: z.literal('create'), + }) + .merge(base.required({ number: true, countryId: true })) +const update = z + .object({ + operation: z.literal('update'), + }) + .merge(base.required({ id: true })) +export type Create = z.infer +export type Update = z.infer +export const ZUpsertSchema = z.discriminatedUnion('operation', [create, update]) +export type TUpsertSchema = z.infer diff --git a/packages/api/router/orgPhone/schemas.ts b/packages/api/router/orgPhone/schemas.ts index 787d2571cd..af31de0ca9 100644 --- a/packages/api/router/orgPhone/schemas.ts +++ b/packages/api/router/orgPhone/schemas.ts @@ -2,6 +2,7 @@ export * from './mutation.create.schema' export * from './mutation.locationLink.schema' export * from './mutation.update.schema' +export * from './mutation.upsert.schema' export * from './query.forContactInfo.schema' export * from './query.forContactInfoEdit.schema' export * from './query.forEditDrawer.schema' diff --git a/packages/api/router/orgWebsite/mutation.upsert.schema.ts b/packages/api/router/orgWebsite/mutation.upsert.schema.ts index c897eb476d..acd8185ffe 100644 --- a/packages/api/router/orgWebsite/mutation.upsert.schema.ts +++ b/packages/api/router/orgWebsite/mutation.upsert.schema.ts @@ -1,6 +1,6 @@ import { z } from 'zod' -import { prefixedId } from '~api/schemas/idPrefix' +import { emptyStringToNull, prefixedId } from '~api/schemas/idPrefix' const base = z .object({ @@ -11,7 +11,7 @@ const base = z published: z.boolean(), deleted: z.boolean(), organizationId: prefixedId('organization'), - orgLocationId: prefixedId('orgLocation').nullable(), + orgLocationId: z.preprocess(emptyStringToNull, prefixedId('orgLocation').nullable()), orgLocationOnly: z.boolean(), }) .partial() diff --git a/packages/api/schemas/idPrefix.ts b/packages/api/schemas/idPrefix.ts index 4b3e033683..8a2d78b17e 100644 --- a/packages/api/schemas/idPrefix.ts +++ b/packages/api/schemas/idPrefix.ts @@ -8,4 +8,5 @@ export const prefixedId = (model: IdPrefix | IdPrefix[]) => { : new RegExp(`^${idPrefix[model]}_\\w+$`) return z.string().regex(regEx) } +export const emptyStringToNull = (s: T) => (s === '' ? null : s) export { idPrefix, type IdPrefix } diff --git a/packages/api/schemas/nestedOps.ts b/packages/api/schemas/nestedOps.ts index 2d4911a01b..c64934ae04 100644 --- a/packages/api/schemas/nestedOps.ts +++ b/packages/api/schemas/nestedOps.ts @@ -123,10 +123,17 @@ export const diffConnectionsMtoN = >( deleteMany: deletions.length ? deletions : undefined, } } -export const connectOneRequired = >(data: T) => { +export const connectOneRequired = | string, K extends string = 'id'>( + data: T, + key: K = 'id' as K +) => { invariant(data) + + if (isString(data)) { + return { connect: { [key]: data } as T extends string ? { [Key in K]: T } : T } + } return { - connect: data, + connect: data as T extends string ? { [Key in K]: T } : T, } } diff --git a/packages/api/types/handler.ts b/packages/api/types/handler.ts index a82cab4973..c3df737e08 100644 --- a/packages/api/types/handler.ts +++ b/packages/api/types/handler.ts @@ -6,7 +6,7 @@ type ContextScenario = 'public' | 'protected' type AuthedContext = SetNonNullable & { actorId: string } export type TRPCHandlerParams = { - ctx: TScenario extends 'public' ? Omit : Omit + ctx: TScenario extends 'public' ? Context : AuthedContext } & (undefined extends TInput ? [TInput] extends [undefined] ? { input?: never } diff --git a/packages/ui/components/core/GoogleMap.tsx b/packages/ui/components/core/GoogleMap.tsx index cbd9ed6a26..99cfdc8539 100644 --- a/packages/ui/components/core/GoogleMap.tsx +++ b/packages/ui/components/core/GoogleMap.tsx @@ -3,6 +3,7 @@ import { Status, Wrapper } from '@googlemaps/react-wrapper' import { rem, Skeleton } from '@mantine/core' import { memo, useCallback, useEffect, useRef } from 'react' +import { useEditMode } from '~ui/hooks/useEditMode' import { useGoogleMaps, useGoogleMapSetup } from '~ui/hooks/useGoogleMaps' import { trpc as api } from '~ui/lib/trpcClient' @@ -31,9 +32,10 @@ const MapRenderer = memo(({ height, width }: MapRendererProps) => { MapRenderer.displayName = 'GoogleMapRenderer' export const GoogleMap = ({ height, width, locationIds }: GoogleMapProps) => { + const { isEditMode } = useEditMode() const { map, mapIsReady, mapEvents, camera } = useGoogleMaps() const { data, isLoading } = api.location.forGoogleMaps.useQuery( - { locationIds: Array.isArray(locationIds) ? locationIds : [locationIds] }, + { locationIds: Array.isArray(locationIds) ? locationIds : [locationIds], isEditMode }, { enabled: mapIsReady } ) diff --git a/packages/ui/components/data-portal/PhoneDrawer/index.tsx b/packages/ui/components/data-portal/PhoneDrawer/index.tsx index 79939fca25..c2902f1fe7 100644 --- a/packages/ui/components/data-portal/PhoneDrawer/index.tsx +++ b/packages/ui/components/data-portal/PhoneDrawer/index.tsx @@ -88,7 +88,7 @@ const _PhoneDrawer = forwardRef( resolver: zodResolver(FormSchema), values: initialData ?? undefined, defaultValues: { - id: '', + id: phoneId, number: '', countryId: '', description: '', @@ -99,7 +99,7 @@ const _PhoneDrawer = forwardRef( const { isDirty: formIsDirty } = formState const [isSaved, setIsSaved] = useState(formIsDirty) const hasLocationId = typeof router.query.orgLocationId === 'string' ? router.query.orgLocationId : null - const siteUpdate = api.orgPhone.update.useMutation({ + const siteUpdate = api.orgPhone.upsert.useMutation({ onSettled: (data) => { apiUtils.orgPhone.invalidate() reset(data) @@ -107,6 +107,8 @@ const _PhoneDrawer = forwardRef( onSuccess: () => { setIsSaved(true) apiUtils.orgPhone.invalidate() + modalHandler.close() + drawerHandler.close() }, }) // const isSaved = /*siteUpdate.isSuccess &&*/ !formState.isDirty @@ -154,19 +156,21 @@ const _PhoneDrawer = forwardRef( }) }, [hasLocationId, phoneId, unlinkFromLocation]) - const handleSaveButton = useCallback(() => { - handleSubmit( - (data) => { - siteUpdate.mutate({ orgId: orgId ?? '', ...data }) - }, - (error) => console.error(error) - ) - }, [handleSubmit, orgId, siteUpdate]) + const handleSaveButton = useCallback( + () => + handleSubmit( + (data) => { + siteUpdate.mutate({ orgId: orgId ?? '', operation: createNew ? 'create' : 'update', ...data }) + }, + (error) => console.error(error) + ), + [createNew, handleSubmit, orgId, siteUpdate] + ) const handleModalSave = useCallback(() => { const valuesToSubmit = getValues() siteUpdate.mutate( - { ...valuesToSubmit, orgId: orgId ?? '' }, + { ...valuesToSubmit, orgId: orgId ?? '', operation: createNew ? 'create' : 'update' }, { onSuccess: () => { modalHandler.close() @@ -174,7 +178,7 @@ const _PhoneDrawer = forwardRef( }, } ) - }, [drawerHandler, getValues, modalHandler, orgId, siteUpdate]) + }, [createNew, drawerHandler, getValues, modalHandler, orgId, siteUpdate]) const handleCloseNoSave = useCallback(() => { reset() modalHandler.close() @@ -186,7 +190,7 @@ const _PhoneDrawer = forwardRef( -
+ diff --git a/packages/ui/components/sections/LocationCard.tsx b/packages/ui/components/sections/LocationCard.tsx index ac9c0e9bd6..5a7f8f2c63 100644 --- a/packages/ui/components/sections/LocationCard.tsx +++ b/packages/ui/components/sections/LocationCard.tsx @@ -1,13 +1,16 @@ import { Card, Divider, Group, List, Stack, Title, useMantineTheme } from '@mantine/core' import { useElementSize } from '@mantine/hooks' -import { Easing, getAll as getAllTweens, Tween } from '@tweenjs/tween.js' +import { Easing, Tween } from '@tweenjs/tween.js' import compact from 'just-compact' import parsePhoneNumber, { type CountryCode } from 'libphonenumber-js' import { formatAddress } from 'localized-address-format' import { useRouter } from 'next/router' -import { useTranslation } from 'next-i18next' -import { useEffect, useMemo, useRef, useState } from 'react' +import { type TFunction, useTranslation } from 'next-i18next' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import invariant from 'tiny-invariant' +import { type SetNonNullable } from 'type-fest' +import { type ApiOutput } from '@weareinreach/api' import { Badge } from '~ui/components/core/Badge' import { Link } from '~ui/components/core/Link' import { Rating } from '~ui/components/core/Rating' @@ -17,17 +20,25 @@ import { useGoogleMaps } from '~ui/hooks/useGoogleMaps' import { type IconList } from '~ui/icon' import { trpc as api } from '~ui/lib/trpcClient' -let runningAnimation: number -const stopAnimations = (animationProcess: number) => { - cancelAnimationFrame(animationProcess) - const tweens = getAllTweens() - for (const instance of tweens) { - instance.stop() +const getAdminArea = (data: ApiOutput['location']['forLocationCard'] | undefined, t: TFunction) => { + if (data?.govDist?.abbrev) { + return data.govDist.abbrev } + if (data?.govDist?.tsKey) { + return t(data.govDist.tsKey, { ns: data.govDist.tsNs }) + } + return undefined } +const hasData = ( + data: ApiOutput['location']['forLocationCard'] | undefined +): data is ApiOutput['location']['forLocationCard'] => !!data +const hasCoords = ( + data: ApiOutput['location']['forLocationCard'] | undefined +): data is SetNonNullable => + !!data?.latitude && !!data?.longitude export const LocationCard = ({ remoteOnly, locationId, edit }: LocationCardProps) => { - const { map, mapIsReady, mapEvents, camera } = useGoogleMaps() + const { map, mapIsReady, mapEvents, camera, tweenGroup } = useGoogleMaps() const [initialPosition, setInitialPosition] = useState() const [canGetCenter, setCanGetCenter] = useState(map?.getCenter() !== undefined) @@ -38,33 +49,32 @@ export const LocationCard = ({ remoteOnly, locationId, edit }: LocationCardProps const { ref: addressRef, height: addressListHeight } = useElementSize() const theme = useMantineTheme() const mapMarker = useGoogleMapMarker() + const { data: orgId } = api.organization.getIdFromSlug.useQuery( { slug: router.query.slug ?? '' }, { enabled: router.isReady } ) - const { data } = api.location.forLocationCard.useQuery(locationId ?? '', { - enabled: !remoteOnly, - }) + const { data } = api.location.forLocationCard.useQuery( + { id: locationId ?? '', isEditMode: edit }, + { + enabled: !remoteOnly, + } + ) const { data: remoteServices, isLoading: remoteIsLoading } = api.service.forServiceInfoCard.useQuery( { parentId: orgId?.id ?? '', remoteOnly }, { enabled: remoteOnly && orgId?.id !== undefined } ) const formattedAddressParts = useMemo(() => { - const adminArea = data?.govDist?.abbrev - ? data.govDist.abbrev - : data?.govDist?.tsKey - ? (t(data.govDist.tsKey, { ns: data.govDist.tsNs }) satisfies string) - : undefined + const administrativeArea = getAdminArea(data, t) return formatAddress({ addressLines: compact([data?.street1?.trim(), data?.street2?.trim()]), locality: data?.city.trim(), postalCode: data?.postCode ? data.postCode.trim() : undefined, postalCountry: data?.country, - administrativeArea: adminArea, + administrativeArea, }) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [data]) + }, [data, t]) mapEvents.initialPropsSet.useSubscription((val) => { setCanGetCenter(val) @@ -77,78 +87,132 @@ export const LocationCard = ({ remoteOnly, locationId, edit }: LocationCardProps } }) + const mapMarkerData = useMemo(() => { + if (!mapIsReady || !map || !hasData(data) || !canGetCenter) { + return null + } + if (!hasCoords(data)) { + return null + } + + return { + map, + id: data.id, + name: data.name ?? '', + lat: data.latitude, + lng: data.longitude, + address: formattedAddressParts, + slug: router.query.slug, + locationId: data.id, + } + }, [mapIsReady, map, data, canGetCenter, formattedAddressParts, router.query.slug]) + useEffect(() => { - if (mapIsReady && map && data?.latitude && data?.longitude && canGetCenter && !data?.notVisitable) { - mapMarker.add({ - map, - id: data.id, - name: data.name ?? '', - lat: data.latitude, - lng: data.longitude, - address: formattedAddressParts, - slug: router.query.slug, - locationId: data.id, - }) + try { + invariant(mapMarkerData) + mapMarker.add(mapMarkerData) + return () => { + mapMarker.remove(mapMarkerData.id) + } + } catch { + return void 0 } - return () => { - if (locationId) { - mapMarker.remove(locationId) + }, [mapMarker, mapMarkerData]) + + const stopAnimations = useCallback(() => { + const tweens = tweenGroup.getAll() + for (const instance of tweens) { + if (instance.isPlaying()) { + instance.stop() } } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [data, mapIsReady, formattedAddressParts, router.query.slug, canGetCenter]) - useEffect(() => { - if (locationId) { + }, [tweenGroup]) + const createTweens = useCallback(() => { + try { + invariant(locationId) + invariant(map) + invariant(data?.latitude) + invariant(data?.longitude) + invariant(initialPosition) const marker = mapMarker.get(locationId) - if (marker && cardRef.current && map && data?.latitude && data?.longitude && initialPosition) { - const card = cardRef.current - const locationCoords = new google.maps.LatLng({ lat: data.latitude, lng: data.longitude }) - const locationView: google.maps.CameraOptions = { - center: locationCoords.toJSON(), - zoom: 17, - } - const zoomIn = new Tween(camera) - .to(locationView, 2000) - .easing(Easing.Quadratic.Out) - .onUpdate(() => { - map.moveCamera(camera) - }) - const zoomOut = new Tween(camera) - .to(initialPosition, 2000) - .easing(Easing.Quadratic.Out) - .onUpdate(() => { - map.moveCamera(camera) - }) + invariant(marker) + const locationCoords = new google.maps.LatLng({ lat: data.latitude, lng: data.longitude }) + const locationView: google.maps.CameraOptions = { + center: locationCoords.toJSON(), + zoom: 17, + } + const zoomIn = new Tween(camera, tweenGroup) + .to(locationView, 2000) + .easing(Easing.Quadratic.Out) + .onUpdate((cameraOptions) => { + map.moveCamera(cameraOptions) + }) + .dynamic(true) - const animateIn = async (time: number) => { - runningAnimation = requestAnimationFrame(animateIn) - setTimeout(() => zoomIn.update(time), 80) - } - const animateOut = async (time: number) => { - runningAnimation = requestAnimationFrame(animateOut) - setTimeout(() => zoomOut.update(time), 80) - } - const enterEvent = () => { - stopAnimations(runningAnimation) - zoomIn.start() - runningAnimation = requestAnimationFrame(animateIn) + const zoomOut = new Tween(camera, tweenGroup) + .to(initialPosition, 2000) + .easing(Easing.Quadratic.Out) + .onUpdate((cameraOptions) => { + map.moveCamera(cameraOptions) + }) + .dynamic(true) + + const animateIn = () => { + if (zoomIn.isPlaying()) { + requestAnimationFrame(animateIn) + zoomIn.update() } - const exitEvent = () => { - stopAnimations(runningAnimation) - zoomOut.start() - runningAnimation = requestAnimationFrame(animateOut) + } + const animateOut = () => { + if (zoomOut.isPlaying()) { + requestAnimationFrame(animateOut) + zoomOut.update() } - card.addEventListener('mouseenter', enterEvent) - card.addEventListener('mouseleave', exitEvent) + } + const enterEvent = () => { + stopAnimations() + zoomIn.start() + requestAnimationFrame(animateIn) + } + const exitEvent = () => { + stopAnimations() + zoomOut.start() + requestAnimationFrame(animateOut) + } + return { enter: enterEvent, exit: exitEvent } + } catch { + return null + } + }, [ + camera, + data?.latitude, + data?.longitude, + initialPosition, + locationId, + map, + mapMarker, + tweenGroup, + stopAnimations, + ]) - return () => { - card.removeEventListener('mouseenter', enterEvent) - card.removeEventListener('mouseleave', exitEvent) - } + useEffect(() => { + try { + const card = cardRef.current + const tweenEvents = createTweens() + invariant(card) + invariant(tweenEvents) + const { enter, exit } = tweenEvents + card.addEventListener('mouseenter', enter) + card.addEventListener('mouseleave', exit) + + return () => { + card.removeEventListener('mouseenter', enter) + card.removeEventListener('mouseleave', exit) } + } catch { + return () => void 0 } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [data, mapIsReady, map, mapMarker.get(locationId ?? ''), initialPosition]) + }, [cardRef, createTweens]) const remoteReady = remoteOnly && remoteServices?.length && !remoteIsLoading @@ -194,13 +258,11 @@ export const LocationCard = ({ remoteOnly, locationId, edit }: LocationCardProps ) } - if (!data) return null + if (!data) { + return null + } - const adminArea = data.govDist?.abbrev - ? data.govDist.abbrev - : data.govDist?.tsKey - ? (t(data.govDist.tsKey, { ns: data.govDist.tsNs }) satisfies string) - : undefined + const adminArea = getAdminArea(data, t) const formattedAddress = formatAddress({ addressLines: compact([data.street1?.trim(), data.street2?.trim()]), diff --git a/packages/ui/hooks/useGoogleMaps.ts b/packages/ui/hooks/useGoogleMaps.ts index 0d665424a9..d7e6838817 100644 --- a/packages/ui/hooks/useGoogleMaps.ts +++ b/packages/ui/hooks/useGoogleMaps.ts @@ -1,3 +1,4 @@ +import { type Group as TweenGroup } from '@tweenjs/tween.js' import { useContext } from 'react' import { GoogleMapContext, type MapEvents, type MarkerState } from '~ui/providers/GoogleMaps' @@ -15,6 +16,7 @@ export const useGoogleMaps = (): UseGoogleMapsReturn => { mapEvents: context.mapEvents, camera: context.camera, marker: context.marker, + tweenGroup: context.tweenGroup, } } else { return { @@ -24,6 +26,7 @@ export const useGoogleMaps = (): UseGoogleMapsReturn => { mapEvents: context.mapEvents, camera: context.camera, marker: context.marker, + tweenGroup: context.tweenGroup, } } } @@ -49,6 +52,7 @@ interface MapNotReady { mapEvents: MapEvents camera: google.maps.CameraOptions marker: MarkerState + tweenGroup: TweenGroup } interface MapIsReady { map: google.maps.Map @@ -57,5 +61,6 @@ interface MapIsReady { mapEvents: MapEvents camera: google.maps.CameraOptions marker: MarkerState + tweenGroup: TweenGroup } type UseGoogleMapsReturn = MapNotReady | MapIsReady diff --git a/packages/ui/providers/GoogleMaps.tsx b/packages/ui/providers/GoogleMaps.tsx index 4b417a2df2..1bbef6bf77 100644 --- a/packages/ui/providers/GoogleMaps.tsx +++ b/packages/ui/providers/GoogleMaps.tsx @@ -1,3 +1,4 @@ +import { Group as TweenGroup } from '@tweenjs/tween.js' import { useEventEmitter, useMap } from 'ahooks' import { type EventEmitter } from 'ahooks/lib/useEventEmitter' import { createContext, type ReactNode, useEffect, useMemo, useRef, useState } from 'react' @@ -27,7 +28,7 @@ export const GoogleMapsProvider = ({ children }: { children: ReactNode }) => { }, [map]) const cameraRef = useRef(initialCamera) - + const tweenGroup = useMemo(() => new TweenGroup(), []) mapEventRef.current.ready.useSubscription((val) => { setIsReady(val) }) @@ -58,9 +59,9 @@ export const GoogleMapsProvider = ({ children }: { children: ReactNode }) => { const mapIsReady = typeof map !== 'undefined' && typeof infoWindow !== 'undefined' - const contextValue: ContextValue = useMemo( + const contextValue: ContextValue = useMemo( () => - mapIsReady + mapIsReady && isReady ? { map, infoWindow, @@ -69,7 +70,8 @@ export const GoogleMapsProvider = ({ children }: { children: ReactNode }) => { mapEvents, marker, markers, - isReady: true, + isReady, + tweenGroup, camera: cameraRef.current, } : { @@ -78,13 +80,13 @@ export const GoogleMapsProvider = ({ children }: { children: ReactNode }) => { mapEvents, marker, markers, + tweenGroup, + isReady: false, map: undefined, infoWindow: undefined, - isReady: false, camera: cameraRef.current, }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [isReady] + [infoWindow, map, mapEvents, mapIsReady, marker, markers, isReady, tweenGroup] ) return {children} } @@ -108,6 +110,7 @@ interface GoogleMapContextBase { camera: google.maps.CameraOptions marker: MarkerState markers: Map + tweenGroup: TweenGroup } interface GoogleMapReadyContext extends GoogleMapContextBase {