From 84927c06bc4061738d06e8608b87ab963a6626d1 Mon Sep 17 00:00:00 2001 From: Daniel Naab Date: Tue, 29 Oct 2024 13:37:57 -0500 Subject: [PATCH] Wire package download button up to UI. There are still issues with PDF data serialization that need to be resolved. --- .../FormEdit/AddPatternDropdown.tsx | 4 +- packages/forms/src/documents/pdf/generate.ts | 3 +- packages/forms/src/pattern.ts | 34 +++++--------- .../src/patterns/package-download/index.ts | 11 +++-- .../patterns/package-download/submit.test.ts | 5 +-- .../forms/src/repository/get-form.test.ts | 37 +++++++-------- packages/forms/src/repository/get-form.ts | 2 +- .../forms/src/repository/save-form.test.ts | 14 ++++-- packages/forms/src/repository/serialize.ts | 4 +- packages/forms/src/services/submit-form.ts | 18 ++------ packages/server/src/lib/api-client.ts | 4 +- packages/server/src/lib/attachments.ts | 45 +++++++++++++++++++ packages/server/src/pages/forms/[id].astro | 5 +++ 13 files changed, 113 insertions(+), 73 deletions(-) create mode 100644 packages/server/src/lib/attachments.ts diff --git a/packages/design/src/FormManager/FormEdit/AddPatternDropdown.tsx b/packages/design/src/FormManager/FormEdit/AddPatternDropdown.tsx index 8287a1db..2abeb996 100644 --- a/packages/design/src/FormManager/FormEdit/AddPatternDropdown.tsx +++ b/packages/design/src/FormManager/FormEdit/AddPatternDropdown.tsx @@ -94,7 +94,7 @@ const sidebarPatterns: DropdownPattern[] = [ ['paragraph', defaultFormConfig.patterns['paragraph']], ['rich-text', defaultFormConfig.patterns['rich-text']], ['radio-group', defaultFormConfig.patterns['radio-group']], - ['package-download', defaultFormConfig.patterns['package-download']] + ['package-download', defaultFormConfig.patterns['package-download']], ] as const; export const fieldsetPatterns: DropdownPattern[] = [ ['form-summary', defaultFormConfig.patterns['form-summary']], @@ -103,7 +103,7 @@ export const fieldsetPatterns: DropdownPattern[] = [ ['paragraph', defaultFormConfig.patterns['paragraph']], ['rich-text', defaultFormConfig.patterns['rich-text']], ['radio-group', defaultFormConfig.patterns['radio-group']], - ['package-download', defaultFormConfig.patterns['package-download']] + ['package-download', defaultFormConfig.patterns['package-download']], ] as const; export const SidebarAddPatternMenuItem = ({ diff --git a/packages/forms/src/documents/pdf/generate.ts b/packages/forms/src/documents/pdf/generate.ts index d6605f06..0750d87c 100644 --- a/packages/forms/src/documents/pdf/generate.ts +++ b/packages/forms/src/documents/pdf/generate.ts @@ -15,7 +15,6 @@ export const createFormOutputFieldData = ( return; } const outputFieldId = output.formFields[patternId]; - console.log('***', patternId, docField, outputFieldId); if (outputFieldId === '') { console.error(`empty outputFieldId for field: ${patternId}: ${docField}`); return; @@ -66,7 +65,7 @@ export const fillPDF = async ( .sort((a, b) => a.name.localeCompare(b.name)); // Console log the resulting array - //console.log('uniqueNamesArray:', uniqueNamesArray); + console.log('uniqueNamesArray:', uniqueNamesArray); // fields.map(field => { // console.log('field name is:', field.getName()); diff --git a/packages/forms/src/pattern.ts b/packages/forms/src/pattern.ts index f7d0fb31..b14e4c4c 100644 --- a/packages/forms/src/pattern.ts +++ b/packages/forms/src/pattern.ts @@ -129,16 +129,12 @@ export const validatePatternAndChildren = ( form: Blueprint, patternConfig: PatternConfig, pattern: Pattern, - values: Record -) => { - const result: { + values: Record, + result: { values: Record; errors: Record; - } = { - values: {}, - errors: {}, - }; - + } = { values: {}, errors: {} } +) => { if (patternConfig.parseUserInput) { const parseResult = patternConfig.parseUserInput( pattern, @@ -151,23 +147,17 @@ export const validatePatternAndChildren = ( result.errors[pattern.id] = parseResult.error; } } - for (const child of patternConfig.getChildren(pattern, form.patterns)) { const childPatternConfig = getPatternConfig(config, child.type); - if (childPatternConfig.parseUserInput) { - const parseResult = childPatternConfig.parseUserInput( - child, - values[child.id] - ); - if (parseResult.success) { - result.values[child.id] = parseResult.data; - } else { - result.values[child.id] = values[child.id]; - result.errors[child.id] = parseResult.error; - } - } + validatePatternAndChildren( + config, + form, + childPatternConfig, + child, + values, + result + ); } - return result; }; diff --git a/packages/forms/src/patterns/package-download/index.ts b/packages/forms/src/patterns/package-download/index.ts index 688f3468..5f8d642c 100644 --- a/packages/forms/src/patterns/package-download/index.ts +++ b/packages/forms/src/patterns/package-download/index.ts @@ -2,7 +2,7 @@ import * as z from 'zod'; import { type Pattern, type PatternConfig } from '../../pattern.js'; import { type PackageDownloadProps } from '../../components.js'; -import { type ActionName, getActionString } from '../../submission.js'; +import { getActionString } from '../../submission.js'; import { safeZodParseFormErrors } from '../../util/zod.js'; const configSchema = z.object({ @@ -21,10 +21,6 @@ export const packageDownloadConfig: PatternConfig = { return []; }, createPrompt(_, session, pattern, options) { - const actionName: ActionName = getActionString({ - handlerId: 'page-set', - patternId: pattern.id, - }); return { props: { _patternId: pattern.id, @@ -32,7 +28,10 @@ export const packageDownloadConfig: PatternConfig = { actions: [ { type: 'submit', - submitAction: actionName, + submitAction: getActionString({ + handlerId: 'package-download', + patternId: pattern.id, + }), text: 'Download PDF', }, ], diff --git a/packages/forms/src/patterns/package-download/submit.test.ts b/packages/forms/src/patterns/package-download/submit.test.ts index 915664d7..53d57a9e 100644 --- a/packages/forms/src/patterns/package-download/submit.test.ts +++ b/packages/forms/src/patterns/package-download/submit.test.ts @@ -1,9 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { failure, success } from '@atj/common'; +import { failure } from '@atj/common'; -import { Blueprint, getDocumentFieldData, type FormSession } from '../..'; -import { defaultFormConfig } from '../../..'; +import { type Blueprint, type FormSession, defaultFormConfig } from '../..'; import { downloadPackageHandler } from './submit'; import { PackageDownload } from './builder'; diff --git a/packages/forms/src/repository/get-form.test.ts b/packages/forms/src/repository/get-form.test.ts index b9d943b9..415f809c 100644 --- a/packages/forms/src/repository/get-form.test.ts +++ b/packages/forms/src/repository/get-form.test.ts @@ -1,6 +1,8 @@ import { expect, it } from 'vitest'; import { type DbTestContext, describeDatabase } from '@atj/database/testing'; + +import { type Blueprint } from '../index.js'; import { getForm } from './get-form.js'; describeDatabase('getForm', () => { @@ -10,7 +12,7 @@ describeDatabase('getForm', () => { .insertInto('forms') .values({ id: '45c66187-64e2-4d75-a45a-e80f1d035bc5', - data: '{"summary":{"title":"Test form","description":"Test description"},"root":"root","patterns":{"root":{"type":"sequence","id":"root","data":{"patterns":[]}}},"outputs":[]}', + data: '{"summary":{"title":"Title","description":"Description"},"root":"root","patterns":{"root":{"type":"sequence","id":"root","data":{"patterns":[]}}},"outputs":[{"data":"AQID","path":"test.pdf","fields":{},"formFields":{}}]}', }) .execute(); @@ -18,23 +20,8 @@ describeDatabase('getForm', () => { db.ctx, '45c66187-64e2-4d75-a45a-e80f1d035bc5' ); - expect(result).toEqual({ - outputs: [], - patterns: { - root: { - data: { - patterns: [], - }, - id: 'root', - type: 'sequence', - }, - }, - root: 'root', - summary: { - description: 'Test description', - title: 'Test form', - }, - }); + console.log(result); + expect(result).toEqual(TEST_FORM); }); it('return null with non-existent form', async ({ db }) => { @@ -45,3 +32,17 @@ describeDatabase('getForm', () => { expect(result).toBeNull(); }); }); + +const TEST_FORM: Blueprint = { + summary: { title: 'Title', description: 'Description' }, + root: 'root', + patterns: { root: { type: 'sequence', id: 'root', data: { patterns: [] } } }, + outputs: [ + { + data: new Uint8Array([1, 2, 3]), + path: 'test.pdf', + fields: {}, + formFields: {}, + }, + ], +}; diff --git a/packages/forms/src/repository/get-form.ts b/packages/forms/src/repository/get-form.ts index b3fe828f..e144162b 100644 --- a/packages/forms/src/repository/get-form.ts +++ b/packages/forms/src/repository/get-form.ts @@ -5,7 +5,7 @@ import { type Blueprint } from '../index.js'; export type GetForm = ( ctx: DatabaseContext, formId: string -) => Promise; +) => Promise; export const getForm: GetForm = async (ctx, formId) => { const db = await ctx.getKysely(); diff --git a/packages/forms/src/repository/save-form.test.ts b/packages/forms/src/repository/save-form.test.ts index 600a60de..8befac51 100644 --- a/packages/forms/src/repository/save-form.test.ts +++ b/packages/forms/src/repository/save-form.test.ts @@ -2,10 +2,11 @@ import { expect, it } from 'vitest'; import { type DbTestContext, describeDatabase } from '@atj/database/testing'; +import { type Blueprint } from '../index.js'; import { saveForm } from './save-form.js'; import { addForm } from './add-form.js'; -const TEST_FORM = { +const TEST_FORM: Blueprint = { summary: { title: 'Test form', description: 'Test description', @@ -20,7 +21,14 @@ const TEST_FORM = { }, }, }, - outputs: [], + outputs: [ + { + data: new Uint8Array([1, 2, 3]), + path: 'test.pdf', + fields: {}, + formFields: {}, + }, + ], }; describeDatabase('saveForm', () => { @@ -47,7 +55,7 @@ describeDatabase('saveForm', () => { expect(result[0].id).toEqual(addResult.data.id); expect(result[0].data).toEqual( - '{"summary":{"title":"Updated title","description":"Updated description"},"root":"root","patterns":{"root":{"type":"sequence","id":"root","data":{"patterns":[]}}},"outputs":[]}' + '{"summary":{"title":"Updated title","description":"Updated description"},"root":"root","patterns":{"root":{"type":"sequence","id":"root","data":{"patterns":[]}}},"outputs":[{"data":"AQID","path":"test.pdf","fields":{},"formFields":{}}]}' ); }); }); diff --git a/packages/forms/src/repository/serialize.ts b/packages/forms/src/repository/serialize.ts index d68cc077..11e32283 100644 --- a/packages/forms/src/repository/serialize.ts +++ b/packages/forms/src/repository/serialize.ts @@ -1,4 +1,6 @@ -export const stringifyForm = (form: /*Blueprint*/ any) => { +import { type Blueprint } from '..'; + +export const stringifyForm = (form: Blueprint) => { return JSON.stringify({ ...form, outputs: form.outputs.map((output: any) => ({ diff --git a/packages/forms/src/services/submit-form.ts b/packages/forms/src/services/submit-form.ts index 9aca8f9e..3dd0d4e7 100644 --- a/packages/forms/src/services/submit-form.ts +++ b/packages/forms/src/services/submit-form.ts @@ -11,7 +11,7 @@ import { FormServiceContext } from '../context/index.js'; import { submitPage } from '../patterns/page-set/submit'; import { downloadPackageHandler } from '../patterns/package-download/submit'; import { type FormRoute } from '../route-data.js'; -import { getActionString, SubmissionRegistry } from '../submission'; +import { SubmissionRegistry } from '../submission'; export type SubmitForm = ( ctx: FormServiceContext, @@ -23,7 +23,7 @@ export type SubmitForm = ( Result<{ sessionId: FormSessionId; session: FormSession; - documents?: { + attachments?: { fileName: string; data: Uint8Array; }[]; @@ -70,21 +70,12 @@ export const submitForm: SubmitForm = async ( } : sessionResult.data; - const actionString = formData.action; + const actionString = formData['action']; if (typeof actionString !== 'string') { return failure(`Invalid action: ${actionString}`); } - // Get the root pattern which should be a page-set - const rootPatternId = form.root; - const submitHandlerResult = registry.getHandlerForAction( - form, - getActionString({ - handlerId: 'page-set', - patternId: rootPatternId, - }) - ); - + const submitHandlerResult = registry.getHandlerForAction(form, actionString); if (!submitHandlerResult.success) { return failure(submitHandlerResult.error); } @@ -140,7 +131,6 @@ const getFormSessionOrCreate = async ( route?: FormRoute, sessionId?: FormSessionId ) => { - console.log('got sessionId', sessionId); if (sessionId === undefined) { return success(createFormSession(form, route)); } diff --git a/packages/server/src/lib/api-client.ts b/packages/server/src/lib/api-client.ts index f2819b38..374f79a1 100644 --- a/packages/server/src/lib/api-client.ts +++ b/packages/server/src/lib/api-client.ts @@ -23,7 +23,9 @@ export class FormServiceClient implements FormService { 'Content-Type': 'application/json', }, }); - return await response.json(); + const result = await response.json(); + console.log('addForm result', result); + return result; } async deleteForm(formId: string) { diff --git a/packages/server/src/lib/attachments.ts b/packages/server/src/lib/attachments.ts new file mode 100644 index 00000000..93a458c2 --- /dev/null +++ b/packages/server/src/lib/attachments.ts @@ -0,0 +1,45 @@ +export const createMultipartResponse = ( + pdfs: { fileName: string; data: Uint8Array }[] +): Response => { + const boundary = createBoundary(); + + // Array to store each part of the multipart message + const parts: Uint8Array[] = pdfs.flatMap(pdf => { + const headers = [ + `--${boundary}`, + `Content-Type: application/pdf`, + `Content-Disposition: attachment; filename="${pdf.fileName}"`, + '', + '', // empty line between headers and content + ].join('\r\n'); + + return [stringToUint8Array(headers), pdf.data, stringToUint8Array('\r\n')]; + }); + + // Final boundary to mark the end of the message + parts.push(stringToUint8Array(`--${boundary}--`)); + + // Concatenate all Uint8Array parts into a single Uint8Array body + const body = new Uint8Array( + parts.reduce((sum, part) => sum + part.length, 0) + ); + let offset = 0; + parts.forEach(part => { + body.set(part, offset); + offset += part.length; + }); + + return new Response(body, { + status: 200, + headers: { + 'Content-Type': `multipart/mixed; boundary=${boundary}`, + 'Content-Length': body.length.toString(), + }, + }); +}; + +const createBoundary = (): string => + `boundary_${Math.random().toString(36).slice(2)}`; + +const stringToUint8Array = (str: string): Uint8Array => + new TextEncoder().encode(str); diff --git a/packages/server/src/pages/forms/[id].astro b/packages/server/src/pages/forms/[id].astro index 63be5ddd..dd8fd669 100644 --- a/packages/server/src/pages/forms/[id].astro +++ b/packages/server/src/pages/forms/[id].astro @@ -57,6 +57,11 @@ if (Astro.request.method === 'POST') { }); } setFormSessionCookie(submitFormResult.data.sessionId); + + if (submitFormResult.data.attachments) { + return createMultipartResponse(submitFormResult.data.attachments); + } + return Astro.redirect(getNextUrl(submitFormResult.data.session.route)); }