From 2d753c22371d15674a01a6428ca718ff818e94be Mon Sep 17 00:00:00 2001 From: Daniel Naab Date: Fri, 12 Jan 2024 10:30:26 -0600 Subject: [PATCH] Wire in "prompt" and "prompt parts"; use react-hook form in React components. --- apps/spotlight/package.json | 1 + .../react/form-builder/document-importer.tsx | 8 +- .../components/react/form-builder/index.tsx | 52 ------- .../src/components/react/form/edit.tsx | 135 +++++++++++++++++- .../src/components/react/form/index.tsx | 4 +- .../additional-fields.tsx} | 17 ++- .../react/form/prompts/form-summary.tsx | 11 ++ .../components/react/form/prompts/index.tsx | 16 +++ .../react/form/prompts/text-input.tsx | 30 ++++ .../src/components/react/form/view.tsx | 68 +++++---- apps/spotlight/src/lib/form-repo.ts | 2 +- apps/spotlight/src/pages/form-sample.astro | 2 +- .../src/pages/ud105-evicition-form.astro | 4 +- .../0008-initial-form-handling-strategy.md | 2 +- packages/forms/dist/index.js | 73 +++++++++- packages/forms/src/index.ts | 66 ++++----- packages/forms/src/prompts/index.ts | 53 +++++++ pnpm-lock.yaml | 18 ++- tsconfig.json | 3 + 19 files changed, 426 insertions(+), 139 deletions(-) rename apps/spotlight/src/components/react/form/{fields.tsx => prompts/additional-fields.tsx} (92%) create mode 100644 apps/spotlight/src/components/react/form/prompts/form-summary.tsx create mode 100644 apps/spotlight/src/components/react/form/prompts/index.tsx create mode 100644 apps/spotlight/src/components/react/form/prompts/text-input.tsx create mode 100644 packages/forms/src/prompts/index.ts diff --git a/apps/spotlight/package.json b/apps/spotlight/package.json index 95c075b8c..3ead725d5 100644 --- a/apps/spotlight/package.json +++ b/apps/spotlight/package.json @@ -20,6 +20,7 @@ "cheerio": "1.0.0-rc.12", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-hook-form": "^7.49.3", "react-router-dom": "^6.21.1" }, "devDependencies": { diff --git a/apps/spotlight/src/components/react/form-builder/document-importer.tsx b/apps/spotlight/src/components/react/form-builder/document-importer.tsx index 36b298e5d..7eb04e47e 100644 --- a/apps/spotlight/src/components/react/form-builder/document-importer.tsx +++ b/apps/spotlight/src/components/react/form-builder/document-importer.tsx @@ -4,8 +4,8 @@ import { extractFormFieldData, suggestFormDetails } from '@atj/documents'; import { SuggestedForm, UD105_TEST_DATA } from '@atj/documents/src/suggestions'; import { onFileInputChangeGetFile } from '../../../lib/file-input'; -import { FormFieldset } from '../form/view'; -import { FormBuilder } from '.'; +import { FormView } from '../form/view'; +import { EditFieldset } from '../form/edit'; type State = { page: number; suggestedForm?: SuggestedForm }; type Action = @@ -145,7 +145,7 @@ export const DocumentImporter = () => { }); }} > - + ); @@ -161,7 +161,7 @@ export const DocumentImporter = () => { }); }} > - + ); diff --git a/apps/spotlight/src/components/react/form-builder/index.tsx b/apps/spotlight/src/components/react/form-builder/index.tsx index 0fd5a78c7..e69de29bb 100644 --- a/apps/spotlight/src/components/react/form-builder/index.tsx +++ b/apps/spotlight/src/components/react/form-builder/index.tsx @@ -1,52 +0,0 @@ -import React from 'react'; - -import { SuggestedForm } from '@atj/documents/src/suggestions'; - -export const FormBuilder = ({ fields }: { fields: SuggestedForm }) => { - return ( -
- {fields.map((field, index) => { - return ; - })} -
- ); -}; - -const FieldBuilder = ({ field }: { field: SuggestedForm[number] }) => { - const fieldId = `field-${field.id}`; - return ( -
-
- -
-
- -
-
- -
-
- ); -}; diff --git a/apps/spotlight/src/components/react/form/edit.tsx b/apps/spotlight/src/components/react/form/edit.tsx index f4ad7687a..91f50b087 100644 --- a/apps/spotlight/src/components/react/form/edit.tsx +++ b/apps/spotlight/src/components/react/form/edit.tsx @@ -1,7 +1,15 @@ import React from 'react'; +import { useForm } from 'react-hook-form'; import { Link } from 'react-router-dom'; + +import { + Form, + Question, + addQuestions, + createForm, + getFlatFieldList, +} from '@atj/forms'; import { getFormFromStorage, saveFormToStorage } from '../../../lib/form-repo'; -import { addQuestions } from '@atj/forms'; export const FormEdit = ({ formId }: { formId: string }) => { const form = getFormFromStorage(window.localStorage, formId); @@ -46,6 +54,131 @@ export const FormEdit = ({ formId }: { formId: string }) => { View all forms + saveFormToStorage(window.localStorage, formId, form)} + /> + + ); +}; + +type FieldProps = { + fieldType: 'input' | 'textarea'; + label: string; + initial: string; + required: boolean; +}; +type FieldMap = Record; + +const EditForm = ({ + form, + onSave, +}: { + form: Form; + onSave: (form: Form) => void; +}) => { + const formData: FieldMap = Object.fromEntries( + Object.entries(form.questions).map(([key, value]) => { + return [ + key, + { + fieldType: 'input', + label: value.text, + initial: value.initial, + required: value.required, + }, + ]; + }) + ); + const { register, handleSubmit } = useForm({ + defaultValues: formData, + }); + const fields = getFlatFieldList(form); + return ( +
{ + const updatedForm = replaceFormQuestions(form, data); + onSave(updatedForm); + })} + > + +
+ {fields.map((field, index) => { + const fieldId = field.id; + return ( +
+
+ +
+
+ +
+
+ +
+
+
+ + +
+
+
+ ); + })} +
+ + + ); +}; + +const ButtonBar = () => { + return ( +
+
); }; + +const replaceFormQuestions = (form: Form, data: FieldMap) => { + const questions = Object.entries(data).map(([id, field]) => ({ + id, + text: field.label, + initial: field.initial, + required: field.required, + })); + const newForm = createForm(form.summary); + return addQuestions(newForm, questions); +}; diff --git a/apps/spotlight/src/components/react/form/index.tsx b/apps/spotlight/src/components/react/form/index.tsx index 7a06d34aa..d5d6ac38a 100644 --- a/apps/spotlight/src/components/react/form/index.tsx +++ b/apps/spotlight/src/components/react/form/index.tsx @@ -4,7 +4,7 @@ import { useParams, HashRouter, Route, Routes } from 'react-router-dom'; import { FormDelete } from './delete'; import { FormEdit } from './edit'; import { FormList } from './list'; -import { FormView } from './view'; +import { FormViewById } from './view'; export const FormSection = () => { return ( @@ -18,7 +18,7 @@ export const FormSection = () => { if (formId === undefined) { return
formId is undefined
; } - return ; + return ; }} /> { return ( diff --git a/apps/spotlight/src/components/react/form/prompts/form-summary.tsx b/apps/spotlight/src/components/react/form/prompts/form-summary.tsx new file mode 100644 index 000000000..bdb8d8b07 --- /dev/null +++ b/apps/spotlight/src/components/react/form/prompts/form-summary.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +import { FormSummaryPrompt } from '@atj/forms'; + +export const FormSummary = ({ prompt }: { prompt: FormSummaryPrompt }) => { + return ( + + {prompt.title} - {prompt.description} + + ); +}; diff --git a/apps/spotlight/src/components/react/form/prompts/index.tsx b/apps/spotlight/src/components/react/form/prompts/index.tsx new file mode 100644 index 000000000..7171f582d --- /dev/null +++ b/apps/spotlight/src/components/react/form/prompts/index.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +import { type PromptPart } from '@atj/forms'; +import { FormSummary } from './form-summary'; +import { TextInput } from './text-input'; + +export const PromptSegment = ({ promptPart }: { promptPart: PromptPart }) => { + if (promptPart.type === 'form-summary') { + return ; + } else if (promptPart.type === 'text') { + return ; + } else { + const _exhaustiveCheck: never = promptPart; + return <>; + } +}; diff --git a/apps/spotlight/src/components/react/form/prompts/text-input.tsx b/apps/spotlight/src/components/react/form/prompts/text-input.tsx new file mode 100644 index 000000000..f297d01cf --- /dev/null +++ b/apps/spotlight/src/components/react/form/prompts/text-input.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { useFormContext } from 'react-hook-form'; + +import { type TextInputPrompt } from '@atj/forms'; + +export const TextInput = ({ prompt }: { prompt: TextInputPrompt }) => { + const { register } = useFormContext(); + return ( +
+ + +
+ ); +}; diff --git a/apps/spotlight/src/components/react/form/view.tsx b/apps/spotlight/src/components/react/form/view.tsx index 5760f62e3..daaa9ea81 100644 --- a/apps/spotlight/src/components/react/form/view.tsx +++ b/apps/spotlight/src/components/react/form/view.tsx @@ -1,38 +1,21 @@ import React from 'react'; -import { - TextField, - BooleanField, - CheckBoxField, - SelectField, - RadioField, - DateField, - TextareaField, - ParagraphBlock, - Header3Block, - UnorderedList, -} from './fields'; +import { FormProvider, useForm } from 'react-hook-form'; + +import { Prompt, createFormContext, createPrompt } from '@atj/forms'; import { getFormFromStorage } from '../../../lib/form-repo'; -import { createFormContext, createPrompt } from '@atj/forms'; +import { PromptSegment } from './prompts'; // Assuming this is the structure of your JSON data export interface Field { - tag: string; - type: string; - name: string; + type: 'text'; id: string; - class: string; - value?: string; + name: string; label: string; - title?: string; - required?: boolean; - options?: { name: string; value: string }[]; // For select and radio fields - items?: { tag: string; content: string }[]; - arialabelledby?: string; - ariadescribedby?: string; - linkurl?: string; + required: boolean; + initial?: string; } -export const FormView = ({ formId }: { formId: string }) => { +export const FormViewById = ({ formId }: { formId: string }) => { // Fallback to hardcoded data if a magic ID is chosen. const form = getFormFromStorage(window.localStorage, formId); if (!form) { @@ -40,10 +23,29 @@ export const FormView = ({ formId }: { formId: string }) => { } const context = createFormContext(form); const prompt = createPrompt(context); - return ; + + return ; +}; + +export const FormView = ({ prompt }: { prompt: Prompt }) => { + const formMethods = useForm>({}); + return ( + +
+
+ {prompt.map((promptPart, index) => ( + + ))} + {/* Add submit button or other controls as needed */} +
+ + +
+ ); }; -export const FormFieldset = ({ fields }: { fields: Field[] }) => { +/* +export const FormFieldsetUnwired = ({ fields }: { fields: Field[] }) => { return (
@@ -88,7 +90,15 @@ export const FormFieldset = ({ fields }: { fields: Field[] }) => { return null; } })} - {/* Add submit button or other controls as needed */}
); }; +*/ + +const ButtonBar = () => { + return ( +
+ +
+ ); +}; diff --git a/apps/spotlight/src/lib/form-repo.ts b/apps/spotlight/src/lib/form-repo.ts index 1a4634ad5..3a48de2dc 100644 --- a/apps/spotlight/src/lib/form-repo.ts +++ b/apps/spotlight/src/lib/form-repo.ts @@ -1,4 +1,4 @@ -import { Form, FormSummary, createForm } from '@atj/forms/src'; +import { Form, FormSummary, createForm } from '@atj/forms'; export const getFormFromStorage = ( storage: Storage, diff --git a/apps/spotlight/src/pages/form-sample.astro b/apps/spotlight/src/pages/form-sample.astro index bc8afce75..dd3373af3 100644 --- a/apps/spotlight/src/pages/form-sample.astro +++ b/apps/spotlight/src/pages/form-sample.astro @@ -47,6 +47,6 @@ const sampleInterview = createSequentialInterview({ }); --- - + diff --git a/apps/spotlight/src/pages/ud105-evicition-form.astro b/apps/spotlight/src/pages/ud105-evicition-form.astro index 7a3364cd9..5c09d8ac9 100644 --- a/apps/spotlight/src/pages/ud105-evicition-form.astro +++ b/apps/spotlight/src/pages/ud105-evicition-form.astro @@ -1,9 +1,9 @@ --- -import { FormFieldset } from '../components/react/form/view'; +import { FormView } from '../components/react/form/view'; import formData from '../htmlParser/ud105-form-field-output.json'; import ContentLayout from '../layouts/ContentLayout.astro'; --- - + diff --git a/documents/adr/0008-initial-form-handling-strategy.md b/documents/adr/0008-initial-form-handling-strategy.md index 188348a15..f52c0ddc5 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. -Native React components will be bound for form library functionality using lightweight, manual integrations. 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 it; This will enable creating 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/forms/dist/index.js b/packages/forms/dist/index.js index d8b1c782e..a832997be 100644 --- a/packages/forms/dist/index.js +++ b/packages/forms/dist/index.js @@ -20,19 +20,49 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru // src/index.ts var src_exports = {}; __export(src_exports, { + addQuestions: () => addQuestions, createForm: () => createForm, createFormContext: () => createFormContext, + createPrompt: () => createPrompt, + getFlatFieldList: () => getFlatFieldList, updateForm: () => updateForm }); module.exports = __toCommonJS(src_exports); + +// src/prompts/index.ts +var createPrompt = (formContext) => { + const parts = [ + { + type: "form-summary", + title: formContext.form.summary.title, + description: formContext.form.summary.description + } + ]; + if (formContext.form.strategy.type === "sequential") { + parts.push( + ...formContext.form.strategy.order.map((questionId) => { + const question = formContext.form.questions[questionId]; + return { + type: "text", + id: question.id, + value: formContext.context.values[questionId], + label: question.text, + required: question.required + }; + }) + ); + } else if (formContext.form.strategy.type === "null") { + } else { + const _exhaustiveCheck = formContext.form.strategy; + } + return parts; +}; + +// src/index.ts var createForm = (summary, questions = []) => { return { summary, - questions: Object.fromEntries( - questions.map((question) => { - return [question.id, question]; - }) - ), + questions: getQuestionMap(questions), strategy: { type: "sequential", order: questions.map((question) => { @@ -85,9 +115,42 @@ var addError = (form, id, error) => ({ } } }); +var getQuestionMap = (questions) => { + return Object.fromEntries( + questions.map((question) => { + return [question.id, question]; + }) + ); +}; +var addQuestions = (form, questions) => { + const questionMap = getQuestionMap(questions); + return { + ...form, + questions: { ...form.questions, ...questionMap }, + strategy: { + ...form.strategy, + order: [...form.strategy.order, ...Object.keys(questionMap)] + } + }; +}; +var getFlatFieldList = (form) => { + if (form.strategy.type === "sequential") { + return form.strategy.order.map((questionId) => { + return form.questions[questionId]; + }); + } else if (form.strategy.type === "null") { + return []; + } else { + const _exhaustiveCheck = form.strategy; + return _exhaustiveCheck; + } +}; // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { + addQuestions, createForm, createFormContext, + createPrompt, + getFlatFieldList, updateForm }); diff --git a/packages/forms/src/index.ts b/packages/forms/src/index.ts index d60c03497..9f9266a5c 100644 --- a/packages/forms/src/index.ts +++ b/packages/forms/src/index.ts @@ -1,3 +1,5 @@ +export * from './prompts'; + type QuestionId = string; export type Question = { @@ -15,18 +17,18 @@ export type FormSummary = { description: string; }; -export type Form = { +export type Form = { summary: FormSummary; questions: Record; strategy: T; }; -export type FormContext = { +export type FormContext = { context: { errors: ErrorMap; values: QuestionValueMap; }; - form: Form; + form: Form; }; export type SequentialStrategy = { @@ -56,7 +58,9 @@ export const createForm = ( }; }; -export const createFormContext = (form: Form): FormContext => { +export const createFormContext = ( + form: Form +): FormContext => { return { context: { errors: {}, @@ -70,33 +74,8 @@ export const createFormContext = (form: Form): FormContext => { }; }; -// For now, a prompt just returns an array of questions. This will likely need -// to be filled out to support more complicated display formats. -export const createPrompt = (formContext: FormContext) => { - if (formContext.form.strategy.type === 'sequential') { - return formContext.form.strategy.order.map(questionId => { - const question = formContext.form.questions[questionId]; - // This is the structure currently used by FormFieldset in the Astro app. - // FIXME: Shore up this type and add to the forms package. - return { - tag: 'input', - type: 'text', - name: question.id, - id: question.id, - value: formContext.context.values[questionId], - label: question.text, - }; - }); - } else if (formContext.form.strategy.type === 'null') { - return []; - } else { - const _exhaustiveCheck: never = formContext.form.strategy; - return _exhaustiveCheck; - } -}; - -export const updateForm = ( - context: FormContext, +export const updateForm = ( + context: FormContext, id: QuestionId, value: any ) => { @@ -111,11 +90,11 @@ export const updateForm = ( return nextForm; }; -const addValue = ( - form: FormContext, +const addValue = ( + form: FormContext, id: QuestionId, value: QuestionValue -): FormContext => ({ +): FormContext => ({ ...form, context: { ...form.context, @@ -126,11 +105,11 @@ const addValue = ( }, }); -const addError = ( - form: FormContext, +const addError = ( + form: FormContext, id: QuestionId, error: string -): FormContext => ({ +): FormContext => ({ ...form, context: { ...form.context, @@ -163,3 +142,16 @@ export const addQuestions = ( }, }; }; + +export const getFlatFieldList = (form: Form) => { + if (form.strategy.type === 'sequential') { + return form.strategy.order.map(questionId => { + return form.questions[questionId]; + }); + } else if (form.strategy.type === 'null') { + return []; + } else { + const _exhaustiveCheck: never = form.strategy; + return _exhaustiveCheck; + } +}; diff --git a/packages/forms/src/prompts/index.ts b/packages/forms/src/prompts/index.ts new file mode 100644 index 000000000..9e216ef74 --- /dev/null +++ b/packages/forms/src/prompts/index.ts @@ -0,0 +1,53 @@ +// For now, a prompt just returns an array of questions. This will likely need + +import { FormContext, FormStrategy } from '..'; + +export type TextInputPrompt = { + type: 'text'; + id: string; + value: string; + label: string; + required: boolean; +}; + +export type FormSummaryPrompt = { + type: 'form-summary'; + title: string; + description: string; +}; + +export type PromptPart = FormSummaryPrompt | TextInputPrompt; + +export type Prompt = PromptPart[]; + +// to be filled out to support more complicated display formats. +export const createPrompt = ( + formContext: FormContext +): Prompt => { + const parts: PromptPart[] = [ + { + type: 'form-summary', + title: formContext.form.summary.title, + description: formContext.form.summary.description, + }, + ]; + if (formContext.form.strategy.type === 'sequential') { + parts.push( + ...formContext.form.strategy.order.map(questionId => { + const question = formContext.form.questions[questionId]; + return { + type: 'text' as const, + id: question.id, + value: formContext.context.values[questionId], + label: question.text, + required: question.required, + }; + }) + ); + } else if (formContext.form.strategy.type === 'null') { + } else { + const _exhaustiveCheck: never = formContext.form.strategy; + } + + return parts; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6153a0336..48adf3740 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -103,6 +103,9 @@ importers: react-dom: specifier: ^18.2.0 version: 18.2.0(react@18.2.0) + react-hook-form: + specifier: ^7.49.3 + version: 7.49.3(react@18.2.0) react-router-dom: specifier: ^6.21.1 version: 6.21.1(react-dom@18.2.0)(react@18.2.0) @@ -4523,7 +4526,7 @@ packages: dependencies: semver: 7.5.4 shelljs: 0.8.5 - typescript: 5.4.0-dev.20240105 + typescript: 5.4.0-dev.20240108 dev: false /dset@3.1.3: @@ -9141,6 +9144,15 @@ packages: scheduler: 0.23.0 dev: false + /react-hook-form@7.49.3(react@18.2.0): + resolution: {integrity: sha512-foD6r3juidAT1cOZzpmD/gOKt7fRsDhXXZ0y28+Al1CHgX+AY1qIN9VSIIItXRq1dN68QrRwl1ORFlwjBaAqeQ==} + engines: {node: '>=18', pnpm: '8'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 + dependencies: + react: 18.2.0 + dev: false + /react-is@18.2.0: resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==} dev: true @@ -10878,8 +10890,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - /typescript@5.4.0-dev.20240105: - resolution: {integrity: sha512-EShumrhMWO0vjg0BpYtIOOWicTRcWnku+fJfLxnjo4P4h3/Zkz9jkmAbtWqb1c6SPNNrL1zGgVNX38dYZlMinQ==} + /typescript@5.4.0-dev.20240108: + resolution: {integrity: sha512-flXeU+FYwW3mL6zcOz1lNX0juSolUIeIRs4nO8j77jB+N/3lrqWNOSz05dzRC4eYJcjFIIOvv5y9u8MQ5nTtzg==} engines: {node: '>=14.17'} hasBin: true dev: false diff --git a/tsconfig.json b/tsconfig.json index 08cbcf2ae..df44dd7bb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -28,6 +28,9 @@ }, { "path": "./packages/interviews/tsconfig.json" + }, + { + "path": "./apps/spotlight/tsconfig.json" } ], "files": [