diff --git a/packages/api/package.json b/packages/api/package.json index 52c603166d..cb19426f62 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -46,6 +46,7 @@ "libphonenumber-js": "1.10.60", "luxon": "3.4.4", "nanoid": "5.0.7", + "remeda": "1.58.0", "slugify": "1.6.6", "tiny-invariant": "1.3.3", "zod": "3.22.4" diff --git a/packages/api/router/orgWebsite/mutation.update.handler.ts b/packages/api/router/orgWebsite/mutation.update.handler.ts index d0f78ed949..e8897362bd 100644 --- a/packages/api/router/orgWebsite/mutation.update.handler.ts +++ b/packages/api/router/orgWebsite/mutation.update.handler.ts @@ -1,15 +1,38 @@ +import * as R from 'remeda' + import { getAuditedClient } from '@weareinreach/db' import { type TRPCHandlerParams } from '~api/types/handler' import { type TUpdateSchema } from './mutation.update.schema' export const update = async ({ ctx, input }: TRPCHandlerParams) => { - const { where, data } = input const prisma = getAuditedClient(ctx.actorId) - const updated = await prisma.orgWebsite.update({ + const { where, data, - }) - return updated + data: { url }, + } = input + console.log(input) + if (R.isString(url)) { + const { locations, ...rest } = data + const upserted = await prisma.orgWebsite.upsert({ + where, + create: { + id: where.id, + url, + ...(locations && { locations: { create: locations.upsert.create } }), + ...rest, + }, + update: data, + }) + + return upserted + } else { + const updated = await prisma.orgWebsite.update({ + where, + data, + }) + return updated + } } export default update diff --git a/packages/api/router/orgWebsite/mutation.update.schema.ts b/packages/api/router/orgWebsite/mutation.update.schema.ts index fd8dbee204..0541521064 100644 --- a/packages/api/router/orgWebsite/mutation.update.schema.ts +++ b/packages/api/router/orgWebsite/mutation.update.schema.ts @@ -12,11 +12,33 @@ export const ZUpdateSchema = z isPrimary: z.boolean(), published: z.boolean(), deleted: z.boolean(), - organizationId: prefixedId('organization'), - orgLocationId: prefixedId('orgLocation'), + organizationId: prefixedId('organization').nullish().catch(undefined), + orgLocationId: prefixedId('orgLocation').optional().catch(undefined), orgLocationOnly: z.boolean(), }) .partial(), }) - .transform(({ data, id }) => Prisma.validator()({ where: { id }, data })) + .transform(({ data: { orgLocationId, organizationId, ...data }, id }) => { + return Prisma.validator()({ + where: { id }, + data: { + ...data, + ...(orgLocationId && { + locations: { + upsert: { + where: { + orgLocationId_orgWebsiteId: { + orgLocationId, + orgWebsiteId: id, + }, + }, + create: { orgLocationId }, + update: { orgLocationId }, + }, + }, + }), + ...(organizationId && { organization: { connect: { id: organizationId } } }), + }, + }) + }) export type TUpdateSchema = z.infer diff --git a/packages/api/router/orgWebsite/query.forEditDrawer.handler.ts b/packages/api/router/orgWebsite/query.forEditDrawer.handler.ts index 81167453e5..24d17bddec 100644 --- a/packages/api/router/orgWebsite/query.forEditDrawer.handler.ts +++ b/packages/api/router/orgWebsite/query.forEditDrawer.handler.ts @@ -1,25 +1,22 @@ import { prisma } from '@weareinreach/db' -import { handleError } from '~api/lib/errorHandler' import { type TRPCHandlerParams } from '~api/types/handler' import { type TForEditDrawerSchema } from './query.forEditDrawer.schema' export const forEditDrawer = async ({ input }: TRPCHandlerParams) => { - try { - const result = await prisma.orgWebsite.findUnique({ - where: input, - include: { - description: { include: { tsKey: true } }, - }, - }) - if (!result) return null - const reformatted = { - ...result, - description: result.description?.tsKey?.text, - } - return reformatted - } catch (error) { - handleError(error) + const result = await prisma.orgWebsite.findUnique({ + where: input, + include: { + description: { include: { tsKey: true } }, + }, + }) + if (!result) { + return null } + const reformatted = { + ...result, + description: result.description?.tsKey?.text, + } + return reformatted } export default forEditDrawer diff --git a/packages/api/router/organization/query.getIdFromSlug.handler.ts b/packages/api/router/organization/query.getIdFromSlug.handler.ts index 8e010fdd29..dbebc77f10 100644 --- a/packages/api/router/organization/query.getIdFromSlug.handler.ts +++ b/packages/api/router/organization/query.getIdFromSlug.handler.ts @@ -9,7 +9,9 @@ import { type TGetIdFromSlugSchema } from './query.getIdFromSlug.schema' export const getIdFromSlug = async ({ ctx, input }: TRPCHandlerParams) => { const { slug } = input const cachedId = await readSlugCache(slug) - if (cachedId) return { id: cachedId } + if (cachedId) { + return { id: cachedId } + } const canSeeUnpublished = ctx.session !== null && checkPermissions({ diff --git a/packages/ui/components/data-display/ContactInfo/Websites.tsx b/packages/ui/components/data-display/ContactInfo/Websites.tsx index d4ef2a90cf..48369ae0c0 100644 --- a/packages/ui/components/data-display/ContactInfo/Websites.tsx +++ b/packages/ui/components/data-display/ContactInfo/Websites.tsx @@ -1,6 +1,6 @@ import { Group, Menu, Stack, Text, Title, useMantineTheme } from '@mantine/core' import { useTranslation } from 'next-i18next' -import { type ReactElement } from 'react' +import { type ReactElement, useCallback } from 'react' import { isIdFor } from '@weareinreach/db/lib/idGen' import { isExternal, Link } from '~ui/components/core/Link' @@ -26,20 +26,28 @@ const WebsitesDisplay = ({ parentId = '', passedData, direct, locationOnly, webs { parentId, locationOnly }, { enabled: !passedData } ) - // eslint-disable-next-line no-useless-escape - const domainExtract = /https?:\/\/([^:\/\n?]+)/ - const componentData = passedData ? passedData : data + const domainExtract = /https?:\/\/([^:/\n?]+)/ - if (!componentData?.length) return null + const componentData = passedData ?? data + + if (!componentData?.length) { + return null + } for (const website of componentData) { const { id, url, orgLocationOnly, description, isPrimary } = website const urlMatch = url.match(domainExtract) const urlBase = urlMatch?.length ? urlMatch[1] : undefined - if (!isExternal(url)) continue - if (!urlBase) continue - if (locationOnly && !orgLocationOnly) continue + if (!isExternal(url)) { + continue + } + if (!urlBase) { + continue + } + if (locationOnly && !orgLocationOnly) { + continue + } if (direct) { return ( @@ -52,11 +60,10 @@ const WebsitesDisplay = ({ parentId = '', passedData, direct, locationOnly, webs ) } - const desc = websiteDesc - ? description?.key + const desc = + websiteDesc && description ? t(description.key, { ns: orgId?.id, defaultText: description.defaultText }) : urlBase - : urlBase const item = ( {desc} @@ -65,7 +72,9 @@ const WebsitesDisplay = ({ parentId = '', passedData, direct, locationOnly, webs isPrimary ? output.unshift(item) : output.push(item) } - if (!output.length) return null + if (!output.length) { + return null + } return ( @@ -96,15 +105,19 @@ const WebsitesEdit = ({ parentId = '' }: WebsitesProps) => { const linkToLocation = api.orgWebsite.locationLink.useMutation({ onSuccess: () => apiUtils.orgWebsite.invalidate(), }) - // eslint-disable-next-line no-useless-escape - const domainExtract = /https?:\/\/([^:\/\n?]+)/ + + const domainExtract = /https?:\/\/([^:/\n?]+)/ const output = data?.map((website) => { const { id, url, description, published, deleted } = website const urlMatch = url.match(domainExtract) const urlBase = urlMatch?.length ? urlMatch[1] : undefined - if (!isExternal(url)) return null - if (!urlBase) return null + if (!isExternal(url)) { + return null + } + if (!urlBase) { + return null + } const desc = description?.key ? t(description.key, { ns: orgId?.id, defaultText: description.defaultText }) : urlBase @@ -135,6 +148,12 @@ const WebsitesEdit = ({ parentId = '' }: WebsitesProps) => { ) return item }) + const handleLinkLocation = useCallback( + (orgWebsiteId: string) => () => { + linkToLocation.mutate({ orgWebsiteId, orgLocationId: parentId, action: 'link' }) + }, + [linkToLocation, parentId] + ) const addOrLink = isLocation ? ( @@ -161,12 +180,7 @@ const WebsitesEdit = ({ parentId = '' }: WebsitesProps) => { ? variants.Text.utility4darkGrayStrikethru : variants.Text.utility4 return ( - - linkToLocation.mutate({ orgLocationId: parentId, orgWebsiteId: id, action: 'link' }) - } - > + diff --git a/packages/ui/components/data-display/ContactInfo/index.tsx b/packages/ui/components/data-display/ContactInfo/index.tsx index 0c809072bc..fdde58dd30 100644 --- a/packages/ui/components/data-display/ContactInfo/index.tsx +++ b/packages/ui/components/data-display/ContactInfo/index.tsx @@ -48,7 +48,9 @@ export const ContactInfo = ({ } export const hasContactInfo = (data: PassedDataObject | null | undefined): data is PassedDataObject => { - if (!data) return false + if (!data) { + return false + } const { websites, phones, emails, socialMedia } = data return Boolean(websites.length || phones.length || emails.length || socialMedia.length) } diff --git a/packages/ui/components/data-display/Hours.tsx b/packages/ui/components/data-display/Hours.tsx index 2e36819f70..f8c87518ee 100644 --- a/packages/ui/components/data-display/Hours.tsx +++ b/packages/ui/components/data-display/Hours.tsx @@ -97,7 +97,9 @@ export const Hours = ({ parentId, label = 'regular', edit, data: passedData }: H Add opening hours ) : ( - {hourTable} + + {hourTable} +
) return ( @@ -108,11 +110,9 @@ export const Hours = ({ parentId, label = 'regular', edit, data: passedData }: H {timezone} {edit ? ( - - - {body} - -
+ + {body} + ) : ( {hourTable} diff --git a/packages/ui/components/data-portal/AddressDrawer.tsx b/packages/ui/components/data-portal/AddressDrawer.tsx index fda7aef0e5..71af738a17 100644 --- a/packages/ui/components/data-portal/AddressDrawer.tsx +++ b/packages/ui/components/data-portal/AddressDrawer.tsx @@ -21,7 +21,7 @@ import { useDebouncedValue, useDisclosure } from '@mantine/hooks' import compact from 'just-compact' import filterObject from 'just-filter-object' import { useTranslation } from 'next-i18next' -import { createContext, forwardRef, useContext, useEffect, useState } from 'react' +import { createContext, forwardRef, useCallback, useContext, useEffect, useState } from 'react' import reactStringReplace from 'react-string-replace' import { z } from 'zod' @@ -250,13 +250,13 @@ const _AddressDrawer = forwardRef(({ loca setTimeout(() => handler.close(), 500) }, }) - function handleUpdate() { + const handleUpdate = useCallback(() => { const changesOnly = filterObject(form.values.data, (key) => form.isDirty(`data.${key}`)) updateLocation.mutate( FormSchema.transform(schemaTransform).parse({ id: form.values.id, data: changesOnly }) ) - } + }, [form, updateLocation]) useEffect(() => { if (isSaved && isSaved === form.isDirty()) { @@ -329,17 +329,23 @@ const _AddressDrawer = forwardRef(({ loca // #region Dropdown item components/handling - function handleAutocompleteSelection(item: AutocompleteItem) { - if (!item.placeId) { - return - } - setGooglePlaceId(item.placeId) - } + const handleAutocompleteSelection = useCallback( + (item: AutocompleteItem) => { + if (!item.placeId) { + return + } + setGooglePlaceId(item.placeId) + }, + [setGooglePlaceId] + ) - function handleAutocompleteChange(val: string) { - setSearchTerm(val) - form.getInputProps('data.street1').onChange(val) - } + const handleAutocompleteChange = useCallback( + (val: string) => { + setSearchTerm(val) + form.getInputProps('data.street1').onChange(val) + }, + [setSearchTerm, form] + ) // #endregion diff --git a/packages/ui/components/data-portal/WebsiteDrawer/index.tsx b/packages/ui/components/data-portal/WebsiteDrawer/index.tsx index 07b07bb273..4422496a14 100644 --- a/packages/ui/components/data-portal/WebsiteDrawer/index.tsx +++ b/packages/ui/components/data-portal/WebsiteDrawer/index.tsx @@ -14,7 +14,7 @@ import { } from '@mantine/core' import { useDisclosure } from '@mantine/hooks' import { useRouter } from 'next/router' -import { forwardRef, useEffect, useState } from 'react' +import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react' import { useForm } from 'react-hook-form' import { Checkbox, TextInput } from 'react-hook-form-mantine' import { z } from 'zod' @@ -22,10 +22,12 @@ import { z } from 'zod' import { generateId } from '@weareinreach/db/lib/idGen' import { Breadcrumb } from '~ui/components/core/Breadcrumb' import { Button } from '~ui/components/core/Button' +import { useNewNotification } from '~ui/hooks/useNewNotification' +import { useOrgInfo } from '~ui/hooks/useOrgInfo' import { Icon } from '~ui/icon' import { trpc as api } from '~ui/lib/trpcClient' -const useStyles = createStyles((theme) => ({ +const useStyles = createStyles(() => ({ drawerContent: { borderRadius: `${rem(32)} 0 0 0`, minWidth: '40vw', @@ -33,21 +35,35 @@ const useStyles = createStyles((theme) => ({ })) const FormSchema = z.object({ - url: z.string().url(), - description: z.string().optional(), - published: z.boolean(), - deleted: z.boolean(), - linkLocationId: z.string().nullish(), + url: z.string().url('Invalid URL. Must start with either "https://" or "http://"'), + description: z.string().nullish(), + published: z.boolean().default(true), + deleted: z.boolean().default(false), + orgLocationId: z.string().nullish(), + organizationId: z.string().nullish(), }) type FormSchema = z.infer const _WebsiteDrawer = forwardRef( ({ id, createNew, ...props }, ref) => { const router = useRouter<'/org/[slug]/edit' | '/org/[slug]/[orgLocationId]/edit'>() + const hasLocationId = typeof router.query.orgLocationId === 'string' ? router.query.orgLocationId : null + const { id: organizationId } = useOrgInfo() + const websiteId = useMemo(() => { + if (createNew || !id) { + return generateId('orgWebsite') + } + return id + }, [createNew, id]) - const [websiteId] = useState(createNew ? generateId('orgWebsite') : id) - const { data, isFetching } = api.orgWebsite.forEditDrawer.useQuery( + const { data: websiteData, isFetching } = api.orgWebsite.forEditDrawer.useQuery( { id: websiteId }, - { enabled: !createNew } + { + enabled: !createNew, + // select: (returnedData) => ({ + // ...returnedData, + // ...(createNew && hasLocationId && { orgLocationId: hasLocationId }), + // }), + } ) const [drawerOpened, drawerHandler] = useDisclosure(false) const [modalOpened, modalHandler] = useDisclosure(false) @@ -61,22 +77,38 @@ const _WebsiteDrawer = forwardRef( setValue: setFormValue, } = useForm({ resolver: zodResolver(FormSchema), - values: data ? data : undefined, + values: websiteData + ? { + ...websiteData, + orgLocationId: hasLocationId, + organizationId: websiteData.organizationId ?? organizationId, + } + : undefined, + defaultValues: { + orgLocationId: hasLocationId ?? '', + organizationId: organizationId ?? '', + url: '', + published: true, + deleted: false, + }, }) const apiUtils = api.useUtils() + const notifySave = useNewNotification({ displayText: 'Saved', icon: 'success' }) + const { isDirty: formIsDirty } = formState const [isSaved, setIsSaved] = useState(formIsDirty) - const hasLocationId = typeof router.query.orgLocationId === 'string' ? router.query.orgLocationId : null const siteUpdate = api.orgWebsite.update.useMutation({ onSettled: (data) => { - apiUtils.orgWebsite.forEditDrawer.invalidate() - apiUtils.orgWebsite.forContactInfoEdit.invalidate() + apiUtils.orgWebsite.invalidate() reset(data) }, onSuccess: () => { setIsSaved(true) + notifySave() + modalHandler.close() + setTimeout(() => drawerHandler.close(), 500) }, }) @@ -84,25 +116,51 @@ const _WebsiteDrawer = forwardRef( onSuccess: () => apiUtils.orgWebsite.invalidate(), }) useEffect(() => { - if (createNew) { + console.log('useEffect', { createNew, hasLocationId, organizationId }) + + if (createNew && organizationId) { setFormValue('published', true) + setFormValue('organizationId', organizationId) if (hasLocationId !== null) { - setFormValue('linkLocationId', hasLocationId) + setFormValue('orgLocationId', hasLocationId) } } - }, [createNew, hasLocationId, setFormValue]) + }, [createNew, hasLocationId, setFormValue, organizationId]) useEffect(() => { if (isSaved && formIsDirty) { setIsSaved(false) } }, [formIsDirty, isSaved]) - const handleClose = () => { + const handleClose = useCallback(() => { if (formState.isDirty) { return modalHandler.open() } else { return drawerHandler.close() } - } + }, [formState.isDirty, drawerHandler, modalHandler]) + + const handleUnlink = useCallback( + () => + hasLocationId && + unlinkFromLocation.mutate({ + orgWebsiteId: websiteId, + orgLocationId: hasLocationId, + action: 'unlink', + }), + [unlinkFromLocation, websiteId, hasLocationId] + ) + + const handleSaveFromModal = useCallback(() => { + const valuesToSubmit = getValues() + siteUpdate.mutate({ id: websiteId, data: valuesToSubmit }) + }, [getValues, siteUpdate, websiteId]) + + const handleCloseAndDiscard = useCallback(() => { + reset() + modalHandler.close() + drawerHandler.close() + }, [reset, modalHandler, drawerHandler]) + return ( <> @@ -135,8 +193,8 @@ const _WebsiteDrawer = forwardRef( {`${createNew ? 'Add New' : 'Edit'} Website`} - - + + {/* */} @@ -145,13 +203,7 @@ const _WebsiteDrawer = forwardRef( {hasLocationId !== null && ( - diff --git a/packages/ui/components/sections/ListingBasicInfo.tsx b/packages/ui/components/sections/ListingBasicInfo.tsx index 796cca114f..c51b34917c 100644 --- a/packages/ui/components/sections/ListingBasicInfo.tsx +++ b/packages/ui/components/sections/ListingBasicInfo.tsx @@ -54,11 +54,12 @@ export const ListingBasicDisplay = memo(({ data }: ListingBasicInfoProps) => { ) ) } - if (data.lastVerified) + if (data.lastVerified) { output.push( ) - output.push() + } + output.push() return output } @@ -69,7 +70,7 @@ export const ListingBasicDisplay = memo(({ data }: ListingBasicInfoProps) => { )) const descriptionSection = - description && description.key ? ( + description !== null ? ( {t(description.key, { ns: id, defaultValue: description.tsKey.text })} @@ -90,7 +91,7 @@ ListingBasicDisplay.displayName = 'ListingBasicDisplay' export const ListingBasicEdit = ({ data, location }: ListingBasicInfoProps) => { const { id: orgId } = useOrgInfo() - const { t, ready: i18nReady } = useTranslation(orgId) + const { t } = useTranslation(orgId) const form = useFormContext() const { attributes, isClaimed } = data const theme = useMantineTheme() @@ -105,14 +106,14 @@ export const ListingBasicEdit = ({ data, location }: ListingBasicInfoProps) => { const leaderBadges = (): ReactNode[] => { if (leaderAttributes.length) { - return leaderAttributes.map(({ attribute }) => ( - + return leaderAttributes.map(({ attribute, id }) => ( + {t(attribute.tsKey)} )) } else { return [ - + Add leader badge , ] @@ -121,11 +122,12 @@ export const ListingBasicEdit = ({ data, location }: ListingBasicInfoProps) => { const infoBadges = () => { const output: ReactNode[] = [] - if (data.lastVerified) + if (data.lastVerified) { output.push( ) - output.push() + } + output.push() return output } const focusedCommBadges: ReactNode[] = focusedCommunities.length @@ -134,7 +136,11 @@ export const ListingBasicEdit = ({ data, location }: ListingBasicInfoProps) => { {t(attribute.tsKey, { ns: attribute.tsNs })} )) - : [Add Focused Community badge(s)] + : [ + + Add Focused Community badge(s) + , + ] return (
diff --git a/packages/ui/hooks/useOrgInfo.ts b/packages/ui/hooks/useOrgInfo.ts index 3a341b936d..01be368ddc 100644 --- a/packages/ui/hooks/useOrgInfo.ts +++ b/packages/ui/hooks/useOrgInfo.ts @@ -24,7 +24,9 @@ export const useOrgInfo = () => { }, [pageSlug, slug]) useEffect(() => { - if (data && !isLoading && data.id !== orgId) setOrgId(data.id) + if (data && !isLoading && data.id !== orgId) { + setOrgId(data.id) + } }, [data, isLoading, orgId]) return { id: orgId, slug } diff --git a/packages/ui/mockData/json/orgWebsite.forEditDrawer.json b/packages/ui/mockData/json/orgWebsite.forEditDrawer.json index 00f793775a..32cea3ad0a 100644 --- a/packages/ui/mockData/json/orgWebsite.forEditDrawer.json +++ b/packages/ui/mockData/json/orgWebsite.forEditDrawer.json @@ -1 +1 @@ -{"id":"oweb_01H29ENF953Z14FKACFT01SXDF","url":"https://www.reclaim.care/","descriptionId":null,"isPrimary":false,"deleted":false,"published":true,"organizationId":"orgn_01H29CX1KXN804H47QZAX7TQAZ","orgLocationId":null,"orgLocationOnly":false,"createdAt":"2023-03-26T20:36:25.142Z","updatedAt":"2023-05-13T00:39:43.348Z"} \ No newline at end of file +{"id":"oweb_01H29ENF953Z14FKACFT01SXDF","url":"https://www.reclaim.care/","descriptionId":null,"isPrimary":false,"deleted":false,"published":true,"organizationId":"orgn_01H29CX1KXN804H47QZAX7TQAZ","orgLocationOnly":false,"createdAt":"2023-03-26T20:36:25.142Z","updatedAt":"2023-05-13T00:39:43.348Z"} \ No newline at end of file diff --git a/packages/ui/mockData/orgWebsite.ts b/packages/ui/mockData/orgWebsite.ts index 763f2ae26b..c45963584d 100644 --- a/packages/ui/mockData/orgWebsite.ts +++ b/packages/ui/mockData/orgWebsite.ts @@ -27,9 +27,9 @@ export const orgWebsite = { update: getTRPCMock({ path: ['orgWebsite', 'update'], type: 'mutation', - response: async (input) => { + response: async ({ data: { orgLocationId: _locId, organizationId: _orgId, ...inputData } }) => { const { default: data } = await import('./json/orgWebsite.forEditDrawer.json') - return { ...data, ...input.data, createdAt: new Date(data.createdAt), updatedAt: new Date(Date.now()) } + return { ...data, ...inputData, createdAt: new Date(data.createdAt), updatedAt: new Date(Date.now()) } }, }), forContactInfoEdit: getTRPCMock({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index beb098446b..77471072c9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -749,6 +749,9 @@ importers: nanoid: specifier: 5.0.7 version: 5.0.7 + remeda: + specifier: 1.58.0 + version: 1.58.0 slugify: specifier: 1.6.6 version: 1.6.6 @@ -23955,6 +23958,10 @@ packages: resolution: {integrity: sha512-CHrQFjgGw7y7d5WDDG21nzES2Z3Ae+s1ZjVAYjLOsoCceM1EMzVrSd4dx+Eb3QooR16tbbHeJhzk0q8qsaquTg==} dev: true + /remeda@1.58.0: + resolution: {integrity: sha512-YZT2U7B6fpZfOYVsT4bJT9SKXhh+jdzMmtoMX2u4+xro/bIXXaloDslnpAOHC4UHGsYegNMbi6hlXrdIzH45kA==} + dev: false + /remove-accents@0.4.2: resolution: {integrity: sha512-7pXIJqJOq5tFgG1A2Zxti3Ht8jJF337m4sowbuHsW30ZnkQFnDzy9qBNhgzX8ZLW4+UBcXiiR7SwR6pokHsxiA==}