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
-
-
-
-
- Drag file here or{' '}
- choose from folder
-
-
-
{
- const fieldData = await extractFormFieldData(fileDetails.data);
- const fieldInfo = suggestFormDetails(fieldData);
- dispatch({ type: 'SELECT_PDF', data: fieldInfo });
- })}
- />
-
-
-
-
- );
- };
-
- const ButtonBar = () => {
- return (
-
-
-
- );
- };
-
- const BuildFormPage = () => {
- return (
-
- );
- };
- const PreviewFormPage = () => {
- return (
-
- );
- };
-
- 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 (
-
- );
-};
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
+
+
+
+
+ Drag file here or{' '}
+ choose from folder
+
+
+
{
+ actions.stepOneSelectPdfByUpload(fileDetails);
+ })}
+ />
+
+
+
+
+ );
+ };
+
+ const ButtonBar = () => {
+ return (
+
+
+
+ );
+ };
+
+ const BuildFormPage = () => {
+ return (
+
+ );
+ };
+ const PreviewFormPage = () => {
+ const previewForm = addDocumentFieldsToForm(
+ form,
+ state.documentFields || {}
+ );
+ const formContext = createFormContext(previewForm);
+ const prompt = createPrompt(formContext);
+ return (
+ <>
+ {
+ //handleFormSubmission(formId, data);
+ console.log(formId, data);
+ }}
+ />
+
+ >
+ );
+ };
+
+ 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 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
+
+ ))}
+
+
+ >
+ );
+};
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 (
-
- );
-};
-
-// Define components for each field type
-const TextField = ({ field }: { field: Field }) => {
+export const TextField = ({ field }: { field: Field }) => {
return (