Skip to content

Commit

Permalink
Feature/364 ssn input (#386)
Browse files Browse the repository at this point in the history
* feat: cretae ssn pattern tckt-364

* feat: create ssn pattern edit form tckt-364

* feat: create ssn icon and phone icons tckt-364

* feat: add ssn input and schema validations tckt-364

* test: add tests for ssn input and schema validations tckt-364

* feat: update ssn validation criteria  based on USWDS recommendations

* feat: address accessibility issues tckt-364

* feat: improve SSN validation error messages for clarity tckt-364

* feat: improve accessibility for ssn input tckt-364

* feat: add input masking to guide correct entry of the Social Security Number tckt-364

---------

Co-authored-by: kalasgarov <[email protected]>
  • Loading branch information
kalasgarov and kalasgarov authored Nov 25, 2024
1 parent c02d98d commit 47ec6d4
Show file tree
Hide file tree
Showing 18 changed files with 743 additions and 9 deletions.
7 changes: 7 additions & 0 deletions packages/common/src/locales/en/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,13 @@ export const en = {
fieldLabel: 'Phone number label',
hintLabel: 'Phone number hint label',
hint: '10-digit, U.S. only, for example 999-999-9999',
},
ssn: {
...defaults,
displayName: 'Social Security Number label',
fieldLabel: 'Social Security Number label',
hintLabel: 'Social Security Number hint label',
hint: 'For example, 555-11-0000',
errorTextMustContainChar: 'String must contain at least 1 character(s)',
},
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import React from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { type Meta, type StoryObj } from '@storybook/react';

import { SocialSecurityNumberPattern } from './SocialSecurityNumber.js';

const meta: Meta<typeof SocialSecurityNumberPattern> = {
title: 'patterns/SocialSecurityNumberPattern',
component: SocialSecurityNumberPattern,
decorators: [
(Story, args) => {
const FormDecorator = () => {
const formMethods = useForm();
return (
<FormProvider {...formMethods}>
<Story {...args} />
</FormProvider>
);
};
return <FormDecorator />;
},
],
tags: ['autodocs'],
};

export default meta;

export const Default: StoryObj<typeof SocialSecurityNumberPattern> = {
args: {
ssnId: 'ssn',
label: 'Social Security Number',
required: false,
},
};

export const WithRequired: StoryObj<typeof SocialSecurityNumberPattern> = {
args: {
ssnId: 'ssn',
label: 'Social Security Number',
required: true,
},
};

export const WithError: StoryObj<typeof SocialSecurityNumberPattern> = {
args: {
ssnId: 'ssn',
label: 'Social Security Number with error',
required: true,
error: {
type: 'custom',
message: 'This field has an error',
},
},
};

export const WithHint: StoryObj<typeof SocialSecurityNumberPattern> = {
args: {
ssnId: 'ssn',
label: 'Social Security Number',
hint: 'For example, 555-11-0000',
required: true,
},
};

export const WithHintAndError: StoryObj<typeof SocialSecurityNumberPattern> = {
args: {
ssnId: 'ssn',
label: 'Social Security Number',
hint: 'For example, 555-11-0000',
required: true,
error: {
type: 'custom',
message: 'This field has an error',
},
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* @vitest-environment jsdom
*/
import { describeStories } from '../../../test-helper.js';
import meta, * as stories from './SocialSecurityNumber.stories.js';

describeStories(meta, stories);
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import React from 'react';
import classNames from 'classnames';
import { useFormContext } from 'react-hook-form';
import { type SocialSecurityNumberProps } from '@atj/forms';

import { type PatternComponent } from '../../index.js';

const formatSSN = (value: string) => {
const rawValue = value.replace(/[^\d]/g, '');
if (rawValue.length <= 3) return rawValue;
if (rawValue.length <= 5)
return `${rawValue.slice(0, 3)}-${rawValue.slice(3)}`;
return `${rawValue.slice(0, 3)}-${rawValue.slice(3, 5)}-${rawValue.slice(5, 9)}`;
};

export const SocialSecurityNumberPattern: PatternComponent<
SocialSecurityNumberProps
> = ({ ssnId, hint, label, required, error, value }) => {
const { register, setValue } = useFormContext();
const errorId = `input-error-message-${ssnId}`;
const hintId = `hint-${ssnId}`;

const handleSSNChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const formattedSSN = formatSSN(e.target.value);
setValue(ssnId, formattedSSN, { shouldValidate: true });
};

return (
<fieldset className="usa-fieldset">
<div className={classNames('usa-form-group margin-top-2')}>
<label
className={classNames('usa-label', {
'usa-label--error': error,
})}
htmlFor={ssnId}
>
{label || 'Social Security Number'}
{required && <span className="required-indicator">*</span>}
</label>
{hint && (
<div className="usa-hint" id={hintId}>
{hint}
</div>
)}
{error && (
<div className="usa-error-message" id={errorId} role="alert">
{error.message}
</div>
)}
<input
className={classNames('usa-input usa-input--xl', {
'usa-input--error': error,
})}
id={ssnId}
type="text"
defaultValue={value}
{...register(ssnId, { required })}
onChange={handleSSNChange}
aria-describedby={
`${hint ? `${hintId}` : ''}${error ? ` ${errorId}` : ''}`.trim() ||
undefined
}
/>
</div>
</fieldset>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { SocialSecurityNumberPattern } from './SocialSecurityNumber.js';

export default SocialSecurityNumberPattern;
2 changes: 2 additions & 0 deletions packages/design/src/Form/components/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import RadioGroup from './RadioGroup/index.js';
import RichText from './RichText/index.js';
import Sequence from './Sequence/index.js';
import SelectDropdown from './SelectDropdown/index.js';
import SocialSecurityNumber from './SocialSecurityNumber/index.js';
import SubmissionConfirmation from './SubmissionConfirmation/index.js';
import TextInput from './TextInput/index.js';

Expand All @@ -37,5 +38,6 @@ export const defaultPatternComponents: ComponentForPattern = {
'rich-text': RichText as PatternComponent,
'select-dropdown': SelectDropdown as PatternComponent,
sequence: Sequence as PatternComponent,
'social-security-number': SocialSecurityNumber as PatternComponent,
'submission-confirmation': SubmissionConfirmation as PatternComponent,
};
10 changes: 10 additions & 0 deletions packages/design/src/FormManager/FormEdit/AddPatternDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import pageIcon from './images/page-icon.svg';
import phoneIcon from './images/phone-icon.svg';
import richTextIcon from './images/richtext-icon.svg';
import shortanswerIcon from './images/shortanswer-icon.svg';
import ssnIcon from './images/ssn-icon.svg';
import singleselectIcon from './images/singleselect-icon.svg';
import templateIcon from './images/template-icon.svg';

Expand All @@ -36,6 +37,7 @@ const icons: Record<string, string | any> = {
'phone-icon.svg': phoneIcon,
'richtext-icon.svg': richTextIcon,
'shortanswer-icon.svg': shortanswerIcon,
'ssn-icon.svg': ssnIcon,
'singleselect-icon.svg': singleselectIcon,
'template-icon.svg': templateIcon,
};
Expand Down Expand Up @@ -108,6 +110,10 @@ const sidebarPatterns: DropdownPattern[] = [
['select-dropdown', defaultFormConfig.patterns['select-dropdown']],
['date-of-birth', defaultFormConfig.patterns['date-of-birth']],
['attachment', defaultFormConfig.patterns['attachment']],
[
'social-security-number',
defaultFormConfig.patterns['social-security-number'],
],
] as const;
export const fieldsetPatterns: DropdownPattern[] = [
['checkbox', defaultFormConfig.patterns['checkbox']],
Expand All @@ -123,6 +129,10 @@ export const fieldsetPatterns: DropdownPattern[] = [
['select-dropdown', defaultFormConfig.patterns['select-dropdown']],
['date-of-birth', defaultFormConfig.patterns['date-of-birth']],
['attachment', defaultFormConfig.patterns['attachment']],
[
'social-security-number',
defaultFormConfig.patterns['social-security-number'],
],
] as const;

export const SidebarAddPatternMenuItem = ({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import type { Meta, StoryObj } from '@storybook/react';
import { expect, userEvent } from '@storybook/test';
import { within } from '@testing-library/react';

import { type SocialSecurityNumberPattern } from '@atj/forms';
import { createPatternEditStoryMeta } from './common/story-helper.js';
import FormEdit from '../index.js';
import { enLocale as message } from '@atj/common';

const pattern: SocialSecurityNumberPattern = {
id: 'social-security-number-1',
type: 'social-security-number',
data: {
label: message.patterns.ssn.displayName,
required: false,
hint: undefined,
},
};

const storyConfig: Meta = {
title: 'Edit components/SocialSecurityNumberPattern',
...createPatternEditStoryMeta({
pattern,
}),
} as Meta<typeof FormEdit>;

export default storyConfig;

export const Basic: StoryObj<typeof FormEdit> = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const updatedLabel = 'Social Security Number update';
const updatedHint = 'Updated hint for Social Security Number';

await userEvent.click(canvas.getByText(message.patterns.ssn.displayName));

const labelInput = canvas.getByLabelText(message.patterns.ssn.fieldLabel);
await userEvent.clear(labelInput);
await userEvent.type(labelInput, updatedLabel);

const hintInput = canvas.getByLabelText(message.patterns.ssn.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.findByText(updatedHint)).toBeInTheDocument();
},
};

export const WithoutHint: StoryObj<typeof FormEdit> = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const updatedLabel = 'Social Security Number update';

await userEvent.click(canvas.getByText(message.patterns.ssn.displayName));

const labelInput = canvas.getByLabelText(message.patterns.ssn.fieldLabel);
await userEvent.clear(labelInput);
await userEvent.type(labelInput, updatedLabel);

const form = labelInput?.closest('form');
form?.requestSubmit();

await expect(await canvas.findByText(updatedLabel)).toBeInTheDocument();
await expect(
await canvas.queryByLabelText(message.patterns.ssn.hintLabel)
).toBeNull();
},
};

export const Error: StoryObj<typeof FormEdit> = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);

await userEvent.click(canvas.getByText(message.patterns.ssn.displayName));

const labelInput = canvas.getByLabelText(message.patterns.ssn.fieldLabel);
await userEvent.clear(labelInput);
labelInput.blur();

await expect(
await canvas.findByText(
message.patterns.selectDropdown.errorTextMustContainChar
)
).toBeInTheDocument();
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* @vitest-environment jsdom
*/
import { describeStories } from '../../../test-helper.js';
import meta, * as stories from './SocialSecurityNumberPatternEdit.js';

describeStories(meta, stories);
Loading

0 comments on commit 47ec6d4

Please sign in to comment.