Skip to content

Commit

Permalink
Merge remote-tracking branch 'origin/main' into logic-1
Browse files Browse the repository at this point in the history
  • Loading branch information
danielnaab committed Nov 25, 2024
2 parents 4a56d44 + 47ec6d4 commit aadf2db
Show file tree
Hide file tree
Showing 42 changed files with 5,363 additions and 6,116 deletions.
15 changes: 15 additions & 0 deletions packages/common/src/locales/en/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ const defaults = {

export const en = {
patterns: {
attachment: {
...defaults,
displayName: 'Attachment',
maxAttachmentsLabel: 'Max attachments',
allowedFileTypesLabel: 'Allowable file types',
errorTextMustContainChar: 'String must contain at least 1 character(s)',
errorUnsupportedFileType: 'Invalid file type found.',
},
checkbox: {
...defaults,
displayName: 'Checkbox',
Expand Down Expand Up @@ -72,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
173 changes: 173 additions & 0 deletions packages/design/src/Form/components/Attachment/Attachment.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import React from 'react';
import { within, userEvent } from '@storybook/test';
import { expect } from '@storybook/test';
import { attachmentFileTypeMimes } from '@atj/forms';
import { type AttachmentProps } from '@atj/forms';
import { FormProvider, useForm } from 'react-hook-form';
import type { Meta, StoryObj } from '@storybook/react';

import Attachment from './index.js';

const defaultArgs = {
_patternId: '',
type: 'attachment',
inputId: 'test-prompt',
value: '',
label: 'File upload',
allowedFileTypes: attachmentFileTypeMimes,
maxAttachments: 1,
maxFileSizeMB: 10,
required: true,
} satisfies AttachmentProps;

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

export const SingleEmpty = {
args: {
...defaultArgs,
},
} satisfies StoryObj<typeof Attachment>;

export const MultipleEmpty = {
args: {
...defaultArgs,
maxAttachments: 2,
},
} satisfies StoryObj<typeof Attachment>;

export const SingleWithValidFile = {
args: {
...defaultArgs,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const fileInput = canvas.getByLabelText(
'Attach a JPG, PDF, or PNG file'
) as HTMLInputElement;

// Create a file to upload
const file = new File(['sample content'], 'sample.png', {
type: 'image/png',
});

// Simulate attaching the file
await userEvent.upload(fileInput, file);

await expect(fileInput.files).not.toBeNull();

if (fileInput.files) {
await expect(fileInput.files[0]).toEqual(file);
await expect(fileInput.files).toHaveLength(1);
}
},
} satisfies StoryObj<typeof Attachment>;

export const MultipleWithValidFiles = {
args: {
...defaultArgs,
allowedFileTypes: ['application/pdf'],
maxAttachments: 3,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const fileInput = canvas.getByLabelText(
'Attach PDF files'
) as HTMLInputElement;

const files = [
new File(['content1'], 'file1.pdf', { type: 'application/pdf' }),
new File(['content2'], 'file2.pdf', { type: 'application/pdf' }),
new File(['content3'], 'file3.pdf', { type: 'application/pdf' }),
];

await userEvent.upload(fileInput, files);

await expect(fileInput.files).not.toBeNull();

if (fileInput.files) {
await expect(fileInput.files).toHaveLength(3);
await expect(Array.from(fileInput.files)).toEqual(files);
}
},
} satisfies StoryObj<typeof Attachment>;

export const ErrorTooManyFiles = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const fileInput = canvas.getByLabelText('Attach PDF files');

// Create multiple files to upload
const files = [
new File(['content1'], 'file1.pdf', { type: 'application/pdf' }),
new File(['content2'], 'file2.pdf', { type: 'application/pdf' }),
new File(['content3'], 'file3.pdf', { type: 'application/pdf' }),
];

await userEvent.upload(fileInput, files);
expect(
canvas.getByText(/There is a maximum of 2 files./i)
).toBeInTheDocument();
},
args: {
...defaultArgs,
allowedFileTypes: ['application/pdf'],
maxAttachments: 2,
},
} satisfies StoryObj<typeof Attachment>;

export const ErrorInvalidFileType = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const fileInput = canvas.getByLabelText('Attach a JPG, PDF, or PNG file');

const file = new File(['sample content'], 'sample.txt', {
type: 'text/plain',
});

await userEvent.upload(fileInput, file);
expect(
canvas.getByText(/Sorry. Only JPG, PDF, or PNG files are accepted./i)
).toBeInTheDocument();
},
args: {
...defaultArgs,
},
} satisfies StoryObj<typeof Attachment>;

export const ErrorTooBig = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const fileInput = canvas.getByLabelText('Attach a JPG, PDF, or PNG file');

const file = new File(['sample content'], 'sample.png', {
type: 'image/png',
});

await userEvent.upload(fileInput, file);
expect(
canvas.getByText(/The maximum allowable size per file is 0 MB./i)
).toBeInTheDocument();
},
args: {
...defaultArgs,
maxFileSizeMB: 0,
},
} satisfies StoryObj<typeof Attachment>;
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 './Attachment.stories.js';

describeStories(meta, stories);
167 changes: 167 additions & 0 deletions packages/design/src/Form/components/Attachment/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import classNames from 'classnames';
import React, { useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { type AttachmentProps } from '@atj/forms';
import { attachmentFileTypeOptions } from '@atj/forms';
import { type PatternComponent } from '../../../Form/index.js';

const Attachment: PatternComponent<AttachmentProps> = props => {
const { register } = useFormContext();
const { onChange, onBlur, name, ref } = register(
props.inputId || Math.random().toString()
);
const [attachments, setAttachments] = useState<File[]>([]);
const [error, setError] = useState<string | null>(null);

const validateFiles = (files: File[]) => {
if (files.length > props.maxAttachments) {
return `There is a maximum of ${props.maxAttachments} files.`;
}

const allowedFileTypes = Array.isArray(props.allowedFileTypes)
? props.allowedFileTypes
: [props.allowedFileTypes];
const invalidFile = files.find(
file => !allowedFileTypes.includes(file.type)
);
if (invalidFile) {
return `Sorry. Only ${new Intl.ListFormat('en', {
style: 'short',
type: 'disjunction',
}).format(
getFileTypeLabelFromMimes(props.allowedFileTypes)
)} files are accepted.`;
}

const maxFileSizeBytes = props.maxFileSizeMB * 1024 * 1024;
const oversizedFile = files.find(file => file.size > maxFileSizeBytes);
if (oversizedFile) {
return `The maximum allowable size per file is ${props.maxFileSizeMB} MB.`;
}

return null;
};

const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);

const errorMsg = validateFiles(files);
if (errorMsg) {
setError(errorMsg);
return;
}

setError(null);
setAttachments(files);
return onChange(event);
};

return (
<div className="usa-form-group-wrapper" key={props.inputId}>
<div
className={classNames('usa-form-group margin-top-2', {
'usa-form-group--error': props.error || error,
})}
>
<div className="usa-form-group">
<p className="text-bold" id={`label-${props.inputId}`}>
{props.label}
</p>
<label className="usa-label" htmlFor={`input-${props.inputId}`}>
{props.maxAttachments === 1
? `Attach a ${new Intl.ListFormat('en', {
style: 'short',
type: 'disjunction',
}).format(
getFileTypeLabelFromMimes(props.allowedFileTypes)
)} file`
: `Attach ${new Intl.ListFormat('en', {
style: 'short',
type: 'disjunction',
}).format(
getFileTypeLabelFromMimes(props.allowedFileTypes)
)} files`}
</label>
<span className="usa-hint" id={`input-hint-${props.inputId}`}>
{props.maxAttachments === 1
? `Select ${props.maxAttachments} file`
: `Select up to ${props.maxAttachments} files`}
</span>
{(props.error || error) && (
<span
className="usa-error-message"
id={`input-error-message-${props.inputId}`}
role="alert"
>
{props.error?.message || error}
</span>
)}
<div className="usa-file-input">
<div className="usa-file-input__target">
{attachments.length === 0 ? (
<div
className="usa-file-input__instructions"
aria-hidden="true"
>
Drag file here or{' '}
<span className="usa-file-input__choose">
choose from folder
</span>
</div>
) : (
<div className="usa-file-input__preview-heading">
{attachments.length === 1
? 'Selected file'
: `${attachments.length} files selected`}
<span className="usa-file-input__choose">
Change file{attachments.length > 1 ? 's' : ''}
</span>
</div>
)}
<div className="usa-file-input__box"></div>
{attachments.map((file, index) => (
<div
className="usa-file-input__preview"
aria-hidden="true"
key={index}
>
<img
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
alt=""
className="usa-file-input__preview-image usa-file-input__preview-image--generic"
/>
{file.name}
</div>
))}
<input
className={classNames('usa-file-input__input usa-file-input', {
'usa-input--error': props.error || error,
})}
id={`input-${props.inputId}`}
aria-describedby={`label-${props.inputId} input-hint-${props.inputId} ${props.error || error ? `input-error-message-${props.inputId}` : null}`}
onChange={handleChange}
onBlur={onBlur}
name={name}
ref={ref}
type="file"
{...(props.maxAttachments === 1 ? {} : { multiple: true })}
/>
</div>
</div>
</div>
</div>
</div>
);
};

export default Attachment;

const getFileTypeLabelFromMimes = (mimes: Array<string>) => {
return attachmentFileTypeOptions
.filter(option => {
return mimes.includes(option.value);
})
.map(item => {
return item.label;
});
};
Loading

0 comments on commit aadf2db

Please sign in to comment.