Skip to content

Commit

Permalink
Implement preliminary validation logic (#48)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
danielnaab authored Feb 21, 2024
1 parent 5057d00 commit 8595414
Show file tree
Hide file tree
Showing 33 changed files with 960 additions and 712 deletions.
2 changes: 1 addition & 1 deletion documents/adr/0008-initial-form-handling-strategy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 3 additions & 0 deletions packages/design/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions packages/design/src/Form/Form.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@ 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',
component: Form,
decorators: [(Story, args) => <Story {...args} />],
args: {
config: createTestFormConfig(),
form: createTestForm(),
session: createTestSession(),
},
tags: ['autodocs'],
} satisfies Meta<typeof Form>;
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<>
<legend className="usa-legend usa-legend--large">
Submission confirmation
</legend>
<table>
<thead>
<tr>
<th>Field</th>
<th>Value</th>
</tr>
</thead>
<tbody>
{prompt.table.map((row, index) => {
return (
<tr key={index}>
<td>{row.label}</td>
<td>{row.value}</td>
</tr>
);
})}
</tbody>
</table>
</>
);
}
52 changes: 35 additions & 17 deletions packages/design/src/Form/PromptSegment/TextInput/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import classNames from 'classnames';
import React from 'react';
import { useFormContext } from 'react-hook-form';

Expand All @@ -9,24 +10,41 @@ export default function TextInput({ prompt }: TextInputProps) {
const { register } = useFormContext();
return (
<div className="usa-form-group" key={prompt.id}>
<label className="usa-label" htmlFor={prompt.id}>
{prompt.label}
{prompt.required && (
<>
{' '}
<abbr title="required" className="usa-hint usa-hint--required">
*
</abbr>
</>
)}
</label>
<input
className="usa-input"
defaultValue={prompt.value}
{...register(prompt.id, {
required: prompt.required,
<div
className={classNames('usa-form-group', {
'usa-form-group--error': prompt.error,
})}
/>
>
<label
className={classNames('usa-label', {
'usa-label--error': prompt.error,
})}
htmlFor="input-error"
>
{prompt.label}
</label>
{prompt.error && (
<span
className="usa-error-message"
id={`input-error-message-${prompt.id}`}
role="alert"
>
{prompt.error}
</span>
)}
<input
className={classNames('usa-input', {
'usa-input--error': prompt.error,
})}
id={`input-${prompt.id}`}
defaultValue={prompt.value}
{...register(prompt.id, {
//required: prompt.required,
})}
type="text"
aria-describedby={`input-message-${prompt.id}`}
/>
</div>
</div>
);
}
3 changes: 3 additions & 0 deletions packages/design/src/Form/PromptSegment/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -13,6 +14,8 @@ export default function PromptSegment({
return <FormSummary prompt={promptPart} />;
} else if (promptPart.type === 'text') {
return <TextInput prompt={promptPart} />;
} else if (promptPart.type === 'submission-confirmation') {
return <SubmissionConfirmation prompt={promptPart} />;
} else {
const _exhaustiveCheck: never = promptPart; // eslint-disable-line @typescript-eslint/no-unused-vars
return (<></>) as never;
Expand Down
57 changes: 50 additions & 7 deletions packages/design/src/Form/index.tsx
Original file line number Diff line number Diff line change
@@ -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<Prompt>(initialPrompt);
const setPrompt = (newPrompt: Prompt) => {
if (!deepEqual(newPrompt, prompt)) {
_setPrompt(newPrompt);
}
};
const updatePrompt = (data: Record<string, string>) => {
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<string, string>) => 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<Record<string, string>>({});

/**
* Regenerate the prompt whenever the form changes.
const allFormData = formMethods.watch();
useEffect(() => {
updatePrompt(allFormData);
}, [allFormData]);
*/

return (
<FormProvider {...formMethods}>
<form className="previewForm"
onSubmit={formMethods.handleSubmit(async data => {
updatePrompt(data);
if (onSubmit) {
console.log('Submitting form...');
onSubmit(data);
Expand Down
9 changes: 5 additions & 4 deletions packages/design/src/FormManager/FormPreview/index.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 (
<>
<InnerPageTopNav formId={formId} formService={formService} />
<Form
config={config}
form={result.data}
session={session}
onSubmit={async data => {
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);
Expand Down
16 changes: 13 additions & 3 deletions packages/design/src/FormRouter/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -34,12 +34,22 @@ export default function FormRouter({
</div>
);
}
const session = createFormSession(result.data);
return (
<Form
config={config}
form={result.data}
session={session}
onSubmit={async data => {
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);
Expand Down
12 changes: 11 additions & 1 deletion packages/design/src/config/InputElementEdit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const InputElementEdit: FormElementComponent<InputElement> = ({ element }) => {
></input>
</label>
</div>
<div className="grid-col grid-col-4">
<div className="grid-col grid-col-2">
<label className="usa-label">
Default field value
<input
Expand All @@ -29,6 +29,16 @@ const InputElementEdit: FormElementComponent<InputElement> = ({ element }) => {
></input>
</label>
</div>
<div className="grid-col grid-col-2">
<label className="usa-label">
Maximum length
<input
className="usa-input"
type="text"
{...register(`${element.id}.data.maxLength`)}
></input>
</label>
</div>
<div className="grid-col grid-col-2">
<label className="usa-label">
Field type
Expand Down
15 changes: 14 additions & 1 deletion packages/design/src/test-form.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createForm, defaultFormConfig } from '@atj/forms';
import { createForm, createFormSession, defaultFormConfig } from '@atj/forms';

import { defaultFormElementComponent } from './config';

Expand All @@ -17,6 +17,10 @@ export const createTestForm = () => {
data: {
elements: ['element-1', 'element-2'],
},
default: {
elements: [],
},
required: true,
},
{
type: 'input',
Expand All @@ -26,6 +30,8 @@ export const createTestForm = () => {
required: true,
initial: '',
},
default: '',
required: true,
},
{
type: 'input',
Expand All @@ -35,6 +41,8 @@ export const createTestForm = () => {
required: false,
initial: 'test',
},
default: '',
required: true,
},
],
}
Expand All @@ -55,3 +63,8 @@ export const createTestFormContext = () => {
components: defaultFormElementComponent,
};
};

export const createTestSession = () => {
const form = createTestForm();
return createFormSession(form);
};
Loading

0 comments on commit 8595414

Please sign in to comment.