diff --git a/.nvmrc b/.nvmrc index 80a9956e..67e145bf 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v20.16.0 +v20.18.0 diff --git a/apps/server-doj/package.json b/apps/server-doj/package.json index 649d5864..b811bcf2 100644 --- a/apps/server-doj/package.json +++ b/apps/server-doj/package.json @@ -17,7 +17,6 @@ "@atj/server": "workspace:*" }, "devDependencies": { - "@types/node": "^20.14.14", "@types/supertest": "^6.0.2", "supertest": "^7.0.0" } diff --git a/apps/server-kansas/package.json b/apps/server-kansas/package.json index 6458dd9f..de3674c7 100644 --- a/apps/server-kansas/package.json +++ b/apps/server-kansas/package.json @@ -17,7 +17,6 @@ "@atj/server": "workspace:*" }, "devDependencies": { - "@types/node": "^20.14.14", "@types/supertest": "^6.0.2", "supertest": "^7.0.0" } diff --git a/apps/spotlight/package.json b/apps/spotlight/package.json index aacc8d0e..a90a1256 100644 --- a/apps/spotlight/package.json +++ b/apps/spotlight/package.json @@ -11,15 +11,20 @@ }, "dependencies": { "@astrojs/react": "^3.6.1", + "@atj/common": "workspace:*", "@atj/design": "workspace:*", "@atj/forms": "workspace:*", "astro": "^4.13.2", + "qs": "^6.13.0", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-error-boundary": "^4.0.13" + "react-error-boundary": "^4.0.13", + "react-router-dom": "^6.26.0", + "zustand": "^4.5.4" }, "devDependencies": { "@astrojs/check": "^0.4.1", + "@types/qs": "^6.9.15", "@types/react": "^18.3.3" } } diff --git a/apps/spotlight/src/components/AppFormRouter.tsx b/apps/spotlight/src/components/AppFormRouter.tsx deleted file mode 100644 index 787692c8..00000000 --- a/apps/spotlight/src/components/AppFormRouter.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react'; - -import { FormRouter } from '@atj/design'; -import { getAppContext } from '../context.js'; - -export default function AppFormRouter() { - const ctx = getAppContext(); - return ; -} diff --git a/apps/spotlight/src/features/form-page/components/AppFormPage.tsx b/apps/spotlight/src/features/form-page/components/AppFormPage.tsx new file mode 100644 index 00000000..107afd65 --- /dev/null +++ b/apps/spotlight/src/features/form-page/components/AppFormPage.tsx @@ -0,0 +1,79 @@ +import React, { useEffect } from 'react'; +import { + type Location, + HashRouter, + Route, + Routes, + useLocation, + useParams, +} from 'react-router-dom'; + +import { defaultPatternComponents, Form } from '@atj/design'; +import { defaultFormConfig, getRouteDataFromQueryString } from '@atj/forms'; + +import { getAppContext } from '../../../context.js'; +import { useFormPageStore } from '../store/index.js'; + +export const AppFormPage = () => { + return ( + + + } /> + + + ); +}; + +const AppFormRoute = () => { + const { actions, formSessionResponse } = useFormPageStore(); + const { id } = useParams(); + const location = useLocation(); + const ctx = getAppContext(); + + if (id === undefined) { + throw new Error('id is undefined'); + } + + useEffect( + () => + actions.initialize({ + formId: id, + route: getRouteParamsFromLocation(location), + }), + [location, id] + ); + return ( + <> + {formSessionResponse.status === 'loading' &&
Loading...
} + {formSessionResponse.status === 'error' && ( +
+
+

Error loading form

+

{formSessionResponse.message}

+
+
+ )} + {formSessionResponse.status === 'loaded' && ( +
actions.onSubmitForm({ formId: id, data })} + /> + )} + + ); +}; + +const getRouteParamsFromLocation = (location: Location) => { + const queryString = location.search.startsWith('?') + ? location.search.substring(1) + : location.search; + return { + params: getRouteDataFromQueryString(queryString), + url: `${location.pathname}`, + }; +}; diff --git a/apps/spotlight/src/features/form-page/index.ts b/apps/spotlight/src/features/form-page/index.ts new file mode 100644 index 00000000..ea8b6276 --- /dev/null +++ b/apps/spotlight/src/features/form-page/index.ts @@ -0,0 +1 @@ +export { AppFormPage } from './components/AppFormPage.js'; diff --git a/apps/spotlight/src/features/form-page/store/actions/get-form-session.ts b/apps/spotlight/src/features/form-page/store/actions/get-form-session.ts new file mode 100644 index 00000000..be02ffbc --- /dev/null +++ b/apps/spotlight/src/features/form-page/store/actions/get-form-session.ts @@ -0,0 +1,56 @@ +import { type FormSession, type RouteData } from '@atj/forms'; +import { type FormPageContext } from './index.js'; + +export type FormSessionResponse = + | { status: 'loading' } + | { status: 'error'; message: string } + | { + status: 'loaded'; + formSession: FormSession; + sessionId: string | undefined; + }; + +export type GetFormSession = ( + ctx: FormPageContext, + opts: { + formId: string; + route: { + params: RouteData; + url: string; + }; + sessionId?: string; + } +) => void; + +export const getFormSession: GetFormSession = async (ctx, opts) => { + ctx.setState({ formSessionResponse: { status: 'loading' } }); + ctx.config.formService + .getFormSession({ + formId: opts.formId, + formRoute: { + params: opts.route.params, + url: `#${opts.route.url}`, + }, + sessionId: opts.sessionId, + }) + .then(result => { + if (result.success === false) { + console.error(result.error); + ctx.setState({ + formSessionResponse: { + status: 'error', + message: result.error, + }, + }); + } else { + console.log('using session', result.data.data); + ctx.setState({ + formSessionResponse: { + status: 'loaded', + formSession: result.data.data, + sessionId: result.data.id, + }, + }); + } + }); +}; diff --git a/apps/spotlight/src/features/form-page/store/actions/index.ts b/apps/spotlight/src/features/form-page/store/actions/index.ts new file mode 100644 index 00000000..9ebc76a7 --- /dev/null +++ b/apps/spotlight/src/features/form-page/store/actions/index.ts @@ -0,0 +1,35 @@ +import { type ServiceMethod, createService } from '@atj/common'; + +import { type AppContext, getAppContext } from '../../../../context.js'; +import { type GetFormSession, getFormSession } from './get-form-session.js'; +import { type Initialize, initialize } from './initialize.js'; +import { type OnSubmitForm, onSubmitForm } from './on-submit-form.js'; +import { type FormPageState } from '../state.js'; + +export type FormPageContext = { + config: AppContext; + getState: () => FormPageState; + setState: (state: Partial) => void; +}; + +export interface FormPageActions { + getFormSession: ServiceMethod; + initialize: ServiceMethod; + onSubmitForm: ServiceMethod; +} + +export const createFormPageActions = ( + getState: () => FormPageState, + setState: (state: Partial) => void +): FormPageActions => { + const ctx: FormPageContext = { + getState, + setState, + config: getAppContext(), + }; + return createService(ctx, { + getFormSession, + initialize, + onSubmitForm, + }); +}; diff --git a/apps/spotlight/src/features/form-page/store/actions/initialize.ts b/apps/spotlight/src/features/form-page/store/actions/initialize.ts new file mode 100644 index 00000000..60216987 --- /dev/null +++ b/apps/spotlight/src/features/form-page/store/actions/initialize.ts @@ -0,0 +1,18 @@ +import { type FormRoute } from '@atj/forms'; +import { type FormPageContext } from './index.js'; +import { getFormSession } from './get-form-session.js'; + +export type Initialize = ( + ctx: FormPageContext, + opts: { formId: string; route: FormRoute } +) => void; + +export const initialize: Initialize = (ctx, opts) => { + // Get the session ID from local storage so we can use it on page reload. + const sessionId = window.localStorage.getItem('form_session_id') || undefined; + getFormSession(ctx, { + formId: opts.formId, + route: opts.route, + sessionId, + }); +}; diff --git a/apps/spotlight/src/features/form-page/store/actions/on-submit-form.ts b/apps/spotlight/src/features/form-page/store/actions/on-submit-form.ts new file mode 100644 index 00000000..7a78214c --- /dev/null +++ b/apps/spotlight/src/features/form-page/store/actions/on-submit-form.ts @@ -0,0 +1,56 @@ +import { type FormPageContext } from './index.js'; + +export type OnSubmitForm = ( + ctx: FormPageContext, + opts: { + formId: string; + sessionId?: string; + data: Record; + } +) => void; + +export const onSubmitForm: OnSubmitForm = async (ctx, opts) => { + const state = ctx.getState(); + if (state.formSessionResponse.status !== 'loaded') { + console.error("Can't submit data. Form session not loaded"); + return; + } + /*const newSession = applyPromptResponse( + config, + session, + response + );*/ + const submission = await ctx.config.formService.submitForm( + opts.sessionId, + opts.formId, + opts.data, + state.formSessionResponse.formSession.route + ); + if (submission.success) { + for (const document of submission.data.documents || []) { + downloadPdfDocument(document.fileName, document.data); + } + ctx.setState({ + formSessionResponse: { + status: 'loaded', + formSession: submission.data.session, + sessionId: submission.data.sessionId, + }, + }); + window.localStorage.setItem('form_session_id', submission.data.sessionId); + } else { + console.error(submission.error); + } +}; + +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/features/form-page/store/index.ts b/apps/spotlight/src/features/form-page/store/index.ts new file mode 100644 index 00000000..417181f2 --- /dev/null +++ b/apps/spotlight/src/features/form-page/store/index.ts @@ -0,0 +1,18 @@ +import { create } from 'zustand'; + +import { + type FormPageActions, + createFormPageActions, +} from './actions/index.js'; +import { type FormPageState, getInitialState } from './state.js'; + +type Store = FormPageState & { + actions: FormPageActions; +}; + +export const useFormPageStore = create((set, get) => { + return { + ...getInitialState(), + actions: createFormPageActions(get, set), + }; +}); diff --git a/apps/spotlight/src/features/form-page/store/state.ts b/apps/spotlight/src/features/form-page/store/state.ts new file mode 100644 index 00000000..1662a8ae --- /dev/null +++ b/apps/spotlight/src/features/form-page/store/state.ts @@ -0,0 +1,11 @@ +import { type FormSessionResponse } from './actions/get-form-session.js'; + +export type FormPageState = { + formId: string; + formSessionResponse: FormSessionResponse; +}; + +export const getInitialState = (): FormPageState => ({ + formId: '', + formSessionResponse: { status: 'loading' }, +}); diff --git a/apps/spotlight/src/pages/forms/index.astro b/apps/spotlight/src/pages/forms/index.astro index e69c1ada..c6f547e4 100644 --- a/apps/spotlight/src/pages/forms/index.astro +++ b/apps/spotlight/src/pages/forms/index.astro @@ -1,8 +1,8 @@ --- -import AppFormRouter from '../../components/AppFormRouter'; +import { AppFormPage } from '../../features/form-page'; import ContentLayout from '../../layouts/ContentLayout.astro'; --- - + diff --git a/apps/spotlight/src/pages/manage/index.astro b/apps/spotlight/src/pages/manage/index.astro index 46fdd18a..30d04c8f 100644 --- a/apps/spotlight/src/pages/manage/index.astro +++ b/apps/spotlight/src/pages/manage/index.astro @@ -4,5 +4,5 @@ import Layout from '../../layouts/Layout.astro'; --- - + diff --git a/apps/spotlight/tsconfig.json b/apps/spotlight/tsconfig.json index a4ef7740..7ea57e6d 100644 --- a/apps/spotlight/tsconfig.json +++ b/apps/spotlight/tsconfig.json @@ -8,6 +8,6 @@ "jsx": "react", "resolveJsonModule": true }, - "include": ["src/**/*.ts"], - "exclude": ["src/components/**"] + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.astro"], + "exclude": [".astro", "dist", "node_modules"] } diff --git a/e2e/src/edit.spec.ts b/e2e/src/edit.spec.ts index b3b0c888..10836177 100644 --- a/e2e/src/edit.spec.ts +++ b/e2e/src/edit.spec.ts @@ -12,15 +12,24 @@ class TestPage { this.page = page; } async setLocalStorage(key: string, value: any) { - await this.page.context().addInitScript(([key, value]) => { - localStorage.setItem(key, value); - }, [key, JSON.stringify(value)]); + await this.page.context().addInitScript( + ([key, value]) => { + localStorage.setItem(key, value); + }, + [key, JSON.stringify(value)] + ); } async moveListItem(buttonText: string, pageTitles: string[]) { - const handle = this.page.locator('li').filter({ hasText: `${buttonText}${pageTitles[0]}` }).getByRole('button'); + const handle = this.page + .locator('li') + .filter({ hasText: `${buttonText}${pageTitles[0]}` }) + .getByRole('button'); await handle.hover(); await this.page.mouse.down(); - const nextElement = this.page.locator('li').filter({ hasText: `${buttonText}${pageTitles[1]}` }).getByRole('button'); + const nextElement = this.page + .locator('li') + .filter({ hasText: `${buttonText}${pageTitles[1]}` }) + .getByRole('button'); await nextElement.hover(); await this.page.mouse.up(); } @@ -35,60 +44,62 @@ class TestPage { } const preparePageTitles = (items: any) => { - return Object.values(items).filter(item => item.type === 'page').map(item => (item.data as PageDataTest).title); -} + return Object.values(items) + .filter(item => item.type === 'page') + .map(item => (item.data as PageDataTest).title); +}; const prepareUpdatedPageTitles = (items: string[]) => { const newFirstItem = items.shift(); return items.splice(1, 0, newFirstItem || ''); -} +}; test('Drag-and-drop pages via mouse interaction', async ({ context, page }) => { - const key = '62ba7264-e869-40fc-ac68-5892f1228a9b'; + const key = 'forms/62ba7264-e869-40fc-ac68-5892f1228a9b'; const obj = { - "summary": { - "title": "My form - 2024-07-10T20:20:08.037Z", - "description": "" + summary: { + title: 'My form - 2024-07-10T20:20:08.037Z', + description: '', }, - "root": "root", - "patterns": { - "root": { - "type": "page-set", - "id": "root", - "data": { - "pages": [ - "cbd810ef-fd29-48f6-901b-fa88ab4f4100", - "5e4381b5-c05e-4471-ba1c-cd4ff045ab74", - "aca1a6a4-e7ba-4f37-bdf1-2726aa40831a" - ] - } + root: 'root', + patterns: { + root: { + type: 'page-set', + id: 'root', + data: { + pages: [ + 'cbd810ef-fd29-48f6-901b-fa88ab4f4100', + '5e4381b5-c05e-4471-ba1c-cd4ff045ab74', + 'aca1a6a4-e7ba-4f37-bdf1-2726aa40831a', + ], + }, }, - "cbd810ef-fd29-48f6-901b-fa88ab4f4100": { - "type": "page", - "id": "cbd810ef-fd29-48f6-901b-fa88ab4f4100", - "data": { - "title": "Page 1", - "patterns": [] - } + 'cbd810ef-fd29-48f6-901b-fa88ab4f4100': { + type: 'page', + id: 'cbd810ef-fd29-48f6-901b-fa88ab4f4100', + data: { + title: 'Page 1', + patterns: [], + }, }, - "5e4381b5-c05e-4471-ba1c-cd4ff045ab74": { - "id": "5e4381b5-c05e-4471-ba1c-cd4ff045ab74", - "type": "page", - "data": { - "title": "A new page", - "patterns": [] - } + '5e4381b5-c05e-4471-ba1c-cd4ff045ab74': { + id: '5e4381b5-c05e-4471-ba1c-cd4ff045ab74', + type: 'page', + data: { + title: 'A new page', + patterns: [], + }, + }, + 'aca1a6a4-e7ba-4f37-bdf1-2726aa40831a': { + id: 'aca1a6a4-e7ba-4f37-bdf1-2726aa40831a', + type: 'page', + data: { + title: 'Different Page', + patterns: [], + }, }, - "aca1a6a4-e7ba-4f37-bdf1-2726aa40831a": { - "id": "aca1a6a4-e7ba-4f37-bdf1-2726aa40831a", - "type": "page", - "data": { - "title": "Different Page", - "patterns": [] - } - } }, - "outputs": [] + outputs: [], }; const testPage = new TestPage(page); await testPage.setLocalStorage(key, obj); @@ -101,17 +112,28 @@ test('Drag-and-drop pages via mouse interaction', async ({ context, page }) => { await testPage.checkFirstUrl('?page='); await testPage.moveListItem(buttonText, pageTitles); - await page.waitForFunction(([pageTitles, buttonText]) => { - const items = document.querySelectorAll('.usa-sidenav .draggable-list-item-wrapper'); - return (items[0] as HTMLElement).innerText === buttonText + '\n' + pageTitles[1] && items.length === 3; - }, [pageTitles, buttonText]); + await page.waitForFunction( + ([pageTitles, buttonText]) => { + const items = document.querySelectorAll( + '.usa-sidenav .draggable-list-item-wrapper' + ); + return ( + (items[0] as HTMLElement).innerText === + buttonText + '\n' + pageTitles[1] && items.length === 3 + ); + }, + [pageTitles, buttonText] + ); const pageTitlesCopy = [...pageTitles]; const newPageTitles = prepareUpdatedPageTitles(pageTitlesCopy); - const reorderedFirst = page.locator('ul').filter({ hasText: buttonText + newPageTitles.join(buttonText) }).getByRole('button').first(); + const reorderedFirst = page + .locator('ul') + .filter({ hasText: buttonText + newPageTitles.join(buttonText) }) + .getByRole('button') + .first(); await expect(reorderedFirst).toBeVisible(); await testPage.checkNextUrl('?page=1'); - -}); \ No newline at end of file +}); diff --git a/infra/cdktf/package.json b/infra/cdktf/package.json index 94a583c1..14dfadd7 100644 --- a/infra/cdktf/package.json +++ b/infra/cdktf/package.json @@ -26,7 +26,6 @@ }, "devDependencies": { "@types/jest": "^29.5.12", - "@types/node": "^20.14.14", "jest": "^29.7.0", "ts-jest": "^29.2.4" } diff --git a/package.json b/package.json index 1751fe2b..6e595757 100644 --- a/package.json +++ b/package.json @@ -23,25 +23,27 @@ "pre-commit": "pnpm format" }, "devDependencies": { - "@rollup/plugin-commonjs": "^26.0.1", + "@playwright/test": "^1.48.1", + "@rollup/plugin-commonjs": "^28.0.0", "@rollup/plugin-json": "^6.1.0", - "@rollup/plugin-node-resolve": "^15.2.3", - "@types/node": "^20.14.14", - "@vitest/coverage-v8": "^2.0.5", - "@vitest/ui": "^2.0.5", - "esbuild": "^0.23.0", + "@rollup/plugin-node-resolve": "^15.3.0", + "@types/node": "^22.7.4", + "@vitest/browser": "^2.1.3", + "@vitest/coverage-v8": "^2.1.3", + "@vitest/ui": "^2.1.3", + "esbuild": "^0.24.0", "eslint": "^8.57.0", - "husky": "^9.1.4", + "husky": "^9.1.6", "npm-run-all": "^4.1.5", "prettier": "^3.3.3", "rimraf": "^6.0.1", - "rollup": "^4.20.0", + "rollup": "^4.23.0", "rollup-plugin-typescript2": "^0.36.0", "ts-node": "^10.9.2", - "tsup": "^8.2.4", - "turbo": "^2.0.14", - "typescript": "^5.5.4", - "vitest": "^2.0.5", + "tsup": "^8.3.0", + "turbo": "^2.1.3", + "typescript": "^5.6.2", + "vitest": "^2.1.3", "vitest-mock-extended": "^2.0.0" } } diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 45ae1556..5f4ab061 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -1,4 +1,4 @@ -export { createService } from './service.js'; +export { type ServiceMethod, createService } from './service.js'; export type Success = { success: true; data: T }; export type VoidSuccess = { success: true }; diff --git a/packages/common/src/service.ts b/packages/common/src/service.ts index c3ff8bc0..88cd5a94 100644 --- a/packages/common/src/service.ts +++ b/packages/common/src/service.ts @@ -9,6 +9,11 @@ type ServiceFunction = ( ...args: Args ) => Return; +export type ServiceMethod> = + F extends (context: infer C, ...args: infer A) => infer R + ? (...args: A) => R + : never; + type ServiceFunctions = { [key: string]: ServiceFunction; }; diff --git a/packages/database/README.md b/packages/database/README.md new file mode 100644 index 00000000..dc30662c --- /dev/null +++ b/packages/database/README.md @@ -0,0 +1,63 @@ +# @atj/database + +This package maintains the supporting infrastructure for the Form Platform's +database. + +PostgreSQL is the supported production database. Sqlite3 is also supported, +to facilitate fast in-memory integration testing. + +## Database migrations + +To create a new database migration in [./migrations](./migrations): + +```bash +pnpm knex migrate:make migration-name +``` + +Application of database migrations are orchestrated by the application via +[./src/management/migrate-database.ts](./src/management/migrate-database.ts). + +## Testing + +Packages that leverage `@atj/database` may use provided helpers for testing +purposes. + +### Testing database gateway routines + +`describeDatabase` is a Vitest suite factory that will run a test spec against +a clean database on both Sqlite3 and PostgreSQL: + +```typescript +import { expect, it } from 'vitest'; + +import { type DbTestContext, describeDatabase } from '@atj/database/testing'; + +describeDatabase('database connection', () => { + it('selects all via kysely', async ({ db }) => { + const kysely2 = await db.ctx.getKysely(); + const users2 = await kysely2.selectFrom('users').selectAll().execute(); + expect(users2).toBeDefined(); + }); + it('selects all via knex', async ({ db }) => { + const knex = await db.ctx.getKnex(); + const users = await knex.select().from('users'); + expect(users).toBeDefined(); + }); +``` + +### Integration testing + +For business logic tests that integrate with a clean database, you may leverage +the `createInMemoryDatabaseContext` factory. This will provide an ephemeral +in-memory Sqlite3 database. + +```typescript +import { createInMemoryDatabaseContext } from '@atj/database/context'; + +describe('business logic tested with in-memory database', () => { + it('context helper has a connection to a sqlite database', async () => { + const db = await createInMemoryDatabaseContext(); + expect(db.engine).toEqual('sqlite'); + }); +}); +``` diff --git a/packages/database/knexfile.mjs b/packages/database/knexfile.mjs index a17822f7..86377606 100644 --- a/packages/database/knexfile.mjs +++ b/packages/database/knexfile.mjs @@ -1,4 +1,9 @@ -const migrationsDirectory = path.resolve(__dirname, './migrations'); +import { dirname, resolve } from 'path'; +import { fileURLToPath } from 'url'; + +const migrationsDirectory = resolve( + dirname(fileURLToPath(import.meta.url), './migrations') +); /** * @type { Object. } diff --git a/packages/database/migrations/20240912134651_form_session_table.mjs b/packages/database/migrations/20240912134651_form_session_table.mjs new file mode 100644 index 00000000..7b0d34b6 --- /dev/null +++ b/packages/database/migrations/20240912134651_form_session_table.mjs @@ -0,0 +1,29 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +export const up = async knex => { + await knex.schema.createTable('form_sessions', table => { + table.uuid('id').primary(); + table + .uuid('form_id') + .notNullable() + .references('id') + .inTable('forms') + .onDelete('CASCADE'); + table.text('data').notNullable(); + table.timestamps(true, true); + table.unique(['id', 'form_id'], { + indexName: 'form_sessions_id_form_id_unique', + useConstraint: true, + }); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +export const down = async knex => { + await knex.schema.dropTableIfExists('form_sessions'); +}; diff --git a/packages/database/package.json b/packages/database/package.json index 30a065cf..679b3dc3 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -39,9 +39,9 @@ "pg": "^8.12.0" }, "devDependencies": { - "@testcontainers/postgresql": "^10.11.0", + "@testcontainers/postgresql": "^10.13.2", "@types/better-sqlite3": "^7.6.11", - "testcontainers": "^10.11.0", + "testcontainers": "^10.13.2", "vite-tsconfig-paths": "^4.3.2" } } diff --git a/packages/database/src/clients/knex.ts b/packages/database/src/clients/knex.ts index 8423ead0..71f393fc 100644 --- a/packages/database/src/clients/knex.ts +++ b/packages/database/src/clients/knex.ts @@ -1,6 +1,7 @@ import path, { dirname } from 'path'; import { fileURLToPath } from 'url'; +import { Database as SqliteDatabase } from 'better-sqlite3'; import knex, { type Knex } from 'knex'; const migrationsDirectory = path.resolve( @@ -29,29 +30,36 @@ export const getPostgresKnex = ( }; export const getInMemoryKnex = (): Knex => { - return knex({ - client: 'better-sqlite3', - connection: { - filename: ':memory:', - }, - useNullAsDefault: true, - migrations: { - directory: migrationsDirectory, - loadExtensions: ['.mjs'], - }, - }); + return getSqlite3Knex(':memory:'); }; export const getFileSystemKnex = (path: string): Knex => { + return getSqlite3Knex(path); +}; + +const getSqlite3Knex = (filename: string): Knex => { return knex({ client: 'better-sqlite3', connection: { - filename: path, + filename, }, - useNullAsDefault: true, migrations: { directory: migrationsDirectory, loadExtensions: ['.mjs'], }, + pool: { + afterCreate: ( + conn: SqliteDatabase, + done: (err: Error | null, connection?: SqliteDatabase) => void + ) => { + try { + conn.pragma('foreign_keys = ON'); + done(null, conn); + } catch (err) { + done(err as Error); + } + }, + }, + useNullAsDefault: true, }); }; diff --git a/packages/database/src/clients/kysely/types.ts b/packages/database/src/clients/kysely/types.ts index 4496d21a..3a95fb04 100644 --- a/packages/database/src/clients/kysely/types.ts +++ b/packages/database/src/clients/kysely/types.ts @@ -13,6 +13,7 @@ export interface Database { users: UsersTable; sessions: SessionsTable; forms: FormsTable; + form_sessions: FormSessionsTable; } interface UsersTable { @@ -48,3 +49,12 @@ export type FormsTableInsertable = Insertable; export type FormsTableUpdateable = Updateable; export type DatabaseClient = Kysely; + +interface FormSessionsTable { + id: string; + form_id: string; + data: string; +} +export type FormSessionsTableSelectable = Selectable; +export type FormSessionsTableInsertable = Insertable; +export type FormSessionsTableUpdateable = Updateable; diff --git a/packages/database/src/clients/test-containers.ts b/packages/database/src/clients/test-containers.ts index 971a9291..11de9051 100644 --- a/packages/database/src/clients/test-containers.ts +++ b/packages/database/src/clients/test-containers.ts @@ -31,6 +31,7 @@ export const setupPostgresContainer = async ({ if (global.postgresTestContainer === undefined) { process.stdout.write('Starting PostgreSQL test container...'); global.postgresTestContainer = new PostgreSqlContainer().start(); + const container = await global.postgresTestContainer; } else { process.stdout.write( 'Using already initialized PostgreSQL test container...' diff --git a/packages/database/src/testing.ts b/packages/database/src/testing.ts index 8b025994..59c6daca 100644 --- a/packages/database/src/testing.ts +++ b/packages/database/src/testing.ts @@ -36,7 +36,6 @@ export const describeDatabase = ( if (!connectionDetails) { throw new Error('Connection details not found'); } - const { connectionUri, databaseName } = await createTestDatabase(connectionDetails); const ctx = new PostgresDatabaseContext(connectionUri); diff --git a/packages/design/src/Form/components/PageSet/PageSet.stories.tsx b/packages/design/src/Form/components/PageSet/PageSet.stories.tsx index a9ca1c17..5a0b4aaf 100644 --- a/packages/design/src/Form/components/PageSet/PageSet.stories.tsx +++ b/packages/design/src/Form/components/PageSet/PageSet.stories.tsx @@ -40,11 +40,13 @@ export const Basic = { pages: [ { title: 'First page', - active: false, + selected: false, + url: '#/?page=0', }, { title: 'Second page', - active: true, + selected: true, + url: '#/?page=0', }, ], actions: [], diff --git a/packages/design/src/Form/components/PageSet/PageSet.tsx b/packages/design/src/Form/components/PageSet/PageSet.tsx index 21d90dfc..0f9b6f3d 100644 --- a/packages/design/src/Form/components/PageSet/PageSet.tsx +++ b/packages/design/src/Form/components/PageSet/PageSet.tsx @@ -4,26 +4,14 @@ import { type PageSetProps } from '@atj/forms'; import { type PatternComponent } from '../../index.js'; import ActionBar from '../../../Form/ActionBar/index.js'; -import { useRouteParams } from '../../../FormRouter/hooks.js'; import { PageMenu } from './PageMenu/index.js'; const PageSet: PatternComponent = props => { - const { routeParams, pathname } = useRouteParams(); return (
{ + if (!route) { + return ''; + } else { + const queryString = new URLSearchParams( + route.params as Record + ).toString(); + return `${route.url}?${queryString}`; + } +}; + export default function Form({ context, session, @@ -84,14 +96,6 @@ export default function Form({ const formMethods = useForm>({}); - /** - * Regenerate the prompt whenever the form changes. - const allFormData = formMethods.watch(); - useEffect(() => { - updatePrompt(allFormData); - }, [allFormData]); - */ - return (
@@ -100,15 +104,18 @@ export default function Form({ {!isPreview ? ( { - updatePrompt(data); - if (onSubmit) { - console.log('Submitting form...'); - onSubmit(data); - } else { - console.warn('Skipping form submission...'); - } - })} + onSubmit={ + onSubmit + ? formMethods.handleSubmit(async data => { + updatePrompt(data); + console.log('Submitting form...'); + onSubmit(data); + }) + : undefined + } + method="POST" + action={getRouteUrl(session.route)} + aria-label={session.form.summary.title || 'Form'} > @@ -133,85 +140,6 @@ const FormContents = ({ }) => { return ( <> - {false && ( -
- - Request to Change Name - -
-
-
- - County where you live - - - -
-
-
- -
-
-
- - Your current name - - - - - - - -
-
-

- To ask the court to change your name, you must fill out this - form, and: -

-
    -
  • - Attach a certified copy of your birth certificate and a copy - of your photo ID, and -
  • -
  • - File your form and attachements in the same county where you - live. -
  • -
-
-
-
-
- )} -
{prompt.components.map((component, index) => { return ( diff --git a/packages/design/src/FormManager/FormEdit/FormEdit.stories.tsx b/packages/design/src/FormManager/FormEdit/FormEdit.stories.tsx index 0db5e535..26dc3732 100644 --- a/packages/design/src/FormManager/FormEdit/FormEdit.stories.tsx +++ b/packages/design/src/FormManager/FormEdit/FormEdit.stories.tsx @@ -22,7 +22,12 @@ const meta: Meta = { context={createTestFormManagerContext()} session={createTestSession({ form: createOnePageTwoPatternTestForm(), - routeParams: 'page=0', + route: { + params: { + page: '0', + }, + url: '#', + }, })} > diff --git a/packages/design/src/FormManager/FormEdit/components/PageEdit.tsx b/packages/design/src/FormManager/FormEdit/components/PageEdit.tsx index 4b3d7edb..ec71647c 100644 --- a/packages/design/src/FormManager/FormEdit/components/PageEdit.tsx +++ b/packages/design/src/FormManager/FormEdit/components/PageEdit.tsx @@ -5,7 +5,7 @@ import { PageProps } from '@atj/forms'; import { enLocale as message } from '@atj/common'; import { PagePattern } from '@atj/forms'; -import { useRouteParams } from '../../../FormRouter/hooks.js'; +import { useRouteParams } from '../../hooks.js'; import { PatternEditComponent } from '../types.js'; import { PatternEditActions } from './common/PatternEditActions.js'; diff --git a/packages/design/src/FormManager/FormEdit/components/PageSetEdit.tsx b/packages/design/src/FormManager/FormEdit/components/PageSetEdit.tsx index 565479e3..87cdcc2d 100644 --- a/packages/design/src/FormManager/FormEdit/components/PageSetEdit.tsx +++ b/packages/design/src/FormManager/FormEdit/components/PageSetEdit.tsx @@ -5,7 +5,6 @@ import { getPattern, type PageSetProps } from '@atj/forms'; import { PatternEditComponent } from '../types.js'; import ActionBar from '../../../Form/ActionBar/index.js'; -import { useRouteParams } from '../../../FormRouter/hooks.js'; import classNames from 'classnames'; import styles from '../../../Form/components/PageSet/PageMenu/pageMenuStyles.module.css'; import { DraggableList } from './PreviewSequencePattern/DraggableList.js'; @@ -15,21 +14,10 @@ import { UniqueIdentifier } from '@dnd-kit/core'; import { PageMenuProps } from '../../../Form/components/PageSet/PageMenu/PageMenu.js'; const PageSetEdit: PatternEditComponent = ({ previewProps }) => { - const { routeParams, pathname } = useRouteParams(); return (
{ const { context, setSession } = useFormManagerStore(state => ({ @@ -15,8 +15,8 @@ export const FormPreview = () => { const { routeParams } = useRouteParams(); useEffect(() => { - if (routeParams.page !== session.routeParams?.page) { - const newSession = mergeSession(session, { routeParams }); + if (routeParams.page !== session.route?.params.page) { + const newSession = mergeSession(session, { route: session.route }); setSession(newSession); } }, [routeParams]); diff --git a/packages/design/src/FormRouter/hooks.ts b/packages/design/src/FormManager/hooks.ts similarity index 68% rename from packages/design/src/FormRouter/hooks.ts rename to packages/design/src/FormManager/hooks.ts index 36a606f2..299a0b3a 100644 --- a/packages/design/src/FormRouter/hooks.ts +++ b/packages/design/src/FormManager/hooks.ts @@ -15,11 +15,3 @@ export const useRouteParams = (): { pathname: location.pathname, }; }; - -export const useQueryString = (): string => { - const location = useLocation(); - const queryString = location.search.startsWith('?') - ? location.search.substring(1) - : location.search; - return queryString; -}; diff --git a/packages/design/src/FormManager/index.tsx b/packages/design/src/FormManager/index.tsx index f5511dd6..7659f551 100644 --- a/packages/design/src/FormManager/index.tsx +++ b/packages/design/src/FormManager/index.tsx @@ -164,7 +164,10 @@ export default function FormManager(props: FormManagerProps) { ; - -export const FormRouterSample = {} satisfies StoryObj; diff --git a/packages/design/src/FormRouter/FormRouter.test.ts b/packages/design/src/FormRouter/FormRouter.test.ts deleted file mode 100644 index 7bee94da..00000000 --- a/packages/design/src/FormRouter/FormRouter.test.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { describeStories } from '../test-helper.js'; -import meta, * as stories from './FormRouter.stories.js'; - -describeStories(meta, stories); diff --git a/packages/design/src/FormRouter/index.tsx b/packages/design/src/FormRouter/index.tsx deleted file mode 100644 index f1583c1b..00000000 --- a/packages/design/src/FormRouter/index.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { useParams, HashRouter, Route, Routes } from 'react-router-dom'; - -import { type Result } from '@atj/common'; -import { - type Blueprint, - type FormService, - defaultFormConfig, -} from '@atj/forms'; -import { createFormSession } from '@atj/forms'; - -import { useQueryString } from './hooks.js'; -import { defaultPatternComponents } from '../index.js'; -import Form, { FormUIContext } from '../Form/index.js'; - -// Wrapper around Form that includes a client-side router for loading forms. -export default function FormRouter({ - uswdsRoot, - formService, -}: { - uswdsRoot: `${string}/`; - formService: FormService; -}) { - // For now, hardcode the pattern configuration. - // If these are user-configurable, we'll likely need to, in some manner, - // inject a compiled bundle into the application. - const context: FormUIContext = { - config: defaultFormConfig, - components: defaultPatternComponents, - uswdsRoot, - }; - return ( - - - { - const { formId } = useParams(); - const queryString = useQueryString(); - if (formId === undefined) { - return
formId is undefined
; - } - - const [formResult, setFormResult] = useState | null>(null); - useEffect(() => { - formService.getForm(formId).then(result => { - setFormResult(result); - }); - }, []); - - if (formResult === null) { - return; - } - if (formResult.success === false) { - return ( -
-
-

Error loading form

-

- {formResult.error.message} -

-
-
- ); - } - - const session = createFormSession(formResult.data, queryString); - return ( -
{ - /*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); - }); - } else { - console.error(submission.error); - } - }} - /> - ); - }} - /> - - - ); -} - -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/packages/design/src/index.ts b/packages/design/src/index.ts index 6089d061..80d5ee94 100644 --- a/packages/design/src/index.ts +++ b/packages/design/src/index.ts @@ -1,11 +1,10 @@ //import '@uswds/uswds'; export { default as AvailableFormList } from './AvailableFormList/index.js'; -export { default as Form } from './Form/index.js'; +export { default as Form, type FormUIContext } from './Form/index.js'; export { defaultPatternComponents } from './Form/components/index.js'; export { default as FormManager, type FormManagerContext, } from './FormManager/index.js'; export { defaultPatternEditComponents } from './FormManager/FormEdit/components/index.js'; -export { default as FormRouter } from './FormRouter/index.js'; diff --git a/packages/design/src/test-form.ts b/packages/design/src/test-form.ts index d527f334..7a5fd5c1 100644 --- a/packages/design/src/test-form.ts +++ b/packages/design/src/test-form.ts @@ -15,6 +15,7 @@ import { type FormUIContext } from './Form/index.js'; import { defaultPatternComponents } from './Form/components/index.js'; import { defaultPatternEditComponents } from './FormManager/FormEdit/components/index.js'; import { type FormManagerContext } from './FormManager/index.js'; +import { FormRoute } from '../../forms/dist/types/route-data.js'; export const createOnePageTwoPatternTestForm = () => { return createForm( @@ -218,10 +219,10 @@ export const createTestFormManagerContext = (): FormManagerContext => { export const createTestSession = (options?: { form?: Blueprint; - routeParams?: string; + route?: FormRoute; }) => { return createFormSession( options?.form || createTwoPatternTestForm(), - options?.routeParams + options?.route ); }; diff --git a/packages/forms/src/components.ts b/packages/forms/src/components.ts index e98cf1e2..c9dfe3de 100644 --- a/packages/forms/src/components.ts +++ b/packages/forms/src/components.ts @@ -60,7 +60,7 @@ export type CheckboxProps = PatternProps<{ export type PageSetProps = PatternProps<{ type: 'page-set'; - pages: { title: string; active: boolean }[]; + pages: { title: string; selected: boolean; url: string }[]; actions: PromptAction[]; }>; diff --git a/packages/forms/src/context/browser/form-repo.ts b/packages/forms/src/context/browser/form-repo.ts index cdfbfcea..c39566ec 100644 --- a/packages/forms/src/context/browser/form-repo.ts +++ b/packages/forms/src/context/browser/form-repo.ts @@ -1,11 +1,59 @@ import { type Result, type VoidResult, failure } from '@atj/common'; -import { type Blueprint } from '../../index.js'; +import { FormSession, FormSessionId, type Blueprint } from '../../index.js'; import { FormRepository } from '../../repository/index.js'; +const formKey = (formId: string) => `forms/${formId}`; +const isFormKey = (key: string) => key.startsWith('forms/'); +const getFormIdFromKey = (key: string) => { + const match = key.match(/^forms\/(.+)$/); + if (!match) { + throw new Error(`invalid key: "${key}"`); + } + return match[1]; +}; +const formSessionKey = (sessionId: string) => `formSessions/${sessionId}`; +//const isFormSessionKey = (key: string) => key.startsWith('formSessions/'); + export class BrowserFormRepository implements FormRepository { constructor(private storage: Storage) {} + getFormSession( + id: string + ): Promise> { + const formSession = this.storage.getItem(formSessionKey(id)); + if (!formSession) { + return Promise.resolve(failure(`not found: ${id}`)); + } + return Promise.resolve({ + success: true, + data: JSON.parse(formSession), + }); + } + + upsertFormSession(opts: { + id?: string; + formId: string; + data: FormSession; + }): Promise> { + const id = opts.id || crypto.randomUUID(); + this.storage.setItem( + formSessionKey(id), + JSON.stringify({ + id, + formId: opts.formId, + data: opts.data, + }) + ); + return Promise.resolve({ + success: true, + data: { + timestamp: new Date(), + id, + }, + }); + } + async addForm( form: Blueprint ): Promise> { @@ -26,7 +74,7 @@ export class BrowserFormRepository implements FormRepository { } async deleteForm(formId: string): Promise { - this.storage.removeItem(formId); + this.storage.removeItem(formKey(formId)); return { success: true }; } @@ -34,7 +82,7 @@ export class BrowserFormRepository implements FormRepository { if (!this.storage || !id) { return null; } - const formString = this.storage.getItem(id); + const formString = this.storage.getItem(`forms/${id}`); if (!formString) { return null; } @@ -65,7 +113,7 @@ export class BrowserFormRepository implements FormRepository { async saveForm(formId: string, form: Blueprint): Promise { try { - this.storage.setItem(formId, stringifyForm(form)); + this.storage.setItem(formKey(formId), stringifyForm(form)); } catch { return failure(`error saving '${formId}' to storage`); } @@ -80,14 +128,17 @@ export const getFormList = (storage: Storage) => { if (key === null) { return null; } - keys.push(key); + if (!isFormKey(key)) { + continue; + } + keys.push(getFormIdFromKey(key)); } return keys; }; export const saveForm = (storage: Storage, formId: string, form: Blueprint) => { try { - storage.setItem(formId, stringifyForm(form)); + storage.setItem(formKey(formId), stringifyForm(form)); } catch { return { success: false as const, diff --git a/packages/forms/src/index.ts b/packages/forms/src/index.ts index 64d68be0..cad04c39 100644 --- a/packages/forms/src/index.ts +++ b/packages/forms/src/index.ts @@ -28,7 +28,11 @@ export { type FormRepository, createFormsRepository, } from './repository/index.js'; -export { type RouteData, getRouteDataFromQueryString } from './route-data.js'; +export { + type FormRoute, + type RouteData, + getRouteDataFromQueryString, +} from './route-data.js'; export type Blueprint = { summary: FormSummary; diff --git a/packages/forms/src/patterns/checkbox.ts b/packages/forms/src/patterns/checkbox.ts index db6788fb..619b6b19 100644 --- a/packages/forms/src/patterns/checkbox.ts +++ b/packages/forms/src/patterns/checkbox.ts @@ -18,7 +18,15 @@ const configSchema = z.object({ }); export type CheckboxPattern = Pattern>; -const PatternOutput = z.boolean(); +export const checkbox = () => + z.union([ + z.literal('on').transform(() => true), + z.literal('off').transform(() => false), + z.literal(undefined).transform(() => false), + z.boolean(), + ]); + +const PatternOutput = checkbox(); type PatternOutput = z.infer; export const checkboxConfig: PatternConfig = { @@ -57,9 +65,8 @@ export const checkboxConfig: PatternConfig = { type: 'checkbox', id: pattern.id, name: pattern.id, - value: sessionValue, label: pattern.data.label, - defaultChecked: pattern.data.defaultChecked, + defaultChecked: sessionValue, // pattern.data.defaultChecked, ...extraAttributes, } as CheckboxProps, children: [], diff --git a/packages/forms/src/patterns/page-set/prompt.ts b/packages/forms/src/patterns/page-set/prompt.ts index b19ef2d9..14f91ee2 100644 --- a/packages/forms/src/patterns/page-set/prompt.ts +++ b/packages/forms/src/patterns/page-set/prompt.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; import { type CreatePrompt, + type FormSession, type PageSetProps, type PromptAction, createPromptForPattern, @@ -19,7 +20,7 @@ export const createPrompt: CreatePrompt = ( pattern, options ) => { - const route = parseRouteData(pattern, session.routeParams); + const route = parseRouteData(pattern, session.route?.params); const activePage = route.success ? route.data.page : null; const children = activePage !== null @@ -32,7 +33,11 @@ export const createPrompt: CreatePrompt = ( ), ] : []; - const actions = getActionsForPage(pattern.data.pages.length, activePage); + const actions = getActionsForPage({ + session, + pageCount: pattern.data.pages.length, + pageIndex: activePage, + }); return { props: { _patternId: pattern.id, @@ -43,9 +48,14 @@ export const createPrompt: CreatePrompt = ( if (childPattern.type !== 'page') { throw new Error('Page set children must be pages'); } + const params = new URLSearchParams({ + ...session.route?.params, + page: index.toString(), + }); return { title: childPattern.data.title || 'Untitled', - active: index === activePage, + selected: index === activePage, + url: session.route?.url + '?' + params.toString(), }; }), } satisfies PageSetProps, @@ -68,25 +78,24 @@ const parseRouteData = (pattern: PageSetPattern, routeParams?: RouteData) => { return safeZodParseFormErrors(schema, routeParams || {}); }; -const getActionsForPage = ( - pageCount: number, - pageIndex: number | null -): PromptAction[] => { - if (pageIndex === null) { +const getActionsForPage = (opts: { + session: FormSession; + pageCount: number; + pageIndex: number | null; +}): PromptAction[] => { + if (opts.pageIndex === null) { return []; } const actions: PromptAction[] = []; - if (pageIndex > 0) { - // FIXME: HACK! Don't do this here. We need to pass the form's ID, or its - // URL, to createPrompt. - const pathName = location.hash.split('?')[0]; + if (opts.pageIndex > 0) { + const pathName = opts.session.route?.url || ''; actions.push({ type: 'link', text: 'Back', - url: `${pathName}?page=${pageIndex - 1}`, + url: `${pathName}?page=${opts.pageIndex - 1}`, }); } - if (pageIndex < pageCount - 1) { + if (opts.pageIndex < opts.pageCount - 1) { actions.push({ type: 'submit', submitAction: 'next', diff --git a/packages/forms/src/repository/add-form.ts b/packages/forms/src/repository/add-form.ts index 93ddfeed..34370752 100644 --- a/packages/forms/src/repository/add-form.ts +++ b/packages/forms/src/repository/add-form.ts @@ -1,12 +1,15 @@ import { type Result, failure, success } from '@atj/common'; - import { type DatabaseContext } from '@atj/database'; + +import { type Blueprint } from '../index.js'; import { stringifyForm } from './serialize.js'; -export const addForm = async ( +export type AddForm = ( ctx: DatabaseContext, - form: any // Blueprint -): Promise> => { + form: Blueprint +) => Promise>; + +export const addForm: AddForm = async (ctx, form) => { const uuid = crypto.randomUUID(); const db = await ctx.getKysely(); return db diff --git a/packages/forms/src/repository/delete-form.ts b/packages/forms/src/repository/delete-form.ts index 6ac2f2fe..0a78c835 100644 --- a/packages/forms/src/repository/delete-form.ts +++ b/packages/forms/src/repository/delete-form.ts @@ -2,10 +2,12 @@ import { type VoidResult, failure } from '@atj/common'; import { type DatabaseContext } from '@atj/database'; -export const deleteForm = async ( +export type DeleteForm = ( ctx: DatabaseContext, formId: string -): Promise => { +) => Promise; + +export const deleteForm: DeleteForm = async (ctx, formId) => { const db = await ctx.getKysely(); const deleteResult = await db diff --git a/packages/forms/src/repository/get-form-list.ts b/packages/forms/src/repository/get-form-list.ts index 745beda3..dc7ef3da 100644 --- a/packages/forms/src/repository/get-form-list.ts +++ b/packages/forms/src/repository/get-form-list.ts @@ -1,6 +1,15 @@ import { type DatabaseContext } from '@atj/database'; -export const getFormList = async (ctx: DatabaseContext) => { +export type GetFormList = (ctx: DatabaseContext) => Promise< + | { + id: string; + title: string; + description: string; + }[] + | null +>; + +export const getFormList: GetFormList = async ctx => { const db = await ctx.getKysely(); const rows = await db.selectFrom('forms').select(['id', 'data']).execute(); diff --git a/packages/forms/src/repository/get-form-session.test.ts b/packages/forms/src/repository/get-form-session.test.ts new file mode 100644 index 00000000..f5e4c088 --- /dev/null +++ b/packages/forms/src/repository/get-form-session.test.ts @@ -0,0 +1,48 @@ +import { expect, it } from 'vitest'; + +import { type DbTestContext, describeDatabase } from '@atj/database/testing'; + +import { createTestBlueprint } from '../builder/builder.test'; +import { addForm } from './add-form'; +import { getFormSession } from './get-form-session'; + +describeDatabase('getFormSession', () => { + it('returns a preexisting form session', async ctx => { + const db = await ctx.db.ctx.getKysely(); + const form = await addForm(ctx.db.ctx, createTestBlueprint()); + if (!form.success) { + expect.fail(form.error); + } + const formSessionId = '7128b29f-e03d-48c8-8a82-2af8759fc146'; + await db + .insertInto('form_sessions') + .values({ + id: formSessionId, + form_id: form.data.id, + data: '{}', + }) + .executeTakeFirstOrThrow(); + + const formSessionResult = await getFormSession(ctx.db.ctx, formSessionId); + if (!formSessionResult.success) { + expect.fail(formSessionResult.error); + } + + expect(formSessionResult.data).toEqual({ + id: formSessionId, + formId: form.data.id, + data: {}, + }); + }); + + it('returns an error if the form session does not exist', async ctx => { + const formSessionResult = await getFormSession( + ctx.db.ctx, + '7128b29f-e03d-48c8-8a82-2af8759fc146' + ); + expect(formSessionResult).toEqual({ + error: 'no result', + success: false, + }); + }); +}); diff --git a/packages/forms/src/repository/get-form-session.ts b/packages/forms/src/repository/get-form-session.ts new file mode 100644 index 00000000..a6d4a4fe --- /dev/null +++ b/packages/forms/src/repository/get-form-session.ts @@ -0,0 +1,33 @@ +import { type Result, failure, success } from '@atj/common'; +import { type DatabaseContext } from '@atj/database'; +import { type FormSession, type FormSessionId } from '../session'; + +export type GetFormSession = ( + ctx: DatabaseContext, + id: string +) => Promise< + Result<{ + id: FormSessionId; + formId: string; + data: FormSession; + }> +>; + +export const getFormSession: GetFormSession = async (ctx, id) => { + const db = await ctx.getKysely(); + return await db + .selectFrom('form_sessions') + .where('id', '=', id) + .select(['id', 'form_id', 'data']) + .executeTakeFirstOrThrow() + .then(result => { + return success({ + id: result.id, + formId: result.form_id, + data: JSON.parse(result.data), + }); + }) + .catch(err => { + return failure(err.message); + }); +}; diff --git a/packages/forms/src/repository/get-form.ts b/packages/forms/src/repository/get-form.ts index 42c751b2..b3fe828f 100644 --- a/packages/forms/src/repository/get-form.ts +++ b/packages/forms/src/repository/get-form.ts @@ -2,10 +2,12 @@ import { type DatabaseContext } from '@atj/database'; import { type Blueprint } from '../index.js'; -export const getForm = async ( +export type GetForm = ( ctx: DatabaseContext, formId: string -): Promise => { +) => Promise; + +export const getForm: GetForm = async (ctx, formId) => { const db = await ctx.getKysely(); const selectResult = await db .selectFrom('forms') diff --git a/packages/forms/src/repository/index.ts b/packages/forms/src/repository/index.ts index b4e3eb74..dd469d4e 100644 --- a/packages/forms/src/repository/index.ts +++ b/packages/forms/src/repository/index.ts @@ -1,27 +1,25 @@ -import { type Result, type VoidResult, createService } from '@atj/common'; +import { type ServiceMethod, createService } from '@atj/common'; import { type DatabaseContext } from '@atj/database'; -import { type Blueprint } from '../index.js'; - -import { addForm } from './add-form.js'; -import { deleteForm } from './delete-form.js'; -import { getFormList } from './get-form-list.js'; -import { getForm } from './get-form.js'; -import { saveForm } from './save-form.js'; +import { type AddForm, addForm } from './add-form.js'; +import { type DeleteForm, deleteForm } from './delete-form.js'; +import { type GetForm, getForm } from './get-form.js'; +import { type GetFormList, getFormList } from './get-form-list.js'; +import { type GetFormSession, getFormSession } from './get-form-session.js'; +import { type SaveForm, saveForm } from './save-form.js'; +import { + type UpsertFormSession, + upsertFormSession, +} from './upsert-form-session.js'; export interface FormRepository { - addForm(form: Blueprint): Promise>; - deleteForm(formId: string): Promise; - getForm(id?: string): Promise; - getFormList(): Promise< - | { - id: string; - title: string; - description: string; - }[] - | null - >; - saveForm(formId: string, form: Blueprint): Promise; + addForm: ServiceMethod; + deleteForm: ServiceMethod; + getForm: ServiceMethod; + getFormSession: ServiceMethod; + getFormList: ServiceMethod; + saveForm: ServiceMethod; + upsertFormSession: ServiceMethod; } export const createFormsRepository = (ctx: DatabaseContext): FormRepository => @@ -29,6 +27,8 @@ export const createFormsRepository = (ctx: DatabaseContext): FormRepository => addForm, deleteForm, getFormList, + getFormSession, getForm, saveForm, + upsertFormSession, }); diff --git a/packages/forms/src/repository/save-form.ts b/packages/forms/src/repository/save-form.ts index b84b8adc..18365c80 100644 --- a/packages/forms/src/repository/save-form.ts +++ b/packages/forms/src/repository/save-form.ts @@ -1,14 +1,16 @@ -import { failure, success } from '@atj/common'; +import { type VoidResult, failure, success } from '@atj/common'; import { type DatabaseContext } from '@atj/database'; import { type Blueprint } from '../index.js'; import { stringifyForm } from './serialize.js'; -export const saveForm = async ( +export type SaveForm = ( ctx: DatabaseContext, - id: string, - blueprint: Blueprint -) => { + formId: string, + form: Blueprint +) => Promise; + +export const saveForm: SaveForm = async (ctx, id, blueprint) => { const db = await ctx.getKysely(); return await db diff --git a/packages/forms/src/repository/upsert-form-session.test.ts b/packages/forms/src/repository/upsert-form-session.test.ts new file mode 100644 index 00000000..612a7d35 --- /dev/null +++ b/packages/forms/src/repository/upsert-form-session.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, expect, it } from 'vitest'; + +import { type DbTestContext, describeDatabase } from '@atj/database/testing'; + +import { type Blueprint } from '..'; +import { createTestBlueprint } from '../builder/builder.test'; +import { type FormSession } from '../session'; + +import { addForm } from './add-form'; +import { upsertFormSession } from './upsert-form-session'; + +type UpsertTestContext = DbTestContext & { + form: Blueprint; + formId: string; + sessionData: FormSession; +}; + +describeDatabase('upsertFormSession', () => { + beforeEach(async ctx => { + ctx.form = createTestBlueprint(); + const addFormResult = await addForm(ctx.db.ctx, ctx.form); + if (!addFormResult.success) { + expect.fail('Failed to add test form'); + } + + ctx.formId = addFormResult.data.id; + ctx.sessionData = { + data: { errors: {}, values: {} }, + form: ctx.form, + } satisfies FormSession; + }); + + it('creates and updates form session', async ctx => { + const result = await upsertFormSession(ctx.db.ctx, { + formId: ctx.formId, + data: ctx.sessionData, + }); + if (!result.success) { + expect.fail(result.error); + } + + const kysely = await ctx.db.ctx.getKysely(); + const formSessionResult = await kysely + .selectFrom('form_sessions') + .select(['data']) + .where('id', '=', result.data.id) + .where('form_id', '=', ctx.formId) + .executeTakeFirstOrThrow(); + expect(JSON.parse(formSessionResult.data)).toEqual(ctx.sessionData); + + // Upsert a second time + const formSession = { + id: result.data.id, + formId: ctx.formId, + data: { + data: { errors: {}, values: {} }, + form: ctx.form, + }, + }; + const result2 = await upsertFormSession(ctx.db.ctx, formSession); + if (!result2.success) { + expect.fail(result2.error); + } + + const formSession2 = await kysely + .selectFrom('form_sessions') + .select(['data']) + .where('id', '=', result.data.id) + .where('form_id', '=', ctx.formId) + .executeTakeFirstOrThrow(); + expect(JSON.parse(formSession2.data)).toEqual(formSession.data); + }); +}); diff --git a/packages/forms/src/repository/upsert-form-session.ts b/packages/forms/src/repository/upsert-form-session.ts new file mode 100644 index 00000000..2d21edb0 --- /dev/null +++ b/packages/forms/src/repository/upsert-form-session.ts @@ -0,0 +1,40 @@ +import { type Result, failure, success } from '@atj/common'; +import { type DatabaseContext } from '@atj/database'; +import { type FormSession } from '../session'; + +export type UpsertFormSession = ( + ctx: DatabaseContext, + opts: { + id?: string; + formId: string; + data: FormSession; + } +) => Promise>; + +export const upsertFormSession: UpsertFormSession = async (ctx, opts) => { + const db = await ctx.getKysely(); + const strData = JSON.stringify(opts.data); + const id = opts.id || crypto.randomUUID(); + return await db + .insertInto('form_sessions') + .values({ + id, + form_id: opts.formId, + data: strData, + }) + .onConflict(oc => + oc.columns(['id', 'form_id']).doUpdateSet({ + data: strData, + }) + ) + .executeTakeFirstOrThrow() + .then(() => { + return success({ + timestamp: new Date(), + id, + }); + }) + .catch(err => { + return failure(err.message); + }); +}; diff --git a/packages/forms/src/route-data.ts b/packages/forms/src/route-data.ts index 346646c2..a826b479 100644 --- a/packages/forms/src/route-data.ts +++ b/packages/forms/src/route-data.ts @@ -2,6 +2,10 @@ import qs from 'qs'; import { PatternId } from './pattern.js'; export type RouteData = qs.ParsedQs; +export type FormRoute = { + url: string; + params: RouteData; +}; export const getRouteDataFromQueryString = (queryString: string): RouteData => { return qs.parse(queryString, { allowDots: true }); diff --git a/packages/forms/src/services/add-form.ts b/packages/forms/src/services/add-form.ts index bc7f5f89..9845f8f4 100644 --- a/packages/forms/src/services/add-form.ts +++ b/packages/forms/src/services/add-form.ts @@ -12,7 +12,7 @@ type AddFormResult = { id: string; }; -type AddForm = ( +export type AddForm = ( ctx: FormServiceContext, form: Blueprint ) => Promise>; diff --git a/packages/forms/src/services/delete-form.ts b/packages/forms/src/services/delete-form.ts index 754586a9..a4aaa40b 100644 --- a/packages/forms/src/services/delete-form.ts +++ b/packages/forms/src/services/delete-form.ts @@ -7,7 +7,7 @@ type DeleteFormError = { message: string; }; -type DeleteForm = ( +export type DeleteForm = ( ctx: FormServiceContext, formId: string ) => Promise>; diff --git a/packages/forms/src/services/get-form-list.ts b/packages/forms/src/services/get-form-list.ts index 7beb3cee..b89b5764 100644 --- a/packages/forms/src/services/get-form-list.ts +++ b/packages/forms/src/services/get-form-list.ts @@ -12,7 +12,7 @@ type FormListError = { message: string; }; -type GetFormList = ( +export type GetFormList = ( ctx: FormServiceContext ) => Promise>; diff --git a/packages/forms/src/services/get-form-session.test.ts b/packages/forms/src/services/get-form-session.test.ts new file mode 100644 index 00000000..68a0cfa0 --- /dev/null +++ b/packages/forms/src/services/get-form-session.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest'; + +import { type Blueprint, createForm, createFormSession } from '../index.js'; +import { type FormServiceContext } from '../context/index.js'; +import { createTestFormServiceContext } from '../testing.js'; +import { getFormSession } from './get-form-session.js'; + +describe('getFormSession', () => { + it('Returns empty, non-persisted session if sessionId is not provided', async () => { + const ctx = await createTestFormServiceContext({ + isUserLoggedIn: () => false, + }); + const form = createForm({ title: 'Test form', description: '' }); + const formResult = await ctx.repository.addForm(form); + if (!formResult.success) { + expect.fail('Failed to add test form:', formResult.error); + } + + const sessionResult = await getFormSession(ctx, { + formId: formResult.data.id, + formRoute: { url: `/ignored`, params: {} }, + }); + if (!sessionResult.success) { + expect.fail('Failed to get form session', sessionResult.error); + } + expect(sessionResult.data).toEqual({ + id: sessionResult.data.id, + formId: formResult.data.id, + data: { + form: form, + route: { url: `/ignored`, params: {} }, + data: { errors: {}, values: {} }, + }, + }); + }); + + it('Returns existing session', async () => { + const ctx = await createTestFormServiceContext({ + isUserLoggedIn: () => false, + }); + const testData = await createTestFormSession(ctx); + + const sessionResult = await getFormSession(ctx, { + formId: testData.formId, + formRoute: { url: `/ignored`, params: {} }, + sessionId: testData.sessionId, + }); + if (!sessionResult.success) { + expect.fail( + `Failed to get inserted form session: ${sessionResult.error}` + ); + } + + expect(sessionResult.data).toEqual({ + id: testData.sessionId, + formId: testData.formId, + data: { + data: { errors: {}, values: {} }, + form: testData.form, + route: { url: `/ignored`, params: {} }, + }, + }); + }); + + it('Returns new, unsaved session if existing session is not found', async () => { + const ctx = await createTestFormServiceContext({ + isUserLoggedIn: () => false, + }); + const testData = await createTestFormSession(ctx); + + const sessionResult = await getFormSession(ctx, { + formId: testData.formId, + formRoute: { url: `/ignored`, params: {} }, + sessionId: 'non-existent-session-id', + }); + if (!sessionResult.success) { + expect.fail( + `Failed to get inserted form session: ${sessionResult.error}` + ); + } + + expect(sessionResult.data).toEqual({ + id: sessionResult.data.id, + formId: testData.formId, + data: { + data: { errors: {}, values: {} }, + form: testData.form, + route: { url: `/ignored`, params: {} }, + }, + }); + }); +}); + +const createTestFormSession = async (ctx: FormServiceContext) => { + const form = createForm({ title: 'Test form', description: '' }); + const formAddResult = await ctx.repository.addForm(form); + if (!formAddResult.success) { + expect.fail(`Failed to add test form: ${formAddResult.error}`); + } + return { + form, + formId: formAddResult.data.id, + sessionId: await insertFormSession(ctx, formAddResult.data.id, form), + }; +}; + +const insertFormSession = async ( + ctx: FormServiceContext, + formId: string, + form: Blueprint +) => { + const formSession = createFormSession(form, { + url: `/ignored`, + params: {}, + }); + const sessionInsertResult = await ctx.repository.upsertFormSession({ + formId: formId, + data: formSession, + }); + if (!sessionInsertResult.success) { + expect.fail(`Failed to insert form session: ${sessionInsertResult.error}`); + } + return sessionInsertResult.data.id; +}; diff --git a/packages/forms/src/services/get-form-session.ts b/packages/forms/src/services/get-form-session.ts new file mode 100644 index 00000000..eb3521af --- /dev/null +++ b/packages/forms/src/services/get-form-session.ts @@ -0,0 +1,61 @@ +import { type Result, failure, success } from '@atj/common'; + +import { type FormServiceContext } from '../context/index.js'; +import { type FormRoute } from '../route-data.js'; +import { + type FormSession, + type FormSessionId, + createFormSession, +} from '../session.js'; + +export type GetFormSession = ( + ctx: FormServiceContext, + opts: { + formId: string; + formRoute: FormRoute; + sessionId?: string; + } +) => Promise< + Result<{ + id?: FormSessionId; + formId: string; + data: FormSession; + }> +>; + +export const getFormSession: GetFormSession = async (ctx, opts) => { + const form = await ctx.repository.getForm(opts.formId); + if (form === null) { + return failure(`form '${opts.formId} does not exist`); + } + + // If this request corresponds to an non-existent session, return a new + // session that is not yet persisted. + if (opts.sessionId === undefined) { + const formSession = await createFormSession(form, opts.formRoute); + return success({ + formId: opts.formId, + data: formSession, + }); + } + + const formSession = await ctx.repository.getFormSession(opts.sessionId); + if (!formSession.success) { + console.error( + `Error retrieving form session: ${formSession.error}. Returning new session.` + ); + const newSession = await createFormSession(form, opts.formRoute); + return success({ + formId: opts.formId, + data: newSession, + }); + } + + return success({ + ...formSession.data, + data: { + ...formSession.data.data, + route: opts.formRoute, + }, + }); +}; diff --git a/packages/forms/src/services/get-form.test.ts b/packages/forms/src/services/get-form.test.ts index 4be75251..412f10c0 100644 --- a/packages/forms/src/services/get-form.test.ts +++ b/packages/forms/src/services/get-form.test.ts @@ -8,7 +8,7 @@ import { getForm } from './get-form.js'; const TEST_FORM = createForm({ title: 'Form Title', description: '' }); describe('getForm', () => { - it('returns access denied (401) if user is not logged in', async () => { + it('non-existent form returns 404', async () => { const ctx = await createTestFormServiceContext({ isUserLoggedIn: () => false, }); @@ -16,15 +16,15 @@ describe('getForm', () => { expect(result).toEqual({ success: false, error: { - status: 401, - message: 'You must be logged in to delete a form', + status: 404, + message: 'Form not found', }, }); }); - it('gets form successfully when user is logged in', async () => { + it('gets form successfully', async () => { const ctx = await createTestFormServiceContext({ - isUserLoggedIn: () => true, + isUserLoggedIn: () => false, }); const addResult = await ctx.repository.addForm(TEST_FORM); if (!addResult.success) { diff --git a/packages/forms/src/services/get-form.ts b/packages/forms/src/services/get-form.ts index a58edd0b..82737020 100644 --- a/packages/forms/src/services/get-form.ts +++ b/packages/forms/src/services/get-form.ts @@ -8,18 +8,12 @@ type GetFormError = { message: string; }; -type GetForm = ( +export type GetForm = ( ctx: FormServiceContext, formId: string ) => Promise>; export const getForm: GetForm = async (ctx, formId) => { - if (!ctx.isUserLoggedIn()) { - return failure({ - status: 401, - message: 'You must be logged in to delete a form', - }); - } const result = await ctx.repository.getForm(formId); if (result === null) { return failure({ diff --git a/packages/forms/src/services/index.ts b/packages/forms/src/services/index.ts index 710b2f6a..96cc598a 100644 --- a/packages/forms/src/services/index.ts +++ b/packages/forms/src/services/index.ts @@ -1,13 +1,14 @@ -import { createService } from '@atj/common'; +import { createService, ServiceMethod } from '@atj/common'; import { type FormServiceContext } from '../context/index.js'; -import { addForm } from './add-form.js'; -import { deleteForm } from './delete-form.js'; -import { getForm } from './get-form.js'; -import { getFormList } from './get-form-list.js'; -import { saveForm } from './save-form.js'; -import { submitForm } from './submit-form.js'; +import { type AddForm, addForm } from './add-form.js'; +import { type DeleteForm, deleteForm } from './delete-form.js'; +import { type GetForm, getForm } from './get-form.js'; +import { type GetFormList, getFormList } from './get-form-list.js'; +import { type GetFormSession, getFormSession } from './get-form-session.js'; +import { type SaveForm, saveForm } from './save-form.js'; +import { type SubmitForm, submitForm } from './submit-form.js'; export const createFormService = (ctx: FormServiceContext) => createService(ctx, { @@ -15,13 +16,17 @@ export const createFormService = (ctx: FormServiceContext) => deleteForm, getForm, getFormList, + getFormSession, saveForm, submitForm, }); -/* -export type FormService = Omit< - ReturnType, - 'getContext' ->; -*/ -export type FormService = ReturnType; + +export type FormService = { + addForm: ServiceMethod; + deleteForm: ServiceMethod; + getForm: ServiceMethod; + getFormList: ServiceMethod; + getFormSession: ServiceMethod; + saveForm: ServiceMethod; + submitForm: ServiceMethod; +}; diff --git a/packages/forms/src/services/save-form.ts b/packages/forms/src/services/save-form.ts index aeb5b5b6..133aed90 100644 --- a/packages/forms/src/services/save-form.ts +++ b/packages/forms/src/services/save-form.ts @@ -8,7 +8,7 @@ type SaveFormError = { message: string; }; -type SaveForm = ( +export type SaveForm = ( ctx: FormServiceContext, formId: string, form: Blueprint diff --git a/packages/forms/src/services/submit-form.test.ts b/packages/forms/src/services/submit-form.test.ts index 5967f3f9..e68fd1b4 100644 --- a/packages/forms/src/services/submit-form.test.ts +++ b/packages/forms/src/services/submit-form.test.ts @@ -1,25 +1,1613 @@ import { describe, expect, it } from 'vitest'; -import { createForm, createFormSession } from '../index.js'; +import { + addDocument, + createForm, + createFormSession, + defaultFormConfig, + BlueprintBuilder, + type Blueprint, + type InputPattern, + type PagePattern, + type PageSetPattern, + type PatternValueMap, +} from '../index.js'; import { createTestFormServiceContext } from '../testing.js'; +import { loadSamplePDF } from '../documents/__tests__/sample-data.js'; import { submitForm } from './submit-form.js'; +import { object } from 'zod'; describe('submitForm', () => { it('succeeds with empty form', async () => { - const ctx = await createTestFormServiceContext({ - isUserLoggedIn: () => false, + const { ctx, id, form } = await setupTestForm(); + const session = createFormSession(form); + const formSessionResult = await ctx.repository.upsertFormSession({ + formId: id, + data: session, }); - const testForm = createForm({ title: 'test', description: 'description' }); - const addFormResult = await ctx.repository.addForm(testForm); - if (addFormResult.success === false) { - expect.fail('addForm failed'); + if (!formSessionResult.success) { + expect.fail('upsertFormSession failed'); } - const session = createFormSession(testForm); - const result = await submitForm(ctx, session, addFormResult.data.id, {}); + const result = await submitForm(ctx, formSessionResult.data.id, id, {}); expect(result).toEqual({ success: true, - data: [], + data: { + session: session, + sessionId: formSessionResult.data.id, + documents: [], + }, + }); + }); + + it('fails with invalid form ID', async () => { + const { ctx, form, id } = await setupTestForm(); + const session = createFormSession(form); + const formSessionResult = await ctx.repository.upsertFormSession({ + formId: id, + data: session, + }); + if (!formSessionResult.success) { + expect.fail('upsertFormSession failed'); + } + const result = await submitForm( + ctx, + formSessionResult.data.id, + 'invalid-id', + {} + ); + expect(result).toEqual({ + success: false, + error: 'Form not found', + }); + }); + + it('succeeds with incomplete session', async () => { + const { ctx, form, id } = await setupTestForm(createOnePatternTestForm()); + const session = createFormSession(form); + const formSessionResult = await ctx.repository.upsertFormSession({ + formId: id, + data: session, + }); + if (!formSessionResult.success) { + expect.fail('upsertFormSession failed'); + } + const result = await submitForm(ctx, formSessionResult.data.id, id, {}); + expect(result).toEqual({ + data: { + sessionId: formSessionResult.data.id, + session: { + ...session, + route: undefined, + }, + }, + success: true, + }); + }); + + it('succeeds with complete session', async () => { + const { ctx, form, id } = await setupTestForm(createOnePatternTestForm()); + const session = createFormSession(form); + const formSessionResult = await ctx.repository.upsertFormSession({ + formId: id, + data: session, + }); + if (!formSessionResult.success) { + expect.fail('upsertFormSession failed'); + } + const result = await submitForm(ctx, formSessionResult.data.id, id, { + 'element-1': 'test', + }); + expect(result).toEqual({ + success: true, + data: { + session: expect.any(Object), + sessionId: formSessionResult.data.id, + documents: [], + }, + }); + }); + + it('returns a pdf with completed form', async () => { + const { ctx, form, id } = await setupTestForm( + await createTestFormWithPDF() + ); + const formData = getMockFormData(form); + const formSessionResult = await ctx.repository.upsertFormSession({ + formId: id, + data: createFormSession(form), + }); + if (!formSessionResult.success) { + expect.fail('upsertFormSession failed'); + } + const result = await submitForm( + ctx, + formSessionResult.data.id, + id, + formData + ); + expect(result).toEqual( + expect.objectContaining({ + success: true, + data: { + session: expect.any(Object), + sessionId: formSessionResult.data.id, + documents: [ + { + fileName: 'test.pdf', + data: expect.any(Uint8Array), + }, + ], + }, + }) + ); + }); + + it.fails('handles page one of a multi-page form', async () => { + const form = createForm( + { + title: 'Test form', + description: 'Test description', + }, + { + root: 'root', + patterns: [ + { + type: 'page-set', + id: 'root', + data: { + pages: ['page-1', 'page-2'], + }, + } satisfies PageSetPattern, + { + type: 'page', + id: 'page-1', + data: { + title: 'Page 1', + patterns: ['element-1'], + }, + } satisfies PagePattern, + { + type: 'input', + id: 'element-1', + data: { + label: 'Pattern 1', + initial: '', + required: true, + maxLength: 128, + }, + } satisfies InputPattern, + { + type: 'page', + id: 'page-2', + data: { + title: 'Page 2', + patterns: ['element-2'], + }, + } satisfies PagePattern, + { + type: 'input', + id: 'element-2', + data: { + label: 'Pattern 2', + initial: '', + required: true, + maxLength: 128, + }, + } satisfies InputPattern, + ], + } + ); + const { ctx, id } = await setupTestForm(form); + const formSessionResult = await ctx.repository.upsertFormSession({ + formId: id, + data: createFormSession(form), + }); + if (!formSessionResult.success) { + expect.fail('upsertFormSession failed'); + } + const result = await submitForm(ctx, formSessionResult.data.id, id, { + 'element-1': 'test', + }); + expect(result).toEqual({ + success: true, + data: { documents: [] }, }); }); }); + +const setupTestForm = async (form?: Blueprint) => { + form = form || createForm({ title: 'test', description: 'description' }); + const ctx = await createTestFormServiceContext({ + isUserLoggedIn: () => false, + }); + const addFormResult = await ctx.repository.addForm(form); + if (addFormResult.success === false) { + expect.fail('addForm failed'); + } + return { ctx, id: addFormResult.data.id, form }; +}; + +const createOnePatternTestForm = () => { + return createForm( + { + title: 'Test form', + description: 'Test description', + }, + { + root: 'root', + patterns: [ + { + type: 'page-set', + id: 'root', + data: { + pages: ['page-1'], + }, + } satisfies PageSetPattern, + { + type: 'page', + id: 'page-1', + data: { + title: 'Page 1', + patterns: ['element-1', 'element-2'], + }, + } satisfies PagePattern, + { + type: 'input', + id: 'element-1', + data: { + label: 'Pattern 1', + initial: '', + required: true, + maxLength: 128, + }, + } satisfies InputPattern, + ], + } + ); +}; + +const createTestFormWithPDF = async () => { + const pdfBytes = await loadSamplePDF( + 'doj-pardon-marijuana/application_for_certificate_of_pardon_for_simple_marijuana_possession.pdf' + ); + const builder = new BlueprintBuilder(defaultFormConfig); + const { updatedForm } = await addDocument( + builder.form, + { + name: 'test.pdf', + data: new Uint8Array(pdfBytes), + }, + { + fetchPdfApiResponse: async () => SERVICE_RESPONSE, + } + ); + + return updatedForm; +}; + +const getMockFormData = (form: Blueprint): PatternValueMap => { + return Object.keys(form.patterns).reduce((acc, key) => { + if (form.patterns[key].type === 'checkbox') { + acc[key] = true; + } else { + acc[key] = 'test value'; + } + return acc; + }, {} as PatternValueMap); +}; + +const SERVICE_RESPONSE = { + message: 'PDF parsed successfully', + parsed_pdf: { + raw_text: + 'OMB Control No: 1123-0014 Expires 03/31/2027 \nAPPLICATION FOR CERTIFICATE OF PARDON FOR THE OFFENSES OF \nSIMPLE POSSESSION, ATTEMPTED SIMPLE POSSESSION, OR USE OF \nMARIJUANA \nOn October 6, 2022, President Biden issued a presidential proclamation that pardoned many federal and D.C. \noffenses for simple marijuana possession. On December 22, 2023, President Biden issued another proclamation that \nexpanded the relief provided by the original proclamation by pardoning the federal offenses of simple possession, \nattempted possession, and use of marijuana. \nHow a pardon can help you \nA pardon is an expression of the President\u2019s forgiveness. It does not mean you are innocent or expunge your \nconviction. But it does remove civil disabilities\u2014such as restrictions on the right to vote, to hold office, or to sit \non a jury\u2014that are imposed because of the pardoned conviction. It may also be helpful in obtaining licenses, \nbonding, or employment. Learn more about the pardon. \nYou qualify for the pardon if: \n\u2022 On or before December 22, 2023, you were charged with or convicted of simple possession, attempted \npossession, or use of marijuana under the federal code, the District of Columbia code, or the Code of \nFederal Regulations \n\u2022 You were a U.S. citizen or lawfully present in the United States at the time of the offense \n\u2022 You were a U.S. citizen or lawful permanent resident on December 22, 2023 \nRequest a certificate to show proof of the pardon \nA Certificate of Pardon is proof that you were pardoned under the proclamation. The certificate is the only \ndocumentation you will receive of the pardon. Use the application below to start your request. \nWhat you\'ll need for the request \nAbout you \nYou can submit a request for yourself or someone else can submit on your behalf. You must provide \npersonal details, like name or citizenship status and either a mailing address, an email address or both to \ncontact you. We strongly recommend including an email address, if available, as we may not be able to \nrespond as quickly if you do not provide it. You can also use the mailing address or email address of \nanother person, if you do not have your own. \nAbout the charge or conviction \nYou must state whether it was a charge or conviction, the court district where it happened, and the date \n(month, day, year). If possible, you should also: \n\u2022 enter information about your case (docket or case number and the code section that was \ncharged) \n\u2022 upload your documents \no charging documents, like the indictment, complaint, criminal information, ticket or \ncitation; or \no conviction documents, like the judgment of conviction, the court docket sheet showing \nthe sentence and date it was imposed, or if you did not go to court, the receipt showing \npayment of fine \nIf you were charged by a ticket or citation and paid a fine instead of appearing in court, you should also provide the \ndate of conviction or the date the fine was paid. \nWithout this information, we can\'t guarantee that we\'ll be able to determine if you qualify for the pardon under \nthe proclamation. \n \nPage 1 of 4 \nUnited States Department of Justice Office of the Pardon Attorney Washington, D.C. 20530 January 2024 OMB Control No: 1123-0014 Expires 03/31/2027 \nAPPLICATION FOR CERTIFICATE OF PARDON FOR THE OFFENSES OF \nSIMPLE POSSESSION, ATTEMPTED SIMPLE POSSESSION, OR USE OF \nMARIJUANA \nInstructions: \nAn online version of this application is available at: Presidential Proclamation on Marijuana Possession \n(justice.gov). You can also complete and return this application with the required documents to \nUSPardon.Attorney@usdoj.gov or U.S. Department of Justice, Office of the Pardon Attorney, 950 Pennsylvania \nAvenue NW, Washington, DC 20530. \nPublic Burden Statement: \nThis collection meets the requirements of 44 U.S.C. \u00a7 3507, as amended by the Paperwork Reduction Act of 1995. \nWe estimate that it will take 120 minutes to read the instructions, gather the relevant materials, and answer \nquestions on the form. Send comments regarding the burden estimate or any other aspect of this collection of \ninformation, including suggestions for reducing this burden, to Office of the Pardon Attorney, U.S. Department of \nJustice, Attn: OMB Number 1123-0014, RFK Building, 950 Pennsylvania Avenue, N.W., Washington DC 20530. \nThe OMB Clearance number, 1123-0014, is currently valid. \nPrivacy Act Statement: \nThe Office of the Pardon Attorney has authority to collect this information under the U.S. Constitution, Article \nII, Section 2 (the pardon clause); Orders of the Attorney General Nos. 1798-93, 58 Fed. Reg. 53658 and 53659 \n(1993), 2317-2000, 65 Fed. Reg. 48381 (2000), and 2323-2000, 65 Fed. Reg. 58223 and 58224 (2000), codified in \n28 C.F.R. \u00a7\u00a7 1.1 et seq. (the rules governing petitions for executive clemency); and Order of the Attorney General \nNo. 1012-83, 48 Fed. Reg. 22290 (1983), as codified in 28 C.F.R. \u00a7\u00a7 0.35 and 0.36 (the authority of the Office of \nthe Pardon Attorney). The principal purpose for collecting this information is to enable the Office of the Pardon \nAttorney to issue an individual certificate of pardon to you. The routine uses which may be made of this \ninformation include provision of data to the President and his staff, other governmental entities, and the public. \nThe full list of routine uses for this correspondence can be found in the System of Records Notice titled, \u201cPrivacy \nAct of 1974; System of Records,\u201d published in Federal Register, September 15, 2011, Vol. 76, No. 179, at pages \n57078 through 57080; as amended by \u201cPrivacy Act of 1974; System of Records,\u201d published in the Federal \nRegister, May 25, 2017, Vol. 82, No. 100, at page 24161, and at the U.S. Department of Justice, Office of Privacy \nand Civil Liberties\' website at: https://www.justice.gov/opcl/doj-systems-records#OPA. \nBy signing the attached form, you consent to allowing the Office of the Pardon Attorney to obtain information \nregarding your citizenship and/or immigration status from the courts, from other government agencies, from other \ncomponents within the Department of Justice, and from the Department of Homeland Security, U.S. Citizenship \nand Immigration Services (DHS-USCIS), Systematic Alien Verification for Entitlements (SAVE) program. The \ninformation received from these sources will be used for the sole purposes of determining an applicant\'s \nqualification for a Certificate of Pardon under the December 22 proclamation and for record-keeping of those \ndeterminations. Further, please be aware that if the Office of the Pardon Attorney is unable to verify your \ncitizenship or immigration status based on the information provided below, we may contact you to obtain \nadditional verification information. Learn more about the DHS-USCIS\'s SAVE program and its ordinary uses. \nYour disclosure of information to the Office of the Pardon Attorney on this form is voluntary. If you do not \ncomplete all or some of the information fields in this form, however, the Office of the Pardon Attorney may not be \nable to effectively respond. Information regarding gender, race, or ethnicity is not required and will not affect the \nprocessing of the application. \nNote: Submit a separate form for each conviction or charge for which you are seeking a certificate of pardon. \nApplication Form on page 3. \nPage 2 of 4 \nUnited States Department of Justice Office of the Pardon Attorney Washington, D.C. 20530 January 2024 OMB Control No: 1123-0014 Expires 03/31/2027 \nAPPLICATION FOR CERTIFICATE OF PARDON FOR THE OFFENSES OF \nSIMPLE POSSESSION, ATTEMPTED SIMPLE POSSESSION, OR USE OF \nMARIJUANA \nComplete the following: \nName: \n(first) (middle) (last) \nName at Conviction: \n(if different) (first) (middle) (last) \nAddress: \n(number) (street) (apartment/unit no.) \n\n(city) (state) (Zip Code) \nEmail Address: Phone Number: \nDate of Birth: Gender: Are you Hispanic or Latino?: Yes No \nRace: Alaska Native or American Indian Asian Black or African American \nNative Hawaiian or Other Pacific Islander White Other \nCitizenship or Residency Status: \nU.S. citizen by birth \nU.S. naturalized citizen Date Naturalization Granted: \nLawful Permanent Resident Date Residency Granted: \nAlien Registration Number (A-Number), Certificate of Naturalization Number, or Citizenship Number \n(if applicant is a lawful permanent resident or naturalized citizen): \n(A-Number) \n1. Applicant was convicted on: in the U.S. District Court for the \n(month/day/year) (Northern, etc.) \nDistrict of (state) or D.C. Superior Court of simple possession of marijuana, under \nDocket No. : and Code Section: ; OR \n(docket number) (code section) \n2. Applicant was charged with Code Section: in the U.S. District Court for the \n(code section) (Eastern, etc.) \nDistrict of or D.C. Superior Court under Docket No: \n(state) (docket number) \n \nUnited States Department of Justice Office of the Pardon Attorney Page 3 of 4 Washington, D.C. 20530 January 2024 OMB Control No: 1123-0014 Expires 03/31/2027 \nAPPLICATION FOR CERTIFICATE OF PARDON FOR THE OFFENSES OF \nSIMPLE POSSESSION, ATTEMPTED SIMPLE POSSESSION, OR USE OF \nMARIJUANA \nWith knowledge of the penalties for false statements to Federal Agencies, as provided by 18 \nU.S.C. \u00a7 1001, and with knowledge that this statement is submitted by me to affect action by \nthe U.S. Department of Justice, I certify that: \n1. The applicant was either a U.S. citizen or lawfully present in the United States at the time of the \noffense. \n2. The applicant was a U.S. citizen or lawful permanent resident on December 22, 2023. \n3. The above statements, and accompanying documents, are true and complete to the \n best of my knowledge, information, and belief. \n4. I acknowledge that any certificate issued in reliance on the above information will be \nvoided, if the information is subsequently determined to be false. \n\n(date) (signature) \nPage 4 of 4 \nUnited States Department of Justice Office of the Pardon Attorney Washington, D.C. 20530 January 2024 ', + form_summary: { + component_type: 'form_summary', + title: 'My Form Title', + description: 'My Form Description', + }, + elements: [ + { + component_type: 'paragraph', + text: "OMB Control No: 1123-0014 Expires 03/31/2027 APPLICATION FOR CERTIFICATE OF PARDON FOR THE OFFENSES OF SIMPLE POSSESSION, ATTEMPTED SIMPLE POSSESSION, OR USE OF MARIJUANA On October 6, 2022, President Biden issued a presidential proclamation that pardoned many federal and D.C. offenses for simple marijuana possession. On December 22, 2023, President Biden issued another proclamation that expanded the relief provided by the original proclamation by pardoning the federal offenses of simple possession, attempted possession, and use of marijuana. How a pardon can help you A pardon is an expression of the President\u2019s forgiveness. It does not mean you are innocent or expunge your conviction. But it does remove civil disabilities\u2014such as restrictions on the right to vote, to hold office, or to sit on a jury\u2014that are imposed because of the pardoned conviction. It may also be helpful in obtaining licenses, bonding, or employment. Learn more about the pardon. You qualify for the pardon if: \u2022 On or before December 22, 2023, you were charged with or convicted of simple possession, attempted possession, or use of marijuana under the federal code, the District of Columbia code, or the Code of Federal Regulations \u2022 You were a U.S. citizen or lawfully present in the United States at the time of the offense \u2022 You were a U.S. citizen or lawful permanent resident on December 22, 2023 Request a certificate to show proof of the pardon A Certificate of Pardon is proof that you were pardoned under the proclamation. The certificate is the only documentation you will receive of the pardon. Use the application below to start your request. What you'll need for the request About you You can submit a request for yourself or someone else can submit on your behalf. You must provide personal details, like name or citizenship status and either a mailing address, an email address or both to contact you. We strongly recommend including an email address, if available, as we may not be able to respond as quickly if you do not provide it. You can also use the mailing address or email address of another person, if you do not have your own. About the charge or conviction You must state whether it was a charge or conviction, the court district where it happened, and the date (month, day, year). If possible, you should also: \u2022 enter information about your case (docket or case number and the code section that was charged) \u2022 upload your documents o charging documents, like the indictment, complaint, criminal information, ticket or citation; or o conviction documents, like the judgment of conviction, the court docket sheet showing the sentence and date it was imposed, or if you did not go to court, the receipt showing payment of fine If you were charged by a ticket or citation and paid a fine instead of appearing in court, you should also provide the date of conviction or the date the fine was paid. Without this information, we can't guarantee that we'll be able to determine if you qualify for the pardon under the proclamation. Page 1 of 4 United States Department of Justice Office of the Pardon Attorney Washington, D.C. 20530 January 2024", + style: 'normal', + page: 0, + }, + { + component_type: 'paragraph', + text: "OMB Control No: 1123-0014 Expires 03/31/2027 APPLICATION FOR CERTIFICATE OF PARDON FOR THE OFFENSES OF SIMPLE POSSESSION, ATTEMPTED SIMPLE POSSESSION, OR USE OF MARIJUANA Instructions: An online version of this application is available at: Presidential Proclamation on Marijuana Possession (justice.gov). You can also complete and return this application with the required documents to USPardon.Attorney@usdoj.gov or U.S. Department of Justice, Office of the Pardon Attorney, 950 Pennsylvania Avenue NW, Washington, DC 20530. Public Burden Statement: This collection meets the requirements of 44 U.S.C. \u00a7 3507, as amended by the Paperwork Reduction Act of 1995. We estimate that it will take 120 minutes to read the instructions, gather the relevant materials, and answer questions on the form. Send comments regarding the burden estimate or any other aspect of this collection of information, including suggestions for reducing this burden, to Office of the Pardon Attorney, U.S. Department of Justice, Attn: OMB Number 1123-0014, RFK Building, 950 Pennsylvania Avenue, N.W., Washington DC 20530. The OMB Clearance number, 1123-0014, is currently valid. Privacy Act Statement: The Office of the Pardon Attorney has authority to collect this information under the U.S. Constitution, Article II, Section 2 (the pardon clause); Orders of the Attorney General Nos. 1798-93, 58 Fed. Reg. 53658 and 53659 (1993), 2317-2000, 65 Fed. Reg. 48381 (2000), and 2323-2000, 65 Fed. Reg. 58223 and 58224 (2000), codified in 28 C.F.R. \u00a7\u00a7 1.1 et seq. (the rules governing petitions for executive clemency); and Order of the Attorney General No. 1012-83, 48 Fed. Reg. 22290 (1983), as codified in 28 C.F.R. \u00a7\u00a7 0.35 and 0.36 (the authority of the Office of the Pardon Attorney). The principal purpose for collecting this information is to enable the Office of the Pardon Attorney to issue an individual certificate of pardon to you. The routine uses which may be made of this information include provision of data to the President and his staff, other governmental entities, and the public. The full list of routine uses for this correspondence can be found in the System of Records Notice titled, \u201cPrivacy Act of 1974; System of Records,\u201d published in Federal Register, September 15, 2011, Vol. 76, No. 179, at pages 57078 through 57080; as amended by \u201cPrivacy Act of 1974; System of Records,\u201d published in the Federal Register, May 25, 2017, Vol. 82, No. 100, at page 24161, and at the U.S. Department of Justice, Office of Privacy and Civil Liberties' website at: https://www.justice.gov/opcl/doj-systems-records#OPA. By signing the attached form, you consent to allowing the Office of the Pardon Attorney to obtain information regarding your citizenship and/or immigration status from the courts, from other government agencies, from other components within the Department of Justice, and from the Department of Homeland Security, U.S. Citizenship and Immigration Services (DHS-USCIS), Systematic Alien Verification for Entitlements (SAVE) program. The information received from these sources will be used for the sole purposes of determining an applicant's qualification for a Certificate of Pardon under the December 22 proclamation and for record-keeping of those determinations. Further, please be aware that if the Office of the Pardon Attorney is unable to verify your citizenship or immigration status based on the information provided below, we may contact you to obtain additional verification information. Learn more about the DHS-USCIS's SAVE program and its ordinary uses. Your disclosure of information to the Office of the Pardon Attorney on this form is voluntary. If you do not complete all or some of the information fields in this form, however, the Office of the Pardon Attorney may not be able to effectively respond. Information regarding gender, race, or ethnicity is not required and will not affect the processing of the application. Note: Submit a separate form for each conviction or charge for which you are seeking a certificate of pardon. Application Form on page 3. Page 2 of 4 United States Department of Justice Office of the Pardon Attorney Washington, D.C. 20530 January 2024", + style: 'normal', + page: 1, + }, + { + component_type: 'paragraph', + text: 'OMB Control No: 1123-0014 Expires 03/31/2027 APPLICATION FOR CERTIFICATE OF PARDON FOR THE OFFENSES OF SIMPLE POSSESSION, ATTEMPTED SIMPLE POSSESSION, OR USE OF MARIJUANA Complete the following:', + style: 'normal', + page: 2, + }, + { + component_type: 'fieldset', + legend: 'Name: ', + fields: [ + { + component_type: 'text_input', + id: 'Fst Name 1', + label: 'First Name', + default_value: '', + required: true, + page: 2, + }, + { + component_type: 'text_input', + id: '', + label: 'Middle Name', + default_value: '', + required: true, + page: 2, + }, + { + component_type: 'text_input', + id: '', + label: 'Last Name', + default_value: '', + required: true, + page: 2, + }, + ], + page: 2, + }, + { + component_type: 'paragraph', + text: '(first) (middle) (last)', + style: 'normal', + page: 2, + }, + { + component_type: 'fieldset', + legend: 'Name at Conviction: ', + fields: [ + { + component_type: 'text_input', + id: 'Conv Fst Name', + label: 'First Name at Conviction', + default_value: '', + required: true, + page: 2, + }, + { + component_type: 'text_input', + id: 'Conv Mid Name', + label: 'Middle Name at Conviction', + default_value: '', + required: true, + page: 2, + }, + { + component_type: 'text_input', + id: 'Conv Lst Name', + label: 'Last Name at Conviction', + default_value: '', + required: true, + page: 2, + }, + ], + page: 2, + }, + { + component_type: 'paragraph', + text: '(if different) (first) (middle) (last)', + style: 'normal', + page: 2, + }, + { + component_type: 'fieldset', + legend: 'Address: ', + fields: [ + { + component_type: 'text_input', + id: 'Address', + label: 'Address (number, street, apartment/unit number)', + default_value: '', + required: true, + page: 2, + }, + ], + page: 2, + }, + { + component_type: 'paragraph', + text: '(number) (street) (apartment/unit no.)', + style: 'normal', + page: 2, + }, + { + component_type: 'fieldset', + legend: 'City', + fields: [ + { + component_type: 'text_input', + id: 'City', + label: 'City', + default_value: '', + required: true, + page: 2, + }, + { + component_type: 'text_input', + id: 'State', + label: 'State', + default_value: '', + required: true, + page: 2, + }, + { + component_type: 'text_input', + id: 'Zip Code', + label: '(Zip Code)', + default_value: '', + required: true, + page: 2, + }, + ], + page: 2, + }, + { + component_type: 'paragraph', + text: '(city) (state) (Zip Code)', + style: 'normal', + page: 2, + }, + { + component_type: 'fieldset', + legend: 'Email Address: ', + fields: [ + { + component_type: 'text_input', + id: 'Email Address', + label: 'Email Address', + default_value: '', + required: true, + page: 2, + }, + ], + page: 2, + }, + { + component_type: 'fieldset', + legend: 'Phone Number: ', + fields: [ + { + component_type: 'text_input', + id: 'Phone Number', + label: 'Phone Number', + default_value: '', + required: true, + page: 2, + }, + ], + page: 2, + }, + { + component_type: 'paragraph', + text: 'Date of Birth: Gender:', + style: 'normal', + page: 2, + }, + { + component_type: 'fieldset', + legend: 'Date of Birth', + fields: [ + { + component_type: 'text_input', + id: 'Date of Birth', + label: 'Date of Birth', + default_value: '', + required: true, + page: 2, + }, + { + component_type: 'text_input', + id: 'Gender', + label: 'Gender', + default_value: '', + required: true, + page: 2, + }, + ], + page: 2, + }, + { + component_type: 'radio_group', + legend: 'Are you Hispanic or Latino?: ', + options: [ + { + id: 'Yes', + label: 'Yes ', + name: 'Yes', + default_checked: false, + page: 2, + }, + { + id: 'No', + label: 'No ', + name: 'No', + default_checked: false, + page: 2, + }, + ], + id: 'Ethnicity', + page: 2, + }, + { + component_type: 'fieldset', + legend: 'Race:', + fields: [ + { + component_type: 'checkbox', + id: 'Nat Amer', + label: 'Alaska Native or American Indian ', + default_checked: false, + struct_parent: 20, + page: 2, + }, + { + component_type: 'checkbox', + id: 'Asian', + label: 'Asian ', + default_checked: false, + struct_parent: 21, + page: 2, + }, + { + component_type: 'checkbox', + id: 'Blck Amer', + label: 'Black or African American ', + default_checked: false, + struct_parent: 22, + page: 2, + }, + { + component_type: 'checkbox', + id: 'Nat Haw Islander', + label: 'Native Hawaiian or Other Pacific Islander ', + default_checked: false, + struct_parent: 23, + page: 2, + }, + { + component_type: 'checkbox', + id: 'White', + label: 'White ', + default_checked: false, + struct_parent: 24, + page: 2, + }, + { + component_type: 'checkbox', + id: 'Other', + label: 'Other ', + default_checked: false, + struct_parent: 25, + page: 2, + }, + ], + page: 2, + }, + { + component_type: 'radio_group', + legend: 'Citizenship or Residency Status: ', + options: [ + { + id: 'Birth', + label: 'U.S. citizen by birth ', + name: 'Birth', + default_checked: false, + page: 2, + }, + { + id: 'Naturalized', + label: 'U.S. naturalized citizen ', + name: 'Naturalized', + default_checked: false, + page: 2, + }, + { + id: 'Permanent_Resident', + label: 'Lawful Permanent Resident ', + name: 'Permanent_Resident', + default_checked: false, + page: 2, + }, + ], + id: 'Citizenship', + page: 2, + }, + { + component_type: 'fieldset', + legend: 'U.S. naturalized citizen ', + fields: [ + { + component_type: 'text_input', + id: 'Residency Date_af_date', + label: 'Date Residency Granted (mm/dd/yyyy)', + default_value: '', + required: true, + page: 2, + }, + { + component_type: 'text_input', + id: '', + label: 'date naturalization granted', + default_value: '', + required: true, + page: 2, + }, + ], + page: 2, + }, + { + component_type: 'paragraph', + text: 'Date Residency Granted: Alien Registration Number (A-Number), Certificate of Naturalization Number, or Citizenship Number', + style: 'normal', + page: 2, + }, + { + component_type: 'fieldset', + legend: + '(if applicant is a lawful permanent resident or naturalized citizen): ', + fields: [ + { + component_type: 'text_input', + id: 'A-Number', + label: 'Alien Registration, Naturalization, or Citizenship Number', + default_value: '', + required: true, + page: 2, + }, + ], + page: 2, + }, + { + component_type: 'paragraph', + text: '(A-Number) 1.', + style: 'normal', + page: 2, + }, + { + component_type: 'fieldset', + legend: ' Applicant was convicted on: ', + fields: [ + { + component_type: 'text_input', + id: 'Convict-Date_af_date', + label: 'Convict Date', + default_value: '', + required: true, + page: 2, + }, + ], + page: 2, + }, + { + component_type: 'fieldset', + legend: 'in the U.S. District Court for the ', + fields: [ + { + component_type: 'text_input', + id: 'US District Court', + label: 'US District Court', + default_value: '', + required: true, + page: 2, + }, + ], + page: 2, + }, + { + component_type: 'paragraph', + text: '(month/day/year) (Northern, etc.)', + style: 'normal', + page: 2, + }, + { + component_type: 'fieldset', + legend: 'District of ', + fields: [ + { + component_type: 'text_input', + id: 'Dist State', + label: 'State', + default_value: '', + required: true, + page: 2, + }, + ], + page: 2, + }, + { + component_type: 'paragraph', + text: '(state) or D.C. Superior Court of simple possession of marijuana, under :', + style: 'normal', + page: 2, + }, + { + component_type: 'fieldset', + legend: 'Docket No. ', + fields: [ + { + component_type: 'text_input', + id: 'Docket No', + label: 'Docket Number', + default_value: '', + required: true, + page: 2, + }, + ], + page: 2, + }, + { + component_type: 'paragraph', + text: ';', + style: 'normal', + page: 2, + }, + { + component_type: 'fieldset', + legend: 'and Code Section: ', + fields: [ + { + component_type: 'text_input', + id: 'Code Section', + label: 'Code Section', + default_value: '', + required: true, + page: 2, + }, + ], + page: 2, + }, + { + component_type: 'paragraph', + text: 'OR (docket number) (code section) 2.', + style: 'normal', + page: 2, + }, + { + component_type: 'fieldset', + legend: ' Applicant was charged with Code Section: ', + fields: [ + { + component_type: 'text_input', + id: 'Code Section_2', + label: 'Code Section', + default_value: '', + required: true, + page: 2, + }, + ], + page: 2, + }, + { + component_type: 'fieldset', + legend: 'in the U.S. District Court for the ', + fields: [ + { + component_type: 'text_input', + id: 'US District Court_2', + label: 'U.S. District Court', + default_value: '', + required: true, + page: 2, + }, + ], + page: 2, + }, + { + component_type: 'paragraph', + text: '(code section) (Eastern, etc.)', + style: 'normal', + page: 2, + }, + { + component_type: 'fieldset', + legend: 'District of ', + fields: [ + { + component_type: 'text_input', + id: 'District 2', + label: 'State', + default_value: '', + required: true, + page: 2, + }, + ], + page: 2, + }, + { + component_type: 'paragraph', + text: 'or', + style: 'normal', + page: 2, + }, + { + component_type: 'fieldset', + legend: 'D.C. Superior Court under Docket No: ', + fields: [ + { + component_type: 'text_input', + id: 'Docket No 2', + label: 'Docket No 2', + default_value: '', + required: true, + page: 2, + }, + ], + page: 2, + }, + { + component_type: 'paragraph', + text: '(state) (docket number) United States Department of Justice Office of the Pardon Attorney Page 3 of 4 Washington, D.C. 20530 January 2024', + style: 'normal', + page: 2, + }, + { + component_type: 'paragraph', + text: 'OMB Control No: 1123-0014 Expires 03/31/2027 APPLICATION FOR CERTIFICATE OF PARDON FOR THE OFFENSES OF SIMPLE POSSESSION, ATTEMPTED SIMPLE POSSESSION, OR USE OF MARIJUANA With knowledge of the penalties for false statements to Federal Agencies, as provided by 18 U.S.C. \u00a7 1001, and with knowledge that this statement is submitted by me to affect action by the U.S. Department of Justice, I certify that: 1. The applicant was either a U.S. citizen or lawfully present in the United States at the time of the offense. 2. The applicant was a U.S. citizen or lawful permanent resident on December 22, 2023. 3. The above statements, and accompanying documents, are true and complete to the best of my knowledge, information, and belief. 4. I acknowledge that any certificate issued in reliance on the above information will be voided, if the information is subsequently determined to be false.', + style: 'normal', + page: 3, + }, + { + component_type: 'fieldset', + legend: 'App Date', + fields: [ + { + component_type: 'text_input', + id: 'App Date', + label: 'Date', + default_value: '', + required: true, + page: 3, + }, + ], + page: 3, + }, + { + component_type: 'paragraph', + text: '(date) (signature) Page 4 of 4 United States Department of Justice Office of the Pardon Attorney Washington, D.C. 20530 January 2024', + style: 'normal', + page: 3, + }, + ], + raw_fields: { + '0': [], + '1': [], + '2': [ + { + type: '/Tx', + var_name: 'Fst Name 1', + field_dict: { + field_type: '/Tx', + coordinates: [97.0, 636.960022, 233.279999, 659.640015], + field_label: 'Fst Name 1', + field_instructions: 'First Name', + struct_parent: 4, + font_info: '', + hidden: false, + child_fields: [], + num_children: 0, + }, + page_number: 2, + path: 'Fst Name 1', + struct_parent: 4, + }, + { + type: '/Tx', + var_name: '', + field_dict: { + coordinates: [233.087006, 637.580994, 390.214996, 659.320007], + field_instructions: 'Middle Name', + struct_parent: 5, + name: 0, + field_type: '/Tx', + font_info: '', + field_label: '', + hidden: false, + child_fields: [], + num_children: 0, + }, + page_number: 2, + path: 'Mid Name 1/0', + struct_parent: 5, + }, + { + type: '/Tx', + var_name: '', + field_dict: { + coordinates: [390.996002, 637.492981, 548.124023, 659.231995], + field_instructions: 'Last Name', + struct_parent: 6, + name: 0, + field_type: '/Tx', + font_info: '', + field_label: '', + hidden: false, + child_fields: [], + num_children: 0, + }, + page_number: 2, + path: 'Lst Name 1/0', + struct_parent: 6, + }, + { + type: '/Tx', + var_name: 'Conv Fst Name', + field_dict: { + field_type: '/Tx', + coordinates: [153.740005, 598.085022, 283.246002, 620.765015], + field_label: 'Conv Fst Name', + field_instructions: 'First Name at Conviction', + struct_parent: 7, + font_info: '', + hidden: false, + child_fields: [], + num_children: 0, + }, + page_number: 2, + path: 'Conv Fst Name', + struct_parent: 7, + }, + { + type: '/Tx', + var_name: 'Conv Mid Name', + field_dict: { + field_type: '/Tx', + coordinates: [282.497986, 598.164001, 410.80899, 620.843994], + field_label: 'Conv Mid Name', + field_instructions: 'Middle Name at Conviction', + struct_parent: 8, + font_info: '', + hidden: false, + child_fields: [], + num_children: 0, + }, + page_number: 2, + path: 'Conv Mid Name', + struct_parent: 8, + }, + { + type: '/Tx', + var_name: 'Conv Lst Name', + field_dict: { + field_type: '/Tx', + coordinates: [410.212006, 597.677002, 536.132019, 620.357971], + field_label: 'Conv Lst Name', + field_instructions: 'Last Name at Conviction', + struct_parent: 9, + font_info: '', + hidden: false, + child_fields: [], + num_children: 0, + }, + page_number: 2, + path: 'Conv Lst Name', + struct_parent: 9, + }, + { + type: '/Tx', + var_name: 'Address', + field_dict: { + field_type: '/Tx', + coordinates: [102.839996, 563.880005, 547.080017, 586.559998], + field_label: 'Address', + field_instructions: + 'Address (number, street, apartment/unit number)', + struct_parent: 10, + font_info: '', + hidden: false, + child_fields: [], + num_children: 0, + }, + page_number: 2, + path: 'Address', + struct_parent: 10, + }, + { + type: '/Tx', + var_name: 'City', + field_dict: { + field_type: '/Tx', + coordinates: [64.500504, 531.0, 269.519989, 551.880005], + field_label: 'City', + field_instructions: 'City', + struct_parent: 11, + font_info: '', + hidden: false, + child_fields: [], + num_children: 0, + }, + page_number: 2, + path: 'City', + struct_parent: 11, + }, + { + type: '/Tx', + var_name: 'State', + field_dict: { + field_type: '/Tx', + coordinates: [273.959991, 531.0, 440.519989, 551.880005], + field_label: 'State', + field_instructions: 'State', + struct_parent: 12, + font_info: '', + hidden: false, + child_fields: [], + num_children: 0, + }, + page_number: 2, + path: 'State', + struct_parent: 12, + }, + { + type: '/Tx', + var_name: 'Zip Code', + field_dict: { + field_type: '/Tx', + coordinates: [444.959991, 531.0, 552.719971, 551.880005], + field_label: 'Zip Code', + field_instructions: '(Zip Code)', + struct_parent: 13, + font_info: '', + hidden: false, + child_fields: [], + num_children: 0, + }, + page_number: 2, + path: 'Zip Code', + struct_parent: 13, + }, + { + type: '/Tx', + var_name: 'Email Address', + field_dict: { + field_type: '/Tx', + coordinates: [131.863998, 489.600006, 290.743988, 512.280029], + field_label: 'Email Address', + field_instructions: 'Email Address', + struct_parent: 14, + font_info: '', + hidden: false, + child_fields: [], + num_children: 0, + }, + page_number: 2, + path: 'Email Address', + struct_parent: 14, + }, + { + type: '/Tx', + var_name: 'Phone Number', + field_dict: { + field_type: '/Tx', + coordinates: [385.679993, 489.600006, 549.599976, 512.280029], + field_label: 'Phone Number', + field_instructions: 'Phone Number', + struct_parent: 15, + font_info: '', + hidden: false, + child_fields: [], + num_children: 0, + }, + page_number: 2, + path: 'Phone Number', + struct_parent: 15, + }, + { + type: '/Tx', + var_name: 'Date of Birth', + field_dict: { + field_type: '/Tx', + coordinates: [126.480003, 451.679993, 197.880005, 474.359985], + field_label: 'Date of Birth', + field_instructions: 'Date of Birth', + struct_parent: 16, + font_info: '', + hidden: false, + child_fields: [], + num_children: 0, + }, + page_number: 2, + path: 'Date of Birth', + struct_parent: 16, + }, + { + type: '/Tx', + var_name: 'Gender', + field_dict: { + field_type: '/Tx', + coordinates: [241.559998, 451.679993, 313.079987, 474.359985], + field_label: 'Gender', + field_instructions: 'Gender', + struct_parent: 17, + font_info: '', + hidden: false, + child_fields: [], + num_children: 0, + }, + page_number: 2, + path: 'Gender', + struct_parent: 17, + }, + { + type: '/Btn', + var_name: '', + field_dict: { + coordinates: [505.618988, 450.865997, 523.619019, 468.865997], + struct_parent: 18, + name: 'Yes', + field_type: '/Btn', + field_instructions: '', + font_info: '', + field_label: '', + flags: { + '15': 'NoToggleToOff: (Radio buttons only) If set, exactly one radio button must be selected at all times; clicking the currently selected button has no effect. If clear, clicking the selected button deselects it, leaving no button selected.', + '16': 'Radio: If set, the field is a set of radio buttons; if clear, the field is a check box. This flag is meaningful only if the Pushbutton flag is clear.', + }, + hidden: false, + child_fields: [], + num_children: 0, + }, + page_number: 2, + path: 'Ethnicity/Yes', + struct_parent: 18, + }, + { + type: '/Btn', + var_name: '', + field_dict: { + coordinates: [558.213013, 450.865997, 576.213013, 468.865997], + struct_parent: 19, + name: 'No', + field_type: '/Btn', + field_instructions: '', + font_info: '', + field_label: '', + flags: { + '15': 'NoToggleToOff: (Radio buttons only) If set, exactly one radio button must be selected at all times; clicking the currently selected button has no effect. If clear, clicking the selected button deselects it, leaving no button selected.', + '16': 'Radio: If set, the field is a set of radio buttons; if clear, the field is a check box. This flag is meaningful only if the Pushbutton flag is clear.', + }, + hidden: false, + child_fields: [], + num_children: 0, + }, + page_number: 2, + path: 'Ethnicity/No', + struct_parent: 19, + }, + { + type: '/Btn', + var_name: 'Nat Amer', + field_dict: { + field_type: '/Btn', + coordinates: [280.10199, 426.162994, 298.10199, 444.162994], + field_label: 'Nat Amer', + field_instructions: 'Alaska Native or American Indian', + struct_parent: 20, + font_info: '', + hidden: false, + child_fields: [], + num_children: 0, + }, + page_number: 2, + path: 'Nat Amer', + struct_parent: 20, + }, + { + type: '/Btn', + var_name: 'Asian', + field_dict: { + field_type: '/Btn', + coordinates: [366.563995, 426.162994, 384.563995, 444.162994], + field_label: 'Asian', + field_instructions: 'Asian', + struct_parent: 21, + font_info: '', + hidden: false, + child_fields: [], + num_children: 0, + }, + page_number: 2, + path: 'Asian', + struct_parent: 21, + }, + { + type: '/Btn', + var_name: 'Blck Amer', + field_dict: { + field_type: '/Btn', + coordinates: [531.517029, 426.162994, 549.517029, 444.162994], + field_label: 'Blck Amer', + field_instructions: 'Black or African American', + struct_parent: 22, + font_info: '', + hidden: false, + child_fields: [], + num_children: 0, + }, + page_number: 2, + path: 'Blck Amer', + struct_parent: 22, + }, + { + type: '/Btn', + var_name: 'Nat Haw Islander', + field_dict: { + field_type: '/Btn', + coordinates: [309.587006, 401.061005, 327.587006, 419.061005], + field_label: 'Nat Haw Islander', + field_instructions: 'Native Hawaiian or Other Pacific Islander', + struct_parent: 23, + font_info: '', + hidden: false, + child_fields: [], + num_children: 0, + }, + page_number: 2, + path: 'Nat Haw Islander', + struct_parent: 23, + }, + { + type: '/Btn', + var_name: 'White', + field_dict: { + field_type: '/Btn', + coordinates: [438.681, 401.061005, 456.681, 419.061005], + field_label: 'White', + field_instructions: 'White', + struct_parent: 24, + font_info: '', + hidden: false, + child_fields: [], + num_children: 0, + }, + page_number: 2, + path: 'White', + struct_parent: 24, + }, + { + type: '/Btn', + var_name: 'Other', + field_dict: { + field_type: '/Btn', + coordinates: [508.806, 401.061005, 526.80603, 419.061005], + field_label: 'Other', + field_instructions: 'Other', + struct_parent: 25, + font_info: '', + hidden: false, + child_fields: [], + num_children: 0, + }, + page_number: 2, + path: 'Other', + struct_parent: 25, + }, + { + type: '/Btn', + var_name: '', + field_dict: { + coordinates: [98.414398, 349.662994, 116.414001, 367.662994], + field_instructions: 'U S Citizen by birth', + struct_parent: 26, + name: 'Birth', + field_type: '/Btn', + font_info: '', + field_label: '', + flags: { + '15': 'NoToggleToOff: (Radio buttons only) If set, exactly one radio button must be selected at all times; clicking the currently selected button has no effect. If clear, clicking the selected button deselects it, leaving no button selected.', + '16': 'Radio: If set, the field is a set of radio buttons; if clear, the field is a check box. This flag is meaningful only if the Pushbutton flag is clear.', + }, + hidden: false, + child_fields: [], + num_children: 0, + }, + page_number: 2, + path: 'Citizenship/Birth', + struct_parent: 26, + }, + { + type: '/Btn', + var_name: '', + field_dict: { + coordinates: [98.414398, 331.733002, 116.414001, 349.733002], + field_instructions: 'U S naturalized citizen', + struct_parent: 27, + name: 'Naturalized', + field_type: '/Btn', + font_info: '', + field_label: '', + flags: { + '15': 'NoToggleToOff: (Radio buttons only) If set, exactly one radio button must be selected at all times; clicking the currently selected button has no effect. If clear, clicking the selected button deselects it, leaving no button selected.', + '16': 'Radio: If set, the field is a set of radio buttons; if clear, the field is a check box. This flag is meaningful only if the Pushbutton flag is clear.', + }, + hidden: false, + child_fields: [], + num_children: 0, + }, + page_number: 2, + path: 'Citizenship/Naturalized', + struct_parent: 27, + }, + { + type: '/Btn', + var_name: '', + field_dict: { + coordinates: [98.414398, 313.006012, 116.414001, 331.006012], + field_instructions: 'Lawful Permenent Resident', + struct_parent: 29, + name: 'Permanent Resident', + field_type: '/Btn', + font_info: '', + field_label: '', + flags: { + '15': 'NoToggleToOff: (Radio buttons only) If set, exactly one radio button must be selected at all times; clicking the currently selected button has no effect. If clear, clicking the selected button deselects it, leaving no button selected.', + '16': 'Radio: If set, the field is a set of radio buttons; if clear, the field is a check box. This flag is meaningful only if the Pushbutton flag is clear.', + }, + hidden: false, + child_fields: [], + num_children: 0, + }, + page_number: 2, + path: 'Citizenship/Permanent Resident', + struct_parent: 29, + }, + { + type: '/Tx', + var_name: '', + field_dict: { + coordinates: [432.306, 331.979004, 489.425995, 352.92099], + field_instructions: 'date naturalization granted', + struct_parent: 28, + name: 0, + field_type: '/Tx', + font_info: '', + field_label: '', + hidden: false, + child_fields: [], + num_children: 0, + }, + page_number: 2, + path: 'Naturalization Date_af_date/0', + struct_parent: 28, + }, + { + type: '/Tx', + var_name: 'Residency Date_af_date', + field_dict: { + field_type: '/Tx', + coordinates: [414.304993, 329.523987, 471.424988, 308.582001], + field_label: 'Residency Date_af_date', + field_instructions: 'Date Residency Granted (mm/dd/yyyy)', + struct_parent: 30, + font_info: '', + hidden: false, + child_fields: [], + num_children: 0, + }, + page_number: 2, + path: 'Residency Date_af_date', + struct_parent: 30, + }, + { + type: '/Tx', + var_name: 'A-Number', + field_dict: { + field_type: '/Tx', + coordinates: [296.279999, 257.76001, 507.959991, 280.440002], + field_label: 'A-Number', + field_instructions: + 'Alien Registration, Naturalization, or Citizenship Number', + struct_parent: 31, + font_info: '', + hidden: false, + child_fields: [], + num_children: 0, + }, + page_number: 2, + path: 'A-Number', + struct_parent: 31, + }, + { + type: '/Tx', + var_name: 'Convict-Date_af_date', + field_dict: { + field_type: '/Tx', + coordinates: [203.602005, 218.822006, 301.363007, 245.341995], + field_label: 'Convict-Date_af_date', + field_instructions: 'Convict Date', + struct_parent: 32, + font_info: '', + hidden: false, + child_fields: [], + num_children: 0, + }, + page_number: 2, + path: 'Convict-Date_af_date', + struct_parent: 32, + }, + { + type: '/Tx', + var_name: 'US District Court', + field_dict: { + field_type: '/Tx', + coordinates: [451.200012, 219.0, 522.719971, 241.679993], + field_label: 'US District Court', + field_instructions: 'US District Court', + struct_parent: 33, + font_info: '', + hidden: false, + child_fields: [], + num_children: 0, + }, + page_number: 2, + path: 'US District Court', + struct_parent: 33, + }, + { + type: '/Tx', + var_name: 'Dist State', + field_dict: { + field_type: '/Tx', + coordinates: [105.720001, 187.919998, 177.240005, 210.600006], + field_label: 'Dist State', + field_instructions: 'State', + struct_parent: 34, + font_info: '', + hidden: false, + child_fields: [], + num_children: 0, + }, + page_number: 2, + path: 'Dist State', + struct_parent: 34, + }, + { + type: '/Tx', + var_name: 'Docket No', + field_dict: { + field_type: '/Tx', + coordinates: [114.015999, 153.479996, 262.575989, 176.160004], + field_label: 'Docket No', + field_instructions: 'Docket Number', + struct_parent: 36, + font_info: '', + hidden: false, + child_fields: [], + num_children: 0, + }, + page_number: 2, + path: 'Docket No', + struct_parent: 36, + }, + { + type: '/Tx', + var_name: 'Code Section', + field_dict: { + field_type: '/Tx', + coordinates: [349.320007, 153.479996, 448.320007, 176.160004], + field_label: 'Code Section', + field_instructions: 'Code Section', + struct_parent: 37, + font_info: '', + hidden: false, + child_fields: [], + num_children: 0, + }, + page_number: 2, + path: 'Code Section', + struct_parent: 37, + }, + { + type: '/Tx', + var_name: 'Code Section_2', + field_dict: { + field_type: '/Tx', + coordinates: [266.640015, 121.440002, 316.200012, 144.119995], + field_label: 'Code Section_2', + field_instructions: 'Code Section', + struct_parent: 38, + font_info: '', + hidden: false, + child_fields: [], + num_children: 0, + }, + page_number: 2, + path: 'Code Section_2', + struct_parent: 38, + }, + { + type: '/Tx', + var_name: 'US District Court_2', + field_dict: { + field_type: '/Tx', + coordinates: [464.040009, 121.32, 542.039978, 144.0], + field_label: 'US District Court_2', + field_instructions: 'U.S. District Court', + struct_parent: 39, + font_info: '', + hidden: false, + child_fields: [], + num_children: 0, + }, + page_number: 2, + path: 'US District Court_2', + struct_parent: 39, + }, + { + type: '/Tx', + var_name: 'District 2', + field_dict: { + field_type: '/Tx', + coordinates: [105.720001, 86.760002, 188.160004, 109.440002], + field_label: 'District 2', + field_instructions: 'State', + struct_parent: 40, + font_info: '', + hidden: false, + child_fields: [], + num_children: 0, + }, + page_number: 2, + path: 'District 2', + struct_parent: 40, + }, + { + type: '/Tx', + var_name: 'Docket No 2', + field_dict: { + field_type: '/Tx', + coordinates: [403.920013, 86.760002, 525.0, 109.440002], + field_label: 'Docket No 2', + field_instructions: 'Docket No 2', + struct_parent: 42, + font_info: '', + hidden: false, + child_fields: [], + num_children: 0, + }, + page_number: 2, + path: 'Docket No 2', + struct_parent: 42, + }, + ], + '3': [ + { + type: '/Tx', + var_name: 'App Date', + field_dict: { + field_type: '/Tx', + coordinates: [75.120003, 396.720001, 219.479996, 425.519989], + field_label: 'App Date', + field_instructions: 'Date', + struct_parent: 44, + font_info: '', + hidden: false, + child_fields: [], + num_children: 0, + }, + page_number: 3, + path: 'App Date', + struct_parent: 44, + }, + ], + }, + grouped_items: [], + raw_fields_pages: { + '0': "OMB Control No: 1123-0014 Expires 03/31/2027 \nAPPLICATION FOR CERTIFICATE OF PARDON FOR THE OFFENSES OF \nSIMPLE POSSESSION, ATTEMPTED SIMPLE POSSESSION, OR USE OF \nMARIJUANA \nOn October 6, 2022, President Biden issued a presidential proclamation that pardoned many federal and D.C. \noffenses for simple marijuana possession. On December 22, 2023, President Biden issued another proclamation that \nexpanded the relief provided by the original proclamation by pardoning the federal offenses of simple possession, \nattempted possession, and use of marijuana. \nHow a pardon can help you \nA pardon is an expression of the President\u2019s forgiveness. It does not mean you are innocent or expunge your \nconviction. But it does remove civil disabilities\u2014such as restrictions on the right to vote, to hold office, or to sit \non a jury\u2014that are imposed because of the pardoned conviction. It may also be helpful in obtaining licenses, \nbonding, or employment. Learn more about the pardon. \nYou qualify for the pardon if: \n\u2022 On or before December 22, 2023, you were charged with or convicted of simple possession, attempted \npossession, or use of marijuana under the federal code, the District of Columbia code, or the Code of \nFederal Regulations \n\u2022 You were a U.S. citizen or lawfully present in the United States at the time of the offense \n\u2022 You were a U.S. citizen or lawful permanent resident on December 22, 2023 \nRequest a certificate to show proof of the pardon \nA Certificate of Pardon is proof that you were pardoned under the proclamation. The certificate is the only \ndocumentation you will receive of the pardon. Use the application below to start your request. \nWhat you'll need for the request \nAbout you \nYou can submit a request for yourself or someone else can submit on your behalf. You must provide \npersonal details, like name or citizenship status and either a mailing address, an email address or both to \ncontact you. We strongly recommend including an email address, if available, as we may not be able to \nrespond as quickly if you do not provide it. You can also use the mailing address or email address of \nanother person, if you do not have your own. \nAbout the charge or conviction \nYou must state whether it was a charge or conviction, the court district where it happened, and the date \n(month, day, year). If possible, you should also: \n\u2022 enter information about your case (docket or case number and the code section that was \ncharged) \n\u2022 upload your documents \no charging documents, like the indictment, complaint, criminal information, ticket or \ncitation; or \no conviction documents, like the judgment of conviction, the court docket sheet showing \nthe sentence and date it was imposed, or if you did not go to court, the receipt showing \npayment of fine \nIf you were charged by a ticket or citation and paid a fine instead of appearing in court, you should also provide the \ndate of conviction or the date the fine was paid. \nWithout this information, we can't guarantee that we'll be able to determine if you qualify for the pardon under \nthe proclamation. \n \nPage 1 of 4 \nUnited States Department of Justice Office of the Pardon Attorney Washington, D.C. 20530 January 2024 ", + '1': "OMB Control No: 1123-0014 Expires 03/31/2027 \nAPPLICATION FOR CERTIFICATE OF PARDON FOR THE OFFENSES OF \nSIMPLE POSSESSION, ATTEMPTED SIMPLE POSSESSION, OR USE OF \nMARIJUANA \nInstructions: \nAn online version of this application is available at: Presidential Proclamation on Marijuana Possession \n(justice.gov). You can also complete and return this application with the required documents to \nUSPardon.Attorney@usdoj.gov or U.S. Department of Justice, Office of the Pardon Attorney, 950 Pennsylvania \nAvenue NW, Washington, DC 20530. \nPublic Burden Statement: \nThis collection meets the requirements of 44 U.S.C. \u00a7 3507, as amended by the Paperwork Reduction Act of 1995. \nWe estimate that it will take 120 minutes to read the instructions, gather the relevant materials, and answer \nquestions on the form. Send comments regarding the burden estimate or any other aspect of this collection of \ninformation, including suggestions for reducing this burden, to Office of the Pardon Attorney, U.S. Department of \nJustice, Attn: OMB Number 1123-0014, RFK Building, 950 Pennsylvania Avenue, N.W., Washington DC 20530. \nThe OMB Clearance number, 1123-0014, is currently valid. \nPrivacy Act Statement: \nThe Office of the Pardon Attorney has authority to collect this information under the U.S. Constitution, Article \nII, Section 2 (the pardon clause); Orders of the Attorney General Nos. 1798-93, 58 Fed. Reg. 53658 and 53659 \n(1993), 2317-2000, 65 Fed. Reg. 48381 (2000), and 2323-2000, 65 Fed. Reg. 58223 and 58224 (2000), codified in \n28 C.F.R. \u00a7\u00a7 1.1 et seq. (the rules governing petitions for executive clemency); and Order of the Attorney General \nNo. 1012-83, 48 Fed. Reg. 22290 (1983), as codified in 28 C.F.R. \u00a7\u00a7 0.35 and 0.36 (the authority of the Office of \nthe Pardon Attorney). The principal purpose for collecting this information is to enable the Office of the Pardon \nAttorney to issue an individual certificate of pardon to you. The routine uses which may be made of this \ninformation include provision of data to the President and his staff, other governmental entities, and the public. \nThe full list of routine uses for this correspondence can be found in the System of Records Notice titled, \u201cPrivacy \nAct of 1974; System of Records,\u201d published in Federal Register, September 15, 2011, Vol. 76, No. 179, at pages \n57078 through 57080; as amended by \u201cPrivacy Act of 1974; System of Records,\u201d published in the Federal \nRegister, May 25, 2017, Vol. 82, No. 100, at page 24161, and at the U.S. Department of Justice, Office of Privacy \nand Civil Liberties' website at: https://www.justice.gov/opcl/doj-systems-records#OPA. \nBy signing the attached form, you consent to allowing the Office of the Pardon Attorney to obtain information \nregarding your citizenship and/or immigration status from the courts, from other government agencies, from other \ncomponents within the Department of Justice, and from the Department of Homeland Security, U.S. Citizenship \nand Immigration Services (DHS-USCIS), Systematic Alien Verification for Entitlements (SAVE) program. The \ninformation received from these sources will be used for the sole purposes of determining an applicant's \nqualification for a Certificate of Pardon under the December 22 proclamation and for record-keeping of those \ndeterminations. Further, please be aware that if the Office of the Pardon Attorney is unable to verify your \ncitizenship or immigration status based on the information provided below, we may contact you to obtain \nadditional verification information. Learn more about the DHS-USCIS's SAVE program and its ordinary uses. \nYour disclosure of information to the Office of the Pardon Attorney on this form is voluntary. If you do not \ncomplete all or some of the information fields in this form, however, the Office of the Pardon Attorney may not be \nable to effectively respond. Information regarding gender, race, or ethnicity is not required and will not affect the \nprocessing of the application. \nNote: Submit a separate form for each conviction or charge for which you are seeking a certificate of pardon. \nApplication Form on page 3. \nPage 2 of 4 \nUnited States Department of Justice Office of the Pardon Attorney Washington, D.C. 20530 January 2024 ", + '2': 'OMB Control No: 1123-0014 Expires 03/31/2027 \nAPPLICATION FOR CERTIFICATE OF PARDON FOR THE OFFENSES OF \nSIMPLE POSSESSION, ATTEMPTED SIMPLE POSSESSION, OR USE OF \nMARIJUANA \nComplete the following: \nName: \n(first) (middle) (last) \nName at Conviction: \n(if different) (first) (middle) (last) \nAddress: \n(number) (street) (apartment/unit no.) \n\n(city) (state) (Zip Code) \nEmail Address: Phone Number: \nDate of Birth: Gender: Are you Hispanic or Latino?: Yes No \nRace: Alaska Native or American Indian Asian Black or African American \nNative Hawaiian or Other Pacific Islander White Other \nCitizenship or Residency Status: \nU.S. citizen by birth \nU.S. naturalized citizen Date Naturalization Granted: \nLawful Permanent Resident Date Residency Granted: \nAlien Registration Number (A-Number), Certificate of Naturalization Number, or Citizenship Number \n(if applicant is a lawful permanent resident or naturalized citizen): \n(A-Number) \n1. Applicant was convicted on: in the U.S. District Court for the \n(month/day/year) (Northern, etc.) \nDistrict of (state) or D.C. Superior Court of simple possession of marijuana, under \nDocket No. : and Code Section: ; OR \n(docket number) (code section) \n2. Applicant was charged with Code Section: in the U.S. District Court for the \n(code section) (Eastern, etc.) \nDistrict of or D.C. Superior Court under Docket No: \n(state) (docket number) \n \nUnited States Department of Justice Office of the Pardon Attorney Page 3 of 4 Washington, D.C. 20530 January 2024 ', + '3': 'OMB Control No: 1123-0014 Expires 03/31/2027 \nAPPLICATION FOR CERTIFICATE OF PARDON FOR THE OFFENSES OF \nSIMPLE POSSESSION, ATTEMPTED SIMPLE POSSESSION, OR USE OF \nMARIJUANA \nWith knowledge of the penalties for false statements to Federal Agencies, as provided by 18 \nU.S.C. \u00a7 1001, and with knowledge that this statement is submitted by me to affect action by \nthe U.S. Department of Justice, I certify that: \n1. The applicant was either a U.S. citizen or lawfully present in the United States at the time of the \noffense. \n2. The applicant was a U.S. citizen or lawful permanent resident on December 22, 2023. \n3. The above statements, and accompanying documents, are true and complete to the \n best of my knowledge, information, and belief. \n4. I acknowledge that any certificate issued in reliance on the above information will be \nvoided, if the information is subsequently determined to be false. \n\n(date) (signature) \nPage 4 of 4 \nUnited States Department of Justice Office of the Pardon Attorney Washington, D.C. 20530 January 2024 ', + }, + }, + cache_id: 'Cache ID is not implemented yet', +}; diff --git a/packages/forms/src/services/submit-form.ts b/packages/forms/src/services/submit-form.ts index 4015f3d7..55ab74db 100644 --- a/packages/forms/src/services/submit-form.ts +++ b/packages/forms/src/services/submit-form.ts @@ -1,64 +1,114 @@ -import { type Result } from '@atj/common'; +import { failure, success, type Result } from '@atj/common'; import { type Blueprint, type FormSession, + type FormSessionId, applyPromptResponse, createFormOutputFieldData, + createFormSession, fillPDF, sessionIsComplete, } from '../index.js'; import { FormServiceContext } from '../context/index.js'; +import { type FormRoute } from '../route-data.js'; -type SubmitForm = ( +export type SubmitForm = ( ctx: FormServiceContext, - //sessionId: string, - session: FormSession, // TODO: load session from storage by ID + sessionId: FormSessionId | undefined, formId: string, - formData: Record + formData: Record, + route?: FormRoute ) => Promise< - Result< - { + Result<{ + sessionId: FormSessionId; + session: FormSession; + documents?: { fileName: string; data: Uint8Array; - }[] - > + }[]; + }> >; export const submitForm: SubmitForm = async ( ctx, - //sessionId: string, - session, // TODO: load session from storage by ID + sessionId, formId, - formData + formData, + route ) => { const form = await ctx.repository.getForm(formId); if (form === null) { - return Promise.resolve({ - success: false, - error: 'Form not found', - }); + return failure('Form not found'); + } + + const sessionResult = await getFormSessionOrCreate( + ctx, + form, + route, + sessionId + ); + if (!sessionResult.success) { + return failure('Session not found'); } + //const session = getSessionFromStorage(ctx.storage, sessionId) || createFormSession(form); // For now, the client-side is producing its own error messages. // In the future, we'll want this service to return errors to the client. - const newSessionResult = applyPromptResponse(ctx.config, session, { + const newSessionResult = applyPromptResponse(ctx.config, sessionResult.data, { action: 'submit', data: formData, }); if (!newSessionResult.success) { - return Promise.resolve({ - success: false, - error: newSessionResult.error, - }); + return failure(newSessionResult.error); } - if (!sessionIsComplete(ctx.config, newSessionResult.data)) { - return Promise.resolve({ - success: false, - error: 'Session is not complete', + + const saveFormSessionResult = await ctx.repository.upsertFormSession({ + id: sessionId, + formId, + data: newSessionResult.data, + }); + if (!saveFormSessionResult.success) { + return failure(saveFormSessionResult.error); + } + + /* TODO: consider whether this is necessary, or should happen elsewhere. */ + if (sessionIsComplete(ctx.config, newSessionResult.data)) { + const documentsResult = await generateDocumentPackage( + form, + newSessionResult.data.data.values + ); + if (!documentsResult.success) { + return failure(documentsResult.error); + } + + return success({ + sessionId: saveFormSessionResult.data.id, + session: newSessionResult.data, + documents: documentsResult.data, }); } - return generateDocumentPackage(form, newSessionResult.data.data.values); + + return success({ + sessionId: saveFormSessionResult.data.id, + session: newSessionResult.data, + }); +}; + +const getFormSessionOrCreate = async ( + ctx: FormServiceContext, + form: Blueprint, + route?: FormRoute, + sessionId?: FormSessionId +) => { + if (sessionId === undefined) { + return success(createFormSession(form, route)); + } + const sessionResult = await ctx.repository.getFormSession(sessionId); + if (!sessionResult.success) { + return failure('Session not found'); + } + return success(sessionResult.data.data); }; const generateDocumentPackage = async ( diff --git a/packages/forms/src/session.ts b/packages/forms/src/session.ts index 84083c53..3f9a31a1 100644 --- a/packages/forms/src/session.ts +++ b/packages/forms/src/session.ts @@ -12,17 +12,25 @@ import { type PatternValue, type PatternValueMap, } from './pattern.js'; -import { type RouteData, getRouteDataFromQueryString } from './route-data.js'; +import { + type FormRoute, + type RouteData, + getRouteDataFromQueryString, +} from './route-data.js'; export type FormErrorMap = Record; +export type FormSessionId = string; export type FormSession = { data: { errors: FormErrorMap; values: PatternValueMap; }; form: Blueprint; - routeParams?: RouteData; + route?: { + params: RouteData; + url: string; + }; }; export const nullSession: FormSession = { @@ -53,7 +61,7 @@ export const nullSession: FormSession = { export const createFormSession = ( form: Blueprint, - queryString?: string + route?: FormRoute ): FormSession => { return { data: { @@ -69,9 +77,7 @@ export const createFormSession = ( */ }, form, - routeParams: queryString - ? getRouteDataFromQueryString(queryString) - : undefined, + route: route, }; }; @@ -202,7 +208,7 @@ export const getPageCount = (bp: Blueprint) => { }; export const getSessionPage = (session: FormSession) => { - const currentPageIndex = parseInt(session.routeParams?.page as string) || 0; + const currentPageIndex = parseInt(session.route?.params.page as string) || 0; const lastPageIndex = getPageCount(session.form) - 1; if (currentPageIndex <= lastPageIndex) { return currentPageIndex; diff --git a/packages/forms/src/testing.ts b/packages/forms/src/testing.ts index 35964602..cdad9203 100644 --- a/packages/forms/src/testing.ts +++ b/packages/forms/src/testing.ts @@ -1,3 +1,4 @@ +import { type DatabaseContext } from '@atj/database'; import { createInMemoryDatabaseContext } from '@atj/database/context'; import { createFormsRepository } from './repository'; import { defaultFormConfig } from './patterns'; @@ -7,11 +8,16 @@ type Options = { }; export const createTestFormServiceContext = async (opts?: Partial) => { - const db = await createInMemoryDatabaseContext(); + const db: DatabaseContext = await createInMemoryDatabaseContext(); const repository = createFormsRepository(db); return { + db, repository, config: defaultFormConfig, isUserLoggedIn: opts?.isUserLoggedIn || (() => true), }; }; + +export type TestFormServiceContext = Awaited< + ReturnType +>; diff --git a/packages/server/astro.config.mjs b/packages/server/astro.config.mjs index e8985539..85e2b0d6 100644 --- a/packages/server/astro.config.mjs +++ b/packages/server/astro.config.mjs @@ -23,7 +23,7 @@ export default defineConfig({ checkOrigin: true, }, server: { - port: 4322, + port: 4321, }, vite: { define: { diff --git a/packages/server/globals.d.ts b/packages/server/globals.d.ts new file mode 100644 index 00000000..7c2950e3 --- /dev/null +++ b/packages/server/globals.d.ts @@ -0,0 +1,4 @@ +declare module '*.astro' { + const Component: any; + export default Component; +} diff --git a/packages/server/package.json b/packages/server/package.json index 7075ecb7..4d337001 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -18,22 +18,27 @@ }, "dependencies": { "@astrojs/check": "^0.9.2", - "@astrojs/node": "^8.3.2", + "@astrojs/node": "^8.3.4", "@astrojs/react": "^3.6.1", "@atj/auth": "workspace:^", "@atj/common": "workspace:*", "@atj/database": "workspace:*", "@atj/design": "workspace:*", "@atj/forms": "workspace:*", - "astro": "^4.13.2", - "express": "^4.19.2", + "astro": "^4.15.10", + "express": "^4.21.0", "jwt-decode": "^4.0.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-error-boundary": "^4.0.13" }, "devDependencies": { + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.4.8", + "@testing-library/user-event": "^14.5.2", "@types/express": "^4.17.21", - "@types/react": "^18.3.3" + "@types/jsdom": "^21.1.7", + "@types/react": "^18.3.10", + "jsdom": "^25.0.1" } } diff --git a/packages/server/src/components/AppAvailableFormList.tsx b/packages/server/src/components/AppAvailableFormList.tsx index 9621761d..872e155d 100644 --- a/packages/server/src/components/AppAvailableFormList.tsx +++ b/packages/server/src/components/AppAvailableFormList.tsx @@ -3,9 +3,9 @@ import { ErrorBoundary } from 'react-error-boundary'; import { AvailableFormList } from '@atj/design'; -import { type AppContext } from '../context'; -import { getFormManagerUrlById, getFormUrl } from '../routes'; -import DebugTools from './DebugTools'; +import { type AppContext } from '../config/context.js'; +import { getFormManagerUrlById, getFormUrl } from '../routes.js'; +import DebugTools from './DebugTools.js'; export default ({ ctx }: { ctx: AppContext }) => { return ( diff --git a/packages/server/src/components/AppForm.tsx b/packages/server/src/components/AppForm.tsx new file mode 100644 index 00000000..d04fd91a --- /dev/null +++ b/packages/server/src/components/AppForm.tsx @@ -0,0 +1,22 @@ +import React from 'react'; + +import { defaultPatternComponents, Form } from '@atj/design'; +import { type FormSession, defaultFormConfig } from '@atj/forms'; + +type AppFormProps = { + uswdsRoot: `${string}/`; + session: FormSession; +}; + +export const AppForm = (props: AppFormProps) => { + return ( + + ); +}; diff --git a/packages/server/src/components/AppFormManager.tsx b/packages/server/src/components/AppFormManager.tsx index 849b6183..8a1175df 100644 --- a/packages/server/src/components/AppFormManager.tsx +++ b/packages/server/src/components/AppFormManager.tsx @@ -2,9 +2,9 @@ import React from 'react'; import { FormManager } from '@atj/design'; -import { FormServiceClient } from '../lib/api-client'; -import { type AppContext } from '../context'; -import { getFormManagerUrlById, getFormUrl } from '../routes'; +import { FormServiceClient } from '../lib/api-client.js'; +import { type AppContext } from '../config/context.js'; +import { getFormManagerUrlById, getFormUrl } from '../routes.js'; type AppFormManagerContext = { baseUrl: AppContext['baseUrl']; diff --git a/packages/server/src/components/AppFormRouter.tsx b/packages/server/src/components/AppFormRouter.tsx deleted file mode 100644 index 4a19f23e..00000000 --- a/packages/server/src/components/AppFormRouter.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import React from 'react'; - -import { FormRouter } from '@atj/design'; - -import { type AppContext } from '../context'; -import { FormServiceClient } from '../lib/api-client'; - -export default function AppFormRouter({ - baseUrl, - uswdsRoot, -}: { - baseUrl: AppContext['baseUrl']; - uswdsRoot: AppContext['uswdsRoot']; -}) { - const formService = new FormServiceClient({ baseUrl: baseUrl }); - return ; -} diff --git a/packages/server/src/components/Footer.astro b/packages/server/src/components/Footer.astro index 6c9044c2..b6e2a879 100644 --- a/packages/server/src/components/Footer.astro +++ b/packages/server/src/components/Footer.astro @@ -1,8 +1,8 @@ --- import { getBranchTreeUrl } from '../lib/github'; -import { getAstroAppContext } from '../context'; -const { github } = await getAstroAppContext(Astro); +import { getServerContext } from '../config/astro.js'; +const { github } = await getServerContext(Astro); ---