From 85954143f83e942c195cbaa9da8a8b4fa7d975af Mon Sep 17 00:00:00 2001 From: Daniel Naab Date: Wed, 21 Feb 2024 10:40:31 -0600 Subject: [PATCH] Implement preliminary validation logic (#48) * In-progress, looking for an old commit or stash... * Handle errors in the prompt response * Add a "submission-confirmation" prompt type, and display when a form's session is complete. "Complete" is defined as every values in the form is valid. TODO: implement the validation with a zod parser. * Wire input validation up, using zod. * To make validation interactive on the client-side, update the prompt in response to input changes. * Fully wire up the validation so it works with the UI and form submission. TODO: implement backend session storage and improve the shape of the form service interface * Fix missed tests --- .../0008-initial-form-handling-strategy.md | 2 +- packages/design/package.json | 3 + packages/design/src/Form/Form.stories.tsx | 4 +- .../SubmissionConfirmation.stories.tsx | 0 .../SubmissionConfirmation/index.tsx | 37 + .../Form/PromptSegment/TextInput/index.tsx | 52 +- .../design/src/Form/PromptSegment/index.tsx | 3 + packages/design/src/Form/index.tsx | 57 +- .../src/FormManager/FormPreview/index.tsx | 9 +- packages/design/src/FormRouter/index.tsx | 16 +- .../design/src/config/InputElementEdit.tsx | 12 +- packages/design/src/test-form.ts | 15 +- packages/documents/src/document.ts | 22 +- .../form-service/src/context/browser/index.ts | 8 +- .../src/context/browser/session-repo.ts | 66 ++ .../form-service/src/context/test/index.ts | 3 + .../src/operations/submit-form.test.ts | 15 +- .../src/operations/submit-form.ts | 49 +- packages/form-service/src/types.ts | 4 +- packages/forms/package.json | 5 +- packages/forms/src/config/elements/input.ts | 32 +- .../forms/src/config/elements/sequence.ts | 18 +- packages/forms/src/config/index.ts | 11 +- packages/forms/src/element.ts | 73 ++ packages/forms/src/elements.ts | 24 - packages/forms/src/index.ts | 21 +- packages/forms/src/prompt.ts | 106 +++ packages/forms/src/prompts.ts | 74 -- packages/forms/src/response.ts | 71 ++ packages/forms/src/session.ts | 128 ++++ packages/forms/src/util/zod.ts | 23 + packages/forms/tests/two-field-form.test.ts | 20 +- pnpm-lock.yaml | 689 ++++-------------- 33 files changed, 960 insertions(+), 712 deletions(-) create mode 100644 packages/design/src/Form/PromptSegment/SubmissionConfirmation/SubmissionConfirmation.stories.tsx create mode 100644 packages/design/src/Form/PromptSegment/SubmissionConfirmation/index.tsx create mode 100644 packages/form-service/src/context/browser/session-repo.ts create mode 100644 packages/forms/src/element.ts delete mode 100644 packages/forms/src/elements.ts create mode 100644 packages/forms/src/prompt.ts delete mode 100644 packages/forms/src/prompts.ts create mode 100644 packages/forms/src/response.ts create mode 100644 packages/forms/src/session.ts create mode 100644 packages/forms/src/util/zod.ts diff --git a/documents/adr/0008-initial-form-handling-strategy.md b/documents/adr/0008-initial-form-handling-strategy.md index f52c0ddc..ddd398df 100644 --- a/documents/adr/0008-initial-form-handling-strategy.md +++ b/documents/adr/0008-initial-form-handling-strategy.md @@ -22,7 +22,7 @@ The project team will, initially, identify lightweight approaches that are easil Initially, forms will be implemented as a collection of React components. Each component will be isolated and be configured via props. -The `react-hook-form` library will be utilized. This library is widely used and provides a simple way to bind dynamic behavior to forms. Additionally, it works well with unmanaged React forms, which we want to maintain options around. To be thoughtful about the surface area over this library, we will wrap it; This will enable creating a lightweight interface that does just what we need, without potential impedance mismatch. +The `react-hook-form` library will be utilized. This library is widely used and provides a simple way to bind dynamic behavior to forms. Additionally, it works well with unmanaged React forms, which we want to maintain options around. To be thoughtful about the surface area over this library, we will wrap components with its Controller component, which will enable a lightweight interface that does just what we need, without potential impedance mismatch. Initially, a simple and bespoke declarative format for form definitions will be utilized. This format will be augmented as integrations are added, and supported interview flows are increased. diff --git a/packages/design/package.json b/packages/design/package.json index 6324f594..8b8350c8 100644 --- a/packages/design/package.json +++ b/packages/design/package.json @@ -38,6 +38,7 @@ "@storybook/test-runner": "^0.16.0", "@storybook/types": "^7.6.10", "@testing-library/react": "^14.1.2", + "@types/deep-equal": "^1.0.4", "@types/prop-types": "^15.7.11", "@types/react": "^18.2.48", "@typescript-eslint/eslint-plugin": "^6.19.1", @@ -68,6 +69,8 @@ "@dnd-kit/sortable": "^8.0.0", "@dnd-kit/utilities": "^3.2.2", "@uswds/uswds": "^3.7.1", + "classnames": "^2.5.1", + "deep-equal": "^2.2.3", "react-hook-form": "^7.49.3", "react-router-dom": "^6.21.2", "storybook": "^7.6.10" diff --git a/packages/design/src/Form/Form.stories.tsx b/packages/design/src/Form/Form.stories.tsx index a8e834a9..c99383db 100644 --- a/packages/design/src/Form/Form.stories.tsx +++ b/packages/design/src/Form/Form.stories.tsx @@ -2,7 +2,7 @@ import React from 'react'; import type { Meta, StoryObj } from '@storybook/react'; import Form from '.'; -import { createTestForm, createTestFormConfig } from '../test-form'; +import { createTestFormConfig, createTestSession } from '../test-form'; export default { title: 'Form', @@ -10,7 +10,7 @@ export default { decorators: [(Story, args) => ], args: { config: createTestFormConfig(), - form: createTestForm(), + session: createTestSession(), }, tags: ['autodocs'], } satisfies Meta; diff --git a/packages/design/src/Form/PromptSegment/SubmissionConfirmation/SubmissionConfirmation.stories.tsx b/packages/design/src/Form/PromptSegment/SubmissionConfirmation/SubmissionConfirmation.stories.tsx new file mode 100644 index 00000000..e69de29b diff --git a/packages/design/src/Form/PromptSegment/SubmissionConfirmation/index.tsx b/packages/design/src/Form/PromptSegment/SubmissionConfirmation/index.tsx new file mode 100644 index 00000000..5e5dada9 --- /dev/null +++ b/packages/design/src/Form/PromptSegment/SubmissionConfirmation/index.tsx @@ -0,0 +1,37 @@ +import React from 'react'; + +import { type SubmissionConfirmationPrompt } from '@atj/forms'; + +export type SubmissionConfirmationProps = { + prompt: SubmissionConfirmationPrompt; +}; + +export default function SubmissionConfirmation({ + prompt, +}: SubmissionConfirmationProps) { + return ( + <> + + Submission confirmation + + + + + + + + + + {prompt.table.map((row, index) => { + return ( + + + + + ); + })} + +
FieldValue
{row.label}{row.value}
+ + ); +} diff --git a/packages/design/src/Form/PromptSegment/TextInput/index.tsx b/packages/design/src/Form/PromptSegment/TextInput/index.tsx index cdcc6c58..d7bd29cb 100644 --- a/packages/design/src/Form/PromptSegment/TextInput/index.tsx +++ b/packages/design/src/Form/PromptSegment/TextInput/index.tsx @@ -1,3 +1,4 @@ +import classNames from 'classnames'; import React from 'react'; import { useFormContext } from 'react-hook-form'; @@ -9,24 +10,41 @@ export default function TextInput({ prompt }: TextInputProps) { const { register } = useFormContext(); return (
- - + > + + {prompt.error && ( + + {prompt.error} + + )} + +
); } diff --git a/packages/design/src/Form/PromptSegment/index.tsx b/packages/design/src/Form/PromptSegment/index.tsx index b3c751c4..237785b3 100644 --- a/packages/design/src/Form/PromptSegment/index.tsx +++ b/packages/design/src/Form/PromptSegment/index.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { type PromptPart } from '@atj/forms'; import FormSummary from './FormSummary'; +import SubmissionConfirmation from './SubmissionConfirmation'; import TextInput from './TextInput'; export default function PromptSegment({ @@ -13,6 +14,8 @@ export default function PromptSegment({ return ; } else if (promptPart.type === 'text') { return ; + } else if (promptPart.type === 'submission-confirmation') { + return ; } else { const _exhaustiveCheck: never = promptPart; // eslint-disable-line @typescript-eslint/no-unused-vars return (<>) as never; diff --git a/packages/design/src/Form/index.tsx b/packages/design/src/Form/index.tsx index cd7ca544..334cf531 100644 --- a/packages/design/src/Form/index.tsx +++ b/packages/design/src/Form/index.tsx @@ -1,33 +1,76 @@ -import React from 'react'; +import deepEqual from 'deep-equal'; +import React, { useState } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import { - createFormSession, + applyPromptResponse, createPrompt, type FormConfig, - type FormDefinition, + type FormSession, + type Prompt, } from '@atj/forms'; import PromptSegment from './PromptSegment'; import ActionBar from './ActionBar'; +const usePrompt = ( + initialPrompt: Prompt, + config: FormConfig, + session: FormSession +) => { + const [prompt, _setPrompt] = useState(initialPrompt); + const setPrompt = (newPrompt: Prompt) => { + if (!deepEqual(newPrompt, prompt)) { + _setPrompt(newPrompt); + } + }; + const updatePrompt = (data: Record) => { + const result = applyPromptResponse( + config, + session, + { + action: 'submit', + data, + }, + { validate: true } + ); + if (!result.success) { + console.warn('Error applying prompt response...', result.error); + return; + } + const prompt = createPrompt(config, result.data, { validate: true }); + setPrompt(prompt); + }; + return { prompt, updatePrompt }; +}; + export default function Form({ config, - form, + session, onSubmit, }: { config: FormConfig; - form: FormDefinition; + session: FormSession; onSubmit?: (data: Record) => void; }) { - const session = createFormSession(form); - const prompt = createPrompt(config, session); + const initialPrompt = createPrompt(config, session, { validate: false }); + const { prompt, updatePrompt } = usePrompt(initialPrompt, config, session); const formMethods = useForm>({}); + + /** + * Regenerate the prompt whenever the form changes. + const allFormData = formMethods.watch(); + useEffect(() => { + updatePrompt(allFormData); + }, [allFormData]); + */ + return (
{ + updatePrompt(data); if (onSubmit) { console.log('Submitting form...'); onSubmit(data); diff --git a/packages/design/src/FormManager/FormPreview/index.tsx b/packages/design/src/FormManager/FormPreview/index.tsx index a0e6b15f..a31c5e8f 100644 --- a/packages/design/src/FormManager/FormPreview/index.tsx +++ b/packages/design/src/FormManager/FormPreview/index.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { type FormConfig } from '@atj/forms'; +import { type FormConfig, createFormSession } from '@atj/forms'; import { type FormService } from '@atj/form-service'; import Form from '../../Form'; @@ -20,15 +20,16 @@ export const FormViewById = ({ if (!result.success) { return 'null form retrieved from storage'; } - + const form = result.data; + const session = createFormSession(form); return ( <> { - const submission = await formService.submitForm(formId, data); + const submission = await formService.submitForm(session, formId, data); if (submission.success) { submission.data.forEach(document => { downloadPdfDocument(document.fileName, document.data); diff --git a/packages/design/src/FormRouter/index.tsx b/packages/design/src/FormRouter/index.tsx index b59082a4..4846ef4f 100644 --- a/packages/design/src/FormRouter/index.tsx +++ b/packages/design/src/FormRouter/index.tsx @@ -3,7 +3,7 @@ import { useParams, HashRouter, Route, Routes } from 'react-router-dom'; import { type FormService } from '@atj/form-service'; import Form from '../Form'; -import { type FormConfig } from '@atj/forms'; +import { createFormSession, type FormConfig } from '@atj/forms'; // Wrapper around Form that includes a client-side router for loading forms. export default function FormRouter({ @@ -34,12 +34,22 @@ export default function FormRouter({ ); } + const session = createFormSession(result.data); return ( { - const submission = await formService.submitForm(formId, data); + /*const newSession = applyPromptResponse( + config, + session, + response + );*/ + const submission = await formService.submitForm( + session, + formId, + data + ); if (submission.success) { submission.data.forEach(document => { downloadPdfDocument(document.fileName, document.data); diff --git a/packages/design/src/config/InputElementEdit.tsx b/packages/design/src/config/InputElementEdit.tsx index fc465c6d..5b181e75 100644 --- a/packages/design/src/config/InputElementEdit.tsx +++ b/packages/design/src/config/InputElementEdit.tsx @@ -19,7 +19,7 @@ const InputElementEdit: FormElementComponent = ({ element }) => { > -
+
+
+ +