Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: create ssn input component tckt-364 #386

Merged
merged 10 commits into from
Nov 25, 2024
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>}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just opened a bug for this to address in a different sprint because it's a larger issue we have in various places in the application, but if we're doing the asterisk to indicate a required field, let's add title="required" to the abbr tag.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the bug I opened for a future sprint for reference: #388

</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
Loading