diff --git a/README.md b/README.md index 226a8c38..0c7a06a6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# 10x Access to Justice Tooling +# 10x Forms Platform -Test bed for ATJ platform tooling, completed as part of the [10x Digital Access to Justice Platform](https://trello.com/c/25Jl6NwJ/207-digital-access-to-justice-platform) project. +Test bed for 10x forms tooling, completed as part of the [10x Forms Platform](https://github.com/orgs/GSA-TTS/projects/38?pane=issue&itemId=58755590&issue=GSA-TTS%7C10x-projects%7C29) project. ## Overview @@ -17,13 +17,13 @@ The platform is made up of the following high-level terms. ### Key personas -- Content authors: legal experts who craft guided interview experiences via a "no code" interface -- Self-represented litigants (SREs): end-users who interact with the court via guided interviews created by content authors +- Form Builders: government program office staff or UX experts who create and publish "guided interview" web experiences for members of the public and fellow government staff via a friendly browser-based app, no coding necessary. For examples of "guided interview" style web experiences, check out [IRS Direct File](https://coforma.io/case-studies/irs-direct-file#results) (filing your taxes), [GetCalFresh](https://codeforamerica.org/news/overcoming-barriers-setting-expectations-for-calfresh-eligibility/) (Applying for food benefits) and [Court Forms Online](https://courtformsonline.org/) (filing court documents). +- Form Fillers: folks who provide info to the government via guided interviews created by Form Builders ### Things -- **Blueprint**: produced by a content author, the blueprint defines the structure of an interactive session between a court and an SRL -- **Conversation**: one instance of a blueprint; the interactive session between a court and an SRL. Other terms for this concept include dialogue or session. +- **Blueprint**: produced by a form builder, the blueprint defines the structure of an interactive session between a government office and a form filler. +- **Conversation**: one instance of a blueprint; the interactive session between a government office and a form filler. Other terms for this concept include dialogue or session. - **Pattern/template**: the building blocks of a blueprint, patterns implement UX best-practices, defining the content and behavior of the user interface. - **Prompt**: produced by a pattern, the prompt defines what is presented to the end user at single point in a conversation. - **Component**: user interface component that acts as the building block of prompts. @@ -61,6 +61,7 @@ To start developing with hot reloading, use: ```bash pnpm build ``` + then run: ```bash diff --git a/apps/spotlight/src/context.ts b/apps/spotlight/src/context.ts index 0059da9c..b55f264d 100644 --- a/apps/spotlight/src/context.ts +++ b/apps/spotlight/src/context.ts @@ -2,6 +2,7 @@ import { type FormConfig, type FormService, createFormService, + parsePdf, } from '@atj/forms'; import { defaultFormConfig } from '@atj/forms'; import { BrowserFormRepository } from '@atj/forms/context'; @@ -43,6 +44,7 @@ const createAppFormService = () => { repository, config: defaultFormConfig, isUserLoggedIn: () => true, + parsePdf, }); } else { return createTestBrowserFormService(); diff --git a/apps/spotlight/src/features/form-page/store/actions/get-form-session.ts b/apps/spotlight/src/features/form-page/store/actions/get-form-session.ts index be02ffbc..a3083cca 100644 --- a/apps/spotlight/src/features/form-page/store/actions/get-form-session.ts +++ b/apps/spotlight/src/features/form-page/store/actions/get-form-session.ts @@ -43,7 +43,6 @@ export const getFormSession: GetFormSession = async (ctx, opts) => { }, }); } else { - console.log('using session', result.data.data); ctx.setState({ formSessionResponse: { status: 'loaded', diff --git a/apps/spotlight/src/lib/github.ts b/apps/spotlight/src/lib/github.ts index b979f6d8..2d638567 100644 --- a/apps/spotlight/src/lib/github.ts +++ b/apps/spotlight/src/lib/github.ts @@ -7,7 +7,7 @@ export type GithubRepository = { export const DEFAULT_REPOSITORY: GithubRepository = { owner: 'gsa-tts', - repository: 'atj-platform', + repository: 'forms', branch: 'main', commit: 'main', }; @@ -28,7 +28,7 @@ export const getGithubRepository = async ( const { execSync } = await import('child_process'); return { owner: env.OWNER || 'gsa-tts', - repository: env.REPOSITORY || 'atj-platform', + repository: env.REPOSITORY || 'forms', branch: env.BRANCH || 'main', commit: execSync('git rev-parse HEAD').toString().trim(), }; diff --git a/documents/podman-integration.md b/documents/podman-integration.md index 14e9f3ef..034f9632 100644 --- a/documents/podman-integration.md +++ b/documents/podman-integration.md @@ -59,7 +59,6 @@ export TESTCONTAINERS_RYUK_DISABLED=true After adding the above lines to your shell configuration file, apply the changes by reloading your shell configuration: - For Zsh: ```bash source ~/.zshrc @@ -86,7 +85,6 @@ pnpm test If you're new to the team and need to start with Podman, follow these steps: - ### Step 1: Install Podman Install Podman using Homebrew: @@ -122,7 +120,6 @@ export TESTCONTAINERS_RYUK_DISABLED=true After adding the above lines to your shell configuration file, apply the changes by reloading your shell configuration: - For Zsh: ```bash source ~/.zshrc @@ -187,7 +184,6 @@ export PATH="$PNPM_HOME:$PATH" Save the file and close the editor. Apply the changes by reloading your shell configuration: - For Zsh: ```bash source ~/.zshrc diff --git a/infra/cdktf/__tests__/main-test.ts b/infra/cdktf/__tests__/main-test.ts index dc82a2af..22e4574e 100644 --- a/infra/cdktf/__tests__/main-test.ts +++ b/infra/cdktf/__tests__/main-test.ts @@ -1,5 +1,5 @@ import 'cdktf/lib/testing/adapters/jest'; -describe('atj-platform app stack', () => { +describe('Forms Platform app stack', () => { it.todo('should be tested'); }); diff --git a/infra/cdktf/src/lib/cloud.gov/node-astro.ts b/infra/cdktf/src/lib/cloud.gov/node-astro.ts index 3c3915d1..45e8be40 100644 --- a/infra/cdktf/src/lib/cloud.gov/node-astro.ts +++ b/infra/cdktf/src/lib/cloud.gov/node-astro.ts @@ -72,7 +72,7 @@ export class AstroService extends Construct { new cloudfoundry.app.App(this, `${id}-app`, { name: `${id}-app`, space: spaceId, - dockerImage: `ghcr.io/gsa-tts/atj-platform/${imageName}`, + dockerImage: `ghcr.io/gsa-tts/forms/${imageName}`, memory: 1024, diskQuota: 4096, healthCheckType: 'http', diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 5f4ab061..58865a89 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -7,6 +7,7 @@ export type Result = Success | Failure; export type VoidResult = VoidSuccess | Failure; export const success = (data: T): Success => ({ success: true, data }); +export const voidSuccess: VoidSuccess = { success: true }; export const failure = (error: E): Failure => ({ success: false, error }); export { en as enLocale } from './locales/en/app.js'; diff --git a/packages/common/src/locales/en/app.ts b/packages/common/src/locales/en/app.ts index aa7c383e..fa715580 100644 --- a/packages/common/src/locales/en/app.ts +++ b/packages/common/src/locales/en/app.ts @@ -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', @@ -31,7 +39,7 @@ export const en = { fieldLabel: 'Page title', }, paragraph: { - fieldLabel: 'Paragraph Text', + fieldLabel: 'Paragraph text', displayName: 'Paragraph', errorTextMustContainChar: 'String must contain at least 1 character(s)', }, @@ -48,17 +56,38 @@ export const en = { }, selectDropdown: { ...defaults, - displayName: 'Select Dropdown label', - fieldLabel: 'Select Dropdown label', + displayName: 'Select dropdown label', + fieldLabel: 'Select dropdown label', errorTextMustContainChar: 'String must contain at least 1 character(s)', }, dateOfBirth: { ...defaults, - displayName: 'Date of Birth label', - fieldLabel: 'Date Of Birth label', + displayName: 'Date of birth label', + fieldLabel: 'Date of birth label', hintLabel: 'Date of Birth Hint label', hint: 'For example: January 19 2000', errorTextMustContainChar: 'String must contain at least 1 character(s)', }, + emailInput: { + ...defaults, + displayName: 'Email Input label', + fieldLabel: 'Email Input label', + errorTextMustContainChar: 'String must contain at least 1 character(s)', + }, + phoneNumber: { + ...defaults, + displayName: 'Phone number label', + 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)', + }, }, }; diff --git a/packages/database/migrations/20241031103354_form_documents_table.mjs b/packages/database/migrations/20241031103354_form_documents_table.mjs new file mode 100644 index 00000000..85ddc3f2 --- /dev/null +++ b/packages/database/migrations/20241031103354_form_documents_table.mjs @@ -0,0 +1,21 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +export async function up(knex) { + await knex.schema.createTable('form_documents', table => { + table.uuid('id').primary(); + table.string('type').notNullable(); + table.string('file_name').notNullable(); + table.binary('data').notNullable(); + table.string('extract').notNullable(); + }); +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +export async function down(knex) { + await knex.schema.dropTableIfExists('form_documents'); +} diff --git a/packages/database/src/clients/kysely/types.ts b/packages/database/src/clients/kysely/types.ts index 80c04b27..0c54b490 100644 --- a/packages/database/src/clients/kysely/types.ts +++ b/packages/database/src/clients/kysely/types.ts @@ -1,4 +1,5 @@ import type { + ColumnType, Generated, Insertable, Kysely, @@ -14,7 +15,9 @@ export interface Database { sessions: SessionsTable; forms: FormsTable; form_sessions: FormSessionsTable; + form_documents: FormDocumentsTable; } +export type DatabaseClient = Kysely; interface UsersTable { id: string; @@ -48,8 +51,6 @@ export type FormsTableSelectable = Selectable; export type FormsTableInsertable = Insertable; export type FormsTableUpdateable = Updateable; -export type DatabaseClient = Kysely; - interface FormSessionsTable { id: string; form_id: string; @@ -60,3 +61,14 @@ interface FormSessionsTable { export type FormSessionsTableSelectable = Selectable; export type FormSessionsTableInsertable = Insertable; export type FormSessionsTableUpdateable = Updateable; + +interface FormDocumentsTable { + id: string; + type: string; + data: ColumnType; + file_name: string; + extract: string; +} +export type FormDocumentsTableSelectable = Selectable; +export type FormDocumentsTableInsertable = Insertable; +export type FormDocumentsTableUpdateable = Updateable; diff --git a/packages/design/src/Form/components/Attachment/Attachment.stories.tsx b/packages/design/src/Form/components/Attachment/Attachment.stories.tsx new file mode 100644 index 00000000..28ff08f2 --- /dev/null +++ b/packages/design/src/Form/components/Attachment/Attachment.stories.tsx @@ -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 = { + title: 'patterns/Attachment', + component: Attachment, + decorators: [ + (Story, args) => { + const FormDecorator = () => { + const formMethods = useForm(); + return ( + + + + ); + }; + return ; + }, + ], + tags: ['autodocs'], +}; +export default meta; + +export const SingleEmpty = { + args: { + ...defaultArgs, + }, +} satisfies StoryObj; + +export const MultipleEmpty = { + args: { + ...defaultArgs, + maxAttachments: 2, + }, +} satisfies StoryObj; + +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; + +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; + +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; + +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; + +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; diff --git a/packages/design/src/Form/components/Attachment/Attachment.test.ts b/packages/design/src/Form/components/Attachment/Attachment.test.ts new file mode 100644 index 00000000..48458281 --- /dev/null +++ b/packages/design/src/Form/components/Attachment/Attachment.test.ts @@ -0,0 +1,7 @@ +/** + * @vitest-environment jsdom + */ +import { describeStories } from '../../../test-helper.js'; +import meta, * as stories from './Attachment.stories.js'; + +describeStories(meta, stories); diff --git a/packages/design/src/Form/components/Attachment/index.tsx b/packages/design/src/Form/components/Attachment/index.tsx new file mode 100644 index 00000000..3ab38c5a --- /dev/null +++ b/packages/design/src/Form/components/Attachment/index.tsx @@ -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 = props => { + const { register } = useFormContext(); + const { onChange, onBlur, name, ref } = register( + props.inputId || Math.random().toString() + ); + const [attachments, setAttachments] = useState([]); + const [error, setError] = useState(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) => { + const files = Array.from(event.target.files || []); + + const errorMsg = validateFiles(files); + if (errorMsg) { + setError(errorMsg); + return; + } + + setError(null); + setAttachments(files); + return onChange(event); + }; + + return ( +
+
+
+

+ {props.label} +

+ + + {props.maxAttachments === 1 + ? `Select ${props.maxAttachments} file` + : `Select up to ${props.maxAttachments} files`} + + {(props.error || error) && ( + + {props.error?.message || error} + + )} +
+
+ {attachments.length === 0 ? ( + + ) : ( +
+ {attachments.length === 1 + ? 'Selected file' + : `${attachments.length} files selected`} + + Change file{attachments.length > 1 ? 's' : ''} + +
+ )} +
+ {attachments.map((file, index) => ( + + ))} + +
+
+
+
+
+ ); +}; + +export default Attachment; + +const getFileTypeLabelFromMimes = (mimes: Array) => { + return attachmentFileTypeOptions + .filter(option => { + return mimes.includes(option.value); + }) + .map(item => { + return item.label; + }); +}; diff --git a/packages/design/src/Form/components/DateOfBirth/DateOfBirth.tsx b/packages/design/src/Form/components/DateOfBirth/DateOfBirth.tsx index 79f4432b..ff14afad 100644 --- a/packages/design/src/Form/components/DateOfBirth/DateOfBirth.tsx +++ b/packages/design/src/Form/components/DateOfBirth/DateOfBirth.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import classNames from 'classnames'; import { useFormContext } from 'react-hook-form'; import { type DateOfBirthProps } from '@atj/forms'; import { type PatternComponent } from '../../index.js'; @@ -18,6 +19,14 @@ const months = [ { value: '12', label: 'December' }, ]; +const getAriaDescribedBy = ( + errorId: string | null, + hintId: string | null +): string | undefined => { + const ids = [errorId, hintId].filter(Boolean).join(' '); + return ids || undefined; +}; + export const DateOfBirthPattern: PatternComponent = ({ monthId, dayId, @@ -28,6 +37,8 @@ export const DateOfBirthPattern: PatternComponent = ({ error, }) => { const { register } = useFormContext(); + const errorId = `input-error-message-${monthId}`; + const hintId = `hint-${monthId}`; return (
@@ -36,20 +47,32 @@ export const DateOfBirthPattern: PatternComponent = ({ {required && *} {hint && ( - + {hint} )} + {error && ( + + )}
@@ -81,22 +109,22 @@ export const DateOfBirthPattern: PatternComponent = ({ Year
- {error && ( - - {error.message} - - )}
); }; diff --git a/packages/design/src/Form/components/EmailInput/EmailInput.stories.tsx b/packages/design/src/Form/components/EmailInput/EmailInput.stories.tsx new file mode 100644 index 00000000..281628e1 --- /dev/null +++ b/packages/design/src/Form/components/EmailInput/EmailInput.stories.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; +import { type Meta, type StoryObj } from '@storybook/react'; + +import { EmailInputPattern } from './EmailInput.js'; + +const meta: Meta = { + title: 'patterns/EmailInputPattern', + component: EmailInputPattern, + decorators: [ + (Story, args) => { + const FormDecorator = () => { + const formMethods = useForm({ + defaultValues: { + email: '', + }, + }); + return ( + + + + ); + }; + return ; + }, + ], + tags: ['autodocs'], +}; + +export default meta; + +export const Default: StoryObj = { + args: { + emailId: 'email', + label: 'Email address', + required: true, + }, +}; + +export const WithoutRequired: StoryObj = { + args: { + emailId: 'email', + label: 'Email address', + required: false, + }, +}; + +export const WithError: StoryObj = { + args: { + emailId: 'email', + label: 'Email address with error', + required: true, + error: { + type: 'custom', + message: 'This field has an error', + }, + }, +}; diff --git a/packages/design/src/Form/components/EmailInput/EmailInput.test.tsx b/packages/design/src/Form/components/EmailInput/EmailInput.test.tsx new file mode 100644 index 00000000..a70bb448 --- /dev/null +++ b/packages/design/src/Form/components/EmailInput/EmailInput.test.tsx @@ -0,0 +1,7 @@ +/** + * @vitest-environment jsdom + */ +import { describeStories } from '../../../test-helper.js'; +import meta, * as stories from './EmailInput.stories.js'; + +describeStories(meta, stories); diff --git a/packages/design/src/Form/components/EmailInput/EmailInput.tsx b/packages/design/src/Form/components/EmailInput/EmailInput.tsx new file mode 100644 index 00000000..2f2d9cc8 --- /dev/null +++ b/packages/design/src/Form/components/EmailInput/EmailInput.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import classNames from 'classnames'; +import { useFormContext } from 'react-hook-form'; +import { type EmailInputProps } from '@atj/forms'; +import { type PatternComponent } from '../../index.js'; + +export const EmailInputPattern: PatternComponent = ({ + emailId, + label, + required, + error, +}) => { + const { register } = useFormContext(); + const errorId = `input-error-message-${emailId}`; + + return ( +
+
+ + {error && ( + + )} + +
+
+ ); +}; diff --git a/packages/design/src/Form/components/EmailInput/index.tsx b/packages/design/src/Form/components/EmailInput/index.tsx new file mode 100644 index 00000000..bc02ffc7 --- /dev/null +++ b/packages/design/src/Form/components/EmailInput/index.tsx @@ -0,0 +1,3 @@ +import { EmailInputPattern } from './EmailInput.js'; + +export default EmailInputPattern; diff --git a/packages/design/src/Form/components/PhoneNumber/PhoneNumber.stories.tsx b/packages/design/src/Form/components/PhoneNumber/PhoneNumber.stories.tsx new file mode 100644 index 00000000..8418f038 --- /dev/null +++ b/packages/design/src/Form/components/PhoneNumber/PhoneNumber.stories.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; +import { type Meta, type StoryObj } from '@storybook/react'; + +import { PhoneNumberPattern } from './PhoneNumber.js'; + +const meta: Meta = { + title: 'patterns/PhoneNumberPattern', + component: PhoneNumberPattern, + decorators: [ + (Story, args) => { + const FormDecorator = () => { + const formMethods = useForm(); + return ( + + + + ); + }; + return ; + }, + ], + tags: ['autodocs'], +}; + +export default meta; + +export const Default: StoryObj = { + args: { + phoneId: 'phone', + label: 'Phone number', + required: false, + }, +}; + +export const WithRequired: StoryObj = { + args: { + phoneId: 'phone', + label: 'Phone number', + required: true, + }, +}; + +export const WithError: StoryObj = { + args: { + phoneId: 'phone', + label: 'Phone number with error', + required: true, + error: { + type: 'custom', + message: 'This field has an error', + }, + }, +}; + +export const WithHint: StoryObj = { + args: { + phoneId: 'phone', + label: 'Phone number', + hint: '10-digit, U.S. only, for example 999-999-9999', + required: true, + }, +}; + +export const WithHintAndError: StoryObj = { + args: { + phoneId: 'phone', + label: 'Phone number', + hint: '10-digit, U.S. only, for example 999-999-9999', + required: true, + error: { + type: 'custom', + message: 'This field has an error', + }, + }, +}; diff --git a/packages/design/src/Form/components/PhoneNumber/PhoneNumber.test.tsx b/packages/design/src/Form/components/PhoneNumber/PhoneNumber.test.tsx new file mode 100644 index 00000000..fb57ad66 --- /dev/null +++ b/packages/design/src/Form/components/PhoneNumber/PhoneNumber.test.tsx @@ -0,0 +1,7 @@ +/** + * @vitest-environment jsdom + */ +import { describeStories } from '../../../test-helper.js'; +import meta, * as stories from './PhoneNumber.stories.js'; + +describeStories(meta, stories); diff --git a/packages/design/src/Form/components/PhoneNumber/PhoneNumber.tsx b/packages/design/src/Form/components/PhoneNumber/PhoneNumber.tsx new file mode 100644 index 00000000..2661a3d2 --- /dev/null +++ b/packages/design/src/Form/components/PhoneNumber/PhoneNumber.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import classNames from 'classnames'; +import { useFormContext } from 'react-hook-form'; +import { type PhoneNumberProps } from '@atj/forms'; +import { type PatternComponent } from '../../index.js'; + +export const PhoneNumberPattern: PatternComponent = ({ + phoneId, + hint, + label, + required, + error, + value, +}) => { + const { register } = useFormContext(); + const errorId = `input-error-message-${phoneId}`; + const hintId = `hint-${phoneId}`; + + return ( +
+
+ + {hint && ( +
+ {hint} +
+ )} + {error && ( + + )} + +
+
+ ); +}; diff --git a/packages/design/src/Form/components/PhoneNumber/index.tsx b/packages/design/src/Form/components/PhoneNumber/index.tsx new file mode 100644 index 00000000..a9232364 --- /dev/null +++ b/packages/design/src/Form/components/PhoneNumber/index.tsx @@ -0,0 +1,3 @@ +import { PhoneNumberPattern } from './PhoneNumber.js'; + +export default PhoneNumberPattern; diff --git a/packages/design/src/Form/components/SelectDropdown/SelectDropdown.stories.tsx b/packages/design/src/Form/components/SelectDropdown/SelectDropdown.stories.tsx index 51fe214c..5d9eaa93 100644 --- a/packages/design/src/Form/components/SelectDropdown/SelectDropdown.stories.tsx +++ b/packages/design/src/Form/components/SelectDropdown/SelectDropdown.stories.tsx @@ -5,7 +5,7 @@ import { type Meta, type StoryObj } from '@storybook/react'; import { SelectDropdownPattern } from './SelectDropdown.js'; const meta: Meta = { - title: 'patterns/SelectPattern', + title: 'patterns/SelectDropdownPattern', component: SelectDropdownPattern, decorators: [ (Story, args) => { diff --git a/packages/design/src/Form/components/SelectDropdown/SelectDropdown.tsx b/packages/design/src/Form/components/SelectDropdown/SelectDropdown.tsx index ab9c3485..9ae3a59a 100644 --- a/packages/design/src/Form/components/SelectDropdown/SelectDropdown.tsx +++ b/packages/design/src/Form/components/SelectDropdown/SelectDropdown.tsx @@ -1,6 +1,6 @@ import React from 'react'; +import classNames from 'classnames'; import { useFormContext } from 'react-hook-form'; - import { type SelectDropdownProps } from '@atj/forms'; import { type PatternComponent } from '../../index.js'; @@ -12,13 +12,27 @@ export const SelectDropdownPattern: PatternComponent = ({ error, }) => { const { register } = useFormContext(); + const errorId = `input-error-message-${selectId}`; + return (
- @@ -28,11 +42,6 @@ export const SelectDropdownPattern: PatternComponent = ({ ))} - {error && ( - - {error.message} - - )}
); }; diff --git a/packages/design/src/Form/components/SocialSecurityNumber/SocialSecurityNumber.stories.tsx b/packages/design/src/Form/components/SocialSecurityNumber/SocialSecurityNumber.stories.tsx new file mode 100644 index 00000000..8979759c --- /dev/null +++ b/packages/design/src/Form/components/SocialSecurityNumber/SocialSecurityNumber.stories.tsx @@ -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 = { + title: 'patterns/SocialSecurityNumberPattern', + component: SocialSecurityNumberPattern, + decorators: [ + (Story, args) => { + const FormDecorator = () => { + const formMethods = useForm(); + return ( + + + + ); + }; + return ; + }, + ], + tags: ['autodocs'], +}; + +export default meta; + +export const Default: StoryObj = { + args: { + ssnId: 'ssn', + label: 'Social Security Number', + required: false, + }, +}; + +export const WithRequired: StoryObj = { + args: { + ssnId: 'ssn', + label: 'Social Security Number', + required: true, + }, +}; + +export const WithError: StoryObj = { + args: { + ssnId: 'ssn', + label: 'Social Security Number with error', + required: true, + error: { + type: 'custom', + message: 'This field has an error', + }, + }, +}; + +export const WithHint: StoryObj = { + args: { + ssnId: 'ssn', + label: 'Social Security Number', + hint: 'For example, 555-11-0000', + required: true, + }, +}; + +export const WithHintAndError: StoryObj = { + args: { + ssnId: 'ssn', + label: 'Social Security Number', + hint: 'For example, 555-11-0000', + required: true, + error: { + type: 'custom', + message: 'This field has an error', + }, + }, +}; diff --git a/packages/design/src/Form/components/SocialSecurityNumber/SocialSecurityNumber.test.tsx b/packages/design/src/Form/components/SocialSecurityNumber/SocialSecurityNumber.test.tsx new file mode 100644 index 00000000..c7522acb --- /dev/null +++ b/packages/design/src/Form/components/SocialSecurityNumber/SocialSecurityNumber.test.tsx @@ -0,0 +1,7 @@ +/** + * @vitest-environment jsdom + */ +import { describeStories } from '../../../test-helper.js'; +import meta, * as stories from './SocialSecurityNumber.stories.js'; + +describeStories(meta, stories); diff --git a/packages/design/src/Form/components/SocialSecurityNumber/SocialSecurityNumber.tsx b/packages/design/src/Form/components/SocialSecurityNumber/SocialSecurityNumber.tsx new file mode 100644 index 00000000..7074998e --- /dev/null +++ b/packages/design/src/Form/components/SocialSecurityNumber/SocialSecurityNumber.tsx @@ -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) => { + const formattedSSN = formatSSN(e.target.value); + setValue(ssnId, formattedSSN, { shouldValidate: true }); + }; + + return ( +
+
+ + {hint && ( +
+ {hint} +
+ )} + {error && ( + + )} + +
+
+ ); +}; diff --git a/packages/design/src/Form/components/SocialSecurityNumber/index.tsx b/packages/design/src/Form/components/SocialSecurityNumber/index.tsx new file mode 100644 index 00000000..241c5d49 --- /dev/null +++ b/packages/design/src/Form/components/SocialSecurityNumber/index.tsx @@ -0,0 +1,3 @@ +import { SocialSecurityNumberPattern } from './SocialSecurityNumber.js'; + +export default SocialSecurityNumberPattern; diff --git a/packages/design/src/Form/components/SubmissionConfirmation/index.tsx b/packages/design/src/Form/components/SubmissionConfirmation/index.tsx index ed863d74..7e422663 100644 --- a/packages/design/src/Form/components/SubmissionConfirmation/index.tsx +++ b/packages/design/src/Form/components/SubmissionConfirmation/index.tsx @@ -5,6 +5,7 @@ import { type PatternComponent } from '../../../Form/index.js'; const SubmissionConfirmation: PatternComponent< SubmissionConfirmationProps + // eslint-disable-next-line > = props => { return ( <> @@ -44,24 +45,24 @@ const SubmissionConfirmation: PatternComponent< className="usa-accordion__content usa-prose" hidden={true} > - - - - - - - - - {props.table.map((row, index) => { - return ( - - - - - ); - })} - -
Form fieldProvided value
{row.label}{row.value}
+ {/**/} + {/* */} + {/* */} + {/* */} + {/* */} + {/* */} + {/* */} + {/* */} + {/* {props.table.map((row, index) => {*/} + {/* return (*/} + {/* */} + {/* */} + {/* */} + {/* */} + {/* );*/} + {/* })}*/} + {/* */} + {/*
Form fieldProvided value
{row.label}{row.value}
*/} diff --git a/packages/design/src/Form/components/index.tsx b/packages/design/src/Form/components/index.tsx index 87d376fa..1dfb8adc 100644 --- a/packages/design/src/Form/components/index.tsx +++ b/packages/design/src/Form/components/index.tsx @@ -1,24 +1,31 @@ import { PatternComponent, type ComponentForPattern } from '../index.js'; +import Attachment from './Attachment/index.js'; import Address from './Address/index.js'; import Checkbox from './Checkbox/index.js'; +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 PackageDownload from './PackageDownload/index.js'; import Page from './Page/index.js'; import PageSet from './PageSet/index.js'; import Paragraph from './Paragraph/index.js'; +import PhoneNumber from './PhoneNumber/index.js'; 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 DateOfBirth from './DateOfBirth/index.js'; +import SocialSecurityNumber from './SocialSecurityNumber/index.js'; import SubmissionConfirmation from './SubmissionConfirmation/index.js'; import TextInput from './TextInput/index.js'; export const defaultPatternComponents: ComponentForPattern = { + attachment: Attachment as PatternComponent, address: Address as PatternComponent, checkbox: Checkbox as PatternComponent, + 'date-of-birth': DateOfBirth as PatternComponent, + 'email-input': EmailInput as PatternComponent, fieldset: Fieldset as PatternComponent, 'form-summary': FormSummary as PatternComponent, input: TextInput as PatternComponent, @@ -26,10 +33,11 @@ export const defaultPatternComponents: ComponentForPattern = { page: Page as PatternComponent, 'page-set': PageSet as PatternComponent, paragraph: Paragraph as PatternComponent, + 'phone-number': PhoneNumber as PatternComponent, 'radio-group': RadioGroup as PatternComponent, 'rich-text': RichText as PatternComponent, 'select-dropdown': SelectDropdown as PatternComponent, - 'date-of-birth': DateOfBirth as PatternComponent, sequence: Sequence as PatternComponent, + 'social-security-number': SocialSecurityNumber as PatternComponent, 'submission-confirmation': SubmissionConfirmation as PatternComponent, }; diff --git a/packages/design/src/Form/index.tsx b/packages/design/src/Form/index.tsx index 683bb700..c979af6e 100644 --- a/packages/design/src/Form/index.tsx +++ b/packages/design/src/Form/index.tsx @@ -104,6 +104,7 @@ export default function Form({ {!isPreview ? (
{ diff --git a/packages/design/src/FormManager/FormEdit/AddPatternDropdown.tsx b/packages/design/src/FormManager/FormEdit/AddPatternDropdown.tsx index f542356d..d4104e29 100644 --- a/packages/design/src/FormManager/FormEdit/AddPatternDropdown.tsx +++ b/packages/design/src/FormManager/FormEdit/AddPatternDropdown.tsx @@ -5,30 +5,39 @@ import { defaultFormConfig, type PatternConfig } from '@atj/forms'; import { useFormManagerStore } from '../store.js'; import styles from './formEditStyles.module.css'; +import attachmentIcon from './images/page-icon.svg'; import blockIcon from './images/block-icon.svg'; import checkboxIcon from './images/checkbox-icon.svg'; import dateIcon from './images/date-icon.svg'; import dropDownIcon from './images/dropdown-icon.svg'; import dropDownOptionIcon from './images/dropdownoption-icon.svg'; -import richTextIcon from './images/richtext-icon.svg'; +import emailInputIcon from './images/email-icon.svg'; import longanswerIcon from './images/longanswer-icon.svg'; 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'; + import classNames from 'classnames'; // eslint-disable-next-line @typescript-eslint/no-explicit-any const icons: Record = { + 'attachment-icon.svg': attachmentIcon, 'block-icon.svg': blockIcon, 'checkbox-icon.svg': checkboxIcon, 'date-icon.svg': dateIcon, 'dropdown-icon.svg': dropDownIcon, 'dropdownoption-icon.svg': dropDownOptionIcon, - 'richtext-icon.svg': richTextIcon, + 'email-icon.svg': emailInputIcon, 'longanswer-icon.svg': longanswerIcon, 'page-icon.svg': pageIcon, + 'phone-icon.svg': phoneIcon, + 'richtext-icon.svg': richTextIcon, 'shortanswer-icon.svg': shortanswerIcon, + 'ssn-icon.svg': ssnIcon, 'singleselect-icon.svg': singleselectIcon, 'template-icon.svg': templateIcon, }; @@ -87,27 +96,43 @@ export const AddPatternMenu = () => { type DropdownPattern = [string, PatternConfig]; const sidebarPatterns: DropdownPattern[] = [ - ['form-summary', defaultFormConfig.patterns['form-summary']], + ['checkbox', defaultFormConfig.patterns['checkbox']], + ['date-of-birth', defaultFormConfig.patterns['date-of-birth']], + ['email-input', defaultFormConfig.patterns['email-input']], ['fieldset', defaultFormConfig.patterns['fieldset']], + ['form-summary', defaultFormConfig.patterns['form-summary']], ['input', defaultFormConfig.patterns['input']], - ['checkbox', defaultFormConfig.patterns['checkbox']], + ['package-download', defaultFormConfig.patterns['package-download']], ['paragraph', defaultFormConfig.patterns['paragraph']], - ['rich-text', defaultFormConfig.patterns['rich-text']], + ['phone-number', defaultFormConfig.patterns['phone-number']], ['radio-group', defaultFormConfig.patterns['radio-group']], - ['package-download', defaultFormConfig.patterns['package-download']], + ['rich-text', defaultFormConfig.patterns['rich-text']], ['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']], + ['date-of-birth', defaultFormConfig.patterns['date-of-birth']], + ['email-input', defaultFormConfig.patterns['email-input']], ['form-summary', defaultFormConfig.patterns['form-summary']], ['input', defaultFormConfig.patterns['input']], - ['checkbox', defaultFormConfig.patterns['checkbox']], + ['package-download', defaultFormConfig.patterns['package-download']], ['paragraph', defaultFormConfig.patterns['paragraph']], - ['rich-text', defaultFormConfig.patterns['rich-text']], + ['phone-number', defaultFormConfig.patterns['phone-number']], ['radio-group', defaultFormConfig.patterns['radio-group']], - ['package-download', defaultFormConfig.patterns['package-download']], + ['rich-text', defaultFormConfig.patterns['rich-text']], ['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 = ({ @@ -303,7 +328,9 @@ const AddPatternDropdownContent = ({ > { + return getIconPath(pattern.iconPath || 'block-icon.svg'); + })()} alt="" width="24" height="24" diff --git a/packages/design/src/FormManager/FormEdit/components/AttachmentPatternEdit/AttachmentPatternEdit.stories.tsx b/packages/design/src/FormManager/FormEdit/components/AttachmentPatternEdit/AttachmentPatternEdit.stories.tsx new file mode 100644 index 00000000..44e9dad0 --- /dev/null +++ b/packages/design/src/FormManager/FormEdit/components/AttachmentPatternEdit/AttachmentPatternEdit.stories.tsx @@ -0,0 +1,70 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { enLocale as message } from '@atj/common'; +import { type AttachmentPattern } from '@atj/forms'; + +import { + createPatternEditStoryMeta, + testEmptyFormLabelError, + testUpdateFormFieldOnSubmit, +} from '../common/story-helper.js'; +import FormEdit from '../../index.js'; +import { userEvent, expect } from '@storybook/test'; +import { within } from '@testing-library/react'; + +const label = 'Attach a PDF file'; + +const pattern: AttachmentPattern = { + id: '1', + type: 'attachment', + data: { + label: 'File upload', + required: true, + maxAttachments: 1, + maxFileSizeMB: 10, + allowedFileTypes: ['application/pdf'], + }, +}; + +const storyConfig: Meta = { + title: 'Edit components/AttachmentPattern', + ...createPatternEditStoryMeta({ + pattern, + }), +} as Meta; +export default storyConfig; + +export const Basic: StoryObj = { + play: async ({ canvasElement }) => { + await testUpdateFormFieldOnSubmit( + canvasElement, + label, + message.patterns.attachment.fieldLabel, + 'Updated attachment pattern' + ); + }, +}; + +export const Error: StoryObj = { + play: async ({ canvasElement }) => { + await testEmptyFormLabelError( + canvasElement, + label, + message.patterns.attachment.fieldLabel, + message.patterns.attachment.fieldLabelRequired + ); + + const canvas = within(canvasElement); + const fileTypes = await canvas.findByDisplayValue('application/pdf'); + await userEvent.click(fileTypes); + fileTypes.blur(); + + const maxAttachments = await canvas.findByLabelText('Max attachments'); + await userEvent.clear(maxAttachments); + maxAttachments.blur(); + + const invalidError = canvas.getByText('Invalid file type found.', { + selector: '.usa-error-message', + }); + expect(invalidError).toBeInTheDocument(); + }, +}; diff --git a/packages/design/src/FormManager/FormEdit/components/AttachmentPatternEdit/AttachmentPatternEdit.test.tsx b/packages/design/src/FormManager/FormEdit/components/AttachmentPatternEdit/AttachmentPatternEdit.test.tsx new file mode 100644 index 00000000..69b98cc2 --- /dev/null +++ b/packages/design/src/FormManager/FormEdit/components/AttachmentPatternEdit/AttachmentPatternEdit.test.tsx @@ -0,0 +1,7 @@ +/** + * @vitest-environment jsdom + */ +import { describeStories } from '../../../../test-helper.js'; +import meta, * as stories from './AttachmentPatternEdit.stories.js'; + +describeStories(meta, stories); diff --git a/packages/design/src/FormManager/FormEdit/components/AttachmentPatternEdit/attachmentPatternEditStyles.module.css b/packages/design/src/FormManager/FormEdit/components/AttachmentPatternEdit/attachmentPatternEditStyles.module.css new file mode 100644 index 00000000..d7cb697b --- /dev/null +++ b/packages/design/src/FormManager/FormEdit/components/AttachmentPatternEdit/attachmentPatternEditStyles.module.css @@ -0,0 +1,9 @@ +p.inlineFieldLabel, +.inlineFieldLabel { + margin-top: 1.2rem; + padding: 0 0 0.5rem 0; +} + +.attachmentInputSmall { + max-width: 13ex; +} diff --git a/packages/design/src/FormManager/FormEdit/components/AttachmentPatternEdit/index.tsx b/packages/design/src/FormManager/FormEdit/components/AttachmentPatternEdit/index.tsx new file mode 100644 index 00000000..a5f7592e --- /dev/null +++ b/packages/design/src/FormManager/FormEdit/components/AttachmentPatternEdit/index.tsx @@ -0,0 +1,194 @@ +import classNames from 'classnames'; +import React from 'react'; +import { PatternId, AttachmentProps } from '@atj/forms'; +import { AttachmentPattern, attachmentFileTypeOptions } from '@atj/forms'; +import Attachment from '../../../../Form/components/Attachment/index.js'; +import { useFormManagerStore } from '../../../store.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 './attachmentPatternEditStyles.module.css'; + +const AttachmentPatternEdit: PatternEditComponent = ({ + focus, + previewProps, +}) => { + return ( + <> + {focus ? ( + } + > + ) : ( +
+ +
+ )} + + ); +}; + +const EditComponent = ({ patternId }: { patternId: PatternId }) => { + const pattern = useFormManagerStore( + state => state.session.form.patterns[patternId] + ); + const { fieldId, register, getFieldState } = + usePatternEditFormContext(patternId); + + const label = getFieldState('label'); + const maxAttachments = getFieldState('maxAttachments'); + const allowedFileTypes = getFieldState('allowedFileTypes'); + + return ( +
+
+ +
+ +
+
+
+

+ {message.patterns.attachment.allowedFileTypesLabel} + {allowedFileTypes.error ? ( + + {allowedFileTypes.error.message} + + ) : null} +

+
+
+
+ {attachmentFileTypeOptions.map(item => { + return ( +
+
+
+ + +
+
+
+ ); + })} +
+
+
+
+ +
+
+
+ +
+
+ +
+
+
+ +
+ + + + + + +
+
+ ); +}; + +export default AttachmentPatternEdit; diff --git a/packages/design/src/FormManager/FormEdit/components/DateOfBirthPatternEdit.stories.tsx b/packages/design/src/FormManager/FormEdit/components/DateOfBirthPatternEdit.stories.tsx index c686994b..8f9acc50 100644 --- a/packages/design/src/FormManager/FormEdit/components/DateOfBirthPatternEdit.stories.tsx +++ b/packages/design/src/FormManager/FormEdit/components/DateOfBirthPatternEdit.stories.tsx @@ -97,7 +97,7 @@ export const Error: StoryObj = { await expect( await canvas.findByText( - message.patterns.selectDropdown.errorTextMustContainChar + message.patterns.dateOfBirth.errorTextMustContainChar ) ).toBeInTheDocument(); }, diff --git a/packages/design/src/FormManager/FormEdit/components/EmailInputPatternEdit.stories.tsx b/packages/design/src/FormManager/FormEdit/components/EmailInputPatternEdit.stories.tsx new file mode 100644 index 00000000..e9f3f372 --- /dev/null +++ b/packages/design/src/FormManager/FormEdit/components/EmailInputPatternEdit.stories.tsx @@ -0,0 +1,70 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { expect, userEvent } from '@storybook/test'; +import { within } from '@testing-library/react'; + +import { type EmailInputPattern } from '@atj/forms'; +import { createPatternEditStoryMeta } from './common/story-helper.js'; +import FormEdit from '../index.js'; +import { enLocale as message } from '@atj/common'; + +const pattern: EmailInputPattern = { + id: 'email-input-1', + type: 'email-input', + data: { + label: message.patterns.emailInput.displayName, + required: false, + }, +}; + +const storyConfig: Meta = { + title: 'Edit components/EmailInputPattern', + ...createPatternEditStoryMeta({ + pattern, + }), +} as Meta; + +export default storyConfig; + +export const Basic: StoryObj = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const updatedLabel = 'Test Email Update Label'; + + await userEvent.click( + canvas.getByText(message.patterns.emailInput.displayName) + ); + + const labelInput = canvas.getByLabelText( + message.patterns.emailInput.fieldLabel + ); + await userEvent.clear(labelInput); + await userEvent.type(labelInput, updatedLabel); + + const form = labelInput?.closest('form'); + form?.requestSubmit(); + + await expect(await canvas.findByText(updatedLabel)).toBeInTheDocument(); + }, +}; + +export const Error: StoryObj = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await userEvent.click( + canvas.getByText(message.patterns.emailInput.displayName) + ); + + const labelInput = canvas.getByLabelText( + message.patterns.emailInput.fieldLabel + ); + await userEvent.clear(labelInput); + labelInput.blur(); + + await expect( + await canvas.findByText( + message.patterns.emailInput.errorTextMustContainChar + ) + ).toBeInTheDocument(); + }, +}; diff --git a/packages/design/src/FormManager/FormEdit/components/EmailInputPatternEdit.test.tsx b/packages/design/src/FormManager/FormEdit/components/EmailInputPatternEdit.test.tsx new file mode 100644 index 00000000..c4df65a2 --- /dev/null +++ b/packages/design/src/FormManager/FormEdit/components/EmailInputPatternEdit.test.tsx @@ -0,0 +1,7 @@ +/** + * @vitest-environment jsdom + */ +import { describeStories } from '../../../test-helper.js'; +import meta, * as stories from './EmailInputPatternEdit.stories.js'; + +describeStories(meta, stories); diff --git a/packages/design/src/FormManager/FormEdit/components/EmailInputPatternEdit.tsx b/packages/design/src/FormManager/FormEdit/components/EmailInputPatternEdit.tsx new file mode 100644 index 00000000..876deb5b --- /dev/null +++ b/packages/design/src/FormManager/FormEdit/components/EmailInputPatternEdit.tsx @@ -0,0 +1,92 @@ +import classnames from 'classnames'; +import React from 'react'; + +import { type EmailInputProps } from '@atj/forms'; +import { type EmailInputPattern } from '@atj/forms'; + +import EmailInput from '../../../Form/components/EmailInput/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 EmailInputEdit: PatternEditComponent = ({ + focus, + previewProps, +}) => { + return ( + <> + {focus ? ( + } + > + ) : ( +
+ +
+ )} + + ); +}; + +const EditComponent = ({ pattern }: { pattern: EmailInputPattern }) => { + const { fieldId, getFieldState, register } = + usePatternEditFormContext(pattern.id); + const label = getFieldState('label'); + + return ( +
+
+ +
+
+ + + + + + +
+
+ ); +}; + +export default EmailInputEdit; diff --git a/packages/design/src/FormManager/FormEdit/components/PhoneNumberPatternEdit.stories.tsx b/packages/design/src/FormManager/FormEdit/components/PhoneNumberPatternEdit.stories.tsx new file mode 100644 index 00000000..a5755a28 --- /dev/null +++ b/packages/design/src/FormManager/FormEdit/components/PhoneNumberPatternEdit.stories.tsx @@ -0,0 +1,104 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { expect, userEvent } from '@storybook/test'; +import { within } from '@testing-library/react'; + +import { type PhoneNumberPattern } from '@atj/forms'; +import { createPatternEditStoryMeta } from './common/story-helper.js'; +import FormEdit from '../index.js'; +import { enLocale as message } from '@atj/common'; + +const pattern: PhoneNumberPattern = { + id: 'phone-number-1', + type: 'phone-number', + data: { + label: message.patterns.phoneNumber.displayName, + required: false, + hint: undefined, + }, +}; + +const storyConfig: Meta = { + title: 'Edit components/PhoneNumberPattern', + ...createPatternEditStoryMeta({ + pattern, + }), +} as Meta; + +export default storyConfig; + +export const Basic: StoryObj = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const updatedLabel = 'Phone Number update'; + const updatedHint = 'Updated hint for Phone Number'; + + await userEvent.click( + canvas.getByText(message.patterns.phoneNumber.displayName) + ); + + const labelInput = canvas.getByLabelText( + message.patterns.phoneNumber.fieldLabel + ); + await userEvent.clear(labelInput); + await userEvent.type(labelInput, updatedLabel); + + const hintInput = canvas.getByLabelText( + message.patterns.phoneNumber.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 = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const updatedLabel = 'Phone Number update'; + + await userEvent.click( + canvas.getByText(message.patterns.phoneNumber.displayName) + ); + + const labelInput = canvas.getByLabelText( + message.patterns.phoneNumber.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.phoneNumber.hintLabel) + ).toBeNull(); + }, +}; + +export const Error: StoryObj = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await userEvent.click( + canvas.getByText(message.patterns.phoneNumber.displayName) + ); + + const labelInput = canvas.getByLabelText( + message.patterns.phoneNumber.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/PhoneNumberPatternEdit.test.tsx b/packages/design/src/FormManager/FormEdit/components/PhoneNumberPatternEdit.test.tsx new file mode 100644 index 00000000..29ccbe4a --- /dev/null +++ b/packages/design/src/FormManager/FormEdit/components/PhoneNumberPatternEdit.test.tsx @@ -0,0 +1,7 @@ +/** + * @vitest-environment jsdom + */ +import { describeStories } from '../../../test-helper.js'; +import meta, * as stories from './PhoneNumberPatternEdit.stories.js'; + +describeStories(meta, stories); diff --git a/packages/design/src/FormManager/FormEdit/components/PhoneNumberPatternEdit.tsx b/packages/design/src/FormManager/FormEdit/components/PhoneNumberPatternEdit.tsx new file mode 100644 index 00000000..e2e2b6a9 --- /dev/null +++ b/packages/design/src/FormManager/FormEdit/components/PhoneNumberPatternEdit.tsx @@ -0,0 +1,117 @@ +import classnames from 'classnames'; +import React from 'react'; + +import { type PhoneNumberProps } from '@atj/forms'; +import { type PhoneNumberPattern } from '@atj/forms'; + +import PhoneNumber from '../../../Form/components/PhoneNumber/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 PhoneNumberPatternEdit: PatternEditComponent = ({ + focus, + previewProps, +}) => { + return ( + <> + {focus ? ( + } + > + ) : ( +
+ +
+ )} + + ); +}; + +const EditComponent = ({ pattern }: { pattern: PhoneNumberPattern }) => { + const { fieldId, getFieldState, register } = + usePatternEditFormContext(pattern.id); + const label = getFieldState('label'); + const hint = getFieldState('hint'); + + return ( +
+
+ +
+
+ +
+
+ + + + + + +
+
+ ); +}; + +export default PhoneNumberPatternEdit; diff --git a/packages/design/src/FormManager/FormEdit/components/SocialSecurityNumberPatternEdit.stories.tsx b/packages/design/src/FormManager/FormEdit/components/SocialSecurityNumberPatternEdit.stories.tsx new file mode 100644 index 00000000..483a700e --- /dev/null +++ b/packages/design/src/FormManager/FormEdit/components/SocialSecurityNumberPatternEdit.stories.tsx @@ -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; + +export default storyConfig; + +export const Basic: StoryObj = { + 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 = { + 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 = { + 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(); + }, +}; diff --git a/packages/design/src/FormManager/FormEdit/components/SocialSecurityNumberPatternEdit.tests.tsx b/packages/design/src/FormManager/FormEdit/components/SocialSecurityNumberPatternEdit.tests.tsx new file mode 100644 index 00000000..77b05af4 --- /dev/null +++ b/packages/design/src/FormManager/FormEdit/components/SocialSecurityNumberPatternEdit.tests.tsx @@ -0,0 +1,7 @@ +/** + * @vitest-environment jsdom + */ +import { describeStories } from '../../../test-helper.js'; +import meta, * as stories from './SocialSecurityNumberPatternEdit.js'; + +describeStories(meta, stories); diff --git a/packages/design/src/FormManager/FormEdit/components/SocialSecurityNumberPatternEdit.tsx b/packages/design/src/FormManager/FormEdit/components/SocialSecurityNumberPatternEdit.tsx new file mode 100644 index 00000000..8f5aa818 --- /dev/null +++ b/packages/design/src/FormManager/FormEdit/components/SocialSecurityNumberPatternEdit.tsx @@ -0,0 +1,118 @@ +import classnames from 'classnames'; +import React from 'react'; + +import { type SocialSecurityNumberProps } from '@atj/forms'; +import { type SocialSecurityNumberPattern } from '@atj/forms'; + +import SocialSecurityNumber from '../../../Form/components/SocialSecurityNumber/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 SocialSecurityNumberPatternEdit: PatternEditComponent< + SocialSecurityNumberProps +> = ({ focus, previewProps }) => { + return ( + <> + {focus ? ( + } + > + ) : ( +
+ +
+ )} + + ); +}; + +const EditComponent = ({ + pattern, +}: { + pattern: SocialSecurityNumberPattern; +}) => { + const { fieldId, getFieldState, register } = + usePatternEditFormContext(pattern.id); + const label = getFieldState('label'); + const hint = getFieldState('hint'); + + return ( +
+
+ +
+
+ +
+
+ + + + + + +
+
+ ); +}; + +export default SocialSecurityNumberPatternEdit; diff --git a/packages/design/src/FormManager/FormEdit/components/index.ts b/packages/design/src/FormManager/FormEdit/components/index.ts index 4e4f355a..ade8e567 100644 --- a/packages/design/src/FormManager/FormEdit/components/index.ts +++ b/packages/design/src/FormManager/FormEdit/components/index.ts @@ -3,34 +3,43 @@ import { type EditComponentForPattern, } from '../types.js'; +import AttachmentPatternEdit from './AttachmentPatternEdit/index.js'; import CheckboxPatternEdit from './CheckboxPatternEdit.js'; import DateOfBirthPatternEdit from './DateOfBirthPatternEdit.js'; +import EmailInputPatternEdit from './EmailInputPatternEdit.js'; import FieldsetEdit from './FieldsetEdit.js'; import FormSummaryEdit from './FormSummaryEdit.js'; import InputPatternEdit from './InputPatternEdit.js'; import PackageDownloadPatternEdit from './PackageDownloadPatternEdit.js'; -import { PageEdit } from './PageEdit.js'; import PageSetEdit from './PageSetEdit.js'; +import { PageEdit } from './PageEdit.js'; import ParagraphPatternEdit from './ParagraphPatternEdit.js'; import { PatternPreviewSequence } from './PreviewSequencePattern/index.js'; +import PhoneNumberPatternEdit from './PhoneNumberPatternEdit.js'; import RadioGroupPatternEdit from './RadioGroupPatternEdit.js'; import RichTextPatternEdit from './RichTextPatternEdit/index.js'; import SelectDropdownPatternEdit from './SelectDropdownPatternEdit.js'; +import SocialSecurityNumberPatternEdit from './SocialSecurityNumberPatternEdit.js'; import SubmissionConfirmationEdit from './SubmissionConfirmationEdit.js'; export const defaultPatternEditComponents: EditComponentForPattern = { + attachment: AttachmentPatternEdit as PatternEditComponent, checkbox: CheckboxPatternEdit as PatternEditComponent, 'date-of-birth': DateOfBirthPatternEdit as PatternEditComponent, - paragraph: ParagraphPatternEdit as PatternEditComponent, - input: InputPatternEdit as PatternEditComponent, - 'form-summary': FormSummaryEdit as PatternEditComponent, + 'email-input': EmailInputPatternEdit as PatternEditComponent, fieldset: FieldsetEdit as PatternEditComponent, + 'form-summary': FormSummaryEdit as PatternEditComponent, + input: InputPatternEdit as PatternEditComponent, 'package-download': PackageDownloadPatternEdit as PatternEditComponent, page: PageEdit as PatternEditComponent, 'page-set': PageSetEdit as PatternEditComponent, + paragraph: ParagraphPatternEdit as PatternEditComponent, + 'phone-number': PhoneNumberPatternEdit as PatternEditComponent, 'radio-group': RadioGroupPatternEdit as PatternEditComponent, 'rich-text': RichTextPatternEdit as PatternEditComponent, 'select-dropdown': SelectDropdownPatternEdit as PatternEditComponent, sequence: PatternPreviewSequence as PatternEditComponent, + 'social-security-number': + SocialSecurityNumberPatternEdit as PatternEditComponent, 'submission-confirmation': SubmissionConfirmationEdit as PatternEditComponent, }; diff --git a/packages/design/src/FormManager/FormEdit/formEditStyles.module.css b/packages/design/src/FormManager/FormEdit/formEditStyles.module.css index 36f46de2..9d7dbef5 100644 --- a/packages/design/src/FormManager/FormEdit/formEditStyles.module.css +++ b/packages/design/src/FormManager/FormEdit/formEditStyles.module.css @@ -51,11 +51,11 @@ padding-left: 1.5rem; } -.draggableListItemWrapper .radioFormPattern legend { - padding-left: 0; -} - -.draggableListItemWrapper .dateOfBirthPattern legend { +.draggableListItemWrapper .radioFormPattern legend, +.draggableListItemWrapper .dateOfBirthPattern legend, +.draggableListItemWrapper .phoneNumberPattern legend, +.draggableListItemWrapper .ssnPattern legend, +.draggableListItemWrapper .emailInputPattern legend { padding-left: 0; } diff --git a/packages/design/src/FormManager/FormEdit/images/email-icon.svg b/packages/design/src/FormManager/FormEdit/images/email-icon.svg new file mode 100644 index 00000000..46bc2d51 --- /dev/null +++ b/packages/design/src/FormManager/FormEdit/images/email-icon.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/design/src/FormManager/FormEdit/images/phone-icon.svg b/packages/design/src/FormManager/FormEdit/images/phone-icon.svg new file mode 100644 index 00000000..a047f931 --- /dev/null +++ b/packages/design/src/FormManager/FormEdit/images/phone-icon.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/packages/design/src/FormManager/FormEdit/images/ssn-icon.svg b/packages/design/src/FormManager/FormEdit/images/ssn-icon.svg new file mode 100644 index 00000000..dbacd843 --- /dev/null +++ b/packages/design/src/FormManager/FormEdit/images/ssn-icon.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/design/src/FormManager/FormList/store.ts b/packages/design/src/FormManager/FormList/store.ts index bb545437..2bd36d54 100644 --- a/packages/design/src/FormManager/FormList/store.ts +++ b/packages/design/src/FormManager/FormList/store.ts @@ -1,6 +1,6 @@ import { type StateCreator } from 'zustand'; -import { BlueprintBuilder } from '@atj/forms'; +import { BlueprintBuilder, uint8ArrayToBase64 } from '@atj/forms'; import { type FormManagerContext } from '../../FormManager/index.js'; import { type Result, failure } from '@atj/common'; @@ -23,18 +23,17 @@ export const createFormListSlice = () => ({ context, createNewFormByPDFUrl: async url => { - const data = await fetchUint8Array(`${context.baseUrl}${url}`); - - const builder = new BlueprintBuilder(context.config); - builder.setFormSummary({ - title: url, - description: '', - }); - await builder.addDocument({ - name: url, - data, + const data = await fetchAsBase64(`${context.baseUrl}${url}`); + const result = await context.formService.initializeForm({ + summary: { + title: url, + description: '', + }, + document: { + fileName: url, + data, + }, }); - const result = await context.formService.addForm(builder.form); if (result.success) { return { success: true, @@ -51,7 +50,16 @@ export const createFormListSlice = description: '', }); await builder.addDocument(fileDetails); - const result = await context.formService.addForm(builder.form); + const result = await context.formService.initializeForm({ + summary: { + title: fileDetails.name, + description: '', + }, + document: { + fileName: fileDetails.name, + data: await uint8ArrayToBase64(fileDetails.data), + }, + }); if (result.success) { return { success: true, @@ -63,8 +71,9 @@ export const createFormListSlice = }, }); -const fetchUint8Array = async (url: string) => { +const fetchAsBase64 = async (url: string) => { const response = await fetch(url); const blob = await response.blob(); - return new Uint8Array(await blob.arrayBuffer()); + const data = new Uint8Array(await blob.arrayBuffer()); + return uint8ArrayToBase64(data); }; diff --git a/packages/design/src/FormManager/store.tsx b/packages/design/src/FormManager/store.tsx index 6479ad4f..625893e0 100644 --- a/packages/design/src/FormManager/store.tsx +++ b/packages/design/src/FormManager/store.tsx @@ -8,7 +8,7 @@ import { import { createContext } from 'zustand-utils'; import { type Result, failure } from '@atj/common'; -import { type FormSession, type Blueprint, BlueprintBuilder } from '@atj/forms'; +import { type FormSession, type Blueprint } from '@atj/forms'; import { type FormListSlice, createFormListSlice } from './FormList/store.js'; import { type FormEditSlice, createFormEditSlice } from './FormEdit/store.js'; @@ -79,12 +79,12 @@ const createFormManagerSlice = inProgress: false, }, createNewForm: async function () { - const builder = new BlueprintBuilder(context.config); - builder.setFormSummary({ - title: `My form - ${new Date().toISOString()}`, - description: '', + const result = await context.formService.initializeForm({ + summary: { + title: `My form - ${new Date().toISOString()}`, + description: '', + }, }); - const result = await context.formService.addForm(builder.form); if (!result.success) { return failure(result.error.message); } diff --git a/packages/forms/src/blueprint.ts b/packages/forms/src/blueprint.ts index 555a6862..4bcfae75 100644 --- a/packages/forms/src/blueprint.ts +++ b/packages/forms/src/blueprint.ts @@ -71,11 +71,11 @@ export const createForm = ( patterns: [ { id: 'root', - type: 'sequence', + type: 'page-set', data: { - patterns: [], + pages: [], }, - } satisfies SequencePattern, + } satisfies PageSetPattern, ], root: 'root', } diff --git a/packages/forms/src/builder/index.ts b/packages/forms/src/builder/index.ts index 5f109702..74323ccb 100644 --- a/packages/forms/src/builder/index.ts +++ b/packages/forms/src/builder/index.ts @@ -9,7 +9,7 @@ import { removePatternFromBlueprint, updateFormSummary, } from '../blueprint.js'; -import { addDocument } from '../documents/document.js'; +import { addDocument, addParsedPdfToForm } from '../documents/document.js'; import type { FormErrors } from '../error.js'; import { createDefaultPattern, @@ -23,6 +23,7 @@ import { import { type FieldsetPattern } from '../patterns/fieldset/config.js'; import { type PageSetPattern } from '../patterns/page-set/config.js'; import type { Blueprint, FormSummary } from '../types.js'; +import type { ParsedPdf } from '../documents/pdf/parsing-api.js'; export class BlueprintBuilder { bp: Blueprint; @@ -47,6 +48,15 @@ export class BlueprintBuilder { this.bp = updatedForm; } + async addDocumentRef(opts: { id: string; extract: ParsedPdf }) { + const { updatedForm } = await addParsedPdfToForm(this.form, { + id: opts.id, + label: opts.extract.title, + extract: opts.extract, + }); + this.bp = updatedForm; + } + addPage() { const newPage = createDefaultPattern(this.config, 'page'); this.bp = addPageToPageSet(this.form, newPage); diff --git a/packages/forms/src/builder/parse-form.test.ts b/packages/forms/src/builder/parse-form.test.ts new file mode 100644 index 00000000..b70aafd4 --- /dev/null +++ b/packages/forms/src/builder/parse-form.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect } from 'vitest'; +import { failure, success } from '@atj/common'; + +import { parseForm, parseFormString } from './parse-form'; +import { defaultFormConfig, type InputPattern } from '../patterns'; +import type { Blueprint } from '../types'; + +describe('parseForm', () => { + it('should return success when form data is valid', () => { + const formData: Blueprint = { + summary: { + title: 'Test Title', + description: 'Test Description', + }, + root: 'rootValue', + patterns: { + validPattern: { + type: 'input', + id: 'validPattern', + data: { + label: 'label', + required: true, + maxLength: 100, + }, + } satisfies InputPattern, + }, + outputs: [ + { + id: 'output1', + path: 'path/to/output', + fields: { + field1: { + type: 'TextField', + name: 'name', + label: 'label', + value: 'value', + required: true, + }, + }, + formFields: { + formField1: 'formValue1', + }, + }, + ], + }; + + const result = parseForm(defaultFormConfig, formData); + expect(result).toEqual(success(formData)); + }); + + it('should return failure when form data is invalid', () => { + const formData = { + summary: { + title: 'Test Title', + description: 'Test Description', + }, + root: 'rootValue', + patterns: { + invalidPattern: { + type: 'invalidPattern', + data: {}, + }, + }, + outputs: [ + { + id: 'output1', + path: 'path/to/output', + fields: { + field1: 'value1', + }, + formFields: { + formField1: 'formValue1', + }, + }, + ], + }; + + const result = parseForm(defaultFormConfig, formData); + expect(result.success).toEqual(false); + }); +}); + +describe('parseFormString', () => { + it('should return success when JSON string is valid', () => { + const jsonString = JSON.stringify({ + summary: { + title: 'Test Title', + description: 'Test Description', + }, + root: 'rootValue', + patterns: { + validPattern: { + type: 'input', + id: 'validPattern', + data: { + label: 'label', + required: true, + maxLength: 100, + initial: '', + }, + } satisfies InputPattern, + }, + outputs: [], + } satisfies Blueprint); + + const result = parseFormString(defaultFormConfig, jsonString); + expect(result).toEqual(success(JSON.parse(jsonString))); + }); + + it('should return failure when JSON string is invalid', () => { + const jsonString = JSON.stringify({ + summary: { + title: 'Test Title', + description: 'Test Description', + }, + root: 'rootValue', + patterns: { + invalidPattern: { + type: 'invalidPattern', + data: {}, + }, + }, + outputs: [ + { + id: 'output1', + path: 'path/to/output', + fields: { + field1: 'value1', + }, + formFields: { + formField1: 'formValue1', + }, + }, + ], + }); + + const result = parseFormString(defaultFormConfig, jsonString); + expect(result).toEqual({ + success: false, + error: + '[\n' + + ' {\n' + + ' "code": "custom",\n' + + ' "message": "Invalid pattern",\n' + + ' "path": [\n' + + ' "patterns",\n' + + ' "invalidPattern"\n' + + ' ]\n' + + ' }\n' + + ']', + }); + }); +}); diff --git a/packages/forms/src/builder/parse-form.ts b/packages/forms/src/builder/parse-form.ts new file mode 100644 index 00000000..ea86f1f2 --- /dev/null +++ b/packages/forms/src/builder/parse-form.ts @@ -0,0 +1,62 @@ +import * as z from 'zod'; + +import { failure, success, type Result } from '@atj/common'; +import type { FormConfig } from '../pattern'; +import type { Blueprint } from '../types'; + +export const parseForm = (config: FormConfig, obj: any): Result => { + const formSchema = createFormSchema(config); + const result = formSchema.safeParse(obj); + if (result.error) { + return failure(result.error.message); + } + return success(result.data); +}; + +export const parseFormString = ( + config: FormConfig, + json: string +): Result => { + return parseForm(config, JSON.parse(json)); +}; + +const createFormSchema = (config: FormConfig) => { + return z.object({ + summary: z.object({ + title: z.string(), + description: z.string(), + }), + root: z.string(), + patterns: z.record( + z.string(), + z.any().refine( + val => { + const patternConfig = config.patterns[val?.type]; + if (!patternConfig) { + return false; + } + const result = patternConfig.parseConfigData(val?.data); + if (!result.success) { + const message = Object.values(result.error) + .map(err => err.message || '') + .join(', '); + console.error(val?.type, result.error); + console.error(`Pattern config error: ${message}`); + } + return result.success; + }, + { + message: 'Invalid pattern', + } + ) + ), + outputs: z.array( + z.object({ + id: z.string(), + path: z.string(), + fields: z.record(z.string(), z.any()), + formFields: z.record(z.string(), z.string()), + }) + ), + }); +}; diff --git a/packages/forms/src/components.ts b/packages/forms/src/components.ts index a4f4b5da..c5364c04 100644 --- a/packages/forms/src/components.ts +++ b/packages/forms/src/components.ts @@ -24,6 +24,18 @@ export type TextInputProps = PatternProps<{ error?: FormError; }>; +export type AttachmentProps = PatternProps<{ + type: 'attachment'; + inputId: string; + value: string; + label: string; + maxAttachments: number; + maxFileSizeMB: number; + allowedFileTypes: Array; + required: boolean; + error?: FormError; +}>; + export type FormSummaryProps = PatternProps<{ type: 'form-summary'; title: string; @@ -112,6 +124,34 @@ export type DateOfBirthProps = PatternProps<{ error?: FormError; }>; +export type EmailInputProps = PatternProps<{ + type: 'email-input'; + emailId: string; + label: string; + required: boolean; + error?: FormError; +}>; + +export type PhoneNumberProps = PatternProps<{ + type: 'phone-number'; + phoneId: string; + hint?: string; + label: string; + required: boolean; + error?: FormError; + value: string; +}>; + +export type SocialSecurityNumberProps = PatternProps<{ + type: 'social-security-number'; + ssnId: string; + hint?: string; + label: string; + required: boolean; + error?: FormError; + value: string; +}>; + export type SequenceProps = PatternProps<{ type: 'sequence'; }>; diff --git a/packages/forms/src/context/browser/form-repo.ts b/packages/forms/src/context/browser/form-repo.ts index c39566ec..9e4107db 100644 --- a/packages/forms/src/context/browser/form-repo.ts +++ b/packages/forms/src/context/browser/form-repo.ts @@ -1,8 +1,15 @@ -import { type Result, type VoidResult, failure } from '@atj/common'; - -import { FormSession, FormSessionId, type Blueprint } from '../../index.js'; +import { type Result, type VoidResult, failure, success } from '@atj/common'; + +import { + FormSession, + FormSessionId, + type Blueprint, + type DocumentFieldMap, +} from '../../index.js'; import { FormRepository } from '../../repository/index.js'; +import type { ParsedPdf } from '../../documents/pdf/parsing-api.js'; +const documentKey = (id: string) => `documents/${id}`; const formKey = (formId: string) => `forms/${formId}`; const isFormKey = (key: string) => key.startsWith('forms/'); const getFormIdFromKey = (key: string) => { @@ -73,20 +80,22 @@ export class BrowserFormRepository implements FormRepository { }; } - async deleteForm(formId: string): Promise { + async deleteForm( + formId: string + ): Promise> { this.storage.removeItem(formKey(formId)); return { success: true }; } - async getForm(id?: string): Promise { + async getForm(id?: string): Promise> { if (!this.storage || !id) { - return null; + return success(null); } const formString = this.storage.getItem(`forms/${id}`); if (!formString) { - return null; + return success(null); } - return parseStringForm(formString); + return Promise.resolve(success(JSON.parse(formString))); } async getFormList(): Promise< @@ -98,14 +107,17 @@ export class BrowserFormRepository implements FormRepository { } return Promise.all( forms.map(async key => { - const form = await this.getForm(key); - if (form === null) { + const formResult = await this.getForm(key); + if (!formResult.success) { + throw new Error('Error getting form'); + } + if (formResult.data === null) { throw new Error('key mismatch'); } return { id: key, - title: form.summary.title, - description: form.summary.description, + title: formResult.data.summary.title, + description: formResult.data.summary.description, }; }) ); @@ -113,12 +125,55 @@ export class BrowserFormRepository implements FormRepository { async saveForm(formId: string, form: Blueprint): Promise { try { - this.storage.setItem(formKey(formId), stringifyForm(form)); + this.storage.setItem(formKey(formId), JSON.stringify(form)); } catch { return failure(`error saving '${formId}' to storage`); } return { success: true }; } + + addDocument(document: { + fileName: string; + data: Uint8Array; + extract: { parsedPdf: ParsedPdf; fields: DocumentFieldMap }; + }) { + const documentId = crypto.randomUUID(); + const data = uint8ArrayToBase64(document.data); + this.storage.setItem( + documentKey(documentId), + JSON.stringify({ + id: documentId, + type: 'pdf', + file_name: document.fileName, + data, + extract: JSON.stringify(document.extract), + }) + ); + return Promise.resolve( + success({ + id: documentId, + }) + ); + } + + getDocument(id: string): Promise< + Result<{ + id: string; + data: Uint8Array; + path: string; + fields: DocumentFieldMap; + }> + > { + const value = this.storage.getItem(documentKey(id)); + if (value === null) { + return Promise.resolve(failure(`Document with id ${id} not found`)); + } + const json = JSON.parse(value); + return Promise.resolve({ + ...json, + data: base64ToUint8Array(json.data), + }); + } } export const getFormList = (storage: Storage) => { @@ -138,7 +193,7 @@ export const getFormList = (storage: Storage) => { export const saveForm = (storage: Storage, formId: string, form: Blueprint) => { try { - storage.setItem(formKey(formId), stringifyForm(form)); + storage.setItem(formKey(formId), JSON.stringify(form)); } catch { return { success: false as const, @@ -150,17 +205,6 @@ export const saveForm = (storage: Storage, formId: string, form: Blueprint) => { }; }; -const stringifyForm = (form: Blueprint) => { - return JSON.stringify({ - ...form, - outputs: form.outputs.map(output => ({ - ...output, - // TODO: we probably want to do this somewhere in the documents module - data: uint8ArrayToBase64(output.data), - })), - }); -}; - const parseStringForm = (formString: string): Blueprint => { const form = JSON.parse(formString) as Blueprint; return { @@ -181,12 +225,20 @@ const uint8ArrayToBase64 = (buffer: Uint8Array): string => { return btoa(binary); }; +const fixBase64 = (base64: string): string => { + const padding = base64.length % 4; + if (padding === 2) return base64 + '=='; + if (padding === 3) return base64 + '='; + return base64; +}; + const base64ToUint8Array = (base64: string): Uint8Array => { - const binaryString = atob(base64); - const len = binaryString.length; - const bytes = new Uint8Array(len); + const fixedBase64 = fixBase64(base64); + const binary = atob(fixedBase64); + const len = binary.length; + const buffer = new Uint8Array(len); for (let i = 0; i < len; i++) { - bytes[i] = binaryString.charCodeAt(i); + buffer[i] = binary.charCodeAt(i); } - return bytes; + return buffer; }; diff --git a/packages/forms/src/context/index.ts b/packages/forms/src/context/index.ts index 910f3f6e..d73929c7 100644 --- a/packages/forms/src/context/index.ts +++ b/packages/forms/src/context/index.ts @@ -1,10 +1,13 @@ -import { type FormConfig } from '../pattern.js'; -import { type FormRepository } from '../repository/index.js'; +import type { ParsePdf } from '../documents/index.js'; +import type { FormConfig } from '../pattern.js'; +import type { FormRepository } from '../repository/index.js'; -export { createTestBrowserFormService } from './test/index.js'; export { BrowserFormRepository } from './browser/form-repo.js'; +export { createTestBrowserFormService } from './test/index.js'; + export type FormServiceContext = { repository: FormRepository; config: FormConfig; isUserLoggedIn: () => boolean; + parsePdf: ParsePdf; }; diff --git a/packages/forms/src/context/test/index.ts b/packages/forms/src/context/test/index.ts index 6e4cc835..8122c05d 100644 --- a/packages/forms/src/context/test/index.ts +++ b/packages/forms/src/context/test/index.ts @@ -1,4 +1,5 @@ import { BrowserFormRepository } from '../browser/form-repo.js'; +import { parsePdf } from '../../documents/pdf/index.js'; import { defaultFormConfig } from '../../patterns/index.js'; import { type FormService, createFormService } from '../../services/index.js'; @@ -14,6 +15,7 @@ export const createTestBrowserFormService = ( repository, config: defaultFormConfig, isUserLoggedIn: () => true, + parsePdf, }); if (testData) { Object.entries(testData).forEach(([id, blueprint]) => { diff --git a/packages/forms/src/documents/__tests__/doj-pardon-marijuana.test.ts b/packages/forms/src/documents/__tests__/doj-pardon-marijuana.test.ts index 1b828d4a..48ce61aa 100644 --- a/packages/forms/src/documents/__tests__/doj-pardon-marijuana.test.ts +++ b/packages/forms/src/documents/__tests__/doj-pardon-marijuana.test.ts @@ -3,7 +3,8 @@ import { describe, expect, test } from 'vitest'; import { Success } from '@atj/common'; import { type DocumentFieldMap } from '../index.js'; -import { fillPDF, getDocumentFieldData } from '../pdf/index.js'; +import { fillPDF } from '../pdf/index.js'; +import { getDocumentFieldData } from '../pdf/extract.js'; import { loadSamplePDF } from './sample-data.js'; diff --git a/packages/forms/src/documents/__tests__/extract.test.ts b/packages/forms/src/documents/__tests__/extract.test.ts index ccdaef1f..a2ec418b 100644 --- a/packages/forms/src/documents/__tests__/extract.test.ts +++ b/packages/forms/src/documents/__tests__/extract.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { getDocumentFieldData } from '../index.js'; import { loadSamplePDF } from './sample-data.js'; +import { getDocumentFieldData } from '../pdf/extract.js'; describe('PDF form field extraction', () => { it('extracts data from California UD-105 form', async () => { diff --git a/packages/forms/src/documents/__tests__/fill-pdf.test.ts b/packages/forms/src/documents/__tests__/fill-pdf.test.ts index 8fbc5068..4ec57684 100644 --- a/packages/forms/src/documents/__tests__/fill-pdf.test.ts +++ b/packages/forms/src/documents/__tests__/fill-pdf.test.ts @@ -2,8 +2,9 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { type Failure, type Success } from '@atj/common'; -import { getDocumentFieldData, fillPDF } from '../index.js'; +import { fillPDF } from '../index.js'; import { loadSamplePDF } from './sample-data.js'; +import { getDocumentFieldData } from '../pdf/extract.js'; describe('PDF form filler', () => { let pdfBytes: Uint8Array; diff --git a/packages/forms/src/documents/document.ts b/packages/forms/src/documents/document.ts index 2e455702..2338834b 100644 --- a/packages/forms/src/documents/document.ts +++ b/packages/forms/src/documents/document.ts @@ -6,20 +6,50 @@ import { } from '../blueprint.js'; import { type Pattern } from '../pattern.js'; import { type InputPattern } from '../patterns/input/config.js'; +import { type AttachmentPattern } from '../patterns/attachment/config.js'; +import { attachmentFileTypeMimes } from '../patterns/attachment/file-type-options.js'; import { type SequencePattern } from '../patterns/sequence.js'; import { type Blueprint } from '../types.js'; +import { getDocumentFieldData } from './pdf/extract.js'; -import { type PDFDocument, getDocumentFieldData } from './pdf/index.js'; +import { type PDFDocument } from './pdf/index.js'; import { type FetchPdfApiResponse, - processApiResponse, + type ParsedPdf, fetchPdfApiResponse, + processApiResponse, } from './pdf/parsing-api.js'; import { type DocumentFieldMap } from './types.js'; export type DocumentTemplate = PDFDocument; +export const addParsedPdfToForm = async ( + form: Blueprint, + document: { + id: string; + label: string; + extract: ParsedPdf; + } +) => { + form = addPatternMap(form, document.extract.patterns, document.extract.root); + const updatedForm = addFormOutput(form, { + id: document.id, + path: document.label, + fields: document.extract.outputs, + formFields: Object.fromEntries( + Object.keys(document.extract.outputs).map(output => { + return [output, document.extract.outputs[output].name]; + }) + ), + }); + return { + newFields: document.extract.outputs, + updatedForm, + errors: document.extract.errors, + }; +}; + export const addDocument = async ( form: Blueprint, fileDetails: { @@ -41,7 +71,7 @@ export const addDocument = async ( }); form = addPatternMap(form, parsedPdf.patterns, parsedPdf.root); const updatedForm = addFormOutput(form, { - data: fileDetails.data, + id: 'document-1', // TODO: generate a unique ID path: fileDetails.name, fields: parsedPdf.outputs, formFields: Object.fromEntries( @@ -58,7 +88,7 @@ export const addDocument = async ( } else { const formWithFields = addDocumentFieldsToForm(form, fields); const updatedForm = addFormOutput(formWithFields, { - data: fileDetails.data, + id: 'document-1', // TODO: generate a unique ID path: fileDetails.name, fields, // TODO: for now, reuse the field IDs from the PDF. we need to generate diff --git a/packages/forms/src/documents/pdf/extract.ts b/packages/forms/src/documents/pdf/extract.ts index 6b619fe7..099dfb3c 100644 --- a/packages/forms/src/documents/pdf/extract.ts +++ b/packages/forms/src/documents/pdf/extract.ts @@ -10,7 +10,7 @@ import { PDFRadioGroup, } from 'pdf-lib'; -import { stringToBase64 } from '../util.js'; +import { stringToBase64 } from '../../util/base64.js'; import type { DocumentFieldValue, DocumentFieldMap } from '../types.js'; // TODO: copied from pdf-lib acrofield internals, check if it's already exposed outside of acroform somewhere diff --git a/packages/forms/src/documents/pdf/generate.ts b/packages/forms/src/documents/pdf/generate.ts index 0750d87c..63aade0e 100644 --- a/packages/forms/src/documents/pdf/generate.ts +++ b/packages/forms/src/documents/pdf/generate.ts @@ -107,6 +107,9 @@ const setFormFieldData = ( } else { field.uncheck(); } + } else if (fieldType === 'Attachment') { + const field = form.getDropdown(fieldName); + field.select(fieldValue); } else if (fieldType === 'Dropdown') { const field = form.getDropdown(fieldName); field.select(fieldValue); diff --git a/packages/forms/src/documents/pdf/index.ts b/packages/forms/src/documents/pdf/index.ts index 6cdd0aba..edafb097 100644 --- a/packages/forms/src/documents/pdf/index.ts +++ b/packages/forms/src/documents/pdf/index.ts @@ -1,4 +1,11 @@ -export { getDocumentFieldData } from './extract.js'; +import { getDocumentFieldData } from './extract.js'; +import { + type ParsedPdf, + fetchPdfApiResponse, + processApiResponse, +} from './parsing-api.js'; +import type { DocumentFieldMap } from '../types.js'; + export * from './generate.js'; export { generateDummyPDF } from './generate-dummy.js'; @@ -14,9 +21,21 @@ export type PDFField = { }; export type PDFFieldType = | 'TextField' + | 'Attachment' | 'CheckBox' | 'Dropdown' | 'OptionList' | 'RadioGroup' | 'Paragraph' | 'RichText'; + +export type ParsePdf = ( + pdf: Uint8Array +) => Promise<{ parsedPdf: ParsedPdf; fields: DocumentFieldMap }>; + +export const parsePdf: ParsePdf = async (pdfBytes: Uint8Array) => { + const fields = await getDocumentFieldData(pdfBytes); + const apiResponse = await fetchPdfApiResponse(pdfBytes); + const parsedPdf = await processApiResponse(apiResponse); + return { parsedPdf, fields }; +}; diff --git a/packages/forms/src/documents/pdf/parsing-api.ts b/packages/forms/src/documents/pdf/parsing-api.ts index 82d44f15..d24b9a86 100644 --- a/packages/forms/src/documents/pdf/parsing-api.ts +++ b/packages/forms/src/documents/pdf/parsing-api.ts @@ -9,7 +9,7 @@ import { type CheckboxPattern } from '../../patterns/checkbox.js'; import { type RadioGroupPattern } from '../../patterns/radio-group.js'; import { RichTextPattern } from '../../patterns/rich-text.js'; -import { uint8ArrayToBase64 } from '../util.js'; +import { uint8ArrayToBase64 } from '../../util/base64.js'; import { type DocumentFieldMap } from '../types.js'; import { createPattern, @@ -157,7 +157,6 @@ export const processApiResponse = async (json: any): Promise => { for (const element of extracted.elements) { const fieldsetPatterns: PatternId[] = []; - // Add paragraph elements if (element.component_type === 'paragraph') { const paragraph = processPatternData( diff --git a/packages/forms/src/index.ts b/packages/forms/src/index.ts index 98825238..11200c7c 100644 --- a/packages/forms/src/index.ts +++ b/packages/forms/src/index.ts @@ -8,8 +8,18 @@ export * from './patterns/index.js'; export * from './response.js'; export * from './session.js'; export * from './types.js'; +export * from './util/base64.js'; export { type FormService, createFormService } from './services/index.js'; +export { + defaultFormConfig, + attachmentFileTypeOptions, + attachmentFileTypeMimes, +} from './patterns/index.js'; +import { type PagePattern } from './patterns/page/config.js'; +import { type PageSetPattern } from './patterns/page-set/config.js'; export { type RichTextPattern } from './patterns/rich-text.js'; +import { type SequencePattern } from './patterns/sequence.js'; +import { FieldsetPattern } from './patterns/index.js'; export { type FormRepository, createFormsRepository, diff --git a/packages/forms/src/pattern.ts b/packages/forms/src/pattern.ts index b0a39eec..36c337ca 100644 --- a/packages/forms/src/pattern.ts +++ b/packages/forms/src/pattern.ts @@ -146,7 +146,7 @@ const aggregateValuesByPrefix = ( return aggregatedValues; }; -export const validatePatternAndChildren = ( +export const aggregatePatternSessionValues = ( config: FormConfig, form: Blueprint, patternConfig: PatternConfig, @@ -155,7 +155,7 @@ export const validatePatternAndChildren = ( result: { values: Record; errors: Record; - } = { values: {}, errors: {} } + } ) => { const aggregatedValues = aggregateValuesByPrefix(values); @@ -165,6 +165,7 @@ export const validatePatternAndChildren = ( if (parseResult.success) { result.values[pattern.id] = parseResult.data; + delete result.errors[pattern.id]; } else { result.values[pattern.id] = values[pattern.id]; result.errors[pattern.id] = parseResult.error; @@ -172,7 +173,7 @@ export const validatePatternAndChildren = ( } for (const child of patternConfig.getChildren(pattern, form.patterns)) { const childPatternConfig = getPatternConfig(config, child.type); - validatePatternAndChildren( + aggregatePatternSessionValues( config, form, childPatternConfig, diff --git a/packages/forms/src/patterns/README.md b/packages/forms/src/patterns/README.md index 56daa04b..25b50e76 100644 --- a/packages/forms/src/patterns/README.md +++ b/packages/forms/src/patterns/README.md @@ -39,11 +39,14 @@ const input: InputPattern = { } ``` +... or patterns may be created with the builder's object-oriented interface: + ```typescript -const input = new InputPatternBuilder(); +const input1 = new InputPatternBuilder(); +const input2 = new InputPatternBuilder(); const page1 = new Page({ title: 'Page 1', patterns: [input1.id] }); -const pageSet = new PageSet({ pages: [page1.id] }, 'page-set'); -const page2 = new Page({ title: 'Page 2', patterns: [input1.id] }); +const pageSet = new PageSet({ pages: [page1.id] }); +const page2 = new Page({ title: 'Page 2', patterns: [input2.id] }); pageSet.addPage(page2) // Construct the pattern objects diff --git a/packages/forms/src/patterns/attachment/attachment.test.ts b/packages/forms/src/patterns/attachment/attachment.test.ts new file mode 100644 index 00000000..6769916b --- /dev/null +++ b/packages/forms/src/patterns/attachment/attachment.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it } from 'vitest'; + +import { parseUserInput } from './response.js'; +import { type AttachmentPattern, parseConfigData } from './config.js'; + +describe('AttachmentPattern tests', () => { + const defaultData: AttachmentPattern['data'] = { + label: 'File upload', + required: true, + maxAttachments: 1, + maxFileSizeMB: 10, + allowedFileTypes: ['image/jpeg', 'application/pdf', 'image/png'], + }; + + describe('parseConfigData', () => { + it('should create schema for required attachment', () => { + const data = { + ...defaultData, + }; + + const input = parseConfigData(data); + expect(input.success).toBe(true); + }); + + it('should create schema for an optional attachment', () => { + const data = { + ...defaultData, + required: false, + }; + + const input = parseConfigData(data); + expect(input.success).toBe(true); + }); + + it('should accept a string of an allowed MIME type', () => { + const data = { + ...defaultData, + allowedFileTypes: defaultData.allowedFileTypes[0], + }; + + const input = parseConfigData(data); + expect(input.success).toBe(true); + }); + + it('should require at least 1 attachment type', () => { + const data = { + ...defaultData, + allowedFileTypes: [], + }; + + const input = parseConfigData(data); + expect((input as any).error).toStrictEqual({ + allowedFileTypes: { + message: 'Invalid file type found.', + type: 'required', + }, + }); + }); + + it('should require a valid attachment type', () => { + const data = { + ...defaultData, + allowedFileTypes: ['invalid/mimetype'], + }; + + const input = parseConfigData(data); + expect((input as any).error).toStrictEqual({ + allowedFileTypes: { + message: 'Invalid input', + type: 'custom', + }, + }); + }); + + it('should require all attachment types to be valid', () => { + const data = { + ...defaultData, + allowedFileTypes: ['image/jpeg', 'invalid/mimetype'], + }; + + const input = parseConfigData(data); + expect((input as any).error).toStrictEqual({ + allowedFileTypes: { + message: 'Invalid input', + type: 'custom', + }, + }); + }); + + it('should not allow humongous files', () => { + const data = { + ...defaultData, + maxFileSizeMB: 10000000, + }; + + const input = parseConfigData(data); + expect((input as any).error).toStrictEqual({ + maxFileSizeMB: { + message: 'Number must be less than or equal to 10', + type: 'custom', + }, + }); + }); + + it('should require positive integer of max attachments', () => { + const data = { + ...defaultData, + maxAttachments: 0, + }; + + const input = parseConfigData(data); + expect((input as any).error).toStrictEqual({ + maxAttachments: { + message: 'Number must be greater than 0', + type: 'custom', + }, + }); + }); + }); + + describe('parseUserInput', () => { + it('accepts a single file with valid input', () => { + const pattern = { + id: '1', + type: 'attachment', + data: { + ...defaultData, + }, + }; + + const file = new File(['abc123'], 'mock.jpg', { type: 'image/jpeg' }); + + const input = parseUserInput(pattern, file); + expect(input.success).toBe(true); + }); + + it('accepts multiple files with valid input', () => { + const pattern = { + id: '1', + type: 'attachment', + data: { + ...defaultData, + maxAttachments: 2, + }, + }; + + const file = new File(['abc123'], 'mock.jpg', { type: 'image/jpeg' }); + + const input = parseUserInput(pattern, [file, file]); + expect(input.success).toBe(true); + }); + + it('allows empty input if the field is not required', () => { + const pattern = { + id: '1', + type: 'attachment', + data: { + ...defaultData, + required: false, + }, + }; + + /** + * this is what an empty input field with a type of file sends in its payload: + `File { + size: 0, + type: 'application/octet-stream', + name: '', + lastModified: 1731954072461 + }` + */ + const file = new File([], '', { type: 'application/octet-stream' }); + + const input = parseUserInput(pattern, file); + expect(input.success).toBe(true); + }); + + it('checks for too many attachments', () => { + const pattern = { + id: '1', + type: 'attachment', + data: { + ...defaultData, + }, + }; + + const file = new File(['abc123'], 'mock.jpg', { type: 'image/jpeg' }); + + const input = parseUserInput( + pattern, + Array(pattern.data.maxAttachments + 1).fill(file) + ); + expect(input.success).toBe(false); + }); + + it('checks for valid attachment types', () => { + const pattern = { + id: '1', + type: 'attachment', + data: { + ...defaultData, + }, + }; + + const file = new File(['abc123'], 'mock.txt', { type: 'text/plain' }); + + const input = parseUserInput(pattern, [file]); + expect(input.success).toBe(false); + }); + + it('checks for file size compliance', () => { + const pattern = { + id: '1', + type: 'attachment', + data: { + ...defaultData, + maxFileSizeMB: 0, + }, + }; + + const file = new File(['abc123'], 'mock.txt', { type: 'text/plain' }); + + const input = parseUserInput(pattern, [file]); + expect(input.success).toBe(false); + }); + }); +}); diff --git a/packages/forms/src/patterns/attachment/config.ts b/packages/forms/src/patterns/attachment/config.ts new file mode 100644 index 00000000..b31cf6f1 --- /dev/null +++ b/packages/forms/src/patterns/attachment/config.ts @@ -0,0 +1,39 @@ +import { z } from 'zod'; +import { enLocale as message } from '@atj/common'; +import { ParsePatternConfigData, type Pattern } from '../../pattern.js'; +import { safeZodParseFormErrors } from '../../util/zod.js'; +import { attachmentFileTypeMimes } from './file-type-options'; + +export type AttachmentPattern = Pattern; + +export const configSchema = z.object({ + label: z.string().min(1, message.patterns.attachment.fieldLabelRequired), + required: z.boolean(), + maxAttachments: z.coerce.number().int().gt(0), + maxFileSizeMB: z.coerce.number().int().gt(1).lte(10), + allowedFileTypes: z.union([ + z + .array( + z.enum( + attachmentFileTypeMimes as [(typeof attachmentFileTypeMimes)[number]] + ) + ) + .nonempty(message.patterns.attachment.errorUnsupportedFileType), + z.enum( + attachmentFileTypeMimes as [(typeof attachmentFileTypeMimes)[number]] + ), + ]), +}); + +export type AttachmentConfigSchema = z.infer; + +export const parseConfigData: ParsePatternConfigData< + AttachmentConfigSchema +> = obj => { + let newObj = { + ...(obj as AttachmentConfigSchema), + maxFileSizeMB: (obj as AttachmentConfigSchema).maxFileSizeMB ?? 10, + }; + + return safeZodParseFormErrors(configSchema, newObj); +}; diff --git a/packages/forms/src/patterns/attachment/file-type-options.ts b/packages/forms/src/patterns/attachment/file-type-options.ts new file mode 100644 index 00000000..9c5c0d1e --- /dev/null +++ b/packages/forms/src/patterns/attachment/file-type-options.ts @@ -0,0 +1,23 @@ +interface AttachmentFileTypeOption { + label: string; + value: string; +} + +export const attachmentFileTypeOptions: AttachmentFileTypeOption[] = [ + { + label: 'JPG', + value: 'image/jpeg', + }, + { + label: 'PDF', + value: 'application/pdf', + }, + { + label: 'PNG', + value: 'image/png', + }, +]; + +export const attachmentFileTypeMimes = attachmentFileTypeOptions.map( + item => item.value +); diff --git a/packages/forms/src/patterns/attachment/index.ts b/packages/forms/src/patterns/attachment/index.ts new file mode 100644 index 00000000..50b30260 --- /dev/null +++ b/packages/forms/src/patterns/attachment/index.ts @@ -0,0 +1,29 @@ +import { enLocale as message } from '@atj/common'; + +import { type PatternConfig } from '../../pattern.js'; + +import { parseConfigData, type AttachmentPattern } from './config.js'; +import { createPrompt } from './prompt.js'; +import { type AttachmentPatternOutput, parseUserInput } from './response.js'; +import { attachmentFileTypeMimes } from './file-type-options'; + +export const attachmentConfig: PatternConfig< + AttachmentPattern, + AttachmentPatternOutput +> = { + displayName: message.patterns.attachment.displayName, + iconPath: 'shortanswer-icon.svg', + initial: { + label: 'File upload', + required: true, + maxAttachments: 1, + allowedFileTypes: attachmentFileTypeMimes as [string, ...string[]], + maxFileSizeMB: 10, + }, + parseUserInput, + parseConfigData, + getChildren() { + return []; + }, + createPrompt, +}; diff --git a/packages/forms/src/patterns/attachment/prompt.ts b/packages/forms/src/patterns/attachment/prompt.ts new file mode 100644 index 00000000..13272f67 --- /dev/null +++ b/packages/forms/src/patterns/attachment/prompt.ts @@ -0,0 +1,43 @@ +import { attachmentConfig } from './index.js'; +import { type AttachmentPattern } from './config.js'; +import { + type CreatePrompt, + type AttachmentProps, + getFormSessionValue, + validatePattern, +} from '../../index.js'; + +export const createPrompt: CreatePrompt = ( + _, + session, + pattern, + options +) => { + const extraAttributes: Record = {}; + const sessionValue = getFormSessionValue(session, pattern.id); + if (options.validate) { + const isValidResult = validatePattern( + attachmentConfig, + pattern, + sessionValue + ); + if (!isValidResult.success) { + extraAttributes['error'] = isValidResult.error; + } + } + return { + props: { + _patternId: pattern.id, + type: 'attachment', + inputId: pattern.id, + value: sessionValue, + label: pattern.data.label, + required: pattern.data.required, + maxAttachments: pattern.data.maxAttachments, + maxFileSizeMB: 10, + allowedFileTypes: pattern.data.allowedFileTypes, + ...extraAttributes, + } as AttachmentProps, + children: [], + }; +}; diff --git a/packages/forms/src/patterns/attachment/response.ts b/packages/forms/src/patterns/attachment/response.ts new file mode 100644 index 00000000..0a28efe1 --- /dev/null +++ b/packages/forms/src/patterns/attachment/response.ts @@ -0,0 +1,86 @@ +import { z } from 'zod'; +import { ParseUserInput } from '../../pattern.js'; +import { safeZodParseToFormError } from '../../util/zod.js'; +import { type AttachmentPattern } from './config.js'; + +const convertMBtoBytes = (sizeInMB: number) => { + return sizeInMB * 1024 * 1024; +}; + +export type AttachmentPatternOutput = z.infer>; + +const normalizeFiles = (items: File | File[] | FileList): File[] => { + if (items instanceof File) { + // Empty input fields with a type of file will have a size of 0 + // and MIME type of application/octet-stream on both the client's `FormData` + // and on the server. + if (!items.size && items.type === 'application/octet-stream') { + return []; + } + return [items]; + } + if (typeof FileList !== 'undefined' && items instanceof FileList) { + return Array.from(items); + } + return items as File[]; +}; + +const createSchema = (data: AttachmentPattern['data']) => { + const maxFileSizeBytes = convertMBtoBytes(data.maxFileSizeMB); + + return z + .any() + .transform(normalizeFiles) + .refine( + (items: File[]) => { + return !(data.required && items.length === 0); + }, + { + message: 'This field is required', + } + ) + .refine( + (items: File[]) => { + return items.every(item => data.allowedFileTypes.includes(item.type)); + }, + { + message: `Invalid file type`, + } + ) + .refine( + (items: File[]) => { + return items.length <= data.maxAttachments; + }, + { + message: `The maximum number of attachments is ${data.maxAttachments}`, + } + ) + .refine( + (items: File[]) => { + return items.every(item => item.size <= maxFileSizeBytes); + }, + { + message: `The maximum allowable size per file is ${data.maxFileSizeMB}MB`, + } + ) + .transform((items: File[]) => { + /** + * TODO: during the form filler epic, we'll want to switch this and other validation + * methods to be async for consistency and talk about how we're returning the object on the session. + * The tentative plan is to write the file to storage and return the ID on the session here. + */ + return items.map(item => { + return { + name: item.name, + data: 'YWJj', // Just some dummy base64 encoded data => "abc" + }; + }); + }); +}; + +export const parseUserInput: ParseUserInput< + AttachmentPattern, + AttachmentPatternOutput +> = (pattern, obj) => { + return safeZodParseToFormError(createSchema(pattern['data']), obj); +}; diff --git a/packages/forms/src/patterns/date-of-birth/date-of-birth.test.ts b/packages/forms/src/patterns/date-of-birth/date-of-birth.test.ts index 15d4b34a..77204553 100644 --- a/packages/forms/src/patterns/date-of-birth/date-of-birth.test.ts +++ b/packages/forms/src/patterns/date-of-birth/date-of-birth.test.ts @@ -58,7 +58,7 @@ describe('DateOfBirthPattern tests', () => { if (result.success) { expect(result.data).toEqual(inputValue); } else { - throw new Error('Unexpected validation failure'); + expect.fail('Unexpected validation failure'); } }); @@ -81,7 +81,7 @@ describe('DateOfBirthPattern tests', () => { if (!result.success) { expect(result.error).toBeDefined(); } else { - throw new Error('Unexpected validation success'); + expect.fail('Unexpected validation success'); } }); @@ -101,7 +101,7 @@ describe('DateOfBirthPattern tests', () => { expect(result.data.required).toBe(true); expect(result.data.hint).toBe('Enter your date of birth'); } else { - throw new Error('Unexpected validation failure'); + expect.fail('Unexpected validation failure'); } }); @@ -119,7 +119,7 @@ describe('DateOfBirthPattern tests', () => { if (!result.success) { expect(result.error).toBeDefined(); } else { - throw new Error('Unexpected validation success'); + expect.fail('Unexpected validation success'); } }); }); diff --git a/packages/forms/src/patterns/email-input/email-input.test.ts b/packages/forms/src/patterns/email-input/email-input.test.ts new file mode 100644 index 00000000..4c984d21 --- /dev/null +++ b/packages/forms/src/patterns/email-input/email-input.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest'; +import { + createEmailSchema, + emailInputConfig, + type EmailInputPattern, +} from './email-input'; + +describe('EmailInputPattern tests', () => { + describe('createEmailSchema', () => { + it('should create schema for required email input', () => { + const data: EmailInputPattern['data'] = { + label: 'Test Email Input Label', + required: true, + }; + + const schema = createEmailSchema(data); + const validInput = { email: 'testEmail@test.com' }; + const invalidInput = { email: 'testEmail.com' }; + + expect(schema.safeParse(validInput).success).toBe(true); + expect(schema.safeParse(invalidInput).success).toBe(false); + }); + + it('should create schema for optional email input', () => { + const data: EmailInputPattern['data'] = { + label: 'Test Email Input Label', + required: false, + }; + + const schema = createEmailSchema(data); + const validInput = { email: 'testEmail@test.com' }; + const emptyInput = {}; + + expect(schema.safeParse(validInput).success).toBe(true); + expect(schema.safeParse(emptyInput).success).toBe(true); + }); + }); + + describe('emailInputConfig', () => { + it('should parse user input correctly', () => { + const pattern: EmailInputPattern = { + type: 'email-input', + id: 'test', + data: { + label: 'Test Email Input Label', + required: true, + }, + }; + + const inputValue = { email: 'testEmail@test.com' }; + if (!emailInputConfig.parseUserInput) { + expect.fail('emailInputConfig.parseUserInput is undefined'); + } + const result = emailInputConfig.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: EmailInputPattern = { + type: 'email-input', + id: 'test', + data: { + label: 'Test Email Input Label', + required: true, + }, + }; + + const inputValue = { email: 'testEmail.co' }; + if (!emailInputConfig.parseUserInput) { + expect.fail('emailInputConfig.parseUserInput is undefined'); + } + const result = emailInputConfig.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 Email Input Label', + required: true, + }; + + if (!emailInputConfig.parseConfigData) { + expect.fail('emailInputConfig.parseConfigData is undefined'); + } + const result = emailInputConfig.parseConfigData(obj); + if (result.success) { + expect(result.data.label).toBe('Test Email Input Label'); + expect(result.data.required).toBe(true); + } else { + expect.fail('Unexpected validation failure'); + } + }); + + it('should handle invalid config data', () => { + const obj = { + label: '', + required: true, + }; + + if (!emailInputConfig.parseConfigData) { + expect.fail('emailInputConfig.parseConfigData is undefined'); + } + const result = emailInputConfig.parseConfigData(obj); + if (!result.success) { + expect(result.error).toBeDefined(); + } else { + expect.fail('Unexpected validation success'); + } + }); + }); +}); diff --git a/packages/forms/src/patterns/email-input/email-input.ts b/packages/forms/src/patterns/email-input/email-input.ts new file mode 100644 index 00000000..1572706f --- /dev/null +++ b/packages/forms/src/patterns/email-input/email-input.ts @@ -0,0 +1,97 @@ +import * as z from 'zod'; + +import { type EmailInputProps } from '../../components.js'; +import { + type Pattern, + type PatternConfig, + validatePattern, +} 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(), +}); + +export type EmailInputPattern = Pattern>; + +export type EmailInputPatternOutput = z.infer< + ReturnType +>; + +export const createEmailSchema = (data: EmailInputPattern['data']) => { + const emailSchema = z + .string() + .regex( + /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/, + 'Invalid email format' + ) + .optional(); + + if (!data.required) { + return z + .object({ + email: emailSchema, + }) + .optional(); + } + + return z.object({ + email: emailSchema, + }); +}; + +export const emailInputConfig: PatternConfig< + EmailInputPattern, + EmailInputPatternOutput +> = { + displayName: 'Email Input', + iconPath: 'email-icon.svg', + initial: { + label: 'Email Input', + required: true, + }, + + parseUserInput: (pattern, inputValue) => { + console.log('TEST Parsing user input:', inputValue); + return safeZodParseToFormError(createEmailSchema(pattern.data), inputValue); + }, + + parseConfigData: obj => { + return safeZodParseFormErrors(configSchema, obj); + }, + getChildren() { + return []; + }, + + createPrompt(_, session, pattern, options) { + const extraAttributes: Record = {}; + const sessionValue = getFormSessionValue(session, pattern.id); + if (options.validate) { + const isValidResult = validatePattern( + emailInputConfig, + pattern, + sessionValue + ); + if (!isValidResult.success) { + extraAttributes['error'] = isValidResult.error; + } + } + + return { + props: { + _patternId: pattern.id, + type: 'email-input', + label: pattern.data.label, + emailId: `${pattern.id}.email`, + required: pattern.data.required, + ...extraAttributes, + } as EmailInputProps, + children: [], + }; + }, +}; diff --git a/packages/forms/src/patterns/index.ts b/packages/forms/src/patterns/index.ts index 4fe5dbcd..bb58d988 100644 --- a/packages/forms/src/patterns/index.ts +++ b/packages/forms/src/patterns/index.ts @@ -1,8 +1,10 @@ import { type FormConfig } from '../pattern.js'; +import { attachmentConfig } from './attachment/index.js'; import { addressConfig } from './address/index.js'; import { checkboxConfig } from './checkbox.js'; 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 { inputConfig } from './input/index.js'; @@ -10,10 +12,12 @@ import { packageDownloadConfig } from './package-download/index.js'; import { pageConfig } from './page/index.js'; import { pageSetConfig } from './page-set/index.js'; import { paragraphConfig } from './paragraph.js'; +import { phoneNumberConfig } from './phone-number/phone-number.js'; import { radioGroupConfig } from './radio-group.js'; import { richTextConfig } from './rich-text.js'; import { selectDropdownConfig } from './select-dropdown/select-dropdown.js'; import { sequenceConfig } from './sequence.js'; +import { socialSecurityNumberConfig } from './social-security-number/social-security-number.js'; // This configuration reflects what a user of this library would provide for // their usage scenarios. For now, keep here in the form service until we @@ -21,8 +25,10 @@ import { sequenceConfig } from './sequence.js'; export const defaultFormConfig: FormConfig = { patterns: { address: addressConfig, + attachment: attachmentConfig, checkbox: checkboxConfig, 'date-of-birth': dateOfBirthConfig, + 'email-input': emailInputConfig, fieldset: fieldsetConfig, 'form-summary': formSummaryConfig, input: inputConfig, @@ -30,16 +36,22 @@ export const defaultFormConfig: FormConfig = { page: pageConfig, 'page-set': pageSetConfig, paragraph: paragraphConfig, - 'rich-text': richTextConfig, + 'phone-number': phoneNumberConfig, 'radio-group': radioGroupConfig, + 'rich-text': richTextConfig, 'select-dropdown': selectDropdownConfig, + 'social-security-number': socialSecurityNumberConfig, sequence: sequenceConfig, }, } as const; +export * from './attachment/index.js'; +export { type AttachmentPattern } from './attachment/config.js'; +export * from './attachment/file-type-options.js'; export * from './address/index.js'; export * from './checkbox.js'; export * from './date-of-birth/date-of-birth.js'; +export * from './email-input/email-input.js'; export * from './fieldset/index.js'; export { type FieldsetPattern } from './fieldset/config.js'; export * from './form-summary.js'; @@ -51,6 +63,8 @@ export { type PagePattern } from './page/config.js'; export * from './page-set/index.js'; export { type PageSetPattern } from './page-set/config.js'; export * from './paragraph.js'; +export * from './phone-number/phone-number.js'; export * from './radio-group.js'; export * from './select-dropdown/select-dropdown.js'; +export * from './social-security-number/social-security-number.js'; export * from './sequence.js'; diff --git a/packages/forms/src/patterns/package-download/submit.test.ts b/packages/forms/src/patterns/package-download/submit.test.ts index 53d57a9e..c0815231 100644 --- a/packages/forms/src/patterns/package-download/submit.test.ts +++ b/packages/forms/src/patterns/package-download/submit.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { failure } from '@atj/common'; +import { failure, success } from '@atj/common'; import { type Blueprint, type FormSession, defaultFormConfig } from '../..'; @@ -18,11 +18,17 @@ describe('downloadPackageHandler', async () => { data: { errors: {}, values: {} }, route: { url: '#', params: {} }, }; - const result = await downloadPackageHandler(defaultFormConfig, { - pattern: new PackageDownload({ text: 'Download now!' }).toPattern(), - session, - data: {}, - }); + const result = await downloadPackageHandler( + { + config: defaultFormConfig, + getDocument: () => Promise.resolve(failure('Document not found')), + }, + { + pattern: new PackageDownload({ text: 'Download now!' }).toPattern(), + session, + data: {}, + } + ); expect(result).toEqual(failure('Form is not complete')); }); @@ -38,11 +44,25 @@ describe('downloadPackageHandler', async () => { }, route: { url: '#', params: {} }, }; - const result = await downloadPackageHandler(defaultFormConfig, { - pattern: new PackageDownload({ text: 'Download now!' }).toPattern(), - session, - data: {}, - }); + const result = await downloadPackageHandler( + { + config: defaultFormConfig, + getDocument: async () => + success({ + id: 'id', + data: await loadSamplePDF( + 'doj-pardon-marijuana/application_for_certificate_of_pardon_for_simple_marijuana_possession.pdf' + ), + path: 'test.pdf', + fields: {}, + }), + }, + { + pattern: new PackageDownload({ text: 'Download now!' }).toPattern(), + session, + data: {}, + } + ); expect(result).toEqual( expect.objectContaining({ success: true, @@ -83,8 +103,8 @@ const createTestForm = async (): Promise => { }, outputs: [ { + id: 'test-id', path: 'test.pdf', - data: new Uint8Array(pdfBytes), fields: { [input1.id]: { type: 'TextField', diff --git a/packages/forms/src/patterns/package-download/submit.ts b/packages/forms/src/patterns/package-download/submit.ts index ad240a71..462db543 100644 --- a/packages/forms/src/patterns/package-download/submit.ts +++ b/packages/forms/src/patterns/package-download/submit.ts @@ -1,6 +1,6 @@ -import { failure, success } from '@atj/common'; +import { failure, success, type Result } from '@atj/common'; -import { type Blueprint } from '../..'; +import { type Blueprint, type FormOutput } from '../..'; import { createFormOutputFieldData, fillPDF } from '../../documents'; import { sessionIsComplete } from '../../session'; import { type SubmitHandler } from '../../submission'; @@ -9,17 +9,38 @@ import { type PackageDownloadPattern } from './index'; export const downloadPackageHandler: SubmitHandler< PackageDownloadPattern -> = async (config, opts) => { - if (!sessionIsComplete(config, opts.session)) { +> = async (context, opts) => { + if (!sessionIsComplete(context.config, opts.session)) { return failure('Form is not complete'); } + const outputsResult: Result<(FormOutput & { data: Uint8Array })[]> = + await Promise.all( + opts.session.form.outputs.map(async output => { + const doc = await context.getDocument(output.id); + if (!doc.success) { + throw new Error(doc.error); + } + return { + id: output.id, + path: doc.data.path, + fields: output.fields, + formFields: output.formFields, + data: doc.data.data, + }; + }) + ) + .then(values => success(values)) + .catch(error => failure(error)); + if (!outputsResult.success) { + return failure(outputsResult.error); + } + const documentsResult = await generateDocumentPackage( - opts.session.form, + outputsResult.data, opts.session.data.values ); if (!documentsResult.success) { - console.log('values', opts.session.data.values); return failure(documentsResult.error); } @@ -30,12 +51,12 @@ export const downloadPackageHandler: SubmitHandler< }; const generateDocumentPackage = async ( - form: Blueprint, + outputs: (FormOutput & { data: Uint8Array })[], formData: Record ) => { const errors = new Array(); const documents = new Array<{ fileName: string; data: Uint8Array }>(); - for (const document of form.outputs) { + for (const document of outputs) { const docFieldData = createFormOutputFieldData(document, formData); const pdfDocument = await fillPDF(document.data, docFieldData); if (!pdfDocument.success) { diff --git a/packages/forms/src/patterns/page-set/submit.test.ts b/packages/forms/src/patterns/page-set/submit.test.ts index 0cd041ed..bd186e7b 100644 --- a/packages/forms/src/patterns/page-set/submit.test.ts +++ b/packages/forms/src/patterns/page-set/submit.test.ts @@ -7,17 +7,27 @@ import { createFormSession } from '../../session'; import { PageSet } from './builder'; import { submitPage } from './submit'; +import { success } from '@atj/common'; describe('Page-set submission', () => { it('stores session data for valid page data', async () => { const session = createTestSession(); - const result = await submitPage(defaultFormConfig, { - pattern: session.form.patterns['page-set-1'], - session, - data: { - 'input-1': 'test', + const result = await submitPage( + { + config: defaultFormConfig, + getDocument: () => + Promise.resolve( + success({ id: 'id', data: new Uint8Array(), path: '', fields: {} }) + ), }, - }); + { + pattern: session.form.patterns['page-set-1'], + session, + data: { + 'input-1': 'test', + }, + } + ); expect(result).toEqual({ data: { session: { @@ -43,13 +53,22 @@ describe('Page-set submission', () => { it('stores session data for invalid page data', async () => { const session = createTestSession(); - const result = await submitPage(defaultFormConfig, { - pattern: session.form.patterns['page-set-1'], - session, - data: { - 'input-1': '', + const result = await submitPage( + { + config: defaultFormConfig, + getDocument: () => + Promise.resolve( + success({ id: 'id', data: new Uint8Array(), path: '', fields: {} }) + ), }, - }); + { + pattern: session.form.patterns['page-set-1'], + session, + data: { + 'input-1': '', + }, + } + ); expect(result).toEqual({ data: { session: { @@ -80,21 +99,30 @@ describe('Page-set submission', () => { it('terminates on the last page', async () => { const session = createTestSession(); - const result = await submitPage(defaultFormConfig, { - pattern: session.form.patterns['page-set-1'], - session: { - ...session, - route: { - url: '#', - params: { - page: '1', + const result = await submitPage( + { + config: defaultFormConfig, + getDocument: () => + Promise.resolve( + success({ id: 'id', data: new Uint8Array(), path: '', fields: {} }) + ), + }, + { + pattern: session.form.patterns['page-set-1'], + session: { + ...session, + route: { + url: '#', + params: { + page: '1', + }, }, }, - }, - data: { - 'input-2': 'test', - }, - }); + data: { + 'input-2': 'test', + }, + } + ); expect(result).toEqual({ data: { session: { diff --git a/packages/forms/src/patterns/page-set/submit.ts b/packages/forms/src/patterns/page-set/submit.ts index 9cadb644..e6e2aee6 100644 --- a/packages/forms/src/patterns/page-set/submit.ts +++ b/packages/forms/src/patterns/page-set/submit.ts @@ -3,8 +3,8 @@ import { failure, success } from '@atj/common'; import { getPatternConfig, getPatternSafely, - validatePatternAndChildren, -} from '../../pattern'; + aggregatePatternSessionValues, +} from '../../pattern.js'; import { type FormSession } from '../../session'; import { type SubmitHandler } from '../../submission'; import { type PagePattern } from '../page/config'; @@ -16,7 +16,7 @@ const getPage = (formSession: FormSession) => { }; export const submitPage: SubmitHandler = async ( - config, + context, opts ) => { const pageNumber = getPage(opts.session); @@ -25,7 +25,7 @@ export const submitPage: SubmitHandler = async ( return failure(`Page ${pageNumber} does not exist`); } - const pagePatternConfig = getPatternConfig(config, 'page'); + const pagePatternConfig = getPatternConfig(context.config, 'page'); const pagePattern = getPatternSafely({ type: 'page', form: opts.session.form, @@ -35,12 +35,16 @@ export const submitPage: SubmitHandler = async ( return failure(pagePattern.error); } - const result = validatePatternAndChildren( - config, + const result = aggregatePatternSessionValues( + context.config, opts.session.form, pagePatternConfig, pagePattern.data, - opts.data + opts.data, + { + values: { ...opts.session.data.values }, + errors: { ...opts.session.data.errors }, + } ); // Increment the page number if there are no errors and this isn't the last page. @@ -53,17 +57,7 @@ export const submitPage: SubmitHandler = async ( return success({ session: { ...opts.session, - data: { - ...opts.session.data, - values: { - ...opts.session.data.values, - ...result.values, - }, - errors: { - ...opts.session.data.errors, - ...result.errors, - }, - }, + data: result, route: opts.session.route ? { ...opts.session.route, diff --git a/packages/forms/src/patterns/phone-number/phone-number.test.ts b/packages/forms/src/patterns/phone-number/phone-number.test.ts new file mode 100644 index 00000000..8d5ab3b1 --- /dev/null +++ b/packages/forms/src/patterns/phone-number/phone-number.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from 'vitest'; +import { + createPhoneSchema, + phoneNumberConfig, + type PhoneNumberPattern, +} from './phone-number'; + +describe('PhoneNumberPattern tests', () => { + describe('createPhoneSchema', () => { + it('should create schema for required phone input', () => { + const data: PhoneNumberPattern['data'] = { + label: 'Test Phone Input Label', + required: true, + }; + + const schema = createPhoneSchema(data); + const validInput = '+12223334444'; + const invalidInput = '123456abc'; + + expect(schema.safeParse(validInput).success).toBe(true); + const invalidResult = schema.safeParse(invalidInput); + expect(invalidResult.success).toBe(false); + expect(invalidResult.error?.issues[0].message).toBe( + 'Phone number may only contain digits, spaces, parentheses, hyphens, and periods.' + ); + }); + + it('should create schema for optional phone input', () => { + const data: PhoneNumberPattern['data'] = { + label: 'Test Phone Input Label', + required: false, + }; + + const schema = createPhoneSchema(data); + const validInput = '+12223334444'; + const emptyInput = ''; + const invalidInput = '123456abc'; + + expect(schema.safeParse(validInput).success).toBe(true); + expect(schema.safeParse(emptyInput).success).toBe(true); + + const invalidResult = schema.safeParse(invalidInput); + expect(invalidResult.success).toBe(false); + expect(invalidResult.error?.issues[0].message).toBe( + 'Phone number may only contain digits, spaces, parentheses, hyphens, and periods.' + ); + }); + + it('should fail with less than 10 digits', () => { + const data: PhoneNumberPattern['data'] = { + label: 'Test Phone Input Label', + required: true, + }; + + const schema = createPhoneSchema(data); + const shortInput = '123456789'; + + const shortInputResult = schema.safeParse(shortInput); + expect(shortInputResult.success).toBe(false); + expect(shortInputResult.error?.issues[0].message).toBe( + 'Phone number must contain at least 10 digits' + ); + }); + }); + + describe('phoneNumberConfig', () => { + it('should parse user input correctly', () => { + const pattern: PhoneNumberPattern = { + type: 'phone-number', + id: 'test', + data: { + label: 'Test Phone Input Label', + required: true, + }, + }; + + const inputValue = '+12223334444'; + if (!phoneNumberConfig.parseUserInput) { + expect.fail('phoneNumberConfig.parseUserInput is undefined'); + } + const result = phoneNumberConfig.parseUserInput(pattern, inputValue); + if (result.success) { + expect(result.data).toBe(inputValue); + } else { + expect.fail('Unexpected validation failure'); + } + }); + + it('should handle validation error for user input', () => { + const pattern: PhoneNumberPattern = { + type: 'phone-number', + id: 'test', + data: { + label: 'Test Phone Input Label', + required: true, + }, + }; + + const invalidInput = '123456abc'; + if (!phoneNumberConfig.parseUserInput) { + expect.fail('phoneNumberConfig.parseUserInput is undefined'); + } + const result = phoneNumberConfig.parseUserInput(pattern, invalidInput); + if (!result.success) { + expect(result.error).toBeDefined(); + expect(result.error?.message).toContain( + 'Phone number may only contain digits, spaces, parentheses, hyphens, and periods.' + ); + } else { + expect.fail('Unexpected validation success'); + } + }); + + it('should parse config data correctly', () => { + const obj = { + label: 'Test Phone Input Label', + required: true, + }; + + if (!phoneNumberConfig.parseConfigData) { + expect.fail('phoneNumberConfig.parseConfigData is undefined'); + } + const result = phoneNumberConfig.parseConfigData(obj); + if (result.success) { + expect(result.data.label).toBe('Test Phone Input Label'); + expect(result.data.required).toBe(true); + } else { + expect.fail('Unexpected validation failure'); + } + }); + + it('should handle invalid config data', () => { + const obj = { + label: '', + required: true, + }; + + if (!phoneNumberConfig.parseConfigData) { + expect.fail('phoneNumberConfig.parseConfigData is undefined'); + } + const result = phoneNumberConfig.parseConfigData(obj); + if (!result.success) { + expect(result.error).toBeDefined(); + } else { + expect.fail('Unexpected validation success'); + } + }); + }); +}); diff --git a/packages/forms/src/patterns/phone-number/phone-number.ts b/packages/forms/src/patterns/phone-number/phone-number.ts new file mode 100644 index 00000000..ba842720 --- /dev/null +++ b/packages/forms/src/patterns/phone-number/phone-number.ts @@ -0,0 +1,107 @@ +import * as z from 'zod'; + +import { type PhoneNumberProps } from '../../components.js'; +import { + type Pattern, + type PatternConfig, + validatePattern, +} 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(), +}); + +export type PhoneNumberPattern = Pattern>; + +export type PhoneNumberPatternOutput = z.infer< + ReturnType +>; + +export const createPhoneSchema = (data: PhoneNumberPattern['data']) => { + let phoneSchema = z + .string() + .regex( + /^[\d+(). -]{1,}$/, + 'Phone number may only contain digits, spaces, parentheses, hyphens, and periods.' + ) + .refine(value => { + const stripped = value.replace(/[^\d]/g, ''); + return stripped.length >= 10; + }, 'Phone number must contain at least 10 digits'); + + if (!data.required) { + // Allow empty strings for optional fields + return phoneSchema.or(z.literal('').optional()).optional(); + } + + return phoneSchema; +}; + +export const phoneNumberConfig: PatternConfig< + PhoneNumberPattern, + PhoneNumberPatternOutput +> = { + displayName: 'Phone Number', + iconPath: 'phone-icon.svg', + initial: { + label: 'Phone Number', + required: true, + hint: '10-digit, U.S. only, for example 999-999-9999', + }, + + parseUserInput: (pattern, inputValue) => { + const result = safeZodParseToFormError( + createPhoneSchema(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 error = session.data.errors[pattern.id]; + + /* + if (options.validate) { + const isValidResult = validatePattern( + phoneNumberConfig, + pattern, + sessionValue + ); + if (!isValidResult.success) { + extraAttributes['error'] = isValidResult.error; + } + } + */ + + return { + props: { + _patternId: pattern.id, + type: 'phone-number', + label: pattern.data.label, + phoneId: pattern.id, + required: pattern.data.required, + hint: pattern.data.hint, + value: sessionValue, + error, + ...extraAttributes, + } as PhoneNumberProps, + children: [], + }; + }, +}; diff --git a/packages/forms/src/patterns/select-dropdown/select-dropdown.test.ts b/packages/forms/src/patterns/select-dropdown/select-dropdown.test.ts index 3d728330..abe653b0 100644 --- a/packages/forms/src/patterns/select-dropdown/select-dropdown.test.ts +++ b/packages/forms/src/patterns/select-dropdown/select-dropdown.test.ts @@ -79,7 +79,7 @@ describe('SelectDropdownPattern tests', () => { if (result.success) { expect(result.data).toBe('value1'); } else { - throw new Error('Unexpected validation failure'); + expect.fail('Unexpected validation failure'); } }); @@ -105,11 +105,11 @@ describe('SelectDropdownPattern tests', () => { console.log('Test parse result (error case):', result); if (!result.success) { expect(result.error).toBeDefined(); - expect(result.error.message).toBe( + expect(result.error?.message).toBe( "Invalid enum value. Expected 'value1' | 'value2', received 'invalid'" ); } else { - throw new Error('Unexpected validation success'); + expect.fail('Unexpected validation success'); } }); @@ -132,7 +132,7 @@ describe('SelectDropdownPattern tests', () => { expect(result.data.required).toBe(true); expect(result.data.options.length).toBe(2); } else { - throw new Error('Unexpected validation failure'); + expect.fail('Unexpected validation failure'); } }); @@ -150,7 +150,7 @@ describe('SelectDropdownPattern tests', () => { if (!result.success) { expect(result.error).toBeDefined(); } else { - throw new Error('Unexpected validation success'); + expect.fail('Unexpected validation success'); } }); }); diff --git a/packages/forms/src/patterns/social-security-number/social-security-number.test.ts b/packages/forms/src/patterns/social-security-number/social-security-number.test.ts new file mode 100644 index 00000000..fad769be --- /dev/null +++ b/packages/forms/src/patterns/social-security-number/social-security-number.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from 'vitest'; +import { + createSSNSchema, + socialSecurityNumberConfig, + type SocialSecurityNumberPattern, +} from './social-security-number'; + +describe('SocialSecurityNumberPattern tests', () => { + describe('createSSNSchema', () => { + it('should create schema for required SSN input', () => { + const data: SocialSecurityNumberPattern['data'] = { + label: 'Test SSN Input Label', + required: true, + }; + + const schema = createSSNSchema(data); + const validInput = '555-11-1234'; + const invalidInput = '444-44-56as'; + + expect(schema.safeParse(validInput).success).toBe(true); + + const invalidResult = schema.safeParse(invalidInput); + expect(invalidResult.success).toBe(false); + expect(invalidResult.error?.issues[0].message).toBe( + 'Social Security Number must have exactly 9 digits' + ); + }); + + it('should create schema for optional SSN input', () => { + const data: SocialSecurityNumberPattern['data'] = { + label: 'Test SSN Input Label', + required: false, + }; + + const schema = createSSNSchema(data); + const validInput = '555-11-1234'; + const emptyInput = ''; + const invalidInput = '444-44-56as'; + + expect(schema.safeParse(validInput).success).toBe(true); + expect(schema.safeParse(emptyInput).success).toBe(true); + + const invalidResult = schema.safeParse(invalidInput); + expect(invalidResult.success).toBe(false); + expect(invalidResult.error?.issues[0].message).toBe( + 'Social Security Number must have exactly 9 digits' + ); + }); + + it('should fail with less than 9 digits', () => { + const data: SocialSecurityNumberPattern['data'] = { + label: 'Test SSN Input Label', + required: true, + }; + + const schema = createSSNSchema(data); + const shortInput = '123-45-678'; + + const shortInputResult = schema.safeParse(shortInput); + expect(shortInputResult.success).toBe(false); + expect(shortInputResult.error?.issues[0].message).toBe( + 'Social Security Number must have exactly 9 digits' + ); + }); + + it('should fail with invalid SSN prefixes', () => { + const data: SocialSecurityNumberPattern['data'] = { + label: 'Test SSN Input Label', + required: true, + }; + + const schema = createSSNSchema(data); + const invalidSSNs = ['966-45-6789', '666-45-6789', '000-12-3456']; + + invalidSSNs.forEach(ssn => { + const result = schema.safeParse(ssn); + expect(result.success).toBe(false); + expect(result.error?.issues[0].message).toBe( + 'Social Security Number must start with a valid prefix (not 9, 666, or 000)' + ); + }); + }); + + it('should fail with invalid middle and suffix digits', () => { + const data: SocialSecurityNumberPattern['data'] = { + label: 'Test SSN Input Label', + required: true, + }; + + const schema = createSSNSchema(data); + const invalidSSNs = ['555-00-6789', '555-12-0000']; + + invalidSSNs.forEach(ssn => { + const result = schema.safeParse(ssn); + expect(result.success).toBe(false); + const errorMessage = result.error?.issues[0].message; + if (ssn === '555-00-6789') { + expect(errorMessage).toBe( + 'Social Security Number must have a valid middle segment (not 00)' + ); + } else if (ssn === '555-12-0000') { + expect(errorMessage).toBe( + 'Social Security Number must have a valid suffix (not 0000)' + ); + } + }); + }); + }); + + describe('socialSecurityNumberConfig', () => { + it('should parse user input correctly', () => { + const pattern: SocialSecurityNumberPattern = { + type: 'social-security-number', + id: 'test', + data: { + label: 'Test SSN Input Label', + required: true, + }, + }; + + const inputValue = '555-11-1234'; + if (!socialSecurityNumberConfig.parseUserInput) { + expect.fail('socialSecurityNumberConfig.parseUserInput is undefined'); + } + const result = socialSecurityNumberConfig.parseUserInput( + pattern, + inputValue + ); + if (result.success) { + expect(result.data).toBe(inputValue); + } else { + expect.fail('Unexpected validation failure'); + } + }); + + it('should handle validation error for user input', () => { + const pattern: SocialSecurityNumberPattern = { + type: 'social-security-number', + id: 'test', + data: { + label: 'Test SSN Input Label', + required: true, + }, + }; + + const invalidInput = '444-44-56as'; + if (!socialSecurityNumberConfig.parseUserInput) { + expect.fail('socialSecurityNumberConfig.parseUserInput is undefined'); + } + const result = socialSecurityNumberConfig.parseUserInput( + pattern, + invalidInput + ); + if (!result.success) { + expect(result.error).toBeDefined(); + expect(result.error?.message).toContain( + 'Social Security Number must have exactly 9 digits' + ); + } else { + expect.fail('Unexpected validation success'); + } + }); + + it('should parse config data correctly', () => { + const obj = { + label: 'Test SSN Input Label', + required: true, + }; + + if (!socialSecurityNumberConfig.parseConfigData) { + expect.fail('socialSecurityNumberConfig.parseConfigData is undefined'); + } + const result = socialSecurityNumberConfig.parseConfigData(obj); + if (result.success) { + expect(result.data.label).toBe('Test SSN Input Label'); + expect(result.data.required).toBe(true); + } else { + expect.fail('Unexpected validation failure'); + } + }); + + it('should handle invalid config data', () => { + const obj = { + label: '', + required: true, + }; + + if (!socialSecurityNumberConfig.parseConfigData) { + expect.fail('socialSecurityNumberConfig.parseConfigData is undefined'); + } + const result = socialSecurityNumberConfig.parseConfigData(obj); + if (!result.success) { + expect(result.error).toBeDefined(); + } else { + expect.fail('Unexpected validation success'); + } + }); + }); +}); diff --git a/packages/forms/src/patterns/social-security-number/social-security-number.ts b/packages/forms/src/patterns/social-security-number/social-security-number.ts new file mode 100644 index 00000000..a03b550d --- /dev/null +++ b/packages/forms/src/patterns/social-security-number/social-security-number.ts @@ -0,0 +1,134 @@ +import * as z from 'zod'; +import { type SocialSecurityNumberProps } 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(), +}); + +export type SocialSecurityNumberPattern = Pattern>; + +export type SocialSecurityNumberPatternOutput = z.infer< + ReturnType +>; + +export const createSSNSchema = (data: SocialSecurityNumberPattern['data']) => { + const baseSchema = z + .string() + .transform(value => value.replace(/[^0-9]/g, '')) + .superRefine((value, ctx) => { + if (!data.required && value === '') { + return; + } + + let issues = []; + + if (value.length !== 9) { + issues.push('have exactly 9 digits'); + } else { + if ( + value.startsWith('9') || + value.startsWith('666') || + value.startsWith('000') + ) { + issues.push('start with a valid prefix (not 9, 666, or 000)'); + } + + if (value.slice(3, 5) === '00') { + issues.push('have a valid middle segment (not 00)'); + } + + if (value.slice(5) === '0000') { + issues.push('have a valid suffix (not 0000)'); + } + } + + if (issues.length > 0) { + let enhancedMessage = 'Social Security Number must '; + if (issues.length === 1) { + enhancedMessage += issues[0]; + } else if (issues.length === 2) { + enhancedMessage += `${issues[0]} and ${issues[1]}`; + } else { + enhancedMessage += `${issues.slice(0, -1).join(', ')}, and ${issues[issues.length - 1]}`; + } + + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: enhancedMessage, + }); + } + }); + + if (data.required) { + return z + .string() + .refine(value => value.trim().length > 0, { + message: 'This field is required', + }) + .superRefine((value, ctx) => { + const result = baseSchema.safeParse(value.trim()); + if (!result.success) { + result.error.issues.forEach(issue => ctx.addIssue(issue)); + } + }); + } else { + return baseSchema.optional(); + } +}; + +export const socialSecurityNumberConfig: PatternConfig< + SocialSecurityNumberPattern, + SocialSecurityNumberPatternOutput +> = { + displayName: 'Social Security Number', + iconPath: 'ssn-icon.svg', + initial: { + label: 'Social Security Number', + required: true, + hint: 'For example, 555-11-0000', + }, + + parseUserInput: (pattern, inputValue) => { + const result = safeZodParseToFormError( + createSSNSchema(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 error = session.data.errors[pattern.id]; + + return { + props: { + _patternId: pattern.id, + type: 'social-security-number', + label: pattern.data.label, + ssnId: pattern.id, + required: pattern.data.required, + hint: pattern.data.hint, + value: sessionValue, + error, + ...extraAttributes, + } as SocialSecurityNumberProps, + children: [], + }; + }, +}; diff --git a/packages/forms/src/repository/add-document.test.ts b/packages/forms/src/repository/add-document.test.ts new file mode 100644 index 00000000..6786d16c --- /dev/null +++ b/packages/forms/src/repository/add-document.test.ts @@ -0,0 +1,33 @@ +import { beforeAll, expect, it, vi } from 'vitest'; + +import { type DbTestContext, describeDatabase } from '@atj/database/testing'; +import { addDocument } from './add-document.js'; +import type { ParsedPdf } from '../documents/pdf/parsing-api.js'; +import type { DocumentFieldMap } from '../documents/types.js'; +import { defaultFormConfig } from '../patterns/index.js'; + +describeDatabase('add document', () => { + const today = new Date(2000, 1, 1); + + beforeAll(async () => { + vi.setSystemTime(today); + }); + + it('works', async ({ db }) => { + const result = await addDocument( + { db: db.ctx, formConfig: defaultFormConfig }, + { + fileName: 'file.pdf', + data: new Uint8Array([1, 2, 3]), + extract: { + parsedPdf: {} as ParsedPdf, + fields: {} as DocumentFieldMap, + }, + } + ); + if (result.success === false) { + expect.fail(`addDocument failed: ${result.error}`); + } + expect(result.data.id).toBeTypeOf('string'); + }); +}); diff --git a/packages/forms/src/repository/add-document.ts b/packages/forms/src/repository/add-document.ts new file mode 100644 index 00000000..7945cb6e --- /dev/null +++ b/packages/forms/src/repository/add-document.ts @@ -0,0 +1,39 @@ +import { type Result, failure, success } from '@atj/common'; + +import type { ParsedPdf } from '../documents/pdf/parsing-api'; +import type { DocumentFieldMap } from '../documents/types'; +import type { FormRepositoryContext } from '.'; + +export type AddDocument = ( + ctx: FormRepositoryContext, + document: { + fileName: string; + data: Uint8Array; + extract: { + parsedPdf: ParsedPdf; + fields: DocumentFieldMap; + }; + } +) => Promise>; + +export const addDocument: AddDocument = async (ctx, document) => { + const uuid = crypto.randomUUID(); + const db = await ctx.db.getKysely(); + + return await db + .insertInto('form_documents') + .values({ + id: uuid, + type: 'pdf', + file_name: document.fileName, + data: Buffer.from(document.data), + extract: JSON.stringify(document.extract), + }) + .execute() + .then(() => + success({ + id: uuid, + }) + ) + .catch(err => failure(err.message)); +}; diff --git a/packages/forms/src/repository/add-form.test.ts b/packages/forms/src/repository/add-form.test.ts index 17f8b5c7..1e815b68 100644 --- a/packages/forms/src/repository/add-form.test.ts +++ b/packages/forms/src/repository/add-form.test.ts @@ -2,6 +2,7 @@ import { beforeAll, expect, it, vi } from 'vitest'; import { type DbTestContext, describeDatabase } from '@atj/database/testing'; import { addForm } from './add-form.js'; +import { defaultFormConfig } from '../patterns/index.js'; describeDatabase('add form', () => { const today = new Date(2000, 1, 1); @@ -11,7 +12,10 @@ describeDatabase('add form', () => { }); it('works', async ({ db }) => { - const result = await addForm(db.ctx, testForm); + const result = await addForm( + { db: db.ctx, formConfig: defaultFormConfig }, + testForm + ); if (result.success === false) { expect.fail('addForm failed'); } diff --git a/packages/forms/src/repository/add-form.ts b/packages/forms/src/repository/add-form.ts index 34370752..c272f2c7 100644 --- a/packages/forms/src/repository/add-form.ts +++ b/packages/forms/src/repository/add-form.ts @@ -1,22 +1,21 @@ import { type Result, failure, success } from '@atj/common'; -import { type DatabaseContext } from '@atj/database'; import { type Blueprint } from '../index.js'; -import { stringifyForm } from './serialize.js'; +import type { FormRepositoryContext } from './index.js'; export type AddForm = ( - ctx: DatabaseContext, + ctx: FormRepositoryContext, form: Blueprint ) => Promise>; export const addForm: AddForm = async (ctx, form) => { const uuid = crypto.randomUUID(); - const db = await ctx.getKysely(); + const db = await ctx.db.getKysely(); return db .insertInto('forms') .values({ id: uuid, - data: stringifyForm(form), + data: JSON.stringify(form), }) .execute() .then(() => diff --git a/packages/forms/src/repository/delete-form.test.ts b/packages/forms/src/repository/delete-form.test.ts index 5b104bb6..b1519162 100644 --- a/packages/forms/src/repository/delete-form.test.ts +++ b/packages/forms/src/repository/delete-form.test.ts @@ -1,7 +1,15 @@ import { beforeAll, expect, it, vi } from 'vitest'; +import type { Result } from '@atj/common'; import { type DbTestContext, describeDatabase } from '@atj/database/testing'; + +import { createTestBlueprint } from '../builder/builder.test.js'; +import type { Blueprint } from '../types.js'; + +import { addDocument } from './add-document.js'; +import { addForm } from './add-form.js'; import { deleteForm } from './delete-form.js'; +import { defaultFormConfig } from '../patterns/index.js'; describeDatabase('delete form', () => { const today = new Date(2000, 1, 1); @@ -21,11 +29,11 @@ describeDatabase('delete form', () => { .execute(); const result = await deleteForm( - db.ctx, + { db: db.ctx, formConfig: defaultFormConfig }, '45c66187-64e2-4d75-a45a-e80f1d035bc5' ); if (result.success === false) { - expect.fail('addForm failed'); + expect.fail(`addForm failed: ${result.error}`); } const selectResult = await kysely @@ -40,9 +48,123 @@ describeDatabase('delete form', () => { it('fails with invalid form ID', async ({ db }) => { const result = await deleteForm( - db.ctx, + { db: db.ctx, formConfig: defaultFormConfig }, '45c66187-64e2-4d75-a45a-e80f1d035bc5' ); expect(result.success).toBe(false); }); + + it('removes associated documents', async ({ db }) => { + // Setup + const { id: id1 } = await ensure( + await addDocument( + { db: db.ctx, formConfig: defaultFormConfig }, + { + fileName: 'test1.pdf', + data: new Uint8Array(), + extract: { + parsedPdf: { + patterns: {}, + errors: [], + outputs: {}, + root: 'root', + title: 'Test form', + description: 'Test description', + }, + fields: {}, + }, + } + ), + 'Failed to add test document' + ); + const { id: id2 } = await ensure( + await addDocument( + { db: db.ctx, formConfig: defaultFormConfig }, + { + fileName: 'test2.pdf', + data: new Uint8Array(), + extract: { + parsedPdf: { + patterns: {}, + errors: [], + outputs: {}, + root: 'root', + title: 'Test form', + description: 'Test description', + }, + fields: {}, + }, + } + ), + 'Failed to add test document' + ); + const form = createTestBlueprint(); + const { id: formId } = await ensure( + await addForm( + { db: db.ctx, formConfig: defaultFormConfig }, + { + ...form, + outputs: [ + { id: id1, path: 'test1.pdf', fields: {}, formFields: {} }, + { id: id2, path: 'test2.pdf', fields: {}, formFields: {} }, + ], + } + ), + 'Failed to add test form' + ); + + // Test + const result = await deleteForm( + { db: db.ctx, formConfig: defaultFormConfig }, + formId + ); + + // Assert + expect(result).toEqual({ success: true }); + // Select the count of rows in the form_documents table: + const kysely = await db.ctx.getKysely(); + const selectResult = await kysely + .selectFrom('form_documents') + .select(kysely.fn.count('id').as('count')) + .executeTakeFirst(); + expect(Number(selectResult?.count)).toEqual(0); + }); }); + +const ensure = (result: Result, message: string = 'Ensure failure') => { + if (result.success === false) { + expect.fail(`${message}: ${result.error}`); + } + return result.data; +}; + +const TEST_FORM: Blueprint = { + summary: { + title: 'Test form', + description: 'Test description', + }, + root: 'root', + patterns: { + root: { + type: 'sequence', + id: 'root', + data: { + patterns: [], + }, + }, + }, + outputs: [ + { + id: '1', + path: 'test1.pdf', + fields: {}, + formFields: {}, + }, + { + id: '2', + path: 'test2.pdf', + fields: {}, + formFields: {}, + }, + ], +}; diff --git a/packages/forms/src/repository/delete-form.ts b/packages/forms/src/repository/delete-form.ts index 0a78c835..022b5539 100644 --- a/packages/forms/src/repository/delete-form.ts +++ b/packages/forms/src/repository/delete-form.ts @@ -1,23 +1,45 @@ -import { type VoidResult, failure } from '@atj/common'; +import { type VoidResult, failure, voidSuccess } from '@atj/common'; -import { type DatabaseContext } from '@atj/database'; +import type { FormOutput } from '../types'; +import type { FormRepositoryContext } from '.'; export type DeleteForm = ( - ctx: DatabaseContext, + ctx: FormRepositoryContext, formId: string -) => Promise; +) => Promise>; export const deleteForm: DeleteForm = async (ctx, formId) => { - const db = await ctx.getKysely(); + const db = await ctx.db.getKysely(); - const deleteResult = await db - .deleteFrom('forms') - .where('id', '=', formId) - .execute(); + const result = await db.transaction().execute(async trx => { + const deleteResult = await trx + .deleteFrom('forms') + .where('id', '=', formId) + .returning('data') + .executeTakeFirst(); - if (!deleteResult[0].numDeletedRows) { - return failure('form not found'); - } + if (!deleteResult) { + return failure({ message: 'form not found', code: 'not-found' as const }); + } - return { success: true }; + const form = JSON.parse(deleteResult.data); + const documentIds: string[] = form.outputs.map( + (output: FormOutput) => output.id + ); + + if (documentIds.length === 0) { + return voidSuccess; + } + + return await trx + .deleteFrom('form_documents') + .where('id', 'in', documentIds) + .execute() + .then(_ => voidSuccess) + .catch((error: Error) => { + return failure({ message: error.message, code: 'unknown' as const }); + }); + }); + + return result; }; diff --git a/packages/forms/src/repository/get-document.test.ts b/packages/forms/src/repository/get-document.test.ts new file mode 100644 index 00000000..eb784ea8 --- /dev/null +++ b/packages/forms/src/repository/get-document.test.ts @@ -0,0 +1,45 @@ +import { beforeAll, expect, it, vi } from 'vitest'; + +import { type DbTestContext, describeDatabase } from '@atj/database/testing'; +import { addDocument } from './add-document'; +import type { DocumentFieldMap } from '../documents/types'; +import type { ParsedPdf } from '../documents/pdf/parsing-api'; +import { defaultFormConfig } from '../patterns'; +import { getDocument } from './get-document'; + +describeDatabase('get document', () => { + const today = new Date(2000, 1, 1); + + beforeAll(async () => { + vi.setSystemTime(today); + }); + + it('works', async ({ db }) => { + const context = { db: db.ctx, formConfig: defaultFormConfig }; + // add test document + const testDocument = { + fileName: 'file.pdf', + data: new Uint8Array([1, 2, 3]), + extract: { + parsedPdf: {} as ParsedPdf, + fields: {} as DocumentFieldMap, + }, + }; + const result = await addDocument(context, testDocument); + if (!result.success) { + expect.fail(`addDocument failed: ${result.error}`); + } + + // get the document + const docResult = await getDocument(context, result.data.id); + expect(docResult).toEqual({ + success: true, + data: { + id: result.data.id, + path: testDocument.fileName, + data: new Buffer(testDocument.data), + fields: {}, + }, + }); + }); +}); diff --git a/packages/forms/src/repository/get-document.ts b/packages/forms/src/repository/get-document.ts new file mode 100644 index 00000000..35b6c028 --- /dev/null +++ b/packages/forms/src/repository/get-document.ts @@ -0,0 +1,39 @@ +import { type Result, failure, success } from '@atj/common'; + +import type { ParsedPdf } from '../documents/pdf/parsing-api'; +import type { DocumentFieldMap } from '../documents/types'; +import type { FormRepositoryContext } from '.'; + +export type GetDocument = ( + ctx: FormRepositoryContext, + id: string +) => Promise< + Result<{ + id: string; + data: Uint8Array; + path: string; + fields: DocumentFieldMap; + //formFields: Record; + }> +>; + +export const getDocument: GetDocument = async (ctx, id) => { + const db = await ctx.db.getKysely(); + + return await db + .selectFrom('form_documents') + .select(['id', 'type', 'file_name', 'data', 'extract']) + .where('id', '=', id) + .executeTakeFirstOrThrow() + .then(data => { + const extract: { parsedPdf: ParsedPdf; fields: DocumentFieldMap } = + JSON.parse(data.extract); + return success({ + id: data.id, + data: data.data, + path: data.file_name, + fields: extract.fields, + }); + }) + .catch(err => failure(err.message)); +}; diff --git a/packages/forms/src/repository/get-form-list.test.ts b/packages/forms/src/repository/get-form-list.test.ts index 33841208..001704e3 100644 --- a/packages/forms/src/repository/get-form-list.test.ts +++ b/packages/forms/src/repository/get-form-list.test.ts @@ -3,9 +3,11 @@ import { expect, it } from 'vitest'; import { type DbTestContext, describeDatabase } from '@atj/database/testing'; import { getForm } from './get-form.js'; import { getFormList } from './get-form-list.js'; +import { defaultFormConfig } from '../patterns/index.js'; describeDatabase('getFormList', () => { it('retrieves form list successfully', async ({ db }) => { + const context = { db: db.ctx, formConfig: defaultFormConfig }; const kysely = await db.ctx.getKysely(); await kysely .insertInto('forms') @@ -21,7 +23,7 @@ describeDatabase('getFormList', () => { ]) .execute(); - const result = await getFormList(db.ctx); + const result = await getFormList(context); expect(result).toEqual([ { id: '45c66187-64e2-4d75-a45a-e80f1d035bc5', @@ -37,10 +39,11 @@ describeDatabase('getFormList', () => { }); it('return null with non-existent form', async ({ db }) => { + const context = { db: db.ctx, formConfig: defaultFormConfig }; const result = await getForm( - db.ctx, + context, '45c66187-64e2-4d75-a45a-e80f1d035bc5' ); - expect(result).toBeNull(); + expect(result).toEqual({ success: true, data: null }); }); }); diff --git a/packages/forms/src/repository/get-form-list.ts b/packages/forms/src/repository/get-form-list.ts index dc7ef3da..2f25fc7f 100644 --- a/packages/forms/src/repository/get-form-list.ts +++ b/packages/forms/src/repository/get-form-list.ts @@ -1,6 +1,6 @@ -import { type DatabaseContext } from '@atj/database'; +import type { FormRepositoryContext } from '.'; -export type GetFormList = (ctx: DatabaseContext) => Promise< +export type GetFormList = (ctx: FormRepositoryContext) => Promise< | { id: string; title: string; @@ -10,7 +10,7 @@ export type GetFormList = (ctx: DatabaseContext) => Promise< >; export const getFormList: GetFormList = async ctx => { - const db = await ctx.getKysely(); + const db = await ctx.db.getKysely(); const rows = await db.selectFrom('forms').select(['id', 'data']).execute(); return rows.map(row => { diff --git a/packages/forms/src/repository/get-form-session.test.ts b/packages/forms/src/repository/get-form-session.test.ts index f5e4c088..76cd350a 100644 --- a/packages/forms/src/repository/get-form-session.test.ts +++ b/packages/forms/src/repository/get-form-session.test.ts @@ -5,11 +5,13 @@ import { type DbTestContext, describeDatabase } from '@atj/database/testing'; import { createTestBlueprint } from '../builder/builder.test'; import { addForm } from './add-form'; import { getFormSession } from './get-form-session'; +import { defaultFormConfig } from '../patterns'; describeDatabase('getFormSession', () => { it('returns a preexisting form session', async ctx => { const db = await ctx.db.ctx.getKysely(); - const form = await addForm(ctx.db.ctx, createTestBlueprint()); + const context = { db: ctx.db.ctx, formConfig: defaultFormConfig }; + const form = await addForm(context, createTestBlueprint()); if (!form.success) { expect.fail(form.error); } @@ -23,7 +25,7 @@ describeDatabase('getFormSession', () => { }) .executeTakeFirstOrThrow(); - const formSessionResult = await getFormSession(ctx.db.ctx, formSessionId); + const formSessionResult = await getFormSession(context, formSessionId); if (!formSessionResult.success) { expect.fail(formSessionResult.error); } @@ -36,8 +38,9 @@ describeDatabase('getFormSession', () => { }); it('returns an error if the form session does not exist', async ctx => { + const context = { db: ctx.db.ctx, formConfig: defaultFormConfig }; const formSessionResult = await getFormSession( - ctx.db.ctx, + context, '7128b29f-e03d-48c8-8a82-2af8759fc146' ); expect(formSessionResult).toEqual({ diff --git a/packages/forms/src/repository/get-form-session.ts b/packages/forms/src/repository/get-form-session.ts index a6d4a4fe..d2aa7af7 100644 --- a/packages/forms/src/repository/get-form-session.ts +++ b/packages/forms/src/repository/get-form-session.ts @@ -1,9 +1,9 @@ import { type Result, failure, success } from '@atj/common'; -import { type DatabaseContext } from '@atj/database'; import { type FormSession, type FormSessionId } from '../session'; +import type { FormRepositoryContext } from '.'; export type GetFormSession = ( - ctx: DatabaseContext, + ctx: FormRepositoryContext, id: string ) => Promise< Result<{ @@ -14,7 +14,7 @@ export type GetFormSession = ( >; export const getFormSession: GetFormSession = async (ctx, id) => { - const db = await ctx.getKysely(); + const db = await ctx.db.getKysely(); return await db .selectFrom('form_sessions') .where('id', '=', id) diff --git a/packages/forms/src/repository/get-form.test.ts b/packages/forms/src/repository/get-form.test.ts index 415f809c..5d4441b1 100644 --- a/packages/forms/src/repository/get-form.test.ts +++ b/packages/forms/src/repository/get-form.test.ts @@ -2,7 +2,7 @@ import { expect, it } from 'vitest'; import { type DbTestContext, describeDatabase } from '@atj/database/testing'; -import { type Blueprint } from '../index.js'; +import { defaultFormConfig, type Blueprint } from '../index.js'; import { getForm } from './get-form.js'; describeDatabase('getForm', () => { @@ -12,34 +12,33 @@ describeDatabase('getForm', () => { .insertInto('forms') .values({ id: '45c66187-64e2-4d75-a45a-e80f1d035bc5', - data: '{"summary":{"title":"Title","description":"Description"},"root":"root","patterns":{"root":{"type":"sequence","id":"root","data":{"patterns":[]}}},"outputs":[{"data":"AQID","path":"test.pdf","fields":{},"formFields":{}}]}', + data: '{"summary":{"title":"Title","description":"Description"},"root":"root","patterns":{"root":{"type":"page-set","id":"root","data":{"pages":[]}}},"outputs":[{"id":"test-id","path":"test.pdf","fields":{},"formFields":{}}]}', }) .execute(); const result = await getForm( - db.ctx, + { db: db.ctx, formConfig: defaultFormConfig }, '45c66187-64e2-4d75-a45a-e80f1d035bc5' ); - console.log(result); - expect(result).toEqual(TEST_FORM); + expect(result).toEqual({ success: true, data: TEST_FORM }); }); it('return null with non-existent form', async ({ db }) => { const result = await getForm( - db.ctx, + { db: db.ctx, formConfig: defaultFormConfig }, '45c66187-64e2-4d75-a45a-e80f1d035bc5' ); - expect(result).toBeNull(); + expect(result).toEqual({ success: true, data: null }); }); }); const TEST_FORM: Blueprint = { summary: { title: 'Title', description: 'Description' }, root: 'root', - patterns: { root: { type: 'sequence', id: 'root', data: { patterns: [] } } }, + patterns: { root: { type: 'page-set', id: 'root', data: { pages: [] } } }, outputs: [ { - data: new Uint8Array([1, 2, 3]), + id: 'test-id', path: 'test.pdf', fields: {}, formFields: {}, diff --git a/packages/forms/src/repository/get-form.ts b/packages/forms/src/repository/get-form.ts index e144162b..eeb11de2 100644 --- a/packages/forms/src/repository/get-form.ts +++ b/packages/forms/src/repository/get-form.ts @@ -1,14 +1,15 @@ -import { type DatabaseContext } from '@atj/database'; - +import { failure, success, type Result } from '@atj/common'; +import { parseFormString } from '../builder/parse-form.js'; import { type Blueprint } from '../index.js'; +import type { FormRepositoryContext } from './index.js'; export type GetForm = ( - ctx: DatabaseContext, + ctx: FormRepositoryContext, formId: string -) => Promise; +) => Promise>; export const getForm: GetForm = async (ctx, formId) => { - const db = await ctx.getKysely(); + const db = await ctx.db.getKysely(); const selectResult = await db .selectFrom('forms') .select(['data']) @@ -16,29 +17,13 @@ export const getForm: GetForm = async (ctx, formId) => { .executeTakeFirst(); if (selectResult === undefined) { - return null; + return success(null); } - return parseStringForm(selectResult.data); -}; - -const parseStringForm = (formString: string): Blueprint => { - const form = JSON.parse(formString) as Blueprint; - return { - ...form, - outputs: form.outputs.map((output: any) => ({ - ...output, - data: base64ToUint8Array((output as any).data), - })), - }; -}; - -const base64ToUint8Array = (base64: string): Uint8Array => { - const binaryString = atob(base64); - const len = binaryString.length; - const bytes = new Uint8Array(len); - for (let i = 0; i < len; i++) { - bytes[i] = binaryString.charCodeAt(i); + const parseResult = parseFormString(ctx.formConfig, selectResult.data); + if (!parseResult.success) { + return failure(`Failed to parse form: ${parseResult.error}`); } - return bytes; + + return success(parseResult.data); }; diff --git a/packages/forms/src/repository/index.ts b/packages/forms/src/repository/index.ts index dd469d4e..3c250ffc 100644 --- a/packages/forms/src/repository/index.ts +++ b/packages/forms/src/repository/index.ts @@ -1,8 +1,12 @@ import { type ServiceMethod, createService } from '@atj/common'; import { type DatabaseContext } from '@atj/database'; +import type { FormConfig } from '../pattern.js'; + +import { type AddDocument, addDocument } from './add-document.js'; import { type AddForm, addForm } from './add-form.js'; import { type DeleteForm, deleteForm } from './delete-form.js'; +import { type GetDocument, getDocument } from './get-document.js'; import { type GetForm, getForm } from './get-form.js'; import { type GetFormList, getFormList } from './get-form-list.js'; import { type GetFormSession, getFormSession } from './get-form-session.js'; @@ -13,8 +17,10 @@ import { } from './upsert-form-session.js'; export interface FormRepository { + addDocument: ServiceMethod; addForm: ServiceMethod; deleteForm: ServiceMethod; + getDocument: ServiceMethod; getForm: ServiceMethod; getFormSession: ServiceMethod; getFormList: ServiceMethod; @@ -22,10 +28,19 @@ export interface FormRepository { upsertFormSession: ServiceMethod; } -export const createFormsRepository = (ctx: DatabaseContext): FormRepository => +export type FormRepositoryContext = { + db: DatabaseContext; + formConfig: FormConfig; +}; + +export const createFormsRepository = ( + ctx: FormRepositoryContext +): FormRepository => createService(ctx, { + addDocument, addForm, deleteForm, + getDocument, getFormList, getFormSession, getForm, diff --git a/packages/forms/src/repository/save-form.test.ts b/packages/forms/src/repository/save-form.test.ts index 8befac51..62c7f379 100644 --- a/packages/forms/src/repository/save-form.test.ts +++ b/packages/forms/src/repository/save-form.test.ts @@ -2,7 +2,7 @@ import { expect, it } from 'vitest'; import { type DbTestContext, describeDatabase } from '@atj/database/testing'; -import { type Blueprint } from '../index.js'; +import { defaultFormConfig, type Blueprint } from '../index.js'; import { saveForm } from './save-form.js'; import { addForm } from './add-form.js'; @@ -23,7 +23,7 @@ const TEST_FORM: Blueprint = { }, outputs: [ { - data: new Uint8Array([1, 2, 3]), + id: 'test-id', path: 'test.pdf', fields: {}, formFields: {}, @@ -34,15 +34,22 @@ const TEST_FORM: Blueprint = { describeDatabase('saveForm', () => { it('saves pre-existing form successfully', async ({ db }) => { const kysely = await db.ctx.getKysely(); - const addResult = await addForm(db.ctx, TEST_FORM); + const addResult = await addForm( + { db: db.ctx, formConfig: defaultFormConfig }, + TEST_FORM + ); if (!addResult.success) { throw new Error('Failed to add form'); } - const saveResult = await saveForm(db.ctx, addResult.data.id, { - ...TEST_FORM, - summary: { title: 'Updated title', description: 'Updated description' }, - }); + const saveResult = await saveForm( + { db: db.ctx, formConfig: defaultFormConfig }, + addResult.data.id, + { + ...TEST_FORM, + summary: { title: 'Updated title', description: 'Updated description' }, + } + ); if (!saveResult.success) { expect.fail('Failed to save form', saveResult.error); } @@ -55,7 +62,7 @@ describeDatabase('saveForm', () => { expect(result[0].id).toEqual(addResult.data.id); expect(result[0].data).toEqual( - '{"summary":{"title":"Updated title","description":"Updated description"},"root":"root","patterns":{"root":{"type":"sequence","id":"root","data":{"patterns":[]}}},"outputs":[{"data":"AQID","path":"test.pdf","fields":{},"formFields":{}}]}' + '{"summary":{"title":"Updated title","description":"Updated description"},"root":"root","patterns":{"root":{"type":"sequence","id":"root","data":{"patterns":[]}}},"outputs":[{"id":"test-id","path":"test.pdf","fields":{},"formFields":{}}]}' ); }); }); diff --git a/packages/forms/src/repository/save-form.ts b/packages/forms/src/repository/save-form.ts index 18365c80..438f5d65 100644 --- a/packages/forms/src/repository/save-form.ts +++ b/packages/forms/src/repository/save-form.ts @@ -1,22 +1,21 @@ import { type VoidResult, failure, success } from '@atj/common'; -import { type DatabaseContext } from '@atj/database'; import { type Blueprint } from '../index.js'; -import { stringifyForm } from './serialize.js'; +import type { FormRepositoryContext } from './index.js'; export type SaveForm = ( - ctx: DatabaseContext, + ctx: FormRepositoryContext, formId: string, form: Blueprint ) => Promise; export const saveForm: SaveForm = async (ctx, id, blueprint) => { - const db = await ctx.getKysely(); + const db = await ctx.db.getKysely(); return await db .updateTable('forms') .set({ - data: stringifyForm(blueprint), + data: JSON.stringify(blueprint), }) .where('id', '=', id) .execute() diff --git a/packages/forms/src/repository/serialize.ts b/packages/forms/src/repository/serialize.ts deleted file mode 100644 index 11e32283..00000000 --- a/packages/forms/src/repository/serialize.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { type Blueprint } from '..'; - -export const stringifyForm = (form: Blueprint) => { - return JSON.stringify({ - ...form, - outputs: form.outputs.map((output: any) => ({ - ...output, - // TODO: we probably want to do this somewhere in the documents module - data: uint8ArrayToBase64(output.data), - })), - }); -}; - -const uint8ArrayToBase64 = (buffer: Uint8Array): string => { - let binary = ''; - const len = buffer.byteLength; - for (let i = 0; i < len; i++) { - binary += String.fromCharCode(buffer[i]); - } - return btoa(binary); -}; diff --git a/packages/forms/src/repository/upsert-form-session.test.ts b/packages/forms/src/repository/upsert-form-session.test.ts index 612a7d35..26d18997 100644 --- a/packages/forms/src/repository/upsert-form-session.test.ts +++ b/packages/forms/src/repository/upsert-form-session.test.ts @@ -2,7 +2,7 @@ import { beforeEach, expect, it } from 'vitest'; import { type DbTestContext, describeDatabase } from '@atj/database/testing'; -import { type Blueprint } from '..'; +import { defaultFormConfig, type Blueprint } from '..'; import { createTestBlueprint } from '../builder/builder.test'; import { type FormSession } from '../session'; @@ -18,7 +18,10 @@ type UpsertTestContext = DbTestContext & { describeDatabase('upsertFormSession', () => { beforeEach(async ctx => { ctx.form = createTestBlueprint(); - const addFormResult = await addForm(ctx.db.ctx, ctx.form); + const addFormResult = await addForm( + { db: ctx.db.ctx, formConfig: defaultFormConfig }, + ctx.form + ); if (!addFormResult.success) { expect.fail('Failed to add test form'); } @@ -31,10 +34,13 @@ describeDatabase('upsertFormSession', () => { }); it('creates and updates form session', async ctx => { - const result = await upsertFormSession(ctx.db.ctx, { - formId: ctx.formId, - data: ctx.sessionData, - }); + const result = await upsertFormSession( + { db: ctx.db.ctx, formConfig: defaultFormConfig }, + { + formId: ctx.formId, + data: ctx.sessionData, + } + ); if (!result.success) { expect.fail(result.error); } @@ -57,7 +63,10 @@ describeDatabase('upsertFormSession', () => { form: ctx.form, }, }; - const result2 = await upsertFormSession(ctx.db.ctx, formSession); + const result2 = await upsertFormSession( + { db: ctx.db.ctx, formConfig: defaultFormConfig }, + formSession + ); if (!result2.success) { expect.fail(result2.error); } diff --git a/packages/forms/src/repository/upsert-form-session.ts b/packages/forms/src/repository/upsert-form-session.ts index 2d21edb0..830f71d7 100644 --- a/packages/forms/src/repository/upsert-form-session.ts +++ b/packages/forms/src/repository/upsert-form-session.ts @@ -1,9 +1,9 @@ import { type Result, failure, success } from '@atj/common'; -import { type DatabaseContext } from '@atj/database'; import { type FormSession } from '../session'; +import type { FormRepositoryContext } from '.'; export type UpsertFormSession = ( - ctx: DatabaseContext, + ctx: FormRepositoryContext, opts: { id?: string; formId: string; @@ -12,7 +12,7 @@ export type UpsertFormSession = ( ) => Promise>; export const upsertFormSession: UpsertFormSession = async (ctx, opts) => { - const db = await ctx.getKysely(); + const db = await ctx.db.getKysely(); const strData = JSON.stringify(opts.data); const id = opts.id || crypto.randomUUID(); return await db diff --git a/packages/forms/src/services/delete-form.ts b/packages/forms/src/services/delete-form.ts index a4aaa40b..f3eb1b60 100644 --- a/packages/forms/src/services/delete-form.ts +++ b/packages/forms/src/services/delete-form.ts @@ -19,8 +19,14 @@ export const deleteForm: DeleteForm = async (ctx, formId) => { message: 'You must be logged in to delete a form', }); } - const form = await ctx.repository.getForm(formId); - if (form === null) { + const formResult = await ctx.repository.getForm(formId); + if (!formResult.success) { + return failure({ + status: 500, + message: formResult.error, + }); + } + if (formResult.data === null) { return failure({ status: 404, message: `form '${formId} does not exist`, diff --git a/packages/forms/src/services/get-form-session.ts b/packages/forms/src/services/get-form-session.ts index eb3521af..13767d4d 100644 --- a/packages/forms/src/services/get-form-session.ts +++ b/packages/forms/src/services/get-form-session.ts @@ -24,15 +24,22 @@ export type GetFormSession = ( >; export const getFormSession: GetFormSession = async (ctx, opts) => { - const form = await ctx.repository.getForm(opts.formId); - if (form === null) { + const formResult = await ctx.repository.getForm(opts.formId); + if (!formResult.success) { + return failure(`Failed to retrieve form: ${formResult.error}`); + } + + if (formResult.data === null) { return failure(`form '${opts.formId} does not exist`); } // If this request corresponds to an non-existent session, return a new // session that is not yet persisted. if (opts.sessionId === undefined) { - const formSession = await createFormSession(form, opts.formRoute); + const formSession = await createFormSession( + formResult.data, + opts.formRoute + ); return success({ formId: opts.formId, data: formSession, @@ -44,7 +51,7 @@ export const getFormSession: GetFormSession = async (ctx, opts) => { console.error( `Error retrieving form session: ${formSession.error}. Returning new session.` ); - const newSession = await createFormSession(form, opts.formRoute); + const newSession = await createFormSession(formResult.data, opts.formRoute); return success({ formId: opts.formId, data: newSession, diff --git a/packages/forms/src/services/get-form.test.ts b/packages/forms/src/services/get-form.test.ts index 412f10c0..be59cee7 100644 --- a/packages/forms/src/services/get-form.test.ts +++ b/packages/forms/src/services/get-form.test.ts @@ -33,7 +33,7 @@ describe('getForm', () => { const result = await getForm(ctx, addResult.data.id); if (!result.success) { - expect.fail('Failed to add form:', result.error); + expect.fail(`Failed to get form: ${JSON.stringify(result.error)}`); } expect(result.data).toEqual(TEST_FORM); }); diff --git a/packages/forms/src/services/get-form.ts b/packages/forms/src/services/get-form.ts index 82737020..d2c0f67a 100644 --- a/packages/forms/src/services/get-form.ts +++ b/packages/forms/src/services/get-form.ts @@ -1,7 +1,8 @@ import { type Result, failure, success } from '@atj/common'; -import { type Blueprint } from '../index.js'; +import { parseForm } from '../builder/parse-form.js'; import { type FormServiceContext } from '../context/index.js'; +import { type Blueprint } from '../types.js'; type GetFormError = { status: number; @@ -14,12 +15,20 @@ export type GetForm = ( ) => Promise>; export const getForm: GetForm = async (ctx, formId) => { - const result = await ctx.repository.getForm(formId); - if (result === null) { + const formResult = await ctx.repository.getForm(formId); + if (!formResult.success) { + return failure({ + status: 500, + message: formResult.error, + }); + } + + if (formResult.data === null) { return failure({ status: 404, message: 'Form not found', }); } - return success(result); + + return success(formResult.data); }; diff --git a/packages/forms/src/services/index.ts b/packages/forms/src/services/index.ts index 96cc598a..d426dc29 100644 --- a/packages/forms/src/services/index.ts +++ b/packages/forms/src/services/index.ts @@ -1,4 +1,4 @@ -import { createService, ServiceMethod } from '@atj/common'; +import { type ServiceMethod, createService } from '@atj/common'; import { type FormServiceContext } from '../context/index.js'; @@ -7,6 +7,7 @@ import { type DeleteForm, deleteForm } from './delete-form.js'; import { type GetForm, getForm } from './get-form.js'; import { type GetFormList, getFormList } from './get-form-list.js'; import { type GetFormSession, getFormSession } from './get-form-session.js'; +import { type InitializeForm, initializeForm } from './initialize-form.js'; import { type SaveForm, saveForm } from './save-form.js'; import { type SubmitForm, submitForm } from './submit-form.js'; @@ -17,6 +18,7 @@ export const createFormService = (ctx: FormServiceContext) => getForm, getFormList, getFormSession, + initializeForm, saveForm, submitForm, }); @@ -27,6 +29,7 @@ export type FormService = { getForm: ServiceMethod; getFormList: ServiceMethod; getFormSession: ServiceMethod; + initializeForm: ServiceMethod; saveForm: ServiceMethod; submitForm: ServiceMethod; }; diff --git a/packages/forms/src/services/initialize-form.test.ts b/packages/forms/src/services/initialize-form.test.ts new file mode 100644 index 00000000..a1c994ab --- /dev/null +++ b/packages/forms/src/services/initialize-form.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; + +import { createTestFormServiceContext } from '../testing.js'; + +import { initializeForm } from './initialize-form.js'; + +const summary = { title: 'Form Title', description: '' }; + +describe('initializeForm', () => { + it('returns access denied (401) if user is not logged in', async () => { + const ctx = await createTestFormServiceContext({ + isUserLoggedIn: () => false, + }); + const result = await initializeForm(ctx, { summary }); + expect(result).toEqual({ + success: false, + error: { + status: 401, + message: 'You must be logged in to initialize a new form', + }, + }); + }); + + it('initializes with summary when user is logged in', async () => { + const ctx = await createTestFormServiceContext({ + isUserLoggedIn: () => true, + }); + const result = await initializeForm(ctx, { summary }); + expect(result).toEqual({ + success: true, + data: { + timestamp: expect.any(String), + id: expect.any(String), + }, + }); + }); + + it('initializes successfully with document when user is logged in', async () => { + const ctx = await createTestFormServiceContext({ + isUserLoggedIn: () => true, + parsedPdf: async () => ({ + parsedPdf: { + text: 'test', + title: '', + root: 'root', + description: '', + patterns: {}, + errors: [], + outputs: {}, + }, + fields: {}, + }), + }); + const result = await initializeForm(ctx, { + summary, + document: { fileName: 'test.pdf', data: 'VGhpcyBpcyBub3QgYSBQREYu' }, + }); + expect(result).toEqual({ + success: true, + data: { + timestamp: expect.any(String), + id: expect.any(String), + }, + }); + }); +}); diff --git a/packages/forms/src/services/initialize-form.ts b/packages/forms/src/services/initialize-form.ts new file mode 100644 index 00000000..218d1641 --- /dev/null +++ b/packages/forms/src/services/initialize-form.ts @@ -0,0 +1,121 @@ +import * as z from 'zod'; + +import { type Result, failure, success } from '@atj/common'; + +import { BlueprintBuilder } from '../builder/index.js'; +import { type FormServiceContext } from '../context/index.js'; +import type { FormSummary } from '../types.js'; +import { base64ToUint8Array } from '../util/base64.js'; + +type InitializeFormError = { + status: number; + message: string; +}; +type InitializeFormResult = { + timestamp: string; + id: string; +}; + +export type InitializeForm = ( + ctx: FormServiceContext, + opts: + | unknown + | { + summary?: FormSummary; + document?: { fileName: string; data: Uint8Array }; + } +) => Promise>; + +const base64 = + /^(?:[A-Za-z0-9+\/]{4})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)?$/; + +const optionSchema = z.object({ + summary: z + .object({ + title: z.string(), + description: z.string(), + }) + .optional(), + document: z + .object({ + fileName: z.string(), + data: z + .string() + .refine(value => base64.test(value), { + message: 'Invalid base64 string', + }) + .transform(value => base64ToUint8Array(value)), + }) + .optional(), +}); + +export const initializeForm: InitializeForm = async (ctx, opts) => { + if (!ctx.isUserLoggedIn()) { + return failure({ + status: 401, + message: 'You must be logged in to initialize a new form', + }); + } + + const parseResult = optionSchema.safeParse(opts); + if (!parseResult.success) { + return failure({ + status: 400, + message: 'Invalid options', + }); + } + const { document, summary } = parseResult.data; + + const builder = new BlueprintBuilder(ctx.config); + if (document !== undefined) { + const parsePdfResult = await ctx + .parsePdf(document.data) + .then(result => success(result)) + .catch(err => + failure({ + status: 400, + message: `Failed to parse PDF: ${err.message}`, + }) + ); + if (!parsePdfResult.success) { + return parsePdfResult; + } + const { parsedPdf } = parsePdfResult.data; + + builder.setFormSummary({ + title: parsedPdf.title || document.fileName, + description: parsedPdf.description, + }); + + const fileName = document.fileName.split('/').pop() || 'my-form.pdf'; + const addDocumentResult = await ctx.repository.addDocument({ + fileName, + data: document.data, + extract: parsePdfResult.data, + }); + if (!addDocumentResult.success) { + return failure({ + status: 500, + message: `Failed to add document: ${addDocumentResult.error}`, + }); + } + await builder.addDocumentRef({ + id: addDocumentResult.data.id, + extract: parsedPdf, + }); + } + + if (summary) { + builder.setFormSummary(summary); + } + + const result = await ctx.repository.addForm(builder.form); + if (!result.success) { + console.error('Failed to add form:', result.error); + return failure({ + status: 500, + message: result.error, + }); + } + return result; +}; diff --git a/packages/forms/src/services/save-form.test.ts b/packages/forms/src/services/save-form.test.ts index 94e3f873..3c180fef 100644 --- a/packages/forms/src/services/save-form.test.ts +++ b/packages/forms/src/services/save-form.test.ts @@ -4,6 +4,7 @@ import { createForm } from '../index.js'; import { createTestFormServiceContext } from '../testing.js'; import { saveForm } from './save-form.js'; +import { success } from '@atj/common'; const TEST_FORM = createForm({ title: 'Form Title', description: '' }); const TEST_FORM_2 = { @@ -42,9 +43,11 @@ describe('saveForm', () => { if (!result.success) { expect.fail('Failed to add form:', result.error); } - expect(result.data).toEqual({ timestamp: expect.any(Date) }); + expect(result.data).toEqual( + expect.objectContaining({ timestamp: expect.any(Date) }) + ); const savedForm = await ctx.repository.getForm(addResult.data.id); - expect(savedForm).toEqual(TEST_FORM_2); + expect(savedForm).toEqual(success(TEST_FORM_2)); }); }); diff --git a/packages/forms/src/services/save-form.ts b/packages/forms/src/services/save-form.ts index 133aed90..73d7245a 100644 --- a/packages/forms/src/services/save-form.ts +++ b/packages/forms/src/services/save-form.ts @@ -1,7 +1,8 @@ import { type Result, failure, success } from '@atj/common'; -import { Blueprint } from '../index.js'; import { type FormServiceContext } from '../context/index.js'; +import { type Blueprint } from '../types.js'; +import { parseForm } from '../builder/parse-form.js'; type SaveFormError = { status: number; @@ -21,7 +22,16 @@ export const saveForm: SaveForm = async (ctx, formId, form) => { message: 'You must be logged in to save a form', }); } - const result = await ctx.repository.saveForm(formId, form); + + const parseResult = parseForm(ctx.config, form); + if (!parseResult.success) { + return failure({ + status: 422, + message: parseResult.error, + }); + } + + const result = await ctx.repository.saveForm(formId, parseResult.data); if (result.success === false) { return failure({ status: 500, diff --git a/packages/forms/src/services/submit-form.ts b/packages/forms/src/services/submit-form.ts index ef917b79..533d1e6e 100644 --- a/packages/forms/src/services/submit-form.ts +++ b/packages/forms/src/services/submit-form.ts @@ -48,14 +48,18 @@ export const submitForm: SubmitForm = async ( formData, route ) => { - const form = await ctx.repository.getForm(formId); - if (form === null) { + const formResult = await ctx.repository.getForm(formId); + if (!formResult.success) { + return failure(formResult.error); + } + + if (formResult.data === null) { return failure('Form not found'); } const sessionResult = await getFormSessionOrCreate( ctx, - form, + formResult.data, route, sessionId ); @@ -75,17 +79,28 @@ export const submitForm: SubmitForm = async ( return failure(`Invalid action: ${actionString}`); } - const submitHandlerResult = registry.getHandlerForAction(form, actionString); + const submitHandlerResult = registry.getHandlerForAction( + formResult.data, + actionString + ); if (!submitHandlerResult.success) { return failure(submitHandlerResult.error); } const { handler, pattern } = submitHandlerResult.data; - const newSessionResult = await handler(ctx.config, { - pattern, - session, - data: formData, - }); + const newSessionResult = await handler( + { + config: ctx.config, + getDocument: id => { + return ctx.repository.getDocument(id); + }, + }, + { + pattern, + session, + data: formData, + } + ); if (!newSessionResult.success) { return failure(newSessionResult.error); diff --git a/packages/forms/src/session.ts b/packages/forms/src/session.ts index 76d5a49f..9823e034 100644 --- a/packages/forms/src/session.ts +++ b/packages/forms/src/session.ts @@ -141,7 +141,6 @@ export const updateSession = ( export const sessionIsComplete = (config: FormConfig, session: FormSession) => { return Object.values(session.form.patterns).every(pattern => { - console.log('validating', pattern.type, pattern.id); const patternConfig = getPatternConfig(config, pattern.type); const value = getFormSessionValue(session, pattern.id); const isValidResult = validatePattern(patternConfig, pattern, value); diff --git a/packages/forms/src/submission.ts b/packages/forms/src/submission.ts index f1f220b1..8b7b3a93 100644 --- a/packages/forms/src/submission.ts +++ b/packages/forms/src/submission.ts @@ -9,10 +9,22 @@ import { getPattern, } from './pattern'; import { type FormSession } from './session'; -import { type Blueprint } from '.'; +import { type Blueprint, type DocumentFieldMap } from '.'; + +export type SubmitHandlerContext = { + config: FormConfig; + getDocument: (id: string) => Promise< + Result<{ + id: string; + data: Uint8Array; + path: string; + fields: DocumentFieldMap; + }> + >; +}; export type SubmitHandler

= ( - config: FormConfig, + context: SubmitHandlerContext, opts: { pattern: P; session: FormSession; diff --git a/packages/forms/src/testing.ts b/packages/forms/src/testing.ts index cdad9203..a1fcc061 100644 --- a/packages/forms/src/testing.ts +++ b/packages/forms/src/testing.ts @@ -1,20 +1,29 @@ import { type DatabaseContext } from '@atj/database'; import { createInMemoryDatabaseContext } from '@atj/database/context'; -import { createFormsRepository } from './repository'; + +import type { FormServiceContext } from './context'; +import { type ParsePdf, parsePdf } from './documents'; import { defaultFormConfig } from './patterns'; +import { createFormsRepository } from './repository'; type Options = { isUserLoggedIn: () => boolean; + parsedPdf: ParsePdf; }; -export const createTestFormServiceContext = async (opts?: Partial) => { +export const createTestFormServiceContext = async ( + opts?: Partial +): Promise => { const db: DatabaseContext = await createInMemoryDatabaseContext(); - const repository = createFormsRepository(db); - return { + const repository = createFormsRepository({ db, + formConfig: defaultFormConfig, + }); + return { repository, config: defaultFormConfig, isUserLoggedIn: opts?.isUserLoggedIn || (() => true), + parsePdf: opts?.parsedPdf || parsePdf, }; }; diff --git a/packages/forms/src/types.ts b/packages/forms/src/types.ts index e5147eb4..d27d6bc9 100644 --- a/packages/forms/src/types.ts +++ b/packages/forms/src/types.ts @@ -14,7 +14,7 @@ export type FormSummary = { }; export type FormOutput = { - data: Uint8Array; + id: string; path: string; fields: DocumentFieldMap; formFields: Record; diff --git a/packages/forms/src/documents/util.ts b/packages/forms/src/util/base64.ts similarity index 65% rename from packages/forms/src/documents/util.ts rename to packages/forms/src/util/base64.ts index 90f6a49a..859a5746 100644 --- a/packages/forms/src/documents/util.ts +++ b/packages/forms/src/util/base64.ts @@ -12,7 +12,10 @@ export const stringToBase64 = (input: string): string => { } }; -export const uint8ArrayToBase64 = (uint8Array: Uint8Array) => { +export const uint8ArrayToBase64 = async (uint8Array: Uint8Array) => { + if (typeof Buffer !== 'undefined') { + return Buffer.from(uint8Array).toString('base64'); + } return new Promise((resolve, reject) => { const blob = new Blob([uint8Array], { type: 'application/octet-stream' }); const reader = new FileReader(); @@ -25,3 +28,13 @@ export const uint8ArrayToBase64 = (uint8Array: Uint8Array) => { reader.readAsDataURL(blob); }); }; + +export const base64ToUint8Array = (base64: string) => { + const binaryString = atob(base64); + const len = binaryString.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes; +}; diff --git a/packages/server/src/config/services.ts b/packages/server/src/config/services.ts index a378a3b4..f0db284b 100644 --- a/packages/server/src/config/services.ts +++ b/packages/server/src/config/services.ts @@ -3,6 +3,7 @@ import { createFormService, createFormsRepository, defaultFormConfig, + parsePdf, } from '@atj/forms'; import { type ServerOptions } from './options.js'; @@ -11,8 +12,12 @@ export const createServerFormService = ( ctx: { isUserLoggedIn: () => boolean } ): FormService => { return createFormService({ - repository: createFormsRepository(options.db), + repository: createFormsRepository({ + db: options.db, + formConfig: defaultFormConfig, + }), config: defaultFormConfig, isUserLoggedIn: ctx.isUserLoggedIn, + parsePdf, }); }; diff --git a/packages/server/src/lib/api-client.ts b/packages/server/src/lib/api-client.ts index 374f79a1..74b70176 100644 --- a/packages/server/src/lib/api-client.ts +++ b/packages/server/src/lib/api-client.ts @@ -5,6 +5,7 @@ import { type FormSessionId, type Blueprint, type FormService, + type FormSummary, } from '@atj/forms'; import { type FormServiceContext } from '@atj/forms/context'; @@ -24,7 +25,43 @@ export class FormServiceClient implements FormService { }, }); const result = await response.json(); - console.log('addForm result', result); + return result; + } + + async initializeForm( + opts: + | unknown + | { + summary?: FormSummary; + document?: { fileName: string; data: string }; + } + ): Promise< + Result< + { timestamp: string; id: string }, + { status: number; message: string } + > + > { + const options = opts as { + summary?: FormSummary; + document?: { fileName: string; data: string }; + }; + const body = JSON.stringify({ + summary: options.summary ? options.summary : undefined, + document: options.document + ? { + fileName: options.document.fileName, + data: options.document.data, + } + : undefined, + }); + const response = await fetch(`${this.ctx.baseUrl}api/forms`, { + method: 'POST', + body, + headers: { + 'Content-Type': 'application/json', + }, + }); + const result = await response.json(); return result; } diff --git a/packages/server/src/lib/attachments.ts b/packages/server/src/lib/attachments.ts deleted file mode 100644 index 93a458c2..00000000 --- a/packages/server/src/lib/attachments.ts +++ /dev/null @@ -1,45 +0,0 @@ -export const createMultipartResponse = ( - pdfs: { fileName: string; data: Uint8Array }[] -): Response => { - const boundary = createBoundary(); - - // Array to store each part of the multipart message - const parts: Uint8Array[] = pdfs.flatMap(pdf => { - const headers = [ - `--${boundary}`, - `Content-Type: application/pdf`, - `Content-Disposition: attachment; filename="${pdf.fileName}"`, - '', - '', // empty line between headers and content - ].join('\r\n'); - - return [stringToUint8Array(headers), pdf.data, stringToUint8Array('\r\n')]; - }); - - // Final boundary to mark the end of the message - parts.push(stringToUint8Array(`--${boundary}--`)); - - // Concatenate all Uint8Array parts into a single Uint8Array body - const body = new Uint8Array( - parts.reduce((sum, part) => sum + part.length, 0) - ); - let offset = 0; - parts.forEach(part => { - body.set(part, offset); - offset += part.length; - }); - - return new Response(body, { - status: 200, - headers: { - 'Content-Type': `multipart/mixed; boundary=${boundary}`, - 'Content-Length': body.length.toString(), - }, - }); -}; - -const createBoundary = (): string => - `boundary_${Math.random().toString(36).slice(2)}`; - -const stringToUint8Array = (str: string): Uint8Array => - new TextEncoder().encode(str); diff --git a/packages/server/src/lib/github.ts b/packages/server/src/lib/github.ts index b979f6d8..2d638567 100644 --- a/packages/server/src/lib/github.ts +++ b/packages/server/src/lib/github.ts @@ -7,7 +7,7 @@ export type GithubRepository = { export const DEFAULT_REPOSITORY: GithubRepository = { owner: 'gsa-tts', - repository: 'atj-platform', + repository: 'forms', branch: 'main', commit: 'main', }; @@ -28,7 +28,7 @@ export const getGithubRepository = async ( const { execSync } = await import('child_process'); return { owner: env.OWNER || 'gsa-tts', - repository: env.REPOSITORY || 'atj-platform', + repository: env.REPOSITORY || 'forms', branch: env.BRANCH || 'main', commit: execSync('git rev-parse HEAD').toString().trim(), }; diff --git a/packages/server/src/pages/api/forms/index.ts b/packages/server/src/pages/api/forms/index.ts index fe071985..15780209 100644 --- a/packages/server/src/pages/api/forms/index.ts +++ b/packages/server/src/pages/api/forms/index.ts @@ -13,9 +13,10 @@ export const GET: APIRoute = async context => { }; export const POST: APIRoute = async context => { - const form = await context.request.json(); + const input = await context.request.json(); const ctx = await getServerContext(context); - const result = await ctx.formService.addForm(form); + //const result = await ctx.formService.addForm(form); + const result = await ctx.formService.initializeForm(input); return new Response(JSON.stringify(result), { headers: { 'Content-Type': 'application/json', diff --git a/packages/server/src/pages/forms/[id].astro b/packages/server/src/pages/forms/[id].astro index dd8fd669..69ab8059 100644 --- a/packages/server/src/pages/forms/[id].astro +++ b/packages/server/src/pages/forms/[id].astro @@ -40,9 +40,18 @@ const getNextUrl = (route?: FormRoute) => { if (Astro.request.method === 'POST') { const formData = await Astro.request.formData(); - const formDataObject: Record = {}; + const formDataObject: Record = {}; // Changed to 'any' to handle different types + formData.forEach((value, key) => { - formDataObject[key] = value.toString(); + // Values from the attachment input will either be a typeof File or an array of Files. + // They're later normalized into an array during response validation + if (value instanceof File) { + formDataObject[key] = value; + } else if (Array.isArray(value) && value.every(item => item instanceof File)) { + formDataObject[key] = value; + } else { + formDataObject[key] = value.toString(); + } }); const submitFormResult = await ctx.formService.submitForm( @@ -51,15 +60,33 @@ if (Astro.request.method === 'POST') { formDataObject, formRoute ); + if (!submitFormResult.success) { return new Response(submitFormResult.error, { status: 500, }); } + setFormSessionCookie(submitFormResult.data.sessionId); if (submitFormResult.data.attachments) { - return createMultipartResponse(submitFormResult.data.attachments); + if (submitFormResult.data.attachments.length > 1) { + return new Response( + 'Multiple attachments are not supported at this time.', + { + status: 501, + } + ); + } + const attachment = submitFormResult.data.attachments[0]; + return new Response(attachment.data, { + headers: { + 'Content-Type': 'application/pdf', + 'Content-Disposition': `attachment; filename="${attachment.fileName}"`, + 'Content-Length': attachment.data.length.toString(), + }, + status: 200, + }); } return Astro.redirect(getNextUrl(submitFormResult.data.session.route));