Skip to content

Commit

Permalink
Form rendering and submission managed by backend (#307)
Browse files Browse the repository at this point in the history
* Add basic tests for existing form submission behavior.

* Add "submit form" test that confirms a completed form returns a PDF.

* Add README for database package.

* upsertFormSession persistence function

* Add getFormSession db routine

* In-progress work on server-rendered form

* Pass route params to form components via session; form rendering in server app. todo: client rendering via spotlight app

* Use hash urls on client-side form router

* Add session management services; handling form routing in the app - in server, use Astro, move FormRouter into Spotlight, get rid of window.location references in @atj/design.

* Standard http POST form submissions working. This included many tweaks to routing/page handling and session services updates.

* Client-side routing fixes

* Linting server package

* Use node.js v20.18.0

* Update all playwright resources to 1.48.0

* Update lockfile

* Add type for *.astro files, so "pnpm typecheck" in the workspace will not fail on imports of .astro modules from .ts modules.

* Remove comment

* Rename getAstroAppContext -> getServerContext

* Add initial Playwright test and try to track down problems with Testcontainers usage (not working locally). Committing to test on CI.

* Mark session incomplete test as expect failure

* Update incomplete session test

* Limit to just the node.js form route test. Will use browser mode for component tests, and avoid Playwright for now.

* Confirm session is updated on form POST

* Improve Spotlight behavior. Still some reorg work to do on the follow-up PR.

* Organize frontend state

* Minor cleanup

* Fix e2e test: prefix localStorage key with "forms/"
  • Loading branch information
danielnaab authored Oct 29, 2024
1 parent 7bcd57f commit eef7768
Show file tree
Hide file tree
Showing 111 changed files with 4,892 additions and 1,479 deletions.
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v20.16.0
v20.18.0
1 change: 0 additions & 1 deletion apps/server-doj/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
"@atj/server": "workspace:*"
},
"devDependencies": {
"@types/node": "^20.14.14",
"@types/supertest": "^6.0.2",
"supertest": "^7.0.0"
}
Expand Down
1 change: 0 additions & 1 deletion apps/server-kansas/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
"@atj/server": "workspace:*"
},
"devDependencies": {
"@types/node": "^20.14.14",
"@types/supertest": "^6.0.2",
"supertest": "^7.0.0"
}
Expand Down
7 changes: 6 additions & 1 deletion apps/spotlight/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
9 changes: 0 additions & 9 deletions apps/spotlight/src/components/AppFormRouter.tsx

This file was deleted.

79 changes: 79 additions & 0 deletions apps/spotlight/src/features/form-page/components/AppFormPage.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<HashRouter>
<Routes>
<Route path="/:id" element={<AppFormRoute />} />
</Routes>
</HashRouter>
);
};

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' && <div>Loading...</div>}
{formSessionResponse.status === 'error' && (
<div className="usa-alert usa-alert--error" role="alert">
<div className="usa-alert__body">
<h4 className="usa-alert__heading">Error loading form</h4>
<p className="usa-alert__text">{formSessionResponse.message}</p>
</div>
</div>
)}
{formSessionResponse.status === 'loaded' && (
<Form
context={{
config: defaultFormConfig,
components: defaultPatternComponents,
uswdsRoot: ctx.uswdsRoot,
}}
session={formSessionResponse.formSession}
onSubmit={data => 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}`,
};
};
1 change: 1 addition & 0 deletions apps/spotlight/src/features/form-page/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { AppFormPage } from './components/AppFormPage.js';
Original file line number Diff line number Diff line change
@@ -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,
},
});
}
});
};
35 changes: 35 additions & 0 deletions apps/spotlight/src/features/form-page/store/actions/index.ts
Original file line number Diff line number Diff line change
@@ -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<FormPageState>) => void;
};

export interface FormPageActions {
getFormSession: ServiceMethod<GetFormSession>;
initialize: ServiceMethod<Initialize>;
onSubmitForm: ServiceMethod<OnSubmitForm>;
}

export const createFormPageActions = (
getState: () => FormPageState,
setState: (state: Partial<FormPageState>) => void
): FormPageActions => {
const ctx: FormPageContext = {
getState,
setState,
config: getAppContext(),
};
return createService(ctx, {
getFormSession,
initialize,
onSubmitForm,
});
};
18 changes: 18 additions & 0 deletions apps/spotlight/src/features/form-page/store/actions/initialize.ts
Original file line number Diff line number Diff line change
@@ -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,
});
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { type FormPageContext } from './index.js';

export type OnSubmitForm = (
ctx: FormPageContext,
opts: {
formId: string;
sessionId?: string;
data: Record<string, string>;
}
) => 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);
};
18 changes: 18 additions & 0 deletions apps/spotlight/src/features/form-page/store/index.ts
Original file line number Diff line number Diff line change
@@ -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<Store>((set, get) => {
return {
...getInitialState(),
actions: createFormPageActions(get, set),
};
});
11 changes: 11 additions & 0 deletions apps/spotlight/src/features/form-page/store/state.ts
Original file line number Diff line number Diff line change
@@ -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' },
});
4 changes: 2 additions & 2 deletions apps/spotlight/src/pages/forms/index.astro
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
---
import AppFormRouter from '../../components/AppFormRouter';
import { AppFormPage } from '../../features/form-page';
import ContentLayout from '../../layouts/ContentLayout.astro';
---

<ContentLayout title="10x Access to Justice Spotlight">
<AppFormRouter client:only />
<AppFormPage client:only="react" />
</ContentLayout>
2 changes: 1 addition & 1 deletion apps/spotlight/src/pages/manage/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@ import Layout from '../../layouts/Layout.astro';
---

<Layout title="10x Access to Justice Spotlight">
<AppFormManager client:only />
<AppFormManager client:only="react" />
</Layout>
4 changes: 2 additions & 2 deletions apps/spotlight/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
Loading

0 comments on commit eef7768

Please sign in to comment.