From 193f9cdc9b01574302b719b49481596e0db8e024 Mon Sep 17 00:00:00 2001 From: Khayal Alasgarov Date: Mon, 9 Dec 2024 13:48:56 -0800 Subject: [PATCH] feat: create gender identity input component tckt-365 (#395) * feat: create gender id pattern tckt-365 * feat: create gender id pattern edit form tckt-365 * feat: add input and schema validation tckt-365 * feat: update gender identity icon svg tckt-365 * refactor: update story and enhanch component file structure for gender id -tckt-365 * fix: resolve checkbox issue to persist the checked state tckt-365 --------- Co-authored-by: kalasgarov --- packages/common/src/locales/en/app.ts | 10 ++ .../components/GenderId/GenderId.stories.tsx | 64 +++++++ .../components/GenderId/GenderId.test.tsx | 7 + .../src/Form/components/GenderId/index.tsx | 98 +++++++++++ packages/design/src/Form/components/index.tsx | 2 + .../FormEdit/AddPatternDropdown.tsx | 4 + .../GenderIdPatternEdit.stories.tsx | 159 ++++++++++++++++++ .../GenderIdPatternEdit.test.tsx | 7 + .../components/GenderIdPatternEdit/index.tsx | 136 +++++++++++++++ .../FormManager/FormEdit/components/index.ts | 2 + .../FormEdit/formEditStyles.module.css | 1 + .../FormEdit/images/gender-id-icon.svg | 4 + packages/forms/src/components.ts | 12 ++ .../src/patterns/gender-id/gender-id.test.ts | 133 +++++++++++++++ .../forms/src/patterns/gender-id/gender-id.ts | 99 +++++++++++ packages/forms/src/patterns/index.ts | 3 + 16 files changed, 741 insertions(+) create mode 100644 packages/design/src/Form/components/GenderId/GenderId.stories.tsx create mode 100644 packages/design/src/Form/components/GenderId/GenderId.test.tsx create mode 100644 packages/design/src/Form/components/GenderId/index.tsx create mode 100644 packages/design/src/FormManager/FormEdit/components/GenderIdPatternEdit/GenderIdPatternEdit.stories.tsx create mode 100644 packages/design/src/FormManager/FormEdit/components/GenderIdPatternEdit/GenderIdPatternEdit.test.tsx create mode 100644 packages/design/src/FormManager/FormEdit/components/GenderIdPatternEdit/index.tsx create mode 100644 packages/design/src/FormManager/FormEdit/images/gender-id-icon.svg create mode 100644 packages/forms/src/patterns/gender-id/gender-id.test.ts create mode 100644 packages/forms/src/patterns/gender-id/gender-id.ts diff --git a/packages/common/src/locales/en/app.ts b/packages/common/src/locales/en/app.ts index fa715580..bb5f7c68 100644 --- a/packages/common/src/locales/en/app.ts +++ b/packages/common/src/locales/en/app.ts @@ -89,5 +89,15 @@ export const en = { hint: 'For example, 555-11-0000', errorTextMustContainChar: 'String must contain at least 1 character(s)', }, + genderId: { + ...defaults, + displayName: 'Gender Identity label', + fieldLabel: 'Gender Identity label', + hintLabel: 'Gender Identity hint label', + hint: 'For example, man, woman, non-binary', + errorTextMustContainChar: 'String must contain at least 1 character(s)', + preferNotToAnswerTextLabel: + 'Prefer not to share my gender identity checkbox label', + }, }, }; diff --git a/packages/design/src/Form/components/GenderId/GenderId.stories.tsx b/packages/design/src/Form/components/GenderId/GenderId.stories.tsx new file mode 100644 index 00000000..d9d863cb --- /dev/null +++ b/packages/design/src/Form/components/GenderId/GenderId.stories.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; +import { type Meta, type StoryObj } from '@storybook/react'; +import GenderIdPattern from './index.js'; + +const meta: Meta = { + title: 'patterns/GenderIdPattern', + component: GenderIdPattern, + decorators: [ + (Story, args) => { + const FormDecorator = () => { + const formMethods = useForm(); + return ( + + + + ); + }; + return ; + }, + ], + tags: ['autodocs'], +}; + +export default meta; + +const defaultArgs = { + genderId: 'gender-identity', + label: 'Gender identity', + hint: 'For example, man, woman, non-binary', + required: true, + preferNotToAnswerText: 'Prefer not to share my gender identity', +}; + +export const Default: StoryObj = { + args: { ...defaultArgs }, +}; + +export const Optional: StoryObj = { + args: { ...defaultArgs, required: false }, +}; + +export const WithError: StoryObj = { + args: { + ...defaultArgs, + label: 'Gender identity with error', + error: { + type: 'custom', + message: 'This field has an error', + }, + }, +}; + +export const WithHint: StoryObj = { + args: { ...defaultArgs }, +}; + +export const WithCheckboxChecked: StoryObj = { + args: { ...defaultArgs, preferNotToAnswerChecked: true }, +}; + +export const WithoutCheckbox: StoryObj = { + args: { ...defaultArgs, preferNotToAnswerText: undefined }, +}; diff --git a/packages/design/src/Form/components/GenderId/GenderId.test.tsx b/packages/design/src/Form/components/GenderId/GenderId.test.tsx new file mode 100644 index 00000000..7b92cfa2 --- /dev/null +++ b/packages/design/src/Form/components/GenderId/GenderId.test.tsx @@ -0,0 +1,7 @@ +/** + * @vitest-environment jsdom + */ +import { describeStories } from '../../../test-helper.js'; +import meta, * as stories from './GenderId.stories.js'; + +describeStories(meta, stories); diff --git a/packages/design/src/Form/components/GenderId/index.tsx b/packages/design/src/Form/components/GenderId/index.tsx new file mode 100644 index 00000000..565b4e35 --- /dev/null +++ b/packages/design/src/Form/components/GenderId/index.tsx @@ -0,0 +1,98 @@ +import React, { useState } from 'react'; +import classNames from 'classnames'; +import { useFormContext, useWatch } from 'react-hook-form'; +import { type GenderIdProps } from '@atj/forms'; +import { type PatternComponent } from '../../index.js'; + +const GenderIdPattern: PatternComponent = ({ + genderId, + hint, + label, + required, + error, + value = '', + preferNotToAnswerText, + preferNotToAnswerChecked: initialPreferNotToAnswerChecked = false, +}) => { + const { register, setValue } = useFormContext(); + const [preferNotToAnswerChecked, setPreferNotToAnswerChecked] = useState( + initialPreferNotToAnswerChecked + ); + + const errorId = `input-error-message-${genderId}`; + const hintId = `hint-${genderId}`; + const preferNotToAnswerId = `${genderId}.preferNotToAnswer`; + const inputId = `${genderId}.input`; + + const watchedValue = useWatch({ name: inputId, defaultValue: value }); + + const handleCheckboxChange = (event: React.ChangeEvent) => { + const isChecked = event.target.checked; + setPreferNotToAnswerChecked(isChecked); + setValue(genderId, isChecked ? preferNotToAnswerText : value, { + shouldValidate: true, + }); + }; + + return ( +
+
+ + {hint && ( +
+ {hint} +
+ )} + {error && ( + + )} + + {preferNotToAnswerText && ( +
+ + +
+ )} +
+
+ ); +}; + +export default GenderIdPattern; diff --git a/packages/design/src/Form/components/index.tsx b/packages/design/src/Form/components/index.tsx index 1dfb8adc..205bdf43 100644 --- a/packages/design/src/Form/components/index.tsx +++ b/packages/design/src/Form/components/index.tsx @@ -7,6 +7,7 @@ import DateOfBirth from './DateOfBirth/index.js'; import EmailInput from './EmailInput/index.js'; import Fieldset from './Fieldset/index.js'; import FormSummary from './FormSummary/index.js'; +import GenderId from './GenderId/index.js'; import PackageDownload from './PackageDownload/index.js'; import Page from './Page/index.js'; import PageSet from './PageSet/index.js'; @@ -28,6 +29,7 @@ export const defaultPatternComponents: ComponentForPattern = { 'email-input': EmailInput as PatternComponent, fieldset: Fieldset as PatternComponent, 'form-summary': FormSummary as PatternComponent, + 'gender-id': GenderId as PatternComponent, input: TextInput as PatternComponent, 'package-download': PackageDownload as PatternComponent, page: Page as PatternComponent, diff --git a/packages/design/src/FormManager/FormEdit/AddPatternDropdown.tsx b/packages/design/src/FormManager/FormEdit/AddPatternDropdown.tsx index d4104e29..a763df68 100644 --- a/packages/design/src/FormManager/FormEdit/AddPatternDropdown.tsx +++ b/packages/design/src/FormManager/FormEdit/AddPatternDropdown.tsx @@ -12,6 +12,7 @@ import dateIcon from './images/date-icon.svg'; import dropDownIcon from './images/dropdown-icon.svg'; import dropDownOptionIcon from './images/dropdownoption-icon.svg'; import emailInputIcon from './images/email-icon.svg'; +import genderId from './images/gender-id-icon.svg'; import longanswerIcon from './images/longanswer-icon.svg'; import pageIcon from './images/page-icon.svg'; import phoneIcon from './images/phone-icon.svg'; @@ -32,6 +33,7 @@ const icons: Record = { 'dropdown-icon.svg': dropDownIcon, 'dropdownoption-icon.svg': dropDownOptionIcon, 'email-icon.svg': emailInputIcon, + 'gender-id-icon.svg': genderId, 'longanswer-icon.svg': longanswerIcon, 'page-icon.svg': pageIcon, 'phone-icon.svg': phoneIcon, @@ -101,6 +103,7 @@ const sidebarPatterns: DropdownPattern[] = [ ['email-input', defaultFormConfig.patterns['email-input']], ['fieldset', defaultFormConfig.patterns['fieldset']], ['form-summary', defaultFormConfig.patterns['form-summary']], + ['gender-id', defaultFormConfig.patterns['gender-id']], ['input', defaultFormConfig.patterns['input']], ['package-download', defaultFormConfig.patterns['package-download']], ['paragraph', defaultFormConfig.patterns['paragraph']], @@ -120,6 +123,7 @@ export const fieldsetPatterns: DropdownPattern[] = [ ['date-of-birth', defaultFormConfig.patterns['date-of-birth']], ['email-input', defaultFormConfig.patterns['email-input']], ['form-summary', defaultFormConfig.patterns['form-summary']], + ['gender-id', defaultFormConfig.patterns['gender-id']], ['input', defaultFormConfig.patterns['input']], ['package-download', defaultFormConfig.patterns['package-download']], ['paragraph', defaultFormConfig.patterns['paragraph']], diff --git a/packages/design/src/FormManager/FormEdit/components/GenderIdPatternEdit/GenderIdPatternEdit.stories.tsx b/packages/design/src/FormManager/FormEdit/components/GenderIdPatternEdit/GenderIdPatternEdit.stories.tsx new file mode 100644 index 00000000..b3858166 --- /dev/null +++ b/packages/design/src/FormManager/FormEdit/components/GenderIdPatternEdit/GenderIdPatternEdit.stories.tsx @@ -0,0 +1,159 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { userEvent, expect } from '@storybook/test'; +import { within } from '@testing-library/react'; + +import { type GenderIdPattern } from '@atj/forms'; +import { createPatternEditStoryMeta } from '../common/story-helper.js'; +import FormEdit from '../../index.js'; +import { enLocale as message } from '@atj/common'; + +const pattern: GenderIdPattern = { + id: 'gender-identity-1', + type: 'gender-id', + data: { + label: message.patterns.genderId.displayName, + required: true, + hint: undefined, + preferNotToAnswerText: message.patterns.genderId.preferNotToAnswerTextLabel, + }, +}; + +const storyConfig: Meta = { + title: 'Edit components/GenderIdPattern', + ...createPatternEditStoryMeta({ + pattern, + }), +} as Meta; + +export default storyConfig; + +export const Basic: StoryObj = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const updatedLabel = 'Gender identity update'; + const updatedHint = 'Updated hint for Gender identity'; + const updatedPreferNotToAnswerText = + 'Updated prefer not to share my gender identity text'; + + await userEvent.click( + canvas.getByText(message.patterns.genderId.displayName) + ); + + const labelInput = canvas.getByLabelText( + message.patterns.genderId.fieldLabel + ); + await userEvent.clear(labelInput); + await userEvent.type(labelInput, updatedLabel); + + const hintInput = canvas.getByLabelText( + message.patterns.genderId.hintLabel + ); + await userEvent.clear(hintInput); + await userEvent.type(hintInput, updatedHint); + + const preferNotToAnswerInput = canvas.getByLabelText( + message.patterns.genderId.preferNotToAnswerTextLabel + ); + await userEvent.clear(preferNotToAnswerInput); + await userEvent.type(preferNotToAnswerInput, updatedPreferNotToAnswerText); + + const form = labelInput?.closest('form'); + form?.requestSubmit(); + + await expect(await canvas.findByText(updatedLabel)).toBeInTheDocument(); + await expect(await canvas.findByText(updatedHint)).toBeInTheDocument(); + await expect( + await canvas.findByText(updatedPreferNotToAnswerText) + ).toBeInTheDocument(); + }, +}; + +export const WithoutHint: StoryObj = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const updatedLabel = 'Gender identity update'; + const updatedPreferNotToAnswerText = + 'Prefer not to update my gender identity'; + + await userEvent.click( + canvas.getByText(message.patterns.genderId.displayName) + ); + + const labelInput = canvas.getByLabelText( + message.patterns.genderId.fieldLabel + ); + await userEvent.clear(labelInput); + await userEvent.type(labelInput, updatedLabel); + + const preferNotToAnswerInput = canvas.getByLabelText( + message.patterns.genderId.preferNotToAnswerTextLabel + ); + await userEvent.clear(preferNotToAnswerInput); + await userEvent.type(preferNotToAnswerInput, updatedPreferNotToAnswerText); + + const form = labelInput?.closest('form'); + form?.requestSubmit(); + + await expect(await canvas.findByText(updatedLabel)).toBeInTheDocument(); + await expect( + await canvas.findByText(updatedPreferNotToAnswerText) + ).toBeInTheDocument(); + await expect( + await canvas.queryByLabelText(message.patterns.genderId.hintLabel) + ).toBeNull(); + }, +}; + +export const WithoutCheckbox: StoryObj = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const updatedLabel = 'Gender identity update'; + const updatedHint = 'Updated hint for Gender identity'; + + await userEvent.click( + canvas.getByText(message.patterns.genderId.displayName) + ); + + const labelInput = canvas.getByLabelText( + message.patterns.genderId.fieldLabel + ); + await userEvent.clear(labelInput); + await userEvent.type(labelInput, updatedLabel); + + const hintInput = canvas.getByLabelText( + message.patterns.genderId.hintLabel + ); + await userEvent.clear(hintInput); + await userEvent.type(hintInput, updatedHint); + + const form = labelInput?.closest('form'); + form?.requestSubmit(); + + await expect(await canvas.findByText(updatedLabel)).toBeInTheDocument(); + await expect( + await canvas.queryByLabelText(message.patterns.genderId.hintLabel) + ).toBeNull(); + }, +}; + +export const Error: StoryObj = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await userEvent.click( + canvas.getByText(message.patterns.genderId.displayName) + ); + + const labelInput = canvas.getByLabelText( + message.patterns.genderId.fieldLabel + ); + await userEvent.clear(labelInput); + labelInput.blur(); + + await expect( + await canvas.findByText( + message.patterns.selectDropdown.errorTextMustContainChar + ) + ).toBeInTheDocument(); + }, +}; diff --git a/packages/design/src/FormManager/FormEdit/components/GenderIdPatternEdit/GenderIdPatternEdit.test.tsx b/packages/design/src/FormManager/FormEdit/components/GenderIdPatternEdit/GenderIdPatternEdit.test.tsx new file mode 100644 index 00000000..8b7113f2 --- /dev/null +++ b/packages/design/src/FormManager/FormEdit/components/GenderIdPatternEdit/GenderIdPatternEdit.test.tsx @@ -0,0 +1,7 @@ +/** + * @vitest-environment jsdom + */ +import { describeStories } from '../../../../test-helper.js'; +import meta, * as stories from './GenderIdPatternEdit.stories.js'; + +describeStories(meta, stories); diff --git a/packages/design/src/FormManager/FormEdit/components/GenderIdPatternEdit/index.tsx b/packages/design/src/FormManager/FormEdit/components/GenderIdPatternEdit/index.tsx new file mode 100644 index 00000000..b2c29400 --- /dev/null +++ b/packages/design/src/FormManager/FormEdit/components/GenderIdPatternEdit/index.tsx @@ -0,0 +1,136 @@ +import classnames from 'classnames'; +import React from 'react'; + +import { type GenderIdProps } from '@atj/forms'; +import { type GenderIdPattern } from '@atj/forms'; + +import GenderId from '../../../../Form/components/GenderId/index.js'; +import { PatternEditComponent } from '../../types.js'; + +import { PatternEditActions } from '../common/PatternEditActions.js'; +import { PatternEditForm } from '../common/PatternEditForm.js'; +import { usePatternEditFormContext } from '../common/hooks.js'; +import { enLocale as message } from '@atj/common'; +import styles from '../../formEditStyles.module.css'; + +const GenderIdPatternEdit: PatternEditComponent = ({ + focus, + previewProps, +}) => { + return ( + <> + {focus ? ( + } + > + ) : ( +
+ +
+ )} + + ); +}; + +const EditComponent = ({ pattern }: { pattern: GenderIdPattern }) => { + const { fieldId, getFieldState, register } = + usePatternEditFormContext(pattern.id); + const label = getFieldState('label'); + const hint = getFieldState('hint'); + const preferNotToAnswerText = getFieldState('preferNotToAnswerText'); + + return ( +
+
+ +
+
+ +
+
+ +
+
+ + + + + + +
+
+ ); +}; + +export default GenderIdPatternEdit; diff --git a/packages/design/src/FormManager/FormEdit/components/index.ts b/packages/design/src/FormManager/FormEdit/components/index.ts index a7e33aca..2dffbaf0 100644 --- a/packages/design/src/FormManager/FormEdit/components/index.ts +++ b/packages/design/src/FormManager/FormEdit/components/index.ts @@ -10,6 +10,7 @@ import EmailInputPatternEdit from './EmailInputPatternEdit/index.js'; import FieldsetEdit from './FieldsetEdit/index.js'; import FormSummaryEdit from './FormSummaryEdit.js'; import InputPatternEdit from './InputPatternEdit/index.js'; +import GenderIdPatternEdit from './GenderIdPatternEdit/index.js'; import PackageDownloadPatternEdit from './PackageDownloadPatternEdit.js'; import PageSetEdit from './PageSetEdit/index.js'; import { PageEdit } from './PageEdit.js'; @@ -29,6 +30,7 @@ export const defaultPatternEditComponents: EditComponentForPattern = { 'email-input': EmailInputPatternEdit as PatternEditComponent, fieldset: FieldsetEdit as PatternEditComponent, 'form-summary': FormSummaryEdit as PatternEditComponent, + 'gender-id': GenderIdPatternEdit as PatternEditComponent, input: InputPatternEdit as PatternEditComponent, 'package-download': PackageDownloadPatternEdit as PatternEditComponent, page: PageEdit as PatternEditComponent, diff --git a/packages/design/src/FormManager/FormEdit/formEditStyles.module.css b/packages/design/src/FormManager/FormEdit/formEditStyles.module.css index 9d7dbef5..fe72a96b 100644 --- a/packages/design/src/FormManager/FormEdit/formEditStyles.module.css +++ b/packages/design/src/FormManager/FormEdit/formEditStyles.module.css @@ -55,6 +55,7 @@ .draggableListItemWrapper .dateOfBirthPattern legend, .draggableListItemWrapper .phoneNumberPattern legend, .draggableListItemWrapper .ssnPattern legend, +.draggableListItemWrapper .genderIdPattern legend, .draggableListItemWrapper .emailInputPattern legend { padding-left: 0; } diff --git a/packages/design/src/FormManager/FormEdit/images/gender-id-icon.svg b/packages/design/src/FormManager/FormEdit/images/gender-id-icon.svg new file mode 100644 index 00000000..a3682540 --- /dev/null +++ b/packages/design/src/FormManager/FormEdit/images/gender-id-icon.svg @@ -0,0 +1,4 @@ + + + GI + \ No newline at end of file diff --git a/packages/forms/src/components.ts b/packages/forms/src/components.ts index c5364c04..a3da0480 100644 --- a/packages/forms/src/components.ts +++ b/packages/forms/src/components.ts @@ -152,6 +152,18 @@ export type SocialSecurityNumberProps = PatternProps<{ value: string; }>; +export type GenderIdProps = PatternProps<{ + type: 'gender-id'; + genderId: string; + hint?: string; + label: string; + required: boolean; + error?: FormError; + value: string; + preferNotToAnswerText?: string; + preferNotToAnswerChecked?: boolean; +}>; + export type SequenceProps = PatternProps<{ type: 'sequence'; }>; diff --git a/packages/forms/src/patterns/gender-id/gender-id.test.ts b/packages/forms/src/patterns/gender-id/gender-id.test.ts new file mode 100644 index 00000000..d6a5c574 --- /dev/null +++ b/packages/forms/src/patterns/gender-id/gender-id.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from 'vitest'; +import { + createGenderIdSchema, + genderIdConfig, + type GenderIdPattern, + type GenderIdPatternOutput, +} from './gender-id'; + +describe('GenderIdPattern tests', () => { + describe('createGenderIdSchema', () => { + it('should create schema for required gender identity input', () => { + const data: GenderIdPattern['data'] = { + label: 'Test Gender Identity Label', + required: true, + preferNotToAnswerText: 'Prefer not to share my gender identity', + }; + + const schema = createGenderIdSchema(data); + const validInput = { input: 'Test Gender' }; + const invalidInput = { input: '' }; + const preferNotToAnswerInput = { + preferNotToAnswer: 'Prefer not to share my gender identity', + }; + + expect(schema.safeParse(validInput).success).toBe(true); + expect(schema.safeParse(invalidInput).success).toBe(false); + expect(schema.safeParse(preferNotToAnswerInput).success).toBe(true); + }); + + it('should create schema for optional gender identity input', () => { + const data: GenderIdPattern['data'] = { + label: 'Test Gender Identity Label', + required: false, + }; + + const schema = createGenderIdSchema(data); + const validInput = { input: 'Test Gender' }; + const emptyInput = { input: '' }; + + expect(schema.safeParse(validInput).success).toBe(true); + expect(schema.safeParse(emptyInput).success).toBe(true); + }); + }); + + describe('genderIdConfig', () => { + it('should parse user input correctly', () => { + const pattern: GenderIdPattern = { + id: 'gender-identity-1', + type: 'gender-id', + data: { + label: 'Test Gender Identity Label', + required: true, + preferNotToAnswerText: 'Prefer not to share my gender identity', + }, + }; + + const inputValue = { input: 'Test Gender' }; + if (!genderIdConfig.parseUserInput) { + expect.fail('genderIdConfig.parseUserInput is undefined'); + } + const result = genderIdConfig.parseUserInput(pattern, inputValue); + if (result.success) { + expect(result.data).toEqual(inputValue); + } else { + expect.fail('Unexpected validation failure'); + } + }); + + it('should handle validation error for user input', () => { + const pattern: GenderIdPattern = { + id: 'gender-identity-1', + type: 'gender-id', + data: { + label: 'Test Gender Identity Label', + required: true, + preferNotToAnswerText: 'Prefer not to share my gender identity', + }, + }; + + const inputValue = { input: '' }; + if (!genderIdConfig.parseUserInput) { + expect.fail('genderIdConfig.parseUserInput is undefined'); + } + const result = genderIdConfig.parseUserInput(pattern, inputValue); + if (!result.success) { + expect(result.error).toBeDefined(); + } else { + expect.fail('Unexpected validation success'); + } + }); + + it('should parse config data correctly', () => { + const obj = { + label: 'Test Gender Identity Label', + required: true, + hint: 'For example, man, woman, non-binary', + preferNotToAnswerText: 'Prefer not to share my gender identity', + }; + + if (!genderIdConfig.parseConfigData) { + expect.fail('genderIdConfig.parseConfigData is undefined'); + } + const result = genderIdConfig.parseConfigData(obj); + if (result.success) { + expect(result.data.label).toBe('Test Gender Identity Label'); + expect(result.data.required).toBe(true); + expect(result.data.hint).toBe('For example, man, woman, non-binary'); + expect(result.data.preferNotToAnswerText).toBe( + 'Prefer not to share my gender identity' + ); + } else { + expect.fail('Unexpected validation failure'); + } + }); + + it('should handle invalid config data', () => { + const obj = { + label: '', + required: true, + }; + + if (!genderIdConfig.parseConfigData) { + expect.fail('genderIdConfig.parseConfigData is undefined'); + } + const result = genderIdConfig.parseConfigData(obj); + if (!result.success) { + expect(result.error).toBeDefined(); + } else { + expect.fail('Unexpected validation success'); + } + }); + }); +}); diff --git a/packages/forms/src/patterns/gender-id/gender-id.ts b/packages/forms/src/patterns/gender-id/gender-id.ts new file mode 100644 index 00000000..93bf7b68 --- /dev/null +++ b/packages/forms/src/patterns/gender-id/gender-id.ts @@ -0,0 +1,99 @@ +import * as z from 'zod'; +import { type GenderIdProps } from '../../components.js'; +import { type Pattern, type PatternConfig } from '../../pattern.js'; +import { getFormSessionValue } from '../../session.js'; +import { + safeZodParseFormErrors, + safeZodParseToFormError, +} from '../../util/zod.js'; + +const configSchema = z.object({ + label: z.string().min(1), + required: z.boolean(), + hint: z.string().optional(), + preferNotToAnswerText: z.string().optional(), +}); + +export type GenderIdPattern = Pattern>; + +export type GenderIdPatternOutput = z.infer< + ReturnType +>; + +export const createGenderIdSchema = (data: GenderIdPattern['data']) => { + return z + .object({ + input: z.string().optional(), + preferNotToAnswer: z.string().optional(), + }) + .superRefine((value, ctx) => { + const { input, preferNotToAnswer } = value; + + if ( + data.required && + !input?.trim() && + preferNotToAnswer !== data.preferNotToAnswerText + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'This field is required', + }); + return; + } + }); +}; + +export const genderIdConfig: PatternConfig< + GenderIdPattern, + GenderIdPatternOutput +> = { + displayName: 'Gender Identity', + iconPath: 'gender-id-icon.svg', + initial: { + label: 'Gender identity', + required: true, + hint: 'For example, man, woman, non-binary', + preferNotToAnswerText: 'Prefer not to share my gender identity', + }, + + parseUserInput: (pattern, inputValue) => { + const result = safeZodParseToFormError( + createGenderIdSchema(pattern.data), + inputValue + ); + return result; + }, + + parseConfigData: obj => { + return safeZodParseFormErrors(configSchema, obj); + }, + getChildren() { + return []; + }, + + createPrompt(_, session, pattern, options) { + const extraAttributes: Record = {}; + const sessionValue = getFormSessionValue(session, pattern.id); + const value = sessionValue?.input || ''; + const preferNotToAnswerChecked = + sessionValue?.preferNotToAnswer === pattern.data.preferNotToAnswerText; + const error = session.data.errors[pattern.id]; + + return { + props: { + _patternId: pattern.id, + type: 'gender-id', + label: pattern.data.label, + genderId: pattern.id, + required: pattern.data.required, + hint: pattern.data.hint, + preferNotToAnswerText: pattern.data.preferNotToAnswerText, + preferNotToAnswerChecked, + value, + error, + ...extraAttributes, + } as GenderIdProps, + children: [], + }; + }, +}; diff --git a/packages/forms/src/patterns/index.ts b/packages/forms/src/patterns/index.ts index bb58d988..dc5f5fbd 100644 --- a/packages/forms/src/patterns/index.ts +++ b/packages/forms/src/patterns/index.ts @@ -7,6 +7,7 @@ import { dateOfBirthConfig } from './date-of-birth/date-of-birth.js'; import { emailInputConfig } from './email-input/email-input.js'; import { fieldsetConfig } from './fieldset/index.js'; import { formSummaryConfig } from './form-summary.js'; +import { genderIdConfig } from './gender-id/gender-id.js'; import { inputConfig } from './input/index.js'; import { packageDownloadConfig } from './package-download/index.js'; import { pageConfig } from './page/index.js'; @@ -31,6 +32,7 @@ export const defaultFormConfig: FormConfig = { 'email-input': emailInputConfig, fieldset: fieldsetConfig, 'form-summary': formSummaryConfig, + 'gender-id': genderIdConfig, input: inputConfig, 'package-download': packageDownloadConfig, page: pageConfig, @@ -55,6 +57,7 @@ export * from './email-input/email-input.js'; export * from './fieldset/index.js'; export { type FieldsetPattern } from './fieldset/config.js'; export * from './form-summary.js'; +export * from './gender-id/gender-id.js'; export * from './input/index.js'; export { type InputPattern } from './input/config.js'; export * from './package-download/index.js';