Skip to content

Commit

Permalink
Add FormClient and supporting code to make it easier to test form ses…
Browse files Browse the repository at this point in the history
…sion state transitions; add currently failing test "honors first matching page rule". Will follow-up with a refactoring of page references, to refer to its pattern ID rather than array index.
  • Loading branch information
danielnaab committed Nov 19, 2024
1 parent ed2b6ea commit 8e2bc3d
Show file tree
Hide file tree
Showing 9 changed files with 219 additions and 31 deletions.
24 changes: 24 additions & 0 deletions packages/forms/src/builder/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import {
import { type FieldsetPattern } from '../patterns/fieldset/config.js';
import { type PageSetPattern } from '../patterns/pages/page-set/config.js';
import type { Blueprint, FormSummary } from '../types.js';
import type { FormRoute } from '../route-data.js';
import { PageSet } from '../patterns/pages/page-set/builder.js';

export class BlueprintBuilder {
bp: Blueprint;
Expand Down Expand Up @@ -151,3 +153,25 @@ export class BlueprintBuilder {
};
}
}

export class Form {
constructor(
private config: FormConfig,
private readonly _bp: Blueprint
) {}

get bp() {
return this._bp;
}

getInitialFormRoute(): FormRoute {
const pattern = this.bp.patterns[this.bp.root];
const patternConfig = this.config.patterns[pattern.type];
if (!patternConfig.getInitialFormRoute) {
throw new Error(
`Can't get getInitialFormRoute for pattern '${pattern.type}'`
);
}
return patternConfig.getInitialFormRoute(pattern);
}
}
3 changes: 3 additions & 0 deletions packages/forms/src/pattern.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import set from 'set-value';
import { type CreatePrompt } from './components.js';
import { type FormError, type FormErrors } from './error.js';
import { type Blueprint } from './types.js';
import type { FormRoute } from './route-data.js';

export type Pattern<C = any> = {
type: string;
Expand Down Expand Up @@ -65,6 +66,7 @@ export const getPatternSafely = <P extends Pattern>(opts: {
form: Blueprint;
patternId: PatternId;
}): r.Result<P> => {
console.log('looking for', opts.patternId);
const pattern = opts.form.patterns[opts.patternId];
if (pattern === undefined) {
return r.failure(`Pattern with id ${opts.patternId} does not exist`);
Expand Down Expand Up @@ -102,6 +104,7 @@ export type PatternConfig<
) => Pattern[];
removeChildPattern?: RemoveChildPattern<ThisPattern>;
createPrompt: CreatePrompt<ThisPattern>;
getInitialFormRoute?: (pattern: ThisPattern) => FormRoute;
};

export type FormConfig<T extends Pattern = Pattern, PatternOutput = unknown> = {
Expand Down
78 changes: 78 additions & 0 deletions packages/forms/src/patterns/pages/form-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { Form } from '../../builder';
import type { FormConfig } from '../../pattern';
import type { FormService } from '../../services';
import { type FormSession, type FormSessionId } from '../../session';
import { getActionString } from '../../submission';
import type { Blueprint } from '../../types';

type FormClientContext = {
config: FormConfig;
formService: FormService;
};

type FormClientState = {
sessionId?: FormSessionId;
session: FormSession;
attachments?: {
fileName: string;
data: Uint8Array;
}[];
};

export class FormClient {
private form: Form;
private _state?: FormClientState;

constructor(
private ctx: FormClientContext,
private formId: string,
blueprint: Blueprint
) {
this.form = new Form(ctx.config, blueprint);
}

async getState(): Promise<FormClientState> {
if (!this._state) {
const result = await this.ctx.formService.getFormSession({
formId: this.formId,
formRoute: this.form.getInitialFormRoute(),
});
if (!result.success) {
throw new Error('Error getting form session');
}
this._state = {
sessionId: result.data.id,
session: result.data.data,
};
}
return this._state;
}

setState(state: FormClientState) {
this._state = state;
}

async submitPage(formData: Record<string, string>): Promise<void> {
const state = await this.getState();
const result = await this.ctx.formService.submitForm(
state.sessionId,
this.formId,
{
...formData,
action: getActionString({
handlerId: 'page-set',
patternId: state.session.form.root,
}),
},
state.session.route
);
if (!result.success) {
throw new Error(`Error submitting form: ${result.error}`);
}
this.setState({
sessionId: result.data.sessionId,
session: result.data.session,
attachments: result.data.attachments,
});
}
}
5 changes: 0 additions & 5 deletions packages/forms/src/patterns/pages/page-set/builder.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,7 @@
import { type Blueprint } from '../../..';
import { PatternBuilder } from '../../../pattern';
import { type Page } from '../page/builder';
import { type PageSetPattern } from './config';

export class Form {
constructor(public readonly blueprint: Blueprint) {}
}

export class PageSet extends PatternBuilder<PageSetPattern> {
type = 'page-set';

Expand Down
9 changes: 9 additions & 0 deletions packages/forms/src/patterns/pages/page-set/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,13 @@ export const pageSetConfig: PatternConfig<PageSetPattern> = {
},
};
},
getInitialFormRoute(pattern) {
if (pattern.data.pages.length === 0) {
throw new Error('No route for empty page-set.');
}
return {
url: '#',
params: { page: '0' },
};
},
};
23 changes: 11 additions & 12 deletions packages/forms/src/patterns/pages/page/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@ import { z } from 'zod';
import { type Pattern, type ParsePatternConfigData } from '../../../pattern.js';
import { safeZodParseFormErrors } from '../../../util/zod.js';

const ruleSchema = z.object({
patternId: z.string(),
condition: z.object({
operator: z.literal('='),
value: z.string(),
}),
next: z.string(),
});
type Rule = z.infer<typeof ruleSchema>;

const configSchema = z.object({
title: z.string(),
patterns: z.union([
Expand All @@ -21,18 +31,7 @@ const configSchema = z.object({
)
.pipe(z.string().array()),
]),
rules: z
.array(
z.object({
patternId: z.string(),
condition: z.object({
operator: z.literal('='),
value: z.string(),
}),
next: z.string(),
})
)
.default([]),
rules: z.array(ruleSchema).default([]),
});

type PageConfigSchema = z.infer<typeof configSchema>;
Expand Down
1 change: 1 addition & 0 deletions packages/forms/src/patterns/pages/rules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import type { Pattern } from '../../pattern';
99 changes: 88 additions & 11 deletions packages/forms/src/patterns/pages/submit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import { submitPage } from './submit';
import { defaultFormConfig } from '..';
import type { Blueprint } from '../../types';
import { Page } from './page/builder';
import { FormClient } from './form-client';
import { createTestBrowserFormService } from '../../context';
import { getActionString } from '../../submission';

describe('Page-set submission', () => {
it('stores session data for valid page data', async () => {
Expand Down Expand Up @@ -88,28 +91,24 @@ describe('Page-set submission', () => {
route: {
url: '#',
params: {
page: '1',
page: '2',
},
},
},
data: {
'input-2': 'test',
},
data: {},
});
expect(result).toEqual({
data: {
session: {
...session,
data: {
errors: {},
values: {
'input-2': 'test',
},
values: {},
},
route: {
url: '#',
params: {
page: '1',
page: '2',
},
},
form: session.form,
Expand All @@ -118,9 +117,50 @@ describe('Page-set submission', () => {
success: true,
});
});

it.fails('honors first matching page rule', async () => {
const { id, form, formService } = await createTestFormContext();
const client = new FormClient(
{
config: defaultFormConfig,
formService,
},
id,
form
);
await client.submitPage({
'input-1': 'rule-2',
});
const state = await client.getState();
expect(state).toEqual(
expect.objectContaining({
sessionId: expect.any(String),
session: {
data: {
errors: {},
values: {
'input-1': 'rule-2',
},
},
route: {
url: '#',
params: {
page: '2',
},
},
form,
},
})
);
});
});

const createTestSession = () => {
const testForm = createTestForm();
return createFormSession(testForm, { url: '#', params: { page: '0' } });
};

const createTestForm = () => {
const input1 = new Input(
{ label: 'label', required: true, maxLength: 10 },
'input-1'
Expand All @@ -130,14 +170,36 @@ const createTestSession = () => {
'input-2'
);
const page1 = new Page(
{ title: 'Page 1', patterns: [input1.id], rules: [] },
{
title: 'Page 1',
patterns: [input1.id],
rules: [
{
patternId: input1.id,
condition: { value: 'rule-1', operator: '=' },
next: 'page-2',
},
{
patternId: input1.id,
condition: { value: 'rule-2', operator: '=' },
next: 'page-3',
},
],
},
'page-1'
);
const page2 = new Page(
{ title: 'Page 2', patterns: [input2.id], rules: [] },
'page-2'
);
const pageSet = new PageSet({ pages: [page1.id, page2.id] }, 'page-set-1');
const page3 = new Page(
{ title: 'Page 3', patterns: [], rules: [] },
'page-3'
);
const pageSet = new PageSet(
{ pages: [page1.id, page2.id, page3.id] },
'page-set-1'
);
const testForm: Blueprint = {
summary: {
description: 'A test form',
Expand All @@ -147,11 +209,26 @@ const createTestSession = () => {
patterns: {
[page1.id]: page1.toPattern(),
[page2.id]: page2.toPattern(),
[page3.id]: page3.toPattern(),
[pageSet.id]: pageSet.toPattern(),
[input1.id]: input1.toPattern(),
[input2.id]: input2.toPattern(),
},
outputs: [],
};
return createFormSession(testForm, { url: '#', params: { page: '0' } });
return testForm;
};

const createTestFormContext = async () => {
const form = createTestForm();
const formService = createTestBrowserFormService();
const addFormResult = await formService.addForm(form);
if (!addFormResult.success) {
expect.fail('Error adding test form');
}
return {
id: addFormResult.data.id,
form,
formService,
};
};
8 changes: 5 additions & 3 deletions packages/forms/src/services/submit-form.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ import { type Blueprint } from '../types.js';
import { Checkbox } from '../patterns/checkbox.js';
import { FieldSet } from '../patterns/fieldset/builder.js';
import { Page } from '../patterns/pages/page/builder.js';
import { Form, PageSet } from '../patterns/pages/page-set/builder.js';
import { PageSet } from '../patterns/pages/page-set/builder.js';
import { Form } from '../builder/index.js';
import { defaultFormConfig } from '../patterns/index.js';

describe('submitForm', () => {
it('fails with missing action string', async () => {
Expand Down Expand Up @@ -557,7 +559,7 @@ const createMultiPageFormWithSkipLogic = () => {
},
'root'
);
const form = new Form({
const form = new Form(defaultFormConfig, {
summary: {
title: 'Test form',
description: 'Test description',
Expand All @@ -574,5 +576,5 @@ const createMultiPageFormWithSkipLogic = () => {
},
outputs: [],
});
return form.blueprint;
return form.bp;
};

0 comments on commit 8e2bc3d

Please sign in to comment.