From 92ca81a2ce29ef9e81a2936017c32a14a3a93024 Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Tue, 13 Feb 2024 12:34:05 -0500 Subject: [PATCH 01/61] fix ts errors --- apps/app/next-i18next.config.mjs | 2 -- packages/auth/package.json | 3 +++ 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/app/next-i18next.config.mjs b/apps/app/next-i18next.config.mjs index 34ee72bb18..0dba342de3 100644 --- a/apps/app/next-i18next.config.mjs +++ b/apps/app/next-i18next.config.mjs @@ -51,12 +51,10 @@ const plugins = () => { } if (process.env.NODE_ENV === 'development') { if (isBrowser) { - // @ts-expect-error - yelling about declaration file import('i18next-hmr/plugin').then(({ HMRPlugin }) => pluginsToUse.push(new HMRPlugin({ webpack: { client: true } })) ) } else { - // @ts-expect-error - yelling about declaration file import('i18next-hmr/plugin').then(({ HMRPlugin }) => pluginsToUse.push(new HMRPlugin({ webpack: { server: true } })) ) diff --git a/packages/auth/package.json b/packages/auth/package.json index e15f756e48..29529d5c0a 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -34,6 +34,9 @@ "./resetPassword": { "default": "./lib/resetPassword.ts" }, + "./server": { + "default": "./server.ts" + }, "./userLogin": { "default": "./lib/userLogin.ts" }, From 98ef10474666242e97992931a332fb42630bd444 Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Tue, 13 Feb 2024 12:45:50 -0500 Subject: [PATCH 02/61] go to service edit page in edit mode --- .../ui/components/sections/ServicesInfo.tsx | 74 +++++++++++++------ 1 file changed, 51 insertions(+), 23 deletions(-) diff --git a/packages/ui/components/sections/ServicesInfo.tsx b/packages/ui/components/sections/ServicesInfo.tsx index 33fccb6741..8032f16e54 100644 --- a/packages/ui/components/sections/ServicesInfo.tsx +++ b/packages/ui/components/sections/ServicesInfo.tsx @@ -3,8 +3,11 @@ import { useRouter } from 'next/router' import { useTranslation } from 'next-i18next' import { transformer } from '@weareinreach/util/transformer' +import { Link } from '~ui/components/core' import { Badge, BadgeGroup } from '~ui/components/core/Badge' -import { useCustomVariant, useScreenSize } from '~ui/hooks' +import { useCustomVariant } from '~ui/hooks/useCustomVariant' +import { useEditMode } from '~ui/hooks/useEditMode' +import { useScreenSize } from '~ui/hooks/useScreenSize' import { Icon } from '~ui/icon' import { trpc as api } from '~ui/lib/trpcClient' import { ServiceModal } from '~ui/modals/Service' @@ -33,6 +36,7 @@ const useServiceSectionStyles = createStyles((theme) => ({ const ServiceSection = ({ category, services, hideRemoteBadges }: ServiceSectionProps) => { const router = useRouter<'/org/[slug]' | '/org/[slug]/[orgLocationId]'>() + const { isEditMode } = useEditMode() const { slug } = router.isReady ? router.query : { slug: '' } const { data: orgId } = api.organization.getIdFromSlug.useQuery({ slug }, { enabled: router.isReady }) const { t } = useTranslation(orgId?.id ? ['common', 'services', orgId.id] : ['common', 'services']) @@ -48,32 +52,56 @@ const ServiceSection = ({ category, services, hideRemoteBadges }: ServiceSection )} - {services.map((service) => ( - apiUtils.service.forServiceModal.prefetch(service.id)} - > - {service.offersRemote && !hideRemoteBadges ? ( - + {services.map((service) => { + const children = ( + <> + {' '} + {service.offersRemote && !hideRemoteBadges ? ( + + + {t(service.tsKey ?? '', { ns: orgId?.id, defaultValue: service.defaultText }) as string} + + + + ) : ( {t(service.tsKey ?? '', { ns: orgId?.id, defaultValue: service.defaultText }) as string} - + )} + + + ) + + return isEditMode ? ( + + + {children} - ) : ( - - {t(service.tsKey ?? '', { ns: orgId?.id, defaultValue: service.defaultText }) as string} - - )} - - - - ))} + + ) : ( + apiUtils.service.forServiceModal.prefetch(service.id)} + > + {children} + + ) + })} ) From 26537b4b00308f6e6925a96612cfb95571d9a082 Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Tue, 13 Feb 2024 12:51:41 -0500 Subject: [PATCH 03/61] group with other basic components --- packages/ui/components/core/Donate/index.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/components/core/Donate/index.stories.tsx b/packages/ui/components/core/Donate/index.stories.tsx index 2710a1d5b9..329a8e69d3 100644 --- a/packages/ui/components/core/Donate/index.stories.tsx +++ b/packages/ui/components/core/Donate/index.stories.tsx @@ -3,7 +3,7 @@ import { type Meta, type StoryObj } from '@storybook/react' import { DonateModal } from './index' export default { - title: 'Components/Core/Donate', + title: 'Design System/Donate', component: DonateModal, parameters: { layoutWrapper: 'centeredFullscreen', From 94b160eed2dea3e932a2f3edf76e92d9aec80c24 Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Tue, 13 Feb 2024 13:26:19 -0500 Subject: [PATCH 04/61] tweaks from convo with Josh --- packages/eslint-config/base.js | 13 ++++++++----- packages/eslint-config/next.js | 2 +- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/eslint-config/base.js b/packages/eslint-config/base.js index ba4e3b2ada..f486af790e 100644 --- a/packages/eslint-config/base.js +++ b/packages/eslint-config/base.js @@ -15,8 +15,8 @@ const config = { // 'plugin:turbo/recommended', 'plugin:@tanstack/eslint-plugin-query/recommended', 'plugin:@typescript-eslint/recommended', + // 'plugin:@typescript-eslint/recommended-type-checked', 'plugin:import/typescript', - 'prettier', ], rules: { '@typescript-eslint/consistent-type-assertions': [ @@ -111,6 +111,9 @@ const config = { 'no-return-await': 'off', '@typescript-eslint/return-await': 'off', 'deprecation/deprecation': 'warn', + // // temp downgrade these + // '@typescript-eslint/no-floating-promises': 'warn', + // '@typescript-eslint/no-misused-promises': 'warn', }, overrides: [ { @@ -121,7 +124,7 @@ const config = { }, { files: ['./**/*.{js,mjs,cjs}'], - parserOptions: { project: null }, + parserOptions: { project: true }, rules: { '@typescript-eslint/require-await': 'off', '@typescript-eslint/return-await': 'off', @@ -133,9 +136,9 @@ const config = { parser: '@typescript-eslint/parser', parserOptions: { EXPERIMENTAL_useProjectService: true, - project: tsconfigGlobs, - emitDecoratorMetadata: true, - ecmaVersion: 2020, + // project: tsconfigGlobs, + // emitDecoratorMetadata: true, + // ecmaVersion: 2020, }, ignorePatterns: ['!.*', 'node_modules', 'dist/', '.next/'], settings: { diff --git a/packages/eslint-config/next.js b/packages/eslint-config/next.js index 8831e543cd..e8082d0db5 100644 --- a/packages/eslint-config/next.js +++ b/packages/eslint-config/next.js @@ -1,6 +1,6 @@ /** @type {import('eslint').ESLint.ConfigData} */ const config = { - extends: ['./base.js', 'next/core-web-vitals'], + extends: ['next/core-web-vitals', './base.js'], rules: { '@next/next/no-html-link-for-pages': 'off', 'no-restricted-imports': [ From f4c311c0a89705f4f570a3a1e50c7b32ffad17be Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Tue, 13 Feb 2024 15:34:27 -0500 Subject: [PATCH 05/61] update service edit drawer to use RHF --- .../query.forServiceEditDrawer.handler.ts | 95 ++++++-------- .../index.stories.tsx} | 4 +- .../index.tsx} | 122 +++++++----------- .../data-portal/ServiceEditDrawer/schemas.ts | 55 ++++++++ .../data-portal/ServiceEditDrawer/styles.ts | 29 +++++ .../json/service.forServiceEditDrawer.json | 2 +- 6 files changed, 175 insertions(+), 132 deletions(-) rename packages/ui/components/data-portal/{ServiceEditDrawer.stories.tsx => ServiceEditDrawer/index.stories.tsx} (89%) rename packages/ui/components/data-portal/{ServiceEditDrawer.tsx => ServiceEditDrawer/index.tsx} (75%) create mode 100644 packages/ui/components/data-portal/ServiceEditDrawer/schemas.ts create mode 100644 packages/ui/components/data-portal/ServiceEditDrawer/styles.ts diff --git a/packages/api/router/service/query.forServiceEditDrawer.handler.ts b/packages/api/router/service/query.forServiceEditDrawer.handler.ts index 3f993f3810..6921fbe8fb 100644 --- a/packages/api/router/service/query.forServiceEditDrawer.handler.ts +++ b/packages/api/router/service/query.forServiceEditDrawer.handler.ts @@ -1,88 +1,71 @@ import { prisma } from '@weareinreach/db' -import { transformer } from '@weareinreach/util/transformer' +import { formatAttributes } from '~api/formatters/attributes' +import { formatHours } from '~api/formatters/hours' import { globalSelect } from '~api/selects/global' import { type TRPCHandlerParams } from '~api/types/handler' import { type TForServiceEditDrawerSchema } from './query.forServiceEditDrawer.schema' +const freeTextSelect = { + select: { tsKey: { select: { key: true, text: true, ns: true, crowdinId: true } } }, +} as const export const forServiceEditDrawer = async ({ input }: TRPCHandlerParams) => { const result = await prisma.orgService.findUniqueOrThrow({ where: { id: input }, select: { id: true, - attributes: { - select: { - attribute: { - select: { - id: true, - tsKey: true, - tsNs: true, - icon: true, - categories: { select: { category: { select: { tag: true } } } }, - }, - }, - - id: true, - active: true, - data: true, - boolean: true, - countryId: true, - govDistId: true, - languageId: true, - text: globalSelect.freeText({ withCrowdinId: true }), - }, - }, - description: globalSelect.freeText({ withCrowdinId: true }), + published: true, + deleted: true, + attributes: formatAttributes.prismaSelect(true), + description: freeTextSelect, phones: { select: { phone: { select: { id: true } } } }, emails: { select: { email: { select: { id: true } } } }, - locations: { select: { location: { select: { id: true } } } }, - hours: { select: { id: true, dayIndex: true, start: true, end: true, closed: true, tz: true } }, - services: { select: { tag: { select: { id: true, primaryCategoryId: true } } } }, + locations: { select: { orgLocationId: true } }, + hours: formatHours.prismaSelect(true), + services: { select: { tag: { select: { id: true, tsKey: true, tsNs: true } } } }, serviceAreas: { select: { id: true, - countries: { select: { country: { select: { id: true } } } }, - districts: { select: { govDist: { select: { id: true } } } }, + countries: { select: { countryId: true } }, + districts: { select: { govDistId: true } }, }, }, - published: true, - deleted: true, - serviceName: globalSelect.freeText({ withCrowdinId: true }), + + serviceName: freeTextSelect, }, }) - const { attributes, phones, emails, locations, services, serviceAreas, ...rest } = result + const { + attributes: rawAttributes, + phones, + emails, + locations, + services, + serviceAreas, + hours, + description, + serviceName, + ...rest + } = result + const { attributes, accessDetails } = formatAttributes.processAndSeparateAccessDetails(rawAttributes) + const transformed = { ...rest, + name: serviceName?.tsKey, + description: description?.tsKey, phones: phones.map(({ phone }) => phone.id), emails: emails.map(({ email }) => email.id), - locations: locations.map(({ location }) => location.id), - services: services.map(({ tag }) => ({ id: tag.id, primaryCategoryId: tag.primaryCategoryId })), + locations: locations.map(({ orgLocationId }) => orgLocationId), + services: services.map(({ tag }) => tag.id), + hours: formatHours.process(hours), serviceAreas: serviceAreas ? { id: serviceAreas.id, - countries: serviceAreas.countries.map(({ country }) => country.id), - districts: serviceAreas.districts.map(({ govDist }) => govDist.id), + countries: serviceAreas.countries.map(({ countryId }) => countryId), + districts: serviceAreas.districts.map(({ govDistId }) => govDistId), } : null, - attributes: attributes - .filter(({ attribute }) => - attribute.categories.every(({ category }) => category.tag !== 'service-access-instructions') - ) - .map(({ attribute, ...supplement }) => { - const { categories, ...attr } = attribute - return { - attribute: { ...attr, categories: categories.map(({ category }) => category.tag) }, - supplement, - } - }), - accessDetails: attributes - .filter(({ attribute }) => - attribute.categories.some(({ category }) => category.tag === 'service-access-instructions') - ) - .map(({ attribute, ...supplement }) => ({ - attribute, - supplement, - })), + attributes, + accessDetails, } return transformed diff --git a/packages/ui/components/data-portal/ServiceEditDrawer.stories.tsx b/packages/ui/components/data-portal/ServiceEditDrawer/index.stories.tsx similarity index 89% rename from packages/ui/components/data-portal/ServiceEditDrawer.stories.tsx rename to packages/ui/components/data-portal/ServiceEditDrawer/index.stories.tsx index 8206fb7722..5bbc2c1fd1 100644 --- a/packages/ui/components/data-portal/ServiceEditDrawer.stories.tsx +++ b/packages/ui/components/data-portal/ServiceEditDrawer/index.stories.tsx @@ -1,11 +1,12 @@ import { type Meta, type StoryObj } from '@storybook/react' import { Button } from '~ui/components/core/Button' +import { component } from '~ui/mockData/component' import { fieldOpt } from '~ui/mockData/fieldOpt' import { organization } from '~ui/mockData/organization' import { service } from '~ui/mockData/service' -import { ServiceEditDrawer } from './ServiceEditDrawer' +import { ServiceEditDrawer } from './index' export default { title: 'Data Portal/Drawers/Service Edit', @@ -29,6 +30,7 @@ export default { service.getOptions, fieldOpt.govDistsByCountry, fieldOpt.countryGovDistMap, + component.ServiceSelect, ], }, args: { diff --git a/packages/ui/components/data-portal/ServiceEditDrawer.tsx b/packages/ui/components/data-portal/ServiceEditDrawer/index.tsx similarity index 75% rename from packages/ui/components/data-portal/ServiceEditDrawer.tsx rename to packages/ui/components/data-portal/ServiceEditDrawer/index.tsx index de69fb8694..573141c890 100644 --- a/packages/ui/components/data-portal/ServiceEditDrawer.tsx +++ b/packages/ui/components/data-portal/ServiceEditDrawer/index.tsx @@ -1,100 +1,65 @@ +import { zodResolver } from '@hookform/resolvers/zod' import { Box, type ButtonProps, createPolymorphicComponent, - createStyles, Drawer, List, Modal, - rem, Stack, Text, - Textarea, Title, } from '@mantine/core' -import { useForm } from '@mantine/form' import { useDisclosure } from '@mantine/hooks' import compact from 'just-compact' import { useTranslation } from 'next-i18next' import { forwardRef, type ReactNode, useEffect, useMemo } from 'react' +import { useForm } from 'react-hook-form' +import { Textarea, TextInput } from 'react-hook-form-mantine' -import { BadgeGroup, type ServiceTagProps } from '~ui/components/core/Badge' +import { Badge, BadgeGroup, type ServiceTagProps } from '~ui/components/core/Badge' import { Breadcrumb } from '~ui/components/core/Breadcrumb' +import { ServiceSelect } from '~ui/components/data-portal/ServiceSelect' import { useCustomVariant } from '~ui/hooks' import { Icon } from '~ui/icon' import { trpc as api } from '~ui/lib/trpcClient' import { DataViewer } from '~ui/other/DataViewer' -import { InlineTextInput } from './InlineTextInput' +import { FormSchema, type TFormSchema } from './schemas' +import { useStyles } from './styles' +import { InlineTextInput } from '../InlineTextInput' + +const isObject = (x: unknown): x is object => typeof x === 'object' -const useStyles = createStyles((theme) => ({ - drawerContent: { - borderRadius: `${rem(32)} 0 0 0`, - minWidth: '40vw', - }, - drawerBody: { - padding: `${rem(40)} ${rem(32)}`, - '&:not(:only-child)': { - paddingTop: rem(40), - }, - }, - badgeGroup: { - width: '100%', - cursor: 'pointer', - backgroundColor: theme.fn.lighten(theme.other.colors.secondary.teal, 0.9), - borderRadius: rem(8), - padding: rem(4), - }, - tealText: { - color: theme.other.colors.secondary.teal, - }, - dottedCard: { - border: `${rem(1)} dashed ${theme.other.colors.secondary.teal}`, - borderRadius: rem(16), - padding: rem(20), - }, -})) const _ServiceEditDrawer = forwardRef( ({ serviceId, ...props }, ref) => { const [drawerOpened, drawerHandler] = useDisclosure(true) const [serviceModalOpened, serviceModalHandler] = useDisclosure(false) const { classes } = useStyles() - const form = useForm() const variants = useCustomVariant() const { t } = useTranslation(['country', 'gov-dist']) // #region Get existing data/populate form const { data, isLoading } = api.service.forServiceEditDrawer.useQuery(serviceId, { refetchOnWindowFocus: false, }) - - useEffect(() => { - if (data && !isLoading) { - form.setValues(data) - form.resetDirty() - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [data, isLoading]) + const form = useForm({ + resolver: zodResolver(FormSchema), + values: data, + }) + const dirtyFields = { + name: isObject(form.formState.dirtyFields.name) ? form.formState.dirtyFields.name.text : false, + description: isObject(form.formState.dirtyFields.description) + ? form.formState.dirtyFields.description.text + : false, + services: form.formState.dirtyFields.services ?? false, + } // #endregion // #region Get all available service options & filter selected const { data: allServices } = api.service.getOptions.useQuery(undefined, { refetchOnWindowFocus: false }) - const serviceBadges: ServiceTagProps[] = useMemo(() => { - if (!form.values.services?.length || !allServices) return [] - - return compact( - form.values.services.map(({ id }) => { - const service = allServices.find((item) => item.id === id) - if (service) { - return { - variant: 'service', - tsKey: service.tsKey, - } - } - }) - ) - }, [form.values.services, allServices]) + const activeServices = form.watch('services') ?? [] // #endregion @@ -104,7 +69,7 @@ const _ServiceEditDrawer = forwardRef }) const serviceAreas = () => { const serviceAreaObj: Record = {} - const { countries, districts } = form.values.serviceAreas ?? {} + const { countries, districts } = form.watch('serviceAreas') ?? {} if (!geoMap) return null const countryIdRegex = /^ctry_.*/ const distIdRegex = /^gdst_.*/ @@ -174,7 +139,7 @@ const _ServiceEditDrawer = forwardRef continue } } - return Object.entries(serviceAreaObj).map(([key, value]) => { + return Object.entries(serviceAreaObj)?.map(([key, value]) => { const country = geoMap.get(key) if (!country) return null return ( @@ -200,25 +165,34 @@ const _ServiceEditDrawer = forwardRef - } + name='name.text' + control={form.control} + fontSize='h2' + data-isDirty={dirtyFields.name} + /> + } + name='description.text' + control={form.control} + data-isDirty={dirtyFields.description} autosize - {...form.getInputProps('description.tsKey.text')} /> - {Boolean(serviceBadges.length) && ( - <> - - - Tag edit screen - - - )} + + + {activeServices.map((serviceId) => { + const service = allServices?.find((s) => s.id === serviceId) + if (!service) return null + return ( + + {t(service.tsKey, { ns: service.tsNs })} + + ) + })} + + {/* */} diff --git a/packages/ui/components/data-portal/ServiceEditDrawer/schemas.ts b/packages/ui/components/data-portal/ServiceEditDrawer/schemas.ts new file mode 100644 index 0000000000..2e318f6edc --- /dev/null +++ b/packages/ui/components/data-portal/ServiceEditDrawer/schemas.ts @@ -0,0 +1,55 @@ +import { z } from 'zod' + +import { prefixedId } from '@weareinreach/api/schemas/idPrefix' + +const FreetextObject = z + .object({ + text: z.string().nullable(), + key: z.string().nullish(), + ns: z.string().nullish(), + crowdinId: z.number().nullish(), + }) + .nullish() + +const literalSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]) +type Literal = z.infer<typeof literalSchema> +type Json = Literal | { [key: string]: Json } | Json[] +const JsonSchema: z.ZodType<Json> = z.lazy(() => + z.union([literalSchema, z.array(JsonSchema), z.record(JsonSchema)]) +) + +export const FormSchema = z.object({ + name: FreetextObject, + description: FreetextObject, + services: prefixedId('serviceTag').array(), + attributes: z + .object({ + text: z + .object({ + key: z.string(), + text: z.string(), + ns: z.string(), + }) + .nullable(), + boolean: z.boolean().nullable(), + data: z.any(), + active: z.boolean(), + countryId: z.string().nullable(), + govDistId: z.string().nullable(), + languageId: z.string().nullable(), + category: z.string(), + attributeId: z.string(), + supplementId: z.string(), + }) + .array(), + serviceAreas: z + .object({ + id: prefixedId('serviceArea'), + countries: prefixedId('country').array(), + districts: prefixedId('govDist').array(), + }) + .nullable(), + published: z.boolean(), + deleted: z.boolean(), +}) +export type TFormSchema = z.infer<typeof FormSchema> diff --git a/packages/ui/components/data-portal/ServiceEditDrawer/styles.ts b/packages/ui/components/data-portal/ServiceEditDrawer/styles.ts new file mode 100644 index 0000000000..ebc45d47ca --- /dev/null +++ b/packages/ui/components/data-portal/ServiceEditDrawer/styles.ts @@ -0,0 +1,29 @@ +import { createStyles, rem } from '@mantine/core' + +export const useStyles = createStyles((theme) => ({ + drawerContent: { + borderRadius: `${rem(32)} 0 0 0`, + minWidth: '40vw', + }, + drawerBody: { + padding: `${rem(40)} ${rem(32)}`, + '&:not(:only-child)': { + paddingTop: rem(40), + }, + }, + badgeGroup: { + width: '100%', + cursor: 'pointer', + backgroundColor: theme.fn.lighten(theme.other.colors.secondary.teal, 0.9), + borderRadius: rem(8), + padding: rem(4), + }, + tealText: { + color: theme.other.colors.secondary.teal, + }, + dottedCard: { + border: `${rem(1)} dashed ${theme.other.colors.secondary.teal}`, + borderRadius: rem(16), + padding: rem(20), + }, +})) diff --git a/packages/ui/mockData/json/service.forServiceEditDrawer.json b/packages/ui/mockData/json/service.forServiceEditDrawer.json index dfda96b34a..bb71586888 100644 --- a/packages/ui/mockData/json/service.forServiceEditDrawer.json +++ b/packages/ui/mockData/json/service.forServiceEditDrawer.json @@ -1 +1 @@ -{"id":"osvc_01GVH3VEVPF1KEKBTRVTV70WGV","description":{"key":"orgn_01GVH3V408N0YS7CDYAH3F2BMH.osvc_01GVH3VEVPF1KEKBTRVTV70WGV.description","ns":"org-data","tsKey":{"text":"Whitman-Walker provides walk-in HIV testing at multiple locations in DC. Walk-in HIV testing includes a confidential, rapid HIV test and risk-reduction counseling. The counseling provides clients with education on their options for having safer sex. Whitman-Walker uses the INSTI® HIV-1/HIV-2 Rapid Antibody Test and results take one minute.","crowdinId":773222}},"hours":[],"published":true,"deleted":false,"serviceName":{"key":"orgn_01GVH3V408N0YS7CDYAH3F2BMH.osvc_01GVH3VEVPF1KEKBTRVTV70WGV.name","ns":"org-data","tsKey":{"text":"Get rapid HIV testing","crowdinId":773224}},"phones":["ophn_01GVH3VEVC36PW0Z9GDV0ZERV1","ophn_01GVH3VEVCFKT3NWQ79STYVDKR"],"emails":[],"locations":["oloc_01GVH3VEVBRCFA2AHNTWCXQA2B","oloc_01GVH3VEVBSA85T6VR2C38BJPT"],"services":[{"id":"svtg_01GW2HHFBRPBXSYN12DWNEAJJ7","primaryCategoryId":"svct_01GW2HHEVKVHTWSBY7PVWC5390"}],"serviceAreas":{"id":"svar_01GW2HT9F1JKT1MCAJ3P7XBDHP","countries":[],"districts":["gdst_01GW2HJ5A278S2G84AB3N9FCW0"]},"attributes":[{"attribute":{"id":"attr_01GW2HHFVA06WHRSM241ZF0FY0","tsKey":"community.hiv-aids","tsNs":"attribute","icon":null,"categories":["community"]},"supplement":{"id":"atts_01E4ENGMG266R5BH78D7B2MB7M","active":true,"data":null,"boolean":null,"countryId":null,"govDistId":null,"languageId":null,"text":null}},{"attribute":{"id":"attr_01GW2HHFVGDTNW9PDQNXK6TF1T","tsKey":"cost.cost-free","tsNs":"attribute","icon":"carbon:piggy-bank","categories":["cost"]},"supplement":{"id":"atts_01E4ENGMG2XWR5JQ1JMBN2SQVM","active":true,"data":null,"boolean":null,"countryId":null,"govDistId":null,"languageId":null,"text":null}},{"attribute":{"id":"attr_01GW2HHFV3BADK80TG0DXXFPMM","tsKey":"additional.has-confidentiality-policy","tsNs":"attribute","icon":null,"categories":["additional-information"]},"supplement":{"id":"atts_01E4ENGMG2J94M4S9DQTE57GWN","active":true,"data":null,"boolean":null,"countryId":null,"govDistId":null,"languageId":null,"text":null}},{"attribute":{"id":"attr_01GW2HHFV4TM7H5V6FHWA7S9JK","tsKey":"additional.time-walk-in","tsNs":"attribute","icon":null,"categories":["additional-information"]},"supplement":{"id":"atts_01E4ENGMG20KXGB20JYGZ4X938","active":true,"data":null,"boolean":null,"countryId":null,"govDistId":null,"languageId":null,"text":null}},{"attribute":{"id":"attr_01GW2HHFVJ8K180CNX339BTXM2","tsKey":"lang.lang-offered","tsNs":"attribute","icon":null,"categories":["languages"]},"supplement":{"id":"atts_01GW2HT9F15B2HJK144B3NZHQK","active":true,"data":null,"boolean":null,"countryId":null,"govDistId":null,"languageId":"lang_0000000000N3K70GZXE29Z03A4","text":null}},{"attribute":{"id":"attr_01GW2HHFVK8KPRGKYFSSM5ECPQ","tsKey":"sys.incompatible-info","tsNs":"attribute","icon":null,"categories":["system"]},"supplement":{"id":"atts_01GW2HT9F13VVJCJ8W2WE86R6N","active":true,"data":{"json":[{"community-lgbt":"true"},{"lang-all-languages-by-interpreter":"Language access services are available, including ASL interpreting."}]},"boolean":null,"countryId":null,"govDistId":null,"languageId":null,"text":null}}],"accessDetails":[{"attribute":{"id":"attr_01GW2HHFVMYXMS8ARA3GE7HZFD","tsKey":"serviceaccess.accesslink","tsNs":"attribute","icon":null,"categories":[{"category":{"tag":"service-access-instructions"}}]},"supplement":{"id":"atts_01GW2HT9F01W2M7FBSKSXAQ9R4","active":true,"data":{"json":{"json":{"_id":{"$oid":"5e7e4bdbd54f1760921a4234"},"access_type":"link","access_value":"https://www.whitman-walker.org/hiv-sti-testing","instructions":"Visit the website to learn more about Whitman-Walker's testing hours and locations.","access_value_ES":"https://www.whitman-walker.org/hiv-sti-testing","instructions_ES":"Visita el sitio web para obtener más información sobre los horarios y lugares de prueba de Whitman-Walker."}}},"boolean":null,"countryId":null,"govDistId":null,"languageId":null,"text":{"key":"orgn_01GVH3V408N0YS7CDYAH3F2BMH.attribute.atts_01GW2HT9F01W2M7FBSKSXAQ9R4","ns":"org-data","tsKey":{"text":"Visit the website to learn more about Whitman-Walker's testing hours and locations.","crowdinId":1535739}}}},{"attribute":{"id":"attr_01GW2HHFVMKTFWCKBVVFJ5GMY0","tsKey":"serviceaccess.accessphone","tsNs":"attribute","icon":null,"categories":[{"category":{"tag":"service-access-instructions"}}]},"supplement":{"id":"atts_01GW2HT9F09GFRWM3JK2A43AWG","active":true,"data":{"json":{"json":{"_id":{"$oid":"5e7e4bdbd54f1760921a4235"},"access_type":"phone","access_value":"202-745-7000","instructions":"Contact the Main Office about services offered in multiple languages upon request.","access_value_ES":"202-745-7000","instructions_ES":"Comunícate con la oficina principal sobre los servicios que se ofrecen en varios idiomas si lo solicitas."}}},"boolean":null,"countryId":null,"govDistId":null,"languageId":null,"text":{"key":"orgn_01GVH3V408N0YS7CDYAH3F2BMH.attribute.atts_01GW2HT9F09GFRWM3JK2A43AWG","ns":"org-data","tsKey":{"text":"Contact the Main Office about services offered in multiple languages upon request.","crowdinId":1535743}}}},{"attribute":{"id":"attr_01GW2HHFVMH6AE94EXN7T5A87C","tsKey":"serviceaccess.accesslocation","tsNs":"attribute","icon":null,"categories":[{"category":{"tag":"service-access-instructions"}}]},"supplement":{"id":"atts_01GW2HT9F0SPS3EBCQ710RCNTA","active":true,"data":{"json":{"_id":{"$oid":"5e7e4bdbd54f1760921a4231"},"access_type":"location","access_value":"2301 M. Luther King Jr., Washington DC 20020","instructions":"Max Robinson Center - NO walk-in testing is available. Monday:08:30-12:30, 13:30-17:30; Tuesday:08:30 - 12:30, 13:30 - 17:30; Wednesday:08:30 - 12:30, 13:30 - 17:30; Thursday:08:30 - 12:30, 13:30 - 17:30; Friday:08:30 - 12:30, 14:15 - 17:30.","access_value_ES":"2301 M. Luther King Jr., Washington DC 20020","instructions_ES":"Centro Max Robinson:NO hay pruebas disponibles sin cita previa. Lunes:08:30-12:30, 13:30-17:30; Martes:08:30 - 12:30, 13:30 - 17:30; Miércoles:08:30 - 12:30, 13:30 - 17:30; Jueves:08:30 - 12:30, 13:30 - 17:30; Viernes:08:30 - 12:30, 14:15 - 17:30."}},"boolean":null,"countryId":null,"govDistId":null,"languageId":null,"text":{"key":"orgn_01GVH3V408N0YS7CDYAH3F2BMH.attribute.atts_01GW2HT9F0SPS3EBCQ710RCNTA","ns":"org-data","tsKey":{"text":"Max Robinson Center - NO walk-in testing is available. Monday:08:30-12:30, 13:30-17:30; Tuesday:08:30 - 12:30, 13:30 - 17:30; Wednesday:08:30 - 12:30, 13:30 - 17:30; Thursday:08:30 - 12:30, 13:30 - 17:30; Friday:08:30 - 12:30, 14:15 - 17:30.","crowdinId":1535745}}}},{"attribute":{"id":"attr_01GW2HHFVMH6AE94EXN7T5A87C","tsKey":"serviceaccess.accesslocation","tsNs":"attribute","icon":null,"categories":[{"category":{"tag":"service-access-instructions"}}]},"supplement":{"id":"atts_01GW2HT9F0638MD74PJ3SCWNXC","active":true,"data":{"json":{"_id":{"$oid":"5e7e4bdbd54f1760921a4233"},"access_type":"location","access_value":"1525 14th St, NW Washington, DC 20005","instructions":"Whitman-Walker at 1525 - NO walk-in testing is available. Monday-Thursday:08:30-12:30 & 13:30-17:30; Friday:08:30- 12:30 & 14:30 -17:30.","access_value_ES":"1525 14th St, NW Washington, DC 20005","instructions_ES":"Whitman-Walker en 1525:NO hay pruebas disponibles. Lunes-Jueves:08:30-12:30 y 13:30-17:30; Viernes:08:30- 12:30 y 14:30 -17:30."}},"boolean":null,"countryId":null,"govDistId":null,"languageId":null,"text":{"key":"orgn_01GVH3V408N0YS7CDYAH3F2BMH.attribute.atts_01GW2HT9F0638MD74PJ3SCWNXC","ns":"org-data","tsKey":{"text":"Whitman-Walker at 1525 - NO walk-in testing is available. Monday-Thursday:08:30-12:30 & 13:30-17:30; Friday:08:30- 12:30 & 14:30 -17:30.","crowdinId":1535741}}}}]} +{"id":"osvc_01GVH3VEVPF1KEKBTRVTV70WGV","published":true,"deleted":false,"name":{"key":"orgn_01GVH3V408N0YS7CDYAH3F2BMH.osvc_01GVH3VEVPF1KEKBTRVTV70WGV.name","text":"Get rapid HIV testing","ns":"org-data","crowdinId":773224},"description":{"key":"orgn_01GVH3V408N0YS7CDYAH3F2BMH.osvc_01GVH3VEVPF1KEKBTRVTV70WGV.description","text":"Whitman-Walker provides walk-in HIV testing at multiple locations in DC. Walk-in HIV testing includes a confidential, rapid HIV test and risk-reduction counseling. The counseling provides clients with education on their options for having safer sex. Whitman-Walker uses the INSTI® HIV-1/HIV-2 Rapid Antibody Test and results take one minute.","ns":"org-data","crowdinId":773222},"phones":["ophn_01GVH3VEVC36PW0Z9GDV0ZERV1","ophn_01GVH3VEVCFKT3NWQ79STYVDKR"],"emails":[],"locations":["oloc_01GVH3VEVBRCFA2AHNTWCXQA2B","oloc_01GVH3VEVBSA85T6VR2C38BJPT"],"services":["svtg_01GW2HHFBRPBXSYN12DWNEAJJ7"],"hours":{},"serviceAreas":{"id":"svar_01GW2HT9F1JKT1MCAJ3P7XBDHP","countries":[],"districts":["gdst_01GW2HJ5A278S2G84AB3N9FCW0"]},"attributes":[{"attributeId":"attr_01GW2HHFVA06WHRSM241ZF0FY0","supplementId":"atts_01E4ENGMG266R5BH78D7B2MB7M","tag":"hiv-aids","category":"community","active":true,"countryId":null,"data":null,"govDistId":null,"languageId":null,"boolean":null,"text":null},{"attributeId":"attr_01GW2HHFVGDTNW9PDQNXK6TF1T","supplementId":"atts_01E4ENGMG2XWR5JQ1JMBN2SQVM","tag":"cost-free","category":"cost","active":true,"countryId":null,"data":null,"govDistId":null,"languageId":null,"boolean":null,"text":null},{"attributeId":"attr_01GW2HHFV3BADK80TG0DXXFPMM","supplementId":"atts_01E4ENGMG2J94M4S9DQTE57GWN","tag":"has-confidentiality-policy","category":"additional-information","active":true,"countryId":null,"data":null,"govDistId":null,"languageId":null,"boolean":null,"text":null},{"attributeId":"attr_01GW2HHFV4TM7H5V6FHWA7S9JK","supplementId":"atts_01E4ENGMG20KXGB20JYGZ4X938","tag":"time-walk-in","category":"additional-information","active":true,"countryId":null,"data":null,"govDistId":null,"languageId":null,"boolean":null,"text":null},{"attributeId":"attr_01GW2HHFVK8KPRGKYFSSM5ECPQ","supplementId":"atts_01GW2HT9F13VVJCJ8W2WE86R6N","tag":"incompatible-info","category":"system","active":false,"countryId":null,"data":{"json":[{"community-lgbt":"true"},{"lang-all-languages-by-interpreter":"Language access services are available, including ASL interpreting."}]},"govDistId":null,"languageId":null,"boolean":null,"text":null},{"attributeId":"attr_01GW2HHFVJ8K180CNX339BTXM2","supplementId":"atts_01GW2HT9F15B2HJK144B3NZHQK","tag":"lang-offered","category":"languages","active":true,"countryId":null,"data":null,"govDistId":null,"languageId":"lang_0000000000N3K70GZXE29Z03A4","boolean":null,"text":null}],"accessDetails":[{"attributeId":"attr_01GW2HHFVMYXMS8ARA3GE7HZFD","supplementId":"atts_01GW2HT9F01W2M7FBSKSXAQ9R4","tag":"accesslink","category":"service-access-instructions","active":true,"countryId":null,"data":{"json":{"_id":{"$oid":"5e7e4bdbd54f1760921a4234"},"access_type":"link","access_value":"https://www.whitman-walker.org/hiv-sti-testing","instructions":"Visit the website to learn more about Whitman-Walker's testing hours and locations.","access_value_ES":"https://www.whitman-walker.org/hiv-sti-testing","instructions_ES":"Visita el sitio web para obtener más información sobre los horarios y lugares de prueba de Whitman-Walker."}},"govDistId":null,"languageId":null,"boolean":null,"text":{"key":"orgn_01GVH3V408N0YS7CDYAH3F2BMH.attribute.atts_01GW2HT9F01W2M7FBSKSXAQ9R4","text":"Visit the website to learn more about Whitman-Walker's testing hours and locations.","ns":"org-data"}},{"attributeId":"attr_01GW2HHFVMKTFWCKBVVFJ5GMY0","supplementId":"atts_01GW2HT9F09GFRWM3JK2A43AWG","tag":"accessphone","category":"service-access-instructions","active":true,"countryId":null,"data":{"json":{"_id":{"$oid":"5e7e4bdbd54f1760921a4235"},"access_type":"phone","access_value":"202-745-7000","instructions":"Contact the Main Office about services offered in multiple languages upon request.","access_value_ES":"202-745-7000","instructions_ES":"Comunícate con la oficina principal sobre los servicios que se ofrecen en varios idiomas si lo solicitas."}},"govDistId":null,"languageId":null,"boolean":null,"text":{"key":"orgn_01GVH3V408N0YS7CDYAH3F2BMH.attribute.atts_01GW2HT9F09GFRWM3JK2A43AWG","text":"Contact the Main Office about services offered in multiple languages upon request.","ns":"org-data"}},{"attributeId":"attr_01GW2HHFVMH6AE94EXN7T5A87C","supplementId":"atts_01GW2HT9F0SPS3EBCQ710RCNTA","tag":"accesslocation","category":"service-access-instructions","active":true,"countryId":null,"data":{"json":{"_id":{"$oid":"5e7e4bdbd54f1760921a4231"},"access_type":"location","access_value":"2301 M. Luther King Jr., Washington DC 20020","instructions":"Max Robinson Center - NO walk-in testing is available. Monday:08:30-12:30, 13:30-17:30; Tuesday:08:30 - 12:30, 13:30 - 17:30; Wednesday:08:30 - 12:30, 13:30 - 17:30; Thursday:08:30 - 12:30, 13:30 - 17:30; Friday:08:30 - 12:30, 14:15 - 17:30.","access_value_ES":"2301 M. Luther King Jr., Washington DC 20020","instructions_ES":"Centro Max Robinson:NO hay pruebas disponibles sin cita previa. Lunes:08:30-12:30, 13:30-17:30; Martes:08:30 - 12:30, 13:30 - 17:30; Miércoles:08:30 - 12:30, 13:30 - 17:30; Jueves:08:30 - 12:30, 13:30 - 17:30; Viernes:08:30 - 12:30, 14:15 - 17:30."}},"govDistId":null,"languageId":null,"boolean":null,"text":{"key":"orgn_01GVH3V408N0YS7CDYAH3F2BMH.attribute.atts_01GW2HT9F0SPS3EBCQ710RCNTA","text":"Max Robinson Center - NO walk-in testing is available. Monday:08:30-12:30, 13:30-17:30; Tuesday:08:30 - 12:30, 13:30 - 17:30; Wednesday:08:30 - 12:30, 13:30 - 17:30; Thursday:08:30 - 12:30, 13:30 - 17:30; Friday:08:30 - 12:30, 14:15 - 17:30.","ns":"org-data"}},{"attributeId":"attr_01GW2HHFVMH6AE94EXN7T5A87C","supplementId":"atts_01GW2HT9F0638MD74PJ3SCWNXC","tag":"accesslocation","category":"service-access-instructions","active":true,"countryId":null,"data":{"json":{"_id":{"$oid":"5e7e4bdbd54f1760921a4233"},"access_type":"location","access_value":"1525 14th St, NW Washington, DC 20005","instructions":"Whitman-Walker at 1525 - NO walk-in testing is available. Monday-Thursday:08:30-12:30 & 13:30-17:30; Friday:08:30- 12:30 & 14:30 -17:30.","access_value_ES":"1525 14th St, NW Washington, DC 20005","instructions_ES":"Whitman-Walker en 1525:NO hay pruebas disponibles. Lunes-Jueves:08:30-12:30 y 13:30-17:30; Viernes:08:30- 12:30 y 14:30 -17:30."}},"govDistId":null,"languageId":null,"boolean":null,"text":{"key":"orgn_01GVH3V408N0YS7CDYAH3F2BMH.attribute.atts_01GW2HT9F0638MD74PJ3SCWNXC","text":"Whitman-Walker at 1525 - NO walk-in testing is available. Monday-Thursday:08:30-12:30 & 13:30-17:30; Friday:08:30- 12:30 & 14:30 -17:30.","ns":"org-data"}}]} From abc3fb4a1269ef74e8c738b43ba19de8cd5b3157 Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Tue, 13 Feb 2024 17:56:16 -0500 Subject: [PATCH 06/61] convert AttributeModal to RHF --- packages/ui/.swcrc | 2 +- .../data-portal/ServiceEditDrawer/index.tsx | 2 +- .../dataPortal/Attributes/SelectionItem.tsx | 41 ++ .../modals/dataPortal/Attributes/fields.tsx | 221 ++++---- .../ui/modals/dataPortal/Attributes/index.tsx | 495 +++++++----------- .../ui/modals/dataPortal/Attributes/schema.ts | 18 + .../ui/modals/dataPortal/Attributes/types.ts | 9 + 7 files changed, 380 insertions(+), 408 deletions(-) create mode 100644 packages/ui/modals/dataPortal/Attributes/SelectionItem.tsx create mode 100644 packages/ui/modals/dataPortal/Attributes/schema.ts create mode 100644 packages/ui/modals/dataPortal/Attributes/types.ts diff --git a/packages/ui/.swcrc b/packages/ui/.swcrc index fa29756c88..e0e541e84f 100644 --- a/packages/ui/.swcrc +++ b/packages/ui/.swcrc @@ -17,5 +17,5 @@ "keepClassNames": false }, "minify": false, - "sourceMaps": true + "sourceMaps": false } diff --git a/packages/ui/components/data-portal/ServiceEditDrawer/index.tsx b/packages/ui/components/data-portal/ServiceEditDrawer/index.tsx index 573141c890..82b52ba734 100644 --- a/packages/ui/components/data-portal/ServiceEditDrawer/index.tsx +++ b/packages/ui/components/data-portal/ServiceEditDrawer/index.tsx @@ -17,7 +17,7 @@ import { forwardRef, type ReactNode, useEffect, useMemo } from 'react' import { useForm } from 'react-hook-form' import { Textarea, TextInput } from 'react-hook-form-mantine' -import { Badge, BadgeGroup, type ServiceTagProps } from '~ui/components/core/Badge' +import { Badge } from '~ui/components/core/Badge' import { Breadcrumb } from '~ui/components/core/Breadcrumb' import { ServiceSelect } from '~ui/components/data-portal/ServiceSelect' import { useCustomVariant } from '~ui/hooks' diff --git a/packages/ui/modals/dataPortal/Attributes/SelectionItem.tsx b/packages/ui/modals/dataPortal/Attributes/SelectionItem.tsx new file mode 100644 index 0000000000..6df9390cbb --- /dev/null +++ b/packages/ui/modals/dataPortal/Attributes/SelectionItem.tsx @@ -0,0 +1,41 @@ +import { Group, Text } from '@mantine/core' +import { type ComponentPropsWithoutRef, forwardRef } from 'react' + +import { Icon, isValidIcon } from '~ui/icon' + +export const SelectionItem = forwardRef<HTMLDivElement, SelectionItemProps>( + ({ icon, label, ...others }, ref) => { + const { requireBoolean, requireGeo, requireData, requireLanguage, requireText, ...props } = others + return ( + <div ref={ref} {...props}> + <Group + sx={(theme) => ({ + alignItems: 'center', + gap: theme.spacing.xs, + padding: theme.spacing.xs, + borderRadius: theme.radius.sm, + // backgroundColor: theme.colors.gray[0], + cursor: 'pointer', + '&:hover': { + backgroundColor: theme.other.colors.primary.lightGray, + }, + })} + > + {icon && isValidIcon(icon) && <Icon icon={icon} width={18} />} + {icon && !isValidIcon(icon) && <Text>{icon}</Text>} + {label && label} + </Group> + </div> + ) + } +) +SelectionItem.displayName = 'SelectionItem' +interface SelectionItemProps extends ComponentPropsWithoutRef<'div'> { + icon?: string + label: string + requireBoolean: boolean + requireGeo: boolean + requireData: boolean + requireLanguage: boolean + requireText: boolean +} diff --git a/packages/ui/modals/dataPortal/Attributes/fields.tsx b/packages/ui/modals/dataPortal/Attributes/fields.tsx index 975e709dd1..6ec229ca43 100644 --- a/packages/ui/modals/dataPortal/Attributes/fields.tsx +++ b/packages/ui/modals/dataPortal/Attributes/fields.tsx @@ -1,69 +1,65 @@ -import { Group, Radio, Select, Stack, Text, TextInput } from '@mantine/core' +import { Group, Select as MantineSelect, Stack, Text } from '@mantine/core' import { useTranslation } from 'next-i18next' import { type ComponentPropsWithoutRef, forwardRef, type MouseEventHandler, useEffect, useState } from 'react' +import { type FieldPath, useFormContext } from 'react-hook-form' +import { + Radio, + type RadioGroupProps, + Select, + type SelectProps, + TextInput, + type TextInputProps, +} from 'react-hook-form-mantine' import { type LiteralUnion, type TupleToUnion } from 'type-fest' import { type ApiOutput } from '@weareinreach/api' +import { countries } from '@weareinreach/api/router/fieldOpt/query.countries.handler' import { Button } from '~ui/components/core/Button' import { trpc as api } from '~ui/lib/trpcClient' -import { useFormContext } from './context' - -const SuppBoolean = ({ handler }: SuppBooleanProps) => { - const form = useFormContext() +import { type FormSchema } from './schema' +const SuppBoolean = () => { + const { control } = useFormContext<FormSchema>() return ( - <Radio.Group - value={ - form.values.supplement?.boolean === undefined - ? undefined - : form.values.supplement.boolean - ? 'true' - : 'false' - } - onChange={handler} - > + <Radio.Group<FormSchema> name='boolean' control={control}> <Group> - <Radio value='true' label='True/Yes' /> - <Radio value='false' label='False/No' /> + <Radio.Item value={true} label='True/Yes' /> + <Radio.Item value={false} label='False/No' /> </Group> </Radio.Group> ) } -interface SuppBooleanProps { - handler: (value: string) => void -} -const SuppText = ({ handler }: SuppTextProps) => { - const form = useFormContext() +const SuppText = () => { + const { control } = useFormContext<FormSchema>() const { t } = useTranslation('common') return ( <Stack> - <TextInput {...form.getInputProps('supplement.text')} /> - <Button onClick={handler}>{t('words.add', { ns: 'common' })}</Button> + <TextInput {...{ control, name: 'text' }} /> + {/* <Button onClick={handler}>{t('words.add', { ns: 'common' })}</Button> */} </Stack> ) } -interface SuppTextProps { - handler: MouseEventHandler<HTMLButtonElement> -} const dataSchemas = ['numMinMaxOrRange', 'numRange', 'numMin', 'numMax', 'number'] as const type DataSchema = TupleToUnion<typeof dataSchemas> const isDataSchema = (schema: string): schema is DataSchema => dataSchemas.includes(schema as DataSchema) -const SuppData = ({ handler, schema }: SuppDataProps) => { - const form = useFormContext() - const { t } = useTranslation('common') - if (!isDataSchema(schema)) throw new Error('Invalid schema') +const SuppData = ({ schema }: SuppDataProps) => { + const { control } = useFormContext<FormSchema>() + if (!isDataSchema(schema)) { + console.error('Invalid schema', schema) + throw new Error('Invalid schema') + } console.log('SuppData') - useEffect(() => { - if (!form.values.supplement?.data) { - form.setFieldValue('supplement.data', {}) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [form.values.supplement]) + // useEffect(() => { + // if (!form.values.supplement?.data) { + // form.setFieldValue('supplement.data', {}) + // } + // // eslint-disable-next-line react-hooks/exhaustive-deps + // }, [form.values.supplement]) const body = (() => { switch (schema) { case 'numMax': @@ -71,14 +67,14 @@ const SuppData = ({ handler, schema }: SuppDataProps) => { case 'number': { const label = schema === 'numMax' ? 'Max' : schema === 'numMin' ? 'Min' : 'Amount' const key = schema === 'numMax' ? 'max' : schema === 'numMin' ? 'min' : 'number' - return <TextInput label={label} {...form.getInputProps(`supplement.data.${key}`)} /> + return <TextInput label={label} {...{ control, name: `data.${key}` }} /> } case 'numRange': case 'numMinMaxOrRange': { return ( <Group> - <TextInput w='25%' label='Min' {...form.getInputProps(`supplement.data.min`)} /> - <TextInput w='25%' label='Max' {...form.getInputProps(`supplement.data.max`)} /> + <TextInput w='25%' label='Min' {...{ control, name: `data.min` }} /> + <TextInput w='25%' label='Max' {...{ control, name: `data.max` }} /> </Group> ) } @@ -88,19 +84,18 @@ const SuppData = ({ handler, schema }: SuppDataProps) => { return ( <Group> {body} - <Button onClick={() => handler(form.values.supplement?.data)}> + {/* <Button onClick={() => handler(form.values.supplement?.data)}> {t('words.add', { ns: 'common' })} - </Button> + </Button> */} </Group> ) } interface SuppDataProps { - handler: (data?: object) => void //MouseEventHandler<HTMLButtonElement> schema: LiteralUnion<DataSchema, string> } -const SuppLang = ({ handler }: SuppLangProps) => { - const form = useFormContext() +const SuppLang = () => { + const { control } = useFormContext<FormSchema>() const { t } = useTranslation('common') const [listOptions, setListOptions] = useState<LangList[] | undefined>() api.fieldOpt.languages.useQuery(undefined, { @@ -109,19 +104,14 @@ const SuppLang = ({ handler }: SuppLangProps) => { }) return ( <Group> - {listOptions && ( - <Select data={listOptions} searchable {...form.getInputProps('supplement.languageId')} /> - )} - <Button onClick={() => handler(form.values.supplement?.languageId)}> + {listOptions && <Select data={listOptions} searchable name='languageId' {...{ control }} />} + {/* <Button onClick={() => handler(form.values.supplement?.languageId)}> {t('words.add', { ns: 'common' })} - </Button> + </Button> */} </Group> ) } -interface SuppLangProps { - handler: (value?: string) => void -} interface LangList { value: string label: string @@ -139,107 +129,113 @@ const GeoItem = forwardRef<HTMLDivElement, GeoItemProps>(({ flag, label, ...prop }) GeoItem.displayName = 'GeoItem' -const SuppGeo = ({ handler, countryOnly }: SuppGeoProps) => { - const form = useFormContext() +const SuppGeo = ({ countryOnly }: SuppGeoProps) => { + const { control, ...form } = useFormContext<FormSchema>() const { t } = useTranslation(['country', 'gov-dist']) - const [primaryList, setPrimaryList] = useState<GeoList[] | undefined>() + // const [primaryList, setPrimaryList] = useState<GeoList[] | undefined>() const [secondaryList, setSecondaryList] = useState<GeoList['districts'] | undefined>() const [tertiaryList, setTertiaryList] = useState< NonNullable<GeoList['districts']>[number]['subDistricts'] | undefined >() - const [primarySearch, onPrimarySearch] = useState<string | undefined>() - const [secondarySearch, onSecondarySearch] = useState<string | undefined>() - const [tertiarySearch, onTertiarySearch] = useState<string | undefined>() - const countries = api.fieldOpt.countries.useQuery(undefined, { - enabled: Boolean(countryOnly), - onSuccess: (data) => - setPrimaryList(data.map(({ id, name, flag }) => ({ value: id, label: name, flag: flag ?? undefined }))), + const [primarySearch, onPrimarySearch] = useState<string | null>(null) + const [secondarySearch, onSecondarySearch] = useState<string | null>(null) + const [tertiarySearch, onTertiarySearch] = useState<string | null>(null) + + const [finalValue, setFinalValue] = useState<string | null>(null) + const [fieldName, setFieldName] = useState<FieldPath<FormSchema> | undefined>( + countryOnly ? 'countryId' : undefined + ) + + const { data: countryList, ...countries } = api.fieldOpt.countries.useQuery(undefined, { + enabled: countryOnly ?? false, + select: (data) => data.map(({ id, name, flag }) => ({ value: id, label: name, flag: flag ?? undefined })), }) - api.fieldOpt.govDistsByCountry.useQuery(undefined, { + const { data: distByCountryList } = api.fieldOpt.govDistsByCountry.useQuery(undefined, { enabled: !countryOnly, - onSuccess: (data) => { - setPrimaryList( - data.map(({ id, tsKey, tsNs, flag, govDist }) => ({ - value: id, - label: t(tsKey, { ns: tsNs }), - flag: flag ?? undefined, - districts: govDist, - })) - ) - }, + select: (data) => + data.map(({ id, tsKey, tsNs, flag, govDist }) => ({ + value: id, + label: t(tsKey, { ns: tsNs }), + flag: flag ?? undefined, + districts: govDist, + })), }) + const primaryList = countryOnly ? countryList : distByCountryList - useEffect(() => { - if (form.values.supplement?.govDistId && secondaryList) { - const secondarySelected = secondaryList.find(({ id }) => id === form.values.supplement?.govDistId) - if (secondarySelected && secondarySelected.subDistricts.length) { - form.setFieldValue('supplement.subDistId', undefined) - onTertiarySearch('') - setTertiaryList(secondarySelected.subDistricts) - } else if (secondarySelected && !secondarySelected.subDistricts.length) { - setTertiaryList(undefined) - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [form.values.supplement?.govDistId]) + // useEffect(() => { + // if (form.values.supplement?.govDistId && secondaryList) { + // const secondarySelected = secondaryList.find(({ id }) => id === form.values.supplement?.govDistId) + // if (secondarySelected && secondarySelected.subDistricts.length) { + // form.setFieldValue('supplement.subDistId', undefined) + // onTertiarySearch('') + // setTertiaryList(secondarySelected.subDistricts) + // } else if (secondarySelected && !secondarySelected.subDistricts.length) { + // setTertiaryList(undefined) + // } + // } + // // eslint-disable-next-line react-hooks/exhaustive-deps + // }, [form.values.supplement?.govDistId]) - useEffect(() => { - if (form.values.supplement?.countryId && !countryOnly && primaryList) { - const primarySelected = primaryList.find(({ value }) => value === form.values.supplement?.countryId) - if (primarySelected && primarySelected.districts?.length) { - setSecondaryList(primarySelected.districts) - } else if (primarySelected && !primarySelected.districts?.length) { - onSecondarySearch('') - setSecondaryList(undefined) - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [form.values.supplement?.countryId, countryOnly]) + // useEffect(() => { + // if (form.values.supplement?.countryId && !countryOnly && primaryList) { + // const primarySelected = primaryList.find(({ value }) => value === form.values.supplement?.countryId) + // if (primarySelected && primarySelected.districts?.length) { + // setSecondaryList(primarySelected.districts) + // } else if (primarySelected && !primarySelected.districts?.length) { + // onSecondarySearch('') + // setSecondaryList(undefined) + // } + // } + // // eslint-disable-next-line react-hooks/exhaustive-deps + // }, [form.values.supplement?.countryId, countryOnly]) if (!primaryList && !countries.isSuccess) return <>Loading...</> return ( <Stack> {primaryList && ( - <Select + <MantineSelect data={primaryList} searchable - searchValue={primarySearch} + searchValue={primarySearch ?? undefined} onSearchChange={onPrimarySearch} itemComponent={GeoItem} - {...form.getInputProps('supplement.countryId')} + // control={control} + name='countryId' /> )} {secondaryList && ( - <Select + <MantineSelect data={secondaryList.map(({ id, tsKey, tsNs }) => ({ value: id, label: t(tsKey, { ns: tsNs }) satisfies string, }))} searchable - searchValue={secondarySearch} + searchValue={secondarySearch ?? undefined} onSearchChange={onSecondarySearch} itemComponent={GeoItem} - {...form.getInputProps('supplement.govDistId')} + // control={control} + name='govDistId' + // {...form.getInputProps('supplement.govDistId')} /> )} {tertiaryList && ( - <Select + <MantineSelect data={tertiaryList.map(({ id, tsKey, tsNs }) => ({ value: id, label: t(tsKey, { ns: tsNs }) satisfies string, }))} searchable - searchValue={tertiarySearch} + searchValue={tertiarySearch ?? undefined} onSearchChange={onTertiarySearch} itemComponent={GeoItem} - {...form.getInputProps('supplement.subDistId')} + // {...form.getInputProps('supplement.subDistId')} /> )} <Button - onClick={() => { - const { govDistId, countryId } = form.values.supplement ?? {} - handler(govDistId ? { govDistId } : { countryId }) - }} + // onClick={() => { + // const { govDistId, countryId } = form.values.supplement ?? {} + // handler(govDistId ? { govDistId } : { countryId }) + // }} > {t('words.add', { ns: 'common' })} </Button> @@ -247,7 +243,6 @@ const SuppGeo = ({ handler, countryOnly }: SuppGeoProps) => { ) } interface SuppGeoProps { - handler: (value?: { govDistId?: string; countryId?: string }) => void countryOnly?: boolean } diff --git a/packages/ui/modals/dataPortal/Attributes/index.tsx b/packages/ui/modals/dataPortal/Attributes/index.tsx index dd4c6d94e6..05257008ca 100644 --- a/packages/ui/modals/dataPortal/Attributes/index.tsx +++ b/packages/ui/modals/dataPortal/Attributes/index.tsx @@ -1,79 +1,34 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ +import { DevTool } from '@hookform/devtools' +import { zodResolver } from '@hookform/resolvers/zod' import { Box, type ButtonProps, createPolymorphicComponent, Group, + Select as MantineSelect, Modal, Select, + Skeleton, Stack, Text, } from '@mantine/core' -import { zodResolver } from '@mantine/form' import { useDisclosure } from '@mantine/hooks' import Ajv from 'ajv' import { useTranslation } from 'next-i18next' import { type ComponentPropsWithoutRef, forwardRef, useEffect, useRef, useState } from 'react' -import { z } from 'zod' +import { FormProvider, useFieldArray, useForm } from 'react-hook-form' -import { JsonInputOrNull } from '@weareinreach/api/schemas/common' import { Badge } from '~ui/components/core/Badge' import { Button } from '~ui/components/core/Button' import { Icon, isValidIcon } from '~ui/icon' import { trpc as api } from '~ui/lib/trpcClient' import { ModalTitle } from '~ui/modals/ModalTitle' -import { AttributeModalFormProvider, type FormData, useForm } from './context' import { Supplement } from './fields' - -const formDataSchema = z.object({ - selected: z - .object({ - value: z.string(), - country: z.string().optional(), - govDist: z.string().optional(), - language: z.string().optional(), - text: z.string().optional(), - boolean: z.coerce.boolean().optional(), - data: JsonInputOrNull.optional(), - }) - .array(), -}) - -const SelectionItem = forwardRef<HTMLDivElement, SelectionItemProps>(({ icon, label, ...others }, ref) => { - const { requireBoolean, requireGeo, requireData, requireLanguage, requireText, ...props } = others - return ( - <div ref={ref} {...props}> - <Group - sx={(theme) => ({ - alignItems: 'center', - gap: theme.spacing.xs, - padding: theme.spacing.xs, - borderRadius: theme.radius.sm, - // backgroundColor: theme.colors.gray[0], - cursor: 'pointer', - '&:hover': { - backgroundColor: theme.other.colors.primary.lightGray, - }, - })} - > - {icon && isValidIcon(icon) && <Icon icon={icon} width={18} />} - {icon && !isValidIcon(icon) && <Text>{icon}</Text>} - {label && label} - </Group> - </div> - ) -}) -SelectionItem.displayName = 'SelectionItem' -interface SelectionItemProps extends ComponentPropsWithoutRef<'div'> { - icon?: string - label: string - requireBoolean: boolean - requireGeo: boolean - requireData: boolean - requireLanguage: boolean - requireText: boolean -} +import { formSchema, type FormSchema } from './schema' +import { SelectionItem } from './SelectionItem' +import { type SupplementEventHandler } from './types' const supplementFields = { boolean: false, @@ -88,87 +43,76 @@ const AttributeModalBody = forwardRef<HTMLButtonElement, AttributeModalProps>( ({ restrictCategories, ...props }, ref) => { const { t } = useTranslation(['attribute', 'common']) const [opened, handler] = useDisclosure(false) - const [attrCat, setAttrCat] = useState<string | undefined>() - const [selectedAttr, setSelectedAttr] = useState<string | undefined>() - const [supplements, setSupplements] = useState<SupplementFieldsNeeded>(supplementFields) - const form = useForm({ - initialValues: { attributes: [], categories: [], selected: [], supplement: undefined }, - validate: zodResolver(formDataSchema), + + const form = useForm<FormSchema>({ + resolver: zodResolver(formSchema), }) const selectAttrRef = useRef<HTMLInputElement>(null) // #region tRPC const utils = api.useUtils() - api.fieldOpt.attributeCategories.useQuery(restrictCategories, { - refetchOnWindowFocus: false, - onSuccess: (data) => { - if (data.length === 1 && data[0]?.tag) { - setAttrCat(data[0].tag) - } else { - form.setFieldValue( - 'categories', - data.map(({ id, icon, intDesc, name, tag }) => ({ value: tag, label: name })) - ) - } - }, - }) - api.fieldOpt.attributesByCategory.useQuery(attrCat, { - enabled: Boolean(attrCat), - refetchOnWindowFocus: false, - onSuccess: (data) => { - const selected = form.values.selected?.map(({ value }) => value) ?? [] - const items = data.map( - ({ - attributeId, - attributeKey, - interpolationValues, - icon, - iconBg, - badgeRender, - requireBoolean, - requireGeo, - requireData, - requireLanguage, - requireText, - dataSchemaName, - dataSchema, - attributeName, - }) => ({ - value: attributeId, - label: attributeName, - tKey: attributeKey, - interpolationValues, - icon: icon ?? undefined, - iconBg: iconBg ?? undefined, - variant: badgeRender ?? undefined, - requireBoolean, - requireGeo, - requireData, - requireLanguage, - requireText, - dataSchemaName, - dataSchema, - }) - ) - form.setFieldValue( - 'attributes', - items.filter(({ value }) => !selected.includes(value)) - ) - }, - }) + const { data: attributeCategories, ...attributeCategoriesApi } = + api.fieldOpt.attributeCategories.useQuery(restrictCategories, { + refetchOnWindowFocus: false, + select: (data) => data.map(({ name, tag }) => ({ value: tag, label: name })), + }) + const [attrCat, setAttrCat] = useState<string | null>() + + const { data: attributesByCategory, ...attributesByCategoryApi } = + api.fieldOpt.attributesByCategory.useQuery(attrCat ?? '', { + enabled: Boolean(attrCat), + refetchOnWindowFocus: false, + select: (data) => + data.map( + ({ + attributeId, + attributeKey, + interpolationValues, + icon, + iconBg, + badgeRender, + requireBoolean, + requireGeo, + requireData, + requireLanguage, + requireText, + dataSchemaName, + dataSchema, + }) => ({ + value: attributeId, + label: t(attributeKey), + tKey: attributeKey, + interpolationValues, + icon: icon ?? undefined, + iconBg: iconBg ?? undefined, + variant: badgeRender ?? undefined, + requireBoolean, + requireGeo, + requireData, + requireLanguage, + requireText, + dataSchemaName, + dataSchema, + }) + ), + }) + + const [selectedAttr, setSelectedAttr] = useState<NonNullable<typeof attributesByCategory>[number] | null>( + null + ) + const [supplements, setSupplements] = useState<SupplementFieldsNeeded>(supplementFields) const saveAttributes = api.organization.attachAttribute.useMutation() // #endregion - + console.log({ attrCat, selectedAttr, supplements }) // #region Handlers const selectHandler = (e: string | null) => { - if (e === null) return - setSelectedAttr(e) - const item = form.values.attributes?.find(({ value }) => value === e) - form.setFieldValue('supplement', undefined) + console.log(e) + if (e === null) return setSelectedAttr(null) + const item = attributesByCategory?.find(({ value }) => value === e) if (item) { + setSelectedAttr(item) const { requireBoolean, requireGeo, requireData, requireLanguage, requireText } = item /** Check if supplemental info required */ if (requireBoolean || requireGeo || requireData || requireLanguage || requireText) { - console.log('eval handler', form.values.supplement) // const { boolean, countryId, govDistId, languageId, text, data } = form.values.supplement ?? {} /** Handle if supplemental info is provided */ @@ -183,29 +127,24 @@ const AttributeModalBody = forwardRef<HTMLButtonElement, AttributeModalProps>( } setSupplements(suppRequired) - form.setFieldValue('supplement', { - attributeId: item.value, - schema: requireData ? item.dataSchema : undefined, - schemaName: requireData ? item.dataSchemaName : undefined, - }) - return // } } - const { label, value, icon, iconBg, variant, tKey } = item - form.setFieldValue('selected', [ - ...form.values.selected, - { label, value, icon, iconBg, variant, tKey }, - ]) - form.setFieldValue( - 'attributes', - form.values.attributes?.filter(({ value }) => value !== e) - ) + form.setValue('attributeId', item.value) + // const { label, value, icon, iconBg, variant, tKey } = item + // form.setFieldValue('selected', [ + // ...form.values.selected, + // { label, value, icon, iconBg, variant, tKey }, + // ]) + // form.setFieldValue( + // 'attributes', + // form.values.attributes?.filter(({ value }) => value !== e) + // ) selectAttrRef.current && (selectAttrRef.current.value = '') } } - const handleSupplement = (e: NonNullable<FormData['supplement']>) => { - const item = form.values.attributes?.find(({ value }) => value === e.attributeId) + const handleSupplement = (e: SupplementEventHandler) => { + const item = attributesByCategory?.find(({ value }) => value === e.attributeId) if (!item) return const { requireBoolean, requireGeo, requireData, requireLanguage, requireText } = item const { boolean, countryId, govDistId, languageId, text, data } = e ?? {} @@ -220,28 +159,30 @@ const AttributeModalBody = forwardRef<HTMLButtonElement, AttributeModalProps>( ) { console.log('handler after supp') const { value, label, icon, iconBg, variant, tKey } = item + // clear state setSupplements(supplementFields) - form.setValues({ - selected: [ - ...form.values.selected, - { - value, - label, - icon, - iconBg, - variant, - countryId, - govDistId, - languageId, - text, - boolean, - data, - tKey, - }, - ], - attributes: form.values.attributes?.filter(({ value }) => value !== e.attributeId), - supplement: undefined, - }) + + // form.setValues({ + // selected: [ + // ...form.values.selected, + // { + // value, + // label, + // icon, + // iconBg, + // variant, + // countryId, + // govDistId, + // languageId, + // text, + // boolean, + // data, + // tKey, + // }, + // ], + // attributes: form.values.attributes?.filter(({ value }) => value !== e.attributeId), + // supplement: undefined, + // }) // clear out supplement store return @@ -249,10 +190,10 @@ const AttributeModalBody = forwardRef<HTMLButtonElement, AttributeModalProps>( } const removeHandler = (e: string) => { - form.setFieldValue( - 'selected', - form.values.selected?.filter(({ value }) => value !== e) - ) + // form.setFieldValue( + // 'selected', + // form.values.selected?.filter(({ value }) => value !== e) + // ) utils.fieldOpt.attributesByCategory.invalidate() } @@ -264,153 +205,121 @@ const AttributeModalBody = forwardRef<HTMLButtonElement, AttributeModalProps>( // #region Title & Selected items display const modalTitle = <ModalTitle breadcrumb={{ option: 'close', onClick: handler.close }} /> - const selectedItems = form.values.selected?.map(({ label, icon, variant, value, iconBg, tKey, data }) => { - switch (variant) { - case 'ATTRIBUTE': { - return ( - <Group key={value} spacing={4}> - <Badge variant='attribute' tsNs='attribute' tsKey={tKey} icon={icon ?? ''} /> - <Icon icon='carbon:close-filled' onClick={() => removeHandler(value)} /> - </Group> - ) - } - case 'COMMUNITY': { - return ( - <Group key={value} spacing={4}> - <Badge variant='community' tsKey={tKey} icon={icon ?? ''} /> - <Icon icon='carbon:close-filled' onClick={() => removeHandler(value)} /> - </Group> - ) - } - case 'LEADER': { - return ( - <Group key={value} spacing={4}> - <Badge variant='leader' tsKey={tKey} icon={icon ?? ''} iconBg={iconBg ?? ''} /> - <Icon icon='carbon:close-filled' onClick={() => removeHandler(value)} /> - </Group> - ) - } - case 'LIST': { - return ( - <Group key={value} spacing={4}> - <Text>{t(tKey, { ns: 'attribute', context: 'range', ...data })}</Text> - <Icon icon='carbon:close-filled' onClick={() => removeHandler(value)} /> - </Group> - ) - } - } - }) + // const selectedItems = form.values.selected?.map(({ label, icon, variant, value, iconBg, tKey, data }) => { + // switch (variant) { + // case 'ATTRIBUTE': { + // return ( + // <Group key={value} spacing={4}> + // <Badge variant='attribute' tsNs='attribute' tsKey={tKey} icon={icon ?? ''} /> + // <Icon icon='carbon:close-filled' onClick={() => removeHandler(value)} /> + // </Group> + // ) + // } + // case 'COMMUNITY': { + // return ( + // <Group key={value} spacing={4}> + // <Badge variant='community' tsKey={tKey} icon={icon ?? ''} /> + // <Icon icon='carbon:close-filled' onClick={() => removeHandler(value)} /> + // </Group> + // ) + // } + // case 'LEADER': { + // return ( + // <Group key={value} spacing={4}> + // <Badge variant='leader' tsKey={tKey} icon={icon ?? ''} iconBg={iconBg ?? ''} /> + // <Icon icon='carbon:close-filled' onClick={() => removeHandler(value)} /> + // </Group> + // ) + // } + // case 'LIST': { + // return ( + // <Group key={value} spacing={4}> + // <Text>{t(tKey, { ns: 'attribute', context: 'range', ...data })}</Text> + // <Icon icon='carbon:close-filled' onClick={() => removeHandler(value)} /> + // </Group> + // ) + // } + // } + // }) // #endregion /** Validate supplement data against defined JSON schema */ - useEffect(() => { - const { data, schema } = form.values.supplement ?? {} - if (data && schema) { - const ajv = new Ajv() - const validate = ajv.compile(schema as object) - const validData = validate(data) - if (!validData && validate.errors) { - form.setFieldError( - 'supplement.data', - validate.errors.map(({ message }) => message) - ) - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [form.values.supplement]) - console.log(form.values) + // useEffect(() => { + // const { data, schema } = form.values.supplement ?? {} + // if (data && schema) { + // const ajv = new Ajv() + // const validate = ajv.compile(schema as object) + // const validData = validate(data) + // if (!validData && validate.errors) { + // form.setFieldError( + // 'supplement.data', + // validate.errors.map(({ message }) => message) + // ) + // } + // } + // // eslint-disable-next-line react-hooks/exhaustive-deps + // }, [form.values.supplement]) + const needsSupplement = Object.values(supplements).includes(true) return ( - <> + <FormProvider {...form}> <Modal title={modalTitle} opened={opened} onClose={() => handler.close()}> - <AttributeModalFormProvider form={form}> + <Stack> + {/* <Group>{selectedItems}</Group> */} <Stack> - <Group>{selectedItems}</Group> - <Stack> - {form.values.categories?.length && ( - <Select - data={form.values.categories} - onChange={(e) => { - if (!e) return - setAttrCat(e) - }} - withinPortal - searchable - /> - )} - <Select - data={form.values.attributes} - disabled={!form.values.attributes?.length} + <Skeleton visible={attributeCategoriesApi.isLoading}> + <MantineSelect + data={attributeCategories ?? []} + label='Select Category' + onChange={(e) => { + setAttrCat(e) + if (selectedAttr) { + setSelectedAttr(null) + } + }} + withinPortal + searchable + clearable + /> + </Skeleton> + {true && ( + // <Skeleton visible={!!attrCat && attributesByCategoryApi.isLoading}> + <MantineSelect + data={ + !attrCat ? [] : (attributesByCategory ?? []).map(({ label, value }) => ({ label, value })) + } + value={selectedAttr?.value ?? null} + label='Select Attribute' + disabled={!attrCat || !attributesByCategory?.length} withinPortal itemComponent={SelectionItem} - searchable={form.values.attributes?.length > 10} + searchable={(attributesByCategory?.length ?? 0) > 10} ref={selectAttrRef} clearable // {...form.getInputProps('selected')} onChange={selectHandler} + inputContainer={(children) => ( + <Skeleton visible={!!attrCat && attributesByCategoryApi.isLoading} radius='md'> + {children} + </Skeleton> + )} /> - </Stack> - {supplements.boolean && ( - <Supplement.Boolean - handler={(e) => { - form.values.supplement?.attributeId && - handleSupplement({ - attributeId: form.values.supplement.attributeId, - boolean: e === 'true' ? true : false, - }) - }} - /> - )} - {supplements.text && ( - <Supplement.Text - handler={() => - form.values.supplement?.attributeId && - handleSupplement({ - attributeId: form.values.supplement.attributeId, - text: form.values.supplement?.text, - }) - } - /> - )} - {supplements.data && form.values.supplement?.schemaName && ( - <Supplement.Data - handler={(data) => { - form.values.supplement?.attributeId && - handleSupplement({ - attributeId: form.values.supplement.attributeId, - data, - }) - }} - schema={form.values.supplement.schemaName} - /> - )} - {supplements.language && ( - <Supplement.Language - handler={(languageId) => { - form.values.supplement?.attributeId && - languageId && - handleSupplement({ - attributeId: form.values.supplement.attributeId, - languageId, - }) - }} - /> - )} - {supplements.geo && ( - <Supplement.Geo - handler={(data) => { - form.values.supplement?.attributeId && - data && - handleSupplement({ attributeId: form.values.supplement.attributeId, ...data }) - }} - /> + // </Skeleton> )} - {!needsSupplement && <Button>{t('words.save', { ns: 'common' })}</Button>} </Stack> - </AttributeModalFormProvider> + {supplements.boolean && <Supplement.Boolean />} + {supplements.text && <Supplement.Text />} + {supplements.data && selectedAttr?.dataSchemaName && ( + <Supplement.Data schema={selectedAttr.dataSchemaName} /> + )} + {supplements.language && <Supplement.Language />} + {supplements.geo && <Supplement.Geo />} + {!needsSupplement && <Button>{t('words.save', { ns: 'common' })}</Button>} + </Stack> </Modal> <Box component='button' ref={ref} onClick={() => handler.open()} {...props} /> - </> + <DevTool control={form.control} placement='top-left' /> + </FormProvider> ) } ) diff --git a/packages/ui/modals/dataPortal/Attributes/schema.ts b/packages/ui/modals/dataPortal/Attributes/schema.ts new file mode 100644 index 0000000000..d672a5466f --- /dev/null +++ b/packages/ui/modals/dataPortal/Attributes/schema.ts @@ -0,0 +1,18 @@ +import { z } from 'zod' + +import { JsonInputOrNull } from '@weareinreach/api/schemas/common' +import { prefixedId } from '@weareinreach/api/schemas/idPrefix' +import { generateId } from '@weareinreach/db/lib/idGen' + +export const formSchema = z.object({ + id: prefixedId('attributeSupplement').default(generateId('attributeSupplement')), + attributeId: prefixedId('attribute'), + value: z.string(), + countryId: z.string().optional(), + govDistId: z.string().optional(), + languageId: z.string().optional(), + text: z.string().optional(), + boolean: z.coerce.boolean().optional(), + data: JsonInputOrNull.optional(), +}) +export type FormSchema = z.infer<typeof formSchema> diff --git a/packages/ui/modals/dataPortal/Attributes/types.ts b/packages/ui/modals/dataPortal/Attributes/types.ts new file mode 100644 index 0000000000..04f1b0b93b --- /dev/null +++ b/packages/ui/modals/dataPortal/Attributes/types.ts @@ -0,0 +1,9 @@ +export interface SupplementEventHandler { + attributeId: string + countryId?: string + govDistId?: string + languageId?: string + text?: string + boolean?: boolean + data?: object +} From 6f858993ec34fd5821c24915e5ce9379b78529af Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Wed, 14 Feb 2024 13:45:38 -0500 Subject: [PATCH 07/61] attribute supplement schemas --- .../db/generated/attributeSupplementSchema.ts | 30 ++++++++++++ packages/db/lib/generateData.ts | 1 + .../generators/attributeSuppDataSchemas.ts | 35 ++++++++++++++ packages/db/lib/generators/index.ts | 1 + packages/db/package.json | 1 + .../migration.sql | 48 +++++++++++++++++++ packages/db/prisma/schema.prisma | 1 + packages/db/zod_util/attributeSupplement.ts | 34 +++++++++++++ pnpm-lock.yaml | 3 ++ 9 files changed, 154 insertions(+) create mode 100644 packages/db/generated/attributeSupplementSchema.ts create mode 100644 packages/db/lib/generators/attributeSuppDataSchemas.ts create mode 100644 packages/db/prisma/migrations/20240214173007_attribute_supplement_schemas/migration.sql diff --git a/packages/db/generated/attributeSupplementSchema.ts b/packages/db/generated/attributeSupplementSchema.ts new file mode 100644 index 0000000000..fc6480b32a --- /dev/null +++ b/packages/db/generated/attributeSupplementSchema.ts @@ -0,0 +1,30 @@ +import { z } from 'zod' + +export const attributeSupplementSchema = { + accessInstructions: z.object({ + access_type: z.enum(['email', 'file', 'link', 'location', 'other', 'phone']), + access_value: z.union([z.string(), z.null()]).optional(), + instructions: z.string(), + }), + currency: z.any(), + incompatible: z.any(), + incompatibleData: z.array(z.record(z.any())), + number: z.object({ num: z.number() }), + 'num-max': z.any(), + numMax: z.object({ max: z.number() }), + 'num-min': z.any(), + numMin: z.object({ min: z.number() }), + 'num-min-max': z.any(), + numMinMaxOrRange: z.union([ + z.object({ min: z.number() }), + z.object({ max: z.number() }), + z.object({ max: z.number(), min: z.number() }), + ]), + numRange: z.object({ max: z.number(), min: z.number() }), + otherDescribe: z.object({ other: z.string() }), +} + +export const isAttributeSupplementSchema = (schema: string): schema is AttributeSupplementSchemas => + Object.keys(attributeSupplementSchema).includes(schema) + +export type AttributeSupplementSchemas = keyof typeof attributeSupplementSchema diff --git a/packages/db/lib/generateData.ts b/packages/db/lib/generateData.ts index af5986c364..486a147497 100644 --- a/packages/db/lib/generateData.ts +++ b/packages/db/lib/generateData.ts @@ -39,6 +39,7 @@ const tasks = new Listr<Context>( defineJob('Service Categories', job.generateServiceCategories), defineJob('Language lists', job.generateLanguageFiles), defineJob('Translation Namespaces', job.generateNamespaces), + defineJob('Attribute Supplement Data Schemas', job.generateDataSchemas), ], { concurrent: true } ), diff --git a/packages/db/lib/generators/attributeSuppDataSchemas.ts b/packages/db/lib/generators/attributeSuppDataSchemas.ts new file mode 100644 index 0000000000..7d557a76c0 --- /dev/null +++ b/packages/db/lib/generators/attributeSuppDataSchemas.ts @@ -0,0 +1,35 @@ +import { type JsonSchemaObject, jsonSchemaToZod } from 'json-schema-to-zod' + +import { prisma } from '~db/client' +import { type ListrTask } from '~db/lib/generateData' + +import { writeOutput } from './common' + +export const generateDataSchemas = async (task: ListrTask) => { + const data = await prisma.attributeSupplementDataSchema.findMany({ + where: { + active: true, + }, + select: { + tag: true, + schema: true, + }, + orderBy: { tag: 'asc' }, + }) + const schemas = data.map(({ tag, schema }) => { + return `"${tag}": ${jsonSchemaToZod(schema as JsonSchemaObject)},` + }) + + const out = ` + import { z } from 'zod'; + export const attributeSupplementSchema = { + ${schemas.join('\n')} + } + + export const isAttributeSupplementSchema = (schema: string): schema is AttributeSupplementSchemas => Object.keys(attributeSupplementSchema).includes(schema) + + export type AttributeSupplementSchemas = keyof typeof attributeSupplementSchema + ` + await writeOutput('attributeSupplementSchema', out) + task.title = `${task.title} (${data.length} items)` +} diff --git a/packages/db/lib/generators/index.ts b/packages/db/lib/generators/index.ts index 17e4078d0f..91661da73a 100644 --- a/packages/db/lib/generators/index.ts +++ b/packages/db/lib/generators/index.ts @@ -2,6 +2,7 @@ export * from './allAttributes' export * from './attributeCategory' export * from './attributesByCategory' +export * from './attributeSuppDataSchemas' export * from './langs' export * from './namespaces' export * from './permission' diff --git a/packages/db/package.json b/packages/db/package.json index 6b72ec4d5e..66d9c7e3bd 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -51,6 +51,7 @@ "@weareinreach/env": "workspace:*", "@weareinreach/util": "workspace:*", "id128": "1.6.6", + "json-schema-to-zod": "2.0.14", "kysely": "0.27.2", "pg": "8.11.3", "prisma-kysely": "1.8.0", diff --git a/packages/db/prisma/migrations/20240214173007_attribute_supplement_schemas/migration.sql b/packages/db/prisma/migrations/20240214173007_attribute_supplement_schemas/migration.sql new file mode 100644 index 0000000000..3ccd720ef8 --- /dev/null +++ b/packages/db/prisma/migrations/20240214173007_attribute_supplement_schemas/migration.sql @@ -0,0 +1,48 @@ +/* + Warnings: + + - Added the required column `schema` to the `AttributeSupplementDataSchema` table without a default value. This is not possible if the table is not empty. + */ +-- AlterTable +ALTER TABLE "AttributeSupplementDataSchema" + ADD COLUMN "schema" JSONB; + +UPDATE + "AttributeSupplementDataSchema" +SET + "schema" = "definition"; + +ALTER TABLE "AttributeSupplementDataSchema" + ALTER COLUMN "schema" SET NOT NULL; + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "AttributeSupplement_active_attributeId_idx" ON + "AttributeSupplement"("active", "attributeId"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "OrgLocationService_active_serviceId_idx" ON + "OrgLocationService"("active", "serviceId"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "OrgService_organizationId_published_deleted_idx" ON + "OrgService"("organizationId", "published" DESC, "deleted"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "ServiceArea_active_organizationId_idx" ON + "ServiceArea"("active", "organizationId"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "ServiceArea_active_orgLocationId_idx" ON + "ServiceArea"("active", "orgLocationId"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "ServiceArea_active_orgServiceId_idx" ON + "ServiceArea"("active", "orgServiceId"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "ServiceAreaCountry_active_serviceAreaId_idx" ON + "ServiceAreaCountry"("active", "serviceAreaId"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "ServiceAreaDist_active_serviceAreaId_idx" ON + "ServiceAreaDist"("active", "serviceAreaId"); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index d3f68836ee..9b9f04b707 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -1062,6 +1062,7 @@ model AttributeSupplementDataSchema { name String active Boolean @default(true) definition Json + schema Json // entryComponent String? createdAt DateTime @default(now()) diff --git a/packages/db/zod_util/attributeSupplement.ts b/packages/db/zod_util/attributeSupplement.ts index 9ca2a5fd9c..5e0cd88f7f 100644 --- a/packages/db/zod_util/attributeSupplement.ts +++ b/packages/db/zod_util/attributeSupplement.ts @@ -131,3 +131,37 @@ export const AttSuppSchemas = { } export type AttributeSupplementSchemas = keyof typeof AttSuppSchemas +/** Dynamic Fields for Supplement Data Schemas */ + +export enum FieldType { + text = 'text', + select = 'select', + number = 'number', + currency = 'currency', +} +interface BaseFieldAttributes { + key: string + label: string + name: string + type: FieldType + required?: boolean +} + +interface TextFieldAttributes extends BaseFieldAttributes { + type: FieldType.text +} +interface SelectFieldAttributes extends BaseFieldAttributes { + type: FieldType.select + options: { value: string; label: string }[] +} +interface NumberFieldAttributes extends BaseFieldAttributes { + type: FieldType.number +} +interface CurrencyFieldAttributes extends BaseFieldAttributes { + type: FieldType.currency +} +export type FieldAttributes = + | TextFieldAttributes + | SelectFieldAttributes + | NumberFieldAttributes + | CurrencyFieldAttributes diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d8b6fc4a5d..0293d8e3b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -961,6 +961,9 @@ importers: id128: specifier: 1.6.6 version: 1.6.6 + json-schema-to-zod: + specifier: 2.0.14 + version: 2.0.14 kysely: specifier: 0.27.2 version: 0.27.2 From 3b2e3919090fbdbc8f5decc9401caababfd5af1d Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Wed, 14 Feb 2024 16:39:06 -0500 Subject: [PATCH 08/61] schema updates --- .../db/generated/attributeSupplementSchema.ts | 5 - .../index.ts | 148 ++++++++++++++++++ packages/db/prisma/data-migrations/index.ts | 1 + 3 files changed, 149 insertions(+), 5 deletions(-) create mode 100644 packages/db/prisma/data-migrations/2024-02-14_attribute-supplement-schemas/index.ts diff --git a/packages/db/generated/attributeSupplementSchema.ts b/packages/db/generated/attributeSupplementSchema.ts index fc6480b32a..0f13462e24 100644 --- a/packages/db/generated/attributeSupplementSchema.ts +++ b/packages/db/generated/attributeSupplementSchema.ts @@ -6,15 +6,10 @@ export const attributeSupplementSchema = { access_value: z.union([z.string(), z.null()]).optional(), instructions: z.string(), }), - currency: z.any(), - incompatible: z.any(), incompatibleData: z.array(z.record(z.any())), number: z.object({ num: z.number() }), - 'num-max': z.any(), numMax: z.object({ max: z.number() }), - 'num-min': z.any(), numMin: z.object({ min: z.number() }), - 'num-min-max': z.any(), numMinMaxOrRange: z.union([ z.object({ min: z.number() }), z.object({ max: z.number() }), diff --git a/packages/db/prisma/data-migrations/2024-02-14_attribute-supplement-schemas/index.ts b/packages/db/prisma/data-migrations/2024-02-14_attribute-supplement-schemas/index.ts new file mode 100644 index 0000000000..e06e0a6d6d --- /dev/null +++ b/packages/db/prisma/data-migrations/2024-02-14_attribute-supplement-schemas/index.ts @@ -0,0 +1,148 @@ +import { prisma, type Prisma } from '~db/client' +import { formatMessage } from '~db/prisma/common' +import { type MigrationJob } from '~db/prisma/dataMigrationRunner' +import { createLogger, type JobDef, jobPostRunner } from '~db/prisma/jobPreRun' +import { type FieldAttributes, FieldType } from '~db/zod_util/attributeSupplement' + +/** Define the job metadata here. */ +const jobDef: JobDef = { + jobId: '2024-02-14_attribute-supplement-schemas', + title: 'attribute supplement schemas', + createdBy: 'Joe Karow', + /** Optional: Longer description for the job */ + description: undefined, +} +/** + * Job export - this variable MUST be UNIQUE + */ +export const job20240214_attribute_supplement_schemas = { + title: `[${jobDef.jobId}] ${jobDef.title}`, + task: async (_ctx, task) => { + /** Create logging instance */ + createLogger(task, jobDef.jobId) + const log = (...args: Parameters<typeof formatMessage>) => (task.output = formatMessage(...args)) + /** + * Start defining your data migration from here. + * + * To log output, use `task.output = 'Message to log'` + * + * This will be written to `stdout` and to a log file in `/prisma/migration-logs/` + */ + + // Do stuff + const deleted = await prisma.attributeSupplementDataSchema.deleteMany({ + where: { + id: { + in: [ + 'asds_01GW2HHH9NFEXHG9RHBTM9NRFR', + 'asds_01GW2HHH9PKJ6H9WFSNZSVK2G4', + 'asds_01GW2HHH9P7J5A1CBGN6B5QCG7', + 'asds_01GW2HHH9PN6MJ4ZS7D17G1YTK', + 'asds_01GW2HHH9PSSYV7TKFA6DY68P4', + ], + }, + }, + }) + + log(`Deleted ${deleted.count} records.`) + + const updateData: SchemaUpdate[] = [ + { + data: { + definition: [ + { key: 'min', label: 'Min', name: 'min', type: FieldType.number }, + { key: 'max', label: 'Max', name: 'max', type: FieldType.number }, + ], + // tag: 'numMinMaxOrRange', + }, + where: { + id: 'asds_01GYX872BWWCGTZREHDT2AFF9D', + }, + }, + { + data: { + definition: [ + { key: 'min', label: 'Min', name: 'min', type: FieldType.number }, + { key: 'max', label: 'Max', name: 'max', type: FieldType.number }, + ], + // tag: 'numRange', + }, + where: { + id: 'asds_01GYX872BYZQ6CC344S1SWTJ97', + }, + }, + { + data: { + definition: { key: 'min', label: 'Min', name: 'min', type: FieldType.number }, + // tag: 'numMin', + }, + where: { + id: 'asds_01GYX872BZE4TN1MJHMTGVAYZ0', + }, + }, + { + data: { + definition: { key: 'max', label: 'Max', name: 'max', type: FieldType.number }, + // tag: 'numMax', + }, + where: { + id: 'asds_01GYX872BZNT0F6WH50XJQWM9G', + }, + }, + { + data: { + definition: { key: 'num', label: 'Number', name: 'num', type: FieldType.number }, + // tag: 'number', + }, + where: { + id: 'asds_01GYX872BZKJPVH6VHC0ABFH8A', + }, + }, + { + data: { + definition: { + key: 'incompatible', + label: 'Incompatible', + name: 'incompatible', + type: FieldType.text, + }, + // tag: 'incompatibleData', + }, + where: { + id: 'asds_01GYX872BZSMTHYM4HYYTCENZM', + }, + }, + { + data: { + definition: { key: 'other', label: 'Other', name: 'other', type: FieldType.text }, + // tag: 'otherDescribe', + }, + where: { + id: 'asds_01GYX872BZ7V6VQ3NE6KSVVRKH', + }, + }, + ] + const updates = await prisma.$transaction( + updateData.map((args) => + prisma.attributeSupplementDataSchema.update( + args as unknown as Prisma.AttributeSupplementDataSchemaUpdateArgs + ) + ) + ) + log(`Updated ${updates.length} records.`) + /** + * DO NOT REMOVE BELOW + * + * This writes a record to the DB to register that this migration has run successfully. + */ + // await jobPostRunner(jobDef) + }, + def: jobDef, +} satisfies MigrationJob + +type SchemaUpdate = { + where: { id: string } + data: { + definition: FieldAttributes | FieldAttributes[] + } +} diff --git a/packages/db/prisma/data-migrations/index.ts b/packages/db/prisma/data-migrations/index.ts index 31638c79d7..f16237b5e7 100644 --- a/packages/db/prisma/data-migrations/index.ts +++ b/packages/db/prisma/data-migrations/index.ts @@ -3,4 +3,5 @@ export * from './2024-01-31_fix-attr-supp-json/index' export * from './2024-01-31_target-population-attrib' export * from './2024-02-01_add-missing-attributes/index' export * from './2024-02-02_deactivate-incompatible-attribs' +export * from './2024-02-14_attribute-supplement-schemas/index' // codegen:end From df853735f998ea8a4ca945a1f67b6142486b70c6 Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Wed, 14 Feb 2024 16:39:28 -0500 Subject: [PATCH 09/61] dynamic fields --- .../modals/dataPortal/Attributes/fields.tsx | 99 +++++++------------ 1 file changed, 38 insertions(+), 61 deletions(-) diff --git a/packages/ui/modals/dataPortal/Attributes/fields.tsx b/packages/ui/modals/dataPortal/Attributes/fields.tsx index 6ec229ca43..b16d3d4ef7 100644 --- a/packages/ui/modals/dataPortal/Attributes/fields.tsx +++ b/packages/ui/modals/dataPortal/Attributes/fields.tsx @@ -1,19 +1,12 @@ import { Group, Select as MantineSelect, Stack, Text } from '@mantine/core' import { useTranslation } from 'next-i18next' -import { type ComponentPropsWithoutRef, forwardRef, type MouseEventHandler, useEffect, useState } from 'react' +import { type ComponentPropsWithoutRef, forwardRef, useState } from 'react' import { type FieldPath, useFormContext } from 'react-hook-form' -import { - Radio, - type RadioGroupProps, - Select, - type SelectProps, - TextInput, - type TextInputProps, -} from 'react-hook-form-mantine' -import { type LiteralUnion, type TupleToUnion } from 'type-fest' +import { NumberInput, Radio, Select, TextInput } from 'react-hook-form-mantine' +import { type TupleToUnion } from 'type-fest' import { type ApiOutput } from '@weareinreach/api' -import { countries } from '@weareinreach/api/router/fieldOpt/query.countries.handler' +import { type FieldAttributes, FieldType } from '@weareinreach/db/zod_util/attributeSupplement' import { Button } from '~ui/components/core/Button' import { trpc as api } from '~ui/lib/trpcClient' @@ -42,65 +35,54 @@ const SuppText = () => { ) } -const dataSchemas = ['numMinMaxOrRange', 'numRange', 'numMin', 'numMax', 'number'] as const -type DataSchema = TupleToUnion<typeof dataSchemas> - -const isDataSchema = (schema: string): schema is DataSchema => dataSchemas.includes(schema as DataSchema) - const SuppData = ({ schema }: SuppDataProps) => { const { control } = useFormContext<FormSchema>() - if (!isDataSchema(schema)) { - console.error('Invalid schema', schema) - throw new Error('Invalid schema') - } - console.log('SuppData') - // useEffect(() => { - // if (!form.values.supplement?.data) { - // form.setFieldValue('supplement.data', {}) - // } - // // eslint-disable-next-line react-hooks/exhaustive-deps - // }, [form.values.supplement]) - const body = (() => { - switch (schema) { - case 'numMax': - case 'numMin': - case 'number': { - const label = schema === 'numMax' ? 'Max' : schema === 'numMin' ? 'Min' : 'Amount' - const key = schema === 'numMax' ? 'max' : schema === 'numMin' ? 'min' : 'number' - return <TextInput label={label} {...{ control, name: `data.${key}` }} /> + + const renderField = (schema: FieldAttributes) => { + const { type, name: dataKey, ...schemaProps } = schema + const baseProps = { + ...schemaProps, + name: `data.${dataKey}` as const, + control, + } + switch (type) { + case FieldType.text: { + return <TextInput {...baseProps} /> + } + case FieldType.select: { + const { options } = schema + return <Select {...baseProps} data={options} /> } - case 'numRange': - case 'numMinMaxOrRange': { - return ( - <Group> - <TextInput w='25%' label='Min' {...{ control, name: `data.min` }} /> - <TextInput w='25%' label='Max' {...{ control, name: `data.max` }} /> - </Group> - ) + case FieldType.number: { + return <NumberInput {...baseProps} type='number' /> + } + case FieldType.currency: { + return <NumberInput {...baseProps} type='number' /> } } - })() + } return ( - <Group> - {body} - {/* <Button onClick={() => handler(form.values.supplement?.data)}> - {t('words.add', { ns: 'common' })} - </Button> */} - </Group> + <Stack> + {schema.flatMap((schema) => { + if (Array.isArray(schema)) { + return <Group noWrap>{schema.map(renderField)}</Group> + } else { + return renderField(schema) + } + })} + </Stack> ) } interface SuppDataProps { - schema: LiteralUnion<DataSchema, string> + // schema: LiteralUnion<DataSchema, string> + schema: FieldAttributes[] | FieldAttributes[][] } const SuppLang = () => { const { control } = useFormContext<FormSchema>() - const { t } = useTranslation('common') - const [listOptions, setListOptions] = useState<LangList[] | undefined>() - api.fieldOpt.languages.useQuery(undefined, { - onSuccess: (data) => - setListOptions(data.map(({ id, languageName }) => ({ value: id, label: languageName }))), + const { data: listOptions } = api.fieldOpt.languages.useQuery(undefined, { + select: (data) => data.map(({ id, languageName }) => ({ value: id, label: languageName })), }) return ( <Group> @@ -112,11 +94,6 @@ const SuppLang = () => { ) } -interface LangList { - value: string - label: string -} - const GeoItem = forwardRef<HTMLDivElement, GeoItemProps>(({ flag, label, ...props }, ref) => { return ( <div ref={ref} {...props}> From 39c28a3a87a05311610a44c13a23da73c6e53c2a Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Thu, 15 Feb 2024 15:42:38 -0500 Subject: [PATCH 10/61] update i18n generation --- apps/app/lib/generators/translationKeys.ts | 6 +++--- apps/app/public/locales/en/attribute.json | 5 +++++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/app/lib/generators/translationKeys.ts b/apps/app/lib/generators/translationKeys.ts index 0ee892a469..3659724328 100644 --- a/apps/app/lib/generators/translationKeys.ts +++ b/apps/app/lib/generators/translationKeys.ts @@ -50,9 +50,9 @@ export const generateTranslationKeys = async (task: PassedTask) => { if (typeof value !== 'string') throw new Error('Invalid nested plural item') outputData[`${item.key}_${key}`] = value } - } else { - outputData[item.key] = item.text - } + } //else { + if (item.ns === 'attribute') outputData[item.key] = item.text + //} } const filename = `${localePath}/${namespace.name}.json` diff --git a/apps/app/public/locales/en/attribute.json b/apps/app/public/locales/en/attribute.json index fb08e90c86..2befe345df 100644 --- a/apps/app/public/locales/en/attribute.json +++ b/apps/app/public/locales/en/attribute.json @@ -9,6 +9,7 @@ "private-practice": "Private Practice", "religiously-affiliated": "Is religiously affiliated", "time-walk-in": "Has Walk-In Hours", + "wheelchair-accessible": "Accessible", "wheelchair-accessible_false": "Not Accessible", "wheelchair-accessible_true": "Accessible" }, @@ -76,6 +77,7 @@ }, "eligibility": { "CATEGORYNAME": "Eligibility Requirements", + "elig-age": "Age eligibility", "elig-age_max": "Under {{max}}", "elig-age_min": "{{min}} and older", "elig-age_range": "{{min}} - {{max}}", @@ -138,6 +140,9 @@ "CATEGORYNAME": "System", "incompatible-info": "Incompatible Information" }, + "tpop": { + "other": "Target Population - Other" + }, "userlawpractice": { "CATEGORYNAME": "Law Practice Options", "corp-law-firm": "Corporate law firm", From 3bac47756699919a0c3e5e3b1b42b20cdc1c55d2 Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Thu, 15 Feb 2024 15:45:42 -0500 Subject: [PATCH 11/61] update schema & migrations --- .../index.ts | 24 +- .../data.json | 1396 +++++++++++++++++ .../2024-02-15_attribute-attachments/index.ts | 64 + packages/db/prisma/data-migrations/index.ts | 1 + .../migration.sql | 11 + .../migration.sql | 33 + packages/db/prisma/schema.prisma | 9 + packages/ui/mockData/fieldOpt.ts | 30 +- .../json/fieldOpt.attributesByCategory.json | 2 +- 9 files changed, 1553 insertions(+), 17 deletions(-) create mode 100644 packages/db/prisma/data-migrations/2024-02-15_attribute-attachments/data.json create mode 100644 packages/db/prisma/data-migrations/2024-02-15_attribute-attachments/index.ts create mode 100644 packages/db/prisma/migrations/20240215164734_attribute_attachment/migration.sql create mode 100644 packages/db/prisma/migrations/20240215172645_update_attrib_by_cat_view/migration.sql diff --git a/packages/db/prisma/data-migrations/2024-02-14_attribute-supplement-schemas/index.ts b/packages/db/prisma/data-migrations/2024-02-14_attribute-supplement-schemas/index.ts index e06e0a6d6d..ca89dd481c 100644 --- a/packages/db/prisma/data-migrations/2024-02-14_attribute-supplement-schemas/index.ts +++ b/packages/db/prisma/data-migrations/2024-02-14_attribute-supplement-schemas/index.ts @@ -73,7 +73,7 @@ export const job20240214_attribute_supplement_schemas = { }, { data: { - definition: { key: 'min', label: 'Min', name: 'min', type: FieldType.number }, + definition: [{ key: 'min', label: 'Min', name: 'min', type: FieldType.number }], // tag: 'numMin', }, where: { @@ -82,7 +82,7 @@ export const job20240214_attribute_supplement_schemas = { }, { data: { - definition: { key: 'max', label: 'Max', name: 'max', type: FieldType.number }, + definition: [{ key: 'max', label: 'Max', name: 'max', type: FieldType.number }], // tag: 'numMax', }, where: { @@ -91,7 +91,7 @@ export const job20240214_attribute_supplement_schemas = { }, { data: { - definition: { key: 'num', label: 'Number', name: 'num', type: FieldType.number }, + definition: [{ key: 'num', label: 'Number', name: 'num', type: FieldType.number }], // tag: 'number', }, where: { @@ -100,12 +100,14 @@ export const job20240214_attribute_supplement_schemas = { }, { data: { - definition: { - key: 'incompatible', - label: 'Incompatible', - name: 'incompatible', - type: FieldType.text, - }, + definition: [ + { + key: 'incompatible', + label: 'Incompatible', + name: 'incompatible', + type: FieldType.text, + }, + ], // tag: 'incompatibleData', }, where: { @@ -114,7 +116,7 @@ export const job20240214_attribute_supplement_schemas = { }, { data: { - definition: { key: 'other', label: 'Other', name: 'other', type: FieldType.text }, + definition: [{ key: 'other', label: 'Other', name: 'other', type: FieldType.text }], // tag: 'otherDescribe', }, where: { @@ -135,7 +137,7 @@ export const job20240214_attribute_supplement_schemas = { * * This writes a record to the DB to register that this migration has run successfully. */ - // await jobPostRunner(jobDef) + await jobPostRunner(jobDef) }, def: jobDef, } satisfies MigrationJob diff --git a/packages/db/prisma/data-migrations/2024-02-15_attribute-attachments/data.json b/packages/db/prisma/data-migrations/2024-02-15_attribute-attachments/data.json new file mode 100644 index 0000000000..74a3a2fdf2 --- /dev/null +++ b/packages/db/prisma/data-migrations/2024-02-15_attribute-attachments/data.json @@ -0,0 +1,1396 @@ +[ + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "at-capacity" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "geo-near-public-transit" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "geo-public-transit-description" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "has-confidentiality-policy" + } + }, + { + "data": { + "canAttachTo": [ + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "offers-remote-services" + } + }, + { + "data": { + "canAttachTo": [ + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "private-practice" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "religiously-affiliated" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "time-walk-in" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "wheelchair-accessible" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "info" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "warn" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "adults" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "africa-immigrant" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "african-american" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "api" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "asexual" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "asia-immigrant" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "asylee" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "asylum-seeker" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "bipoc" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "bisexual" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "black" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "citizens" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "conversion-therapy-survivors" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "daca-recipient-seeker" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "detained-immigrant" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "disabled" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "gay" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "gender-nonconforming" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "hiv-aids" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "homeless" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "human-trafficking-survivor" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "intersex" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "language-speakers" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "latin-america-immigrant" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "latinx" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "lesbian" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "lgbtq-youth" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "lgbtq-youth-caregivers" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "middle-east-immigrant" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "muslim" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "native-american-two-spirit" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "nonbinary" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "queer" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "refugee" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "residents-green-card-holders" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "seniors" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "sex-workers" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "teens" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "transfeminine" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "transgender" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "transmasculine" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "trans-youth" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "trans-youth-caregivers" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "unaccompanied-minors" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "undocumented" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE" + ] + }, + "where": { + "tag": "cost-fees" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE" + ] + }, + "where": { + "tag": "cost-free" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "elders" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "general-lgbtq" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE" + ] + }, + "where": { + "tag": "elig-age" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE" + ] + }, + "where": { + "tag": "other-describe" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE" + ] + }, + "where": { + "tag": "req-medical-insurance" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "req-photo-id" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "req-proof-of-age" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "req-proof-of-income" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "req-proof-of-residence" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "req-referral" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "time-appointment-required" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "all-languages-by-interpreter" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "american-sign-language" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE", + "ORGANIZATION", + "LOCATION" + ] + }, + "where": { + "tag": "lang-offered" + } + }, + { + "data": { + "canAttachTo": [ + "LOCATION", + "ORGANIZATION" + ] + }, + "where": { + "tag": "bipoc-led" + } + }, + { + "data": { + "canAttachTo": [ + "LOCATION", + "ORGANIZATION" + ] + }, + "where": { + "tag": "black-led" + } + }, + { + "data": { + "canAttachTo": [ + "LOCATION", + "ORGANIZATION" + ] + }, + "where": { + "tag": "immigrant-led" + } + }, + { + "data": { + "canAttachTo": [ + "LOCATION", + "ORGANIZATION" + ] + }, + "where": { + "tag": "trans-led" + } + }, + { + "data": { + "canAttachTo": [ + "LOCATION", + "ORGANIZATION" + ] + }, + "where": { + "tag": "women-led" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE" + ] + }, + "where": { + "tag": "accessemail" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE" + ] + }, + "where": { + "tag": "accessfile" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE" + ] + }, + "where": { + "tag": "accesslink" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE" + ] + }, + "where": { + "tag": "accesslocation" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE" + ] + }, + "where": { + "tag": "accessphone" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE" + ] + }, + "where": { + "tag": "accesspublictransit" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE" + ] + }, + "where": { + "tag": "accesssms" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE" + ] + }, + "where": { + "tag": "accesstext" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE" + ] + }, + "where": { + "tag": "accesswhatsapp" + } + }, + { + "data": { + "canAttachTo": [ + "LOCATION", + "ORGANIZATION", + "SERVICE" + ] + }, + "where": { + "tag": "asylum-seekers" + } + }, + { + "data": { + "canAttachTo": [ + "LOCATION", + "ORGANIZATION", + "SERVICE" + ] + }, + "where": { + "tag": "bipoc-comm" + } + }, + { + "data": { + "canAttachTo": [ + "LOCATION", + "ORGANIZATION", + "SERVICE" + ] + }, + "where": { + "tag": "caregivers-focus" + } + }, + { + "data": { + "canAttachTo": [ + "LOCATION", + "ORGANIZATION", + "SERVICE" + ] + }, + "where": { + "tag": "disabled-focus" + } + }, + { + "data": { + "canAttachTo": [ + "LOCATION", + "ORGANIZATION", + "SERVICE" + ] + }, + "where": { + "tag": "elder-focus" + } + }, + { + "data": { + "canAttachTo": [ + "LOCATION", + "ORGANIZATION", + "SERVICE" + ] + }, + "where": { + "tag": "gender-nc" + } + }, + { + "data": { + "canAttachTo": [ + "LOCATION", + "ORGANIZATION", + "SERVICE" + ] + }, + "where": { + "tag": "hiv-comm" + } + }, + { + "data": { + "canAttachTo": [ + "LOCATION", + "ORGANIZATION", + "SERVICE" + ] + }, + "where": { + "tag": "immigrant-comm" + } + }, + { + "data": { + "canAttachTo": [ + "LOCATION", + "ORGANIZATION", + "SERVICE" + ] + }, + "where": { + "tag": "incarcerated-focus" + } + }, + { + "data": { + "canAttachTo": [ + "LOCATION", + "ORGANIZATION", + "SERVICE" + ] + }, + "where": { + "tag": "lgbtq-youth-focus" + } + }, + { + "data": { + "canAttachTo": [ + "LOCATION", + "ORGANIZATION", + "SERVICE" + ] + }, + "where": { + "tag": "resettled-refugees" + } + }, + { + "data": { + "canAttachTo": [ + "LOCATION", + "ORGANIZATION", + "SERVICE" + ] + }, + "where": { + "tag": "spanish-speakers" + } + }, + { + "data": { + "canAttachTo": [ + "LOCATION", + "ORGANIZATION", + "SERVICE" + ] + }, + "where": { + "tag": "trans-comm" + } + }, + { + "data": { + "canAttachTo": [ + "LOCATION", + "ORGANIZATION", + "SERVICE" + ] + }, + "where": { + "tag": "trans-fem" + } + }, + { + "data": { + "canAttachTo": [ + "LOCATION", + "ORGANIZATION", + "SERVICE" + ] + }, + "where": { + "tag": "trans-masc" + } + }, + { + "data": { + "canAttachTo": [ + "LOCATION", + "ORGANIZATION", + "SERVICE" + ] + }, + "where": { + "tag": "trans-youth-focus" + } + }, + { + "data": { + "canAttachTo": [ + "LOCATION", + "ORGANIZATION", + "SERVICE" + ] + }, + "where": { + "tag": "women-focus" + } + }, + { + "data": { + "canAttachTo": [ + "LOCATION", + "ORGANIZATION", + "SERVICE", + "USER" + ] + }, + "where": { + "tag": "incompatible-info" + } + }, + { + "data": { + "canAttachTo": [ + "SERVICE" + ] + }, + "where": { + "tag": "tpop-other" + } + }, + { + "data": { + "canAttachTo": [ + "USER" + ] + }, + "where": { + "tag": "corp-law-firm" + } + }, + { + "data": { + "canAttachTo": [ + "USER" + ] + }, + "where": { + "tag": "law-other" + } + }, + { + "data": { + "canAttachTo": [ + "USER" + ] + }, + "where": { + "tag": "law-school-clinic" + } + }, + { + "data": { + "canAttachTo": [ + "USER" + ] + }, + "where": { + "tag": "legal-nonprofit" + } + }, + { + "data": { + "canAttachTo": [ + "USER" + ] + }, + "where": { + "tag": "userserviceprovider.case-mananger" + } + }, + { + "data": { + "canAttachTo": [ + "USER" + ] + }, + "where": { + "tag": "userserviceprovider.community-org" + } + }, + { + "data": { + "canAttachTo": [ + "USER" + ] + }, + "where": { + "tag": "userserviceprovider.friend-family" + } + }, + { + "data": { + "canAttachTo": [ + "USER" + ] + }, + "where": { + "tag": "userserviceprovider.govt-agency" + } + }, + { + "data": { + "canAttachTo": [ + "USER" + ] + }, + "where": { + "tag": "userserviceprovider.grassroots-direct" + } + }, + { + "data": { + "canAttachTo": [ + "USER" + ] + }, + "where": { + "tag": "userserviceprovider.healthcare" + } + }, + { + "data": { + "canAttachTo": [ + "USER" + ] + }, + "where": { + "tag": "userserviceprovider.lawyer" + } + }, + { + "data": { + "canAttachTo": [ + "USER" + ] + }, + "where": { + "tag": "userserviceprovider.other" + } + }, + { + "data": { + "canAttachTo": [ + "USER" + ] + }, + "where": { + "tag": "userserviceprovider.paralegal" + } + }, + { + "data": { + "canAttachTo": [ + "USER" + ] + }, + "where": { + "tag": "userserviceprovider.social-worker" + } + }, + { + "data": { + "canAttachTo": [ + "USER" + ] + }, + "where": { + "tag": "userserviceprovider.student-club" + } + }, + { + "data": { + "canAttachTo": [ + "USER" + ] + }, + "where": { + "tag": "userserviceprovider.teacher" + } + }, + { + "data": { + "canAttachTo": [ + "USER" + ] + }, + "where": { + "tag": "userserviceprovider.therapist-counselor" + } + } +] \ No newline at end of file diff --git a/packages/db/prisma/data-migrations/2024-02-15_attribute-attachments/index.ts b/packages/db/prisma/data-migrations/2024-02-15_attribute-attachments/index.ts new file mode 100644 index 0000000000..b39733c37e --- /dev/null +++ b/packages/db/prisma/data-migrations/2024-02-15_attribute-attachments/index.ts @@ -0,0 +1,64 @@ +import { z } from 'zod' + +import { prisma, Prisma } from '~db/client' +import { formatMessage } from '~db/prisma/common' +import { type MigrationJob } from '~db/prisma/dataMigrationRunner' +import { createLogger, type JobDef, jobPostRunner } from '~db/prisma/jobPreRun' + +import data from './data.json' + +const Schema = z + .object({ + where: z.object({ tag: z.string() }), + data: z.object({ + canAttachTo: z + .enum(['SERVICE', 'ORGANIZATION', 'LOCATION', 'USER']) + .array() + .transform((x) => ({ set: x })), + }), + }) + .array() + +/** Define the job metadata here. */ +const jobDef: JobDef = { + jobId: '2024-02-15_attribute-attachments', + title: 'attribute attachments', + createdBy: 'Joe Karow', + /** Optional: Longer description for the job */ + description: undefined, +} +/** + * Job export - this variable MUST be UNIQUE + */ +export const job20240215_attribute_attachments = { + title: `[${jobDef.jobId}] ${jobDef.title}`, + task: async (_ctx, task) => { + /** Create logging instance */ + createLogger(task, jobDef.jobId) + const log = (...args: Parameters<typeof formatMessage>) => (task.output = formatMessage(...args)) + /** + * Start defining your data migration from here. + * + * To log output, use `task.output = 'Message to log'` + * + * This will be written to `stdout` and to a log file in `/prisma/migration-logs/` + */ + const parsed = Schema.parse(data) + + const updates = await prisma.$transaction( + parsed.map((args) => { + return prisma.attribute.update(args) + }) + ) + + log(`Updated ${updates.length} records.`) + + /** + * DO NOT REMOVE BELOW + * + * This writes a record to the DB to register that this migration has run successfully. + */ + await jobPostRunner(jobDef) + }, + def: jobDef, +} satisfies MigrationJob diff --git a/packages/db/prisma/data-migrations/index.ts b/packages/db/prisma/data-migrations/index.ts index f16237b5e7..13ac73e580 100644 --- a/packages/db/prisma/data-migrations/index.ts +++ b/packages/db/prisma/data-migrations/index.ts @@ -4,4 +4,5 @@ export * from './2024-01-31_target-population-attrib' export * from './2024-02-01_add-missing-attributes/index' export * from './2024-02-02_deactivate-incompatible-attribs' export * from './2024-02-14_attribute-supplement-schemas/index' +export * from './2024-02-15_attribute-attachments/index' // codegen:end diff --git a/packages/db/prisma/migrations/20240215164734_attribute_attachment/migration.sql b/packages/db/prisma/migrations/20240215164734_attribute_attachment/migration.sql new file mode 100644 index 0000000000..3b72a68a83 --- /dev/null +++ b/packages/db/prisma/migrations/20240215164734_attribute_attachment/migration.sql @@ -0,0 +1,11 @@ +-- CreateEnum +CREATE TYPE "AttributeAttachment" AS ENUM( + 'ORGANIZATION', + 'LOCATION', + 'SERVICE', + 'USER' +); + +-- AlterTable +ALTER TABLE "Attribute" + ADD COLUMN "canAttachTo" "AttributeAttachment"[]; diff --git a/packages/db/prisma/migrations/20240215172645_update_attrib_by_cat_view/migration.sql b/packages/db/prisma/migrations/20240215172645_update_attrib_by_cat_view/migration.sql new file mode 100644 index 0000000000..704492a538 --- /dev/null +++ b/packages/db/prisma/migrations/20240215172645_update_attrib_by_cat_view/migration.sql @@ -0,0 +1,33 @@ +CREATE OR REPLACE VIEW public.attributes_by_category AS +SELECT + ac.id AS "categoryId", + ac.tag AS "categoryName", + ac.name AS "categoryDisplay", + a.id AS "attributeId", + a.tag AS "attributeName", + a."tsKey" AS "attributeKey", + a."tsNs" AS "attributeNs", + a.icon, + a."iconBg", + ac."renderVariant" AS "badgeRender", + a."requireText", + a."requireLanguage", + a."requireGeo", + a."requireBoolean", + a."requireData", + asds.definition AS "dataSchema", + tkey."interpolationValues", + asds.tag AS "dataSchemaName", + a."canAttachTo" +FROM + "AttributeCategory" ac + JOIN "AttributeToCategory" atc ON atc."categoryId" = ac.id + JOIN "Attribute" a ON a.id = atc."attributeId" + LEFT JOIN "AttributeSupplementDataSchema" asds ON asds.id = a."requiredSchemaId" + LEFT JOIN "TranslationKey" tkey ON tkey.key = a."tsKey" +WHERE + a.active = TRUE + AND ac.active = TRUE +ORDER BY + ac.tag, + a.tag; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 9b9f04b707..ae0370dd93 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -997,6 +997,7 @@ model Attribute { requireData Boolean @default(false) requireDataSchema AttributeSupplementDataSchema? @relation(fields: [requiredSchemaId], references: [id]) requiredSchemaId String? + canAttachTo AttributeAttachment[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -1007,6 +1008,13 @@ model Attribute { @@unique([tsKey, tsNs]) } +enum AttributeAttachment { + ORGANIZATION + LOCATION + SERVICE + USER +} + enum FilterType { INCLUDE EXCLUDE @@ -2299,6 +2307,7 @@ view AttributesByCategory { requireData Boolean dataSchemaName String? dataSchema Json? + canAttachTo AttributeAttachment[] @@unique([categoryId, attributeId]) @@map("attributes_by_category") diff --git a/packages/ui/mockData/fieldOpt.ts b/packages/ui/mockData/fieldOpt.ts index ae4c279f48..c558b7b864 100644 --- a/packages/ui/mockData/fieldOpt.ts +++ b/packages/ui/mockData/fieldOpt.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import { type ApiOutput } from '@weareinreach/api' +import { type $Enums } from '@weareinreach/db' import { getTRPCMock, type MockAPIHandler, type MockHandlerObject } from '~ui/lib/getTrpcMock' const queryAttributeCategories: MockAPIHandler<'fieldOpt', 'attributeCategories'> = async (query) => { @@ -12,12 +13,31 @@ const queryAttributeCategories: MockAPIHandler<'fieldOpt', 'attributeCategories' } const queryAttributesByCategory: MockAPIHandler<'fieldOpt', 'attributesByCategory'> = async (query) => { - const attributesByCategory = (await import('./json/fieldOpt.attributesByCategory.json')).default - if (typeof query === 'string' || Array.isArray(query)) { - return attributesByCategory.filter(({ categoryName }) => - Array.isArray(query) ? query.includes(categoryName) : query === categoryName - ) as ApiOutput['fieldOpt']['attributesByCategory'] + const attributesByCategory = (await import('./json/fieldOpt.attributesByCategory.json')) + .default as ApiOutput['fieldOpt']['attributesByCategory'] + + if (query?.categoryName || query?.canAttachTo?.length) { + const canAttachSet = new Set(query.canAttachTo) + const catNameSet = new Set(Array.isArray(query.categoryName) ? query.categoryName : [query.categoryName]) + return attributesByCategory.filter(({ canAttachTo, categoryName }) => { + let match = false + + if (query.canAttachTo?.length) { + for (const item of canAttachTo) { + if (canAttachSet.has(item as $Enums.AttributeAttachment)) { + match = true + break + } + } + } + if (query.categoryName) { + match = catNameSet.has(categoryName) + } + + return match + }) } + return attributesByCategory as ApiOutput['fieldOpt']['attributesByCategory'] } diff --git a/packages/ui/mockData/json/fieldOpt.attributesByCategory.json b/packages/ui/mockData/json/fieldOpt.attributesByCategory.json index e15c5fabd6..31b758d22e 100644 --- a/packages/ui/mockData/json/fieldOpt.attributesByCategory.json +++ b/packages/ui/mockData/json/fieldOpt.attributesByCategory.json @@ -1 +1 @@ -[{"categoryId":"attc_01GW2HHFV3DJ380F351SKB0B74","categoryName":"additional-information","categoryDisplay":"Additional Information","attributeId":"attr_01GW2HHFV3YJ2AWADHVKG79BQ0","attributeName":"at-capacity","attributeKey":"additional.at-capacity","attributeNs":"attribute","badgeRender":"ATTRIBUTE","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01GW2HHFV3DJ380F351SKB0B74","categoryName":"additional-information","categoryDisplay":"Additional Information","attributeId":"attr_01GW2HHFV4D5ZHFMAE7852GB4P","attributeName":"geo-near-public-transit","attributeKey":"additional.geo-near-public-transit","attributeNs":"attribute","badgeRender":"ATTRIBUTE","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01GW2HHFV3DJ380F351SKB0B74","categoryName":"additional-information","categoryDisplay":"Additional Information","attributeId":"attr_01GW2HHFV48VQJBMFA05QCBBV9","attributeName":"geo-public-transit-description","attributeKey":"additional.geo-public-transit-description","attributeNs":"attribute","badgeRender":"ATTRIBUTE","requireText":true,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01GW2HHFV3DJ380F351SKB0B74","categoryName":"additional-information","categoryDisplay":"Additional Information","attributeId":"attr_01GW2HHFV3BADK80TG0DXXFPMM","attributeName":"has-confidentiality-policy","attributeKey":"additional.has-confidentiality-policy","attributeNs":"attribute","badgeRender":"ATTRIBUTE","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01GW2HHFV3DJ380F351SKB0B74","categoryName":"additional-information","categoryDisplay":"Additional Information","attributeId":"attr_01GW2HHFV5Q7XN2ZNTYFR1AD3M","attributeName":"offers-remote-services","attributeKey":"additional.offers-remote-services","attributeNs":"attribute","icon":"carbon:globe","badgeRender":"ATTRIBUTE","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01GW2HHFV3DJ380F351SKB0B74","categoryName":"additional-information","categoryDisplay":"Additional Information","attributeId":"attr_01GW2HHFV4TM7H5V6FHWA7S9JK","attributeName":"time-walk-in","attributeKey":"additional.time-walk-in","attributeNs":"attribute","badgeRender":"ATTRIBUTE","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01GW2HHFV3DJ380F351SKB0B74","categoryName":"additional-information","categoryDisplay":"Additional Information","attributeId":"attr_01GW2HHFV5FYXQNGTPAQB7G2TF","attributeName":"wheelchair-accessible","attributeKey":"additional.wheelchair-accessible","attributeNs":"attribute","interpolationValues":{"true":"Accessible","false":"Not Accessible"},"icon":"carbon:accessibility","badgeRender":"ATTRIBUTE","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":true,"requireData":false},{"categoryId":"attc_01GYSVX1N9T91BJYSHRDPCHJBS","categoryName":"alerts","categoryDisplay":"Alerts","attributeId":"attr_01GYSVX1NAMR6RDV6M69H4KN3T","attributeName":"info","attributeKey":"alerts.info","attributeNs":"attribute","icon":"carbon:information-filled","requireText":true,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01GYSVX1N9T91BJYSHRDPCHJBS","categoryName":"alerts","categoryDisplay":"Alerts","attributeId":"attr_01GYSVX1NAKP7C6JKJ342ZM35M","attributeName":"warn","attributeKey":"alerts.warn","attributeNs":"attribute","icon":"carbon:warning-filled","requireText":true,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01GW2HHFVFKNMYPN8F86M0H576","categoryName":"cost","categoryDisplay":"Cost","attributeId":"attr_01GW2HHFVGWKWB53HWAAHQ9AAZ","attributeName":"cost-fees","attributeKey":"cost.cost-fees","attributeNs":"attribute","icon":"carbon:piggy-bank","badgeRender":"ATTRIBUTE","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":true,"dataSchemaName":"numMinMaxOrRange","dataSchema":{"anyOf":[{"type":"object","required":["min"],"properties":{"min":{"type":"number"}}},{"type":"object","required":["max"],"properties":{"max":{"type":"number"}}},{"type":"object","required":["min","max"],"properties":{"max":{"type":"number"},"min":{"type":"number"}}}],"$schema":"http://json-schema.org/draft-07/schema#"}},{"categoryId":"attc_01GW2HHFVFKNMYPN8F86M0H576","categoryName":"cost","categoryDisplay":"Cost","attributeId":"attr_01GW2HHFVGDTNW9PDQNXK6TF1T","attributeName":"cost-free","attributeKey":"cost.cost-free","attributeNs":"attribute","icon":"carbon:piggy-bank","badgeRender":"ATTRIBUTE","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01H6P8SSY4C141YH7BAC1RW7KJ","categoryName":"crisis-support-community","categoryDisplay":"Crisis Support Community","attributeId":"attr_01GW2HHFVN72D7XEBZZJXCJQXQ","attributeName":"bipoc-comm","attributeKey":"srvfocus.bipoc-comm","attributeNs":"attribute","icon":"️‍️‍✊🏿","badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01H6P8SSY4C141YH7BAC1RW7KJ","categoryName":"crisis-support-community","categoryDisplay":"Crisis Support Community","attributeId":"attr_01H6P951P0V3CR807P8KRH82S1","attributeName":"elders","attributeKey":"crisis-support-community.elders","attributeNs":"attribute","icon":"🌳","badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01H6P8SSY4C141YH7BAC1RW7KJ","categoryName":"crisis-support-community","categoryDisplay":"Crisis Support Community","attributeId":"attr_01H6P8T277D0C8HFQA6N09FJWD","attributeName":"general-lgbtq","attributeKey":"crisis-support-community.general-lgbtq","attributeNs":"attribute","icon":"🏳️‍🌈","badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01H6P8SSY4C141YH7BAC1RW7KJ","categoryName":"crisis-support-community","categoryDisplay":"Crisis Support Community","attributeId":"attr_01GW2HHFVQCZPA3Z5GW6J3MQHW","attributeName":"lgbtq-youth-focus","attributeKey":"srvfocus.lgbtq-youth-focus","attributeNs":"attribute","icon":"🌱","badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01H6P8SSY4C141YH7BAC1RW7KJ","categoryName":"crisis-support-community","categoryDisplay":"Crisis Support Community","attributeId":"attr_01GW2HHFVPSYBCYF37B44WP6CZ","attributeName":"trans-comm","attributeKey":"srvfocus.trans-comm","attributeNs":"attribute","icon":"🏳️‍⚧️","badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01GW2HHFVGHPW1Y72SA8377623","categoryName":"eligibility-requirements","categoryDisplay":"Eligibility Requirements","attributeId":"attr_01GW2HHFVGSAZXGR4JAVHEK6ZC","attributeName":"elig-age","attributeKey":"eligibility.elig-age","attributeNs":"attribute","interpolationValues":{"max":"Under{{max}}","min":"{{min}} and older","range":"{{min}} -{{max}}"},"badgeRender":"LIST","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":true,"dataSchemaName":"numMinMaxOrRange","dataSchema":{"anyOf":[{"type":"object","required":["min"],"properties":{"min":{"type":"number"}}},{"type":"object","required":["max"],"properties":{"max":{"type":"number"}}},{"type":"object","required":["min","max"],"properties":{"max":{"type":"number"},"min":{"type":"number"}}}],"$schema":"http://json-schema.org/draft-07/schema#"}},{"categoryId":"attc_01GW2HHFVGHPW1Y72SA8377623","categoryName":"eligibility-requirements","categoryDisplay":"Eligibility Requirements","attributeId":"attr_01GW2HHFVJDKVF1HV7559CNZCY","attributeName":"other-describe","attributeKey":"eligibility.other-describe","attributeNs":"attribute","badgeRender":"LIST","requireText":true,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01GW2HHFVGHPW1Y72SA8377623","categoryName":"eligibility-requirements","categoryDisplay":"Eligibility Requirements","attributeId":"attr_01GW2HHFVH9DPBZ968VXGE50E7","attributeName":"req-medical-insurance","attributeKey":"eligibility.req-medical-insurance","attributeNs":"attribute","badgeRender":"LIST","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01GW2HHFVGHPW1Y72SA8377623","categoryName":"eligibility-requirements","categoryDisplay":"Eligibility Requirements","attributeId":"attr_01GW2HHFVHZ599M48CMSPGDCSC","attributeName":"req-photo-id","attributeKey":"eligibility.req-photo-id","attributeNs":"attribute","badgeRender":"LIST","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01GW2HHFVGHPW1Y72SA8377623","categoryName":"eligibility-requirements","categoryDisplay":"Eligibility Requirements","attributeId":"attr_01GW2HHFVH0GQK0GAJR5D952V3","attributeName":"req-proof-of-age","attributeKey":"eligibility.req-proof-of-age","attributeNs":"attribute","badgeRender":"LIST","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01GW2HHFVGHPW1Y72SA8377623","categoryName":"eligibility-requirements","categoryDisplay":"Eligibility Requirements","attributeId":"attr_01GW2HHFVHEVX4PMNN077ASQMG","attributeName":"req-proof-of-income","attributeKey":"eligibility.req-proof-of-income","attributeNs":"attribute","badgeRender":"LIST","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01GW2HHFVGHPW1Y72SA8377623","categoryName":"eligibility-requirements","categoryDisplay":"Eligibility Requirements","attributeId":"attr_01GW2HHFVHGMVCAY1G5BWF1PFB","attributeName":"req-proof-of-residence","attributeKey":"eligibility.req-proof-of-residence","attributeNs":"attribute","badgeRender":"LIST","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01GW2HHFVGHPW1Y72SA8377623","categoryName":"eligibility-requirements","categoryDisplay":"Eligibility Requirements","attributeId":"attr_01GW2HHFVJH8MADHYTHBV54CER","attributeName":"req-referral","attributeKey":"eligibility.req-referral","attributeNs":"attribute","badgeRender":"LIST","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01GW2HHFVGHPW1Y72SA8377623","categoryName":"eligibility-requirements","categoryDisplay":"Eligibility Requirements","attributeId":"attr_01GW2HHFVGJ5GD2WHNJDPSFNRW","attributeName":"time-appointment-required","attributeKey":"eligibility.time-appointment-required","attributeNs":"attribute","badgeRender":"LIST","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01GW2HHFVJQQ68XGSBXM976BDF","categoryName":"languages","categoryDisplay":"Languages","attributeId":"attr_01GW2HHFVJGDDWTR5D0C8BY357","attributeName":"all-languages-by-interpreter","attributeKey":"lang.all-languages-by-interpreter","attributeNs":"attribute","badgeRender":"LIST","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01GW2HHFVJQQ68XGSBXM976BDF","categoryName":"languages","categoryDisplay":"Languages","attributeId":"attr_01GW2HHFVJF09GXY5N5CKMSANJ","attributeName":"american-sign-language","attributeKey":"lang.american-sign-language","attributeNs":"attribute","badgeRender":"LIST","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01GW2HHFVJQQ68XGSBXM976BDF","categoryName":"languages","categoryDisplay":"Languages","attributeId":"attr_01GW2HHFVJ8K180CNX339BTXM2","attributeName":"lang-offered","attributeKey":"lang.lang-offered","attributeNs":"attribute","badgeRender":"LIST","requireText":false,"requireLanguage":true,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01GW2HHFVRSN3W3GYZZ43WCW24","categoryName":"law-practice-options","categoryDisplay":"Law Practice Options","attributeId":"attr_01GW2HHFVRH531R2HAV8DMDZSC","attributeName":"corp-law-firm","attributeKey":"userlawpractice.corp-law-firm","attributeNs":"attribute","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01GW2HHFVRSN3W3GYZZ43WCW24","categoryName":"law-practice-options","categoryDisplay":"Law Practice Options","attributeId":"attr_01GW2HHFVSE2074QZJ4SKEW74J","attributeName":"law-other","attributeKey":"userlawpractice.law-other","attributeNs":"attribute","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":true,"dataSchemaName":"otherDescribe","dataSchema":{"type":"object","$schema":"http://json-schema.org/draft-07/schema#","required":["other"],"properties":{"other":{"type":"string"}}}},{"categoryId":"attc_01GW2HHFVRSN3W3GYZZ43WCW24","categoryName":"law-practice-options","categoryDisplay":"Law Practice Options","attributeId":"attr_01GW2HHFVRS8XEJ3TJBBEQJ707","attributeName":"law-school-clinic","attributeKey":"userlawpractice.law-school-clinic","attributeNs":"attribute","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01GW2HHFVRSN3W3GYZZ43WCW24","categoryName":"law-practice-options","categoryDisplay":"Law Practice Options","attributeId":"attr_01GW2HHFVRFPRQCQHNJA6BM3XP","attributeName":"legal-nonprofit","attributeKey":"userlawpractice.legal-nonprofit","attributeNs":"attribute","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01GW2HHFVMNHV2ZS5875JWCRJ7","categoryName":"organization-leadership","categoryDisplay":"Organization Leadership","attributeId":"attr_01GW2HHFVNPKMHYK12DDRVC1VJ","attributeName":"bipoc-led","attributeKey":"orgleader.bipoc-led","attributeNs":"attribute","icon":"🤎","iconBg":"#F1DD7F","badgeRender":"LEADER","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01GW2HHFVMNHV2ZS5875JWCRJ7","categoryName":"organization-leadership","categoryDisplay":"Organization Leadership","attributeId":"attr_01GW2HHFVN3JX2J7REFFT5NAMS","attributeName":"black-led","attributeKey":"orgleader.black-led","attributeNs":"attribute","icon":"️‍️‍✊🏿","iconBg":"#C77E54","badgeRender":"LEADER","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01GW2HHFVMNHV2ZS5875JWCRJ7","categoryName":"organization-leadership","categoryDisplay":"Organization Leadership","attributeId":"attr_01GW2HHFVNHMF72WHVKRF6W4TA","attributeName":"immigrant-led","attributeKey":"orgleader.immigrant-led","attributeNs":"attribute","icon":"️‍️‍🌎","iconBg":"#79ADD7","badgeRender":"LEADER","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01GW2HHFVMNHV2ZS5875JWCRJ7","categoryName":"organization-leadership","categoryDisplay":"Organization Leadership","attributeId":"attr_01GW2HHFVN3RYX9JMXDZSQZM70","attributeName":"trans-led","attributeKey":"orgleader.trans-led","attributeNs":"attribute","icon":"️‍🏳️‍⚧️","iconBg":"#705890","badgeRender":"LEADER","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01GW2HHFVKAMMGPD71H90XRJ38","categoryName":"service-access-instructions","categoryDisplay":"Service Access Instructions","attributeId":"attr_01GW2HHFVKFM4TDY4QRK4AR2ZW","attributeName":"accessemail","attributeKey":"serviceaccess.accessemail","attributeNs":"attribute","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":true,"dataSchemaName":"accessInstructions","dataSchema":{"type":"object","$schema":"http://json-schema.org/draft-07/schema#","required":["access_type","instructions"],"properties":{"access_type":{"enum":["email","file","link","location","other","phone"],"type":"string"},"access_value":{"type":["string","null"]},"instructions":{"type":"string"}}}},{"categoryId":"attc_01GW2HHFVKAMMGPD71H90XRJ38","categoryName":"service-access-instructions","categoryDisplay":"Service Access Instructions","attributeId":"attr_01GW2HHFVKMRHFD8SMDAZM3SSM","attributeName":"accessfile","attributeKey":"serviceaccess.accessfile","attributeNs":"attribute","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":true,"dataSchemaName":"accessInstructions","dataSchema":{"type":"object","$schema":"http://json-schema.org/draft-07/schema#","required":["access_type","instructions"],"properties":{"access_type":{"enum":["email","file","link","location","other","phone"],"type":"string"},"access_value":{"type":["string","null"]},"instructions":{"type":"string"}}}},{"categoryId":"attc_01GW2HHFVKAMMGPD71H90XRJ38","categoryName":"service-access-instructions","categoryDisplay":"Service Access Instructions","attributeId":"attr_01GW2HHFVMYXMS8ARA3GE7HZFD","attributeName":"accesslink","attributeKey":"serviceaccess.accesslink","attributeNs":"attribute","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":true,"dataSchemaName":"accessInstructions","dataSchema":{"type":"object","$schema":"http://json-schema.org/draft-07/schema#","required":["access_type","instructions"],"properties":{"access_type":{"enum":["email","file","link","location","other","phone"],"type":"string"},"access_value":{"type":["string","null"]},"instructions":{"type":"string"}}}},{"categoryId":"attc_01GW2HHFVKAMMGPD71H90XRJ38","categoryName":"service-access-instructions","categoryDisplay":"Service Access Instructions","attributeId":"attr_01GW2HHFVMH6AE94EXN7T5A87C","attributeName":"accesslocation","attributeKey":"serviceaccess.accesslocation","attributeNs":"attribute","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":true,"dataSchemaName":"accessInstructions","dataSchema":{"type":"object","$schema":"http://json-schema.org/draft-07/schema#","required":["access_type","instructions"],"properties":{"access_type":{"enum":["email","file","link","location","other","phone"],"type":"string"},"access_value":{"type":["string","null"]},"instructions":{"type":"string"}}}},{"categoryId":"attc_01GW2HHFVKAMMGPD71H90XRJ38","categoryName":"service-access-instructions","categoryDisplay":"Service Access Instructions","attributeId":"attr_01GW2HHFVMKTFWCKBVVFJ5GMY0","attributeName":"accessphone","attributeKey":"serviceaccess.accessphone","attributeNs":"attribute","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":true,"dataSchemaName":"accessInstructions","dataSchema":{"type":"object","$schema":"http://json-schema.org/draft-07/schema#","required":["access_type","instructions"],"properties":{"access_type":{"enum":["email","file","link","location","other","phone"],"type":"string"},"access_value":{"type":["string","null"]},"instructions":{"type":"string"}}}},{"categoryId":"attc_01GW2HHFVKAMMGPD71H90XRJ38","categoryName":"service-access-instructions","categoryDisplay":"Service Access Instructions","attributeId":"attr_01GW2HHFVMSX7T1WDNZ5QEHKWT","attributeName":"accesspublictransit","attributeKey":"serviceaccess.accesspublictransit","attributeNs":"attribute","requireText":true,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01GW2HHFVKAMMGPD71H90XRJ38","categoryName":"service-access-instructions","categoryDisplay":"Service Access Instructions","attributeId":"attr_01GW2HHFVMMF19AX2KPBTMV6P3","attributeName":"accesstext","attributeKey":"serviceaccess.accesstext","attributeNs":"attribute","requireText":true,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01GW2HHFVNXMNJNV47BF2BPM1R","categoryName":"service-focus","categoryDisplay":"Service Focus","attributeId":"attr_01GW2HHFVPCVX8F3B7M30ZJEHW","attributeName":"asylum-seekers","attributeKey":"srvfocus.asylum-seekers","attributeNs":"attribute","icon":"️‍️‍🌎","badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01GW2HHFVNXMNJNV47BF2BPM1R","categoryName":"service-focus","categoryDisplay":"Service Focus","attributeId":"attr_01GW2HHFVN72D7XEBZZJXCJQXQ","attributeName":"bipoc-comm","attributeKey":"srvfocus.bipoc-comm","attributeNs":"attribute","icon":"️‍️‍✊🏿","badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01GW2HHFVNXMNJNV47BF2BPM1R","categoryName":"service-focus","categoryDisplay":"Service Focus","attributeId":"attr_01GW2HHFVQ7SYGD3KM8WP9X50B","attributeName":"gender-nc","attributeKey":"srvfocus.gender-nc","attributeNs":"attribute","icon":"🏳️‍⚧️","badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01GW2HHFVNXMNJNV47BF2BPM1R","categoryName":"service-focus","categoryDisplay":"Service Focus","attributeId":"attr_01GW2HHFVRMQFJ9AMA633SQQGV","attributeName":"hiv-comm","attributeKey":"srvfocus.hiv-comm","attributeNs":"attribute","icon":"💛","badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01GW2HHFVNXMNJNV47BF2BPM1R","categoryName":"service-focus","categoryDisplay":"Service Focus","attributeId":"attr_01GW2HHFVPTK9555WHJHDBDA2J","attributeName":"immigrant-comm","attributeKey":"srvfocus.immigrant-comm","attributeNs":"attribute","icon":"️‍️‍🌎","badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01GW2HHFVNXMNJNV47BF2BPM1R","categoryName":"service-focus","categoryDisplay":"Service Focus","attributeId":"attr_01GW2HHFVQCZPA3Z5GW6J3MQHW","attributeName":"lgbtq-youth-focus","attributeKey":"srvfocus.lgbtq-youth-focus","attributeNs":"attribute","icon":"🌱","badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01GW2HHFVNXMNJNV47BF2BPM1R","categoryName":"service-focus","categoryDisplay":"Service Focus","attributeId":"attr_01GW2HHFVPJERY0GS9D7F56A23","attributeName":"resettled-refugees","attributeKey":"srvfocus.resettled-refugees","attributeNs":"attribute","icon":"️‍️‍🌎","badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01GW2HHFVNXMNJNV47BF2BPM1R","categoryName":"service-focus","categoryDisplay":"Service Focus","attributeId":"attr_01GW2HHFVQ8AGBKBBZJWTHNP2F","attributeName":"spanish-speakers","attributeKey":"srvfocus.spanish-speakers","attributeNs":"attribute","icon":"🗣️","badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01GW2HHFVNXMNJNV47BF2BPM1R","categoryName":"service-focus","categoryDisplay":"Service Focus","attributeId":"attr_01GW2HHFVPSYBCYF37B44WP6CZ","attributeName":"trans-comm","attributeKey":"srvfocus.trans-comm","attributeNs":"attribute","icon":"🏳️‍⚧️","badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01GW2HHFVNXMNJNV47BF2BPM1R","categoryName":"service-focus","categoryDisplay":"Service Focus","attributeId":"attr_01GW2HHFVQX4M8DY1FSAYSJSSK","attributeName":"trans-fem","attributeKey":"srvfocus.trans-fem","attributeNs":"attribute","icon":"🏳️‍⚧️","badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01GW2HHFVNXMNJNV47BF2BPM1R","categoryName":"service-focus","categoryDisplay":"Service Focus","attributeId":"attr_01GW2HHFVQEFWW42MBAD64BWXZ","attributeName":"trans-masc","attributeKey":"srvfocus.trans-masc","attributeNs":"attribute","icon":"🏳️‍⚧️","badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01GW2HHFVNXMNJNV47BF2BPM1R","categoryName":"service-focus","categoryDisplay":"Service Focus","attributeId":"attr_01GW2HHFVQVEGH6W3A2ANH1QZE","attributeName":"trans-youth-focus","attributeKey":"srvfocus.trans-youth-focus","attributeNs":"attribute","icon":"🌱","badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01GW2HHFVSQWE2Y2RF3DT2VEYX","categoryName":"service-provider-options","categoryDisplay":"Service Provider Options","attributeId":"attr_01H2TK83N5E52PPP828SD88KP8","attributeName":"userserviceprovider.case-mananger","attributeKey":"userserviceprovider.case-mananger","attributeNs":"attribute","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01GW2HHFVSQWE2Y2RF3DT2VEYX","categoryName":"service-provider-options","categoryDisplay":"Service Provider Options","attributeId":"attr_01GW2HHFVTTZ83PZR61M37R8R7","attributeName":"userserviceprovider.community-org","attributeKey":"userserviceprovider.community-org","attributeNs":"attribute","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01GW2HHFVSQWE2Y2RF3DT2VEYX","categoryName":"service-provider-options","categoryDisplay":"Service Provider Options","attributeId":"attr_01GW2HHFVSPXWJJPFG9DKXESEK","attributeName":"userserviceprovider.healthcare","attributeKey":"userserviceprovider.healthcare","attributeNs":"attribute","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01GW2HHFVSQWE2Y2RF3DT2VEYX","categoryName":"service-provider-options","categoryDisplay":"Service Provider Options","attributeId":"attr_01H2TM092CFVG6H0MR148AVAP7","attributeName":"userserviceprovider.lawyer","attributeKey":"userserviceprovider.lawyer","attributeNs":"attribute","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01GW2HHFVSQWE2Y2RF3DT2VEYX","categoryName":"service-provider-options","categoryDisplay":"Service Provider Options","attributeId":"attr_01H2TM0AJHVK8TSR8JNFANFNZ7","attributeName":"userserviceprovider.other","attributeKey":"userserviceprovider.other","attributeNs":"attribute","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01GW2HHFVSQWE2Y2RF3DT2VEYX","categoryName":"service-provider-options","categoryDisplay":"Service Provider Options","attributeId":"attr_01H2TM09EG0G84NXH40G5TESB5","attributeName":"userserviceprovider.paralegal","attributeKey":"userserviceprovider.paralegal","attributeNs":"attribute","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01GW2HHFVSQWE2Y2RF3DT2VEYX","categoryName":"service-provider-options","categoryDisplay":"Service Provider Options","attributeId":"attr_01H2TM09RAK024ZDZQ6FSY0TXB","attributeName":"userserviceprovider.social-worker","attributeKey":"userserviceprovider.social-worker","attributeNs":"attribute","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01GW2HHFVSQWE2Y2RF3DT2VEYX","categoryName":"service-provider-options","categoryDisplay":"Service Provider Options","attributeId":"attr_01GW2HHFVTN6MSCMBW740Y7HN1","attributeName":"userserviceprovider.student-club","attributeKey":"userserviceprovider.student-club","attributeNs":"attribute","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01GW2HHFVSQWE2Y2RF3DT2VEYX","categoryName":"service-provider-options","categoryDisplay":"Service Provider Options","attributeId":"attr_01H2TM0A19DD6S97DNH76ZVP40","attributeName":"userserviceprovider.teacher","attributeKey":"userserviceprovider.teacher","attributeNs":"attribute","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01GW2HHFVSQWE2Y2RF3DT2VEYX","categoryName":"service-provider-options","categoryDisplay":"Service Provider Options","attributeId":"attr_01H2TM0AA4CZXJJHMXHE1PHMVV","attributeName":"userserviceprovider.therapist-counselor","attributeKey":"userserviceprovider.therapist-counselor","attributeNs":"attribute","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false},{"categoryId":"attc_01GW2HHFVKM2PSHFWVFM0TWX1P","categoryName":"system","categoryDisplay":"System","attributeId":"attr_01GW2HHFVK8KPRGKYFSSM5ECPQ","attributeName":"incompatible-info","attributeKey":"sys.incompatible-info","attributeNs":"attribute","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":true,"dataSchemaName":"incompatibleData","dataSchema":{"type":"array","items":{"type":"object","additionalProperties":{}},"$schema":"http://json-schema.org/draft-07/schema#"}}] \ No newline at end of file +[{"categoryId":"attc_01GW2HHFV3DJ380F351SKB0B74","categoryName":"additional-information","categoryDisplay":"Additional Information","attributeId":"attr_01GW2HHFV3YJ2AWADHVKG79BQ0","attributeName":"at-capacity","attributeKey":"additional.at-capacity","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":"ATTRIBUTE","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"dataSchema":null},{"categoryId":"attc_01GW2HHFV3DJ380F351SKB0B74","categoryName":"additional-information","categoryDisplay":"Additional Information","attributeId":"attr_01GW2HHFV4D5ZHFMAE7852GB4P","attributeName":"geo-near-public-transit","attributeKey":"additional.geo-near-public-transit","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":"ATTRIBUTE","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"dataSchema":null},{"categoryId":"attc_01GW2HHFV3DJ380F351SKB0B74","categoryName":"additional-information","categoryDisplay":"Additional Information","attributeId":"attr_01GW2HHFV48VQJBMFA05QCBBV9","attributeName":"geo-public-transit-description","attributeKey":"additional.geo-public-transit-description","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":"ATTRIBUTE","requireText":true,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"dataSchema":null},{"categoryId":"attc_01GW2HHFV3DJ380F351SKB0B74","categoryName":"additional-information","categoryDisplay":"Additional Information","attributeId":"attr_01GW2HHFV3BADK80TG0DXXFPMM","attributeName":"has-confidentiality-policy","attributeKey":"additional.has-confidentiality-policy","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":"ATTRIBUTE","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"dataSchema":null},{"categoryId":"attc_01GW2HHFV3DJ380F351SKB0B74","categoryName":"additional-information","categoryDisplay":"Additional Information","attributeId":"attr_01GW2HHFV5Q7XN2ZNTYFR1AD3M","attributeName":"offers-remote-services","attributeKey":"additional.offers-remote-services","attributeNs":"attribute","interpolationValues":null,"icon":"carbon:globe","iconBg":null,"badgeRender":"ATTRIBUTE","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["ORGANIZATION","LOCATION"],"dataSchema":null},{"categoryId":"attc_01GW2HHFV3DJ380F351SKB0B74","categoryName":"additional-information","categoryDisplay":"Additional Information","attributeId":"attr_01GW2HHFV4TM7H5V6FHWA7S9JK","attributeName":"time-walk-in","attributeKey":"additional.time-walk-in","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":"ATTRIBUTE","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"dataSchema":null},{"categoryId":"attc_01GW2HHFV3DJ380F351SKB0B74","categoryName":"additional-information","categoryDisplay":"Additional Information","attributeId":"attr_01GW2HHFV5FYXQNGTPAQB7G2TF","attributeName":"wheelchair-accessible","attributeKey":"additional.wheelchair-accessible","attributeNs":"attribute","interpolationValues":{"true":"Accessible","false":"Not Accessible"},"icon":"carbon:accessibility","iconBg":null,"badgeRender":"ATTRIBUTE","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":true,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"dataSchema":null},{"categoryId":"attc_01GYSVX1N9T91BJYSHRDPCHJBS","categoryName":"alerts","categoryDisplay":"Alerts","attributeId":"attr_01GYSVX1NAMR6RDV6M69H4KN3T","attributeName":"info","attributeKey":"alerts.info","attributeNs":"attribute","interpolationValues":null,"icon":"carbon:information-filled","iconBg":null,"badgeRender":null,"requireText":true,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"dataSchema":null},{"categoryId":"attc_01GYSVX1N9T91BJYSHRDPCHJBS","categoryName":"alerts","categoryDisplay":"Alerts","attributeId":"attr_01GYSVX1NAKP7C6JKJ342ZM35M","attributeName":"warn","attributeKey":"alerts.warn","attributeNs":"attribute","interpolationValues":null,"icon":"carbon:warning-filled","iconBg":null,"badgeRender":null,"requireText":true,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVFKNMYPN8F86M0H576","categoryName":"cost","categoryDisplay":"Cost","attributeId":"attr_01GW2HHFVGWKWB53HWAAHQ9AAZ","attributeName":"cost-fees","attributeKey":"cost.cost-fees","attributeNs":"attribute","interpolationValues":null,"icon":"carbon:piggy-bank","iconBg":null,"badgeRender":"ATTRIBUTE","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":true,"dataSchemaName":"numMinMaxOrRange","canAttachTo":["SERVICE"],"dataSchema":[{"key":"min","label":"Min","name":"min","type":"number"},{"key":"max","label":"Max","name":"max","type":"number"}]},{"categoryId":"attc_01GW2HHFVFKNMYPN8F86M0H576","categoryName":"cost","categoryDisplay":"Cost","attributeId":"attr_01GW2HHFVGDTNW9PDQNXK6TF1T","attributeName":"cost-free","attributeKey":"cost.cost-free","attributeNs":"attribute","interpolationValues":null,"icon":"carbon:piggy-bank","iconBg":null,"badgeRender":"ATTRIBUTE","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE"],"dataSchema":null},{"categoryId":"attc_01H6P8SSY4C141YH7BAC1RW7KJ","categoryName":"crisis-support-community","categoryDisplay":"Crisis Support Community","attributeId":"attr_01GW2HHFVN72D7XEBZZJXCJQXQ","attributeName":"bipoc-comm","attributeKey":"srvfocus.bipoc-comm","attributeNs":"attribute","interpolationValues":null,"icon":"️‍️‍✊🏿","iconBg":null,"badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION","SERVICE"],"dataSchema":null},{"categoryId":"attc_01H6P8SSY4C141YH7BAC1RW7KJ","categoryName":"crisis-support-community","categoryDisplay":"Crisis Support Community","attributeId":"attr_01H6P951P0V3CR807P8KRH82S1","attributeName":"elders","attributeKey":"crisis-support-community.elders","attributeNs":"attribute","interpolationValues":null,"icon":"🌳","iconBg":null,"badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"dataSchema":null},{"categoryId":"attc_01H6P8SSY4C141YH7BAC1RW7KJ","categoryName":"crisis-support-community","categoryDisplay":"Crisis Support Community","attributeId":"attr_01H6P8T277D0C8HFQA6N09FJWD","attributeName":"general-lgbtq","attributeKey":"crisis-support-community.general-lgbtq","attributeNs":"attribute","interpolationValues":null,"icon":"🏳️‍🌈","iconBg":null,"badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"dataSchema":null},{"categoryId":"attc_01H6P8SSY4C141YH7BAC1RW7KJ","categoryName":"crisis-support-community","categoryDisplay":"Crisis Support Community","attributeId":"attr_01GW2HHFVQCZPA3Z5GW6J3MQHW","attributeName":"lgbtq-youth-focus","attributeKey":"srvfocus.lgbtq-youth-focus","attributeNs":"attribute","interpolationValues":null,"icon":"🌱","iconBg":null,"badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION","SERVICE"],"dataSchema":null},{"categoryId":"attc_01H6P8SSY4C141YH7BAC1RW7KJ","categoryName":"crisis-support-community","categoryDisplay":"Crisis Support Community","attributeId":"attr_01GW2HHFVPSYBCYF37B44WP6CZ","attributeName":"trans-comm","attributeKey":"srvfocus.trans-comm","attributeNs":"attribute","interpolationValues":null,"icon":"🏳️‍⚧️","iconBg":null,"badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION","SERVICE"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVGHPW1Y72SA8377623","categoryName":"eligibility-requirements","categoryDisplay":"Eligibility Requirements","attributeId":"attr_01GW2HHFVGSAZXGR4JAVHEK6ZC","attributeName":"elig-age","attributeKey":"eligibility.elig-age","attributeNs":"attribute","interpolationValues":{"max":"Under{{max}}","min":"{{min}} and older","range":"{{min}} -{{max}}"},"icon":null,"iconBg":null,"badgeRender":"LIST","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":true,"dataSchemaName":"numMinMaxOrRange","canAttachTo":["SERVICE"],"dataSchema":[{"key":"min","label":"Min","name":"min","type":"number"},{"key":"max","label":"Max","name":"max","type":"number"}]},{"categoryId":"attc_01GW2HHFVGHPW1Y72SA8377623","categoryName":"eligibility-requirements","categoryDisplay":"Eligibility Requirements","attributeId":"attr_01GW2HHFVJDKVF1HV7559CNZCY","attributeName":"other-describe","attributeKey":"eligibility.other-describe","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":"LIST","requireText":true,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVGHPW1Y72SA8377623","categoryName":"eligibility-requirements","categoryDisplay":"Eligibility Requirements","attributeId":"attr_01GW2HHFVH9DPBZ968VXGE50E7","attributeName":"req-medical-insurance","attributeKey":"eligibility.req-medical-insurance","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":"LIST","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVGHPW1Y72SA8377623","categoryName":"eligibility-requirements","categoryDisplay":"Eligibility Requirements","attributeId":"attr_01GW2HHFVHZ599M48CMSPGDCSC","attributeName":"req-photo-id","attributeKey":"eligibility.req-photo-id","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":"LIST","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVGHPW1Y72SA8377623","categoryName":"eligibility-requirements","categoryDisplay":"Eligibility Requirements","attributeId":"attr_01GW2HHFVH0GQK0GAJR5D952V3","attributeName":"req-proof-of-age","attributeKey":"eligibility.req-proof-of-age","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":"LIST","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVGHPW1Y72SA8377623","categoryName":"eligibility-requirements","categoryDisplay":"Eligibility Requirements","attributeId":"attr_01GW2HHFVHEVX4PMNN077ASQMG","attributeName":"req-proof-of-income","attributeKey":"eligibility.req-proof-of-income","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":"LIST","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVGHPW1Y72SA8377623","categoryName":"eligibility-requirements","categoryDisplay":"Eligibility Requirements","attributeId":"attr_01GW2HHFVHGMVCAY1G5BWF1PFB","attributeName":"req-proof-of-residence","attributeKey":"eligibility.req-proof-of-residence","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":"LIST","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVGHPW1Y72SA8377623","categoryName":"eligibility-requirements","categoryDisplay":"Eligibility Requirements","attributeId":"attr_01GW2HHFVJH8MADHYTHBV54CER","attributeName":"req-referral","attributeKey":"eligibility.req-referral","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":"LIST","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVGHPW1Y72SA8377623","categoryName":"eligibility-requirements","categoryDisplay":"Eligibility Requirements","attributeId":"attr_01GW2HHFVGJ5GD2WHNJDPSFNRW","attributeName":"time-appointment-required","attributeKey":"eligibility.time-appointment-required","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":"LIST","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVJQQ68XGSBXM976BDF","categoryName":"languages","categoryDisplay":"Languages","attributeId":"attr_01GW2HHFVJGDDWTR5D0C8BY357","attributeName":"all-languages-by-interpreter","attributeKey":"lang.all-languages-by-interpreter","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":"LIST","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVJQQ68XGSBXM976BDF","categoryName":"languages","categoryDisplay":"Languages","attributeId":"attr_01GW2HHFVJF09GXY5N5CKMSANJ","attributeName":"american-sign-language","attributeKey":"lang.american-sign-language","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":"LIST","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVJQQ68XGSBXM976BDF","categoryName":"languages","categoryDisplay":"Languages","attributeId":"attr_01GW2HHFVJ8K180CNX339BTXM2","attributeName":"lang-offered","attributeKey":"lang.lang-offered","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":"LIST","requireText":false,"requireLanguage":true,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVRSN3W3GYZZ43WCW24","categoryName":"law-practice-options","categoryDisplay":"Law Practice Options","attributeId":"attr_01GW2HHFVRH531R2HAV8DMDZSC","attributeName":"corp-law-firm","attributeKey":"userlawpractice.corp-law-firm","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["USER"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVRSN3W3GYZZ43WCW24","categoryName":"law-practice-options","categoryDisplay":"Law Practice Options","attributeId":"attr_01GW2HHFVSE2074QZJ4SKEW74J","attributeName":"law-other","attributeKey":"userlawpractice.law-other","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":true,"dataSchemaName":"otherDescribe","canAttachTo":["USER"],"dataSchema":[{"key":"other","label":"Other","name":"other","type":"text"}]},{"categoryId":"attc_01GW2HHFVRSN3W3GYZZ43WCW24","categoryName":"law-practice-options","categoryDisplay":"Law Practice Options","attributeId":"attr_01GW2HHFVRS8XEJ3TJBBEQJ707","attributeName":"law-school-clinic","attributeKey":"userlawpractice.law-school-clinic","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["USER"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVRSN3W3GYZZ43WCW24","categoryName":"law-practice-options","categoryDisplay":"Law Practice Options","attributeId":"attr_01GW2HHFVRFPRQCQHNJA6BM3XP","attributeName":"legal-nonprofit","attributeKey":"userlawpractice.legal-nonprofit","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["USER"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVMNHV2ZS5875JWCRJ7","categoryName":"organization-leadership","categoryDisplay":"Organization Leadership","attributeId":"attr_01GW2HHFVNPKMHYK12DDRVC1VJ","attributeName":"bipoc-led","attributeKey":"orgleader.bipoc-led","attributeNs":"attribute","interpolationValues":null,"icon":"🤎","iconBg":"#F1DD7F","badgeRender":"LEADER","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVMNHV2ZS5875JWCRJ7","categoryName":"organization-leadership","categoryDisplay":"Organization Leadership","attributeId":"attr_01GW2HHFVN3JX2J7REFFT5NAMS","attributeName":"black-led","attributeKey":"orgleader.black-led","attributeNs":"attribute","interpolationValues":null,"icon":"️‍️‍✊🏿","iconBg":"#C77E54","badgeRender":"LEADER","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVMNHV2ZS5875JWCRJ7","categoryName":"organization-leadership","categoryDisplay":"Organization Leadership","attributeId":"attr_01GW2HHFVNHMF72WHVKRF6W4TA","attributeName":"immigrant-led","attributeKey":"orgleader.immigrant-led","attributeNs":"attribute","interpolationValues":null,"icon":"️‍️‍🌎","iconBg":"#79ADD7","badgeRender":"LEADER","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVMNHV2ZS5875JWCRJ7","categoryName":"organization-leadership","categoryDisplay":"Organization Leadership","attributeId":"attr_01GW2HHFVN3RYX9JMXDZSQZM70","attributeName":"trans-led","attributeKey":"orgleader.trans-led","attributeNs":"attribute","interpolationValues":null,"icon":"️‍🏳️‍⚧️","iconBg":"#705890","badgeRender":"LEADER","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVKAMMGPD71H90XRJ38","categoryName":"service-access-instructions","categoryDisplay":"Service Access Instructions","attributeId":"attr_01GW2HHFVKFM4TDY4QRK4AR2ZW","attributeName":"accessemail","attributeKey":"serviceaccess.accessemail","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":true,"dataSchemaName":"accessInstructions","canAttachTo":["SERVICE"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVKAMMGPD71H90XRJ38","categoryName":"service-access-instructions","categoryDisplay":"Service Access Instructions","attributeId":"attr_01GW2HHFVKMRHFD8SMDAZM3SSM","attributeName":"accessfile","attributeKey":"serviceaccess.accessfile","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":true,"dataSchemaName":"accessInstructions","canAttachTo":["SERVICE"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVKAMMGPD71H90XRJ38","categoryName":"service-access-instructions","categoryDisplay":"Service Access Instructions","attributeId":"attr_01GW2HHFVMYXMS8ARA3GE7HZFD","attributeName":"accesslink","attributeKey":"serviceaccess.accesslink","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":true,"dataSchemaName":"accessInstructions","canAttachTo":["SERVICE"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVKAMMGPD71H90XRJ38","categoryName":"service-access-instructions","categoryDisplay":"Service Access Instructions","attributeId":"attr_01GW2HHFVMH6AE94EXN7T5A87C","attributeName":"accesslocation","attributeKey":"serviceaccess.accesslocation","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":true,"dataSchemaName":"accessInstructions","canAttachTo":["SERVICE"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVKAMMGPD71H90XRJ38","categoryName":"service-access-instructions","categoryDisplay":"Service Access Instructions","attributeId":"attr_01GW2HHFVMKTFWCKBVVFJ5GMY0","attributeName":"accessphone","attributeKey":"serviceaccess.accessphone","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":true,"dataSchemaName":"accessInstructions","canAttachTo":["SERVICE"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVKAMMGPD71H90XRJ38","categoryName":"service-access-instructions","categoryDisplay":"Service Access Instructions","attributeId":"attr_01GW2HHFVMSX7T1WDNZ5QEHKWT","attributeName":"accesspublictransit","attributeKey":"serviceaccess.accesspublictransit","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":true,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVKAMMGPD71H90XRJ38","categoryName":"service-access-instructions","categoryDisplay":"Service Access Instructions","attributeId":"attr_01GW2HHFVMMF19AX2KPBTMV6P3","attributeName":"accesstext","attributeKey":"serviceaccess.accesstext","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":true,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVNXMNJNV47BF2BPM1R","categoryName":"service-focus","categoryDisplay":"Service Focus","attributeId":"attr_01GW2HHFVPCVX8F3B7M30ZJEHW","attributeName":"asylum-seekers","attributeKey":"srvfocus.asylum-seekers","attributeNs":"attribute","interpolationValues":null,"icon":"️‍️‍🌎","iconBg":null,"badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION","SERVICE"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVNXMNJNV47BF2BPM1R","categoryName":"service-focus","categoryDisplay":"Service Focus","attributeId":"attr_01GW2HHFVN72D7XEBZZJXCJQXQ","attributeName":"bipoc-comm","attributeKey":"srvfocus.bipoc-comm","attributeNs":"attribute","interpolationValues":null,"icon":"️‍️‍✊🏿","iconBg":null,"badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION","SERVICE"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVNXMNJNV47BF2BPM1R","categoryName":"service-focus","categoryDisplay":"Service Focus","attributeId":"attr_01GW2HHFVQ7SYGD3KM8WP9X50B","attributeName":"gender-nc","attributeKey":"srvfocus.gender-nc","attributeNs":"attribute","interpolationValues":null,"icon":"🏳️‍⚧️","iconBg":null,"badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION","SERVICE"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVNXMNJNV47BF2BPM1R","categoryName":"service-focus","categoryDisplay":"Service Focus","attributeId":"attr_01GW2HHFVRMQFJ9AMA633SQQGV","attributeName":"hiv-comm","attributeKey":"srvfocus.hiv-comm","attributeNs":"attribute","interpolationValues":null,"icon":"💛","iconBg":null,"badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION","SERVICE"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVNXMNJNV47BF2BPM1R","categoryName":"service-focus","categoryDisplay":"Service Focus","attributeId":"attr_01GW2HHFVPTK9555WHJHDBDA2J","attributeName":"immigrant-comm","attributeKey":"srvfocus.immigrant-comm","attributeNs":"attribute","interpolationValues":null,"icon":"️‍️‍🌎","iconBg":null,"badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION","SERVICE"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVNXMNJNV47BF2BPM1R","categoryName":"service-focus","categoryDisplay":"Service Focus","attributeId":"attr_01GW2HHFVQCZPA3Z5GW6J3MQHW","attributeName":"lgbtq-youth-focus","attributeKey":"srvfocus.lgbtq-youth-focus","attributeNs":"attribute","interpolationValues":null,"icon":"🌱","iconBg":null,"badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION","SERVICE"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVNXMNJNV47BF2BPM1R","categoryName":"service-focus","categoryDisplay":"Service Focus","attributeId":"attr_01GW2HHFVPJERY0GS9D7F56A23","attributeName":"resettled-refugees","attributeKey":"srvfocus.resettled-refugees","attributeNs":"attribute","interpolationValues":null,"icon":"️‍️‍🌎","iconBg":null,"badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION","SERVICE"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVNXMNJNV47BF2BPM1R","categoryName":"service-focus","categoryDisplay":"Service Focus","attributeId":"attr_01GW2HHFVQ8AGBKBBZJWTHNP2F","attributeName":"spanish-speakers","attributeKey":"srvfocus.spanish-speakers","attributeNs":"attribute","interpolationValues":null,"icon":"🗣️","iconBg":null,"badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION","SERVICE"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVNXMNJNV47BF2BPM1R","categoryName":"service-focus","categoryDisplay":"Service Focus","attributeId":"attr_01GW2HHFVPSYBCYF37B44WP6CZ","attributeName":"trans-comm","attributeKey":"srvfocus.trans-comm","attributeNs":"attribute","interpolationValues":null,"icon":"🏳️‍⚧️","iconBg":null,"badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION","SERVICE"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVNXMNJNV47BF2BPM1R","categoryName":"service-focus","categoryDisplay":"Service Focus","attributeId":"attr_01GW2HHFVQX4M8DY1FSAYSJSSK","attributeName":"trans-fem","attributeKey":"srvfocus.trans-fem","attributeNs":"attribute","interpolationValues":null,"icon":"🏳️‍⚧️","iconBg":null,"badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION","SERVICE"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVNXMNJNV47BF2BPM1R","categoryName":"service-focus","categoryDisplay":"Service Focus","attributeId":"attr_01GW2HHFVQEFWW42MBAD64BWXZ","attributeName":"trans-masc","attributeKey":"srvfocus.trans-masc","attributeNs":"attribute","interpolationValues":null,"icon":"🏳️‍⚧️","iconBg":null,"badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION","SERVICE"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVNXMNJNV47BF2BPM1R","categoryName":"service-focus","categoryDisplay":"Service Focus","attributeId":"attr_01GW2HHFVQVEGH6W3A2ANH1QZE","attributeName":"trans-youth-focus","attributeKey":"srvfocus.trans-youth-focus","attributeNs":"attribute","interpolationValues":null,"icon":"🌱","iconBg":null,"badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION","SERVICE"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVSQWE2Y2RF3DT2VEYX","categoryName":"service-provider-options","categoryDisplay":"Service Provider Options","attributeId":"attr_01H2TK83N5E52PPP828SD88KP8","attributeName":"userserviceprovider.case-mananger","attributeKey":"userserviceprovider.case-mananger","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["USER"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVSQWE2Y2RF3DT2VEYX","categoryName":"service-provider-options","categoryDisplay":"Service Provider Options","attributeId":"attr_01GW2HHFVTTZ83PZR61M37R8R7","attributeName":"userserviceprovider.community-org","attributeKey":"userserviceprovider.community-org","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["USER"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVSQWE2Y2RF3DT2VEYX","categoryName":"service-provider-options","categoryDisplay":"Service Provider Options","attributeId":"attr_01GW2HHFVSPXWJJPFG9DKXESEK","attributeName":"userserviceprovider.healthcare","attributeKey":"userserviceprovider.healthcare","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["USER"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVSQWE2Y2RF3DT2VEYX","categoryName":"service-provider-options","categoryDisplay":"Service Provider Options","attributeId":"attr_01H2TM092CFVG6H0MR148AVAP7","attributeName":"userserviceprovider.lawyer","attributeKey":"userserviceprovider.lawyer","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["USER"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVSQWE2Y2RF3DT2VEYX","categoryName":"service-provider-options","categoryDisplay":"Service Provider Options","attributeId":"attr_01H2TM0AJHVK8TSR8JNFANFNZ7","attributeName":"userserviceprovider.other","attributeKey":"userserviceprovider.other","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["USER"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVSQWE2Y2RF3DT2VEYX","categoryName":"service-provider-options","categoryDisplay":"Service Provider Options","attributeId":"attr_01H2TM09EG0G84NXH40G5TESB5","attributeName":"userserviceprovider.paralegal","attributeKey":"userserviceprovider.paralegal","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["USER"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVSQWE2Y2RF3DT2VEYX","categoryName":"service-provider-options","categoryDisplay":"Service Provider Options","attributeId":"attr_01H2TM09RAK024ZDZQ6FSY0TXB","attributeName":"userserviceprovider.social-worker","attributeKey":"userserviceprovider.social-worker","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["USER"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVSQWE2Y2RF3DT2VEYX","categoryName":"service-provider-options","categoryDisplay":"Service Provider Options","attributeId":"attr_01GW2HHFVTN6MSCMBW740Y7HN1","attributeName":"userserviceprovider.student-club","attributeKey":"userserviceprovider.student-club","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["USER"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVSQWE2Y2RF3DT2VEYX","categoryName":"service-provider-options","categoryDisplay":"Service Provider Options","attributeId":"attr_01H2TM0A19DD6S97DNH76ZVP40","attributeName":"userserviceprovider.teacher","attributeKey":"userserviceprovider.teacher","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["USER"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVSQWE2Y2RF3DT2VEYX","categoryName":"service-provider-options","categoryDisplay":"Service Provider Options","attributeId":"attr_01H2TM0AA4CZXJJHMXHE1PHMVV","attributeName":"userserviceprovider.therapist-counselor","attributeKey":"userserviceprovider.therapist-counselor","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["USER"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVKM2PSHFWVFM0TWX1P","categoryName":"system","categoryDisplay":"System","attributeId":"attr_01GW2HHFVK8KPRGKYFSSM5ECPQ","attributeName":"incompatible-info","attributeKey":"sys.incompatible-info","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":true,"dataSchemaName":"incompatibleData","canAttachTo":["LOCATION","ORGANIZATION","SERVICE","USER"],"dataSchema":[{"key":"incompatible","label":"Incompatible","name":"incompatible","type":"text"}]},{"categoryId":"attc_01HNG5BPYJADWX4YFVNENS3TRD","categoryName":"target-population","categoryDisplay":"Target Population","attributeId":"attr_01HNG5GDC5MXW30F32FWJNJ98C","attributeName":"tpop-other","attributeKey":"tpop.other","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":true,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE"],"dataSchema":null}] From ad8d544bb602abc8564f1508bbaf0442600db916 Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Thu, 15 Feb 2024 15:46:52 -0500 Subject: [PATCH 12/61] update api --- .../query.attributesByCategory.handler.ts | 37 ++++++------- .../query.attributesByCategory.schema.ts | 53 +++++++++++++++++-- 2 files changed, 69 insertions(+), 21 deletions(-) diff --git a/packages/api/router/fieldOpt/query.attributesByCategory.handler.ts b/packages/api/router/fieldOpt/query.attributesByCategory.handler.ts index 435bff34ab..e5a863d006 100644 --- a/packages/api/router/fieldOpt/query.attributesByCategory.handler.ts +++ b/packages/api/router/fieldOpt/query.attributesByCategory.handler.ts @@ -1,29 +1,30 @@ -import flush from 'just-flush' -import { type SetOptional } from 'type-fest' - import { prisma } from '@weareinreach/db' -import { type AttributesByCategory } from '@weareinreach/db/client' +import { type FieldAttributes } from '@weareinreach/db/zod_util/attributeSupplement' import { type TRPCHandlerParams } from '~api/types/handler' -import { type TAttributesByCategorySchema } from './query.attributesByCategory.schema' +import { fieldAttributesSchema, type TAttributesByCategorySchema } from './query.attributesByCategory.schema' export const attributesByCategory = async ({ input }: TRPCHandlerParams<TAttributesByCategorySchema>) => { - const where = Array.isArray(input) - ? { categoryName: { in: input } } - : typeof input === 'string' - ? { categoryName: input } - : undefined + console.log(input) const result = await prisma.attributesByCategory.findMany({ - where, + where: { + categoryName: Array.isArray(input?.categoryName) ? { in: input.categoryName } : input?.categoryName, + canAttachTo: input?.canAttachTo?.length ? { hasSome: input.canAttachTo } : undefined, + }, orderBy: [{ categoryName: 'asc' }, { attributeName: 'asc' }], }) - const flushedResults = result.map((item) => - flush<FlushedAttributesByCategory>(item) - ) as FlushedAttributesByCategory[] + const flushedResults = result.map((item) => { + const { dataSchema, ...rest } = item + + const parsedDataSchema = fieldAttributesSchema.safeParse(dataSchema) + + return { + ...rest, + dataSchema: parsedDataSchema.success + ? (parsedDataSchema.data as FieldAttributes[] | FieldAttributes[][]) + : null, + } + }) return flushedResults } -type FlushedAttributesByCategory = SetOptional< - AttributesByCategory, - 'interpolationValues' | 'icon' | 'iconBg' | 'badgeRender' | 'dataSchema' | 'dataSchemaName' -> diff --git a/packages/api/router/fieldOpt/query.attributesByCategory.schema.ts b/packages/api/router/fieldOpt/query.attributesByCategory.schema.ts index 8fc56977e4..9c7f4052b4 100644 --- a/packages/api/router/fieldOpt/query.attributesByCategory.schema.ts +++ b/packages/api/router/fieldOpt/query.attributesByCategory.schema.ts @@ -1,8 +1,55 @@ import { z } from 'zod' +import { FieldType } from '@weareinreach/db/zod_util/attributeSupplement' + export const ZAttributesByCategorySchema = z - .string() - .or(z.string().array()) + .object({ + categoryName: z.string().or(z.string().array()).optional().describe('categoryName'), + canAttachTo: z.enum(['LOCATION', 'ORGANIZATION', 'SERVICE', 'USER']).array().optional(), + }) .optional() - .describe('categoryName') export type TAttributesByCategorySchema = z.infer<typeof ZAttributesByCategorySchema> + +const fieldTypeSchema = z.nativeEnum(FieldType) +const baseFieldAttributesSchema = z.object({ + key: z.string(), + label: z.string(), + name: z.string(), + type: fieldTypeSchema, + required: z.boolean().optional(), +}) + +const textFieldAttributesSchema = baseFieldAttributesSchema.extend({ + type: z.literal(FieldType.text), +}) + +const selectFieldAttributesSchema = baseFieldAttributesSchema.extend({ + type: z.literal(FieldType.select), + options: z.array( + z.object({ + value: z.string(), + label: z.string(), + }) + ), +}) + +const numberFieldAttributesSchema = baseFieldAttributesSchema.extend({ + type: z.literal(FieldType.number), +}) + +const currencyFieldAttributesSchema = baseFieldAttributesSchema.extend({ + type: z.literal(FieldType.currency), +}) + +const fieldAttributesObject = z + .union([ + textFieldAttributesSchema, + selectFieldAttributesSchema, + numberFieldAttributesSchema, + currencyFieldAttributesSchema, + ]) + .brand('FieldAttributes') + .array() +export const fieldAttributesSchema = fieldAttributesObject.or(fieldAttributesObject.array()) + +export type TFieldAttributesSchema = z.infer<typeof fieldAttributesSchema> From baf803a57e2874329a526c78a497d7b19222b299 Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Thu, 15 Feb 2024 15:49:47 -0500 Subject: [PATCH 13/61] Attribute modal --- packages/db/zod_util/attributeSupplement.ts | 10 +- .../modals/dataPortal/Attributes/fields.tsx | 12 +- .../dataPortal/Attributes/index.stories.tsx | 12 +- .../ui/modals/dataPortal/Attributes/index.tsx | 282 +++++------------- .../ui/modals/dataPortal/Attributes/types.ts | 35 +++ 5 files changed, 133 insertions(+), 218 deletions(-) diff --git a/packages/db/zod_util/attributeSupplement.ts b/packages/db/zod_util/attributeSupplement.ts index 5e0cd88f7f..b0d120251a 100644 --- a/packages/db/zod_util/attributeSupplement.ts +++ b/packages/db/zod_util/attributeSupplement.ts @@ -139,7 +139,7 @@ export enum FieldType { number = 'number', currency = 'currency', } -interface BaseFieldAttributes { +export interface BaseFieldAttributes { key: string label: string name: string @@ -147,17 +147,17 @@ interface BaseFieldAttributes { required?: boolean } -interface TextFieldAttributes extends BaseFieldAttributes { +export interface TextFieldAttributes extends BaseFieldAttributes { type: FieldType.text } -interface SelectFieldAttributes extends BaseFieldAttributes { +export interface SelectFieldAttributes extends BaseFieldAttributes { type: FieldType.select options: { value: string; label: string }[] } -interface NumberFieldAttributes extends BaseFieldAttributes { +export interface NumberFieldAttributes extends BaseFieldAttributes { type: FieldType.number } -interface CurrencyFieldAttributes extends BaseFieldAttributes { +export interface CurrencyFieldAttributes extends BaseFieldAttributes { type: FieldType.currency } export type FieldAttributes = diff --git a/packages/ui/modals/dataPortal/Attributes/fields.tsx b/packages/ui/modals/dataPortal/Attributes/fields.tsx index b16d3d4ef7..55ab79ec99 100644 --- a/packages/ui/modals/dataPortal/Attributes/fields.tsx +++ b/packages/ui/modals/dataPortal/Attributes/fields.tsx @@ -1,9 +1,8 @@ import { Group, Select as MantineSelect, Stack, Text } from '@mantine/core' import { useTranslation } from 'next-i18next' import { type ComponentPropsWithoutRef, forwardRef, useState } from 'react' -import { type FieldPath, useFormContext } from 'react-hook-form' +import { useFormContext } from 'react-hook-form' import { NumberInput, Radio, Select, TextInput } from 'react-hook-form-mantine' -import { type TupleToUnion } from 'type-fest' import { type ApiOutput } from '@weareinreach/api' import { type FieldAttributes, FieldType } from '@weareinreach/db/zod_util/attributeSupplement' @@ -26,7 +25,6 @@ const SuppBoolean = () => { const SuppText = () => { const { control } = useFormContext<FormSchema>() - const { t } = useTranslation('common') return ( <Stack> <TextInput {...{ control, name: 'text' }} /> @@ -118,10 +116,10 @@ const SuppGeo = ({ countryOnly }: SuppGeoProps) => { const [secondarySearch, onSecondarySearch] = useState<string | null>(null) const [tertiarySearch, onTertiarySearch] = useState<string | null>(null) - const [finalValue, setFinalValue] = useState<string | null>(null) - const [fieldName, setFieldName] = useState<FieldPath<FormSchema> | undefined>( - countryOnly ? 'countryId' : undefined - ) + // const [finalValue, setFinalValue] = useState<string | null>(null) + // const [fieldName, setFieldName] = useState<FieldPath<FormSchema> | undefined>( + // countryOnly ? 'countryId' : undefined + // ) const { data: countryList, ...countries } = api.fieldOpt.countries.useQuery(undefined, { enabled: countryOnly ?? false, diff --git a/packages/ui/modals/dataPortal/Attributes/index.stories.tsx b/packages/ui/modals/dataPortal/Attributes/index.stories.tsx index bbadd4633c..1d6b966cc2 100644 --- a/packages/ui/modals/dataPortal/Attributes/index.stories.tsx +++ b/packages/ui/modals/dataPortal/Attributes/index.stories.tsx @@ -1,10 +1,11 @@ -import { type Meta } from '@storybook/react' +import { type Meta, type StoryObj } from '@storybook/react' import { Button } from '~ui/components/core/Button' import { allFieldOptHandlers } from '~ui/mockData/fieldOpt' import { AttributeModal } from './index' +type StoryDef = StoryObj<typeof AttributeModal> export default { title: 'Data Portal/Modals/Attributes', component: AttributeModal, @@ -18,7 +19,14 @@ export default { component: Button, children: 'Open Modal', variant: 'inlineInvertedUtil1', + restrictCategories: undefined, + attachesTo: undefined, }, } satisfies Meta<typeof AttributeModal> -export const Modal = {} +export const AllCategories = {} satisfies StoryDef +export const AttachesToService = { + args: { + attachesTo: ['SERVICE'], + }, +} satisfies StoryDef diff --git a/packages/ui/modals/dataPortal/Attributes/index.tsx b/packages/ui/modals/dataPortal/Attributes/index.tsx index 05257008ca..90146af75a 100644 --- a/packages/ui/modals/dataPortal/Attributes/index.tsx +++ b/packages/ui/modals/dataPortal/Attributes/index.tsx @@ -8,7 +8,6 @@ import { Group, Select as MantineSelect, Modal, - Select, Skeleton, Stack, Text, @@ -16,31 +15,29 @@ import { import { useDisclosure } from '@mantine/hooks' import Ajv from 'ajv' import { useTranslation } from 'next-i18next' -import { type ComponentPropsWithoutRef, forwardRef, useEffect, useRef, useState } from 'react' -import { FormProvider, useFieldArray, useForm } from 'react-hook-form' +import { forwardRef, useMemo, useRef, useState } from 'react' +import { FormProvider, useForm } from 'react-hook-form' -import { Badge } from '~ui/components/core/Badge' +import { type ApiOutput } from '@weareinreach/api' import { Button } from '~ui/components/core/Button' -import { Icon, isValidIcon } from '~ui/icon' import { trpc as api } from '~ui/lib/trpcClient' import { ModalTitle } from '~ui/modals/ModalTitle' import { Supplement } from './fields' import { formSchema, type FormSchema } from './schema' import { SelectionItem } from './SelectionItem' -import { type SupplementEventHandler } from './types' -const supplementFields = { +const supplementDefaults = { boolean: false, geo: false, language: false, text: false, data: false, } as const -type SupplementFieldsNeeded = { [K in keyof typeof supplementFields]: boolean } +type SupplementFieldsNeeded = { [K in keyof typeof supplementDefaults]: boolean } const AttributeModalBody = forwardRef<HTMLButtonElement, AttributeModalProps>( - ({ restrictCategories, ...props }, ref) => { + ({ restrictCategories, attachesTo, ...props }, ref) => { const { t } = useTranslation(['attribute', 'common']) const [opened, handler] = useDisclosure(false) @@ -50,74 +47,70 @@ const AttributeModalBody = forwardRef<HTMLButtonElement, AttributeModalProps>( const selectAttrRef = useRef<HTMLInputElement>(null) // #region tRPC const utils = api.useUtils() - const { data: attributeCategories, ...attributeCategoriesApi } = - api.fieldOpt.attributeCategories.useQuery(restrictCategories, { - refetchOnWindowFocus: false, - select: (data) => data.map(({ name, tag }) => ({ value: tag, label: name })), - }) const [attrCat, setAttrCat] = useState<string | null>() - const { data: attributesByCategory, ...attributesByCategoryApi } = - api.fieldOpt.attributesByCategory.useQuery(attrCat ?? '', { - enabled: Boolean(attrCat), + api.fieldOpt.attributesByCategory.useQuery(undefined, { refetchOnWindowFocus: false, - select: (data) => - data.map( - ({ - attributeId, - attributeKey, - interpolationValues, - icon, - iconBg, - badgeRender, - requireBoolean, - requireGeo, - requireData, - requireLanguage, - requireText, - dataSchemaName, - dataSchema, - }) => ({ - value: attributeId, - label: t(attributeKey), - tKey: attributeKey, - interpolationValues, - icon: icon ?? undefined, - iconBg: iconBg ?? undefined, - variant: badgeRender ?? undefined, - requireBoolean, - requireGeo, - requireData, - requireLanguage, - requireText, - dataSchemaName, - dataSchema, - }) - ), + select: (data) => { + return data.map(({ attributeId, attributeKey, ...rest }) => ({ + value: attributeId, + label: t(attributeKey), + tKey: attributeKey, + ...rest, + })) + }, }) + const attributeCategories = useMemo(() => { + return [ + ...new Set( + attributesByCategory + ?.filter(({ canAttachTo }) => { + if (!attachesTo?.length) { + return true + } + let match = false + for (const item of canAttachTo) { + if (attachesTo.includes(item)) { + match = true + break + } + } + return match + }) + .map(({ categoryName, categoryDisplay }) => { + return JSON.stringify({ + value: categoryName, + label: categoryDisplay, + }) + }) + ), + ].map((v) => { + return JSON.parse(v) as { value: string; label: string } + }) + }, [attributesByCategory, attachesTo]) const [selectedAttr, setSelectedAttr] = useState<NonNullable<typeof attributesByCategory>[number] | null>( null ) - const [supplements, setSupplements] = useState<SupplementFieldsNeeded>(supplementFields) + const [supplements, setSupplements] = useState<SupplementFieldsNeeded>(supplementDefaults) const saveAttributes = api.organization.attachAttribute.useMutation() // #endregion - console.log({ attrCat, selectedAttr, supplements }) + // #region Handlers const selectHandler = (e: string | null) => { - console.log(e) - if (e === null) return setSelectedAttr(null) + console.log('selectHandler', e) + if (e === null) { + setSupplements(supplementDefaults) + setSelectedAttr(null) + return + } const item = attributesByCategory?.find(({ value }) => value === e) if (item) { setSelectedAttr(item) const { requireBoolean, requireGeo, requireData, requireLanguage, requireText } = item /** Check if supplemental info required */ if (requireBoolean || requireGeo || requireData || requireLanguage || requireText) { - // const { boolean, countryId, govDistId, languageId, text, data } = form.values.supplement ?? {} /** Handle if supplemental info is provided */ - - // if (!form.values.supplement) { - console.log('init supp handler', item) const suppRequired: SupplementFieldsNeeded = { boolean: requireBoolean ?? false, geo: requireGeo ?? false, @@ -128,74 +121,11 @@ const AttributeModalBody = forwardRef<HTMLButtonElement, AttributeModalProps>( setSupplements(suppRequired) return - // } } form.setValue('attributeId', item.value) - // const { label, value, icon, iconBg, variant, tKey } = item - // form.setFieldValue('selected', [ - // ...form.values.selected, - // { label, value, icon, iconBg, variant, tKey }, - // ]) - // form.setFieldValue( - // 'attributes', - // form.values.attributes?.filter(({ value }) => value !== e) - // ) selectAttrRef.current && (selectAttrRef.current.value = '') } } - const handleSupplement = (e: SupplementEventHandler) => { - const item = attributesByCategory?.find(({ value }) => value === e.attributeId) - if (!item) return - const { requireBoolean, requireGeo, requireData, requireLanguage, requireText } = item - const { boolean, countryId, govDistId, languageId, text, data } = e ?? {} - console.log('🚀 ~ file: index.tsx:219 ~ handleSupplement ~ e:', e) - - if ( - (requireBoolean && boolean !== undefined) || - (requireGeo && (countryId || govDistId)) || - (requireLanguage && languageId) || - (requireText && text) || - (requireData && data) - ) { - console.log('handler after supp') - const { value, label, icon, iconBg, variant, tKey } = item - // clear state - setSupplements(supplementFields) - - // form.setValues({ - // selected: [ - // ...form.values.selected, - // { - // value, - // label, - // icon, - // iconBg, - // variant, - // countryId, - // govDistId, - // languageId, - // text, - // boolean, - // data, - // tKey, - // }, - // ], - // attributes: form.values.attributes?.filter(({ value }) => value !== e.attributeId), - // supplement: undefined, - // }) - - // clear out supplement store - return - } - } - - const removeHandler = (e: string) => { - // form.setFieldValue( - // 'selected', - // form.values.selected?.filter(({ value }) => value !== e) - // ) - utils.fieldOpt.attributesByCategory.invalidate() - } const submitHandler = () => { //TODO: [IN-871] Create submit handler - convert tRPC organization.attachAttribute to be able to handle multiple items & accept org, serv, loc @@ -205,69 +135,13 @@ const AttributeModalBody = forwardRef<HTMLButtonElement, AttributeModalProps>( // #region Title & Selected items display const modalTitle = <ModalTitle breadcrumb={{ option: 'close', onClick: handler.close }} /> - // const selectedItems = form.values.selected?.map(({ label, icon, variant, value, iconBg, tKey, data }) => { - // switch (variant) { - // case 'ATTRIBUTE': { - // return ( - // <Group key={value} spacing={4}> - // <Badge variant='attribute' tsNs='attribute' tsKey={tKey} icon={icon ?? ''} /> - // <Icon icon='carbon:close-filled' onClick={() => removeHandler(value)} /> - // </Group> - // ) - // } - // case 'COMMUNITY': { - // return ( - // <Group key={value} spacing={4}> - // <Badge variant='community' tsKey={tKey} icon={icon ?? ''} /> - // <Icon icon='carbon:close-filled' onClick={() => removeHandler(value)} /> - // </Group> - // ) - // } - // case 'LEADER': { - // return ( - // <Group key={value} spacing={4}> - // <Badge variant='leader' tsKey={tKey} icon={icon ?? ''} iconBg={iconBg ?? ''} /> - // <Icon icon='carbon:close-filled' onClick={() => removeHandler(value)} /> - // </Group> - // ) - // } - // case 'LIST': { - // return ( - // <Group key={value} spacing={4}> - // <Text>{t(tKey, { ns: 'attribute', context: 'range', ...data })}</Text> - // <Icon icon='carbon:close-filled' onClick={() => removeHandler(value)} /> - // </Group> - // ) - // } - // } - // }) - // #endregion - - /** Validate supplement data against defined JSON schema */ - // useEffect(() => { - // const { data, schema } = form.values.supplement ?? {} - // if (data && schema) { - // const ajv = new Ajv() - // const validate = ajv.compile(schema as object) - // const validData = validate(data) - // if (!validData && validate.errors) { - // form.setFieldError( - // 'supplement.data', - // validate.errors.map(({ message }) => message) - // ) - // } - // } - // // eslint-disable-next-line react-hooks/exhaustive-deps - // }, [form.values.supplement]) - const needsSupplement = Object.values(supplements).includes(true) return ( <FormProvider {...form}> <Modal title={modalTitle} opened={opened} onClose={() => handler.close()}> <Stack> - {/* <Group>{selectedItems}</Group> */} <Stack> - <Skeleton visible={attributeCategoriesApi.isLoading}> + <Skeleton visible={attributesByCategoryApi.isLoading}> <MantineSelect data={attributeCategories ?? []} label='Select Category' @@ -282,35 +156,34 @@ const AttributeModalBody = forwardRef<HTMLButtonElement, AttributeModalProps>( clearable /> </Skeleton> - {true && ( - // <Skeleton visible={!!attrCat && attributesByCategoryApi.isLoading}> - <MantineSelect - data={ - !attrCat ? [] : (attributesByCategory ?? []).map(({ label, value }) => ({ label, value })) - } - value={selectedAttr?.value ?? null} - label='Select Attribute' - disabled={!attrCat || !attributesByCategory?.length} - withinPortal - itemComponent={SelectionItem} - searchable={(attributesByCategory?.length ?? 0) > 10} - ref={selectAttrRef} - clearable - // {...form.getInputProps('selected')} - onChange={selectHandler} - inputContainer={(children) => ( - <Skeleton visible={!!attrCat && attributesByCategoryApi.isLoading} radius='md'> - {children} - </Skeleton> - )} - /> - // </Skeleton> - )} + <MantineSelect + data={ + !attrCat + ? [] + : (attributesByCategory ?? []) + .filter(({ categoryName }) => categoryName === attrCat) + .map(({ label, value }) => ({ label, value })) + } + value={selectedAttr?.value ?? null} + label='Select Attribute' + disabled={!attrCat || !attributesByCategory?.length} + withinPortal + itemComponent={SelectionItem} + searchable={(attributesByCategory?.length ?? 0) > 10} + ref={selectAttrRef} + clearable + onChange={selectHandler} + inputContainer={(children) => ( + <Skeleton visible={!!attrCat && attributesByCategoryApi.isLoading} radius='md'> + {children} + </Skeleton> + )} + /> </Stack> {supplements.boolean && <Supplement.Boolean />} {supplements.text && <Supplement.Text />} - {supplements.data && selectedAttr?.dataSchemaName && ( - <Supplement.Data schema={selectedAttr.dataSchemaName} /> + {supplements.data && selectedAttr?.dataSchema && ( + <Supplement.Data schema={selectedAttr.dataSchema} /> )} {supplements.language && <Supplement.Language />} {supplements.geo && <Supplement.Geo />} @@ -329,4 +202,5 @@ export const AttributeModal = createPolymorphicComponent<'button', AttributeModa export interface AttributeModalProps extends ButtonProps { restrictCategories?: string[] + attachesTo?: ApiOutput['fieldOpt']['attributesByCategory'][number]['canAttachTo'] } diff --git a/packages/ui/modals/dataPortal/Attributes/types.ts b/packages/ui/modals/dataPortal/Attributes/types.ts index 04f1b0b93b..71afb5e562 100644 --- a/packages/ui/modals/dataPortal/Attributes/types.ts +++ b/packages/ui/modals/dataPortal/Attributes/types.ts @@ -7,3 +7,38 @@ export interface SupplementEventHandler { boolean?: boolean data?: object } + +/** Dynamic Fields for Supplement Data Schemas */ + +export enum FieldType { + text = 'text', + select = 'select', + number = 'number', + currency = 'currency', +} +interface BaseFieldAttributes { + key: string + label: string + name: string + type: FieldType + required?: boolean +} + +interface TextFieldAttributes extends BaseFieldAttributes { + type: FieldType.text +} +interface SelectFieldAttributes extends BaseFieldAttributes { + type: FieldType.select + options: { value: string; label: string }[] +} +interface NumberFieldAttributes extends BaseFieldAttributes { + type: FieldType.number +} +interface CurrencyFieldAttributes extends BaseFieldAttributes { + type: FieldType.currency +} +export type FieldAttributes = + | TextFieldAttributes + | SelectFieldAttributes + | NumberFieldAttributes + | CurrencyFieldAttributes From cec13663bd05b01ec87192ac595051e6deb2dd0a Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Thu, 15 Feb 2024 16:05:52 -0500 Subject: [PATCH 14/61] update handler cache --- packages/api/router/fieldOpt/index.ts | 147 +++++++----------- .../query.attributeCategories.handler.ts | 1 + .../query.attributesByCategory.handler.ts | 1 + .../fieldOpt/query.countries.handler.ts | 1 + .../query.countryGovDistMap.handler.ts | 1 + .../fieldOpt/query.getSubDistricts.handler.ts | 1 + .../router/fieldOpt/query.govDists.handler.ts | 1 + .../query.govDistsByCountry.handler.ts | 1 + .../query.govDistsByCountryNoSub.handler.ts | 1 + .../fieldOpt/query.languages.handler.ts | 1 + .../fieldOpt/query.orgBadges.handler.ts | 3 +- .../fieldOpt/query.phoneTypes.handler.ts | 1 + .../fieldOpt/query.userTitle.handler.ts | 1 + 13 files changed, 69 insertions(+), 92 deletions(-) diff --git a/packages/api/router/fieldOpt/index.ts b/packages/api/router/fieldOpt/index.ts index 880b3cbdf9..9a16c851ab 100644 --- a/packages/api/router/fieldOpt/index.ts +++ b/packages/api/router/fieldOpt/index.ts @@ -1,23 +1,10 @@ -import { defineRouter, publicProcedure } from '~api/lib/trpc' +import { defineRouter, importHandler, publicProcedure } from '~api/lib/trpc' import * as schema from './schemas' -const HandlerCache: Partial<FieldOptHandlerCache> = {} +const NAMESPACE = 'fieldOpt' -type FieldOptHandlerCache = { - govDistsByCountry: typeof import('./query.govDistsByCountry.handler').govDistsByCountry - govDistsByCountryNoSub: typeof import('./query.govDistsByCountryNoSub.handler').govDistsByCountryNoSub - phoneTypes: typeof import('./query.phoneTypes.handler').phoneTypes - attributesByCategory: typeof import('./query.attributesByCategory.handler').attributesByCategory - attributeCategories: typeof import('./query.attributeCategories.handler').attributeCategories - languages: typeof import('./query.languages.handler').languages - countries: typeof import('./query.countries.handler').countries - userTitle: typeof import('./query.userTitle.handler').userTitle - countryGovDistMap: typeof import('./query.countryGovDistMap.handler').countryGovDistMap - getSubDistricts: typeof import('./query.getSubDistricts.handler').getSubDistricts - govDists: typeof import('./query.govDists.handler').govDists - orgBadges: typeof import('./query.orgBadges.handler').orgBadges -} +const namespaced = (s: string) => `${NAMESPACE}.${s}` export const fieldOptRouter = defineRouter({ /** All government districts by country (active for org listings). Gives up to 2 levels of sub-districts */ govDistsByCountry: publicProcedure @@ -26,97 +13,75 @@ export const fieldOptRouter = defineRouter({ 'All government districts by country (active for org listings). Gives 2 levels of sub-districts', }) .input(schema.ZGovDistsByCountrySchema) - .query(async ({ ctx, input }) => { - if (!HandlerCache.govDistsByCountry) - HandlerCache.govDistsByCountry = await import('./query.govDistsByCountry.handler').then( - (mod) => mod.govDistsByCountry - ) - if (!HandlerCache.govDistsByCountry) throw new Error('Failed to load handler') - return HandlerCache.govDistsByCountry({ ctx, input }) + .query(async (opts) => { + const handler = await importHandler( + namespaced('govDistsByCountry'), + () => import('./query.govDistsByCountry.handler') + ) + return handler(opts) }), govDistsByCountryNoSub: publicProcedure .meta({ description: 'All government districts by country (active for org listings).', }) .input(schema.ZGovDistsByCountryNoSubSchema) - .query(async ({ ctx, input }) => { - if (!HandlerCache.govDistsByCountryNoSub) - HandlerCache.govDistsByCountryNoSub = await import('./query.govDistsByCountryNoSub.handler').then( - (mod) => mod.govDistsByCountryNoSub - ) - if (!HandlerCache.govDistsByCountryNoSub) throw new Error('Failed to load handler') - return HandlerCache.govDistsByCountryNoSub({ ctx, input }) + .query(async (opts) => { + const handler = await importHandler( + namespaced('govDistsByCountryNoSub'), + () => import('./query.govDistsByCountryNoSub.handler') + ) + return handler(opts) }), phoneTypes: publicProcedure.query(async () => { - if (!HandlerCache.phoneTypes) - HandlerCache.phoneTypes = await import('./query.phoneTypes.handler').then((mod) => mod.phoneTypes) - if (!HandlerCache.phoneTypes) throw new Error('Failed to load handler') - return HandlerCache.phoneTypes() + const handler = await importHandler(namespaced('phoneTypes'), () => import('./query.phoneTypes.handler')) + return handler() }), - attributesByCategory: publicProcedure - .input(schema.ZAttributesByCategorySchema) - .query(async ({ ctx, input }) => { - if (!HandlerCache.attributesByCategory) - HandlerCache.attributesByCategory = await import('./query.attributesByCategory.handler').then( - (mod) => mod.attributesByCategory - ) - if (!HandlerCache.attributesByCategory) throw new Error('Failed to load handler') - return HandlerCache.attributesByCategory({ ctx, input }) - }), - attributeCategories: publicProcedure - .input(schema.ZAttributeCategoriesSchema) - .query(async ({ ctx, input }) => { - if (!HandlerCache.attributeCategories) - HandlerCache.attributeCategories = await import('./query.attributeCategories.handler').then( - (mod) => mod.attributeCategories - ) - if (!HandlerCache.attributeCategories) throw new Error('Failed to load handler') - return HandlerCache.attributeCategories({ ctx, input }) - }), - languages: publicProcedure.input(schema.ZLanguagesSchema).query(async ({ ctx, input }) => { - if (!HandlerCache.languages) - HandlerCache.languages = await import('./query.languages.handler').then((mod) => mod.languages) - if (!HandlerCache.languages) throw new Error('Failed to load handler') - return HandlerCache.languages({ ctx, input }) + attributesByCategory: publicProcedure.input(schema.ZAttributesByCategorySchema).query(async (opts) => { + const handler = await importHandler( + namespaced('attributesByCategory'), + () => import('./query.attributesByCategory.handler') + ) + return handler(opts) }), - countries: publicProcedure.input(schema.ZCountriesSchema).query(async ({ ctx, input }) => { - if (!HandlerCache.countries) - HandlerCache.countries = await import('./query.countries.handler').then((mod) => mod.countries) - if (!HandlerCache.countries) throw new Error('Failed to load handler') - return HandlerCache.countries({ ctx, input }) + attributeCategories: publicProcedure.input(schema.ZAttributeCategoriesSchema).query(async (opts) => { + const handler = await importHandler( + namespaced('attributeCategories'), + () => import('./query.attributeCategories.handler') + ) + return handler(opts) + }), + languages: publicProcedure.input(schema.ZLanguagesSchema).query(async (opts) => { + const handler = await importHandler(namespaced('languages'), () => import('./query.languages.handler')) + return handler(opts) + }), + countries: publicProcedure.input(schema.ZCountriesSchema).query(async (opts) => { + const handler = await importHandler(namespaced('countries'), () => import('./query.countries.handler')) + return handler(opts) }), userTitle: publicProcedure.query(async () => { - if (!HandlerCache.userTitle) - HandlerCache.userTitle = await import('./query.userTitle.handler').then((mod) => mod.userTitle) - if (!HandlerCache.userTitle) throw new Error('Failed to load handler') - return HandlerCache.userTitle() + const handler = await importHandler(namespaced('userTitle'), () => import('./query.userTitle.handler')) + return handler() }), countryGovDistMap: publicProcedure.query(async () => { - if (!HandlerCache.countryGovDistMap) - HandlerCache.countryGovDistMap = await import('./query.countryGovDistMap.handler').then( - (mod) => mod.countryGovDistMap - ) - if (!HandlerCache.countryGovDistMap) throw new Error('Failed to load handler') - return HandlerCache.countryGovDistMap() + const handler = await importHandler( + namespaced('countryGovDistMap'), + () => import('./query.countryGovDistMap.handler') + ) + return handler() }), - getSubDistricts: publicProcedure.input(schema.ZGetSubDistrictsSchema).query(async ({ ctx, input }) => { - if (!HandlerCache.getSubDistricts) - HandlerCache.getSubDistricts = await import('./query.getSubDistricts.handler').then( - (mod) => mod.getSubDistricts - ) - if (!HandlerCache.getSubDistricts) throw new Error('Failed to load handler') - return HandlerCache.getSubDistricts({ ctx, input }) + getSubDistricts: publicProcedure.input(schema.ZGetSubDistrictsSchema).query(async (opts) => { + const handler = await importHandler( + namespaced('getSubDistricts'), + () => import('./query.getSubDistricts.handler') + ) + return handler(opts) }), - govDists: publicProcedure.input(schema.ZGovDistsSchema).query(async ({ ctx, input }) => { - if (!HandlerCache.govDists) - HandlerCache.govDists = await import('./query.govDists.handler').then((mod) => mod.govDists) - if (!HandlerCache.govDists) throw new Error('Failed to load handler') - return HandlerCache.govDists({ ctx, input }) + govDists: publicProcedure.input(schema.ZGovDistsSchema).query(async (opts) => { + const handler = await importHandler(namespaced('govDists'), () => import('./query.govDists.handler')) + return handler(opts) }), - orgBadges: publicProcedure.input(schema.ZOrgBadgesSchema).query(async ({ ctx, input }) => { - if (!HandlerCache.orgBadges) - HandlerCache.orgBadges = await import('./query.orgBadges.handler').then((mod) => mod.orgBadges) - if (!HandlerCache.orgBadges) throw new Error('Failed to load handler') - return HandlerCache.orgBadges({ ctx, input }) + orgBadges: publicProcedure.input(schema.ZOrgBadgesSchema).query(async (opts) => { + const handler = await importHandler(namespaced('orgBadges'), () => import('./query.orgBadges.handler')) + return handler(opts) }), }) diff --git a/packages/api/router/fieldOpt/query.attributeCategories.handler.ts b/packages/api/router/fieldOpt/query.attributeCategories.handler.ts index a9c3d6e8eb..a368b90c21 100644 --- a/packages/api/router/fieldOpt/query.attributeCategories.handler.ts +++ b/packages/api/router/fieldOpt/query.attributeCategories.handler.ts @@ -17,3 +17,4 @@ export const attributeCategories = async ({ input }: TRPCHandlerParams<TAttribut }) return results } +export default attributeCategories diff --git a/packages/api/router/fieldOpt/query.attributesByCategory.handler.ts b/packages/api/router/fieldOpt/query.attributesByCategory.handler.ts index e5a863d006..c39e0f75d7 100644 --- a/packages/api/router/fieldOpt/query.attributesByCategory.handler.ts +++ b/packages/api/router/fieldOpt/query.attributesByCategory.handler.ts @@ -28,3 +28,4 @@ export const attributesByCategory = async ({ input }: TRPCHandlerParams<TAttribu }) return flushedResults } +export default attributesByCategory diff --git a/packages/api/router/fieldOpt/query.countries.handler.ts b/packages/api/router/fieldOpt/query.countries.handler.ts index a1cb037afe..d4a87304da 100644 --- a/packages/api/router/fieldOpt/query.countries.handler.ts +++ b/packages/api/router/fieldOpt/query.countries.handler.ts @@ -24,3 +24,4 @@ export const countries = async ({ input }: TRPCHandlerParams<TCountriesSchema>) type CountryResult = (typeof result)[number][] return result as CountryResult } +export default countries diff --git a/packages/api/router/fieldOpt/query.countryGovDistMap.handler.ts b/packages/api/router/fieldOpt/query.countryGovDistMap.handler.ts index 064e9af05d..6293163f3e 100644 --- a/packages/api/router/fieldOpt/query.countryGovDistMap.handler.ts +++ b/packages/api/router/fieldOpt/query.countryGovDistMap.handler.ts @@ -46,3 +46,4 @@ interface CountryGovDistMapItem { children: CountryGovDistMapItemBasic[] parent?: CountryGovDistMapItemBasic & { parent?: CountryGovDistMapItemBasic } } +export default countryGovDistMap diff --git a/packages/api/router/fieldOpt/query.getSubDistricts.handler.ts b/packages/api/router/fieldOpt/query.getSubDistricts.handler.ts index 76ef4261f8..08d81fcecb 100644 --- a/packages/api/router/fieldOpt/query.getSubDistricts.handler.ts +++ b/packages/api/router/fieldOpt/query.getSubDistricts.handler.ts @@ -30,3 +30,4 @@ export const getSubDistricts = async ({ input }: TRPCHandlerParams<TGetSubDistri handleError(error) } } +export default getSubDistricts diff --git a/packages/api/router/fieldOpt/query.govDists.handler.ts b/packages/api/router/fieldOpt/query.govDists.handler.ts index 44e526647b..501af8271d 100644 --- a/packages/api/router/fieldOpt/query.govDists.handler.ts +++ b/packages/api/router/fieldOpt/query.govDists.handler.ts @@ -22,3 +22,4 @@ export const govDists = async ({ input }: TRPCHandlerParams<TGovDistsSchema>) => handleError(error) } } +export default govDists diff --git a/packages/api/router/fieldOpt/query.govDistsByCountry.handler.ts b/packages/api/router/fieldOpt/query.govDistsByCountry.handler.ts index 25d0581b92..c4e2eaa70d 100644 --- a/packages/api/router/fieldOpt/query.govDistsByCountry.handler.ts +++ b/packages/api/router/fieldOpt/query.govDistsByCountry.handler.ts @@ -58,3 +58,4 @@ export const govDistsByCountry = async ({ input }: TRPCHandlerParams<TGovDistsBy }) return data } +export default govDistsByCountry diff --git a/packages/api/router/fieldOpt/query.govDistsByCountryNoSub.handler.ts b/packages/api/router/fieldOpt/query.govDistsByCountryNoSub.handler.ts index 76717690a2..b5cc81e737 100644 --- a/packages/api/router/fieldOpt/query.govDistsByCountryNoSub.handler.ts +++ b/packages/api/router/fieldOpt/query.govDistsByCountryNoSub.handler.ts @@ -36,3 +36,4 @@ export const govDistsByCountryNoSub = async ({ input }: TRPCHandlerParams<TGovDi }) return data } +export default govDistsByCountryNoSub diff --git a/packages/api/router/fieldOpt/query.languages.handler.ts b/packages/api/router/fieldOpt/query.languages.handler.ts index ca1daf4443..5ec0b6861a 100644 --- a/packages/api/router/fieldOpt/query.languages.handler.ts +++ b/packages/api/router/fieldOpt/query.languages.handler.ts @@ -18,3 +18,4 @@ export const languages = async ({ input }: TRPCHandlerParams<TLanguagesSchema>) }) return results } +export default languages diff --git a/packages/api/router/fieldOpt/query.orgBadges.handler.ts b/packages/api/router/fieldOpt/query.orgBadges.handler.ts index b7a74822cc..31c357bf47 100644 --- a/packages/api/router/fieldOpt/query.orgBadges.handler.ts +++ b/packages/api/router/fieldOpt/query.orgBadges.handler.ts @@ -4,7 +4,7 @@ import { type TRPCHandlerParams } from '~api/types/handler' import { type TOrgBadgesSchema } from './query.orgBadges.schema' -export const orgBadges = async ({ ctx, input }: TRPCHandlerParams<TOrgBadgesSchema>) => { +export const orgBadges = async ({ input }: TRPCHandlerParams<TOrgBadgesSchema>) => { try { const badges = await prisma.attribute.findMany({ where: { @@ -24,3 +24,4 @@ export const orgBadges = async ({ ctx, input }: TRPCHandlerParams<TOrgBadgesSche handleError(error) } } +export default orgBadges diff --git a/packages/api/router/fieldOpt/query.phoneTypes.handler.ts b/packages/api/router/fieldOpt/query.phoneTypes.handler.ts index 4e4f030c97..574301d342 100644 --- a/packages/api/router/fieldOpt/query.phoneTypes.handler.ts +++ b/packages/api/router/fieldOpt/query.phoneTypes.handler.ts @@ -12,3 +12,4 @@ export const phoneTypes = async () => { }) return result } +export default phoneTypes diff --git a/packages/api/router/fieldOpt/query.userTitle.handler.ts b/packages/api/router/fieldOpt/query.userTitle.handler.ts index 0a72ea0878..a3749b7a8a 100644 --- a/packages/api/router/fieldOpt/query.userTitle.handler.ts +++ b/packages/api/router/fieldOpt/query.userTitle.handler.ts @@ -7,3 +7,4 @@ export const userTitle = async () => { }) return results } +export default userTitle From c1796416018e297f12addfbe33990f5ed861c760 Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Thu, 15 Feb 2024 16:41:12 -0500 Subject: [PATCH 15/61] fix linting errors --- packages/ui/.storybook/preview.tsx | 27 ++++++++++----------- packages/ui/components/core/Badge/index.tsx | 25 ++++++++++--------- packages/ui/lib/trpcResponse.ts | 24 +++++++++--------- packages/ui/providers/SearchState.tsx | 17 ++++++------- 4 files changed, 45 insertions(+), 48 deletions(-) diff --git a/packages/ui/.storybook/preview.tsx b/packages/ui/.storybook/preview.tsx index 3aaf9176cf..2532169539 100644 --- a/packages/ui/.storybook/preview.tsx +++ b/packages/ui/.storybook/preview.tsx @@ -38,9 +38,9 @@ initializeMsw({ if (url.startsWith('/trpc' || '/api')) { console.error(`Unhandled ${method} request to ${url}. - This exception has been only logged in the console, however, it's strongly recommended to resolve this error as you don't want unmocked data in Storybook stories. - If you wish to mock an error response, please refer to this guide: https://mswjs.io/docs/recipes/mocking-error-responses - `) + This exception has been only logged in the console, however, it's strongly recommended to resolve this error as you don't want unmocked data in Storybook stories. + If you wish to mock an error response, please refer to this guide: https://mswjs.io/docs/recipes/mocking-error-responses + `) } }, }) @@ -134,14 +134,13 @@ type PseudoStates = | 'link' | 'target' -type DesignParams = { name?: string } & ( - | { - type: 'figma' - url: `https://${string}` - } - | { - type: 'figspec' - url: `https://${string}` - accessToken: string - } -) +type DesignParams = ({ name?: string } & DesignFigma) | DesignFigspec +type DesignFigma = { + type: 'figma' + url: `https://${string}` +} +type DesignFigspec = { + type: 'figspec' + url: `https://${string}` + accessToken: string +} diff --git a/packages/ui/components/core/Badge/index.tsx b/packages/ui/components/core/Badge/index.tsx index a4eba11c81..24134c207a 100644 --- a/packages/ui/components/core/Badge/index.tsx +++ b/packages/ui/components/core/Badge/index.tsx @@ -520,19 +520,20 @@ interface BadgeStylesParams { minify?: boolean hideBg?: boolean } +interface BadgeOtherProps extends Omit<BadgeProps, 'variant'> { + /** Preset designs */ + variant?: + | Exclude<CustomVariants, 'leader' | 'verified' | 'attribute' | 'community' | 'service' | 'national'> + | 'outline' + /** + * Item rendered on the left side of the badge. Should be either an emoji unicode string or an Icon + * component + */ + leftSection?: ReactNode + hideTooltip?: boolean +} export type CustomBadgeProps = - | (Omit<BadgeProps, 'variant'> & { - /** Preset designs */ - variant?: - | Exclude<CustomVariants, 'leader' | 'verified' | 'attribute' | 'community' | 'service' | 'national'> - | 'outline' - /** - * Item rendered on the left side of the badge. Should be either an emoji unicode string or an Icon - * component - */ - leftSection?: ReactNode - hideTooltip?: boolean - }) + | BadgeOtherProps | LeaderBadgeProps | VerifiedBadgeProps | AttributeTagProps diff --git a/packages/ui/lib/trpcResponse.ts b/packages/ui/lib/trpcResponse.ts index b03fee1e72..4be780aaa4 100644 --- a/packages/ui/lib/trpcResponse.ts +++ b/packages/ui/lib/trpcResponse.ts @@ -13,20 +13,18 @@ export type RpcSuccessResponse<Data> = { data: Data | SuperJSONResult } } - +type ErrorObject = { + message: string + code: number + data: { + code: string + httpStatus: number + stack: string + path: string //TQuery + } +} export type RpcErrorResponse = { - error: - | { - message: string - code: number - data: { - code: string - httpStatus: number - stack: string - path: string //TQuery - } - } - | SuperJSONResult + error: ErrorObject | SuperJSONResult } // According to JSON-RPC 2.0 and tRPC documentation. diff --git a/packages/ui/providers/SearchState.tsx b/packages/ui/providers/SearchState.tsx index 42e3dae1c3..fd25659ca6 100644 --- a/packages/ui/providers/SearchState.tsx +++ b/packages/ui/providers/SearchState.tsx @@ -89,16 +89,15 @@ export const SearchStateProvider = ({ children, initState }: SearchStateProvider </SearchStateContext.Provider> ) } +type RouteParams = { + params: string[] + page: string + a?: string[] + s?: string[] + sort?: string[] +} -type GetRoute = () => - | { - params: string[] - page: string - a?: string[] - s?: string[] - sort?: string[] - } - | undefined +type GetRoute = () => RouteParams | undefined export interface SearchStateContext { searchState: State & { attributes: string[]; services: string[]; getRoute: GetRoute } From 7fa5892d393e29d1664a0a02584d6cf115c2d6e9 Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Fri, 16 Feb 2024 17:10:21 -0500 Subject: [PATCH 16/61] alter view --- packages/api/package.json | 1 + .../query.attributesByCategory.handler.ts | 20 ++++++++--- .../20240216161351_alter_view/migration.sql | 36 +++++++++++++++++++ packages/db/prisma/schema.prisma | 3 +- .../json/fieldOpt.attributesByCategory.json | 2 +- packages/ui/package.json | 1 + pnpm-lock.yaml | 27 ++++++++++---- 7 files changed, 77 insertions(+), 13 deletions(-) create mode 100644 packages/db/prisma/migrations/20240216161351_alter_view/migration.sql diff --git a/packages/api/package.json b/packages/api/package.json index f91bb7e4cb..fbfb8c520b 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -31,6 +31,7 @@ "@weareinreach/db": "workspace:*", "@weareinreach/env": "workspace:*", "@weareinreach/util": "workspace:*", + "ajv": "8.12.0", "alex": "11.0.1", "crud-object-diff": "2.3.6", "geo-tz": "8.0.1", diff --git a/packages/api/router/fieldOpt/query.attributesByCategory.handler.ts b/packages/api/router/fieldOpt/query.attributesByCategory.handler.ts index c39e0f75d7..771c577cff 100644 --- a/packages/api/router/fieldOpt/query.attributesByCategory.handler.ts +++ b/packages/api/router/fieldOpt/query.attributesByCategory.handler.ts @@ -1,9 +1,19 @@ +import Ajv, { type JSONSchemaType } from 'ajv' + import { prisma } from '@weareinreach/db' import { type FieldAttributes } from '@weareinreach/db/zod_util/attributeSupplement' import { type TRPCHandlerParams } from '~api/types/handler' import { fieldAttributesSchema, type TAttributesByCategorySchema } from './query.attributesByCategory.schema' +const ajv = new Ajv() +const validateJsonSchema = (schema: unknown): schema is JSONSchemaType<unknown> => { + if (schema && typeof schema === 'object' && ajv.validateSchema(schema)) { + return true + } + return false +} + export const attributesByCategory = async ({ input }: TRPCHandlerParams<TAttributesByCategorySchema>) => { console.log(input) const result = await prisma.attributesByCategory.findMany({ @@ -15,15 +25,17 @@ export const attributesByCategory = async ({ input }: TRPCHandlerParams<TAttribu }) const flushedResults = result.map((item) => { - const { dataSchema, ...rest } = item + const { formSchema, dataSchema, ...rest } = item - const parsedDataSchema = fieldAttributesSchema.safeParse(dataSchema) + const parsedFormSchema = fieldAttributesSchema.safeParse(formSchema) + const parsedDataSchema = validateJsonSchema(dataSchema) ? dataSchema : null return { ...rest, - dataSchema: parsedDataSchema.success - ? (parsedDataSchema.data as FieldAttributes[] | FieldAttributes[][]) + formSchema: parsedFormSchema.success + ? (parsedFormSchema.data as FieldAttributes[] | FieldAttributes[][]) : null, + dataSchema: parsedDataSchema, } }) return flushedResults diff --git a/packages/db/prisma/migrations/20240216161351_alter_view/migration.sql b/packages/db/prisma/migrations/20240216161351_alter_view/migration.sql new file mode 100644 index 0000000000..10b021aa1f --- /dev/null +++ b/packages/db/prisma/migrations/20240216161351_alter_view/migration.sql @@ -0,0 +1,36 @@ +ALTER VIEW public.attributes_by_category RENAME COLUMN "dataSchema" TO "formSchema"; + +CREATE OR REPLACE VIEW public.attributes_by_category AS +SELECT + ac.id AS "categoryId", + ac.tag AS "categoryName", + ac.name AS "categoryDisplay", + a.id AS "attributeId", + a.tag AS "attributeName", + a."tsKey" AS "attributeKey", + a."tsNs" AS "attributeNs", + a.icon, + a."iconBg", + ac."renderVariant" AS "badgeRender", + a."requireText", + a."requireLanguage", + a."requireGeo", + a."requireBoolean", + a."requireData", + asds.definition AS "formSchema", + tkey."interpolationValues", + asds.tag AS "dataSchemaName", + a."canAttachTo", + asds.SCHEMA AS "dataSchema" +FROM + "AttributeCategory" ac + JOIN "AttributeToCategory" atc ON atc."categoryId" = ac.id + JOIN "Attribute" a ON a.id = atc."attributeId" + LEFT JOIN "AttributeSupplementDataSchema" asds ON asds.id = a."requiredSchemaId" + LEFT JOIN "TranslationKey" tkey ON tkey.key = a."tsKey" +WHERE + a.active = TRUE + AND ac.active = TRUE +ORDER BY + ac.tag, + a.tag; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index ae0370dd93..82229b3ada 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -2306,8 +2306,9 @@ view AttributesByCategory { requireBoolean Boolean requireData Boolean dataSchemaName String? - dataSchema Json? + formSchema Json? canAttachTo AttributeAttachment[] + dataSchema Json? @@unique([categoryId, attributeId]) @@map("attributes_by_category") diff --git a/packages/ui/mockData/json/fieldOpt.attributesByCategory.json b/packages/ui/mockData/json/fieldOpt.attributesByCategory.json index 31b758d22e..7f269e70be 100644 --- a/packages/ui/mockData/json/fieldOpt.attributesByCategory.json +++ b/packages/ui/mockData/json/fieldOpt.attributesByCategory.json @@ -1 +1 @@ -[{"categoryId":"attc_01GW2HHFV3DJ380F351SKB0B74","categoryName":"additional-information","categoryDisplay":"Additional Information","attributeId":"attr_01GW2HHFV3YJ2AWADHVKG79BQ0","attributeName":"at-capacity","attributeKey":"additional.at-capacity","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":"ATTRIBUTE","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"dataSchema":null},{"categoryId":"attc_01GW2HHFV3DJ380F351SKB0B74","categoryName":"additional-information","categoryDisplay":"Additional Information","attributeId":"attr_01GW2HHFV4D5ZHFMAE7852GB4P","attributeName":"geo-near-public-transit","attributeKey":"additional.geo-near-public-transit","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":"ATTRIBUTE","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"dataSchema":null},{"categoryId":"attc_01GW2HHFV3DJ380F351SKB0B74","categoryName":"additional-information","categoryDisplay":"Additional Information","attributeId":"attr_01GW2HHFV48VQJBMFA05QCBBV9","attributeName":"geo-public-transit-description","attributeKey":"additional.geo-public-transit-description","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":"ATTRIBUTE","requireText":true,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"dataSchema":null},{"categoryId":"attc_01GW2HHFV3DJ380F351SKB0B74","categoryName":"additional-information","categoryDisplay":"Additional Information","attributeId":"attr_01GW2HHFV3BADK80TG0DXXFPMM","attributeName":"has-confidentiality-policy","attributeKey":"additional.has-confidentiality-policy","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":"ATTRIBUTE","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"dataSchema":null},{"categoryId":"attc_01GW2HHFV3DJ380F351SKB0B74","categoryName":"additional-information","categoryDisplay":"Additional Information","attributeId":"attr_01GW2HHFV5Q7XN2ZNTYFR1AD3M","attributeName":"offers-remote-services","attributeKey":"additional.offers-remote-services","attributeNs":"attribute","interpolationValues":null,"icon":"carbon:globe","iconBg":null,"badgeRender":"ATTRIBUTE","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["ORGANIZATION","LOCATION"],"dataSchema":null},{"categoryId":"attc_01GW2HHFV3DJ380F351SKB0B74","categoryName":"additional-information","categoryDisplay":"Additional Information","attributeId":"attr_01GW2HHFV4TM7H5V6FHWA7S9JK","attributeName":"time-walk-in","attributeKey":"additional.time-walk-in","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":"ATTRIBUTE","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"dataSchema":null},{"categoryId":"attc_01GW2HHFV3DJ380F351SKB0B74","categoryName":"additional-information","categoryDisplay":"Additional Information","attributeId":"attr_01GW2HHFV5FYXQNGTPAQB7G2TF","attributeName":"wheelchair-accessible","attributeKey":"additional.wheelchair-accessible","attributeNs":"attribute","interpolationValues":{"true":"Accessible","false":"Not Accessible"},"icon":"carbon:accessibility","iconBg":null,"badgeRender":"ATTRIBUTE","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":true,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"dataSchema":null},{"categoryId":"attc_01GYSVX1N9T91BJYSHRDPCHJBS","categoryName":"alerts","categoryDisplay":"Alerts","attributeId":"attr_01GYSVX1NAMR6RDV6M69H4KN3T","attributeName":"info","attributeKey":"alerts.info","attributeNs":"attribute","interpolationValues":null,"icon":"carbon:information-filled","iconBg":null,"badgeRender":null,"requireText":true,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"dataSchema":null},{"categoryId":"attc_01GYSVX1N9T91BJYSHRDPCHJBS","categoryName":"alerts","categoryDisplay":"Alerts","attributeId":"attr_01GYSVX1NAKP7C6JKJ342ZM35M","attributeName":"warn","attributeKey":"alerts.warn","attributeNs":"attribute","interpolationValues":null,"icon":"carbon:warning-filled","iconBg":null,"badgeRender":null,"requireText":true,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVFKNMYPN8F86M0H576","categoryName":"cost","categoryDisplay":"Cost","attributeId":"attr_01GW2HHFVGWKWB53HWAAHQ9AAZ","attributeName":"cost-fees","attributeKey":"cost.cost-fees","attributeNs":"attribute","interpolationValues":null,"icon":"carbon:piggy-bank","iconBg":null,"badgeRender":"ATTRIBUTE","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":true,"dataSchemaName":"numMinMaxOrRange","canAttachTo":["SERVICE"],"dataSchema":[{"key":"min","label":"Min","name":"min","type":"number"},{"key":"max","label":"Max","name":"max","type":"number"}]},{"categoryId":"attc_01GW2HHFVFKNMYPN8F86M0H576","categoryName":"cost","categoryDisplay":"Cost","attributeId":"attr_01GW2HHFVGDTNW9PDQNXK6TF1T","attributeName":"cost-free","attributeKey":"cost.cost-free","attributeNs":"attribute","interpolationValues":null,"icon":"carbon:piggy-bank","iconBg":null,"badgeRender":"ATTRIBUTE","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE"],"dataSchema":null},{"categoryId":"attc_01H6P8SSY4C141YH7BAC1RW7KJ","categoryName":"crisis-support-community","categoryDisplay":"Crisis Support Community","attributeId":"attr_01GW2HHFVN72D7XEBZZJXCJQXQ","attributeName":"bipoc-comm","attributeKey":"srvfocus.bipoc-comm","attributeNs":"attribute","interpolationValues":null,"icon":"️‍️‍✊🏿","iconBg":null,"badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION","SERVICE"],"dataSchema":null},{"categoryId":"attc_01H6P8SSY4C141YH7BAC1RW7KJ","categoryName":"crisis-support-community","categoryDisplay":"Crisis Support Community","attributeId":"attr_01H6P951P0V3CR807P8KRH82S1","attributeName":"elders","attributeKey":"crisis-support-community.elders","attributeNs":"attribute","interpolationValues":null,"icon":"🌳","iconBg":null,"badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"dataSchema":null},{"categoryId":"attc_01H6P8SSY4C141YH7BAC1RW7KJ","categoryName":"crisis-support-community","categoryDisplay":"Crisis Support Community","attributeId":"attr_01H6P8T277D0C8HFQA6N09FJWD","attributeName":"general-lgbtq","attributeKey":"crisis-support-community.general-lgbtq","attributeNs":"attribute","interpolationValues":null,"icon":"🏳️‍🌈","iconBg":null,"badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"dataSchema":null},{"categoryId":"attc_01H6P8SSY4C141YH7BAC1RW7KJ","categoryName":"crisis-support-community","categoryDisplay":"Crisis Support Community","attributeId":"attr_01GW2HHFVQCZPA3Z5GW6J3MQHW","attributeName":"lgbtq-youth-focus","attributeKey":"srvfocus.lgbtq-youth-focus","attributeNs":"attribute","interpolationValues":null,"icon":"🌱","iconBg":null,"badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION","SERVICE"],"dataSchema":null},{"categoryId":"attc_01H6P8SSY4C141YH7BAC1RW7KJ","categoryName":"crisis-support-community","categoryDisplay":"Crisis Support Community","attributeId":"attr_01GW2HHFVPSYBCYF37B44WP6CZ","attributeName":"trans-comm","attributeKey":"srvfocus.trans-comm","attributeNs":"attribute","interpolationValues":null,"icon":"🏳️‍⚧️","iconBg":null,"badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION","SERVICE"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVGHPW1Y72SA8377623","categoryName":"eligibility-requirements","categoryDisplay":"Eligibility Requirements","attributeId":"attr_01GW2HHFVGSAZXGR4JAVHEK6ZC","attributeName":"elig-age","attributeKey":"eligibility.elig-age","attributeNs":"attribute","interpolationValues":{"max":"Under{{max}}","min":"{{min}} and older","range":"{{min}} -{{max}}"},"icon":null,"iconBg":null,"badgeRender":"LIST","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":true,"dataSchemaName":"numMinMaxOrRange","canAttachTo":["SERVICE"],"dataSchema":[{"key":"min","label":"Min","name":"min","type":"number"},{"key":"max","label":"Max","name":"max","type":"number"}]},{"categoryId":"attc_01GW2HHFVGHPW1Y72SA8377623","categoryName":"eligibility-requirements","categoryDisplay":"Eligibility Requirements","attributeId":"attr_01GW2HHFVJDKVF1HV7559CNZCY","attributeName":"other-describe","attributeKey":"eligibility.other-describe","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":"LIST","requireText":true,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVGHPW1Y72SA8377623","categoryName":"eligibility-requirements","categoryDisplay":"Eligibility Requirements","attributeId":"attr_01GW2HHFVH9DPBZ968VXGE50E7","attributeName":"req-medical-insurance","attributeKey":"eligibility.req-medical-insurance","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":"LIST","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVGHPW1Y72SA8377623","categoryName":"eligibility-requirements","categoryDisplay":"Eligibility Requirements","attributeId":"attr_01GW2HHFVHZ599M48CMSPGDCSC","attributeName":"req-photo-id","attributeKey":"eligibility.req-photo-id","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":"LIST","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVGHPW1Y72SA8377623","categoryName":"eligibility-requirements","categoryDisplay":"Eligibility Requirements","attributeId":"attr_01GW2HHFVH0GQK0GAJR5D952V3","attributeName":"req-proof-of-age","attributeKey":"eligibility.req-proof-of-age","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":"LIST","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVGHPW1Y72SA8377623","categoryName":"eligibility-requirements","categoryDisplay":"Eligibility Requirements","attributeId":"attr_01GW2HHFVHEVX4PMNN077ASQMG","attributeName":"req-proof-of-income","attributeKey":"eligibility.req-proof-of-income","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":"LIST","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVGHPW1Y72SA8377623","categoryName":"eligibility-requirements","categoryDisplay":"Eligibility Requirements","attributeId":"attr_01GW2HHFVHGMVCAY1G5BWF1PFB","attributeName":"req-proof-of-residence","attributeKey":"eligibility.req-proof-of-residence","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":"LIST","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVGHPW1Y72SA8377623","categoryName":"eligibility-requirements","categoryDisplay":"Eligibility Requirements","attributeId":"attr_01GW2HHFVJH8MADHYTHBV54CER","attributeName":"req-referral","attributeKey":"eligibility.req-referral","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":"LIST","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVGHPW1Y72SA8377623","categoryName":"eligibility-requirements","categoryDisplay":"Eligibility Requirements","attributeId":"attr_01GW2HHFVGJ5GD2WHNJDPSFNRW","attributeName":"time-appointment-required","attributeKey":"eligibility.time-appointment-required","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":"LIST","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVJQQ68XGSBXM976BDF","categoryName":"languages","categoryDisplay":"Languages","attributeId":"attr_01GW2HHFVJGDDWTR5D0C8BY357","attributeName":"all-languages-by-interpreter","attributeKey":"lang.all-languages-by-interpreter","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":"LIST","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVJQQ68XGSBXM976BDF","categoryName":"languages","categoryDisplay":"Languages","attributeId":"attr_01GW2HHFVJF09GXY5N5CKMSANJ","attributeName":"american-sign-language","attributeKey":"lang.american-sign-language","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":"LIST","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVJQQ68XGSBXM976BDF","categoryName":"languages","categoryDisplay":"Languages","attributeId":"attr_01GW2HHFVJ8K180CNX339BTXM2","attributeName":"lang-offered","attributeKey":"lang.lang-offered","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":"LIST","requireText":false,"requireLanguage":true,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVRSN3W3GYZZ43WCW24","categoryName":"law-practice-options","categoryDisplay":"Law Practice Options","attributeId":"attr_01GW2HHFVRH531R2HAV8DMDZSC","attributeName":"corp-law-firm","attributeKey":"userlawpractice.corp-law-firm","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["USER"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVRSN3W3GYZZ43WCW24","categoryName":"law-practice-options","categoryDisplay":"Law Practice Options","attributeId":"attr_01GW2HHFVSE2074QZJ4SKEW74J","attributeName":"law-other","attributeKey":"userlawpractice.law-other","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":true,"dataSchemaName":"otherDescribe","canAttachTo":["USER"],"dataSchema":[{"key":"other","label":"Other","name":"other","type":"text"}]},{"categoryId":"attc_01GW2HHFVRSN3W3GYZZ43WCW24","categoryName":"law-practice-options","categoryDisplay":"Law Practice Options","attributeId":"attr_01GW2HHFVRS8XEJ3TJBBEQJ707","attributeName":"law-school-clinic","attributeKey":"userlawpractice.law-school-clinic","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["USER"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVRSN3W3GYZZ43WCW24","categoryName":"law-practice-options","categoryDisplay":"Law Practice Options","attributeId":"attr_01GW2HHFVRFPRQCQHNJA6BM3XP","attributeName":"legal-nonprofit","attributeKey":"userlawpractice.legal-nonprofit","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["USER"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVMNHV2ZS5875JWCRJ7","categoryName":"organization-leadership","categoryDisplay":"Organization Leadership","attributeId":"attr_01GW2HHFVNPKMHYK12DDRVC1VJ","attributeName":"bipoc-led","attributeKey":"orgleader.bipoc-led","attributeNs":"attribute","interpolationValues":null,"icon":"🤎","iconBg":"#F1DD7F","badgeRender":"LEADER","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVMNHV2ZS5875JWCRJ7","categoryName":"organization-leadership","categoryDisplay":"Organization Leadership","attributeId":"attr_01GW2HHFVN3JX2J7REFFT5NAMS","attributeName":"black-led","attributeKey":"orgleader.black-led","attributeNs":"attribute","interpolationValues":null,"icon":"️‍️‍✊🏿","iconBg":"#C77E54","badgeRender":"LEADER","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVMNHV2ZS5875JWCRJ7","categoryName":"organization-leadership","categoryDisplay":"Organization Leadership","attributeId":"attr_01GW2HHFVNHMF72WHVKRF6W4TA","attributeName":"immigrant-led","attributeKey":"orgleader.immigrant-led","attributeNs":"attribute","interpolationValues":null,"icon":"️‍️‍🌎","iconBg":"#79ADD7","badgeRender":"LEADER","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVMNHV2ZS5875JWCRJ7","categoryName":"organization-leadership","categoryDisplay":"Organization Leadership","attributeId":"attr_01GW2HHFVN3RYX9JMXDZSQZM70","attributeName":"trans-led","attributeKey":"orgleader.trans-led","attributeNs":"attribute","interpolationValues":null,"icon":"️‍🏳️‍⚧️","iconBg":"#705890","badgeRender":"LEADER","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVKAMMGPD71H90XRJ38","categoryName":"service-access-instructions","categoryDisplay":"Service Access Instructions","attributeId":"attr_01GW2HHFVKFM4TDY4QRK4AR2ZW","attributeName":"accessemail","attributeKey":"serviceaccess.accessemail","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":true,"dataSchemaName":"accessInstructions","canAttachTo":["SERVICE"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVKAMMGPD71H90XRJ38","categoryName":"service-access-instructions","categoryDisplay":"Service Access Instructions","attributeId":"attr_01GW2HHFVKMRHFD8SMDAZM3SSM","attributeName":"accessfile","attributeKey":"serviceaccess.accessfile","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":true,"dataSchemaName":"accessInstructions","canAttachTo":["SERVICE"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVKAMMGPD71H90XRJ38","categoryName":"service-access-instructions","categoryDisplay":"Service Access Instructions","attributeId":"attr_01GW2HHFVMYXMS8ARA3GE7HZFD","attributeName":"accesslink","attributeKey":"serviceaccess.accesslink","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":true,"dataSchemaName":"accessInstructions","canAttachTo":["SERVICE"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVKAMMGPD71H90XRJ38","categoryName":"service-access-instructions","categoryDisplay":"Service Access Instructions","attributeId":"attr_01GW2HHFVMH6AE94EXN7T5A87C","attributeName":"accesslocation","attributeKey":"serviceaccess.accesslocation","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":true,"dataSchemaName":"accessInstructions","canAttachTo":["SERVICE"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVKAMMGPD71H90XRJ38","categoryName":"service-access-instructions","categoryDisplay":"Service Access Instructions","attributeId":"attr_01GW2HHFVMKTFWCKBVVFJ5GMY0","attributeName":"accessphone","attributeKey":"serviceaccess.accessphone","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":true,"dataSchemaName":"accessInstructions","canAttachTo":["SERVICE"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVKAMMGPD71H90XRJ38","categoryName":"service-access-instructions","categoryDisplay":"Service Access Instructions","attributeId":"attr_01GW2HHFVMSX7T1WDNZ5QEHKWT","attributeName":"accesspublictransit","attributeKey":"serviceaccess.accesspublictransit","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":true,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVKAMMGPD71H90XRJ38","categoryName":"service-access-instructions","categoryDisplay":"Service Access Instructions","attributeId":"attr_01GW2HHFVMMF19AX2KPBTMV6P3","attributeName":"accesstext","attributeKey":"serviceaccess.accesstext","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":true,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVNXMNJNV47BF2BPM1R","categoryName":"service-focus","categoryDisplay":"Service Focus","attributeId":"attr_01GW2HHFVPCVX8F3B7M30ZJEHW","attributeName":"asylum-seekers","attributeKey":"srvfocus.asylum-seekers","attributeNs":"attribute","interpolationValues":null,"icon":"️‍️‍🌎","iconBg":null,"badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION","SERVICE"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVNXMNJNV47BF2BPM1R","categoryName":"service-focus","categoryDisplay":"Service Focus","attributeId":"attr_01GW2HHFVN72D7XEBZZJXCJQXQ","attributeName":"bipoc-comm","attributeKey":"srvfocus.bipoc-comm","attributeNs":"attribute","interpolationValues":null,"icon":"️‍️‍✊🏿","iconBg":null,"badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION","SERVICE"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVNXMNJNV47BF2BPM1R","categoryName":"service-focus","categoryDisplay":"Service Focus","attributeId":"attr_01GW2HHFVQ7SYGD3KM8WP9X50B","attributeName":"gender-nc","attributeKey":"srvfocus.gender-nc","attributeNs":"attribute","interpolationValues":null,"icon":"🏳️‍⚧️","iconBg":null,"badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION","SERVICE"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVNXMNJNV47BF2BPM1R","categoryName":"service-focus","categoryDisplay":"Service Focus","attributeId":"attr_01GW2HHFVRMQFJ9AMA633SQQGV","attributeName":"hiv-comm","attributeKey":"srvfocus.hiv-comm","attributeNs":"attribute","interpolationValues":null,"icon":"💛","iconBg":null,"badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION","SERVICE"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVNXMNJNV47BF2BPM1R","categoryName":"service-focus","categoryDisplay":"Service Focus","attributeId":"attr_01GW2HHFVPTK9555WHJHDBDA2J","attributeName":"immigrant-comm","attributeKey":"srvfocus.immigrant-comm","attributeNs":"attribute","interpolationValues":null,"icon":"️‍️‍🌎","iconBg":null,"badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION","SERVICE"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVNXMNJNV47BF2BPM1R","categoryName":"service-focus","categoryDisplay":"Service Focus","attributeId":"attr_01GW2HHFVQCZPA3Z5GW6J3MQHW","attributeName":"lgbtq-youth-focus","attributeKey":"srvfocus.lgbtq-youth-focus","attributeNs":"attribute","interpolationValues":null,"icon":"🌱","iconBg":null,"badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION","SERVICE"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVNXMNJNV47BF2BPM1R","categoryName":"service-focus","categoryDisplay":"Service Focus","attributeId":"attr_01GW2HHFVPJERY0GS9D7F56A23","attributeName":"resettled-refugees","attributeKey":"srvfocus.resettled-refugees","attributeNs":"attribute","interpolationValues":null,"icon":"️‍️‍🌎","iconBg":null,"badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION","SERVICE"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVNXMNJNV47BF2BPM1R","categoryName":"service-focus","categoryDisplay":"Service Focus","attributeId":"attr_01GW2HHFVQ8AGBKBBZJWTHNP2F","attributeName":"spanish-speakers","attributeKey":"srvfocus.spanish-speakers","attributeNs":"attribute","interpolationValues":null,"icon":"🗣️","iconBg":null,"badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION","SERVICE"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVNXMNJNV47BF2BPM1R","categoryName":"service-focus","categoryDisplay":"Service Focus","attributeId":"attr_01GW2HHFVPSYBCYF37B44WP6CZ","attributeName":"trans-comm","attributeKey":"srvfocus.trans-comm","attributeNs":"attribute","interpolationValues":null,"icon":"🏳️‍⚧️","iconBg":null,"badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION","SERVICE"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVNXMNJNV47BF2BPM1R","categoryName":"service-focus","categoryDisplay":"Service Focus","attributeId":"attr_01GW2HHFVQX4M8DY1FSAYSJSSK","attributeName":"trans-fem","attributeKey":"srvfocus.trans-fem","attributeNs":"attribute","interpolationValues":null,"icon":"🏳️‍⚧️","iconBg":null,"badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION","SERVICE"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVNXMNJNV47BF2BPM1R","categoryName":"service-focus","categoryDisplay":"Service Focus","attributeId":"attr_01GW2HHFVQEFWW42MBAD64BWXZ","attributeName":"trans-masc","attributeKey":"srvfocus.trans-masc","attributeNs":"attribute","interpolationValues":null,"icon":"🏳️‍⚧️","iconBg":null,"badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION","SERVICE"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVNXMNJNV47BF2BPM1R","categoryName":"service-focus","categoryDisplay":"Service Focus","attributeId":"attr_01GW2HHFVQVEGH6W3A2ANH1QZE","attributeName":"trans-youth-focus","attributeKey":"srvfocus.trans-youth-focus","attributeNs":"attribute","interpolationValues":null,"icon":"🌱","iconBg":null,"badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION","SERVICE"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVSQWE2Y2RF3DT2VEYX","categoryName":"service-provider-options","categoryDisplay":"Service Provider Options","attributeId":"attr_01H2TK83N5E52PPP828SD88KP8","attributeName":"userserviceprovider.case-mananger","attributeKey":"userserviceprovider.case-mananger","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["USER"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVSQWE2Y2RF3DT2VEYX","categoryName":"service-provider-options","categoryDisplay":"Service Provider Options","attributeId":"attr_01GW2HHFVTTZ83PZR61M37R8R7","attributeName":"userserviceprovider.community-org","attributeKey":"userserviceprovider.community-org","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["USER"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVSQWE2Y2RF3DT2VEYX","categoryName":"service-provider-options","categoryDisplay":"Service Provider Options","attributeId":"attr_01GW2HHFVSPXWJJPFG9DKXESEK","attributeName":"userserviceprovider.healthcare","attributeKey":"userserviceprovider.healthcare","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["USER"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVSQWE2Y2RF3DT2VEYX","categoryName":"service-provider-options","categoryDisplay":"Service Provider Options","attributeId":"attr_01H2TM092CFVG6H0MR148AVAP7","attributeName":"userserviceprovider.lawyer","attributeKey":"userserviceprovider.lawyer","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["USER"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVSQWE2Y2RF3DT2VEYX","categoryName":"service-provider-options","categoryDisplay":"Service Provider Options","attributeId":"attr_01H2TM0AJHVK8TSR8JNFANFNZ7","attributeName":"userserviceprovider.other","attributeKey":"userserviceprovider.other","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["USER"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVSQWE2Y2RF3DT2VEYX","categoryName":"service-provider-options","categoryDisplay":"Service Provider Options","attributeId":"attr_01H2TM09EG0G84NXH40G5TESB5","attributeName":"userserviceprovider.paralegal","attributeKey":"userserviceprovider.paralegal","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["USER"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVSQWE2Y2RF3DT2VEYX","categoryName":"service-provider-options","categoryDisplay":"Service Provider Options","attributeId":"attr_01H2TM09RAK024ZDZQ6FSY0TXB","attributeName":"userserviceprovider.social-worker","attributeKey":"userserviceprovider.social-worker","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["USER"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVSQWE2Y2RF3DT2VEYX","categoryName":"service-provider-options","categoryDisplay":"Service Provider Options","attributeId":"attr_01GW2HHFVTN6MSCMBW740Y7HN1","attributeName":"userserviceprovider.student-club","attributeKey":"userserviceprovider.student-club","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["USER"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVSQWE2Y2RF3DT2VEYX","categoryName":"service-provider-options","categoryDisplay":"Service Provider Options","attributeId":"attr_01H2TM0A19DD6S97DNH76ZVP40","attributeName":"userserviceprovider.teacher","attributeKey":"userserviceprovider.teacher","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["USER"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVSQWE2Y2RF3DT2VEYX","categoryName":"service-provider-options","categoryDisplay":"Service Provider Options","attributeId":"attr_01H2TM0AA4CZXJJHMXHE1PHMVV","attributeName":"userserviceprovider.therapist-counselor","attributeKey":"userserviceprovider.therapist-counselor","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["USER"],"dataSchema":null},{"categoryId":"attc_01GW2HHFVKM2PSHFWVFM0TWX1P","categoryName":"system","categoryDisplay":"System","attributeId":"attr_01GW2HHFVK8KPRGKYFSSM5ECPQ","attributeName":"incompatible-info","attributeKey":"sys.incompatible-info","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":true,"dataSchemaName":"incompatibleData","canAttachTo":["LOCATION","ORGANIZATION","SERVICE","USER"],"dataSchema":[{"key":"incompatible","label":"Incompatible","name":"incompatible","type":"text"}]},{"categoryId":"attc_01HNG5BPYJADWX4YFVNENS3TRD","categoryName":"target-population","categoryDisplay":"Target Population","attributeId":"attr_01HNG5GDC5MXW30F32FWJNJ98C","attributeName":"tpop-other","attributeKey":"tpop.other","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":true,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE"],"dataSchema":null}] +[{"categoryId":"attc_01GW2HHFV3DJ380F351SKB0B74","categoryName":"additional-information","categoryDisplay":"Additional Information","attributeId":"attr_01GW2HHFV3YJ2AWADHVKG79BQ0","attributeName":"at-capacity","attributeKey":"additional.at-capacity","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":"ATTRIBUTE","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01GW2HHFV3DJ380F351SKB0B74","categoryName":"additional-information","categoryDisplay":"Additional Information","attributeId":"attr_01GW2HHFV4D5ZHFMAE7852GB4P","attributeName":"geo-near-public-transit","attributeKey":"additional.geo-near-public-transit","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":"ATTRIBUTE","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01GW2HHFV3DJ380F351SKB0B74","categoryName":"additional-information","categoryDisplay":"Additional Information","attributeId":"attr_01GW2HHFV48VQJBMFA05QCBBV9","attributeName":"geo-public-transit-description","attributeKey":"additional.geo-public-transit-description","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":"ATTRIBUTE","requireText":true,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01GW2HHFV3DJ380F351SKB0B74","categoryName":"additional-information","categoryDisplay":"Additional Information","attributeId":"attr_01GW2HHFV3BADK80TG0DXXFPMM","attributeName":"has-confidentiality-policy","attributeKey":"additional.has-confidentiality-policy","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":"ATTRIBUTE","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01GW2HHFV3DJ380F351SKB0B74","categoryName":"additional-information","categoryDisplay":"Additional Information","attributeId":"attr_01GW2HHFV5Q7XN2ZNTYFR1AD3M","attributeName":"offers-remote-services","attributeKey":"additional.offers-remote-services","attributeNs":"attribute","interpolationValues":null,"icon":"carbon:globe","iconBg":null,"badgeRender":"ATTRIBUTE","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["ORGANIZATION","LOCATION"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01GW2HHFV3DJ380F351SKB0B74","categoryName":"additional-information","categoryDisplay":"Additional Information","attributeId":"attr_01GW2HHFV4TM7H5V6FHWA7S9JK","attributeName":"time-walk-in","attributeKey":"additional.time-walk-in","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":"ATTRIBUTE","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01GW2HHFV3DJ380F351SKB0B74","categoryName":"additional-information","categoryDisplay":"Additional Information","attributeId":"attr_01GW2HHFV5FYXQNGTPAQB7G2TF","attributeName":"wheelchair-accessible","attributeKey":"additional.wheelchair-accessible","attributeNs":"attribute","interpolationValues":{"true":"Accessible","false":"Not Accessible"},"icon":"carbon:accessibility","iconBg":null,"badgeRender":"ATTRIBUTE","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":true,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01GYSVX1N9T91BJYSHRDPCHJBS","categoryName":"alerts","categoryDisplay":"Alerts","attributeId":"attr_01GYSVX1NAMR6RDV6M69H4KN3T","attributeName":"info","attributeKey":"alerts.info","attributeNs":"attribute","interpolationValues":null,"icon":"carbon:information-filled","iconBg":null,"badgeRender":null,"requireText":true,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01GYSVX1N9T91BJYSHRDPCHJBS","categoryName":"alerts","categoryDisplay":"Alerts","attributeId":"attr_01GYSVX1NAKP7C6JKJ342ZM35M","attributeName":"warn","attributeKey":"alerts.warn","attributeNs":"attribute","interpolationValues":null,"icon":"carbon:warning-filled","iconBg":null,"badgeRender":null,"requireText":true,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01GW2HHFVFKNMYPN8F86M0H576","categoryName":"cost","categoryDisplay":"Cost","attributeId":"attr_01GW2HHFVGWKWB53HWAAHQ9AAZ","attributeName":"cost-fees","attributeKey":"cost.cost-fees","attributeNs":"attribute","interpolationValues":null,"icon":"carbon:piggy-bank","iconBg":null,"badgeRender":"ATTRIBUTE","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":true,"dataSchemaName":"numMinMaxOrRange","canAttachTo":["SERVICE"],"formSchema":[{"key":"min","label":"Min","name":"min","type":"number"},{"key":"max","label":"Max","name":"max","type":"number"}],"dataSchema":{"anyOf":[{"type":"object","required":["min"],"properties":{"min":{"type":"number"}}},{"type":"object","required":["max"],"properties":{"max":{"type":"number"}}},{"type":"object","required":["min","max"],"properties":{"max":{"type":"number"},"min":{"type":"number"}}}],"$schema":"http://json-schema.org/draft-07/schema#"}},{"categoryId":"attc_01GW2HHFVFKNMYPN8F86M0H576","categoryName":"cost","categoryDisplay":"Cost","attributeId":"attr_01GW2HHFVGDTNW9PDQNXK6TF1T","attributeName":"cost-free","attributeKey":"cost.cost-free","attributeNs":"attribute","interpolationValues":null,"icon":"carbon:piggy-bank","iconBg":null,"badgeRender":"ATTRIBUTE","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01H6P8SSY4C141YH7BAC1RW7KJ","categoryName":"crisis-support-community","categoryDisplay":"Crisis Support Community","attributeId":"attr_01GW2HHFVN72D7XEBZZJXCJQXQ","attributeName":"bipoc-comm","attributeKey":"srvfocus.bipoc-comm","attributeNs":"attribute","interpolationValues":null,"icon":"️‍️‍✊🏿","iconBg":null,"badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION","SERVICE"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01H6P8SSY4C141YH7BAC1RW7KJ","categoryName":"crisis-support-community","categoryDisplay":"Crisis Support Community","attributeId":"attr_01H6P951P0V3CR807P8KRH82S1","attributeName":"elders","attributeKey":"crisis-support-community.elders","attributeNs":"attribute","interpolationValues":null,"icon":"🌳","iconBg":null,"badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01H6P8SSY4C141YH7BAC1RW7KJ","categoryName":"crisis-support-community","categoryDisplay":"Crisis Support Community","attributeId":"attr_01H6P8T277D0C8HFQA6N09FJWD","attributeName":"general-lgbtq","attributeKey":"crisis-support-community.general-lgbtq","attributeNs":"attribute","interpolationValues":null,"icon":"🏳️‍🌈","iconBg":null,"badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01H6P8SSY4C141YH7BAC1RW7KJ","categoryName":"crisis-support-community","categoryDisplay":"Crisis Support Community","attributeId":"attr_01GW2HHFVQCZPA3Z5GW6J3MQHW","attributeName":"lgbtq-youth-focus","attributeKey":"srvfocus.lgbtq-youth-focus","attributeNs":"attribute","interpolationValues":null,"icon":"🌱","iconBg":null,"badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION","SERVICE"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01H6P8SSY4C141YH7BAC1RW7KJ","categoryName":"crisis-support-community","categoryDisplay":"Crisis Support Community","attributeId":"attr_01GW2HHFVPSYBCYF37B44WP6CZ","attributeName":"trans-comm","attributeKey":"srvfocus.trans-comm","attributeNs":"attribute","interpolationValues":null,"icon":"🏳️‍⚧️","iconBg":null,"badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION","SERVICE"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01GW2HHFVGHPW1Y72SA8377623","categoryName":"eligibility-requirements","categoryDisplay":"Eligibility Requirements","attributeId":"attr_01GW2HHFVGSAZXGR4JAVHEK6ZC","attributeName":"elig-age","attributeKey":"eligibility.elig-age","attributeNs":"attribute","interpolationValues":{"max":"Under{{max}}","min":"{{min}} and older","range":"{{min}} -{{max}}"},"icon":null,"iconBg":null,"badgeRender":"LIST","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":true,"dataSchemaName":"numMinMaxOrRange","canAttachTo":["SERVICE"],"formSchema":[{"key":"min","label":"Min","name":"min","type":"number"},{"key":"max","label":"Max","name":"max","type":"number"}],"dataSchema":{"anyOf":[{"type":"object","required":["min"],"properties":{"min":{"type":"number"}}},{"type":"object","required":["max"],"properties":{"max":{"type":"number"}}},{"type":"object","required":["min","max"],"properties":{"max":{"type":"number"},"min":{"type":"number"}}}],"$schema":"http://json-schema.org/draft-07/schema#"}},{"categoryId":"attc_01GW2HHFVGHPW1Y72SA8377623","categoryName":"eligibility-requirements","categoryDisplay":"Eligibility Requirements","attributeId":"attr_01GW2HHFVJDKVF1HV7559CNZCY","attributeName":"other-describe","attributeKey":"eligibility.other-describe","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":"LIST","requireText":true,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01GW2HHFVGHPW1Y72SA8377623","categoryName":"eligibility-requirements","categoryDisplay":"Eligibility Requirements","attributeId":"attr_01GW2HHFVH9DPBZ968VXGE50E7","attributeName":"req-medical-insurance","attributeKey":"eligibility.req-medical-insurance","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":"LIST","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01GW2HHFVGHPW1Y72SA8377623","categoryName":"eligibility-requirements","categoryDisplay":"Eligibility Requirements","attributeId":"attr_01GW2HHFVHZ599M48CMSPGDCSC","attributeName":"req-photo-id","attributeKey":"eligibility.req-photo-id","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":"LIST","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01GW2HHFVGHPW1Y72SA8377623","categoryName":"eligibility-requirements","categoryDisplay":"Eligibility Requirements","attributeId":"attr_01GW2HHFVH0GQK0GAJR5D952V3","attributeName":"req-proof-of-age","attributeKey":"eligibility.req-proof-of-age","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":"LIST","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01GW2HHFVGHPW1Y72SA8377623","categoryName":"eligibility-requirements","categoryDisplay":"Eligibility Requirements","attributeId":"attr_01GW2HHFVHEVX4PMNN077ASQMG","attributeName":"req-proof-of-income","attributeKey":"eligibility.req-proof-of-income","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":"LIST","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01GW2HHFVGHPW1Y72SA8377623","categoryName":"eligibility-requirements","categoryDisplay":"Eligibility Requirements","attributeId":"attr_01GW2HHFVHGMVCAY1G5BWF1PFB","attributeName":"req-proof-of-residence","attributeKey":"eligibility.req-proof-of-residence","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":"LIST","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01GW2HHFVGHPW1Y72SA8377623","categoryName":"eligibility-requirements","categoryDisplay":"Eligibility Requirements","attributeId":"attr_01GW2HHFVJH8MADHYTHBV54CER","attributeName":"req-referral","attributeKey":"eligibility.req-referral","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":"LIST","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01GW2HHFVGHPW1Y72SA8377623","categoryName":"eligibility-requirements","categoryDisplay":"Eligibility Requirements","attributeId":"attr_01GW2HHFVGJ5GD2WHNJDPSFNRW","attributeName":"time-appointment-required","attributeKey":"eligibility.time-appointment-required","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":"LIST","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01GW2HHFVJQQ68XGSBXM976BDF","categoryName":"languages","categoryDisplay":"Languages","attributeId":"attr_01GW2HHFVJGDDWTR5D0C8BY357","attributeName":"all-languages-by-interpreter","attributeKey":"lang.all-languages-by-interpreter","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":"LIST","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01GW2HHFVJQQ68XGSBXM976BDF","categoryName":"languages","categoryDisplay":"Languages","attributeId":"attr_01GW2HHFVJF09GXY5N5CKMSANJ","attributeName":"american-sign-language","attributeKey":"lang.american-sign-language","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":"LIST","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01GW2HHFVJQQ68XGSBXM976BDF","categoryName":"languages","categoryDisplay":"Languages","attributeId":"attr_01GW2HHFVJ8K180CNX339BTXM2","attributeName":"lang-offered","attributeKey":"lang.lang-offered","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":"LIST","requireText":false,"requireLanguage":true,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE","ORGANIZATION","LOCATION"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01GW2HHFVRSN3W3GYZZ43WCW24","categoryName":"law-practice-options","categoryDisplay":"Law Practice Options","attributeId":"attr_01GW2HHFVRH531R2HAV8DMDZSC","attributeName":"corp-law-firm","attributeKey":"userlawpractice.corp-law-firm","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["USER"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01GW2HHFVRSN3W3GYZZ43WCW24","categoryName":"law-practice-options","categoryDisplay":"Law Practice Options","attributeId":"attr_01GW2HHFVSE2074QZJ4SKEW74J","attributeName":"law-other","attributeKey":"userlawpractice.law-other","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":true,"dataSchemaName":"otherDescribe","canAttachTo":["USER"],"formSchema":[{"key":"other","label":"Other","name":"other","type":"text"}],"dataSchema":{"type":"object","$schema":"http://json-schema.org/draft-07/schema#","required":["other"],"properties":{"other":{"type":"string"}}}},{"categoryId":"attc_01GW2HHFVRSN3W3GYZZ43WCW24","categoryName":"law-practice-options","categoryDisplay":"Law Practice Options","attributeId":"attr_01GW2HHFVRS8XEJ3TJBBEQJ707","attributeName":"law-school-clinic","attributeKey":"userlawpractice.law-school-clinic","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["USER"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01GW2HHFVRSN3W3GYZZ43WCW24","categoryName":"law-practice-options","categoryDisplay":"Law Practice Options","attributeId":"attr_01GW2HHFVRFPRQCQHNJA6BM3XP","attributeName":"legal-nonprofit","attributeKey":"userlawpractice.legal-nonprofit","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["USER"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01GW2HHFVMNHV2ZS5875JWCRJ7","categoryName":"organization-leadership","categoryDisplay":"Organization Leadership","attributeId":"attr_01GW2HHFVNPKMHYK12DDRVC1VJ","attributeName":"bipoc-led","attributeKey":"orgleader.bipoc-led","attributeNs":"attribute","interpolationValues":null,"icon":"🤎","iconBg":"#F1DD7F","badgeRender":"LEADER","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01GW2HHFVMNHV2ZS5875JWCRJ7","categoryName":"organization-leadership","categoryDisplay":"Organization Leadership","attributeId":"attr_01GW2HHFVN3JX2J7REFFT5NAMS","attributeName":"black-led","attributeKey":"orgleader.black-led","attributeNs":"attribute","interpolationValues":null,"icon":"️‍️‍✊🏿","iconBg":"#C77E54","badgeRender":"LEADER","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01GW2HHFVMNHV2ZS5875JWCRJ7","categoryName":"organization-leadership","categoryDisplay":"Organization Leadership","attributeId":"attr_01GW2HHFVNHMF72WHVKRF6W4TA","attributeName":"immigrant-led","attributeKey":"orgleader.immigrant-led","attributeNs":"attribute","interpolationValues":null,"icon":"️‍️‍🌎","iconBg":"#79ADD7","badgeRender":"LEADER","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01GW2HHFVMNHV2ZS5875JWCRJ7","categoryName":"organization-leadership","categoryDisplay":"Organization Leadership","attributeId":"attr_01GW2HHFVN3RYX9JMXDZSQZM70","attributeName":"trans-led","attributeKey":"orgleader.trans-led","attributeNs":"attribute","interpolationValues":null,"icon":"️‍🏳️‍⚧️","iconBg":"#705890","badgeRender":"LEADER","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01GW2HHFVKAMMGPD71H90XRJ38","categoryName":"service-access-instructions","categoryDisplay":"Service Access Instructions","attributeId":"attr_01GW2HHFVKFM4TDY4QRK4AR2ZW","attributeName":"accessemail","attributeKey":"serviceaccess.accessemail","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":true,"dataSchemaName":"accessInstructions","canAttachTo":["SERVICE"],"formSchema":null,"dataSchema":{"type":"object","$schema":"http://json-schema.org/draft-07/schema#","required":["access_type","instructions"],"properties":{"access_type":{"enum":["email","file","link","location","other","phone"],"type":"string"},"access_value":{"type":["string","null"]},"instructions":{"type":"string"}}}},{"categoryId":"attc_01GW2HHFVKAMMGPD71H90XRJ38","categoryName":"service-access-instructions","categoryDisplay":"Service Access Instructions","attributeId":"attr_01GW2HHFVKMRHFD8SMDAZM3SSM","attributeName":"accessfile","attributeKey":"serviceaccess.accessfile","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":true,"dataSchemaName":"accessInstructions","canAttachTo":["SERVICE"],"formSchema":null,"dataSchema":{"type":"object","$schema":"http://json-schema.org/draft-07/schema#","required":["access_type","instructions"],"properties":{"access_type":{"enum":["email","file","link","location","other","phone"],"type":"string"},"access_value":{"type":["string","null"]},"instructions":{"type":"string"}}}},{"categoryId":"attc_01GW2HHFVKAMMGPD71H90XRJ38","categoryName":"service-access-instructions","categoryDisplay":"Service Access Instructions","attributeId":"attr_01GW2HHFVMYXMS8ARA3GE7HZFD","attributeName":"accesslink","attributeKey":"serviceaccess.accesslink","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":true,"dataSchemaName":"accessInstructions","canAttachTo":["SERVICE"],"formSchema":null,"dataSchema":{"type":"object","$schema":"http://json-schema.org/draft-07/schema#","required":["access_type","instructions"],"properties":{"access_type":{"enum":["email","file","link","location","other","phone"],"type":"string"},"access_value":{"type":["string","null"]},"instructions":{"type":"string"}}}},{"categoryId":"attc_01GW2HHFVKAMMGPD71H90XRJ38","categoryName":"service-access-instructions","categoryDisplay":"Service Access Instructions","attributeId":"attr_01GW2HHFVMH6AE94EXN7T5A87C","attributeName":"accesslocation","attributeKey":"serviceaccess.accesslocation","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":true,"dataSchemaName":"accessInstructions","canAttachTo":["SERVICE"],"formSchema":null,"dataSchema":{"type":"object","$schema":"http://json-schema.org/draft-07/schema#","required":["access_type","instructions"],"properties":{"access_type":{"enum":["email","file","link","location","other","phone"],"type":"string"},"access_value":{"type":["string","null"]},"instructions":{"type":"string"}}}},{"categoryId":"attc_01GW2HHFVKAMMGPD71H90XRJ38","categoryName":"service-access-instructions","categoryDisplay":"Service Access Instructions","attributeId":"attr_01GW2HHFVMKTFWCKBVVFJ5GMY0","attributeName":"accessphone","attributeKey":"serviceaccess.accessphone","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":true,"dataSchemaName":"accessInstructions","canAttachTo":["SERVICE"],"formSchema":null,"dataSchema":{"type":"object","$schema":"http://json-schema.org/draft-07/schema#","required":["access_type","instructions"],"properties":{"access_type":{"enum":["email","file","link","location","other","phone"],"type":"string"},"access_value":{"type":["string","null"]},"instructions":{"type":"string"}}}},{"categoryId":"attc_01GW2HHFVKAMMGPD71H90XRJ38","categoryName":"service-access-instructions","categoryDisplay":"Service Access Instructions","attributeId":"attr_01GW2HHFVMSX7T1WDNZ5QEHKWT","attributeName":"accesspublictransit","attributeKey":"serviceaccess.accesspublictransit","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":true,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01GW2HHFVKAMMGPD71H90XRJ38","categoryName":"service-access-instructions","categoryDisplay":"Service Access Instructions","attributeId":"attr_01GW2HHFVMMF19AX2KPBTMV6P3","attributeName":"accesstext","attributeKey":"serviceaccess.accesstext","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":true,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01GW2HHFVNXMNJNV47BF2BPM1R","categoryName":"service-focus","categoryDisplay":"Service Focus","attributeId":"attr_01GW2HHFVPCVX8F3B7M30ZJEHW","attributeName":"asylum-seekers","attributeKey":"srvfocus.asylum-seekers","attributeNs":"attribute","interpolationValues":null,"icon":"️‍️‍🌎","iconBg":null,"badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION","SERVICE"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01GW2HHFVNXMNJNV47BF2BPM1R","categoryName":"service-focus","categoryDisplay":"Service Focus","attributeId":"attr_01GW2HHFVN72D7XEBZZJXCJQXQ","attributeName":"bipoc-comm","attributeKey":"srvfocus.bipoc-comm","attributeNs":"attribute","interpolationValues":null,"icon":"️‍️‍✊🏿","iconBg":null,"badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION","SERVICE"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01GW2HHFVNXMNJNV47BF2BPM1R","categoryName":"service-focus","categoryDisplay":"Service Focus","attributeId":"attr_01GW2HHFVQ7SYGD3KM8WP9X50B","attributeName":"gender-nc","attributeKey":"srvfocus.gender-nc","attributeNs":"attribute","interpolationValues":null,"icon":"🏳️‍⚧️","iconBg":null,"badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION","SERVICE"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01GW2HHFVNXMNJNV47BF2BPM1R","categoryName":"service-focus","categoryDisplay":"Service Focus","attributeId":"attr_01GW2HHFVRMQFJ9AMA633SQQGV","attributeName":"hiv-comm","attributeKey":"srvfocus.hiv-comm","attributeNs":"attribute","interpolationValues":null,"icon":"💛","iconBg":null,"badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION","SERVICE"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01GW2HHFVNXMNJNV47BF2BPM1R","categoryName":"service-focus","categoryDisplay":"Service Focus","attributeId":"attr_01GW2HHFVPTK9555WHJHDBDA2J","attributeName":"immigrant-comm","attributeKey":"srvfocus.immigrant-comm","attributeNs":"attribute","interpolationValues":null,"icon":"️‍️‍🌎","iconBg":null,"badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION","SERVICE"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01GW2HHFVNXMNJNV47BF2BPM1R","categoryName":"service-focus","categoryDisplay":"Service Focus","attributeId":"attr_01GW2HHFVQCZPA3Z5GW6J3MQHW","attributeName":"lgbtq-youth-focus","attributeKey":"srvfocus.lgbtq-youth-focus","attributeNs":"attribute","interpolationValues":null,"icon":"🌱","iconBg":null,"badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION","SERVICE"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01GW2HHFVNXMNJNV47BF2BPM1R","categoryName":"service-focus","categoryDisplay":"Service Focus","attributeId":"attr_01GW2HHFVPJERY0GS9D7F56A23","attributeName":"resettled-refugees","attributeKey":"srvfocus.resettled-refugees","attributeNs":"attribute","interpolationValues":null,"icon":"️‍️‍🌎","iconBg":null,"badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION","SERVICE"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01GW2HHFVNXMNJNV47BF2BPM1R","categoryName":"service-focus","categoryDisplay":"Service Focus","attributeId":"attr_01GW2HHFVQ8AGBKBBZJWTHNP2F","attributeName":"spanish-speakers","attributeKey":"srvfocus.spanish-speakers","attributeNs":"attribute","interpolationValues":null,"icon":"🗣️","iconBg":null,"badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION","SERVICE"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01GW2HHFVNXMNJNV47BF2BPM1R","categoryName":"service-focus","categoryDisplay":"Service Focus","attributeId":"attr_01GW2HHFVPSYBCYF37B44WP6CZ","attributeName":"trans-comm","attributeKey":"srvfocus.trans-comm","attributeNs":"attribute","interpolationValues":null,"icon":"🏳️‍⚧️","iconBg":null,"badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION","SERVICE"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01GW2HHFVNXMNJNV47BF2BPM1R","categoryName":"service-focus","categoryDisplay":"Service Focus","attributeId":"attr_01GW2HHFVQX4M8DY1FSAYSJSSK","attributeName":"trans-fem","attributeKey":"srvfocus.trans-fem","attributeNs":"attribute","interpolationValues":null,"icon":"🏳️‍⚧️","iconBg":null,"badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION","SERVICE"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01GW2HHFVNXMNJNV47BF2BPM1R","categoryName":"service-focus","categoryDisplay":"Service Focus","attributeId":"attr_01GW2HHFVQEFWW42MBAD64BWXZ","attributeName":"trans-masc","attributeKey":"srvfocus.trans-masc","attributeNs":"attribute","interpolationValues":null,"icon":"🏳️‍⚧️","iconBg":null,"badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION","SERVICE"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01GW2HHFVNXMNJNV47BF2BPM1R","categoryName":"service-focus","categoryDisplay":"Service Focus","attributeId":"attr_01GW2HHFVQVEGH6W3A2ANH1QZE","attributeName":"trans-youth-focus","attributeKey":"srvfocus.trans-youth-focus","attributeNs":"attribute","interpolationValues":null,"icon":"🌱","iconBg":null,"badgeRender":"COMMUNITY","requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["LOCATION","ORGANIZATION","SERVICE"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01GW2HHFVSQWE2Y2RF3DT2VEYX","categoryName":"service-provider-options","categoryDisplay":"Service Provider Options","attributeId":"attr_01H2TK83N5E52PPP828SD88KP8","attributeName":"userserviceprovider.case-mananger","attributeKey":"userserviceprovider.case-mananger","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["USER"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01GW2HHFVSQWE2Y2RF3DT2VEYX","categoryName":"service-provider-options","categoryDisplay":"Service Provider Options","attributeId":"attr_01GW2HHFVTTZ83PZR61M37R8R7","attributeName":"userserviceprovider.community-org","attributeKey":"userserviceprovider.community-org","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["USER"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01GW2HHFVSQWE2Y2RF3DT2VEYX","categoryName":"service-provider-options","categoryDisplay":"Service Provider Options","attributeId":"attr_01GW2HHFVSPXWJJPFG9DKXESEK","attributeName":"userserviceprovider.healthcare","attributeKey":"userserviceprovider.healthcare","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["USER"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01GW2HHFVSQWE2Y2RF3DT2VEYX","categoryName":"service-provider-options","categoryDisplay":"Service Provider Options","attributeId":"attr_01H2TM092CFVG6H0MR148AVAP7","attributeName":"userserviceprovider.lawyer","attributeKey":"userserviceprovider.lawyer","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["USER"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01GW2HHFVSQWE2Y2RF3DT2VEYX","categoryName":"service-provider-options","categoryDisplay":"Service Provider Options","attributeId":"attr_01H2TM0AJHVK8TSR8JNFANFNZ7","attributeName":"userserviceprovider.other","attributeKey":"userserviceprovider.other","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["USER"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01GW2HHFVSQWE2Y2RF3DT2VEYX","categoryName":"service-provider-options","categoryDisplay":"Service Provider Options","attributeId":"attr_01H2TM09EG0G84NXH40G5TESB5","attributeName":"userserviceprovider.paralegal","attributeKey":"userserviceprovider.paralegal","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["USER"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01GW2HHFVSQWE2Y2RF3DT2VEYX","categoryName":"service-provider-options","categoryDisplay":"Service Provider Options","attributeId":"attr_01H2TM09RAK024ZDZQ6FSY0TXB","attributeName":"userserviceprovider.social-worker","attributeKey":"userserviceprovider.social-worker","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["USER"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01GW2HHFVSQWE2Y2RF3DT2VEYX","categoryName":"service-provider-options","categoryDisplay":"Service Provider Options","attributeId":"attr_01GW2HHFVTN6MSCMBW740Y7HN1","attributeName":"userserviceprovider.student-club","attributeKey":"userserviceprovider.student-club","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["USER"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01GW2HHFVSQWE2Y2RF3DT2VEYX","categoryName":"service-provider-options","categoryDisplay":"Service Provider Options","attributeId":"attr_01H2TM0A19DD6S97DNH76ZVP40","attributeName":"userserviceprovider.teacher","attributeKey":"userserviceprovider.teacher","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["USER"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01GW2HHFVSQWE2Y2RF3DT2VEYX","categoryName":"service-provider-options","categoryDisplay":"Service Provider Options","attributeId":"attr_01H2TM0AA4CZXJJHMXHE1PHMVV","attributeName":"userserviceprovider.therapist-counselor","attributeKey":"userserviceprovider.therapist-counselor","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["USER"],"formSchema":null,"dataSchema":null},{"categoryId":"attc_01GW2HHFVKM2PSHFWVFM0TWX1P","categoryName":"system","categoryDisplay":"System","attributeId":"attr_01GW2HHFVK8KPRGKYFSSM5ECPQ","attributeName":"incompatible-info","attributeKey":"sys.incompatible-info","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":false,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":true,"dataSchemaName":"incompatibleData","canAttachTo":["LOCATION","ORGANIZATION","SERVICE","USER"],"formSchema":[{"key":"incompatible","label":"Incompatible","name":"incompatible","type":"text"}],"dataSchema":{"type":"array","items":{"type":"object","additionalProperties":{}},"$schema":"http://json-schema.org/draft-07/schema#"}},{"categoryId":"attc_01HNG5BPYJADWX4YFVNENS3TRD","categoryName":"target-population","categoryDisplay":"Target Population","attributeId":"attr_01HNG5GDC5MXW30F32FWJNJ98C","attributeName":"tpop-other","attributeKey":"tpop.other","attributeNs":"attribute","interpolationValues":null,"icon":null,"iconBg":null,"badgeRender":null,"requireText":true,"requireLanguage":false,"requireGeo":false,"requireBoolean":false,"requireData":false,"dataSchemaName":null,"canAttachTo":["SERVICE"],"formSchema":null,"dataSchema":null}] diff --git a/packages/ui/package.json b/packages/ui/package.json index 3c80910585..89eab18bd0 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -32,6 +32,7 @@ "@weareinreach/util": "workspace:*", "ahooks": "3.7.10", "ajv": "8.12.0", + "ajv-errors": "3.0.0", "alex": "11.0.1", "cookies-next": "4.1.1", "crud-object-diff": "2.3.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7442e48fb2..915e36e235 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -685,6 +685,9 @@ importers: '@weareinreach/util': specifier: workspace:* version: link:../util + ajv: + specifier: 8.12.0 + version: 8.12.0 alex: specifier: 11.0.1 version: 11.0.1 @@ -1248,6 +1251,9 @@ importers: ajv: specifier: 8.12.0 version: 8.12.0 + ajv-errors: + specifier: 3.0.0 + version: 3.0.0(ajv@8.12.0) alex: specifier: 11.0.1 version: 11.0.1 @@ -5169,7 +5175,7 @@ packages: dependencies: '@mantine/ssr': 6.0.21(@emotion/react@11.11.3)(@emotion/server@11.11.0)(react-dom@18.2.0)(react@18.2.0) '@mantine/styles': 6.0.21(@emotion/react@11.11.3)(react-dom@18.2.0)(react@18.2.0) - next: 14.1.0(@opentelemetry/api@1.7.0)(react-dom@18.2.0)(react@18.2.0) + next: 14.1.0(@babel/core@7.23.9)(react-dom@18.2.0)(react@18.2.0) react: 18.2.0 react-dom: 18.2.0(react@18.2.0) transitivePeerDependencies: @@ -5575,7 +5581,7 @@ packages: next: ^13.0.0 || ^14.0.0 || 13 react: ^18.2.0 || 18 dependencies: - next: 14.1.0(@opentelemetry/api@1.7.0)(react-dom@18.2.0)(react@18.2.0) + next: 14.1.0(@babel/core@7.23.9)(react-dom@18.2.0)(react@18.2.0) react: 18.2.0 third-party-capital: 1.0.20 @@ -9755,7 +9761,7 @@ packages: '@trpc/client': 10.45.1(@trpc/server@10.45.1) '@trpc/react-query': 10.45.1(@tanstack/react-query@4.36.1)(@trpc/client@10.45.1)(@trpc/server@10.45.1)(react-dom@18.2.0)(react@18.2.0) '@trpc/server': 10.45.1 - next: 14.1.0(@opentelemetry/api@1.7.0)(react-dom@18.2.0)(react@18.2.0) + next: 14.1.0(@babel/core@7.23.9)(react-dom@18.2.0)(react@18.2.0) react: 18.2.0 react-dom: 18.2.0(react@18.2.0) @@ -11121,6 +11127,14 @@ packages: tslib: 2.6.2 dev: false + /ajv-errors@3.0.0(ajv@8.12.0): + resolution: {integrity: sha512-V3wD15YHfHz6y0KdhYFjyy9vWtEVALT9UrxfN3zqlI6dMioHnJrqOYfyPKol3oqrnCM9uwkcdCwkJ0WUcbLMTQ==} + peerDependencies: + ajv: ^8.0.1 + dependencies: + ajv: 8.12.0 + dev: false + /ajv-formats@2.1.1(ajv@8.12.0): resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} peerDependencies: @@ -19734,7 +19748,7 @@ packages: '@panva/hkdf': 1.1.1 cookie: 0.5.0 jose: 4.15.4 - next: 14.1.0(@opentelemetry/api@1.7.0)(react-dom@18.2.0)(react@18.2.0) + next: 14.1.0(@babel/core@7.23.9)(react-dom@18.2.0)(react@18.2.0) oauth: 0.9.15 openid-client: 5.6.4 preact: 10.19.3 @@ -19758,7 +19772,7 @@ packages: hoist-non-react-statics: 3.3.2 i18next: 23.8.2 i18next-fs-backend: 2.3.1 - next: 14.1.0(@opentelemetry/api@1.7.0)(react-dom@18.2.0)(react@18.2.0) + next: 14.1.0(@babel/core@7.23.9)(react-dom@18.2.0)(react@18.2.0) react: 18.2.0 react-i18next: 14.0.5(i18next@23.8.2)(react-dom@18.2.0)(react@18.2.0) @@ -19811,7 +19825,6 @@ packages: transitivePeerDependencies: - '@babel/core' - babel-plugin-macros - dev: true /next@14.1.0(@opentelemetry/api@1.7.0)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-wlzrsbfeSU48YQBjZhDzOwhWhGsy+uQycR8bHAOt1LY1bn3zZEcDyHQOEoN3aWzQ8LHCAJ1nqrWCc9XF2+O45Q==} @@ -19870,7 +19883,7 @@ packages: next: '*' dependencies: chokidar: 3.5.3 - next: 14.1.0(@opentelemetry/api@1.7.0)(react-dom@18.2.0)(react@18.2.0) + next: 14.1.0(@babel/core@7.23.9)(react-dom@18.2.0)(react@18.2.0) dev: false /nice-try@1.0.5: From c2f3f5a38fd901cb6d41cdad6903b5bb0d251814 Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Fri, 16 Feb 2024 17:11:52 -0500 Subject: [PATCH 17/61] supplement handling --- .../ui/modals/dataPortal/Attributes/index.tsx | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/ui/modals/dataPortal/Attributes/index.tsx b/packages/ui/modals/dataPortal/Attributes/index.tsx index 90146af75a..bae7257103 100644 --- a/packages/ui/modals/dataPortal/Attributes/index.tsx +++ b/packages/ui/modals/dataPortal/Attributes/index.tsx @@ -13,7 +13,7 @@ import { Text, } from '@mantine/core' import { useDisclosure } from '@mantine/hooks' -import Ajv from 'ajv' +import { type JSONSchemaType } from 'ajv' import { useTranslation } from 'next-i18next' import { forwardRef, useMemo, useRef, useState } from 'react' import { FormProvider, useForm } from 'react-hook-form' @@ -41,9 +41,6 @@ const AttributeModalBody = forwardRef<HTMLButtonElement, AttributeModalProps>( const { t } = useTranslation(['attribute', 'common']) const [opened, handler] = useDisclosure(false) - const form = useForm<FormSchema>({ - resolver: zodResolver(formSchema), - }) const selectAttrRef = useRef<HTMLInputElement>(null) // #region tRPC const utils = api.useUtils() @@ -93,15 +90,23 @@ const AttributeModalBody = forwardRef<HTMLButtonElement, AttributeModalProps>( null ) const [supplements, setSupplements] = useState<SupplementFieldsNeeded>(supplementDefaults) + const [supplementSchema, setSupplementSchema] = useState<JSONSchemaType<unknown> | null>(null) const saveAttributes = api.organization.attachAttribute.useMutation() // #endregion // #region Handlers + const form = useForm<FormSchema>({ + resolver: zodResolver(formSchema), + }) + const selectHandler = (e: string | null) => { console.log('selectHandler', e) if (e === null) { setSupplements(supplementDefaults) setSelectedAttr(null) + if (supplementSchema !== null) { + setSupplementSchema(null) + } return } const item = attributesByCategory?.find(({ value }) => value === e) @@ -119,6 +124,9 @@ const AttributeModalBody = forwardRef<HTMLButtonElement, AttributeModalProps>( data: requireData ?? false, } setSupplements(suppRequired) + if (requireData && item.dataSchema) { + setSupplementSchema(item.dataSchema) + } return } @@ -182,8 +190,8 @@ const AttributeModalBody = forwardRef<HTMLButtonElement, AttributeModalProps>( </Stack> {supplements.boolean && <Supplement.Boolean />} {supplements.text && <Supplement.Text />} - {supplements.data && selectedAttr?.dataSchema && ( - <Supplement.Data schema={selectedAttr.dataSchema} /> + {supplements.data && selectedAttr?.formSchema && ( + <Supplement.Data formSchema={selectedAttr.formSchema} dataSchema={selectedAttr.dataSchema} /> )} {supplements.language && <Supplement.Language />} {supplements.geo && <Supplement.Geo />} From 8430e69868a24854f3286e2dc99367774ab78f99 Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Tue, 20 Feb 2024 17:26:13 -0500 Subject: [PATCH 18/61] uncomment criteria --- .../api/router/fieldOpt/query.attributesByCategory.handler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api/router/fieldOpt/query.attributesByCategory.handler.ts b/packages/api/router/fieldOpt/query.attributesByCategory.handler.ts index 22dc9be67b..771c577cff 100644 --- a/packages/api/router/fieldOpt/query.attributesByCategory.handler.ts +++ b/packages/api/router/fieldOpt/query.attributesByCategory.handler.ts @@ -19,7 +19,7 @@ export const attributesByCategory = async ({ input }: TRPCHandlerParams<TAttribu const result = await prisma.attributesByCategory.findMany({ where: { categoryName: Array.isArray(input?.categoryName) ? { in: input.categoryName } : input?.categoryName, - // canAttachTo: input?.canAttachTo?.length ? { hasSome: input.canAttachTo } : undefined, + canAttachTo: input?.canAttachTo?.length ? { hasSome: input.canAttachTo } : undefined, }, orderBy: [{ categoryName: 'asc' }, { attributeName: 'asc' }], }) From 4e8d75673cba2652235d584985d0861cb7916029 Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Mon, 18 Mar 2024 14:23:30 -0400 Subject: [PATCH 19/61] update mock data --- packages/ui/mockData/json/location.forLocationCard.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/mockData/json/location.forLocationCard.json b/packages/ui/mockData/json/location.forLocationCard.json index de10234432..8d843aa5e1 100644 --- a/packages/ui/mockData/json/location.forLocationCard.json +++ b/packages/ui/mockData/json/location.forLocationCard.json @@ -1 +1 @@ -{"id":"oloc_01GVH3VEVBERFNA9PHHJYEBGA3","name":"Whitman-Walker 1525","street1":"1525 14th St. NW","street2":null,"city":"Washington","postCode":"20005","latitude":38.91,"longitude":-77.032,"country":"US","govDist":{"abbrev":"DC","tsKey":"us-district-of-columbia","tsNs":"gov-dist"},"phones":[],"attributes":[],"services":["medical.CATEGORYNAME","mental-health.CATEGORYNAME"]} \ No newline at end of file +{"id":"oloc_01GVH3VEVBERFNA9PHHJYEBGA3","name":"Whitman-Walker 1525","street1":"1525 14th St. NW","street2":null,"city":"Washington","postCode":"20005","latitude":38.91,"longitude":-77.032,"notVisitable":false,"country":"US","govDist":{"abbrev":"DC","tsKey":"us-district-of-columbia","tsNs":"gov-dist"},"phones":[],"attributes":[],"services":["medical.CATEGORYNAME","mental-health.CATEGORYNAME"]} \ No newline at end of file From acc8dcc9924be08bba1dfba94bd67d04611620c5 Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Mon, 18 Mar 2024 16:01:10 -0400 Subject: [PATCH 20/61] update msw worker, clean up common schema files --- packages/api/schemas/create/organization.ts | 98 --------------------- packages/api/schemas/nestedOps.ts | 67 -------------- packages/ui/public/mockServiceWorker.js | 19 ++-- 3 files changed, 8 insertions(+), 176 deletions(-) delete mode 100644 packages/api/schemas/create/organization.ts diff --git a/packages/api/schemas/create/organization.ts b/packages/api/schemas/create/organization.ts deleted file mode 100644 index ed09a8744d..0000000000 --- a/packages/api/schemas/create/organization.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { z } from 'zod' - -import { generateFreeText, generateId, Prisma } from '@weareinreach/db' -import { CreationBase, idString, InputJsonValue } from '~api/schemas/common' -import { createManyWithAudit } from '~api/schemas/nestedOps' - -import { SuggestionSchema } from './browserSafe/suggestOrg' -import { createFreeText } from './freeText' -import { CreateNestedOrgEmailSchema } from './orgEmail' -import { CreateNestedOrgLocationSchema } from './orgLocation' -import { CreateNestedOrgPhoneSchema } from './orgPhone' -import { CreateNestedOrgSocialMediaSchema } from './orgSocialMedia' -import { CreateNestedOrgWebsiteSchema } from './orgWebsite' - -const CreateOrgBase = { - name: z.string(), - slug: z.string(), - sourceId: idString, -} -const CreateOrgLinks = { - description: z.string().optional(), - locations: CreateNestedOrgLocationSchema.optional(), - emails: CreateNestedOrgEmailSchema.optional(), - phones: CreateNestedOrgPhoneSchema.optional(), - websites: CreateNestedOrgWebsiteSchema.optional(), - socialMedia: CreateNestedOrgSocialMediaSchema.optional(), -} -const CreateQuickOrg = z.object({ ...CreateOrgBase, ...CreateOrgLinks }) - -export const CreateQuickOrgSchema = () => { - const { dataParser: parser, inputSchema } = CreationBase(CreateQuickOrg) - - const dataParser = parser.transform(({ actorId, operation, data }) => { - const { name, slug, sourceId } = data - return Prisma.validator<Prisma.OrganizationCreateArgs>()({ - data: { - name, - slug, - source: { - connect: { id: sourceId }, - }, - description: createFreeText(data.slug, data.description), - locations: createManyWithAudit(data.locations, actorId), - emails: createManyWithAudit(data.emails, actorId), - phones: createManyWithAudit(data.phones, actorId), - socialMedia: createManyWithAudit(data.socialMedia, actorId), - websites: createManyWithAudit(data.websites, actorId), - }, - include: { - description: Boolean(data.description), - locations: Boolean(data.locations), - emails: Boolean(data.emails), - phones: Boolean(data.phones), - websites: Boolean(data.websites), - socialMedia: Boolean(data.socialMedia), - }, - }) - }) - return { dataParser, inputSchema } -} - -export const CreateOrgSuggestionSchema = () => { - const { dataParser: parser, inputSchema } = CreationBase(SuggestionSchema) - const dataParser = parser.transform(({ actorId, operation, data }) => { - const { countryId, orgName, orgSlug, communityFocus, orgAddress, orgWebsite, serviceCategories } = data - const organizationId = generateId('organization') - - return Prisma.validator<Prisma.SuggestionCreateArgs>()({ - data: { - organization: { - create: { - id: organizationId, - name: orgName, - slug: orgSlug, - source: { connect: { source: 'suggestion' } }, - }, - }, - data: { - orgWebsite, - orgAddress, - countryId, - communityFocus, - serviceCategories, - }, - }, - select: { - id: true, - }, - }) - }) - return { dataParser, inputSchema } -} - -type CreateQuickOrgReturn = ReturnType<typeof CreateQuickOrgSchema> -export type CreateQuickOrgData = z.infer<CreateQuickOrgReturn['dataParser']> -export type CreateQuickOrgInput = z.input<CreateQuickOrgReturn['dataParser']> -type CreateOrgSuggestionReturn = ReturnType<typeof CreateOrgSuggestionSchema> -export type CreateOrgSuggestionInput = z.input<CreateOrgSuggestionReturn['dataParser']> diff --git a/packages/api/schemas/nestedOps.ts b/packages/api/schemas/nestedOps.ts index e9f5433269..a8dc10f051 100644 --- a/packages/api/schemas/nestedOps.ts +++ b/packages/api/schemas/nestedOps.ts @@ -1,7 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import compact from 'just-compact' -import omit from 'just-omit' -import pick from 'just-pick' import invariant from 'tiny-invariant' /** @@ -33,16 +31,7 @@ export const createManyOptional = <T extends Array<any>>(data: T | undefined) => skipDuplicates: true, }, } -/** Array to individual nested create records with individual Audit Logs */ -export const createManyWithAudit = <T extends Array<any>>(data: T | undefined, actorId: string) => - !data - ? undefined - : ({ - create: compact(data).map((record) => ({ - ...record, - })), - } as const) /** Individual create record */ export const createOne = <T extends Record<string, any>>(data: T | undefined) => !data @@ -50,17 +39,6 @@ export const createOne = <T extends Record<string, any>>(data: T | undefined) => : ({ create: data, } as const) -/** Individual create record with audit log */ - -export const createOneWithAudit = <T extends Record<string, any>>(data: T | undefined, actorId: string) => - !data - ? undefined - : ({ - create: { - ...data, - }, - } as const) - export const connectOne = <T extends Record<string, any>>(data: T | undefined) => !data ? undefined @@ -129,48 +107,3 @@ export const connectOneRequired = <T extends Record<string, any>>(data: T) => { connect: data, } } -type LinkManyOptions<T> = { - auditDataKeys?: Array<keyof T extends string ? keyof T : never> -} -export const linkManyWithAudit = <T extends object>( - data: T[] | undefined, - actorId: string, - opts?: LinkManyOptions<T> -) => { - if (!data) return [undefined, []] as const - const links = { - createMany: { - data, - skipDuplicates: true, - }, - } - const logs = null - - return [links, logs] as const -} - -export const createOneSeparateLog = <T extends object>( - data: T | undefined, - actorId: string, - opts?: LinkManyOptions<T> -) => { - if (!data) return [undefined, undefined] as const - const links = { - create: data, - } - - const log = null - - return [links, log] as const -} - -export const deleteOneSeparateLog = <T extends object>(data: T | undefined, actorId: string) => { - if (!data) return [undefined, undefined] as const - const links = { - delete: data, - } - - const log = null - - return [links, log] as const -} diff --git a/packages/ui/public/mockServiceWorker.js b/packages/ui/public/mockServiceWorker.js index 01875955f9..5d16500534 100644 --- a/packages/ui/public/mockServiceWorker.js +++ b/packages/ui/public/mockServiceWorker.js @@ -2,13 +2,14 @@ /* tslint:disable */ /** - * Mock Service Worker (2.1.0). + * Mock Service Worker. * @see https://github.com/mswjs/msw * - Please do NOT modify this file. * - Please do NOT serve this file on production. */ -const INTEGRITY_CHECKSUM = '223d191a56023cd36aa88c802961b911' +const PACKAGE_VERSION = '2.2.7' +const INTEGRITY_CHECKSUM = '26357c79639bfa20d64c0efca2a87423' const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') const activeClientIds = new Set() @@ -48,7 +49,10 @@ self.addEventListener('message', async function (event) { case 'INTEGRITY_CHECK_REQUEST': { sendToClient(client, { type: 'INTEGRITY_CHECK_RESPONSE', - payload: INTEGRITY_CHECKSUM, + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, }) break } @@ -202,13 +206,6 @@ async function getResponse(event, client, requestId) { return passthrough() } - // Bypass requests with the explicit bypass header. - // Such requests can be issued by "ctx.fetch()". - const mswIntention = request.headers.get('x-msw-intention') - if (['bypass', 'passthrough'].includes(mswIntention)) { - return passthrough() - } - // Notify the client that a request has been intercepted. const requestBuffer = await request.arrayBuffer() const clientMessage = await sendToClient( @@ -240,7 +237,7 @@ async function getResponse(event, client, requestId) { return respondWithMock(clientMessage.data) } - case 'MOCK_NOT_FOUND': { + case 'PASSTHROUGH': { return passthrough() } } From 0ab69aac2b2be8776772fc66335ace014cb03f33 Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Mon, 18 Mar 2024 16:01:56 -0400 Subject: [PATCH 21/61] update handler --- .../mutation.attachAttribute.handler.ts | 46 +++++++++--- .../mutation.attachAttribute.schema.ts | 72 ++++--------------- packages/ui/mockData/organization.ts | 7 ++ 3 files changed, 56 insertions(+), 69 deletions(-) diff --git a/packages/api/router/organization/mutation.attachAttribute.handler.ts b/packages/api/router/organization/mutation.attachAttribute.handler.ts index 14b4836d79..9282eee957 100644 --- a/packages/api/router/organization/mutation.attachAttribute.handler.ts +++ b/packages/api/router/organization/mutation.attachAttribute.handler.ts @@ -1,4 +1,5 @@ -import { getAuditedClient } from '@weareinreach/db' +import { generateNestedFreeText, getAuditedClient } from '@weareinreach/db' +import { connectOneId, connectOneIdRequired } from '~api/schemas/nestedOps' import { type TRPCHandlerParams } from '~api/types/handler' import { type TAttachAttributeSchema } from './mutation.attachAttribute.schema' @@ -8,17 +9,40 @@ export const attachAttribute = async ({ input, }: TRPCHandlerParams<TAttachAttributeSchema, 'protected'>) => { const prisma = getAuditedClient(ctx.actorId) - const { translationKey, freeText, attributeSupplement } = input + const { locationId, organizationId, serviceId } = input - const result = await prisma.$transaction(async (tx) => { - const tKey = translationKey ? await tx.translationKey.create(translationKey) : undefined - const fText = freeText ? await tx.freeText.create(freeText) : undefined - const aSupp = attributeSupplement ? await tx.attributeSupplement.create(attributeSupplement) : undefined - return { - translationKey: tKey, - freeText: fText, - attributeSupplement: aSupp, - } + const { id: orgId } = organizationId + ? { id: organizationId } + : await prisma.organization.findFirstOrThrow({ + where: { + OR: [{ locations: { some: { id: locationId } } }, { services: { some: { id: serviceId } } }], + }, + select: { + id: true, + }, + }) + + const freeText = input.text + ? generateNestedFreeText({ orgId, text: input.text, type: 'attSupp', itemId: input.id }) + : undefined + + const result = await prisma.attributeSupplement.create({ + data: { + id: input.id, + attribute: connectOneIdRequired(input.attributeId), + organization: connectOneId(organizationId), + country: connectOneId(input.countryId), + govDist: connectOneId(input.govDistId), + language: connectOneId(input.languageId), + service: connectOneId(serviceId), + location: connectOneId(locationId), + boolean: input.boolean, + data: input.data, + text: freeText, + }, + select: { + id: true, + }, }) return result } diff --git a/packages/api/router/organization/mutation.attachAttribute.schema.ts b/packages/api/router/organization/mutation.attachAttribute.schema.ts index 6cfcf27a17..7d1ead360d 100644 --- a/packages/api/router/organization/mutation.attachAttribute.schema.ts +++ b/packages/api/router/organization/mutation.attachAttribute.schema.ts @@ -1,64 +1,20 @@ import { z } from 'zod' -import { generateFreeText, generateId, InputJsonValue, Prisma } from '@weareinreach/db' +import { JsonInputOrNull } from '@weareinreach/db' import { prefixedId } from '~api/schemas/idPrefix' -export const ZAttachAttributeSchema = z - .object({ - organizationId: prefixedId('organization'), - attributeId: prefixedId('attribute'), - supplement: z - .object({ - data: InputJsonValue.optional(), - boolean: z.boolean().optional(), - countryId: z.string().optional(), - govDistId: z.string().optional(), - languageId: z.string().optional(), - text: z.string().optional(), - }) - .optional(), - }) - .transform((parsedData) => { - const { organizationId, attributeId, supplement: supplementInput } = parsedData +export const ZAttachAttributeSchema = z.object({ + id: prefixedId('attributeSupplement'), + attributeId: prefixedId('attribute'), + organizationId: prefixedId('organization').optional(), + serviceId: prefixedId('orgService').optional(), + locationId: prefixedId('orgLocation').optional(), + countryId: z.string().optional(), + govDistId: z.string().optional(), + languageId: z.string().optional(), + text: z.string().optional(), + boolean: z.coerce.boolean().optional(), + data: JsonInputOrNull.optional(), +}) - const supplementId = supplementInput ? generateId('attributeSupplement') : undefined - - const { freeText, translationKey } = - supplementId && supplementInput?.text - ? generateFreeText({ - orgId: organizationId, - text: supplementInput.text, - type: 'attSupp', - itemId: supplementId, - }) - : { freeText: undefined, translationKey: undefined } - - const { boolean, countryId, data, govDistId, languageId } = supplementInput ?? {} - - const supplementData = supplementInput - ? { - id: supplementId, - countryId, - boolean, - data, - govDistId, - languageId, - textId: freeText?.id, - attributeId, - organizationId, - } - : undefined - - return { - freeText: freeText ? Prisma.validator<Prisma.FreeTextCreateArgs>()({ data: freeText }) : undefined, - translationKey: translationKey - ? Prisma.validator<Prisma.TranslationKeyCreateArgs>()({ data: translationKey }) - : undefined, - attributeSupplement: supplementData - ? Prisma.validator<Prisma.AttributeSupplementCreateArgs>()({ - data: supplementData, - }) - : undefined, - } - }) export type TAttachAttributeSchema = z.infer<typeof ZAttachAttributeSchema> diff --git a/packages/ui/mockData/organization.ts b/packages/ui/mockData/organization.ts index 9e2ac025ad..df7703377e 100644 --- a/packages/ui/mockData/organization.ts +++ b/packages/ui/mockData/organization.ts @@ -146,4 +146,11 @@ export const organization = { removed: input.deletedVals?.length ?? 0, }), }), + attachAttribute: getTRPCMock({ + path: ['organization', 'attachAttribute'], + type: 'mutation', + response: () => ({ + id: 'atts_NEW0ID', + }), + }), } satisfies MockHandlerObject<'organization'> & { searchDistanceLongTitle: HttpHandler } From d2d0cf32ed6e928d12497424444c04d4797fc8d4 Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Mon, 18 Mar 2024 16:43:06 -0400 Subject: [PATCH 22/61] add submit handler, update schema --- .../modals/dataPortal/Attributes/fields.tsx | 2 +- .../dataPortal/Attributes/index.stories.tsx | 4 ++- .../ui/modals/dataPortal/Attributes/index.tsx | 25 +++++++++++++------ .../ui/modals/dataPortal/Attributes/schema.ts | 4 ++- 4 files changed, 25 insertions(+), 10 deletions(-) diff --git a/packages/ui/modals/dataPortal/Attributes/fields.tsx b/packages/ui/modals/dataPortal/Attributes/fields.tsx index 55ab79ec99..0701a79ecf 100644 --- a/packages/ui/modals/dataPortal/Attributes/fields.tsx +++ b/packages/ui/modals/dataPortal/Attributes/fields.tsx @@ -27,7 +27,7 @@ const SuppText = () => { const { control } = useFormContext<FormSchema>() return ( <Stack> - <TextInput {...{ control, name: 'text' }} /> + <TextInput label='Text' {...{ control, name: 'text' }} /> {/* <Button onClick={handler}>{t('words.add', { ns: 'common' })}</Button> */} </Stack> ) diff --git a/packages/ui/modals/dataPortal/Attributes/index.stories.tsx b/packages/ui/modals/dataPortal/Attributes/index.stories.tsx index 1d6b966cc2..85fdeb3de0 100644 --- a/packages/ui/modals/dataPortal/Attributes/index.stories.tsx +++ b/packages/ui/modals/dataPortal/Attributes/index.stories.tsx @@ -2,6 +2,7 @@ import { type Meta, type StoryObj } from '@storybook/react' import { Button } from '~ui/components/core/Button' import { allFieldOptHandlers } from '~ui/mockData/fieldOpt' +import { organization } from '~ui/mockData/organization' import { AttributeModal } from './index' @@ -12,7 +13,7 @@ export default { parameters: { layout: 'fullscreen', layoutWrapper: 'centeredHalf', - msw: [...allFieldOptHandlers], + msw: [...allFieldOptHandlers, organization.attachAttribute], rqDevtools: true, }, args: { @@ -28,5 +29,6 @@ export const AllCategories = {} satisfies StoryDef export const AttachesToService = { args: { attachesTo: ['SERVICE'], + parentRecord: { serviceId: 'osvc_123456' }, }, } satisfies StoryDef diff --git a/packages/ui/modals/dataPortal/Attributes/index.tsx b/packages/ui/modals/dataPortal/Attributes/index.tsx index bae7257103..b642dfc2bd 100644 --- a/packages/ui/modals/dataPortal/Attributes/index.tsx +++ b/packages/ui/modals/dataPortal/Attributes/index.tsx @@ -16,9 +16,10 @@ import { useDisclosure } from '@mantine/hooks' import { type JSONSchemaType } from 'ajv' import { useTranslation } from 'next-i18next' import { forwardRef, useMemo, useRef, useState } from 'react' -import { FormProvider, useForm } from 'react-hook-form' +import { FormProvider, useForm, useFormState } from 'react-hook-form' import { type ApiOutput } from '@weareinreach/api' +import { generateId } from '@weareinreach/db/lib/idGen' import { Button } from '~ui/components/core/Button' import { trpc as api } from '~ui/lib/trpcClient' import { ModalTitle } from '~ui/modals/ModalTitle' @@ -37,7 +38,7 @@ const supplementDefaults = { type SupplementFieldsNeeded = { [K in keyof typeof supplementDefaults]: boolean } const AttributeModalBody = forwardRef<HTMLButtonElement, AttributeModalProps>( - ({ restrictCategories, attachesTo, ...props }, ref) => { + ({ restrictCategories, attachesTo, parentRecord, ...props }, ref) => { const { t } = useTranslation(['attribute', 'common']) const [opened, handler] = useDisclosure(false) @@ -97,13 +98,20 @@ const AttributeModalBody = forwardRef<HTMLButtonElement, AttributeModalProps>( // #region Handlers const form = useForm<FormSchema>({ resolver: zodResolver(formSchema), + mode: 'all', + + defaultValues: { + id: generateId('attributeSupplement'), + ...parentRecord, + }, }) + const formState = useFormState({ control: form.control }) const selectHandler = (e: string | null) => { - console.log('selectHandler', e) if (e === null) { setSupplements(supplementDefaults) setSelectedAttr(null) + form.resetField('attributeId') if (supplementSchema !== null) { setSupplementSchema(null) } @@ -128,7 +136,7 @@ const AttributeModalBody = forwardRef<HTMLButtonElement, AttributeModalProps>( setSupplementSchema(item.dataSchema) } - return + // return } form.setValue('attributeId', item.value) selectAttrRef.current && (selectAttrRef.current.value = '') @@ -136,7 +144,7 @@ const AttributeModalBody = forwardRef<HTMLButtonElement, AttributeModalProps>( } const submitHandler = () => { - //TODO: [IN-871] Create submit handler - convert tRPC organization.attachAttribute to be able to handle multiple items & accept org, serv, loc + saveAttributes.mutate(form.getValues()) } // #endregion @@ -191,11 +199,13 @@ const AttributeModalBody = forwardRef<HTMLButtonElement, AttributeModalProps>( {supplements.boolean && <Supplement.Boolean />} {supplements.text && <Supplement.Text />} {supplements.data && selectedAttr?.formSchema && ( - <Supplement.Data formSchema={selectedAttr.formSchema} dataSchema={selectedAttr.dataSchema} /> + <Supplement.Data schema={selectedAttr.formSchema} /> )} {supplements.language && <Supplement.Language />} {supplements.geo && <Supplement.Geo />} - {!needsSupplement && <Button>{t('words.save', { ns: 'common' })}</Button>} + <Button onClick={form.handleSubmit(submitHandler)} type='submit'> + {t('words.save', { ns: 'common' })} + </Button> </Stack> </Modal> <Box component='button' ref={ref} onClick={() => handler.open()} {...props} /> @@ -211,4 +221,5 @@ export const AttributeModal = createPolymorphicComponent<'button', AttributeModa export interface AttributeModalProps extends ButtonProps { restrictCategories?: string[] attachesTo?: ApiOutput['fieldOpt']['attributesByCategory'][number]['canAttachTo'] + parentRecord: { organizationId: string } | { serviceId: string } | { locationId: string } } diff --git a/packages/ui/modals/dataPortal/Attributes/schema.ts b/packages/ui/modals/dataPortal/Attributes/schema.ts index d672a5466f..75354a7c5f 100644 --- a/packages/ui/modals/dataPortal/Attributes/schema.ts +++ b/packages/ui/modals/dataPortal/Attributes/schema.ts @@ -7,7 +7,9 @@ import { generateId } from '@weareinreach/db/lib/idGen' export const formSchema = z.object({ id: prefixedId('attributeSupplement').default(generateId('attributeSupplement')), attributeId: prefixedId('attribute'), - value: z.string(), + organizationId: prefixedId('organization').optional(), + serviceId: prefixedId('orgService').optional(), + locationId: prefixedId('orgLocation').optional(), countryId: z.string().optional(), govDistId: z.string().optional(), languageId: z.string().optional(), From 7439194a244ff3ac9302b7e33fda7962cb369d5c Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Tue, 19 Mar 2024 11:01:10 -0400 Subject: [PATCH 23/61] use Section.* components --- packages/ui/modals/Service/index.tsx | 119 ++++++++++++--------------- 1 file changed, 51 insertions(+), 68 deletions(-) diff --git a/packages/ui/modals/Service/index.tsx b/packages/ui/modals/Service/index.tsx index 079f6090f4..b517d16938 100644 --- a/packages/ui/modals/Service/index.tsx +++ b/packages/ui/modals/Service/index.tsx @@ -18,13 +18,8 @@ import { forwardRef, type JSX, type ReactNode } from 'react' import { serviceModalEvent } from '@weareinreach/analytics/events' import { supplementSchema } from '@weareinreach/api/schemas/attributeSupplement' import { AlertMessage } from '~ui/components/core/AlertMessage' -import { - type AttributeTagProps, - Badge, - BadgeGroup, - type CommunityTagProps, - type ServiceTagProps, -} from '~ui/components/core/Badge' +import { type AttributeTagProps, Badge, BadgeGroup, type CommunityTagProps } from '~ui/components/core/Badge' +import { Section } from '~ui/components/core/Section' import { ContactInfo, hasContactInfo, Hours } from '~ui/components/data-display' import { type PassedDataObject } from '~ui/components/data-display/ContactInfo/types' import { getFreeText, useSlug } from '~ui/hooks' @@ -115,44 +110,6 @@ const ServiceModalBody = forwardRef<HTMLButtonElement, ServiceModalProps>(({ ser </Text> ) - const SubSection = ({ title, children, li }: SubsectionProps) => ( - <Stack spacing={12}> - {title && <Title order={3}>{t(`service.${title}`)}} - {li ? ( - - {typeof li === 'string' ? ( - - {li} - - ) : ( - li.map((item, i) => ( - - {item} - - )) - )} - - ) : ( - children - )} - - ) - - const SectionDivider = ({ title, children }: SectionProps) => { - if (!children || (Array.isArray(children) && children.length === 0)) return <> - - return ( - - - - {t(`service.${title}`)} - - - {children} - - ) - } - const contactData: PassedDataObject = { phones: [], emails: [], @@ -320,9 +277,9 @@ const ServiceModalBody = forwardRef(({ ser if (description.length > 0) subsections[namespace].push( - + {description} - + ) break } @@ -364,33 +321,59 @@ const ServiceModalBody = forwardRef(({ ser if (eligibility.age) eligibilityItems.push( - + {eligibility.age} - + ) if (eligibility.requirements.length > 0) - eligibilityItems.push() + eligibilityItems.push( + + + {eligibility.requirements.map((text, i) => ( + {text} + ))} + + + ) if (eligibility.freeText.length > 0) eligibilityItems.push( - + {eligibility.freeText} - + ) - const languages = lang.length === 0 ? undefined : + const languages = + lang.length === 0 ? undefined : ( + + + {lang.map((lang, i) => ( + {lang} + ))} + + + ) const extraInfo: JSX.Element[] = [] if (miscWithIcons.length > 0) extraInfo.push( - + - + ) - if (misc.length > 0) extraInfo.push() + if (misc.length > 0) + extraInfo.push( + + + {misc.map((text, i) => ( + {text} + ))} + + + ) return ( <> @@ -415,30 +398,30 @@ const ServiceModalBody = forwardRef(({ ser {serviceBadges} {(hasContactInfo(contactData) || Boolean(hours.length)) && ( - + {hasContactInfo(contactData) && ( )} {Boolean(hours.length) && } - + )} {(Boolean(clientsServed.srvfocus.length) || Boolean(clientsServed.targetPop.length)) && ( - + {Boolean(clientsServed.srvfocus.length) && ( - + - + )} {Boolean(clientsServed.targetPop.length) && ( - {clientsServed.targetPop} + {clientsServed.targetPop} )} - + )} - {cost} - {eligibilityItems} - {languages} - {extraInfo} - {publicTransit} + {cost} + {eligibilityItems} + {languages} + {extraInfo} + {publicTransit} Date: Tue, 19 Mar 2024 13:57:11 -0400 Subject: [PATCH 24/61] add sections --- .../data-portal/ServiceEditDrawer/index.tsx | 123 +++++++++--------- 1 file changed, 60 insertions(+), 63 deletions(-) diff --git a/packages/ui/components/data-portal/ServiceEditDrawer/index.tsx b/packages/ui/components/data-portal/ServiceEditDrawer/index.tsx index 82b52ba734..8fa506bf92 100644 --- a/packages/ui/components/data-portal/ServiceEditDrawer/index.tsx +++ b/packages/ui/components/data-portal/ServiceEditDrawer/index.tsx @@ -4,8 +4,8 @@ import { type ButtonProps, createPolymorphicComponent, Drawer, + Group, List, - Modal, Stack, Text, Title, @@ -19,6 +19,8 @@ import { Textarea, TextInput } from 'react-hook-form-mantine' import { Badge } from '~ui/components/core/Badge' import { Breadcrumb } from '~ui/components/core/Breadcrumb' +import { Button } from '~ui/components/core/Button' +import { Section } from '~ui/components/core/Section' import { ServiceSelect } from '~ui/components/data-portal/ServiceSelect' import { useCustomVariant } from '~ui/hooks' import { Icon } from '~ui/icon' @@ -37,7 +39,7 @@ const _ServiceEditDrawer = forwardRef const [serviceModalOpened, serviceModalHandler] = useDisclosure(false) const { classes } = useStyles() const variants = useCustomVariant() - const { t } = useTranslation(['country', 'gov-dist']) + const { t } = useTranslation(['common', 'gov-dist']) // #region Get existing data/populate form const { data, isLoading } = api.service.forServiceEditDrawer.useQuery(serviceId, { refetchOnWindowFocus: false, @@ -79,23 +81,14 @@ const _ServiceEditDrawer = forwardRef const array = serviceAreaObj[country] const countryDetails = geoMap.get(country) if (!countryDetails) continue - if (!Array.isArray(array)) { - serviceAreaObj[country] = [ - - - All of {t(countryDetails.tsKey, { ns: countryDetails.tsNs })} - - , - ] - } else { - array.push( - - - All of {t(countryDetails.tsKey, { ns: countryDetails.tsNs })} - - - ) - } + const item = ( + + + All of {t(countryDetails.tsKey, { ns: countryDetails.tsNs })} + + + ) + Array.isArray(array) ? array.push(item) : (serviceAreaObj[country] = [item]) } } if (districts?.length) { @@ -108,34 +101,22 @@ const _ServiceEditDrawer = forwardRef const parent = govDist.parent?.id ?? '' const parentDist = geoMap.get(parent) if (!distIdRegex.test(parent) || !parentDist) { - Array.isArray(array) - ? array.push( - - {t(govDist.tsKey, { ns: govDist.tsNs })} - - ) - : (serviceAreaObj[country] = [ - - {t(govDist.tsKey, { ns: govDist.tsNs })} - , - ]) + const item = ( + + {t(govDist.tsKey, { ns: govDist.tsNs })} + + ) + Array.isArray(array) ? array.push(item) : (serviceAreaObj[country] = [item]) continue } - Array.isArray(array) - ? array.push( - - - {t(parentDist.tsKey, { ns: parentDist.tsNs })} - {t(govDist.tsKey, { ns: govDist.tsNs })} - - - ) - : (serviceAreaObj[country] = [ - - - {t(parentDist.tsKey, { ns: parentDist.tsNs })} - {t(govDist.tsKey, { ns: govDist.tsNs })} - - , - ]) + const item = ( + + + {t(parentDist.tsKey, { ns: parentDist.tsNs })} - {t(govDist.tsKey, { ns: govDist.tsNs })} + + + ) + Array.isArray(array) ? array.push(item) : (serviceAreaObj[country] = [item]) continue } } @@ -161,12 +142,18 @@ const _ServiceEditDrawer = forwardRef - + + + + } + label='Service Name' name='name.text' control={form.control} fontSize='h2' @@ -175,33 +162,43 @@ const _ServiceEditDrawer = forwardRef } + label='Description' name='description.text' control={form.control} data-isDirty={dirtyFields.description} autosize /> - - - {activeServices.map((serviceId) => { - const service = allServices?.find((s) => s.id === serviceId) - if (!service) return null - return ( - - {t(service.tsKey, { ns: service.tsNs })} - - ) - })} - - + + Services + + + {activeServices.map((serviceId) => { + const service = allServices?.find((s) => s.id === serviceId) + if (!service) return null + return ( + + {t(service.tsKey, { ns: service.tsNs })} + + ) + })} + + + {/* */} + + Coverage Area - - Coverage Area - {serviceAreas()} {/* {Boolean(geoMap?.size) && } */} - {/* */} + {t('service.get-help')} + + {t('service.clients-served')} + + {t('service.cost')} + {t('service.eligibility')} + {t('service.languages')} + {t('service.extra-info')} From 0f1afe6e8c689d32d7a5a5740852769a6fe9f821 Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Thu, 21 Mar 2024 12:32:01 -0400 Subject: [PATCH 25/61] patch for z.never() --- .github/renovate.json | 2 +- package.json | 3 ++- patches/json-schema-to-zod@2.0.14.patch | 28 +++++++++++++++++++++++++ pnpm-lock.yaml | 10 ++++++--- 4 files changed, 38 insertions(+), 5 deletions(-) create mode 100644 patches/json-schema-to-zod@2.0.14.patch diff --git a/.github/renovate.json b/.github/renovate.json index 9366e4aed1..0f9dc84ca0 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -6,7 +6,7 @@ "packageRules": [ { "groupName": "patched packages", - "matchPackageNames": ["@crowdin/ota-client", "trpc-panel", "msw-storybook-addon"], + "matchPackageNames": ["@crowdin/ota-client", "trpc-panel", "msw-storybook-addon", "json-schema-to-zod"], "matchUpdateTypes": ["major", "minor", "patch"] }, { diff --git a/package.json b/package.json index 4df9bd0f1b..07d5c6b75f 100644 --- a/package.json +++ b/package.json @@ -104,7 +104,8 @@ "patchedDependencies": { "@crowdin/ota-client@1.0.0": "patches/@crowdin__ota-client@1.0.0.patch", "social-links@1.14.0": "patches/social-links@1.14.0.patch", - "trpc-panel@1.3.4": "patches/trpc-panel@1.3.4.patch" + "trpc-panel@1.3.4": "patches/trpc-panel@1.3.4.patch", + "json-schema-to-zod@2.0.14": "patches/json-schema-to-zod@2.0.14.patch" }, "peerDependencyRules": { "allowedVersions": { diff --git a/patches/json-schema-to-zod@2.0.14.patch b/patches/json-schema-to-zod@2.0.14.patch new file mode 100644 index 0000000000..02438a9cae --- /dev/null +++ b/patches/json-schema-to-zod@2.0.14.patch @@ -0,0 +1,28 @@ +diff --git a/dist/cjs/parsers/parseNot.js b/dist/cjs/parsers/parseNot.js +index 43c2d410d17ab08e194e1227ea2536666d8d6508..5c300332e6b94b8737c5c0fc3ae87d3631e92bc0 100644 +--- a/dist/cjs/parsers/parseNot.js ++++ b/dist/cjs/parsers/parseNot.js +@@ -3,9 +3,6 @@ Object.defineProperty(exports, "__esModule", { value: true }); + exports.parseNot = void 0; + const parseSchema_js_1 = require("./parseSchema.js"); + const parseNot = (schema, refs) => { +- return `z.any().refine((value) => !${(0, parseSchema_js_1.parseSchema)(schema.not, { +- ...refs, +- path: [...refs.path, "not"], +- })}.safeParse(value).success, "Invalid input: Should NOT be valid against schema")`; ++ return `z.never()`; + }; + exports.parseNot = parseNot; +diff --git a/dist/esm/parsers/parseNot.js b/dist/esm/parsers/parseNot.js +index 4aa11ba9febf80de210c07b72c0991d447f03838..6acaeb0e48751c14ba2ee05dd49767ec99f80774 100644 +--- a/dist/esm/parsers/parseNot.js ++++ b/dist/esm/parsers/parseNot.js +@@ -1,7 +1,4 @@ + import { parseSchema } from "./parseSchema.js"; + export const parseNot = (schema, refs) => { +- return `z.any().refine((value) => !${parseSchema(schema.not, { +- ...refs, +- path: [...refs.path, "not"], +- })}.safeParse(value).success, "Invalid input: Should NOT be valid against schema")`; ++ return `z.never()`; + }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dfd39e117d..3a91ed6ba7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,6 +34,9 @@ patchedDependencies: '@crowdin/ota-client@1.0.0': hash: refrge56ym5gomc3tkglzjdymy path: patches/@crowdin__ota-client@1.0.0.patch + json-schema-to-zod@2.0.14: + hash: m6gxyy33n5omrqal5wlfjbvve4 + path: patches/json-schema-to-zod@2.0.14.patch social-links@1.14.0: hash: vsl4v34ksjh5tzibzra6h65ytm path: patches/social-links@1.14.0.patch @@ -982,7 +985,7 @@ importers: version: 1.6.6 json-schema-to-zod: specifier: 2.0.14 - version: 2.0.14 + version: 2.0.14(patch_hash=m6gxyy33n5omrqal5wlfjbvve4) kysely: specifier: 0.27.3 version: 0.27.3 @@ -1299,7 +1302,7 @@ importers: version: 3.3.4 json-schema-to-zod: specifier: 2.0.14 - version: 2.0.14 + version: 2.0.14(patch_hash=m6gxyy33n5omrqal5wlfjbvve4) just-compact: specifier: 3.2.0 version: 3.2.0 @@ -18689,10 +18692,11 @@ packages: valid-url: 1.0.9 dev: true - /json-schema-to-zod@2.0.14: + /json-schema-to-zod@2.0.14(patch_hash=m6gxyy33n5omrqal5wlfjbvve4): resolution: {integrity: sha512-Pp9wg1/AcMw5KA1RA7t6ybUTIes1yX0vp8PeE48cPnddHb+ZZWbAKPaFXVf4Pif4XSbo9u9i/hIzBcS1UHK/TA==} hasBin: true dev: false + patched: true /json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} From 3336155fa71e9ab57e695166ce4e8850642fdf09 Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Thu, 21 Mar 2024 12:40:03 -0400 Subject: [PATCH 26/61] schema update --- .../db/generated/attributeSupplementSchema.ts | 7 +- ...2024-03-21_attribute-supplement-schemas.ts | 95 +++++++++++++++++++ packages/db/prisma/data-migrations/index.ts | 1 + packages/db/zod_util/attributeSupplement.ts | 7 +- 4 files changed, 106 insertions(+), 4 deletions(-) create mode 100644 packages/db/prisma/data-migrations/2024-03-21_attribute-supplement-schemas.ts diff --git a/packages/db/generated/attributeSupplementSchema.ts b/packages/db/generated/attributeSupplementSchema.ts index 0f13462e24..88bb6c7d30 100644 --- a/packages/db/generated/attributeSupplementSchema.ts +++ b/packages/db/generated/attributeSupplementSchema.ts @@ -6,14 +6,15 @@ export const attributeSupplementSchema = { access_value: z.union([z.string(), z.null()]).optional(), instructions: z.string(), }), + currency: z.object({ cost: z.number(), currency: z.union([z.string(), z.null()]).optional() }).strict(), incompatibleData: z.array(z.record(z.any())), number: z.object({ num: z.number() }), numMax: z.object({ max: z.number() }), numMin: z.object({ min: z.number() }), numMinMaxOrRange: z.union([ - z.object({ min: z.number() }), - z.object({ max: z.number() }), - z.object({ max: z.number(), min: z.number() }), + z.object({ max: z.never(), min: z.number() }).strict(), + z.object({ max: z.number(), min: z.never() }).strict(), + z.object({ max: z.number(), min: z.number() }).strict(), ]), numRange: z.object({ max: z.number(), min: z.number() }), otherDescribe: z.object({ other: z.string() }), diff --git a/packages/db/prisma/data-migrations/2024-03-21_attribute-supplement-schemas.ts b/packages/db/prisma/data-migrations/2024-03-21_attribute-supplement-schemas.ts new file mode 100644 index 0000000000..83ede3629e --- /dev/null +++ b/packages/db/prisma/data-migrations/2024-03-21_attribute-supplement-schemas.ts @@ -0,0 +1,95 @@ +import { z } from 'zod' +import { zodToJsonSchema } from 'zod-to-json-schema' + +import { prisma } from '~db/client' +import { formatMessage } from '~db/prisma/common' +import { type MigrationJob } from '~db/prisma/dataMigrationRunner' +import { createLogger, type JobDef, jobPostRunner } from '~db/prisma/jobPreRun' +import { JsonInputOrNull } from '~db/zod_util' +import { type FieldAttributes, FieldType } from '~db/zod_util/attributeSupplement' + +/** Define the job metadata here. */ +const jobDef: JobDef = { + jobId: '2024-03-21_attribute-supplement-schemas', + title: 'attribute supplement schemas', + createdBy: 'Joe Karow', + /** Optional: Longer description for the job */ + description: undefined, +} + +const schemas = { + currency: z.object({ + cost: z.number(), + currency: z.string().nullish(), + }), + numMinMaxOrRange: z + .union([ + z.object({ min: z.number(), max: z.never() }), + z.object({ min: z.never(), max: z.number() }), + z.object({ min: z.number(), max: z.number() }), + ]) + .refine(({ min, max }) => (min && max ? min < max : true), { + message: 'min must be less than max', + }), +} +/** + * Job export - this variable MUST be UNIQUE + */ +export const job20240321_attribute_supplement_schemas = { + title: `[${jobDef.jobId}] ${jobDef.title}`, + task: async (_ctx, task) => { + /** Create logging instance */ + createLogger(task, jobDef.jobId) + const log = (...args: Parameters) => (task.output = formatMessage(...args)) + /** + * Start defining your data migration from here. + * + * To log output, use `task.output = 'Message to log'` + * + * This will be written to `stdout` and to a log file in `/prisma/migration-logs/` + */ + + // Do stuff + + const newSchemas = await prisma.attributeSupplementDataSchema.createMany({ + data: [ + { + id: 'asds_01HSGTSP6SKA5NZS9J42Z8S5BT', + tag: 'currency', + name: 'Currency', + definition: [ + { + key: 'cost', + name: 'cost', + type: FieldType.number, + label: 'Cost', + }, + { + key: 'currency', + name: 'currency', + type: FieldType.text, + label: 'Currency', + }, + ], + schema: JsonInputOrNull.parse(zodToJsonSchema(schemas.currency)), + }, + ], + skipDuplicates: true, + }) + log(`Created ${newSchemas.count} Attribute Supplement Schema records.`) + const updateMinMax = await prisma.attributeSupplementDataSchema.update({ + where: { id: 'asds_01GYX872BWWCGTZREHDT2AFF9D' }, + data: { + schema: JsonInputOrNull.parse(zodToJsonSchema(schemas.numMinMaxOrRange)), + }, + }) + log(`Updated Attribute Supplement Schema: ${updateMinMax.name}.`) + /** + * DO NOT REMOVE BELOW + * + * This writes a record to the DB to register that this migration has run successfully. + */ + await jobPostRunner(jobDef) + }, + def: jobDef, +} satisfies MigrationJob diff --git a/packages/db/prisma/data-migrations/index.ts b/packages/db/prisma/data-migrations/index.ts index 3ee27a277c..5b9ff1dbaf 100644 --- a/packages/db/prisma/data-migrations/index.ts +++ b/packages/db/prisma/data-migrations/index.ts @@ -11,4 +11,5 @@ export * from './2024-02-23_add-missing-website' export * from './2024-03-08_update-alerts-and-org-urls/index' export * from './2024-03-11_hide-locations' export * from './2024-03-15_update-dead-links/index' +export * from './2024-03-21_attribute-supplement-schemas' // codegen:end diff --git a/packages/db/zod_util/attributeSupplement.ts b/packages/db/zod_util/attributeSupplement.ts index b0d120251a..e869bfffe3 100644 --- a/packages/db/zod_util/attributeSupplement.ts +++ b/packages/db/zod_util/attributeSupplement.ts @@ -79,7 +79,10 @@ export const accessInstructions = { access_type: z.literal(''), ...commonAccessInstructions, }), - + publicTransport: z.object({ + access_type: z.literal('publicTransit'), + ...commonAccessInstructions, + }), getAll: function () { return z.discriminatedUnion('access_type', [ this.email, @@ -91,6 +94,7 @@ export const accessInstructions = { this.sms, this.whatsapp, this.blank, + this.publicTransport, ]) }, } @@ -104,6 +108,7 @@ export type AccessInstructions = { sms: z.infer whatsapp: z.infer blank: z.infer + publicTransport: z.infer getAll: () => z.infer> } From a7a2105083251698f3d4ef304584274f47ecaa12 Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Thu, 21 Mar 2024 12:40:48 -0400 Subject: [PATCH 27/61] break in to pieces --- packages/ui/modals/Service/ModalText.tsx | 16 ++ packages/ui/modals/Service/index.tsx | 300 ++--------------------- packages/ui/modals/Service/processor.tsx | 251 +++++++++++++++++++ packages/ui/modals/Service/styles.ts | 17 ++ 4 files changed, 298 insertions(+), 286 deletions(-) create mode 100644 packages/ui/modals/Service/ModalText.tsx create mode 100644 packages/ui/modals/Service/processor.tsx create mode 100644 packages/ui/modals/Service/styles.ts diff --git a/packages/ui/modals/Service/ModalText.tsx b/packages/ui/modals/Service/ModalText.tsx new file mode 100644 index 0000000000..e0f448def1 --- /dev/null +++ b/packages/ui/modals/Service/ModalText.tsx @@ -0,0 +1,16 @@ +import { Text } from '@mantine/core' +import { type ReactNode } from 'react' + +import { useStyles } from './styles' + +export const ModalText = ({ children }: ModalTextprops) => { + const { classes } = useStyles() + return ( + + {children} + + ) +} +type ModalTextprops = { + children: ReactNode +} diff --git a/packages/ui/modals/Service/index.tsx b/packages/ui/modals/Service/index.tsx index 46298b4e55..91b798fe45 100644 --- a/packages/ui/modals/Service/index.tsx +++ b/packages/ui/modals/Service/index.tsx @@ -2,7 +2,6 @@ import { Box, type ButtonProps, createPolymorphicComponent, - createStyles, List, Modal, Stack, @@ -13,37 +12,17 @@ import { import { useDisclosure, useMediaQuery } from '@mantine/hooks' import { useRouter } from 'next/router' import { useTranslation } from 'next-i18next' -import { forwardRef, type JSX, type ReactNode } from 'react' +import { forwardRef, type ReactNode } from 'react' import { serviceModalEvent } from '@weareinreach/analytics/events' -import { supplementSchema } from '@weareinreach/api/schemas/attributeSupplement' -import { AlertMessage } from '~ui/components/core/AlertMessage' import { Badge } from '~ui/components/core/Badge' import { Section } from '~ui/components/core/Section' import { ContactInfo, hasContactInfo, Hours } from '~ui/components/data-display' -import { type PassedDataObject } from '~ui/components/data-display/ContactInfo/types' -import { getFreeText, useSlug } from '~ui/hooks' -import { isValidIcon } from '~ui/icon' +import { useSlug } from '~ui/hooks/useSlug' import { trpc as api } from '~ui/lib/trpcClient' +import { processAccessInstructions, processAttributes } from './processor' import { ModalTitle, type ModalTitleProps } from '../ModalTitle' - -const useStyles = createStyles((theme) => ({ - sectionDivider: { - backgroundColor: theme.other.colors.primary.lightGray, - padding: 12, - }, - timezone: { - ...theme.other.utilityFonts.utility4, - color: theme.other.colors.secondary.darkGray, - }, - blackText: { - color: '#000000', - margin: 0, - whiteSpace: 'pre-line', - }, -})) - /** * TODO: [IN-797] Service Modal updates * @@ -60,7 +39,6 @@ const ServiceModalBody = forwardRef(({ ser const { data, status } = api.service.forServiceModal.useQuery(serviceId) const { data: orgId } = api.organization.getIdFromSlug.useQuery({ slug }) const { t, i18n } = useTranslation(orgId?.id ? ['common', 'attribute', orgId.id] : ['common', 'attribute']) - const { classes } = useStyles() const [opened, handler] = useDisclosure(false) const theme = useMantineTheme() const isMobile = useMediaQuery(`(max-width: ${theme.breakpoints.sm})`) @@ -104,19 +82,6 @@ const ServiceModalBody = forwardRef(({ ser ) } - const ModalText = ({ children }: ModalTextprops) => ( - - {children} - - ) - - const contactData: PassedDataObject = { - phones: [], - emails: [], - websites: [], - socialMedia: [], - } - if (data && status === 'success') { const { serviceName, services, hours, accessDetails, attributes, description, locations } = data @@ -127,203 +92,14 @@ const ServiceModalBody = forwardRef(({ ser ))} ) + const { getHelp, publicTransit } = processAccessInstructions({ accessDetails, locations, t }) + const { eligibility, clientsServed, cost, lang, misc, miscWithIcons, atCapacity } = processAttributes({ + attributes, + t, + locale: i18n.language, + }) - const baseDetails: AccessDetails = { publicTransit: [] } - - const { publicTransit } = accessDetails.reduce((details, { supplement }) => { - const { data, text, id } = supplement - const parsed = supplementSchema.accessInstructions.safeParse(data) - if (parsed.success) { - const { access_type, access_value } = parsed.data - switch (access_type) { - case 'publicTransit': { - if (!text) break - const { key, options } = getFreeText(text) - details[access_type].push({t(key, options)}) - break - } - case 'email': { - contactData.emails.push({ - id, - title: null, - description: null, - email: parsed.data.access_value, - // legacyDesc: parsed.data.instructions, - // firstName: null, - // lastName: null, - primary: false, - locationOnly: false, - serviceOnly: false, - }) - break - } - case 'phone': { - const country = locations.find(({ location }) => Boolean(location.country))?.location?.country - ?.cca2 - if (!country) break - contactData.phones.push({ - id, - number: parsed.data.access_value, - phoneType: null, - country, - primary: false, - locationOnly: false, - ext: null, - description: null, - }) - break - } - case 'link': - case 'file': { - contactData.websites.push({ - id, - description: null, - isPrimary: false, - // orgLocationId: null, - orgLocationOnly: false, - url: parsed.data.access_value, - }) - } - } - - const accessKey = CONTACTS.find((category) => category === access_type) - if (accessKey) details[accessKey] ||= {access_value} - } - return details - }, baseDetails) - - const attributeCategories: Attributes = { - cost: [], - lang: [], - clientsServed: { - srvfocus: [], - targetPop: [], - }, - eligibility: { - requirements: [], - freeText: [], - }, - misc: [], - miscWithIcons: [], - } - - const { eligibility, clientsServed, cost, lang, misc, miscWithIcons, atCapacity } = attributes.reduce( - (subsections, { attribute, supplement }) => { - const { tsKey, icon, tsNs, id } = attribute - /* - Since the tsKeys follow a sort of pattern with the namespace being the first part of the - string before the '.', would it be alright to check for the category that way? - It avoids having to iterate through the categories array with: - categories.find(({ category }) => tsKey.includes(category.tag)) - */ - const namespace = tsKey.split('.').shift() as string - - switch (namespace) { - /** Clients served */ - case 'srvfocus': { - if (typeof icon === 'string' && attribute._count.parents === 0) { - subsections.clientsServed[namespace].push( - - {t(tsKey, { ns: tsNs })} - - ) - } - break - } - /** Target Population & Eligibility Requirements */ - case 'eligibility': { - const type = tsKey.split('.').pop() as string - switch (type) { - case 'elig-age': { - const { data, id } = supplement - const parsed = supplementSchema.age.safeParse(data) - if (!parsed.success) break - const { min, max } = parsed.data - const context = min && max ? 'range' : min ? 'min' : 'max' - subsections[namespace]['age'] = ( - {t('service.elig-age', { ns: 'common', context, min, max })} - ) - break - } - case 'other-describe': { - const { text, id } = supplement - if (!text) break - const { key, options } = getFreeText(text) - subsections.clientsServed.targetPop.push({t(key, options)}) - - break - } - } - - break - } - case 'cost': { - if (!isValidIcon(icon)) break - const costDetails: CostDetails = { description: [] } - - const { text, data, id } = supplement - if (text) { - const { key, options } = getFreeText(text) - costDetails.description.push({t(key, options)}) - } - const parsed = supplementSchema.cost.safeParse(data) - if (parsed.success) { - const { cost, currency } = parsed.data - costDetails.price = new Intl.NumberFormat(i18n.language, { - style: 'currency', - currency: currency ?? undefined, - }).format(cost) - } - - const { price, description } = costDetails - subsections[namespace].push( - - {t(tsKey, { price, ns: tsNs })} - - ) - - if (description.length > 0) - subsections[namespace].push( - - {description} - - ) - break - } - - case 'lang': { - const { language } = supplement - if (!language) break - const { languageName } = language - subsections[namespace].push(languageName) - break - } - case 'additional': { - if (tsKey.includes('at-capacity')) - subsections['atCapacity'] = ( - - ) - else { - isValidIcon(icon) - ? subsections[`miscWithIcons`].push( - - {t(tsKey, { ns: tsNs })} - - ) - : subsections['misc'].push(t(tsKey, { ns: tsNs })) - } - break - } - default: { - break - } - } - return subsections - }, - attributeCategories - ) - - const eligibilityItems: JSX.Element[] = [] + const eligibilityItems: ReactNode[] = [] if (eligibility.age) eligibilityItems.push( @@ -361,7 +137,7 @@ const ServiceModalBody = forwardRef(({ ser ) - const extraInfo: JSX.Element[] = [] + const extraInfo: ReactNode[] = [] if (miscWithIcons.length > 0) extraInfo.push( @@ -403,10 +179,10 @@ const ServiceModalBody = forwardRef(({ ser )} {serviceBadges} - {(hasContactInfo(contactData) || Boolean(hours.length)) && ( + {(hasContactInfo(getHelp) || Boolean(hours.length)) && ( - {hasContactInfo(contactData) && ( - + {hasContactInfo(getHelp) && ( + )} {Boolean(hours.length) && } @@ -451,51 +227,3 @@ export const ServiceModal = createPolymorphicComponent<'button', ServiceModalPro export interface ServiceModalProps extends ButtonProps { serviceId: string } - -type SubsectionProps = { - title?: string - children?: ReactNode - li?: string[] | string -} - -type SectionProps = { - title?: string - children?: ReactNode -} - -type Attributes = { - directEmail?: string - directPhone?: string - directWebsite?: string - cost: JSX.Element[] - lang: string[] - clientsServed: { - srvfocus: JSX.Element[] - targetPop: JSX.Element[] - } - atCapacity?: JSX.Element - eligibility: { - age?: JSX.Element - requirements: string[] - freeText: JSX.Element[] - } - misc: string[] - miscWithIcons: JSX.Element[] -} - -type AccessDetails = { - phone?: JSX.Element - email?: JSX.Element - website?: JSX.Element - atCapacity?: JSX.Element - publicTransit: JSX.Element[] -} - -type CostDetails = { - price?: number | string - description: JSX.Element[] -} - -type ModalTextprops = { - children: ReactNode -} diff --git a/packages/ui/modals/Service/processor.tsx b/packages/ui/modals/Service/processor.tsx new file mode 100644 index 0000000000..8ba9d50879 --- /dev/null +++ b/packages/ui/modals/Service/processor.tsx @@ -0,0 +1,251 @@ +import { type TFunction } from 'next-i18next' +import { type ReactNode } from 'react' + +import { type ApiOutput } from '@weareinreach/api' +import { attributeSupplementSchema } from '@weareinreach/db/generated/attributeSupplementSchema' +import { accessInstructions } from '@weareinreach/db/zod_util/attributeSupplement' +import { AlertMessage } from '~ui/components/core/AlertMessage' +import { Badge } from '~ui/components/core/Badge' +import { Section } from '~ui/components/core/Section' +import { type PassedDataObject } from '~ui/components/data-display/ContactInfo/types' +import { getFreeText } from '~ui/hooks/useFreeText' +import { isValidIcon } from '~ui/icon' + +import { ModalText } from './ModalText' + +export const processAccessInstructions = ({ + accessDetails, + locations, + t, +}: { + accessDetails: ApiOutput['service']['forServiceModal']['accessDetails'] + locations: ApiOutput['service']['forServiceModal']['locations'] + t: TFunction +}): AccessInstructionsOutput => { + const output: AccessInstructionsOutput = { + getHelp: { + phones: [], + emails: [], + websites: [], + socialMedia: [], + }, + publicTransit: null, + } + + for (const { supplement } of accessDetails) { + const { data, text, id } = supplement + const parsed = accessInstructions.getAll().safeParse(data) + if (parsed.success) { + const { access_type, access_value } = parsed.data + switch (access_type) { + case 'publicTransit': { + if (!text) break + const { key, options } = getFreeText(text) + output.publicTransit = {t(key, options)} + break + } + case 'email': { + if (access_value) + output.getHelp.emails.push({ + id, + title: null, + description: null, + email: access_value, + // legacyDesc: parsed.data.instructions, + // firstName: null, + // lastName: null, + primary: false, + locationOnly: false, + serviceOnly: false, + }) + break + } + case 'phone': { + const country = locations.find(({ location }) => Boolean(location.country))?.location?.country?.cca2 + if (!country) break + if (access_value) + output.getHelp.phones.push({ + id, + number: access_value, + phoneType: null, + country, + primary: false, + locationOnly: false, + ext: null, + description: null, + }) + break + } + case 'link': + case 'file': { + if (access_value) + output.getHelp.websites.push({ + id, + description: null, + isPrimary: false, + // orgLocationId: null, + orgLocationOnly: false, + url: access_value, + }) + } + } + } + } + + return output +} + +export const processAttributes = ({ + attributes, + locale = 'en', + t, +}: { + attributes: ApiOutput['service']['forServiceModal']['attributes'] + locale: string + t: TFunction +}): AttributesOutput => { + const output: AttributesOutput = { + clientsServed: { + srvfocus: [], + targetPop: [], + }, + cost: [], + eligibility: { + requirements: [], + freeText: [], + }, + lang: [], + misc: [], + miscWithIcons: [], + } + for (const { attribute, supplement } of attributes) { + const { tsKey, icon, tsNs, id } = attribute + const namespace = tsKey.split('.').shift() as string + + switch (namespace) { + /** Clients served */ + case 'srvfocus': { + if (typeof icon === 'string' && attribute._count.parents === 0) { + output.clientsServed.srvfocus.push( + + {t(tsKey, { ns: tsNs })} + + ) + } + break + } + /** Target Population & Eligibility Requirements */ + case 'eligibility': { + const type = tsKey.split('.').pop() as string + switch (type) { + case 'elig-age': { + const { data, id } = supplement + const parsed = attributeSupplementSchema.numMinMaxOrRange.safeParse(data) + if (!parsed.success) break + const { min, max } = parsed.data + const context = min && max ? 'range' : min ? 'min' : 'max' + output.eligibility.age = ( + {t('service.elig-age', { ns: 'common', context, min, max })} + ) + break + } + case 'other-describe': { + const { text, id } = supplement + if (!text) break + const { key, options } = getFreeText(text) + output.clientsServed.targetPop.push({t(key, options)}) + + break + } + } + + break + } + case 'cost': { + if (!isValidIcon(icon)) break + const costDetails: { + price?: number | string + description: ReactNode[] + } = { description: [] } + + const { text, data, id } = supplement + if (text) { + const { key, options } = getFreeText(text) + costDetails.description.push({t(key, options)}) + } + const parsed = attributeSupplementSchema.currency.safeParse(data) + if (parsed.success) { + const { cost, currency } = parsed.data + costDetails.price = new Intl.NumberFormat(locale, { + style: 'currency', + currency: currency ?? undefined, + }).format(cost) + } + + const { price, description } = costDetails + output.cost.push( + + {t(tsKey, { price, ns: tsNs })} + + ) + + if (description.length > 0) + output.cost.push( + + {description} + + ) + break + } + + case 'lang': { + const { language } = supplement + if (!language) break + const { languageName } = language + output.lang.push(languageName) + break + } + case 'additional': { + if (tsKey.includes('at-capacity')) + output.atCapacity = + else { + isValidIcon(icon) + ? output.miscWithIcons.push( + + {t(tsKey, { ns: tsNs })} + + ) + : output.misc.push(t(tsKey, { ns: tsNs })) + } + break + } + default: { + break + } + } + } + return output +} +interface AccessInstructionsOutput { + getHelp: PassedDataObject + publicTransit: ReactNode +} +interface AttributesOutput { + directEmail?: string + directPhone?: string + directWebsite?: string + cost: ReactNode[] + lang: string[] + clientsServed: { + srvfocus: ReactNode[] + targetPop: ReactNode[] + } + atCapacity?: ReactNode + eligibility: { + age?: ReactNode + requirements: string[] + freeText: ReactNode[] + } + misc: string[] + miscWithIcons: ReactNode[] +} diff --git a/packages/ui/modals/Service/styles.ts b/packages/ui/modals/Service/styles.ts new file mode 100644 index 0000000000..4a73d34a0e --- /dev/null +++ b/packages/ui/modals/Service/styles.ts @@ -0,0 +1,17 @@ +import { createStyles } from '@mantine/core' + +export const useStyles = createStyles((theme) => ({ + sectionDivider: { + backgroundColor: theme.other.colors.primary.lightGray, + padding: 12, + }, + timezone: { + ...theme.other.utilityFonts.utility4, + color: theme.other.colors.secondary.darkGray, + }, + blackText: { + color: '#000000', + margin: 0, + whiteSpace: 'pre-line', + }, +})) From 946363d322f81792cb29f8939aede1c755963f7e Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Thu, 21 Mar 2024 18:03:12 -0400 Subject: [PATCH 28/61] add i18next as peerdep --- packages/api/package.json | 2 ++ pnpm-lock.yaml | 3 +++ 2 files changed, 5 insertions(+) diff --git a/packages/api/package.json b/packages/api/package.json index d3bdd6ae33..39e9f40f24 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -61,6 +61,7 @@ "@weareinreach/eslint-config": "0.100.0", "dotenv-cli": "7.4.1", "eslint": "8.57.0", + "i18next": "23.10.1", "inquirer-search-list": "1.2.6", "just-pascal-case": "3.2.0", "next": "14.1.4", @@ -71,6 +72,7 @@ "typescript": "5.4.3" }, "peerDependencies": { + "i18next": "23.10.1", "next": ">=13" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a91ed6ba7..052c3e44fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -789,6 +789,9 @@ importers: eslint: specifier: 8.57.0 version: 8.57.0 + i18next: + specifier: 23.10.1 + version: 23.10.1 inquirer-search-list: specifier: 1.2.6 version: 1.2.6 From 368212f2055741e8026df48014d4dffd0805b7a7 Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Thu, 21 Mar 2024 18:11:32 -0400 Subject: [PATCH 29/61] enable new turbo ui --- turbo.json | 1 + 1 file changed, 1 insertion(+) diff --git a/turbo.json b/turbo.json index a6f2d50b5d..c36da9ba55 100644 --- a/turbo.json +++ b/turbo.json @@ -1,5 +1,6 @@ { "$schema": "https://turborepo.org/schema.json", + "experimentalUI": true, "globalDependencies": ["./packages/config/tsconfig/base.json"], "globalDotEnv": [".env"], "globalEnv": ["NODE_ENV"], From f0a70dcc5ff789a2fcd9855c96c68abdc3c73de8 Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Fri, 22 Mar 2024 11:13:27 -0400 Subject: [PATCH 30/61] sonarcloud workspace binding --- .vscode/settings.json | 6 ++++++ apps/app/.vscode/settings.json | 6 ++++++ apps/web/.vscode/settings.json | 6 ++++++ lambdas/.vscode/settings.json | 6 ++++++ packages/analytics/.vscode/settings.json | 6 ++++++ packages/api/.vscode/settings.json | 6 ++++++ packages/auth/.vscode/settings.json | 6 ++++++ packages/config/.vscode/settings.json | 6 ++++++ packages/crowdin/.vscode/settings.json | 6 ++++++ packages/db/.vscode/settings.json | 6 ++++++ packages/env/.vscode/settings.json | 6 ++++++ packages/eslint-config/.vscode/settings.json | 6 ++++++ packages/ui/.vscode/settings.json | 4 ++++ packages/util/.vscode/settings.json | 6 ++++++ 14 files changed, 82 insertions(+) create mode 100644 .vscode/settings.json create mode 100644 apps/app/.vscode/settings.json create mode 100644 apps/web/.vscode/settings.json create mode 100644 lambdas/.vscode/settings.json create mode 100644 packages/analytics/.vscode/settings.json create mode 100644 packages/api/.vscode/settings.json create mode 100644 packages/auth/.vscode/settings.json create mode 100644 packages/config/.vscode/settings.json create mode 100644 packages/crowdin/.vscode/settings.json create mode 100644 packages/db/.vscode/settings.json create mode 100644 packages/env/.vscode/settings.json create mode 100644 packages/eslint-config/.vscode/settings.json create mode 100644 packages/util/.vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000000..7c77f0a9b1 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "sonarlint.connectedMode.project": { + "connectionId": "inreach", + "projectKey": "weareinreach_InReach" + } +} diff --git a/apps/app/.vscode/settings.json b/apps/app/.vscode/settings.json new file mode 100644 index 0000000000..7c77f0a9b1 --- /dev/null +++ b/apps/app/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "sonarlint.connectedMode.project": { + "connectionId": "inreach", + "projectKey": "weareinreach_InReach" + } +} diff --git a/apps/web/.vscode/settings.json b/apps/web/.vscode/settings.json new file mode 100644 index 0000000000..7c77f0a9b1 --- /dev/null +++ b/apps/web/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "sonarlint.connectedMode.project": { + "connectionId": "inreach", + "projectKey": "weareinreach_InReach" + } +} diff --git a/lambdas/.vscode/settings.json b/lambdas/.vscode/settings.json new file mode 100644 index 0000000000..7c77f0a9b1 --- /dev/null +++ b/lambdas/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "sonarlint.connectedMode.project": { + "connectionId": "inreach", + "projectKey": "weareinreach_InReach" + } +} diff --git a/packages/analytics/.vscode/settings.json b/packages/analytics/.vscode/settings.json new file mode 100644 index 0000000000..7c77f0a9b1 --- /dev/null +++ b/packages/analytics/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "sonarlint.connectedMode.project": { + "connectionId": "inreach", + "projectKey": "weareinreach_InReach" + } +} diff --git a/packages/api/.vscode/settings.json b/packages/api/.vscode/settings.json new file mode 100644 index 0000000000..7c77f0a9b1 --- /dev/null +++ b/packages/api/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "sonarlint.connectedMode.project": { + "connectionId": "inreach", + "projectKey": "weareinreach_InReach" + } +} diff --git a/packages/auth/.vscode/settings.json b/packages/auth/.vscode/settings.json new file mode 100644 index 0000000000..7c77f0a9b1 --- /dev/null +++ b/packages/auth/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "sonarlint.connectedMode.project": { + "connectionId": "inreach", + "projectKey": "weareinreach_InReach" + } +} diff --git a/packages/config/.vscode/settings.json b/packages/config/.vscode/settings.json new file mode 100644 index 0000000000..7c77f0a9b1 --- /dev/null +++ b/packages/config/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "sonarlint.connectedMode.project": { + "connectionId": "inreach", + "projectKey": "weareinreach_InReach" + } +} diff --git a/packages/crowdin/.vscode/settings.json b/packages/crowdin/.vscode/settings.json new file mode 100644 index 0000000000..7c77f0a9b1 --- /dev/null +++ b/packages/crowdin/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "sonarlint.connectedMode.project": { + "connectionId": "inreach", + "projectKey": "weareinreach_InReach" + } +} diff --git a/packages/db/.vscode/settings.json b/packages/db/.vscode/settings.json new file mode 100644 index 0000000000..7c77f0a9b1 --- /dev/null +++ b/packages/db/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "sonarlint.connectedMode.project": { + "connectionId": "inreach", + "projectKey": "weareinreach_InReach" + } +} diff --git a/packages/env/.vscode/settings.json b/packages/env/.vscode/settings.json new file mode 100644 index 0000000000..7c77f0a9b1 --- /dev/null +++ b/packages/env/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "sonarlint.connectedMode.project": { + "connectionId": "inreach", + "projectKey": "weareinreach_InReach" + } +} diff --git a/packages/eslint-config/.vscode/settings.json b/packages/eslint-config/.vscode/settings.json new file mode 100644 index 0000000000..7c77f0a9b1 --- /dev/null +++ b/packages/eslint-config/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "sonarlint.connectedMode.project": { + "connectionId": "inreach", + "projectKey": "weareinreach_InReach" + } +} diff --git a/packages/ui/.vscode/settings.json b/packages/ui/.vscode/settings.json index a2c3de751c..331acd2d09 100644 --- a/packages/ui/.vscode/settings.json +++ b/packages/ui/.vscode/settings.json @@ -2,5 +2,9 @@ "i18n-ally.localesPaths": "../../apps/app/public/locales", "[json]": { "editor.codeActionsOnSave": { "source.fixAll": "never" } + }, + "sonarlint.connectedMode.project": { + "connectionId": "inreach", + "projectKey": "weareinreach_InReach" } } diff --git a/packages/util/.vscode/settings.json b/packages/util/.vscode/settings.json new file mode 100644 index 0000000000..7c77f0a9b1 --- /dev/null +++ b/packages/util/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "sonarlint.connectedMode.project": { + "connectionId": "inreach", + "projectKey": "weareinreach_InReach" + } +} From b8d5ef1807d08509598c1f5d8cb7d8b417289c79 Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Mon, 25 Mar 2024 14:56:35 -0400 Subject: [PATCH 31/61] service attribute processing --- .../service/formatters/modalAndEditDrawer.ts | 382 ++++++++++++++++++ 1 file changed, 382 insertions(+) create mode 100644 packages/api/router/service/formatters/modalAndEditDrawer.ts diff --git a/packages/api/router/service/formatters/modalAndEditDrawer.ts b/packages/api/router/service/formatters/modalAndEditDrawer.ts new file mode 100644 index 0000000000..807a55dae1 --- /dev/null +++ b/packages/api/router/service/formatters/modalAndEditDrawer.ts @@ -0,0 +1,382 @@ +import { type TOptions } from 'i18next' +import { type z } from 'zod' + +import { type Prisma, prisma } from '@weareinreach/db' +import { attributeSupplementSchema } from '@weareinreach/db/generated/attributeSupplementSchema' +import { accessInstructions } from '@weareinreach/db/zod_util/attributeSupplement' +import { globalWhere } from '~api/selects/global' + +const getFreeText = (freeTextRecord: NonNullable) => { + const { tsKey } = freeTextRecord + const { key: dbKey } = tsKey + const deconstructedKey = dbKey.split('.') + const ns = deconstructedKey[0] + if (!deconstructedKey.length || !ns) throw new Error('Invalid key') + const key = deconstructedKey.join('.') + const options = { ns, defaultValue: tsKey.text } satisfies TOptions + return { key, options } +} +export const attributeSelect = (showAll?: boolean) => + ({ + ...(showAll + ? {} + : ({ + where: { + active: true, + attribute: { active: true }, + }, + } as const)), + select: { + attribute: { + select: { + id: true, + tag: true, + tsKey: true, + tsNs: true, + icon: true, + iconBg: true, + showOnLocation: true, + categories: { select: { category: { select: { tag: true, ns: true } } } }, + _count: { + select: { + parents: true, + children: true, + }, + }, + }, + }, + active: true, + countryId: true, + data: true, + govDistId: true, + id: true, + language: { select: { id: true, languageName: true } }, + languageId: true, + text: { select: { tsKey: { select: { key: true, text: true, ns: true } } } }, + boolean: true, + }, + }) as const +export const locationSelect = (showAll?: boolean) => + ({ + ...(showAll + ? {} + : ({ + where: { + location: globalWhere.isPublic(), + }, + } as const)), + select: { location: { select: { country: { select: { cca2: true } } } } }, + }) as const + +export const transformToProps = (data: ReturnedData): TransformOutput => { + const { attributes, locations } = data + const output: TransformOutput = { + accessInstructions: { + publicTransit: undefined, + email: [], + phone: [], + website: [], + }, + attributeSections: { + clientsServed: { + focused: [], + targetPopulation: [], + }, + eligibility: { + age: undefined, + }, + cost: { + description: [], + badged: [], + }, + language: [], + atCapacity: false, + misc: [], + miscWithIcons: [], + }, + } + type AttributeToProcess = (typeof attributes)[number]['attribute'] + type SupplementToProcess = Omit<(typeof attributes)[number], 'attribute'> + const processAccessInstruction = ( + data: z.infer>, + supplement: SupplementToProcess + ) => { + const { access_type, access_value } = data + switch (access_type) { + case 'publicTransit': { + if (!supplement.text) break + output.accessInstructions.publicTransit = { + key: supplement.id, + children: getFreeText(supplement.text), + } + break + } + case 'email': { + if (access_value) + output.accessInstructions.email.push({ + id: supplement.id, + title: null, + description: null, + email: access_value, + primary: false, + locationOnly: false, + serviceOnly: false, + }) + break + } + case 'phone': { + const country = locations.find(({ location }) => Boolean(location.country))?.location?.country?.cca2 + if (!country) break + if (access_value) + output.accessInstructions.phone.push({ + id: supplement.id, + number: access_value, + phoneType: null, + country, + primary: false, + locationOnly: false, + ext: null, + description: null, + }) + break + } + case 'link': + case 'file': { + if (access_value) + output.accessInstructions.website.push({ + id: supplement.id, + description: null, + isPrimary: false, + orgLocationOnly: false, + url: access_value, + }) + } + } + } + + const handleSrvFocus = (attribute: AttributeToProcess, supplement: SupplementToProcess) => { + if (typeof attribute.icon === 'string' && attribute._count.parents === 0) { + output.attributeSections.clientsServed.focused.push({ + key: supplement.id, + icon: attribute.icon, + children: { + tsKey: attribute.tsKey, + tOptions: { ns: attribute.tsNs }, + }, + }) + } + } + const handleEligibility = (attribute: AttributeToProcess, supplement: SupplementToProcess) => { + const type = attribute.tsKey.split('.').pop() as string + switch (type) { + case 'elig-age': { + const parsed = attributeSupplementSchema.numMinMaxOrRange.safeParse(supplement.data) + if (!parsed.success) break + const { min, max } = parsed.data + const context = (function () { + switch (true) { + case Boolean(min) && Boolean(max): { + return 'range' + } + case Boolean(min): { + return 'min' + } + default: { + return 'max' + } + } + })() + + output.attributeSections.eligibility.age = { + key: supplement.id, + children: { + key: 'service.elig-age', + options: { ns: 'common', context, min, max }, + }, + } + + break + } + case 'other-describe': { + if (!supplement.text) break + output.attributeSections.clientsServed.targetPopulation.push({ + key: supplement.id, + children: getFreeText(supplement.text), + }) + break + } + } + } + const handleCost = (attribute: AttributeToProcess, supplement: SupplementToProcess) => { + if (!attribute.icon) return + if (supplement.text) { + output.attributeSections.cost.description.push({ + key: supplement.id, + children: getFreeText(supplement.text), + }) + } + const parsed = attributeSupplementSchema.currency.safeParse(supplement.data) + if (parsed.success) { + output.attributeSections.cost.badged.push({ + key: supplement.id, + icon: attribute.icon, + style: { justifyContent: 'start' }, + children: { + tsKey: attribute.tsKey, + tOptions: { ns: attribute.tsNs }, + miscInterpolation: { + data: parsed.data.cost, + currency: parsed.data.currency ?? undefined, + style: 'currency', + }, + }, + }) + } + } + const handleLanguage = (attribute: AttributeToProcess, supplement: SupplementToProcess) => { + if (!supplement.language) return + output.attributeSections.language.push(supplement.language.languageName) + } + const handleAdditional = (attribute: AttributeToProcess, supplement: SupplementToProcess) => { + if (attribute.tsKey.includes('at-capacity')) output.attributeSections.atCapacity = true + else { + typeof attribute.icon === 'string' + ? output.attributeSections.miscWithIcons.push({ + key: supplement.id, + icon: attribute.icon, + children: { + tsKey: attribute.tsKey, + tOptions: { ns: attribute.tsNs }, + }, + }) + : output.attributeSections.misc.push({ + tsKey: attribute.tsKey, + tOptions: { ns: attribute.tsNs }, + }) + } + } + const processAttribute = (attribute: AttributeToProcess, supplement: SupplementToProcess) => { + const namespace = attribute.tsKey.split('.').shift() as string + + switch (namespace) { + /** Clients served */ + case 'srvfocus': { + handleSrvFocus(attribute, supplement) + break + } + /** Target Population & Eligibility Requirements */ + case 'eligibility': { + handleEligibility(attribute, supplement) + break + } + case 'cost': { + handleCost(attribute, supplement) + break + } + + case 'lang': { + handleLanguage(attribute, supplement) + break + } + case 'additional': { + handleAdditional(attribute, supplement) + break + } + default: { + break + } + } + } + + for (const { attribute, ...supplement } of attributes) { + const flatAttribs = attribute.categories.map(({ category }) => category.tag) + if (flatAttribs.includes('service-access-instructions')) { + // process access instruction + const parsed = accessInstructions.getAll().safeParse(supplement.data) + if (parsed.success) { + processAccessInstruction(parsed.data, supplement) + } + } else { + // process attribute + processAttribute(attribute, supplement) + } + } + return output +} + +const testTxn = async () => + await prisma.orgService.findFirstOrThrow({ + where: { id: '' }, + select: { attributes: attributeSelect(), locations: locationSelect() }, + }) +type ReturnedData = Prisma.PromiseReturnType +type TransformOutput = { + accessInstructions: { + publicTransit?: ModalTextProps + email: EmailProps[] + phone: PhoneProps[] + website: WebsiteProps[] + } + attributeSections: { + clientsServed: { + focused: AttributeBadgeProps[] + targetPopulation: ModalTextProps[] + } + eligibility: { + age?: ModalTextProps + } + cost: { + badged: AttributeBadgeProps[] + description: ModalTextProps[] + } + language: string[] + atCapacity: boolean + miscWithIcons: AttributeBadgeProps[] + misc: ChildrenT[] + } +} +type ModalTextProps = { + key: string + children: { + key: string + options: TOptions + } +} +type EmailProps = { + id: string + title: null + description: null + email: string + primary: boolean + locationOnly: boolean + serviceOnly: boolean +} +type PhoneProps = { + id: string + number: string + phoneType: null + country: string + primary: boolean + locationOnly: boolean + ext: null + description: null +} +type WebsiteProps = { + id: string + description: null + isPrimary: false + orgLocationOnly: boolean + url: string +} +type AttributeBadgeProps = { + key: string + icon: string + children: ChildrenT + style?: Record +} +type ChildrenT = { + tsKey: string + tOptions: TOptions + miscInterpolation?: InterpolateNumber +} +type InterpolateNumber = Intl.NumberFormatOptions & { data: number } From cb7a6f08f9b27cefdd2099000306a7b4ce674680 Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Mon, 25 Mar 2024 14:56:57 -0400 Subject: [PATCH 32/61] update criteria --- packages/api/formatters/attributes.ts | 12 +++++++----- .../query.forServiceEditDrawer.handler.ts | 5 +++-- .../service/query.forServiceModal.handler.ts | 19 ++----------------- 3 files changed, 12 insertions(+), 24 deletions(-) diff --git a/packages/api/formatters/attributes.ts b/packages/api/formatters/attributes.ts index ce8fc579b9..30adf304fe 100644 --- a/packages/api/formatters/attributes.ts +++ b/packages/api/formatters/attributes.ts @@ -16,11 +16,13 @@ export const formatAttributes = { select: { id: true, tag: true, - // tsKey: true, - // tsNs: true, - // icon: true, - // iconBg: true, - categories: { select: { category: { select: { tag: true, ns: true } } } }, + tsKey: true, + tsNs: true, + icon: true, + iconBg: true, + showOnLocation: true, + categories: { select: { category: { select: { tag: true, icon: true, ns: true } } } }, + _count: { select: { parents: true, children: true } }, }, }, active: true, diff --git a/packages/api/router/service/query.forServiceEditDrawer.handler.ts b/packages/api/router/service/query.forServiceEditDrawer.handler.ts index 6921fbe8fb..0a6ec8ba08 100644 --- a/packages/api/router/service/query.forServiceEditDrawer.handler.ts +++ b/packages/api/router/service/query.forServiceEditDrawer.handler.ts @@ -1,7 +1,6 @@ import { prisma } from '@weareinreach/db' import { formatAttributes } from '~api/formatters/attributes' import { formatHours } from '~api/formatters/hours' -import { globalSelect } from '~api/selects/global' import { type TRPCHandlerParams } from '~api/types/handler' import { type TForServiceEditDrawerSchema } from './query.forServiceEditDrawer.schema' @@ -20,7 +19,9 @@ export const forServiceEditDrawer = async ({ input }: TRPCHandlerParams) => { const result = await prisma.orgService.findUniqueOrThrow({ @@ -36,22 +35,8 @@ export const forServiceModal = async ({ input }: TRPCHandlerParams Date: Mon, 25 Mar 2024 14:58:30 -0400 Subject: [PATCH 33/61] extract processing to separate fns --- .../data-portal/ServiceEditDrawer/index.tsx | 91 +++++++++++-------- 1 file changed, 53 insertions(+), 38 deletions(-) diff --git a/packages/ui/components/data-portal/ServiceEditDrawer/index.tsx b/packages/ui/components/data-portal/ServiceEditDrawer/index.tsx index 8fa506bf92..158cee8d10 100644 --- a/packages/ui/components/data-portal/ServiceEditDrawer/index.tsx +++ b/packages/ui/components/data-portal/ServiceEditDrawer/index.tsx @@ -11,11 +11,11 @@ import { Title, } from '@mantine/core' import { useDisclosure } from '@mantine/hooks' -import compact from 'just-compact' import { useTranslation } from 'next-i18next' -import { forwardRef, type ReactNode, useEffect, useMemo } from 'react' +import { forwardRef, type ReactNode } from 'react' import { useForm } from 'react-hook-form' import { Textarea, TextInput } from 'react-hook-form-mantine' +import invariant from 'tiny-invariant' import { Badge } from '~ui/components/core/Badge' import { Breadcrumb } from '~ui/components/core/Breadcrumb' @@ -25,6 +25,7 @@ import { ServiceSelect } from '~ui/components/data-portal/ServiceSelect' import { useCustomVariant } from '~ui/hooks' import { Icon } from '~ui/icon' import { trpc as api } from '~ui/lib/trpcClient' +import { processAccessInstructions, processAttributes } from '~ui/modals/Service/processor' import { DataViewer } from '~ui/other/DataViewer' import { FormSchema, type TFormSchema } from './schemas' @@ -36,12 +37,11 @@ const isObject = (x: unknown): x is object => typeof x === 'object' const _ServiceEditDrawer = forwardRef( ({ serviceId, ...props }, ref) => { const [drawerOpened, drawerHandler] = useDisclosure(true) - const [serviceModalOpened, serviceModalHandler] = useDisclosure(false) const { classes } = useStyles() const variants = useCustomVariant() const { t } = useTranslation(['common', 'gov-dist']) // #region Get existing data/populate form - const { data, isLoading } = api.service.forServiceEditDrawer.useQuery(serviceId, { + const { data } = api.service.forServiceEditDrawer.useQuery(serviceId, { refetchOnWindowFocus: false, }) const form = useForm({ @@ -76,48 +76,53 @@ const _ServiceEditDrawer = forwardRef const countryIdRegex = /^ctry_.*/ const distIdRegex = /^gdst_.*/ - if (countries?.length) { - for (const country of countries) { - const array = serviceAreaObj[country] - const countryDetails = geoMap.get(country) - if (!countryDetails) continue - const item = ( - + const processCountry = (country: string) => { + serviceAreaObj[country] ??= [] + const array = serviceAreaObj[country] + invariant(array) + const countryDetails = geoMap.get(country) + if (!countryDetails) return + const item = ( + + + All of {t(countryDetails.tsKey, { ns: countryDetails.tsNs })} + + + ) + array.push(item) + } + const processDistrict = (district: string) => { + const govDist = geoMap.get(district) + const country = govDist?.parent?.parent?.id ?? govDist?.parent?.id ?? '' + if (!countryIdRegex.test(country) || !govDist) return + serviceAreaObj[country] ??= [] + const array = serviceAreaObj[country] + invariant(array) + const parent = govDist.parent?.id ?? '' + const parentDist = geoMap.get(parent) + const item = + !distIdRegex.test(parent) || !parentDist ? ( + + {t(govDist.tsKey, { ns: govDist.tsNs })} + + ) : ( + - All of {t(countryDetails.tsKey, { ns: countryDetails.tsNs })} + {t(parentDist.tsKey, { ns: parentDist.tsNs })} - {t(govDist.tsKey, { ns: govDist.tsNs })} ) - Array.isArray(array) ? array.push(item) : (serviceAreaObj[country] = [item]) + array.push(item) + } + + if (countries?.length) { + for (const country of countries) { + processCountry(country) } } if (districts?.length) { for (const district of districts) { - const govDist = geoMap.get(district) - if (!govDist) continue - const country = govDist.parent?.parent?.id ?? govDist.parent?.id ?? '' - if (!countryIdRegex.test(country)) continue - const array = serviceAreaObj[country] - const parent = govDist.parent?.id ?? '' - const parentDist = geoMap.get(parent) - if (!distIdRegex.test(parent) || !parentDist) { - const item = ( - - {t(govDist.tsKey, { ns: govDist.tsNs })} - - ) - Array.isArray(array) ? array.push(item) : (serviceAreaObj[country] = [item]) - continue - } - const item = ( - - - {t(parentDist.tsKey, { ns: parentDist.tsNs })} - {t(govDist.tsKey, { ns: govDist.tsNs })} - - - ) - Array.isArray(array) ? array.push(item) : (serviceAreaObj[country] = [item]) - continue + processDistrict(district) } } return Object.entries(serviceAreaObj)?.map(([key, value]) => { @@ -136,6 +141,16 @@ const _ServiceEditDrawer = forwardRef // #endregion + if (!data) return null + + // const { getHelp, publicTransit } = data + // ? processAccessInstructions({ + // accessDetails: data?.accessDetails, + // locations: data?.locations, + // t, + // }) + // : { getHelp: null, publicTransit: null } + return ( <> From 1e99747dbeaf5fc12cb8607a949dd805690b44fc Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Wed, 27 Mar 2024 15:21:47 -0400 Subject: [PATCH 34/61] unify service attrib select criteria, update api & mock data --- packages/api/formatters/attributes.ts | 104 +++++++++++++--- .../query.forServiceEditDrawer.handler.ts | 4 +- .../service/query.forServiceModal.handler.ts | 117 ++++++++---------- .../json/service.forServiceEditDrawer.json | 2 +- .../json/service.forServiceInfoCard.json | 2 +- .../json/service.forServiceModal.json | 2 +- 6 files changed, 139 insertions(+), 92 deletions(-) diff --git a/packages/api/formatters/attributes.ts b/packages/api/formatters/attributes.ts index 30adf304fe..fd27db61d7 100644 --- a/packages/api/formatters/attributes.ts +++ b/packages/api/formatters/attributes.ts @@ -1,3 +1,5 @@ +import { type Simplify } from 'type-fest' + import { type Prisma } from '@weareinreach/db' export const formatAttributes = { @@ -27,10 +29,24 @@ export const formatAttributes = { }, active: true, countryId: true, + country: { + select: { + cca2: true, + id: true, + name: true, + }, + }, data: true, govDistId: true, + govDist: { select: { tsKey: true, tsNs: true, abbrev: true, id: true } }, id: true, languageId: true, + language: { + select: { + languageName: true, + nativeName: true, + }, + }, text: { select: { tsKey: { select: { key: true, text: true, ns: true } } } }, boolean: true, }, @@ -84,54 +100,102 @@ export const formatAttributes = { } type ReturnedData = { + boolean: boolean | null attribute: { id: string + _count: { + children: number + parents: number + } tag: string - // tsKey: string - // tsNs: string + tsKey: string + tsNs: string categories: { category: { tag: string ns: string + icon: string | null } }[] - // icon: string | null - // iconBg: string | null + icon: string | null + iconBg: string | null + showOnLocation: boolean | null } - boolean: boolean | null - id: string + country: { + id: string + name: string + cca2: string + } | null + govDist: { + id: string + tsKey: string + tsNs: string + abbrev: string | null + } | null + language: { + languageName: string + nativeName: string + } | null data: Prisma.JsonValue + id: string active: boolean text: { tsKey: { key: string - text: string ns: string + text: string } } | null - countryId: string | null govDistId: string | null + countryId: string | null languageId: string | null }[] type DataOutput = { - text: { - key: string - text: string - ns: string - } | null + // ids + attributeId: string + supplementId: string + // attribute + category: string + _count: { + children: number + parents: number + } + tag: string + tsKey: string + tsNs: string + icon: string | null + iconBg: string | null + showOnLocation: boolean | null + // supplement boolean: boolean | null + country: { + id: string + name: string + cca2: string + } | null + govDist: { + id: string + tsKey: string + tsNs: string + abbrev: string | null + } | null + language: { + languageName: string + nativeName: string + } | null data: Prisma.JsonValue active: boolean - countryId: string | null govDistId: string | null + countryId: string | null languageId: string | null - category: string - tag: string - attributeId: string - supplementId: string + text: { + key: string + ns: string + text: string + } | null } type ReturnSegmented = { - attributes: DataOutput[] - accessDetails: DataOutput[] + attributes: Simplify[] + accessDetails: Simplify[] } diff --git a/packages/api/router/service/query.forServiceEditDrawer.handler.ts b/packages/api/router/service/query.forServiceEditDrawer.handler.ts index 0a6ec8ba08..a5b6effdfc 100644 --- a/packages/api/router/service/query.forServiceEditDrawer.handler.ts +++ b/packages/api/router/service/query.forServiceEditDrawer.handler.ts @@ -39,7 +39,7 @@ export const forServiceEditDrawer = async ({ input }: TRPCHandlerParams phone.id), emails: emails.map(({ email }) => email.id), - locations: locations.map(({ orgLocationId }) => orgLocationId), + // locations: locations.map(({ orgLocationId }) => orgLocationId), services: services.map(({ tag }) => tag.id), hours: formatHours.process(hours), serviceAreas: serviceAreas diff --git a/packages/api/router/service/query.forServiceModal.handler.ts b/packages/api/router/service/query.forServiceModal.handler.ts index 70c181f902..8b46dbab5f 100644 --- a/packages/api/router/service/query.forServiceModal.handler.ts +++ b/packages/api/router/service/query.forServiceModal.handler.ts @@ -1,4 +1,5 @@ import { prisma } from '@weareinreach/db' +import { formatAttributes } from '~api/formatters/attributes' import { globalWhere } from '~api/selects/global' import { type TRPCHandlerParams } from '~api/types/handler' @@ -25,56 +26,52 @@ export const forServiceModal = async ({ input }: TRPCHandlerParams - attribute.categories.every(({ category }) => category.tag !== 'service-access-instructions') - ) - .map(({ attribute, ...supplement }) => ({ - attribute, - supplement, - })), - accessDetails: result.attributes - .filter(({ attribute }) => - attribute.categories.some(({ category }) => category.tag === 'service-access-instructions') - ) - .map(({ attribute, ...supplement }) => ({ - attribute, - supplement, - })), + attributes, + accessDetails, } return transformed } diff --git a/packages/ui/mockData/json/service.forServiceEditDrawer.json b/packages/ui/mockData/json/service.forServiceEditDrawer.json index bb71586888..23067c3218 100644 --- a/packages/ui/mockData/json/service.forServiceEditDrawer.json +++ b/packages/ui/mockData/json/service.forServiceEditDrawer.json @@ -1 +1 @@ -{"id":"osvc_01GVH3VEVPF1KEKBTRVTV70WGV","published":true,"deleted":false,"name":{"key":"orgn_01GVH3V408N0YS7CDYAH3F2BMH.osvc_01GVH3VEVPF1KEKBTRVTV70WGV.name","text":"Get rapid HIV testing","ns":"org-data","crowdinId":773224},"description":{"key":"orgn_01GVH3V408N0YS7CDYAH3F2BMH.osvc_01GVH3VEVPF1KEKBTRVTV70WGV.description","text":"Whitman-Walker provides walk-in HIV testing at multiple locations in DC. Walk-in HIV testing includes a confidential, rapid HIV test and risk-reduction counseling. The counseling provides clients with education on their options for having safer sex. Whitman-Walker uses the INSTI® HIV-1/HIV-2 Rapid Antibody Test and results take one minute.","ns":"org-data","crowdinId":773222},"phones":["ophn_01GVH3VEVC36PW0Z9GDV0ZERV1","ophn_01GVH3VEVCFKT3NWQ79STYVDKR"],"emails":[],"locations":["oloc_01GVH3VEVBRCFA2AHNTWCXQA2B","oloc_01GVH3VEVBSA85T6VR2C38BJPT"],"services":["svtg_01GW2HHFBRPBXSYN12DWNEAJJ7"],"hours":{},"serviceAreas":{"id":"svar_01GW2HT9F1JKT1MCAJ3P7XBDHP","countries":[],"districts":["gdst_01GW2HJ5A278S2G84AB3N9FCW0"]},"attributes":[{"attributeId":"attr_01GW2HHFVA06WHRSM241ZF0FY0","supplementId":"atts_01E4ENGMG266R5BH78D7B2MB7M","tag":"hiv-aids","category":"community","active":true,"countryId":null,"data":null,"govDistId":null,"languageId":null,"boolean":null,"text":null},{"attributeId":"attr_01GW2HHFVGDTNW9PDQNXK6TF1T","supplementId":"atts_01E4ENGMG2XWR5JQ1JMBN2SQVM","tag":"cost-free","category":"cost","active":true,"countryId":null,"data":null,"govDistId":null,"languageId":null,"boolean":null,"text":null},{"attributeId":"attr_01GW2HHFV3BADK80TG0DXXFPMM","supplementId":"atts_01E4ENGMG2J94M4S9DQTE57GWN","tag":"has-confidentiality-policy","category":"additional-information","active":true,"countryId":null,"data":null,"govDistId":null,"languageId":null,"boolean":null,"text":null},{"attributeId":"attr_01GW2HHFV4TM7H5V6FHWA7S9JK","supplementId":"atts_01E4ENGMG20KXGB20JYGZ4X938","tag":"time-walk-in","category":"additional-information","active":true,"countryId":null,"data":null,"govDistId":null,"languageId":null,"boolean":null,"text":null},{"attributeId":"attr_01GW2HHFVK8KPRGKYFSSM5ECPQ","supplementId":"atts_01GW2HT9F13VVJCJ8W2WE86R6N","tag":"incompatible-info","category":"system","active":false,"countryId":null,"data":{"json":[{"community-lgbt":"true"},{"lang-all-languages-by-interpreter":"Language access services are available, including ASL interpreting."}]},"govDistId":null,"languageId":null,"boolean":null,"text":null},{"attributeId":"attr_01GW2HHFVJ8K180CNX339BTXM2","supplementId":"atts_01GW2HT9F15B2HJK144B3NZHQK","tag":"lang-offered","category":"languages","active":true,"countryId":null,"data":null,"govDistId":null,"languageId":"lang_0000000000N3K70GZXE29Z03A4","boolean":null,"text":null}],"accessDetails":[{"attributeId":"attr_01GW2HHFVMYXMS8ARA3GE7HZFD","supplementId":"atts_01GW2HT9F01W2M7FBSKSXAQ9R4","tag":"accesslink","category":"service-access-instructions","active":true,"countryId":null,"data":{"json":{"_id":{"$oid":"5e7e4bdbd54f1760921a4234"},"access_type":"link","access_value":"https://www.whitman-walker.org/hiv-sti-testing","instructions":"Visit the website to learn more about Whitman-Walker's testing hours and locations.","access_value_ES":"https://www.whitman-walker.org/hiv-sti-testing","instructions_ES":"Visita el sitio web para obtener más información sobre los horarios y lugares de prueba de Whitman-Walker."}},"govDistId":null,"languageId":null,"boolean":null,"text":{"key":"orgn_01GVH3V408N0YS7CDYAH3F2BMH.attribute.atts_01GW2HT9F01W2M7FBSKSXAQ9R4","text":"Visit the website to learn more about Whitman-Walker's testing hours and locations.","ns":"org-data"}},{"attributeId":"attr_01GW2HHFVMKTFWCKBVVFJ5GMY0","supplementId":"atts_01GW2HT9F09GFRWM3JK2A43AWG","tag":"accessphone","category":"service-access-instructions","active":true,"countryId":null,"data":{"json":{"_id":{"$oid":"5e7e4bdbd54f1760921a4235"},"access_type":"phone","access_value":"202-745-7000","instructions":"Contact the Main Office about services offered in multiple languages upon request.","access_value_ES":"202-745-7000","instructions_ES":"Comunícate con la oficina principal sobre los servicios que se ofrecen en varios idiomas si lo solicitas."}},"govDistId":null,"languageId":null,"boolean":null,"text":{"key":"orgn_01GVH3V408N0YS7CDYAH3F2BMH.attribute.atts_01GW2HT9F09GFRWM3JK2A43AWG","text":"Contact the Main Office about services offered in multiple languages upon request.","ns":"org-data"}},{"attributeId":"attr_01GW2HHFVMH6AE94EXN7T5A87C","supplementId":"atts_01GW2HT9F0SPS3EBCQ710RCNTA","tag":"accesslocation","category":"service-access-instructions","active":true,"countryId":null,"data":{"json":{"_id":{"$oid":"5e7e4bdbd54f1760921a4231"},"access_type":"location","access_value":"2301 M. Luther King Jr., Washington DC 20020","instructions":"Max Robinson Center - NO walk-in testing is available. Monday:08:30-12:30, 13:30-17:30; Tuesday:08:30 - 12:30, 13:30 - 17:30; Wednesday:08:30 - 12:30, 13:30 - 17:30; Thursday:08:30 - 12:30, 13:30 - 17:30; Friday:08:30 - 12:30, 14:15 - 17:30.","access_value_ES":"2301 M. Luther King Jr., Washington DC 20020","instructions_ES":"Centro Max Robinson:NO hay pruebas disponibles sin cita previa. Lunes:08:30-12:30, 13:30-17:30; Martes:08:30 - 12:30, 13:30 - 17:30; Miércoles:08:30 - 12:30, 13:30 - 17:30; Jueves:08:30 - 12:30, 13:30 - 17:30; Viernes:08:30 - 12:30, 14:15 - 17:30."}},"govDistId":null,"languageId":null,"boolean":null,"text":{"key":"orgn_01GVH3V408N0YS7CDYAH3F2BMH.attribute.atts_01GW2HT9F0SPS3EBCQ710RCNTA","text":"Max Robinson Center - NO walk-in testing is available. Monday:08:30-12:30, 13:30-17:30; Tuesday:08:30 - 12:30, 13:30 - 17:30; Wednesday:08:30 - 12:30, 13:30 - 17:30; Thursday:08:30 - 12:30, 13:30 - 17:30; Friday:08:30 - 12:30, 14:15 - 17:30.","ns":"org-data"}},{"attributeId":"attr_01GW2HHFVMH6AE94EXN7T5A87C","supplementId":"atts_01GW2HT9F0638MD74PJ3SCWNXC","tag":"accesslocation","category":"service-access-instructions","active":true,"countryId":null,"data":{"json":{"_id":{"$oid":"5e7e4bdbd54f1760921a4233"},"access_type":"location","access_value":"1525 14th St, NW Washington, DC 20005","instructions":"Whitman-Walker at 1525 - NO walk-in testing is available. Monday-Thursday:08:30-12:30 & 13:30-17:30; Friday:08:30- 12:30 & 14:30 -17:30.","access_value_ES":"1525 14th St, NW Washington, DC 20005","instructions_ES":"Whitman-Walker en 1525:NO hay pruebas disponibles. Lunes-Jueves:08:30-12:30 y 13:30-17:30; Viernes:08:30- 12:30 y 14:30 -17:30."}},"govDistId":null,"languageId":null,"boolean":null,"text":{"key":"orgn_01GVH3V408N0YS7CDYAH3F2BMH.attribute.atts_01GW2HT9F0638MD74PJ3SCWNXC","text":"Whitman-Walker at 1525 - NO walk-in testing is available. Monday-Thursday:08:30-12:30 & 13:30-17:30; Friday:08:30- 12:30 & 14:30 -17:30.","ns":"org-data"}}]} +{"id":"osvc_01GVH3VEVPF1KEKBTRVTV70WGV","published":true,"deleted":false,"locations":[{"orgLocationId":"oloc_01GVH3VEVBRCFA2AHNTWCXQA2B","location":{"country":{"cca2":"US"}}},{"orgLocationId":"oloc_01GVH3VEVBSA85T6VR2C38BJPT","location":{"country":{"cca2":"US"}}}],"name":{"key":"orgn_01GVH3V408N0YS7CDYAH3F2BMH.osvc_01GVH3VEVPF1KEKBTRVTV70WGV.name","text":"Get rapid HIV testing","ns":"org-data","crowdinId":773224},"description":{"key":"orgn_01GVH3V408N0YS7CDYAH3F2BMH.osvc_01GVH3VEVPF1KEKBTRVTV70WGV.description","text":"Whitman-Walker provides walk-in HIV testing at multiple locations in DC. Walk-in HIV testing includes a confidential, rapid HIV test and risk-reduction counseling. The counseling provides clients with education on their options for having safer sex. Whitman-Walker uses the INSTI® HIV-1/HIV-2 Rapid Antibody Test and results take one minute.","ns":"org-data","crowdinId":773222},"phones":["ophn_01GVH3VEVC36PW0Z9GDV0ZERV1","ophn_01GVH3VEVCFKT3NWQ79STYVDKR"],"emails":[],"services":["svtg_01GW2HHFBRPBXSYN12DWNEAJJ7"],"hours":{},"serviceAreas":{"id":"svar_01GW2HT9F1JKT1MCAJ3P7XBDHP","countries":[],"districts":["gdst_01GW2HJ5A278S2G84AB3N9FCW0"]},"attributes":[{"attributeId":"attr_01GW2HHFVA06WHRSM241ZF0FY0","supplementId":"atts_01E4ENGMG266R5BH78D7B2MB7M","tag":"hiv-aids","tsKey":"community.hiv-aids","tsNs":"attribute","icon":null,"iconBg":null,"showOnLocation":null,"_count":{"parents":0,"children":0},"category":"community","active":true,"countryId":null,"country":null,"data":null,"govDistId":null,"govDist":null,"languageId":null,"language":null,"boolean":null,"text":null},{"attributeId":"attr_01GW2HHFVGDTNW9PDQNXK6TF1T","supplementId":"atts_01E4ENGMG2XWR5JQ1JMBN2SQVM","tag":"cost-free","tsKey":"cost.cost-free","tsNs":"attribute","icon":"carbon:piggy-bank","iconBg":null,"showOnLocation":null,"_count":{"parents":0,"children":0},"category":"cost","active":true,"countryId":null,"country":null,"data":null,"govDistId":null,"govDist":null,"languageId":null,"language":null,"boolean":null,"text":null},{"attributeId":"attr_01GW2HHFV3BADK80TG0DXXFPMM","supplementId":"atts_01E4ENGMG2J94M4S9DQTE57GWN","tag":"has-confidentiality-policy","tsKey":"additional.has-confidentiality-policy","tsNs":"attribute","icon":null,"iconBg":null,"showOnLocation":null,"_count":{"parents":0,"children":0},"category":"additional-information","active":true,"countryId":null,"country":null,"data":null,"govDistId":null,"govDist":null,"languageId":null,"language":null,"boolean":null,"text":null},{"attributeId":"attr_01GW2HHFV4TM7H5V6FHWA7S9JK","supplementId":"atts_01E4ENGMG20KXGB20JYGZ4X938","tag":"time-walk-in","tsKey":"additional.time-walk-in","tsNs":"attribute","icon":null,"iconBg":null,"showOnLocation":null,"_count":{"parents":0,"children":0},"category":"additional-information","active":true,"countryId":null,"country":null,"data":null,"govDistId":null,"govDist":null,"languageId":null,"language":null,"boolean":null,"text":null},{"attributeId":"attr_01GW2HHFVK8KPRGKYFSSM5ECPQ","supplementId":"atts_01GW2HT9F13VVJCJ8W2WE86R6N","tag":"incompatible-info","tsKey":"sys.incompatible-info","tsNs":"attribute","icon":null,"iconBg":null,"showOnLocation":null,"_count":{"parents":0,"children":0},"category":"system","active":false,"countryId":null,"country":null,"data":{"json":[{"community-lgbt":"true"},{"lang-all-languages-by-interpreter":"Language access services are available, including ASL interpreting."}]},"govDistId":null,"govDist":null,"languageId":null,"language":null,"boolean":null,"text":null},{"attributeId":"attr_01GW2HHFVJ8K180CNX339BTXM2","supplementId":"atts_01GW2HT9F15B2HJK144B3NZHQK","tag":"lang-offered","tsKey":"lang.lang-offered","tsNs":"attribute","icon":null,"iconBg":null,"showOnLocation":null,"_count":{"parents":0,"children":0},"category":"languages","active":true,"countryId":null,"country":null,"data":null,"govDistId":null,"govDist":null,"languageId":"lang_0000000000N3K70GZXE29Z03A4","language":{"languageName":"English","nativeName":"English"},"boolean":null,"text":null}],"accessDetails":[{"attributeId":"attr_01GW2HHFVMYXMS8ARA3GE7HZFD","supplementId":"atts_01GW2HT9F01W2M7FBSKSXAQ9R4","tag":"accesslink","tsKey":"serviceaccess.accesslink","tsNs":"attribute","icon":null,"iconBg":null,"showOnLocation":null,"_count":{"parents":0,"children":0},"category":"service-access-instructions","active":true,"countryId":null,"country":null,"data":{"json":{"_id":{"$oid":"5e7e4bdbd54f1760921a4234"},"access_type":"link","access_value":"https://www.whitman-walker.org/hiv-sti-testing","instructions":"Visit the website to learn more about Whitman-Walker's testing hours and locations.","access_value_ES":"https://www.whitman-walker.org/hiv-sti-testing","instructions_ES":"Visita el sitio web para obtener más información sobre los horarios y lugares de prueba de Whitman-Walker."}},"govDistId":null,"govDist":null,"languageId":null,"language":null,"boolean":null,"text":{"key":"orgn_01GVH3V408N0YS7CDYAH3F2BMH.attribute.atts_01GW2HT9F01W2M7FBSKSXAQ9R4","text":"Visit the website to learn more about Whitman-Walker's testing hours and locations.","ns":"org-data"}},{"attributeId":"attr_01GW2HHFVMKTFWCKBVVFJ5GMY0","supplementId":"atts_01GW2HT9F09GFRWM3JK2A43AWG","tag":"accessphone","tsKey":"serviceaccess.accessphone","tsNs":"attribute","icon":null,"iconBg":null,"showOnLocation":null,"_count":{"parents":0,"children":0},"category":"service-access-instructions","active":true,"countryId":null,"country":null,"data":{"json":{"_id":{"$oid":"5e7e4bdbd54f1760921a4235"},"access_type":"phone","access_value":"202-745-7000","instructions":"Contact the Main Office about services offered in multiple languages upon request.","access_value_ES":"202-745-7000","instructions_ES":"Comunícate con la oficina principal sobre los servicios que se ofrecen en varios idiomas si lo solicitas."}},"govDistId":null,"govDist":null,"languageId":null,"language":null,"boolean":null,"text":{"key":"orgn_01GVH3V408N0YS7CDYAH3F2BMH.attribute.atts_01GW2HT9F09GFRWM3JK2A43AWG","text":"Contact the Main Office about services offered in multiple languages upon request.","ns":"org-data"}},{"attributeId":"attr_01GW2HHFVMH6AE94EXN7T5A87C","supplementId":"atts_01GW2HT9F0SPS3EBCQ710RCNTA","tag":"accesslocation","tsKey":"serviceaccess.accesslocation","tsNs":"attribute","icon":null,"iconBg":null,"showOnLocation":null,"_count":{"parents":0,"children":0},"category":"service-access-instructions","active":true,"countryId":null,"country":null,"data":{"json":{"_id":{"$oid":"5e7e4bdbd54f1760921a4231"},"access_type":"location","access_value":"2301 M. Luther King Jr., Washington DC 20020","instructions":"Max Robinson Center - NO walk-in testing is available. Monday:08:30-12:30, 13:30-17:30; Tuesday:08:30 - 12:30, 13:30 - 17:30; Wednesday:08:30 - 12:30, 13:30 - 17:30; Thursday:08:30 - 12:30, 13:30 - 17:30; Friday:08:30 - 12:30, 14:15 - 17:30.","access_value_ES":"2301 M. Luther King Jr., Washington DC 20020","instructions_ES":"Centro Max Robinson:NO hay pruebas disponibles sin cita previa. Lunes:08:30-12:30, 13:30-17:30; Martes:08:30 - 12:30, 13:30 - 17:30; Miércoles:08:30 - 12:30, 13:30 - 17:30; Jueves:08:30 - 12:30, 13:30 - 17:30; Viernes:08:30 - 12:30, 14:15 - 17:30."}},"govDistId":null,"govDist":null,"languageId":null,"language":null,"boolean":null,"text":{"key":"orgn_01GVH3V408N0YS7CDYAH3F2BMH.attribute.atts_01GW2HT9F0SPS3EBCQ710RCNTA","text":"Max Robinson Center - NO walk-in testing is available. Monday:08:30-12:30, 13:30-17:30; Tuesday:08:30 - 12:30, 13:30 - 17:30; Wednesday:08:30 - 12:30, 13:30 - 17:30; Thursday:08:30 - 12:30, 13:30 - 17:30; Friday:08:30 - 12:30, 14:15 - 17:30.","ns":"org-data"}},{"attributeId":"attr_01GW2HHFVMH6AE94EXN7T5A87C","supplementId":"atts_01GW2HT9F0638MD74PJ3SCWNXC","tag":"accesslocation","tsKey":"serviceaccess.accesslocation","tsNs":"attribute","icon":null,"iconBg":null,"showOnLocation":null,"_count":{"parents":0,"children":0},"category":"service-access-instructions","active":true,"countryId":null,"country":null,"data":{"json":{"_id":{"$oid":"5e7e4bdbd54f1760921a4233"},"access_type":"location","access_value":"1525 14th St, NW Washington, DC 20005","instructions":"Whitman-Walker at 1525 - NO walk-in testing is available. Monday-Thursday:08:30-12:30 & 13:30-17:30; Friday:08:30- 12:30 & 14:30 -17:30.","access_value_ES":"1525 14th St, NW Washington, DC 20005","instructions_ES":"Whitman-Walker en 1525:NO hay pruebas disponibles. Lunes-Jueves:08:30-12:30 y 13:30-17:30; Viernes:08:30- 12:30 y 14:30 -17:30."}},"govDistId":null,"govDist":null,"languageId":null,"language":null,"boolean":null,"text":{"key":"orgn_01GVH3V408N0YS7CDYAH3F2BMH.attribute.atts_01GW2HT9F0638MD74PJ3SCWNXC","text":"Whitman-Walker at 1525 - NO walk-in testing is available. Monday-Thursday:08:30-12:30 & 13:30-17:30; Friday:08:30- 12:30 & 14:30 -17:30.","ns":"org-data"}}]} \ No newline at end of file diff --git a/packages/ui/mockData/json/service.forServiceInfoCard.json b/packages/ui/mockData/json/service.forServiceInfoCard.json index f999911369..546dbec06c 100644 --- a/packages/ui/mockData/json/service.forServiceInfoCard.json +++ b/packages/ui/mockData/json/service.forServiceInfoCard.json @@ -1 +1 @@ -[{"id":"osvc_01GVH3VEWK33YAKZMQ2W3GT4QK","serviceName":{"tsKey":{"text":"Access PEP and PrEP"},"tsNs":"org-data","defaultText":"Access PEP and PrEP"},"serviceCategories":["medical.CATEGORYNAME"],"offersRemote":false},{"id":"osvc_01GVH3VEW3CZ8P9VS6A5MA0R7Z","serviceName":{"tsKey":{"text":"Receive behavioral health services"},"tsNs":"org-data","defaultText":"Receive behavioral health services"},"serviceCategories":["mental-health.CATEGORYNAME"],"offersRemote":true},{"id":"osvc_01GVH3VEWFZ5FHZ6S7BXQY1W55","serviceName":{"tsKey":{"text":"Get the COVID-19 vaccine"},"tsNs":"org-data","defaultText":"Get the COVID-19 vaccine"},"serviceCategories":["medical.CATEGORYNAME"],"offersRemote":false},{"id":"osvc_01GVH3VEWD5ZQY1JZM16Y5M9NG","serviceName":{"tsKey":{"text":"Get legal help for transgender people to replace and update name/gender marker on immigration documents"},"tsNs":"org-data","defaultText":"Get legal help for transgender people to replace and update name/gender marker on immigration documents"},"serviceCategories":["legal.CATEGORYNAME"],"offersRemote":false},{"id":"osvc_01GVH3VEVY24KAYTWY2ZSFZNBX","serviceName":{"tsKey":{"text":"Get free individual and group psychotherapy for LGBTQ young people (ages 13-24)"},"tsNs":"org-data","defaultText":"Get free individual and group psychotherapy for LGBTQ young people (ages 13-24)"},"serviceCategories":["community-support.CATEGORYNAME","mental-health.CATEGORYNAME"],"offersRemote":false},{"id":"osvc_01GVH3VEVVHBRF1FFXZGMMYG7D","serviceName":{"tsKey":{"text":"Access youth and family support services"},"tsNs":"org-data","defaultText":"Access youth and family support services"},"serviceCategories":["community-support.CATEGORYNAME","medical.CATEGORYNAME","mental-health.CATEGORYNAME"],"offersRemote":true},{"id":"osvc_01GVH3VEWHDC6F5FCQHB0H5GD6","serviceName":{"tsKey":{"text":"Get gender affirming hormone therapy"},"tsNs":"org-data","defaultText":"Get gender affirming hormone therapy"},"serviceCategories":["medical.CATEGORYNAME"],"offersRemote":false},{"id":"osvc_01GVH3VEVR4SRPFQD2SJF1MCJJ","serviceName":{"tsKey":{"text":"Receive gender affirming care and services"},"tsNs":"org-data","defaultText":"Receive gender affirming care and services"},"serviceCategories":["legal.CATEGORYNAME","medical.CATEGORYNAME","mental-health.CATEGORYNAME"],"offersRemote":false},{"id":"osvc_01GVH3VEW2ND36DB0XWAH1PQY0","serviceName":{"tsKey":{"text":"Get dental health services for HIV-positive individuals"},"tsNs":"org-data","defaultText":"Get dental health services for HIV-positive individuals"},"serviceCategories":["medical.CATEGORYNAME"],"offersRemote":false},{"id":"osvc_01GVH3VEVSNF9NH79R7HC9FHY6","serviceName":{"tsKey":{"text":"Get HIV care for newly diagnosed patients"},"tsNs":"org-data","defaultText":"Get HIV care for newly diagnosed patients"},"serviceCategories":["medical.CATEGORYNAME"],"offersRemote":false},{"id":"osvc_01GVH3VEWM65579T29F19QXP8E","serviceName":{"tsKey":{"text":"Get help with navigating health insurance options"},"tsNs":"org-data","defaultText":"Get help with navigating health insurance options"},"serviceCategories":["medical.CATEGORYNAME"],"offersRemote":true},{"id":"osvc_01GVH3VEVZY7K2TYY1ZE7WXRRC","serviceName":{"tsKey":{"text":"Get legal help with immigration services"},"tsNs":"org-data","defaultText":"Get legal help with immigration services"},"serviceCategories":["legal.CATEGORYNAME"],"offersRemote":false},{"id":"osvc_01GVH3VEVPF1KEKBTRVTV70WGV","serviceName":{"tsKey":{"text":"Get rapid HIV testing"},"tsNs":"org-data","defaultText":"Get rapid HIV testing"},"serviceCategories":["medical.CATEGORYNAME"],"offersRemote":false}] \ No newline at end of file +[{"id":"osvc_01GVH3VDMNH6PJFW50BVWN0N9R","serviceName":{"tsKey":"orgn_01GVH3V3RCCBMFD55PWHR8AEC0.osvc_01GVH3VDMNH6PJFW50BVWN0N9R.name","tsNs":"org-data","defaultText":"Get emergency shelter for youth ages 18-24"},"serviceCategories":["housing.CATEGORYNAME"],"offersRemote":false},{"id":"osvc_01GVH3VDMSN34BACQDMY6S5GPM","serviceName":{"tsKey":"orgn_01GVH3V3RCCBMFD55PWHR8AEC0.osvc_01GVH3VDMSN34BACQDMY6S5GPM.name","tsNs":"org-data","defaultText":"Get education and employment services for youth ages 24 and under"},"serviceCategories":["education-and-employment.CATEGORYNAME"],"offersRemote":false},{"id":"osvc_01GVH3VDMZYAPMQWQ5F3YWM8FW","serviceName":{"tsKey":"orgn_01GVH3V3RCCBMFD55PWHR8AEC0.osvc_01GVH3VDMZYAPMQWQ5F3YWM8FW.name","tsNs":"org-data","defaultText":"Get housing and support services for youth ages 18-24 with HIV"},"serviceCategories":["housing.CATEGORYNAME"],"offersRemote":false},{"id":"osvc_01GVH3VDN19JS30RV26PH04ZA8","serviceName":{"tsKey":"orgn_01GVH3V3RCCBMFD55PWHR8AEC0.osvc_01GVH3VDN19JS30RV26PH04ZA8.name","tsNs":"org-data","defaultText":"Get supportive housing for LGBTQ youth ages 18-24"},"serviceCategories":["housing.CATEGORYNAME"],"offersRemote":false},{"id":"osvc_01GVH3VDN4M572FCVMDZTCNYT0","serviceName":{"tsKey":"orgn_01GVH3V3RCCBMFD55PWHR8AEC0.osvc_01GVH3VDN4M572FCVMDZTCNYT0.name","tsNs":"org-data","defaultText":"Get homeless support services at a drop-in center for ages 24 and under"},"serviceCategories":["housing.CATEGORYNAME","hygiene-and-clothing.CATEGORYNAME"],"offersRemote":false},{"id":"osvc_01GVH3VDN73VP7ZAFMPC67HSWN","serviceName":{"tsKey":"orgn_01GVH3V3RCCBMFD55PWHR8AEC0.osvc_01GVH3VDN73VP7ZAFMPC67HSWN.name","tsNs":"org-data","defaultText":"Get free medical care for youth ages 25 and under"},"serviceCategories":["medical.CATEGORYNAME"],"offersRemote":false},{"id":"osvc_01GVH3VDN9470A0E49NNYP9JX6","serviceName":{"tsKey":"orgn_01GVH3V3RCCBMFD55PWHR8AEC0.osvc_01GVH3VDN9470A0E49NNYP9JX6.name","tsNs":"org-data","defaultText":"Get emergency shelter for children ages 17 and younger"},"serviceCategories":["housing.CATEGORYNAME"],"offersRemote":false},{"id":"osvc_01GVH3VDNCMFMKMGSA8EGA8NPB","serviceName":{"tsKey":"orgn_01GVH3V3RCCBMFD55PWHR8AEC0.osvc_01GVH3VDNCMFMKMGSA8EGA8NPB.name","tsNs":"org-data","defaultText":"Call a crisis help line for youth"},"serviceCategories":["housing.CATEGORYNAME","mental-health.CATEGORYNAME"],"offersRemote":true}] \ No newline at end of file diff --git a/packages/ui/mockData/json/service.forServiceModal.json b/packages/ui/mockData/json/service.forServiceModal.json index 66d55b3b28..3c8c715ecd 100644 --- a/packages/ui/mockData/json/service.forServiceModal.json +++ b/packages/ui/mockData/json/service.forServiceModal.json @@ -1 +1 @@ -{"id":"osvc_01GVH3VDMSN34BACQDMY6S5GPM","services":[{"tag":{"tsKey":"education-and-employment.career-counseling"}}],"serviceName":{"key":"orgn_01GVH3V3RCCBMFD55PWHR8AEC0.osvc_01GVH3VDMSN34BACQDMY6S5GPM.name","ns":"org-data","tsKey":{"text":"Get education and employment services for youth ages 24 and under"}},"locations":[{"location":{"country":{"cca2":"US"}}}],"attributes":[{"attribute":{"id":"attr_01GW2HHFVGDTNW9PDQNXK6TF1T","tsKey":"cost.cost-free","tsNs":"attribute","icon":"carbon:piggy-bank","iconBg":null,"showOnLocation":null,"categories":[{"category":{"tag":"cost","icon":null}}],"_count":{"parents":0,"children":0}},"supplement":{"id":"atts_01E4ENGJDYSQVYQQG5K7ZHPRGS","country":null,"language":null,"text":null,"govDist":null,"boolean":null,"data":null}},{"attribute":{"id":"attr_01GW2HHFVE9NE0NMDPK4X8WBNB","tsKey":"community.teens","tsNs":"attribute","icon":null,"iconBg":null,"showOnLocation":null,"categories":[{"category":{"tag":"community","icon":null}}],"_count":{"parents":0,"children":0}},"supplement":{"id":"atts_01E4ENGJDYXPSMZJYMYTSRQSBG","country":null,"language":null,"text":null,"govDist":null,"boolean":null,"data":null}},{"attribute":{"id":"attr_01GW2HHFVAKWSPFVAN9CYQE982","tsKey":"community.homeless","tsNs":"attribute","icon":null,"iconBg":null,"showOnLocation":null,"categories":[{"category":{"tag":"community","icon":null}}],"_count":{"parents":0,"children":0}},"supplement":{"id":"atts_01E4ENGJDYGA7WJPW6TE0XECN3","country":null,"language":null,"text":null,"govDist":null,"boolean":null,"data":null}},{"attribute":{"id":"attr_01GW2HHFVCKH2AQ2E1CKA1A8HP","tsKey":"community.lgbtq-youth","tsNs":"attribute","icon":null,"iconBg":null,"showOnLocation":null,"categories":[{"category":{"tag":"community","icon":null}}],"_count":{"parents":0,"children":0}},"supplement":{"id":"atts_01E4ENGJDY1GX5QJYCSVJ98HKM","country":null,"language":null,"text":null,"govDist":null,"boolean":null,"data":null}},{"attribute":{"id":"attr_01GW2HHFV3BADK80TG0DXXFPMM","tsKey":"additional.has-confidentiality-policy","tsNs":"attribute","icon":null,"iconBg":null,"showOnLocation":null,"categories":[{"category":{"tag":"additional-information","icon":null}}],"_count":{"parents":0,"children":0}},"supplement":{"id":"atts_01E4ENGJDY7F991XESQ0GGGZTR","country":null,"language":null,"text":null,"govDist":null,"boolean":null,"data":null}},{"attribute":{"id":"attr_01GW2HHFVGJ5GD2WHNJDPSFNRW","tsKey":"eligibility.time-appointment-required","tsNs":"attribute","icon":null,"iconBg":null,"showOnLocation":null,"categories":[{"category":{"tag":"eligibility-requirements","icon":null}}],"_count":{"parents":0,"children":0}},"supplement":{"id":"atts_01E4ENGJDYZTZ70RZDSX8X24WX","country":null,"language":null,"text":null,"govDist":null,"boolean":null,"data":null}},{"attribute":{"id":"attr_01GW2HHFVJ8K180CNX339BTXM2","tsKey":"lang.lang-offered","tsNs":"attribute","icon":null,"iconBg":null,"showOnLocation":null,"categories":[{"category":{"tag":"languages","icon":null}}],"_count":{"parents":0,"children":0}},"supplement":{"id":"atts_01GW2HT8C1N900BKNRTY39R58H","country":null,"language":{"languageName":"English","nativeName":"English"},"text":null,"govDist":null,"boolean":null,"data":null}},{"attribute":{"id":"attr_01GW2HHFVGSAZXGR4JAVHEK6ZC","tsKey":"eligibility.elig-age","tsNs":"attribute","icon":null,"iconBg":null,"showOnLocation":null,"categories":[{"category":{"tag":"eligibility-requirements","icon":null}}],"_count":{"parents":0,"children":0}},"supplement":{"id":"atts_01GW2HT8C1J8AQAEHVGANCYRPB","country":null,"language":null,"text":null,"govDist":null,"boolean":null,"data":{"json":{"json":{"max":24}}}}}],"hours":[],"description":{"key":"orgn_01GVH3V3RCCBMFD55PWHR8AEC0.osvc_01GVH3VDMSN34BACQDMY6S5GPM.description","ns":"org-data","tsKey":{"text":"Larkin Street Academy Services offers job readiness, college readiness, computer classes, job placement and retention, internships, tutoring, GED tutoring and classes, secondary and post-secondary school enrollment and support, mindfulness, visual and performing arts. Offices are open Monday through Thursday, 9:00 AM - 16:00 PM, appointments only."}},"accessDetails":[{"attribute":{"id":"attr_01GW2HHFVMH6AE94EXN7T5A87C","tsKey":"serviceaccess.accesslocation","tsNs":"attribute","icon":null,"iconBg":null,"showOnLocation":null,"categories":[{"category":{"tag":"service-access-instructions","icon":null}}],"_count":{"parents":0,"children":0}},"supplement":{"id":"atts_01GW2HT8BWQ0WZ804A34QV7P0J","country":null,"language":null,"text":{"key":"orgn_01GVH3V3RCCBMFD55PWHR8AEC0.attribute.atts_01GW2HT8BWQ0WZ804A34QV7P0J","ns":"org-data","tsKey":{"text":"The above are drop-in service hours for education. Drop-in hours for employment services are Monday, Tuesday:10 a.m. to noon, and 2:30 to 4:30 p.m. Wednesday:10 a.m. to noon, and 1 to 2 p.m. Thursday:10 a.m. to noon, and 1 to 3 p.m. Friday:10 a.m. to 1 p.m."}},"govDist":null,"boolean":null,"data":{"json":{"_id":{"$oid":"5e7e4bd9d54f1760921a3aff"},"access_type":"location","access_value":"134 Golden Gate Ave, San Francisco, CA 94102","instructions":"The above are drop-in service hours for education. Drop-in hours for employment services are Monday, Tuesday:10 a.m. to noon, and 2:30 to 4:30 p.m. Wednesday:10 a.m. to noon, and 1 to 2 p.m. Thursday:10 a.m. to noon, and 1 to 3 p.m. Friday:10 a.m. to 1 p.m.","access_value_ES":"134 Golden Gate Ave, San Francisco, CA 94102","instructions_ES":"Visita para más información."}}}},{"attribute":{"id":"attr_01GW2HHFVMKTFWCKBVVFJ5GMY0","tsKey":"serviceaccess.accessphone","tsNs":"attribute","icon":null,"iconBg":null,"showOnLocation":null,"categories":[{"category":{"tag":"service-access-instructions","icon":null}}],"_count":{"parents":0,"children":0}},"supplement":{"id":"atts_01GW2HT8BWZG5BTQ57DAQHJZ5Z","country":null,"language":null,"text":{"key":"orgn_01GVH3V3RCCBMFD55PWHR8AEC0.attribute.atts_01GW2HT8BWZG5BTQ57DAQHJZ5Z","ns":"org-data","tsKey":{"text":"Call for more information."}},"govDist":null,"boolean":null,"data":{"json":{"_id":{"$oid":"5e7e4bd9d54f1760921a3b00"},"access_type":"phone","access_value":"415-673-0911","instructions":"Call for more information.","access_value_ES":"415-673-0911","instructions_ES":"Llama para más información."}}}}]} +{"id":"osvc_01GVH3VDMSN34BACQDMY6S5GPM","services":[{"tag":{"tsKey":"education-and-employment.career-counseling"}}],"serviceName":{"key":"orgn_01GVH3V3RCCBMFD55PWHR8AEC0.osvc_01GVH3VDMSN34BACQDMY6S5GPM.name","ns":"org-data","tsKey":{"text":"Get education and employment services for youth ages 24 and under"}},"locations":[{"location":{"country":{"cca2":"US"}}}],"attributes":[{"attributeId":"attr_01GW2HHFVGSAZXGR4JAVHEK6ZC","supplementId":"atts_01GW2HT8C1J8AQAEHVGANCYRPB","tag":"elig-age","tsKey":"eligibility.elig-age","tsNs":"attribute","icon":null,"iconBg":null,"showOnLocation":null,"_count":{"parents":0,"children":0},"category":"eligibility-requirements","active":true,"countryId":null,"country":null,"data":{"json":{"max":24}},"govDistId":null,"govDist":null,"languageId":null,"language":null,"boolean":null,"text":null},{"attributeId":"attr_01GW2HHFVGDTNW9PDQNXK6TF1T","supplementId":"atts_01E4ENGJDYSQVYQQG5K7ZHPRGS","tag":"cost-free","tsKey":"cost.cost-free","tsNs":"attribute","icon":"carbon:piggy-bank","iconBg":null,"showOnLocation":null,"_count":{"parents":0,"children":0},"category":"cost","active":true,"countryId":null,"country":null,"data":null,"govDistId":null,"govDist":null,"languageId":null,"language":null,"boolean":null,"text":null},{"attributeId":"attr_01GW2HHFVE9NE0NMDPK4X8WBNB","supplementId":"atts_01E4ENGJDYXPSMZJYMYTSRQSBG","tag":"teens","tsKey":"community.teens","tsNs":"attribute","icon":null,"iconBg":null,"showOnLocation":null,"_count":{"parents":0,"children":0},"category":"community","active":true,"countryId":null,"country":null,"data":null,"govDistId":null,"govDist":null,"languageId":null,"language":null,"boolean":null,"text":null},{"attributeId":"attr_01GW2HHFVAKWSPFVAN9CYQE982","supplementId":"atts_01E4ENGJDYGA7WJPW6TE0XECN3","tag":"homeless","tsKey":"community.homeless","tsNs":"attribute","icon":null,"iconBg":null,"showOnLocation":null,"_count":{"parents":0,"children":0},"category":"community","active":true,"countryId":null,"country":null,"data":null,"govDistId":null,"govDist":null,"languageId":null,"language":null,"boolean":null,"text":null},{"attributeId":"attr_01GW2HHFVCKH2AQ2E1CKA1A8HP","supplementId":"atts_01E4ENGJDY1GX5QJYCSVJ98HKM","tag":"lgbtq-youth","tsKey":"community.lgbtq-youth","tsNs":"attribute","icon":null,"iconBg":null,"showOnLocation":null,"_count":{"parents":0,"children":0},"category":"community","active":true,"countryId":null,"country":null,"data":null,"govDistId":null,"govDist":null,"languageId":null,"language":null,"boolean":null,"text":null},{"attributeId":"attr_01GW2HHFV3BADK80TG0DXXFPMM","supplementId":"atts_01E4ENGJDY7F991XESQ0GGGZTR","tag":"has-confidentiality-policy","tsKey":"additional.has-confidentiality-policy","tsNs":"attribute","icon":null,"iconBg":null,"showOnLocation":null,"_count":{"parents":0,"children":0},"category":"additional-information","active":true,"countryId":null,"country":null,"data":null,"govDistId":null,"govDist":null,"languageId":null,"language":null,"boolean":null,"text":null},{"attributeId":"attr_01GW2HHFVGJ5GD2WHNJDPSFNRW","supplementId":"atts_01E4ENGJDYZTZ70RZDSX8X24WX","tag":"time-appointment-required","tsKey":"eligibility.time-appointment-required","tsNs":"attribute","icon":null,"iconBg":null,"showOnLocation":null,"_count":{"parents":0,"children":0},"category":"eligibility-requirements","active":true,"countryId":null,"country":null,"data":null,"govDistId":null,"govDist":null,"languageId":null,"language":null,"boolean":null,"text":null},{"attributeId":"attr_01GW2HHFVJ8K180CNX339BTXM2","supplementId":"atts_01GW2HT8C1N900BKNRTY39R58H","tag":"lang-offered","tsKey":"lang.lang-offered","tsNs":"attribute","icon":null,"iconBg":null,"showOnLocation":null,"_count":{"parents":0,"children":0},"category":"languages","active":true,"countryId":null,"country":null,"data":null,"govDistId":null,"govDist":null,"languageId":"lang_0000000000N3K70GZXE29Z03A4","language":{"languageName":"English","nativeName":"English"},"boolean":null,"text":null}],"hours":[],"description":{"key":"orgn_01GVH3V3RCCBMFD55PWHR8AEC0.osvc_01GVH3VDMSN34BACQDMY6S5GPM.description","ns":"org-data","tsKey":{"text":"Larkin Street Academy Services offers job readiness, college readiness, computer classes, job placement and retention, internships, tutoring, GED tutoring and classes, secondary and post-secondary school enrollment and support, mindfulness, visual and performing arts. Offices are open Monday through Thursday, 9:00 AM - 16:00 PM, appointments only."}},"accessDetails":[{"attributeId":"attr_01GW2HHFVMH6AE94EXN7T5A87C","supplementId":"atts_01GW2HT8BWQ0WZ804A34QV7P0J","tag":"accesslocation","tsKey":"serviceaccess.accesslocation","tsNs":"attribute","icon":null,"iconBg":null,"showOnLocation":null,"_count":{"parents":0,"children":0},"category":"service-access-instructions","active":true,"countryId":null,"country":null,"data":{"json":{"_id":{"$oid":"5e7e4bd9d54f1760921a3aff"},"access_type":"location","access_value":"134 Golden Gate Ave, San Francisco, CA 94102","instructions":"The above are drop-in service hours for education. Drop-in hours for employment services are Monday, Tuesday:10 a.m. to noon, and 2:30 to 4:30 p.m. Wednesday:10 a.m. to noon, and 1 to 2 p.m. Thursday:10 a.m. to noon, and 1 to 3 p.m. Friday:10 a.m. to 1 p.m.","access_value_ES":"134 Golden Gate Ave, San Francisco, CA 94102","instructions_ES":"Visita para más información."}},"govDistId":null,"govDist":null,"languageId":null,"language":null,"boolean":null,"text":{"key":"orgn_01GVH3V3RCCBMFD55PWHR8AEC0.attribute.atts_01GW2HT8BWQ0WZ804A34QV7P0J","text":"The above are drop-in service hours for education. Drop-in hours for employment services are Monday, Tuesday:10 a.m. to noon, and 2:30 to 4:30 p.m. Wednesday:10 a.m. to noon, and 1 to 2 p.m. Thursday:10 a.m. to noon, and 1 to 3 p.m. Friday:10 a.m. to 1 p.m.","ns":"org-data"}},{"attributeId":"attr_01GW2HHFVMKTFWCKBVVFJ5GMY0","supplementId":"atts_01GW2HT8BWZG5BTQ57DAQHJZ5Z","tag":"accessphone","tsKey":"serviceaccess.accessphone","tsNs":"attribute","icon":null,"iconBg":null,"showOnLocation":null,"_count":{"parents":0,"children":0},"category":"service-access-instructions","active":true,"countryId":null,"country":null,"data":{"json":{"_id":{"$oid":"5e7e4bd9d54f1760921a3b00"},"access_type":"phone","access_value":"415-673-0911","instructions":"Call for more information.","access_value_ES":"415-673-0911","instructions_ES":"Llama para más información."}},"govDistId":null,"govDist":null,"languageId":null,"language":null,"boolean":null,"text":{"key":"orgn_01GVH3V3RCCBMFD55PWHR8AEC0.attribute.atts_01GW2HT8BWZG5BTQ57DAQHJZ5Z","text":"Call for more information.","ns":"org-data"}}]} \ No newline at end of file From 03a07a0c5dc0d39fd43768ab7841fd3515a60bd9 Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Wed, 27 Mar 2024 15:23:17 -0400 Subject: [PATCH 35/61] allow passed data, widen param type --- packages/ui/components/data-display/Hours.tsx | 8 ++++++-- packages/ui/hooks/useFreeText.ts | 17 +++++++++++++---- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/packages/ui/components/data-display/Hours.tsx b/packages/ui/components/data-display/Hours.tsx index 914dc8db25..4ce5434dea 100644 --- a/packages/ui/components/data-display/Hours.tsx +++ b/packages/ui/components/data-display/Hours.tsx @@ -2,6 +2,7 @@ import { createStyles, List, rem, Skeleton, Stack, Table, Text, Title } from '@m import { Interval } from 'luxon' import { useTranslation } from 'next-i18next' +import { type ApiOutput } from '@weareinreach/api' import { HoursDrawer } from '~ui/components/data-portal/HoursDrawer' import { useCustomVariant } from '~ui/hooks/useCustomVariant' import { useLocalizedDays } from '~ui/hooks/useLocalizedDays' @@ -39,11 +40,13 @@ const nullObj = { 6: [], } -export const Hours = ({ parentId, label = 'regular', edit }: HoursProps) => { +export const Hours = ({ parentId, label = 'regular', edit, data: passedData }: HoursProps) => { const { t, i18n } = useTranslation('common') const variants = useCustomVariant() const { classes } = useStyles() - const { data, isLoading } = api.orgHours.forHoursDisplay.useQuery(parentId) + const { data, isLoading } = passedData + ? { data: passedData, isLoading: false } + : api.orgHours.forHoursDisplay.useQuery(parentId) const dayMap = useLocalizedDays(i18n.resolvedLanguage) if (!data && !isLoading) return null @@ -106,4 +109,5 @@ export interface HoursProps { parentId: string label?: keyof typeof labelKeys edit?: boolean + data?: ApiOutput['orgHours']['forHoursDisplay'] } diff --git a/packages/ui/hooks/useFreeText.ts b/packages/ui/hooks/useFreeText.ts index 4b1f846893..b40277143a 100644 --- a/packages/ui/hooks/useFreeText.ts +++ b/packages/ui/hooks/useFreeText.ts @@ -3,8 +3,16 @@ import { useTranslation } from 'next-i18next' import { type DB } from '@weareinreach/api/prisma/types' +const isNestedFreeText = (item: unknown): item is NestedFreeText => { + if (!item || typeof item !== 'object') return false + if ('tsKey' in item) return true + return false +} + export const getFreeText: GetFreeText = (freeTextRecord, tOptions) => { - const { key: dbKey, tsKey } = freeTextRecord + const { key: dbKey, tsKey } = isNestedFreeText(freeTextRecord) + ? freeTextRecord + : { key: freeTextRecord.key, tsKey: { text: freeTextRecord.text } } const deconstructedKey = dbKey.split('.') const ns = deconstructedKey[0] if (!deconstructedKey.length || !ns) throw new Error('Invalid key') @@ -20,13 +28,14 @@ export const useFreeText: UseFreeText = (freeTextRecord, tOptions) => { return t(key, options) } -export interface UseFreeTextProps extends Pick, Partial> { +export interface NestedFreeText extends Pick, Partial> { tsKey: Pick & Partial> } +export type TranslationKeyRecord = Pick export type GetFreeText = ( - freeTextRecord: UseFreeTextProps, + freeTextRecord: NestedFreeText | TranslationKeyRecord, tOptions?: TOptions ) => { key: string; options: TOptions } -export type UseFreeText = (freeTextRecord: UseFreeTextProps, tOptions?: TOptions) => string +export type UseFreeText = (freeTextRecord: NestedFreeText, tOptions?: TOptions) => string From 3f274e6c70460cc0e5f52821cbfa23891e2b2036 Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Wed, 27 Mar 2024 15:24:26 -0400 Subject: [PATCH 36/61] display data --- .../data-display/ContactInfo/index.tsx | 3 +- .../ServiceEditDrawer/index.stories.tsx | 2 + .../data-portal/ServiceEditDrawer/index.tsx | 77 +++++++++++++++---- packages/ui/modals/Service/index.stories.tsx | 1 - packages/ui/modals/Service/processor.tsx | 34 +++++--- 5 files changed, 90 insertions(+), 27 deletions(-) diff --git a/packages/ui/components/data-display/ContactInfo/index.tsx b/packages/ui/components/data-display/ContactInfo/index.tsx index d5d22b9c09..0c809072bc 100644 --- a/packages/ui/components/data-display/ContactInfo/index.tsx +++ b/packages/ui/components/data-display/ContactInfo/index.tsx @@ -47,7 +47,8 @@ export const ContactInfo = ({ return {items} } -export const hasContactInfo = (data: PassedDataObject) => { +export const hasContactInfo = (data: PassedDataObject | null | undefined): data is PassedDataObject => { + 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-portal/ServiceEditDrawer/index.stories.tsx b/packages/ui/components/data-portal/ServiceEditDrawer/index.stories.tsx index 5bbc2c1fd1..159611f216 100644 --- a/packages/ui/components/data-portal/ServiceEditDrawer/index.stories.tsx +++ b/packages/ui/components/data-portal/ServiceEditDrawer/index.stories.tsx @@ -4,6 +4,7 @@ import { Button } from '~ui/components/core/Button' import { component } from '~ui/mockData/component' import { fieldOpt } from '~ui/mockData/fieldOpt' import { organization } from '~ui/mockData/organization' +import { orgHours } from '~ui/mockData/orgHours' import { service } from '~ui/mockData/service' import { ServiceEditDrawer } from './index' @@ -31,6 +32,7 @@ export default { fieldOpt.govDistsByCountry, fieldOpt.countryGovDistMap, component.ServiceSelect, + orgHours.forHoursDisplay, ], }, args: { diff --git a/packages/ui/components/data-portal/ServiceEditDrawer/index.tsx b/packages/ui/components/data-portal/ServiceEditDrawer/index.tsx index 158cee8d10..08179869b9 100644 --- a/packages/ui/components/data-portal/ServiceEditDrawer/index.tsx +++ b/packages/ui/components/data-portal/ServiceEditDrawer/index.tsx @@ -21,6 +21,8 @@ import { Badge } from '~ui/components/core/Badge' import { Breadcrumb } from '~ui/components/core/Breadcrumb' import { Button } from '~ui/components/core/Button' import { Section } from '~ui/components/core/Section' +import { ContactInfo, hasContactInfo } from '~ui/components/data-display/ContactInfo' +import { Hours } from '~ui/components/data-display/Hours' import { ServiceSelect } from '~ui/components/data-portal/ServiceSelect' import { useCustomVariant } from '~ui/hooks' import { Icon } from '~ui/icon' @@ -39,7 +41,7 @@ const _ServiceEditDrawer = forwardRef const [drawerOpened, drawerHandler] = useDisclosure(true) const { classes } = useStyles() const variants = useCustomVariant() - const { t } = useTranslation(['common', 'gov-dist']) + const { t, i18n } = useTranslation(['common', 'gov-dist']) // #region Get existing data/populate form const { data } = api.service.forServiceEditDrawer.useQuery(serviceId, { refetchOnWindowFocus: false, @@ -143,13 +145,19 @@ const _ServiceEditDrawer = forwardRef if (!data) return null - // const { getHelp, publicTransit } = data - // ? processAccessInstructions({ - // accessDetails: data?.accessDetails, - // locations: data?.locations, - // t, - // }) - // : { getHelp: null, publicTransit: null } + const { getHelp, publicTransit } = data + ? processAccessInstructions({ + accessDetails: data?.accessDetails, + locations: data?.locations, + t, + }) + : { getHelp: null, publicTransit: null } + + const attributes = processAttributes({ + attributes: data.attributes, + locale: i18n.resolvedLanguage ?? 'en', + t, + }) return ( <> @@ -206,14 +214,55 @@ const _ServiceEditDrawer = forwardRef {serviceAreas()} {/* {Boolean(geoMap?.size) && } */} - {t('service.get-help')} + + {hasContactInfo(getHelp) && ( + + )} + {Boolean(data.hours) && } + - {t('service.clients-served')} + + {attributes.clientsServed.srvfocus} + + + {attributes.clientsServed.targetPop} + + + {attributes.cost} + + {attributes.eligibility.age} + + + {attributes.eligibility.requirements.map((text, i) => ( + {text} + ))} + + + + {attributes.eligibility.freeText} + + + + + + {attributes.lang.map((lang, i) => ( + {lang} + ))} + + + + + + {attributes.miscWithIcons} + + + + {attributes.misc.map((text, i) => ( + {text} + ))} + + - {t('service.cost')} - {t('service.eligibility')} - {t('service.languages')} - {t('service.extra-info')} diff --git a/packages/ui/modals/Service/index.stories.tsx b/packages/ui/modals/Service/index.stories.tsx index df7df9b1b9..e2919b20f1 100644 --- a/packages/ui/modals/Service/index.stories.tsx +++ b/packages/ui/modals/Service/index.stories.tsx @@ -1,7 +1,6 @@ import { type Meta } from '@storybook/react' import { Button } from '~ui/components/core/Button' -import { getTRPCMock } from '~ui/lib/getTrpcMock' import { organization } from '~ui/mockData/organization' import { savedList } from '~ui/mockData/savedList' import { service } from '~ui/mockData/service' diff --git a/packages/ui/modals/Service/processor.tsx b/packages/ui/modals/Service/processor.tsx index 8ba9d50879..c140ccbcd7 100644 --- a/packages/ui/modals/Service/processor.tsx +++ b/packages/ui/modals/Service/processor.tsx @@ -13,13 +13,25 @@ import { isValidIcon } from '~ui/icon' import { ModalText } from './ModalText' +type AccessDetailsAPI = + | ApiOutput['service']['forServiceModal']['accessDetails'] + | ApiOutput['service']['forServiceEditDrawer']['accessDetails'] + +type LocationsAPI = + | ApiOutput['service']['forServiceModal']['locations'] + | ApiOutput['service']['forServiceEditDrawer']['locations'] + +type AttributesAPI = + | ApiOutput['service']['forServiceModal']['attributes'] + | ApiOutput['service']['forServiceEditDrawer']['attributes'] + export const processAccessInstructions = ({ accessDetails, locations, t, }: { - accessDetails: ApiOutput['service']['forServiceModal']['accessDetails'] - locations: ApiOutput['service']['forServiceModal']['locations'] + accessDetails: AccessDetailsAPI + locations: LocationsAPI t: TFunction }): AccessInstructionsOutput => { const output: AccessInstructionsOutput = { @@ -32,8 +44,8 @@ export const processAccessInstructions = ({ publicTransit: null, } - for (const { supplement } of accessDetails) { - const { data, text, id } = supplement + for (const item of accessDetails) { + const { data, text, supplementId: id } = item const parsed = accessInstructions.getAll().safeParse(data) if (parsed.success) { const { access_type, access_value } = parsed.data @@ -100,7 +112,7 @@ export const processAttributes = ({ locale = 'en', t, }: { - attributes: ApiOutput['service']['forServiceModal']['attributes'] + attributes: AttributesAPI locale: string t: TFunction }): AttributesOutput => { @@ -118,8 +130,8 @@ export const processAttributes = ({ misc: [], miscWithIcons: [], } - for (const { attribute, supplement } of attributes) { - const { tsKey, icon, tsNs, id } = attribute + for (const attribute of attributes) { + const { tsKey, icon, tsNs, supplementId: id } = attribute const namespace = tsKey.split('.').shift() as string switch (namespace) { @@ -139,7 +151,7 @@ export const processAttributes = ({ const type = tsKey.split('.').pop() as string switch (type) { case 'elig-age': { - const { data, id } = supplement + const { data } = attribute const parsed = attributeSupplementSchema.numMinMaxOrRange.safeParse(data) if (!parsed.success) break const { min, max } = parsed.data @@ -150,7 +162,7 @@ export const processAttributes = ({ break } case 'other-describe': { - const { text, id } = supplement + const { text } = attribute if (!text) break const { key, options } = getFreeText(text) output.clientsServed.targetPop.push({t(key, options)}) @@ -168,7 +180,7 @@ export const processAttributes = ({ description: ReactNode[] } = { description: [] } - const { text, data, id } = supplement + const { text, data } = attribute if (text) { const { key, options } = getFreeText(text) costDetails.description.push({t(key, options)}) @@ -199,7 +211,7 @@ export const processAttributes = ({ } case 'lang': { - const { language } = supplement + const { language } = attribute if (!language) break const { languageName } = language output.lang.push(languageName) From f2d8b1ac2d7e61a574cd639f7557ca5f65c112e7 Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Thu, 28 Mar 2024 15:49:04 -0400 Subject: [PATCH 37/61] implement Add Attribute modal --- .../ServiceEditDrawer/index.stories.tsx | 5 +- .../data-portal/ServiceEditDrawer/index.tsx | 89 ++++--------------- 2 files changed, 21 insertions(+), 73 deletions(-) diff --git a/packages/ui/components/data-portal/ServiceEditDrawer/index.stories.tsx b/packages/ui/components/data-portal/ServiceEditDrawer/index.stories.tsx index 159611f216..96531c3276 100644 --- a/packages/ui/components/data-portal/ServiceEditDrawer/index.stories.tsx +++ b/packages/ui/components/data-portal/ServiceEditDrawer/index.stories.tsx @@ -2,7 +2,7 @@ import { type Meta, type StoryObj } from '@storybook/react' import { Button } from '~ui/components/core/Button' import { component } from '~ui/mockData/component' -import { fieldOpt } from '~ui/mockData/fieldOpt' +import { allFieldOptHandlers } from '~ui/mockData/fieldOpt' import { organization } from '~ui/mockData/organization' import { orgHours } from '~ui/mockData/orgHours' import { service } from '~ui/mockData/service' @@ -29,10 +29,9 @@ export default { service.getNames, service.forServiceEditDrawer, service.getOptions, - fieldOpt.govDistsByCountry, - fieldOpt.countryGovDistMap, component.ServiceSelect, orgHours.forHoursDisplay, + ...allFieldOptHandlers, ], }, args: { diff --git a/packages/ui/components/data-portal/ServiceEditDrawer/index.tsx b/packages/ui/components/data-portal/ServiceEditDrawer/index.tsx index 08179869b9..77fa24c0d4 100644 --- a/packages/ui/components/data-portal/ServiceEditDrawer/index.tsx +++ b/packages/ui/components/data-portal/ServiceEditDrawer/index.tsx @@ -27,6 +27,7 @@ import { ServiceSelect } from '~ui/components/data-portal/ServiceSelect' import { useCustomVariant } from '~ui/hooks' import { Icon } from '~ui/icon' import { trpc as api } from '~ui/lib/trpcClient' +import { AttributeModal } from '~ui/modals/dataPortal/Attributes' import { processAccessInstructions, processAttributes } from '~ui/modals/Service/processor' import { DataViewer } from '~ui/other/DataViewer' @@ -167,9 +168,20 @@ const _ServiceEditDrawer = forwardRef - + + } + parentRecord={{ serviceId: data.id }} + attachesTo={['SERVICE']} + > + Add Attribute + + + @@ -218,7 +230,10 @@ const _ServiceEditDrawer = forwardRef {hasContactInfo(getHelp) && ( )} - {Boolean(data.hours) && } + {publicTransit} + {Boolean(Object.values(data.hours).length) && ( + + )} @@ -284,69 +299,3 @@ export const ServiceEditDrawer = createPolymorphicComponent<'button', ServiceEdi interface ServiceEditDrawerProps extends ButtonProps { serviceId: string } - -interface FreeText { - id?: string - key: string - ns: string - tsKey: { - text: string | null - crowdinId: number | null - } -} -interface Attribute { - attribute: { - categories?: string[] - id: string - tsKey: string - tsNs: string - icon?: string | null - } - supplement: { - id: string - active?: boolean - data: unknown - boolean?: boolean | null - countryId?: string | null - govDistId?: string | null - languageId?: string | null - text: FreeText | null - } -} -interface FormData { - id: string - published: boolean - deleted: boolean - serviceName: FreeText | null - description: FreeText | null - hours: { - id: string - dayIndex: number - start: Date - end: Date - closed: boolean - tz: string | null - }[] - phones: string[] - emails: string[] - locations: string[] - services: { - id: string - primaryCategoryId: string - }[] - serviceAreas: { - id: string - countries: string[] - districts: string[] - } | null - attributes: Attribute[] - accessDetails: { - id?: string - attribute: { id: string; tsKey: string; tsNs: string } - supplement: { - id: string - data: unknown - text: { id?: string; key: string; ns: string; tsKey: { text: string; crowdinId: number | null } } | null - } - }[] -} From 925dc17f57cc4021f4dd5dad86529ee6f9e095e4 Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Thu, 28 Mar 2024 17:00:13 -0400 Subject: [PATCH 38/61] fix useRouter import --- packages/ui/components/core/UserAvatar.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ui/components/core/UserAvatar.tsx b/packages/ui/components/core/UserAvatar.tsx index 887d5cbd30..ca81041617 100644 --- a/packages/ui/components/core/UserAvatar.tsx +++ b/packages/ui/components/core/UserAvatar.tsx @@ -1,6 +1,6 @@ import { Avatar, createStyles, Group, rem, Skeleton, Stack, Text, useMantineTheme } from '@mantine/core' import { DateTime } from 'luxon' -import router from 'next/router' +import { useRouter } from 'next/router' import { type User } from 'next-auth' import { useSession } from 'next-auth/react' import { useTranslation } from 'next-i18next' @@ -35,6 +35,7 @@ export const UserAvatar = ({ const { t, i18n } = useTranslation() const { data: session, status } = useSession() const theme = useMantineTheme() + const router = useRouter() const subText = () => { if (!user && useLoggedIn && subheading !== undefined) { From a3ff5464f8f21bfb048e9153152fab3bf6e0d099 Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Thu, 28 Mar 2024 17:00:45 -0400 Subject: [PATCH 39/61] launch service drawer --- packages/ui/components/sections/ServicesInfo.tsx | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/packages/ui/components/sections/ServicesInfo.tsx b/packages/ui/components/sections/ServicesInfo.tsx index 3672d45f77..3b00be0023 100644 --- a/packages/ui/components/sections/ServicesInfo.tsx +++ b/packages/ui/components/sections/ServicesInfo.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'next-i18next' import { transformer } from '@weareinreach/util/transformer' import { Link } from '~ui/components/core' import { Badge } from '~ui/components/core/Badge' +import { ServiceEditDrawer } from '~ui/components/data-portal/ServiceEditDrawer' import { useCustomVariant } from '~ui/hooks/useCustomVariant' import { useEditMode } from '~ui/hooks/useEditMode' import { useScreenSize } from '~ui/hooks/useScreenSize' @@ -73,21 +74,11 @@ const ServiceSection = ({ category, services, hideRemoteBadges }: ServiceSection ) return isEditMode ? ( - + {children} - + ) : ( Date: Thu, 28 Mar 2024 17:01:11 -0400 Subject: [PATCH 40/61] remove data viewer --- apps/app/src/pages/org/[slug]/[orgLocationId]/edit/index.tsx | 2 +- .../ui/components/data-portal/ServiceEditDrawer/index.tsx | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/app/src/pages/org/[slug]/[orgLocationId]/edit/index.tsx b/apps/app/src/pages/org/[slug]/[orgLocationId]/edit/index.tsx index 0f8a061972..729979693c 100644 --- a/apps/app/src/pages/org/[slug]/[orgLocationId]/edit/index.tsx +++ b/apps/app/src/pages/org/[slug]/[orgLocationId]/edit/index.tsx @@ -131,7 +131,7 @@ const OrgLocationPage: NextPage + onClick: async () => router.push({ pathname: '/org/[slug]/edit', query: { slug: data.organization.slug }, diff --git a/packages/ui/components/data-portal/ServiceEditDrawer/index.tsx b/packages/ui/components/data-portal/ServiceEditDrawer/index.tsx index 77fa24c0d4..4118e5a544 100644 --- a/packages/ui/components/data-portal/ServiceEditDrawer/index.tsx +++ b/packages/ui/components/data-portal/ServiceEditDrawer/index.tsx @@ -29,7 +29,6 @@ import { Icon } from '~ui/icon' import { trpc as api } from '~ui/lib/trpcClient' import { AttributeModal } from '~ui/modals/dataPortal/Attributes' import { processAccessInstructions, processAttributes } from '~ui/modals/Service/processor' -import { DataViewer } from '~ui/other/DataViewer' import { FormSchema, type TFormSchema } from './schemas' import { useStyles } from './styles' @@ -39,7 +38,7 @@ const isObject = (x: unknown): x is object => typeof x === 'object' const _ServiceEditDrawer = forwardRef( ({ serviceId, ...props }, ref) => { - const [drawerOpened, drawerHandler] = useDisclosure(true) + const [drawerOpened, drawerHandler] = useDisclosure(false) const { classes } = useStyles() const variants = useCustomVariant() const { t, i18n } = useTranslation(['common', 'gov-dist']) @@ -283,7 +282,6 @@ const _ServiceEditDrawer = forwardRef - From a480ccabdc69a9b249dc28120db0ae6a50e290a2 Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Tue, 2 Apr 2024 11:00:45 -0400 Subject: [PATCH 41/61] temp disable gtag addition --- apps/app/src/pages/_app.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/app/src/pages/_app.tsx b/apps/app/src/pages/_app.tsx index b66767f379..896d34973b 100644 --- a/apps/app/src/pages/_app.tsx +++ b/apps/app/src/pages/_app.tsx @@ -9,7 +9,7 @@ import { type AppProps, type NextWebVitalsMetric } from 'next/app' import dynamic from 'next/dynamic' import Head from 'next/head' import { useRouter } from 'next/router' -import Script from 'next/script' +// import Script from 'next/script' import { type Session } from 'next-auth' import { appWithTranslation } from 'next-i18next' import { DefaultSeo, type DefaultSeoProps } from 'next-seo' @@ -78,11 +78,13 @@ const MyApp = (appProps: AppPropsWithGridSwitch) => { - + */} From d0e570fc6b8559a7670b49038c227573569cbc89 Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Tue, 2 Apr 2024 11:02:24 -0400 Subject: [PATCH 42/61] `slug` isn't being passed to page props?? --- apps/app/src/pages/org/[slug]/index.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/app/src/pages/org/[slug]/index.tsx b/apps/app/src/pages/org/[slug]/index.tsx index 29897c1f7b..d8f6b247fb 100644 --- a/apps/app/src/pages/org/[slug]/index.tsx +++ b/apps/app/src/pages/org/[slug]/index.tsx @@ -10,7 +10,6 @@ import { useEffect, useRef, useState } from 'react' import { trpcServerClient } from '@weareinreach/api/trpc' import { AlertMessage } from '@weareinreach/ui/components/core/AlertMessage' -// import { GoogleMap } from '@weareinreach/ui/components/core/GoogleMap' import { Toolbar } from '@weareinreach/ui/components/core/Toolbar' import { ContactSection } from '@weareinreach/ui/components/sections/ContactSection' import { ListingBasicInfo } from '@weareinreach/ui/components/sections/ListingBasicInfo' @@ -40,11 +39,12 @@ const useStyles = createStyles((theme) => ({ })) const OrganizationPage = ({ - slug, + slug: passedSlug, organizationId: orgId, }: InferGetStaticPropsType) => { const router = useRouter<'/org/[slug]'>() - const { data, status } = api.organization.forOrgPage.useQuery({ slug }, { enabled: !!slug }) + const slug = passedSlug ?? router.query.slug + const { data, status } = api.organization.forOrgPage.useQuery({ slug }) // const { query } = router const { t } = useTranslation(formatNS(orgId)) const [activeTab, setActiveTab] = useState('services') @@ -155,7 +155,7 @@ const OrganizationPage = ({ <> {isTablet && } - {width && ( + {Boolean(width) && ( id)} width={width} @@ -244,7 +244,6 @@ export const getStaticProps = async ({ }: GetStaticPropsContext>) => { if (!params) return { notFound: true } const { slug } = params - const ssg = await trpcServerClient({ session: null }) try { const redirect = await ssg.organization.slugRedirect.fetch(slug) From b86ba236333fc539d362976479413ac3dd39390c Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Tue, 2 Apr 2024 11:03:36 -0400 Subject: [PATCH 43/61] temp remove service multi select popover --- .../src/pages/org/[slug]/[orgLocationId]/edit/index.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/app/src/pages/org/[slug]/[orgLocationId]/edit/index.tsx b/apps/app/src/pages/org/[slug]/[orgLocationId]/edit/index.tsx index 729979693c..8264d58078 100644 --- a/apps/app/src/pages/org/[slug]/[orgLocationId]/edit/index.tsx +++ b/apps/app/src/pages/org/[slug]/[orgLocationId]/edit/index.tsx @@ -131,11 +131,12 @@ const OrgLocationPage: NextPage + onClick: async () => { router.push({ pathname: '/org/[slug]/edit', query: { slug: data.organization.slug }, - }), + }) + }, }} organizationId={data.organization.id} saved={Boolean(isSaved)} @@ -195,13 +196,13 @@ const OrgLocationPage: NextPage {'Associate service(s) to this location'} - + />*/} {'Associated services'} From 7c1137bc883557b5dd8a32994106a7f75bb5a19e Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Tue, 2 Apr 2024 13:16:40 -0400 Subject: [PATCH 44/61] revert paths --- .../{edit/index.tsx => edit.tsx} | 0 .../[orgLocationId]/edit/[orgServiceId].tsx | 298 ------------------ apps/app/src/types/nextjs-routes.d.ts | 1 - 3 files changed, 299 deletions(-) rename apps/app/src/pages/org/[slug]/[orgLocationId]/{edit/index.tsx => edit.tsx} (100%) delete mode 100644 apps/app/src/pages/org/[slug]/[orgLocationId]/edit/[orgServiceId].tsx diff --git a/apps/app/src/pages/org/[slug]/[orgLocationId]/edit/index.tsx b/apps/app/src/pages/org/[slug]/[orgLocationId]/edit.tsx similarity index 100% rename from apps/app/src/pages/org/[slug]/[orgLocationId]/edit/index.tsx rename to apps/app/src/pages/org/[slug]/[orgLocationId]/edit.tsx diff --git a/apps/app/src/pages/org/[slug]/[orgLocationId]/edit/[orgServiceId].tsx b/apps/app/src/pages/org/[slug]/[orgLocationId]/edit/[orgServiceId].tsx deleted file mode 100644 index ae142e0d69..0000000000 --- a/apps/app/src/pages/org/[slug]/[orgLocationId]/edit/[orgServiceId].tsx +++ /dev/null @@ -1,298 +0,0 @@ -import { Grid, Stack } from '@mantine/core' -import dynamic from 'next/dynamic' -import { useRouter } from 'next/router' -import { useTranslation } from 'next-i18next' -import { type GetServerSideProps } from 'nextjs-routes' -import { type ReactNode, Suspense, useEffect, useState } from 'react' -import { /*type Path,*/ useFieldArray, useForm } from 'react-hook-form' -import { Textarea, TextInput } from 'react-hook-form-mantine' -// import { type Merge } from 'type-fest' -import { z } from 'zod' - -import { prefixedId } from '@weareinreach/api/schemas/idPrefix' -import { trpcServerClient } from '@weareinreach/api/trpc' -import { checkServerPermissions } from '@weareinreach/auth' -import { generateId } from '@weareinreach/db/lib/idGen' -import { Badge } from '@weareinreach/ui/components/core/Badge' -import { Section } from '@weareinreach/ui/components/core/Section' -import { InlineTextInput } from '@weareinreach/ui/components/data-portal/InlineTextInput' -import { ServiceSelect } from '@weareinreach/ui/components/data-portal/ServiceSelect' -import { api } from '~app/utils/api' -import { getServerSideTranslations } from '~app/utils/i18n' -import { Button } from '~ui/components/core/Button' - -const DevTool = dynamic(() => import('@hookform/devtools').then((mod) => mod.DevTool), { ssr: false }) - -const FreetextObject = z - .object({ - text: z.string().nullable(), - key: z.string().nullish(), - ns: z.string().nullish(), - }) - .nullish() - -// type MapValue = A extends Map ? V : never - -const literalSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]) -type Literal = z.infer -type Json = Literal | { [key: string]: Json } | Json[] -const JsonSchema: z.ZodType = z.lazy(() => - z.union([literalSchema, z.array(JsonSchema), z.record(JsonSchema)]) -) - -const FormSchema = z.object({ - name: FreetextObject, - description: FreetextObject, - services: prefixedId('serviceTag').array(), - attributes: z - .object({ - text: z - .object({ - key: z.string(), - text: z.string(), - ns: z.string(), - }) - .nullable(), - boolean: z.boolean().nullable(), - data: z.any(), - active: z.boolean(), - countryId: z.string().nullable(), - govDistId: z.string().nullable(), - languageId: z.string().nullable(), - category: z.string(), - attributeId: z.string(), - supplementId: z.string(), - }) - .array(), - published: z.boolean(), - deleted: z.boolean(), -}) -const isObject = (x: unknown): x is object => typeof x === 'object' - -type FormSchemaType = z.infer -const EditServicePage = () => { - const { t } = useTranslation() - const router = useRouter<'/org/[slug]/[orgLocationId]/edit/[orgServiceId]'>() - const { data: attributeMap } = api.attribute.map.useQuery() - const { data } = api.page.serviceEdit.useQuery({ id: router.query.orgServiceId ?? '' }) - const { data: allServices } = api.service.getOptions.useQuery() - const form = useForm({ - values: data ? { ...data, services: data.services.map(({ id }) => id) } : undefined, - }) - const attribFields = useFieldArray({ control: form.control, name: 'attributes', keyName: '_rhfId' }) - - console.log(`🚀 ~ EditServicePage ~ attribFields:`, attribFields.fields) - - const dirtyFields = { - name: isObject(form.formState.dirtyFields.name) ? form.formState.dirtyFields.name.text : false, - description: isObject(form.formState.dirtyFields.description) - ? form.formState.dirtyFields.description.text - : false, - services: form.formState.dirtyFields.services ?? false, - } - const dataAttributes = form.watch('attributes') ?? [] - const activeServices = form.watch('services') ?? [] - - type AttrSectionKeys = 'clientsServed' | 'cost' | 'eligibility' | 'languages' | 'additionalInfo' - // type AttrSectionVals = Merge< - // FormSchemaType['attributes'][number], - // { _rhfName: Path; _rhfLabel: string } - // > - - const attributeBase: { - [key in AttrSectionKeys]: ReactNode[] - } = { - clientsServed: [], - cost: [], - eligibility: [], - languages: [], - additionalInfo: [], - } - const [attributes, setAttributes] = useState(attributeBase) - - console.log(`🚀 ~ EditServicePage ~ attributes:`, attributes) - - useEffect(() => { - if (!attributeMap) return - const attrToSet = attributeBase - - for (const [i, item] of dataAttributes.entries()) { - const attribDef = attributeMap.byId.get(item.attributeId) - - console.log(`🚀 ~ useEffect ~ attribDef:`, attribDef) - - if (!attribDef) continue - - const attribNs = attribDef.tsKey.split('.').length - ? (attribDef.tsKey.split('.').shift() as string) - : attribDef.tsKey - console.log(`🚀 ~ useEffect ~ attribNs:`, attribNs) - - switch (attribNs) { - case 'tpop': { - // attrToSet.clientsServed.push({ - // ...item, - // _rhfName: `attributes.${i}.text.text`, - // _rhfLabel: 'Target Population', - // }) - attrToSet.clientsServed.push( - } - name={`attributes.${i}.text.text`} - control={form.control} - label='Target Population' - data-isDirty={form.getFieldState(`attributes.${i}.text.text`).isDirty} - autosize - /> - ) - break - } - case 'cost': { - if (attribDef.tag === 'cost-free') - // attrToSet.cost.push({ - // ...item, - // _rhfName: `attributes.${i}`, - // _rhfLabel: 'Cost', - // }) - break - } - } - } - setAttributes(attrToSet) - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [dataAttributes, attributeMap]) - - return ( - <> - - - - - } - name='name.text' - control={form.control} - fontSize='h2' - data-isDirty={dirtyFields.name} - /> - - - } - name='description.text' - control={form.control} - data-isDirty={dirtyFields.description} - autosize - /> - - - - - - {activeServices.map((serviceId) => { - const service = allServices?.find((s) => s.id === serviceId) - if (!service) return null - return ( - {t(service.tsKey, { ns: service.tsNs })} - ) - })} - - - - {t('service.get-help')} - - {attributes.clientsServed.length ? ( - // attributes.clientsServed.map(({ _rhfName, _rhfLabel, ...item }) => ( - // } - // name={_rhfName} - // control={form.control} - // label={_rhfLabel} - // data-isDirty={form.getFieldState(_rhfName).isDirty} - // autosize - // /> - // )) - attributes.clientsServed - ) : ( - - )} - - {t('service.cost')} - {t('service.eligibility')} - {t('service.languages')} - {t('service.extra-info')} - - - {/* @ts-expect-error Hush, devtool. */} - - - ) -} - -export default EditServicePage - -export const getServerSideProps: GetServerSideProps = async ({ locale, params, req, res }) => { - const urlParams = z - .object({ - slug: z.string(), - orgLocationId: prefixedId('orgLocation'), - orgServiceId: prefixedId('orgService'), - }) - .safeParse(params) - if (!urlParams.success) return { notFound: true } - const { slug, orgLocationId: _, orgServiceId } = urlParams.data - const session = await checkServerPermissions({ - ctx: { req, res }, - permissions: ['dataPortalBasic'], - has: 'some', - }) - if (!session) { - return { - redirect: { - destination: '/', - permanent: false, - }, - } - } - const ssg = await trpcServerClient({ session }) - const { id: orgId } = await ssg.organization.getIdFromSlug.fetch({ slug }) - const [i18n] = await Promise.all([ - getServerSideTranslations(locale, ['common', 'services', 'attribute']), - ssg.page.serviceEdit.prefetch({ id: orgServiceId }), - ssg.component.ServiceSelect.prefetch(), - ssg.service.getOptions.prefetch(), - ]) - const props = { - organizationId: orgId, - session, - trpcState: ssg.dehydrate(), - ...i18n, - } - - return { - props, - } -} diff --git a/apps/app/src/types/nextjs-routes.d.ts b/apps/app/src/types/nextjs-routes.d.ts index 8ff841f0fb..96bc95ed54 100644 --- a/apps/app/src/types/nextjs-routes.d.ts +++ b/apps/app/src/types/nextjs-routes.d.ts @@ -30,7 +30,6 @@ declare module "nextjs-routes" { | DynamicRoute<"/api/trpc/[trpc]", { "trpc": string }> | StaticRoute<"/api/trpc-playground"> | StaticRoute<"/"> - | DynamicRoute<"/org/[slug]/[orgLocationId]/edit/[orgServiceId]", { "slug": string; "orgLocationId": string; "orgServiceId": string }> | DynamicRoute<"/org/[slug]/[orgLocationId]/edit", { "slug": string; "orgLocationId": string }> | DynamicRoute<"/org/[slug]/[orgLocationId]", { "slug": string; "orgLocationId": string }> | DynamicRoute<"/org/[slug]/edit", { "slug": string }> From dddc731b098260e867a19840fd2adb185465e466 Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Tue, 2 Apr 2024 13:53:28 -0400 Subject: [PATCH 45/61] fix PageContent props --- apps/app/src/pages/_app.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/app/src/pages/_app.tsx b/apps/app/src/pages/_app.tsx index 896d34973b..db029c6449 100644 --- a/apps/app/src/pages/_app.tsx +++ b/apps/app/src/pages/_app.tsx @@ -52,7 +52,7 @@ export function reportWebVitals(stats: NextWebVitalsMetric) { appEvent.webVitals(stats) } -const PageContent = ({ Component, ...pageProps }: AppPropsWithGridSwitch) => { +const PageContent = ({ Component, pageProps }: AppPropsWithGridSwitch) => { const router = useRouter() const autoResetState = Component.autoResetState ? { key: router.asPath } : {} return Component.omitGrid ? ( From 1a80bcb539b639e563ca073ecf645f080f683b98 Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Tue, 2 Apr 2024 14:01:12 -0400 Subject: [PATCH 46/61] fix group spacing --- packages/ui/components/sections/ServicesInfo.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/ui/components/sections/ServicesInfo.tsx b/packages/ui/components/sections/ServicesInfo.tsx index 3b00be0023..8df0044cf2 100644 --- a/packages/ui/components/sections/ServicesInfo.tsx +++ b/packages/ui/components/sections/ServicesInfo.tsx @@ -74,7 +74,12 @@ const ServiceSection = ({ category, services, hideRemoteBadges }: ServiceSection ) return isEditMode ? ( - + {children} From 4e8860c66accdd9723c80d14c11a3b4458e602a7 Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Tue, 2 Apr 2024 17:10:13 -0400 Subject: [PATCH 47/61] Add Coverage modal --- packages/api/router/serviceArea/index.ts | 9 + .../serviceArea/mutation.addToArea.handler.ts | 50 +++ .../serviceArea/mutation.addToArea.schema.ts | 26 ++ packages/api/router/serviceArea/schemas.ts | 1 + packages/ui/mockData/serviceArea.ts | 5 + packages/ui/modals/CoverageArea/hooks.ts | 7 +- .../ui/modals/CoverageArea/index.stories.tsx | 1 + packages/ui/modals/CoverageArea/index.tsx | 385 +++++++----------- packages/ui/modals/CoverageArea/schema.ts | 27 -- 9 files changed, 234 insertions(+), 277 deletions(-) create mode 100644 packages/api/router/serviceArea/mutation.addToArea.handler.ts create mode 100644 packages/api/router/serviceArea/mutation.addToArea.schema.ts delete mode 100644 packages/ui/modals/CoverageArea/schema.ts diff --git a/packages/api/router/serviceArea/index.ts b/packages/api/router/serviceArea/index.ts index eeb1829503..c792ebf279 100644 --- a/packages/api/router/serviceArea/index.ts +++ b/packages/api/router/serviceArea/index.ts @@ -19,4 +19,13 @@ export const serviceAreaRouter = defineRouter({ const handler = await importHandler(namespaced('update'), () => import('./mutation.update.handler')) return handler(opts) }), + addToArea: permissionedProcedure('updateOrgService') + .input(schema.ZAddToAreaSchema) + .mutation(async (opts) => { + const handler = await importHandler( + namespaced('addToArea'), + () => import('./mutation.addToArea.handler') + ) + return handler(opts) + }), }) diff --git a/packages/api/router/serviceArea/mutation.addToArea.handler.ts b/packages/api/router/serviceArea/mutation.addToArea.handler.ts new file mode 100644 index 0000000000..90fed8378e --- /dev/null +++ b/packages/api/router/serviceArea/mutation.addToArea.handler.ts @@ -0,0 +1,50 @@ +import { TRPCError } from '@trpc/server' + +import { generateId, getAuditedClient } from '@weareinreach/db' +import { handleError } from '~api/lib/errorHandler' +import { type TRPCHandlerParams } from '~api/types/handler' + +import { type TAddToAreaSchema } from './mutation.addToArea.schema' + +export const addToArea = async ({ ctx, input }: TRPCHandlerParams) => { + try { + const prisma = getAuditedClient(ctx.actorId) + + const { id: serviceAreaId } = + typeof input.serviceArea === 'string' + ? { id: input.serviceArea as string } + : await prisma.serviceArea.create({ + data: { + id: generateId('serviceArea'), + ...input.serviceArea, + }, + select: { id: true }, + }) + if (input.countryId) { + const result = await prisma.serviceAreaCountry.create({ + data: { + serviceAreaId, + countryId: input.countryId, + }, + }) + if (result) { + return { result: 'added' } + } + } else if (input.govDistId) { + const result = await prisma.serviceAreaDist.create({ + data: { + serviceAreaId, + govDistId: input.govDistId, + }, + }) + if (result) { + return { result: 'added' } + } + } else { + throw new TRPCError({ code: 'BAD_REQUEST' }) + } + } catch (error) { + handleError(error) + } +} +export default addToArea diff --git a/packages/api/router/serviceArea/mutation.addToArea.schema.ts b/packages/api/router/serviceArea/mutation.addToArea.schema.ts new file mode 100644 index 0000000000..b789dc412a --- /dev/null +++ b/packages/api/router/serviceArea/mutation.addToArea.schema.ts @@ -0,0 +1,26 @@ +import { type Simplify } from 'type-fest' +import { z } from 'zod' + +import { prefixedId } from '~api/schemas/idPrefix' + +const organization = z.object({ organizationId: prefixedId('organization') }) +const orgLocation = z.object({ orgLocationId: prefixedId('orgLocation') }) +const orgService = z.object({ orgServiceId: prefixedId('orgService') }) + +const serviceArea = z.union([prefixedId('serviceArea'), organization, orgLocation, orgService]) + +export const ZAddToAreaSchema = z + .object({ + serviceArea, + countryId: prefixedId('country').optional(), + govDistId: prefixedId('govDist').optional(), + }) + .refine( + (data) => + (typeof data.countryId === 'string' || typeof data.govDistId === 'string') && + !(typeof data.countryId === 'string' && typeof data.govDistId === 'string'), + { + message: 'Only one of countryId or govDistId must be provided', + } + ) +export type TAddToAreaSchema = Simplify> diff --git a/packages/api/router/serviceArea/schemas.ts b/packages/api/router/serviceArea/schemas.ts index 917e0a58a5..76e97c2b96 100644 --- a/packages/api/router/serviceArea/schemas.ts +++ b/packages/api/router/serviceArea/schemas.ts @@ -1,4 +1,5 @@ // codegen:start {preset: barrel, include: ./*.schema.ts} +export * from './mutation.addToArea.schema' export * from './mutation.update.schema' export * from './query.getServiceArea.schema' // codegen:end diff --git a/packages/ui/mockData/serviceArea.ts b/packages/ui/mockData/serviceArea.ts index 1503c2c6e4..8cb64f459d 100644 --- a/packages/ui/mockData/serviceArea.ts +++ b/packages/ui/mockData/serviceArea.ts @@ -22,4 +22,9 @@ export const serviceArea = { }, }), }), + addToArea: getTRPCMock({ + path: ['serviceArea', 'addToArea'], + type: 'mutation', + response: () => ({ result: 'added' }), + }), } satisfies MockHandlerObject<'serviceArea'> diff --git a/packages/ui/modals/CoverageArea/hooks.ts b/packages/ui/modals/CoverageArea/hooks.ts index 8280909c3f..83663eda9a 100644 --- a/packages/ui/modals/CoverageArea/hooks.ts +++ b/packages/ui/modals/CoverageArea/hooks.ts @@ -1,7 +1,12 @@ import { useState } from 'react' +import { type Simplify } from 'type-fest' export const useServiceAreaSelections = () => { - const [selected, setSelected] = useState({ country: null, govDist: null, subDist: null }) + const [selected, setSelected] = useState>({ + country: null, + govDist: null, + subDist: null, + }) const setVal = { country: (value: string) => setSelected({ country: value, govDist: null, subDist: null }), govDist: (value: string) => setSelected((prev) => ({ ...prev, govDist: value, subDist: null })), diff --git a/packages/ui/modals/CoverageArea/index.stories.tsx b/packages/ui/modals/CoverageArea/index.stories.tsx index baa3794681..8f24396705 100644 --- a/packages/ui/modals/CoverageArea/index.stories.tsx +++ b/packages/ui/modals/CoverageArea/index.stories.tsx @@ -23,6 +23,7 @@ export default { fieldOpt.govDists, serviceArea.getServiceArea, serviceArea.update, + serviceArea.addToArea, ], rqDevtools: true, whyDidYouRender: { collapseGroups: true }, diff --git a/packages/ui/modals/CoverageArea/index.tsx b/packages/ui/modals/CoverageArea/index.tsx index 6a0cacec76..94b95ebabb 100644 --- a/packages/ui/modals/CoverageArea/index.tsx +++ b/packages/ui/modals/CoverageArea/index.tsx @@ -1,30 +1,20 @@ -import { zodResolver } from '@hookform/resolvers/zod' import { - Badge, Box, Button, type ButtonProps, - CloseButton, createPolymorphicComponent, - Grid, - Group, Modal, Select, Stack, - Text, Title, } from '@mantine/core' import { useDisclosure } from '@mantine/hooks' -import { compareArrayVals } from 'crud-object-diff' -import compact from 'just-compact' import { type TFunction, useTranslation } from 'next-i18next' -import { forwardRef } from 'react' -import { useForm } from 'react-hook-form' +import { forwardRef, useEffect } from 'react' import { trpc as api } from '~ui/lib/trpcClient' import { useServiceAreaSelections } from './hooks' -import { ServiceAreaForm, type ZServiceAreaForm } from './schema' import { useStyles } from './styles' import { ModalTitle } from '../ModalTitle' @@ -38,267 +28,164 @@ const reduceDistType = (data: { tsNs: string; tsKey: string }[] | undefined, t: return [...valueSet].sort().join('/') } -const CoverageAreaModal = forwardRef(({ id, ...props }, ref) => { - const { classes } = useStyles() - const { t, i18n } = useTranslation(['common', 'gov-dist']) - const countryTranslation = new Intl.DisplayNames(i18n.language, { type: 'region' }) - const [opened, { open, close }] = useDisclosure(true) //TODO: remove `true` when done with dev +const CoverageAreaModal = forwardRef( + ({ serviceArea, onSuccessAction, ...props }, ref) => { + const { classes } = useStyles() + const { t, i18n } = useTranslation(['common', 'gov-dist']) + const countryTranslation = new Intl.DisplayNames(i18n.language, { type: 'region' }) + const [modalOpened, modalHandler] = useDisclosure(false) - const [selected, setVal] = useServiceAreaSelections() + const [selected, setVal] = useServiceAreaSelections() - const { data: dataCountry } = api.fieldOpt.countries.useQuery( - { activeForOrgs: true }, - { - select: (data) => - data.map(({ id, cca2 }) => ({ value: id, label: countryTranslation.of(cca2), cca2 })) ?? [], - placeholderData: [], - } - ) - const { data: dataDistrict } = api.fieldOpt.govDists.useQuery( - { countryId: selected.country ?? '', parentsOnly: true }, - { - enabled: selected.country !== null, + useEffect(() => { + if (modalOpened === true) { + setVal.blank() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [modalOpened]) + + const { data: dataCountry } = api.fieldOpt.countries.useQuery( + { activeForOrgs: true }, + { + select: (data) => + data.map(({ id, cca2 }) => ({ value: id, label: countryTranslation.of(cca2), cca2 })) ?? [], + placeholderData: [], + } + ) + const { data: dataDistrict } = api.fieldOpt.govDists.useQuery( + { countryId: selected.country ?? '', parentsOnly: true }, + { + enabled: selected.country !== null, + select: (data) => + data?.map(({ id, tsKey, tsNs, ...rest }) => ({ + value: id, + label: t(tsKey, { ns: tsNs }), + tsKey, + tsNs, + parent: null, + ...rest, + })) ?? [], + placeholderData: [], + } + ) + const { data: dataSubDist } = api.fieldOpt.getSubDistricts.useQuery(selected.govDist ?? '', { + enabled: selected.govDist !== null, select: (data) => data?.map(({ id, tsKey, tsNs, ...rest }) => ({ value: id, label: t(tsKey, { ns: tsNs }), tsKey, tsNs, - parent: null, ...rest, })) ?? [], placeholderData: [], + }) + + const placeHolders = { + first: t('select.base', { item: 'Country' }), + second: t('select.base', { + item: reduceDistType( + dataDistrict?.map(({ govDistType }) => govDistType), + t + ), + }), + third: t('select.base', { + item: reduceDistType( + dataSubDist?.map(({ govDistType }) => govDistType), + t + ), + }), } - ) - const { data: dataSubDist } = api.fieldOpt.getSubDistricts.useQuery(selected.govDist ?? '', { - enabled: selected.govDist !== null, - select: (data) => - data?.map(({ id, tsKey, tsNs, ...rest }) => ({ - value: id, - label: t(tsKey, { ns: tsNs }), - tsKey, - tsNs, - ...rest, - })) ?? [], - placeholderData: [], - }) - const apiUtils = api.useUtils() - const updateServiceArea = api.serviceArea.update.useMutation() - - const form = useForm({ - resolver: zodResolver(ServiceAreaForm), - defaultValues: async () => { - const data = await apiUtils.serviceArea.getServiceArea.fetch(id) - const formatted = { - id: data?.id ?? id, - countries: data?.countries ?? [], - districts: data?.districts ?? [], - } - return formatted - }, - }) - - const serviceAreaCountries = form.watch('countries') - const serviceAreaDistricts = form.watch('districts') - - const placeHolders = { - first: t('select.base', { item: 'Country' }), - second: t('select.base', { - item: reduceDistType( - dataDistrict?.map(({ govDistType }) => govDistType), - t - ), - }), - third: t('select.base', { - item: reduceDistType( - dataSubDist?.map(({ govDistType }) => govDistType), - t - ), - }), - } - - const handleAdd = () => { - switch (true) { - case !!selected.subDist: - case !!selected.govDist: { - const itemId = selected.subDist ?? selected.govDist - const valToAdd = selected.subDist - ? dataSubDist?.find(({ value }) => value === itemId) - : dataDistrict?.find(({ value }) => value === itemId) - if (!valToAdd) return - form.setValue( - 'districts', - [ - ...serviceAreaDistricts, - { - id: valToAdd.value, - tsKey: valToAdd.tsKey, - tsNs: valToAdd.tsNs, - parent: valToAdd.parent, - country: valToAdd.country, - }, - ], - { - shouldValidate: true, - } - ) - setVal.blank() - break - } - case !!selected.country: { - const valToAdd = dataCountry?.find(({ value }) => value === selected.country) - if (!valToAdd) return - form.setValue('countries', [...serviceAreaCountries, { id: valToAdd?.value, cca2: valToAdd?.cca2 }], { - shouldValidate: true, + const addServiceArea = api.serviceArea.addToArea.useMutation({ + onSuccess: (data) => { + if (onSuccessAction instanceof Function) { + onSuccessAction() + } + if (data?.result) { + modalHandler.close() + } + }, + }) + + const canAdd = !!selected.country + const handleAdd = () => { + if (selected.govDist || selected.subDist) { + const distToAdd = selected.subDist ?? selected.govDist + if (!distToAdd) { + throw new Error('Missing district') + } + addServiceArea.mutate({ + serviceArea, + govDistId: distToAdd, }) - setVal.blank() - break + } else if (selected.country) { + addServiceArea.mutate({ serviceArea, countryId: selected.country }) } } - } - - const activeAreas = compact( - [ - serviceAreaCountries?.map((country) => ( - - - {countryTranslation.of(country.cca2)} - - form.setValue( - 'countries', - serviceAreaCountries?.filter(({ id }) => id !== country.id) - ) - } - /> - - - )), - - // Display -> Country / District / Sub-District - serviceAreaDistricts?.map((govDist) => { - const { id, tsKey, tsNs, country, parent } = govDist - - const displayName = compact([ - country.cca2, - parent ? t(parent.tsKey, { ns: parent.tsNs }) : null, - t(tsKey, { ns: tsNs }), - ]).join(' → ') - - return ( - - - {displayName} - - form.setValue( - 'districts', - serviceAreaDistricts?.filter(({ id }) => id !== govDist.id) - ) - } - /> - - - ) - }), - ].flat() - ) - - const handleSave = () => { - const initialData = { - id: form.formState.defaultValues?.id, - countries: compact(form.formState.defaultValues?.countries?.map((country) => country?.id) ?? []), - districts: compact(form.formState.defaultValues?.districts?.map((district) => district?.id) ?? []), - } - const data = form.getValues() - const currentData = { - id: data.id, - countries: data.countries.map((country) => country.id), - districts: data.districts.map((district) => district.id), - } - const changes = { - id: data.id, - countries: compareArrayVals([initialData.countries, currentData.countries]), - districts: compareArrayVals([initialData.districts, currentData.districts]), - } - updateServiceArea.mutate(changes) - } - - return ( - <> - } - onClose={close} - opened={opened} - > - - - {t('portal-module.service-area')} - ({ ...theme.other.utilityFonts.utility4, color: 'black' })}> - {`${t('organization')}: `} - - - - {activeAreas} - - - theme.other.utilityFonts.utility1}> - {t('add', { - item: '$t(portal-module.service-area)', - })} - - - - + return ( + <> + } + onClose={modalHandler.close} + opened={modalOpened} + > + + + + {t('add', { + item: '$t(portal-module.service-area)', + })} + + + + + - {selected.country && !!dataDistrict?.length && ( - - )} - - - - - - + )} + {selected.govDist && !!dataSubDist?.length && ( +