diff --git a/apps/form-service/.gitignore b/apps/rest-api/.gitignore similarity index 100% rename from apps/form-service/.gitignore rename to apps/rest-api/.gitignore diff --git a/apps/form-service/package.json b/apps/rest-api/package.json similarity index 88% rename from apps/form-service/package.json rename to apps/rest-api/package.json index 62d842abf..a6a4e103e 100644 --- a/apps/form-service/package.json +++ b/apps/rest-api/package.json @@ -1,5 +1,5 @@ { - "name": "@atj/form-service", + "name": "@atj/form-rest-api", "private": true, "description": "backend service for handling submitted forms", "main": "src/index.ts", @@ -10,7 +10,7 @@ "dev": "tsup src/* --watch" }, "dependencies": { - "@atj/interviews": "workspace:*" + "@atj/form-service": "workspace:*" }, "devDependencies": { "@types/aws-lambda": "^8.10.109", diff --git a/apps/form-service/src/index.ts b/apps/rest-api/src/index.ts similarity index 100% rename from apps/form-service/src/index.ts rename to apps/rest-api/src/index.ts diff --git a/apps/form-service/tsconfig.json b/apps/rest-api/tsconfig.json similarity index 100% rename from apps/form-service/tsconfig.json rename to apps/rest-api/tsconfig.json diff --git a/apps/spotlight/astro.config.mjs b/apps/spotlight/astro.config.mjs index 9082e4d03..0eca8682c 100644 --- a/apps/spotlight/astro.config.mjs +++ b/apps/spotlight/astro.config.mjs @@ -7,7 +7,7 @@ const githubRepository = await getGithubRepository(process.env); // https://astro.build/config export default defineConfig({ - base: process.env.BASEURL || '/', + base: addTrailingSlash(process.env.BASEURL || ''), integrations: [ react({ include: ['src/components/react/**'], @@ -19,3 +19,11 @@ export default defineConfig({ }, }, }); + +function addTrailingSlash(path) { + var lastChar = path.substr(-1); + if (lastChar === '/') { + return path; + } + return path + '/'; +} diff --git a/apps/spotlight/package.json b/apps/spotlight/package.json index ee6db8ee0..d15bd2f30 100644 --- a/apps/spotlight/package.json +++ b/apps/spotlight/package.json @@ -12,13 +12,16 @@ "dependencies": { "@astrojs/react": "^3.0.3", "@atj/design": "workspace:*", - "@atj/docassemble": "workspace:*", "@atj/documents": "workspace:*", + "@atj/form-service": "workspace:*", + "@atj/forms": "workspace:*", "@atj/interviews": "workspace:*", "astro": "^3.5.4", "cheerio": "1.0.0-rc.12", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-hook-form": "^7.49.3", + "react-router-dom": "^6.21.1" }, "devDependencies": { "@astrojs/check": "^0.3.1", diff --git a/apps/spotlight/public/sample-documents b/apps/spotlight/public/sample-documents new file mode 120000 index 000000000..d48f4bfb0 --- /dev/null +++ b/apps/spotlight/public/sample-documents @@ -0,0 +1 @@ +../../../packages/documents/samples \ No newline at end of file diff --git a/apps/spotlight/src/components/react/document-assembler.tsx b/apps/spotlight/src/components/react/document-assembler.tsx deleted file mode 100644 index 6894ee8fc..000000000 --- a/apps/spotlight/src/components/react/document-assembler.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import React from 'react'; -import { generateDummyPDF } from '@atj/documents'; - -export const downloadPdfBytes = (bytes: Uint8Array) => { - const base64 = btoa(String.fromCharCode(...bytes)); - var element = document.createElement('a'); - element.setAttribute( - 'href', - 'data:application/pdf;base64,' + encodeURIComponent(base64) - ); - element.setAttribute('download', 'sample-document.pdf'); - element.style.display = 'none'; - document.body.appendChild(element); - element.click(); - document.body.removeChild(element); -}; - -const clickHandler = async () => { - const timestamp = new Date().toISOString(); - const pdfBytes = await generateDummyPDF({}); - downloadPdfBytes(pdfBytes); -}; - -export const DocumentAssembler = () => { - return ( -
- -
- ); -}; diff --git a/apps/spotlight/src/components/react/document-importer.tsx b/apps/spotlight/src/components/react/document-importer.tsx deleted file mode 100644 index 5d36d6498..000000000 --- a/apps/spotlight/src/components/react/document-importer.tsx +++ /dev/null @@ -1,185 +0,0 @@ -import React, { PropsWithChildren, useReducer } from 'react'; - -import { extractFormFieldData, suggestFormDetails } from '@atj/documents'; -import { SuggestedForm, UD105_TEST_DATA } from '@atj/documents/src/suggestions'; - -import { onFileInputChangeGetFile } from '../../lib/file-input'; -import DynamicFormFieldset from './dynamic-form'; -import { FormBuilder } from './form-builder'; - -type State = { page: number; suggestedForm?: SuggestedForm }; -type Action = - | { type: 'SELECT_PDF'; data: SuggestedForm } - | { - type: 'SAVE_FORM_FIELDS'; - data: SuggestedForm; - } - | { - type: 'GOTO_PAGE'; - page: number; - }; - -export const DocumentImporter = () => { - const [state, dispatch] = useReducer( - (state: State, action: Action) => { - if (action.type === 'SELECT_PDF') { - return { - page: 2, - suggestedForm: action.data, - }; - } - if (action.type === 'SAVE_FORM_FIELDS') { - return { - page: 3, - suggestedForm: action.data, - }; - } - if (action.type === 'GOTO_PAGE') { - return { - ...state, - page: action.page, - }; - } - return state; - }, - { page: 1 } - ); - - const Step: React.FC< - PropsWithChildren<{ title: string; step: number; current: number }> - > = ({ children, title, step, current }) => { - if (current === step) { - return ( -
  • - - {title} - {children} - -
  • - ); - } else if (current < step) { - return ( -
  • - - {title} - {children} - not completed - -
  • - ); - } else { - return ( -
  • - -
  • - ); - } - }; - - const PDFFileSelect = () => { - return ( -
    -
    - Select a single PDF file -
    -
    -
    - -
    - { - const fieldData = await extractFormFieldData(fileDetails.data); - const fieldInfo = suggestFormDetails(fieldData); - dispatch({ type: 'SELECT_PDF', data: fieldInfo }); - })} - /> -
    -
    - -
    - ); - }; - - const ButtonBar = () => { - return ( -
    - -
    - ); - }; - - const BuildFormPage = () => { - return ( -
    { - dispatch({ - type: 'SAVE_FORM_FIELDS', - data: state.suggestedForm as SuggestedForm, - }); - }} - > - - - - ); - }; - const PreviewFormPage = () => { - return ( -
    { - dispatch({ - type: 'SAVE_FORM_FIELDS', - data: state.suggestedForm as SuggestedForm, - }); - }} - > - - - - ); - }; - - return ( -
    -

    Create an interview from PDF

    -
    -
      - - - -
    -
    - {state.page === 1 && } - {state.page === 2 && } - {state.page === 3 && } -
    - ); -}; diff --git a/apps/spotlight/src/components/react/experiments/document-assembler.tsx b/apps/spotlight/src/components/react/experiments/document-assembler.tsx new file mode 100644 index 000000000..e9188584b --- /dev/null +++ b/apps/spotlight/src/components/react/experiments/document-assembler.tsx @@ -0,0 +1,51 @@ +import React, { useState } from 'react'; +import { generateDummyPDF } from '@atj/documents'; + +export const downloadPdfBytes = (bytes: Uint8Array) => { + const base64 = btoa(String.fromCharCode(...bytes)); + var element = document.createElement('a'); + element.setAttribute( + 'href', + 'data:application/pdf;base64,' + encodeURIComponent(base64) + ); + element.setAttribute('download', 'sample-document.pdf'); + element.style.display = 'none'; + document.body.appendChild(element); + element.click(); + document.body.removeChild(element); +}; + +const generatePDF = async () => { + const timestamp = new Date().toISOString(); + const pdfBytes = await generateDummyPDF({ timestamp }); + downloadPdfBytes(pdfBytes); +}; + +const previewPDF = async setPreviewPdfUrl => { + const timestamp = new Date().toISOString(); + const pdfBytes = await generateDummyPDF({ timestamp }); + const pdfBlob = new Blob([pdfBytes], { type: 'application/pdf' }); + setPreviewPdfUrl(URL.createObjectURL(pdfBlob)); +}; + +export const DocumentAssembler = () => { + const [previewPdfUrl, setPreviewPdfUrl] = useState(); + return ( +
    + + +
    + {previewPdfUrl ? ( + + ) : null} +
    +
    + ); +}; diff --git a/apps/spotlight/src/components/react/interview-form.tsx b/apps/spotlight/src/components/react/experiments/interview-form.tsx similarity index 96% rename from apps/spotlight/src/components/react/interview-form.tsx rename to apps/spotlight/src/components/react/experiments/interview-form.tsx index 724e8d3b9..de4ce55d9 100644 --- a/apps/spotlight/src/components/react/interview-form.tsx +++ b/apps/spotlight/src/components/react/experiments/interview-form.tsx @@ -5,8 +5,8 @@ import { createInterviewContext, nextContext, } from '@atj/interviews'; -import { Field } from '@atj/interviews/src/prompt'; -import { BooleanFact, TextFact } from '@atj/interviews/src/fact'; +import { Field } from '@atj/interviews'; +import { BooleanFact, TextFact } from '@atj/interviews'; const form = { action: 'https://yaawr84uu7.execute-api.us-east-2.amazonaws.com', diff --git a/apps/spotlight/src/components/react/form-builder.tsx b/apps/spotlight/src/components/react/form-builder.tsx deleted file mode 100644 index 5e3a0e43b..000000000 --- a/apps/spotlight/src/components/react/form-builder.tsx +++ /dev/null @@ -1,53 +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 ( -
    -
    -
    id
    -
    {field.id}
    -
    name
    -
    {field.name}
    -
    - - - - -
    - ); -}; diff --git a/apps/spotlight/src/components/react/form-builder/document-importer.tsx b/apps/spotlight/src/components/react/form-builder/document-importer.tsx new file mode 100644 index 000000000..1247b0940 --- /dev/null +++ b/apps/spotlight/src/components/react/form-builder/document-importer.tsx @@ -0,0 +1,301 @@ +import React, { PropsWithChildren, useReducer } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { addDocument, addDocumentFieldsToForm } from '@atj/documents'; +import { createBrowserFormService } from '@atj/form-service'; +import { + type DocumentFieldMap, + type Form, + createFormContext, + createPrompt, +} from '@atj/forms'; + +import { onFileInputChangeGetFile } from '../../../lib/file-input'; +import { FormView } from '../form/view'; + +export const DocumentImporter = ({ + formId, + form, +}: { + formId: string; + form: Form; +}) => { + const { state, actions } = useDocumentImporter(form); + + const Step: React.FC< + PropsWithChildren<{ title: string; step: number; current: number }> + > = ({ children, title, step, current }) => { + if (current === step) { + return ( +
  • + + {title} + {children} + +
  • + ); + } else if (current < step) { + return ( +
  • + + {title} + {children} + not completed + +
  • + ); + } else { + return ( +
  • + +
  • + ); + } + }; + + const PDFFileSelect = () => { + return ( +
    +
    + Select a single PDF file +
    +
    +
    + +
    + { + actions.stepOneSelectPdfByUpload(fileDetails); + })} + /> +
    +
    + +
    + ); + }; + + const ButtonBar = () => { + return ( +
    + +
    + ); + }; + + const BuildFormPage = () => { + return ( +
    { + actions.stepTwoConfirmFields(); + }} + > + {/**/} +
      + {Object.values(state.documentFields || {}).map((field, index) => { + return
    • {JSON.stringify(field)}
    • ; + })} +
    + + + ); + }; + const PreviewFormPage = () => { + const previewForm = addDocumentFieldsToForm( + form, + state.documentFields || {} + ); + const formContext = createFormContext(previewForm); + const prompt = createPrompt(formContext); + return ( + <> + { + //handleFormSubmission(formId, data); + console.log(formId, data); + }} + /> +
    { + actions.stepThreeSaveForm(formId); + }} + > + + + + ); + }; + + return ( +
    +

    Create an interview from PDF

    +
    +
      + + + +
    +
    + {state.page === 1 && } + {state.page === 2 && } + {state.page === 3 && } +
    + ); +}; + +type State = { + page: number; + previewForm: Form; + documentFields?: DocumentFieldMap; +}; + +const useDocumentImporter = (form: Form) => { + const navigate = useNavigate(); + const formService = createBrowserFormService(); + const [state, dispatch] = useReducer( + ( + state: State, + action: + | { + type: 'SELECT_PDF'; + data: { + path: string; + fields: DocumentFieldMap; + previewForm: Form; + }; + } + | { + type: 'PREVIEW_FORM'; + } + | { + type: 'GOTO_PAGE'; + page: number; + } + ) => { + if (action.type === 'SELECT_PDF') { + return { + page: 2, + previewForm: action.data.previewForm, + documentFields: action.data.fields, + }; + } + if (action.type === 'PREVIEW_FORM') { + return { + page: 3, + documentFields: state.documentFields, + previewForm: state.previewForm, + }; + } + if (action.type === 'GOTO_PAGE') { + return { + ...state, + page: action.page, + previewForm: state.previewForm, + documentFields: state.documentFields, + }; + } + return state; + }, + { + page: 1, + previewForm: form, + } + ); + return { + state, + actions: { + async stepOneSelectPdfByUrl(url: string) { + const completeUrl = `${(import.meta as any).env.BASE_URL}${url}`; + const response = await fetch(completeUrl); + const blob = await response.blob(); + const data = new Uint8Array(await blob.arrayBuffer()); + + const { newFields, updatedForm } = await addDocument( + state.previewForm, + { + name: url, + data, + } + ); + dispatch({ + type: 'SELECT_PDF', + data: { + path: url, + fields: newFields, + previewForm: updatedForm, + }, + }); + }, + async stepOneSelectPdfByUpload(fileDetails: { + name: string; + data: Uint8Array; + }) { + const { newFields, updatedForm } = await addDocument( + state.previewForm, + fileDetails + ); + dispatch({ + type: 'SELECT_PDF', + data: { + path: fileDetails.name, + fields: newFields, + previewForm: updatedForm, + }, + }); + }, + stepTwoConfirmFields() { + dispatch({ + type: 'PREVIEW_FORM', + }); + }, + stepThreeSaveForm(formId: string) { + formService.saveForm(formId, state.previewForm); + navigate(`/${formId}/edit`); + }, + gotoPage(step: number) { + dispatch({ type: 'GOTO_PAGE', page: step }); + }, + }, + }; +}; diff --git a/apps/spotlight/src/components/react/form-builder/index.tsx b/apps/spotlight/src/components/react/form-builder/index.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/apps/spotlight/src/components/react/form/delete.tsx b/apps/spotlight/src/components/react/form/delete.tsx new file mode 100644 index 000000000..e8b4b96d2 --- /dev/null +++ b/apps/spotlight/src/components/react/form/delete.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { useNavigate } from 'react-router-dom'; +import { createBrowserFormService } from '@atj/form-service'; + +export const FormDelete = ({ formId }: { formId: string }) => { + const navigate = useNavigate(); + const service = createBrowserFormService(); + const result = service.getForm(formId); + if (!result.success) { + return
    Form {formId} not found.
    ; + } + const form = result.data; + const deleteForm = () => { + service.deleteForm(formId); + navigate('/'); + }; + return ( +
    +

    Delete form

    +
    Are you sure you want to delete the form with id: `{formId}`?
    + {JSON.stringify(form, null, 4)} +
    + +
    +
    + ); +}; diff --git a/apps/spotlight/src/components/react/form/edit.tsx b/apps/spotlight/src/components/react/form/edit.tsx new file mode 100644 index 000000000..af0d73c27 --- /dev/null +++ b/apps/spotlight/src/components/react/form/edit.tsx @@ -0,0 +1,182 @@ +import React from 'react'; +import { useForm } from 'react-hook-form'; +import { Link } from 'react-router-dom'; + +import { createBrowserFormService } from '@atj/form-service'; +import { Form, addQuestions, createForm, getFlatFieldList } from '@atj/forms'; + +export const FormEdit = ({ formId }: { formId: string }) => { + const formService = createBrowserFormService(); + const result = formService.getForm(formId); + if (!result.success) { + return 'Form not found'; + } + const form = result.data; + return ( +
    +

    Edit form interface

    +
    Editing form {formId}
    +
      +
    • + +
    • +
    • + Preview this form +
    • +
    • + Import document +
    • +
    • + View all forms +
    • +
    + formService.saveForm(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.toString(), + 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/import-document.tsx b/apps/spotlight/src/components/react/form/import-document.tsx new file mode 100644 index 000000000..c040aebcb --- /dev/null +++ b/apps/spotlight/src/components/react/form/import-document.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { createBrowserFormService } from '@atj/form-service'; + +import { DocumentImporter } from '../form-builder/document-importer'; + +export const FormDocumentImport = ({ formId }: { formId: string }) => { + const formService = createBrowserFormService(); + // Fallback to hardcoded data if a magic ID is chosen. + const result = formService.getForm(formId); + if (!result.success) { + return 'null form retrieved from storage'; + } + return ; +}; diff --git a/apps/spotlight/src/components/react/form/index.tsx b/apps/spotlight/src/components/react/form/index.tsx new file mode 100644 index 000000000..6daa025df --- /dev/null +++ b/apps/spotlight/src/components/react/form/index.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { useParams, HashRouter, Route, Routes } from 'react-router-dom'; + +import { FormDelete } from './delete'; +import { FormEdit } from './edit'; +import { FormDocumentImport } from './import-document'; +import { FormList } from './list'; +import { FormViewById } from './view'; + +export const FormSection = () => { + return ( + + + + { + const { formId } = useParams(); + if (formId === undefined) { + return
    formId is undefined
    ; + } + return ; + }} + /> + { + const { formId } = useParams(); + if (formId === undefined) { + return
    formId is undefined
    ; + } + return ; + }} + /> + { + const { formId } = useParams(); + if (formId === undefined) { + return
    formId is undefined
    ; + } + return ; + }} + /> + { + const { formId } = useParams(); + if (formId === undefined) { + return
    formId is undefined
    ; + } + return ; + }} + /> +
    +
    + ); +}; diff --git a/apps/spotlight/src/components/react/form/list.tsx b/apps/spotlight/src/components/react/form/list.tsx new file mode 100644 index 000000000..93211e983 --- /dev/null +++ b/apps/spotlight/src/components/react/form/list.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { useNavigate, Link } from 'react-router-dom'; + +import { createBrowserFormService } from '@atj/form-service'; +import { createForm } from '@atj/forms'; + +export const FormList = () => { + const navigate = useNavigate(); + const formService = createBrowserFormService(); + const result = formService.getFormList(); + if (!result.success) { + return
    Error loading form list
    ; + } + return ( + <> +
      + {result.data.map((formId, index) => ( +
    • + {formId} View /{' '} + Edit /{' '} + Delete +
    • + ))} +
    +
    { + event.preventDefault(); + const formData = new FormData(event.currentTarget); + const title = formData.get('summary-title')?.toString(); + const description = formData.get('summary-description')?.toString(); + if (!title || !description) { + console.error('required fields not found'); + return; + } + const form = createForm({ + title, + description, + }); + const result = formService.addForm(form); + if (result.success) { + navigate(`/${result.data}/edit`); + } else { + console.error('Error saving form'); + } + }} + className="usa-form usa-form--large" + > +

    Create new form

    + + + +
    + + ); +}; diff --git a/apps/spotlight/src/components/react/dynamic-form.tsx b/apps/spotlight/src/components/react/form/prompts/additional-fields.tsx similarity index 58% rename from apps/spotlight/src/components/react/dynamic-form.tsx rename to apps/spotlight/src/components/react/form/prompts/additional-fields.tsx index f03509423..be59f1cde 100644 --- a/apps/spotlight/src/components/react/dynamic-form.tsx +++ b/apps/spotlight/src/components/react/form/prompts/additional-fields.tsx @@ -1,6 +1,5 @@ import React from 'react'; -// Assuming this is the structure of your JSON data interface Field { tag: string; type: string; @@ -18,75 +17,11 @@ interface Field { linkurl?: string; } -// Capitalization function -function capitalizeFirstLetter(string) { - return string - .toLowerCase() - .split(' ') - .map(word => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' '); -} - -interface FormProps { - fields: Field[]; -} - -const DynamicFormFieldset = ({ fields }: FormProps) => { - return ( -
    - - UD 105 - Unlawful Detainer Form - - {fields.map(field => { - // Use 'tag' for 'select' and 'textarea', 'type' for others - const fieldType = - field.tag === 'select' || - field.tag === 'textarea' || - field.tag === 'p' || - field.tag === 'h2' || - field.tag === 'h3' || - field.tag === 'ul' - ? field.tag - : field.type; - - switch (fieldType) { - case 'text': - return ; - case 'boolean': - return ; - case 'checkbox': - return ; - case 'select': - return ; - case 'radio': - return ; - case 'date': - return ; - case 'textarea': - return ; - case 'p': - return ; - case 'h2': - return ; - case 'h3': - return ; - case 'ul': - return ; - default: - return null; - } - })} - {/* Add submit button or other controls as needed */} -
    - ); -}; - -// Define components for each field type -const TextField = ({ field }: { field: Field }) => { +export const TextField = ({ field }: { field: Field }) => { return (