From ce75125a0546e630d540c81d1e18c54c5e4a83b4 Mon Sep 17 00:00:00 2001 From: Khayal Alasgarov Date: Wed, 5 Mar 2025 20:02:47 -0800 Subject: [PATCH] feat: create address pattern TCKT-277 (#476) * feat: create address pattern TCKT-277 * feat: create edit component for address pattern TCKT-277 * feat: add address pattern props types TCKT-277 * feat: create address pattern svg icon TCKT-277 * feat: implement config schema and user input validation logic TCKT-277 * chore: clean up unnecessary logs TCKT-277 * feat: update required sign to be black color TCKT-277 * feat: update mailing address not to have Google Plus Code input TCKT-277 * feat: add address pattern to dropdown menu TCKT-277 * test: add tests for address config schema and user input validation TCKT-277 * test(storybook): add story tests for Address component TCKT-277 * refactor: rebase and update inport path for address pattern to gsa-tts forms TCKT-277 * refactor: move stateTerritoryOrMilitaryPostList to @gsa-tts/forms-core for better modularity and reuse TCKT-277 * feat: update address component to hide mailing address based on checkbox status TCKT-277 * feat: add isMailingAddressSameAsPhysical flag to address component props TCKT-277 * feat: update address icon for add element dropdown menu TCKT-277 * refactor: update add element dropdown button styles TCKT-277 --------- Co-authored-by: Daniel Naab --- packages/common/src/locales/en/app.ts | 7 + .../components/Address/Address.stories.tsx | 204 +++++++++ .../src/Form/components/Address/index.tsx | 426 ++++++++++++------ .../FormEdit/AddPatternDropdown.tsx | 16 +- .../AddressPatternEdit.stories.tsx | 69 +++ .../components/AddressPatternEdit/index.tsx | 116 +++++ .../FormManager/FormEdit/components/index.ts | 2 + .../FormEdit/formEditStyles.module.css | 3 +- packages/forms/src/components.ts | 27 ++ packages/forms/src/index.ts | 1 + packages/forms/src/pattern.ts | 2 +- .../src/patterns/address/address.test.ts | 106 +++++ packages/forms/src/patterns/address/index.ts | 383 ++++++++++------ .../src/patterns/package-download/index.ts | 2 +- packages/forms/src/patterns/repeater/index.ts | 2 +- .../forms/src/patterns/repeater/submit.ts | 2 - 16 files changed, 1082 insertions(+), 286 deletions(-) create mode 100644 packages/design/src/Form/components/Address/Address.stories.tsx create mode 100644 packages/design/src/FormManager/FormEdit/components/AddressPatternEdit/AddressPatternEdit.stories.tsx create mode 100644 packages/design/src/FormManager/FormEdit/components/AddressPatternEdit/index.tsx create mode 100644 packages/forms/src/patterns/address/address.test.ts diff --git a/packages/common/src/locales/en/app.ts b/packages/common/src/locales/en/app.ts index 934782673..505be9cfe 100644 --- a/packages/common/src/locales/en/app.ts +++ b/packages/common/src/locales/en/app.ts @@ -7,6 +7,13 @@ const defaults = { export const en = { patterns: { + address: { + ...defaults, + displayName: 'Address', + fieldLabel: 'Address label', + legend: 'Physical address', + errorTextMustContainChar: 'String must contain at least 1 character(s)', + }, attachment: { ...defaults, displayName: 'Attachment', diff --git a/packages/design/src/Form/components/Address/Address.stories.tsx b/packages/design/src/Form/components/Address/Address.stories.tsx new file mode 100644 index 000000000..a7a9ce98c --- /dev/null +++ b/packages/design/src/Form/components/Address/Address.stories.tsx @@ -0,0 +1,204 @@ +import React from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; +import { type Meta, type StoryObj } from '@storybook/react'; + +import AddressPattern from './index.js'; +import { stateTerritoryOrMilitaryPostList } from '@gsa-tts/forms-core'; + +const meta: Meta = { + title: 'patterns/Address', + component: AddressPattern, + decorators: [ + Story => { + const formMethods = useForm({ + defaultValues: { + 'address.physicalStreetAddress': '', + 'address.physicalStreetAddress2': '', + 'address.physicalCity': '', + 'address.physicalStateTerritoryOrMilitaryPost': '', + 'address.physicalZipCode': '', + 'address.mailingStreetAddress': '', + 'address.mailingStreetAddress2': '', + 'address.mailingCity': '', + 'address.mailingStateTerritoryOrMilitaryPost': '', + 'address.mailingZipCode': '', + }, + }); + return ( +
+ + + +
+ ); + }, + ], + tags: ['autodocs'], +}; + +export default meta; + +const baseChildProps = { + physicalStreetAddress: { + type: 'input' as const, + inputId: 'address.physicalStreetAddress', + value: '', + label: 'Street Address', + required: false, + }, + physicalStreetAddress2: { + type: 'input' as const, + inputId: 'address.physicalStreetAddress2', + value: '', + label: 'Street Address 2', + required: false, + }, + physicalCity: { + type: 'input' as const, + inputId: 'address.physicalCity', + value: '', + label: 'City', + required: true, + }, + physicalStateTerritoryOrMilitaryPost: { + type: 'select' as const, + inputId: 'address.physicalStateTerritoryOrMilitaryPost', + value: '', + label: 'State', + required: true, + options: stateTerritoryOrMilitaryPostList, + }, + physicalZipCode: { + type: 'input' as const, + inputId: 'address.physicalZipCode', + value: '', + label: 'ZIP Code', + required: false, + pattern: '[\\d]{5}(-[\\d]{4})?', + }, + physicalUrbanizationCode: { + type: 'input' as const, + inputId: 'address.physicalUrbanizationCode', + value: '', + label: 'Urbanization Code', + required: false, + }, + physicalGooglePlusCode: { + type: 'input' as const, + inputId: 'address.physicalGooglePlusCode', + value: '', + label: 'Google Plus Code', + required: false, + }, + mailingStreetAddress: { + type: 'input' as const, + inputId: 'address.mailingStreetAddress', + value: '', + label: 'Mailing Street Address', + required: true, + }, + mailingStreetAddress2: { + type: 'input' as const, + inputId: 'address.mailingStreetAddress2', + value: '', + label: 'Mailing Street Address 2', + required: false, + }, + mailingCity: { + type: 'input' as const, + inputId: 'address.mailingCity', + value: '', + label: 'Mailing City', + required: true, + }, + mailingStateTerritoryOrMilitaryPost: { + type: 'select' as const, + inputId: 'address.mailingStateTerritoryOrMilitaryPost', + value: '', + label: 'Mailing State', + required: true, + options: stateTerritoryOrMilitaryPostList, + }, + mailingZipCode: { + type: 'input' as const, + inputId: 'address.mailingZipCode', + value: '', + label: 'Mailing ZIP Code', + required: false, + pattern: '[\\d]{5}(-[\\d]{4})?', + }, + mailingUrbanizationCode: { + type: 'input' as const, + inputId: 'address.mailingUrbanizationCode', + value: '', + label: 'Mailing Urbanization Code', + required: false, + }, +}; + +export const Default: StoryObj = { + args: { + _patternId: '', + type: 'address', + childProps: { + ...baseChildProps, + }, + }, +}; + +export const WithError: StoryObj = { + args: { + _patternId: '', + type: 'address', + childProps: { + ...baseChildProps, + physicalStreetAddress: { + ...baseChildProps.physicalStreetAddress, + error: { + type: 'custom', + message: 'Test - Wrong street address', + }, + }, + physicalZipCode: { + ...baseChildProps.physicalZipCode, + error: { + type: 'custom', + message: 'Test - Wrong zip code', + }, + }, + }, + error: { + physical: { + type: 'custom', + message: 'This field has an error', + }, + mailing: { + type: 'custom', + message: 'This field has an error', + }, + }, + }, +}; + +export const WithMailingAddress: StoryObj = { + args: { + _patternId: '', + type: 'address', + childProps: { + ...baseChildProps, + mailingStreetAddress: { + ...baseChildProps.mailingStreetAddress, + required: true, + }, + mailingCity: { + ...baseChildProps.mailingCity, + required: true, + }, + mailingStateTerritoryOrMilitaryPost: { + ...baseChildProps.mailingStateTerritoryOrMilitaryPost, + required: true, + }, + }, + addMailingAddress: true, + }, +}; diff --git a/packages/design/src/Form/components/Address/index.tsx b/packages/design/src/Form/components/Address/index.tsx index ab9b92c52..03524fc83 100644 --- a/packages/design/src/Form/components/Address/index.tsx +++ b/packages/design/src/Form/components/Address/index.tsx @@ -1,150 +1,312 @@ import classNames from 'classnames'; -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { useFormContext } from 'react-hook-form'; import { type AddressComponentProps } from '@gsa-tts/forms-core'; import { type PatternComponent } from '../../index.js'; -const Address: PatternComponent = props => { - const { register } = useFormContext(); +const AddressPattern: PatternComponent = ({ + childProps, // Child fields have their own errors + errors, // Top level pattern errors + legend, + required, + _patternId, + addMailingAddress, + isMailingAddressSameAsPhysical, +}) => { + const { register, setValue, getValues, watch } = useFormContext(); + const [sameAsPhysical, setSameAsPhysical] = useState( + isMailingAddressSameAsPhysical + ); + const [childPatternsProps, setChildPatternsProps] = useState(childProps); + + const physicalAddressError = errors?.physical; // Physical address section error + const [mailingAddressError, setMailingAddressError] = useState( + errors?.mailing + ); // Mailing address section error + + const physicalAddressKeys = Object.keys(childPatternsProps) + .filter(key => key.startsWith('physical')) + .map(key => `${_patternId}.${key}`); + const physicalAddressValues = watch(physicalAddressKeys); + + const getAriaDescribedBy = ( + errorId: string | null, + hintId: string | null + ): string | undefined => { + const ids = [errorId, hintId].filter(Boolean).join(' '); + return ids || undefined; + }; + + const handleSameAsPhysicalChange = ( + event: React.ChangeEvent + ) => { + setSameAsPhysical(event.target.checked); + if (event.target.checked) { + copyPhysicalToMailing(); + } else { + resetMailingAddress(); + } + }; + + const copyPhysicalToMailing = () => { + const formValues = getValues(); + let addressId; + let addressValues; + + for (const key of Object.keys(formValues)) { + const value = formValues[key]; + if ( + value && + typeof value === 'object' && + 'physicalStreetAddress' in value + ) { + addressValues = value; + addressId = key; + break; + } + } + + if (addressValues && addressId) { + Object.entries(addressValues).forEach(([key, value]) => { + if (key.startsWith('physical') && !key.includes('GooglePlusCode')) { + const mailingKey = key.replace('physical', 'mailing'); + setValue(`${addressId}.${mailingKey}`, value, { + shouldValidate: true, + shouldDirty: true, + }); + } + }); + } + }; + + const resetMailingAddress = () => { + const formValues = getValues(); + let addressId; + let addressValues; + + for (const key of Object.keys(formValues)) { + const value = formValues[key]; + if ( + value && + typeof value === 'object' && + 'physicalStreetAddress' in value + ) { + addressValues = value; + addressId = key; + break; + } + } + + if (addressValues && addressId) { + Object.entries(addressValues).forEach(([key]) => { + if (key.startsWith('mailing')) { + setValue(`${addressId}.${key}`, '', { + shouldValidate: true, + shouldDirty: true, + }); + } + }); + + // Reset the error for the mailing address + setMailingAddressError(undefined); + + // Reset the errors for the mailing address child patterns + const newChildPatternsProps = { ...childPatternsProps }; + Object.keys({ ...childPatternsProps }).forEach(key => { + if (key.startsWith('mailing')) { + newChildPatternsProps[key].error = undefined; + } + }); + + setChildPatternsProps(newChildPatternsProps); + } + }; + + useEffect(() => { + if (sameAsPhysical) { + copyPhysicalToMailing(); + } + }, [physicalAddressValues, sameAsPhysical]); + + const formatZipCode = (value: string) => { + // Remove all non-digit characters + const digits = value.replace(/\D/g, ''); + // Format as 99999-9999 + if (digits.length > 5) { + return `${digits.slice(0, 5)}-${digits.slice(5, 9)}`; + } + return digits; + }; + + const handleZipCodeChange = ( + event: React.ChangeEvent, + inputId: string + ) => { + const formattedValue = formatZipCode(event.target.value); + setValue(inputId, formattedValue); + }; + + const renderFields = (prefix: string) => { + return Object.entries(childPatternsProps).map(([key, props]) => { + if (!key.startsWith(prefix)) return null; + return ( + + + {props.error && ( + + )} + {props.type === 'input' ? ( + key.includes('ZipCode') ? ( + handleZipCodeChange(e, props.inputId)} + aria-describedby={getAriaDescribedBy( + props.error ? `error-${props.inputId}` : null, + props.hint ? `hint-${props.inputId}` : null + )} + /> + ) : ( + + ) + ) : ( + + )} + + ); + }); + }; + return (
- - Mailing address - - - - - - - - - -
); }; -export default Address; + +export default AddressPattern; diff --git a/packages/design/src/FormManager/FormEdit/AddPatternDropdown.tsx b/packages/design/src/FormManager/FormEdit/AddPatternDropdown.tsx index 1ef9bd79b..f38e00a86 100644 --- a/packages/design/src/FormManager/FormEdit/AddPatternDropdown.tsx +++ b/packages/design/src/FormManager/FormEdit/AddPatternDropdown.tsx @@ -15,11 +15,11 @@ import classNames from 'classnames'; // eslint-disable-next-line @typescript-eslint/no-explicit-any const icons: Record = { 'attachment-icon.svg': '#attach_file', + 'address-icon.svg': '#home', 'block-icon.svg': blockIcon, 'checkbox-icon.svg': checkboxIcon, 'date-icon.svg': '#calendar_today', 'dropdown-icon.svg': '#expand_more', - 'dropdownoption-icon.svg': '#expand_more', 'email-icon.svg': '#alternate_email', 'gender-id-icon.svg': '#person', 'long-answer-icon.svg': longAnswerIcon, @@ -33,6 +33,7 @@ const icons: Record = { 'template-icon.svg': templateIcon, 'add-element-icon.svg': '#add_circle', 'add-arrow-down-icon.svg': '#arrow_drop_down', + 'package-download-icon.svg': '#file_download', }; const getIconPath = (iconPath: string): string => { @@ -113,6 +114,7 @@ const sidebarPatterns: DropdownPattern[] = [ // defaultFormConfig.patterns['gender-id'], // 'Personal information', // ], + ['address', defaultFormConfig.patterns['address'], 'Personal information'], ['fieldset', defaultFormConfig.patterns['fieldset'], 'Form structure'], ['repeater', defaultFormConfig.patterns['repeater'], 'Form structure'], ['page', defaultFormConfig.patterns['page'], 'Form structure'], @@ -149,10 +151,10 @@ export const SidebarAddPatternMenuItem = ({ patternSelected={patternSelected} >