diff --git a/apps/rest-api/package.json b/apps/rest-api/package.json index 3a497905a..a6a4e103e 100644 --- a/apps/rest-api/package.json +++ b/apps/rest-api/package.json @@ -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/spotlight/package.json b/apps/spotlight/package.json index 3ead725d5..d15bd2f30 100644 --- a/apps/spotlight/package.json +++ b/apps/spotlight/package.json @@ -12,8 +12,8 @@ "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", 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 01281df13..a0be9de3c 100644 --- a/apps/spotlight/src/components/react/form-builder/document-importer.tsx +++ b/apps/spotlight/src/components/react/form-builder/document-importer.tsx @@ -1,37 +1,22 @@ import React, { PropsWithChildren, useReducer } from 'react'; +import { useNavigate } from 'react-router-dom'; import { - DocumentFieldMap, addDocumentFieldsToForm, getDocumentFieldData, suggestFormDetails, } from '@atj/documents'; +import { + DocumentFieldMap, + Form, + addDocument, + createFormContext, + createPrompt, +} from '@atj/forms'; import { onFileInputChangeGetFile } from '../../../lib/file-input'; import { FormView } from '../form/view'; -import { Form, createFormContext, createPrompt } from '@atj/forms'; -import { saveFormToStorage } from '../../../lib/form-repo'; -import { useNavigate } from 'react-router-dom'; - -type State = { - page: number; - form: Form; - documentFields?: DocumentFieldMap; - previewForm?: Form; -}; -type Action = - | { - type: 'SELECT_PDF'; - data: DocumentFieldMap; - } - | { - type: 'PREVIEW_FORM'; - data: DocumentFieldMap; - } - | { - type: 'GOTO_PAGE'; - page: number; - }; +import { saveFormToStorage } from '@atj/form-service'; export const DocumentImporter = ({ formId, @@ -40,46 +25,7 @@ export const DocumentImporter = ({ formId: string; form: Form; }) => { - const navigate = useNavigate(); - const [state, dispatch] = useReducer( - (state: State, action: Action) => { - if (action.type === 'SELECT_PDF') { - return { - page: 2, - documentFields: action.data, - form: state.form, - }; - } - if (action.type === 'PREVIEW_FORM') { - return { - page: 3, - documentFields: action.data, - form: state.form, - }; - } - if (action.type === 'GOTO_PAGE') { - return { - ...state, - page: action.page, - form: state.form, - }; - } - return state; - }, - { - page: 1, - form: form, - } - ); - - const selectDocumentByUrl = async (url: string) => { - const response = await fetch(url); - const blob = await response.blob(); - const data = new Uint8Array(await blob.arrayBuffer()); - const fieldData = await getDocumentFieldData(data); - const fieldInfo = suggestFormDetails(fieldData); - dispatch({ type: 'SELECT_PDF', data: fieldInfo }); - }; + const { state, actions } = useDocumentImporter(form); const Step: React.FC< PropsWithChildren<{ title: string; step: number; current: number }> @@ -108,7 +54,7 @@ export const DocumentImporter = ({
  • diff --git a/apps/spotlight/src/components/react/form/edit.tsx b/apps/spotlight/src/components/react/form/edit.tsx index 66fd5b1df..40223be15 100644 --- a/apps/spotlight/src/components/react/form/edit.tsx +++ b/apps/spotlight/src/components/react/form/edit.tsx @@ -3,7 +3,7 @@ import { useForm } from 'react-hook-form'; import { Link } from 'react-router-dom'; import { Form, addQuestions, createForm, getFlatFieldList } from '@atj/forms'; -import { getFormFromStorage, saveFormToStorage } from '../../../lib/form-repo'; +import { getFormFromStorage, saveFormToStorage } from '@atj/form-service'; export const FormEdit = ({ formId }: { formId: string }) => { const form = getFormFromStorage(window.localStorage, formId); @@ -14,7 +14,6 @@ export const FormEdit = ({ formId }: { formId: string }) => {

    Edit form interface

    Editing form {formId}
    - {JSON.stringify(form)}
    ); }; + +export const downloadPdfDocument = (fileName: string, pdfData: Uint8Array) => { + const blob = new Blob([pdfData], { type: 'application/pdf' }); + const url = URL.createObjectURL(blob); + const element = document.createElement('a'); + element.setAttribute('href', url); + element.setAttribute('download', fileName); + element.style.display = 'none'; + document.body.appendChild(element); + element.click(); + document.body.removeChild(element); +}; diff --git a/apps/spotlight/src/lib/form-repo.ts b/apps/spotlight/src/lib/form-repo.ts deleted file mode 100644 index 3a48de2dc..000000000 --- a/apps/spotlight/src/lib/form-repo.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Form, FormSummary, createForm } from '@atj/forms'; - -export const getFormFromStorage = ( - storage: Storage, - id?: string -): Form | null => { - if (!storage || !id) { - return null; - } - const formString = storage.getItem(id); - if (!formString) { - return null; - } - return JSON.parse(formString); -}; - -export const getFormListFromStorage = (storage: Storage) => { - const keys = []; - for (let i = 0; i < storage.length; i++) { - const key = storage.key(i); - keys.push(key); - } - return keys; -}; - -export const addFormSummaryToStorage = ( - storage: Storage, - summary: FormSummary -) => { - const form = createForm(summary); - const uuid = crypto.randomUUID(); - storage.setItem(uuid, JSON.stringify(form)); - return { - success: true, - data: uuid, - }; -}; - -export const saveFormToStorage = ( - storage: Storage, - formId: string, - form: Form -) => { - storage.setItem(formId, JSON.stringify(form)); - return { - success: true, - }; -}; - -export const deleteFormFromStorage = (storage: Storage, formId: string) => { - storage.removeItem(formId); -}; diff --git a/packages/documents/src/document.ts b/packages/documents/src/document.ts index 7179eff1d..ba102651f 100644 --- a/packages/documents/src/document.ts +++ b/packages/documents/src/document.ts @@ -1,46 +1,8 @@ -import { Form, Question, addQuestions } from '@atj/forms'; +import { DocumentFieldMap, Form, Question, addQuestions } from '@atj/forms'; import { PDFDocument } from './pdf'; export type DocumentTemplate = PDFDocument; -export type DocumentFieldValue = - | { - type: 'TextField'; - name: string; - label: string; - value: string; - maxLength?: number; - required: boolean; - } - | { - type: 'CheckBox'; - name: string; - label: string; - value: boolean; - required: boolean; - } - | { - type: 'Dropdown'; - name: string; - label: string; - value: string[]; - required: boolean; - } - | { - type: 'OptionList'; - name: string; - label: string; - value: string[]; - required: boolean; - } - | { - type: 'not-supported'; - name: string; - error: string; - }; - -export type DocumentFieldMap = Record; - export const addDocumentFieldsToForm = ( form: Form, fields: DocumentFieldMap diff --git a/packages/documents/src/pdf/generate.ts b/packages/documents/src/pdf/generate.ts index 7af921448..ecaa14afe 100644 --- a/packages/documents/src/pdf/generate.ts +++ b/packages/documents/src/pdf/generate.ts @@ -1,7 +1,27 @@ import { PDFDocument, type PDFForm } from 'pdf-lib'; +import { DocumentFieldMap } from '@atj/forms'; import { PDFFieldType } from '.'; +export const createDocumentFieldData = ( + documentFields: DocumentFieldMap, + formFields: Record, + formData: Record +): Record => { + const results = {} as Record; + Object.entries(documentFields).forEach(([documentId, docField]) => { + if (docField.type === 'not-supported') { + return; + } + const fieldId = formFields[documentId]; + results[documentId] = { + type: docField.type, + value: formData[fieldId], + }; + }); + return results; +}; + export const fillPDF = async ( pdfBytes: Uint8Array, fieldData: Record diff --git a/packages/documents/src/pdf/index.ts b/packages/documents/src/pdf/index.ts index 670b018ee..230e9279f 100644 --- a/packages/documents/src/pdf/index.ts +++ b/packages/documents/src/pdf/index.ts @@ -1,14 +1,15 @@ export { getDocumentFieldData } from './extract'; -export { fillPDF } from './generate'; +export * from './generate'; export { generateDummyPDF } from './generate-dummy'; export type PDFDocument = { type: 'pdf'; - fields: { - id: string; - type: PDFFieldType; - label: string; - default?: any; - }[]; + fields: PDFField[]; +}; +export type PDFField = { + id: string; + type: PDFFieldType; + label: string; + default?: any; }; export type PDFFieldType = 'TextField' | 'CheckBox' | 'Dropdown' | 'OptionList'; diff --git a/packages/form-service/.gitignore b/packages/form-service/.gitignore new file mode 100644 index 000000000..849ddff3b --- /dev/null +++ b/packages/form-service/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/packages/form-service/README.md b/packages/form-service/README.md new file mode 100644 index 000000000..babb41e5c --- /dev/null +++ b/packages/form-service/README.md @@ -0,0 +1,3 @@ +# @atj/form-service + +The public API for interacting with forms. diff --git a/packages/form-service/package.json b/packages/form-service/package.json new file mode 100644 index 000000000..11e758fe5 --- /dev/null +++ b/packages/form-service/package.json @@ -0,0 +1,16 @@ +{ + "name": "@atj/form-service", + "version": "1.0.0", + "description": "10x ATJ public forms interface", + "license": "CC0", + "main": "src/index.ts", + "scripts": { + "build": "tsup src/* --env.NODE_ENV production", + "dev": "tsup src/* --watch", + "test": "vitest run --coverage" + }, + "dependencies": { + "@atj/documents": "workspace:*", + "@atj/forms": "workspace:*" + } +} diff --git a/packages/form-service/src/browser.ts b/packages/form-service/src/browser.ts new file mode 100644 index 000000000..4d5b509e2 --- /dev/null +++ b/packages/form-service/src/browser.ts @@ -0,0 +1,51 @@ +import { createDocumentFieldData, fillPDF } from '@atj/documents'; + +import { type FormService } from '.'; +import { getFormFromStorage } from './form-repo'; + +type Fetch = typeof fetch; + +export const createBrowserFormService = (fetch: Fetch, storage: Storage) => { + return { + submitForm: async (formId: string, formData: Record) => { + const form = getFormFromStorage(storage, formId); + if (form === null) { + return Promise.resolve({ + success: false as const, + error: 'Form not found', + }); + } + const errors = new Array(); + const documents = new Array<{ fileName: string; data: Uint8Array }>(); + for (const document of form.documents) { + const response = await fetch(document.path); + const pdfBytes = new Uint8Array(await response.arrayBuffer()); + console.log('document', document); + const docFieldData = createDocumentFieldData( + document.fields, + document.formFields, + formData + ); + const pdfDocument = await fillPDF(pdfBytes, docFieldData); + if (!pdfDocument.success) { + errors.push(pdfDocument.error); + } else { + documents.push({ + fileName: document.path, + data: pdfDocument.data, + }); + } + } + if (errors.length > 0) { + return { + success: false as const, + error: errors.join('\n'), + }; + } + return { + success: true as const, + data: documents, + }; + }, + }; +}; diff --git a/packages/form-service/src/form-repo.ts b/packages/form-service/src/form-repo.ts new file mode 100644 index 000000000..af208c807 --- /dev/null +++ b/packages/form-service/src/form-repo.ts @@ -0,0 +1,93 @@ +import { Form, FormSummary, createForm } from '@atj/forms'; + +export const getFormFromStorage = ( + storage: Storage, + id?: string +): Form | null => { + if (!storage || !id) { + return null; + } + const formString = storage.getItem(id); + if (!formString) { + return null; + } + return parseStringForm(formString); +}; + +export const getFormListFromStorage = (storage: Storage) => { + const keys = []; + for (let i = 0; i < storage.length; i++) { + const key = storage.key(i); + keys.push(key); + } + return keys; +}; + +export const addFormSummaryToStorage = ( + storage: Storage, + summary: FormSummary +) => { + const form = createForm(summary); + const uuid = crypto.randomUUID(); + storage.setItem(uuid, stringifyForm(form)); + return { + success: true, + data: uuid, + }; +}; + +export const saveFormToStorage = ( + storage: Storage, + formId: string, + form: Form +) => { + storage.setItem(formId, stringifyForm(form)); + return { + success: true, + }; +}; + +export const deleteFormFromStorage = (storage: Storage, formId: string) => { + storage.removeItem(formId); +}; + +const stringifyForm = (form: Form) => { + return JSON.stringify({ + ...form, + documents: form.documents.map(document => ({ + ...document, + // TODO: we probably want to do this somewhere in the documents module + data: uint8ArrayToBase64(document.data), + })), + }); +}; + +const parseStringForm = (formString: string): Form => { + const form = JSON.parse(formString) as Form; + return { + ...form, + documents: form.documents.map(document => ({ + ...document, + data: base64ToUint8Array((document as any).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); +}; + +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); + } + return bytes; +}; diff --git a/packages/form-service/src/index.ts b/packages/form-service/src/index.ts new file mode 100644 index 000000000..e2cf79d22 --- /dev/null +++ b/packages/form-service/src/index.ts @@ -0,0 +1,3 @@ +export { type FormService } from './types'; +export { createBrowserFormService } from './browser'; +export * from './form-repo'; diff --git a/packages/form-service/src/types.ts b/packages/form-service/src/types.ts new file mode 100644 index 000000000..e0e220f9b --- /dev/null +++ b/packages/form-service/src/types.ts @@ -0,0 +1,6 @@ +export type FormService = { + submitForm: ( + formId: string, + formData: Record + ) => Promise>; +}; diff --git a/packages/form-service/tsconfig.json b/packages/form-service/tsconfig.json new file mode 100644 index 000000000..c4e898619 --- /dev/null +++ b/packages/form-service/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "emitDeclarationOnly": true + }, + "include": [ + "./src" + ], + "references": [] +} diff --git a/packages/forms/dist/index.js b/packages/forms/dist/index.js index a832997be..5067472fb 100644 --- a/packages/forms/dist/index.js +++ b/packages/forms/dist/index.js @@ -20,6 +20,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru // src/index.ts var src_exports = {}; __export(src_exports, { + addDocument: () => addDocument, addQuestions: () => addQuestions, createForm: () => createForm, createFormContext: () => createFormContext, @@ -68,7 +69,8 @@ var createForm = (summary, questions = []) => { order: questions.map((question) => { return question.id; }) - } + }, + documents: [] }; }; var createFormContext = (form) => { @@ -145,8 +147,15 @@ var getFlatFieldList = (form) => { return _exhaustiveCheck; } }; +var addDocument = (form, document) => { + return { + ...form, + documents: [...form.documents, document] + }; +}; // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { + addDocument, addQuestions, createForm, createFormContext, diff --git a/packages/forms/package.json b/packages/forms/package.json index ed9f697cb..afd0cf3fa 100644 --- a/packages/forms/package.json +++ b/packages/forms/package.json @@ -1,7 +1,7 @@ { "name": "@atj/forms", "version": "1.0.0", - "description": "10x ATJ business logic", + "description": "10x ATJ form handling", "license": "CC0", "main": "src/index.ts", "scripts": { diff --git a/packages/forms/src/documents/index.ts b/packages/forms/src/documents/index.ts new file mode 100644 index 000000000..35bc2ef8e --- /dev/null +++ b/packages/forms/src/documents/index.ts @@ -0,0 +1,37 @@ +export type DocumentFieldValue = + | { + type: 'TextField'; + name: string; + label: string; + value: string; + maxLength?: number; + required: boolean; + } + | { + type: 'CheckBox'; + name: string; + label: string; + value: boolean; + required: boolean; + } + | { + type: 'Dropdown'; + name: string; + label: string; + value: string[]; + required: boolean; + } + | { + type: 'OptionList'; + name: string; + label: string; + value: string[]; + required: boolean; + } + | { + type: 'not-supported'; + name: string; + error: string; + }; + +export type DocumentFieldMap = Record; diff --git a/packages/forms/src/index.ts b/packages/forms/src/index.ts index 77704a266..9b93d8d0a 100644 --- a/packages/forms/src/index.ts +++ b/packages/forms/src/index.ts @@ -1,3 +1,6 @@ +import { DocumentFieldMap } from './documents'; + +export * from './documents'; export * from './prompts'; type QuestionId = string; @@ -21,10 +24,7 @@ export type Form = { summary: FormSummary; questions: Record; strategy: T; - documents: { - path: string; - fieldMap: Record; - }[]; + documents: FormDocument[]; }; export type FormContext = { @@ -46,6 +46,13 @@ export type NullStrategy = { export type FormStrategy = SequentialStrategy | NullStrategy; +export type FormDocument = { + data: Uint8Array; + path: string; + fields: DocumentFieldMap; + formFields: Record; +}; + export const createForm = ( summary: FormSummary, questions: Question[] = [] @@ -160,3 +167,13 @@ export const getFlatFieldList = (form: Form) => { return _exhaustiveCheck; } }; + +export const addDocument = ( + form: Form, + document: FormDocument +) => { + return { + ...form, + documents: [...form.documents, document], + }; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a741ee9bb..ca1ecfc42 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -60,9 +60,9 @@ importers: apps/rest-api: dependencies: - '@atj/interviews': + '@atj/form-service': specifier: workspace:* - version: link:../../packages/interviews + version: link:../../packages/form-service devDependencies: '@types/aws-lambda': specifier: ^8.10.109 @@ -79,12 +79,12 @@ importers: '@atj/design': specifier: workspace:* version: link:../../packages/design - '@atj/docassemble': - specifier: workspace:* - version: link:../../packages/docassemble '@atj/documents': specifier: workspace:* version: link:../../packages/documents + '@atj/form-service': + specifier: workspace:* + version: link:../../packages/form-service '@atj/forms': specifier: workspace:* version: link:../../packages/forms @@ -206,6 +206,15 @@ importers: specifier: ^20.10.4 version: 20.10.4 + packages/form-service: + dependencies: + '@atj/documents': + specifier: workspace:* + version: link:../documents + '@atj/forms': + specifier: workspace:* + version: link:../forms + packages/forms: {} packages/interviews: {} @@ -4526,7 +4535,7 @@ packages: dependencies: semver: 7.5.4 shelljs: 0.8.5 - typescript: 5.4.0-dev.20240108 + typescript: 5.4.0-dev.20240112 dev: false /dset@3.1.3: @@ -10890,8 +10899,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - /typescript@5.4.0-dev.20240108: - resolution: {integrity: sha512-flXeU+FYwW3mL6zcOz1lNX0juSolUIeIRs4nO8j77jB+N/3lrqWNOSz05dzRC4eYJcjFIIOvv5y9u8MQ5nTtzg==} + /typescript@5.4.0-dev.20240112: + resolution: {integrity: sha512-jpdPO53r47ZiTebHw2O9jKp1ItTtwPe9kc62LGCZHEexROJn1MXChI7l/UmRtItOhOKwe2ZXOBlNMQh4YTMhIg==} engines: {node: '>=14.17'} hasBin: true dev: false diff --git a/tsconfig.json b/tsconfig.json index df44dd7bb..9d3181bad 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,6 +26,12 @@ { "path": "./packages/documents/tsconfig.json" }, + { + "path": "./packages/form-service/tsconfig.json" + }, + { + "path": "./packages/forms/tsconfig.json" + }, { "path": "./packages/interviews/tsconfig.json" }, diff --git a/workspace-dependencies.svg b/workspace-dependencies.svg index 4b55cd298..91498eb9a 100644 --- a/workspace-dependencies.svg +++ b/workspace-dependencies.svg @@ -4,130 +4,142 @@ - + workspace - + @atj/cli-app - -@atj/cli-app + +@atj/cli-app @atj/interviews - -@atj/interviews + +@atj/interviews @atj/cli-app->@atj/interviews - - + + @atj/dependency-graph - -@atj/dependency-graph + +@atj/dependency-graph @atj/cli-app->@atj/dependency-graph - - + + @atj/docassemble - -@atj/docassemble + +@atj/docassemble @atj/cli-app->@atj/docassemble - - + + - + @atj/docassemble->@atj/interviews - - + + - + @atj/documents - -@atj/documents + +@atj/documents - + @atj/docassemble->@atj/documents - - + + - + +@atj/form-rest-api + +@atj/form-rest-api + + + @atj/form-service - -@atj/form-service + +@atj/form-service - + -@atj/form-service->@atj/interviews - - +@atj/form-rest-api->@atj/form-service + + - + @atj/spotlight - -@atj/spotlight + +@atj/spotlight - + @atj/spotlight->@atj/interviews - - + + - - -@atj/spotlight->@atj/docassemble - - + + +@atj/spotlight->@atj/form-service + + - + @atj/design - -@atj/design + +@atj/design @atj/spotlight->@atj/design - - + + - + @atj/spotlight->@atj/documents - - + + - + @atj/forms - -@atj/forms + +@atj/forms + + + +@atj/spotlight->@atj/forms + + - + @atj/documents->@atj/forms - - + +