diff --git a/.github/workflows/_validate.yml b/.github/workflows/_validate.yml index d86415c9e..67feae7c3 100644 --- a/.github/workflows/_validate.yml +++ b/.github/workflows/_validate.yml @@ -62,7 +62,7 @@ jobs: if: steps.playwright-cache.outputs.cache-hit != 'true' # While most of the test suite is self-contained, the tests for the demo - # servers require a prod build of @atj/server. + # servers require a prod build of @gsa-tts/forms-server. - name: Build run: pnpm build @@ -70,10 +70,10 @@ jobs: run: mkdir -p output/build-artifacts - name: Spotlight app performance budget - run: pnpm --filter @atj/spotlight size:ci > output/build-artifacts/spotlight-size-output.txt + run: pnpm --filter @gsa-tts/forms-spotlight size:ci > output/build-artifacts/spotlight-size-output.txt - name: Design package performance budget - run: pnpm --filter @atj/design size:ci > output/build-artifacts/design-size-output.txt + run: pnpm --filter @gsa-tts/forms-design size:ci > output/build-artifacts/design-size-output.txt - name: Upload size:ci results uses: actions/upload-artifact@v4 diff --git a/packages/design/.storybook/main.ts b/packages/design/.storybook/main.ts index 183e3cce3..93d61aca0 100644 --- a/packages/design/.storybook/main.ts +++ b/packages/design/.storybook/main.ts @@ -15,7 +15,7 @@ const config: StorybookConfig = { addons: [ getAbsolutePath('@storybook/addon-links'), getAbsolutePath('@storybook/addon-essentials'), - getAbsolutePath('@storybook/addon-interactions'), + //getAbsolutePath('@storybook/addon-interactions'), getAbsolutePath('@storybook/addon-a11y'), getAbsolutePath('@storybook/addon-coverage'), getAbsolutePath('@storybook/experimental-addon-test'), diff --git a/packages/design/package.json b/packages/design/package.json index 227f9159b..d6ddb77fa 100644 --- a/packages/design/package.json +++ b/packages/design/package.json @@ -35,7 +35,6 @@ "@storybook/addon-a11y": "^8.4.7", "@storybook/addon-coverage": "^1.0.5", "@storybook/addon-essentials": "^8.4.7", - "@storybook/addon-interactions": "^8.4.7", "@storybook/addon-links": "^8.4.7", "@storybook/blocks": "^8.4.7", "@storybook/experimental-addon-test": "^8.4.7", diff --git a/packages/design/src/FormManager/FormEdit/components/PageEdit.tsx b/packages/design/src/FormManager/FormEdit/components/PageEdit.tsx index a9efe1c7c..c01b6c668 100644 --- a/packages/design/src/FormManager/FormEdit/components/PageEdit.tsx +++ b/packages/design/src/FormManager/FormEdit/components/PageEdit.tsx @@ -59,7 +59,6 @@ export const PageEdit: PatternEditComponent = props => { previewProps={{ type: 'sequence', _patternId: props.previewProps._patternId, - children: props.previewProps.children, }} childComponents={props.childComponents} /> diff --git a/packages/design/src/FormManager/FormEdit/components/PageEdit/PageEdit.stories.tsx b/packages/design/src/FormManager/FormEdit/components/PageEdit/PageEdit.stories.tsx new file mode 100644 index 000000000..b7fa49358 --- /dev/null +++ b/packages/design/src/FormManager/FormEdit/components/PageEdit/PageEdit.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { within } from '@testing-library/react'; + +import { createThreePageFormWithPageRules } from '../../../../test-form.js'; +import { createPatternEditStoryMeta } from '../common/story-helper.js'; +import { PageEdit } from './index.js'; + +const blueprint = createThreePageFormWithPageRules(); + +const storyConfig: Meta = { + title: 'Edit components/PageEdit', + ...createPatternEditStoryMeta({ + blueprint, + }), +}; +export default storyConfig; + +export const CreateCustomRule: StoryObj = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + //const pagesetHeaderElement = await canvas.findByText(/Page 1/); + canvas.getByRole('button', { name: /Create custom rule/ }); + //await userEvent.click(button); + }, +}; diff --git a/packages/design/src/FormManager/FormEdit/components/PageEdit/index.tsx b/packages/design/src/FormManager/FormEdit/components/PageEdit/index.tsx new file mode 100644 index 000000000..a4cab51f2 --- /dev/null +++ b/packages/design/src/FormManager/FormEdit/components/PageEdit/index.tsx @@ -0,0 +1,342 @@ +import classnames from 'classnames'; +import React, { useState } from 'react'; + +import { PagePattern, PageProps } from '@gsa-tts/forms-core'; +import { enLocale as message } from '@gsa-tts/forms-common'; + +import { useRouteParams } from '../../../hooks.js'; +import { PatternEditComponent } from '../../types.js'; + +import { PatternEditActions } from '../common/PatternEditActions.js'; +import { PatternEditForm } from '../common/PatternEditForm.js'; +import { usePatternEditFormContext } from '../common/hooks.js'; +import { PatternPreviewSequence } from '../PreviewSequencePattern/index.js'; +import styles from '../../formEditStyles.module.css'; +import type { FormManagerContext } from '../../../index.js'; + +export const PageEdit: PatternEditComponent = props => { + const [editingRule, setEditingRule] = useState< + PageProps['rules'][number] | null + >(null); + + return ( + <> + {props.focus ? ( + } + > + ) : ( + + )} + + + {/* I can't get the pattern here. hmmmm */} + {props.focus?.pattern && ( + + } + > + )} + + {!editingRule && ( + <> + + + + )} + + ); +}; + +const RuleEdit = (props: { + context: FormManagerContext; + ruleTargetOptions: { value: string; label: string }[]; + previewProps: React.PropsWithChildren; + editingRule: PageProps['rules'][number] | null; +}) => { + const { register } = usePatternEditFormContext( + props.previewProps._patternId + ); + + return props.previewProps.rules.map( + (rule, index) => + rule === props.editingRule && ( +
+ +
+ {props.previewProps.children} +
+
+
+ + If the applicant makes the selection above, then: +
+ +
+ + +
+ + +
+
+ ) + ); +}; + +const RuleEditList = (props: { + context: FormManagerContext; + setEditRule: (rule: PageProps['rules'][number] | null) => void; + previewProps: PageProps; +}) => { + return ( +
+
+ NAVIGATION: After the current page is completed, go to + the next page unless a custom rule applies. +
+ {props.previewProps.rules.map((rule, index) => ( + + ))} +
+
+ +
+
+ ); +}; + +const EditRuleRow = (props: { + context: FormManagerContext; + setEditRule: (rule: PageProps['rules'][number] | null) => void; + rule: PageProps['rules'][number]; +}) => { + return ( +
+
+
+ +
+ If{' '} + {' '} + are selected, go to{' '} + {props.rule.next}. +
+
+
+ + + + +
+
+
+ ); +}; + +const PageEditComponent = ({ pattern }: { pattern: PagePattern }) => { + const { fieldId, getFieldState, register } = + usePatternEditFormContext(pattern.id); + const title = getFieldState('title'); + return ( +
+ +
+ + {title.error ? ( + + {title.error.message} + + ) : null} + +
+
+ +
+
+ ); +}; + +const RuleButton = (props: { + context: FormManagerContext; + icon: string; + label: string; +}) => ( + +); + +const PageEditHeader = (props: { title: string }) => { + const { routeParams } = useRouteParams(); + const params = new URLSearchParams(routeParams?.toString()); + const pageNumberText = Number(params.get('page')) + 1; + + const handleParentClick = ( + event: React.MouseEvent + ) => { + if (event.target === event.currentTarget) { + // Trigger focus or any other action you want when the parent div is clicked + event.currentTarget.focus(); + } + }; + + return ( +
+ + {props.title || 'untitled page'}{' '} + * + + + Page {pageNumberText} + +
+ ); +}; + +const CustomRuleInfoAlert = () => ( +
+
+

To create a custom rule:

+

+

    +
  1. Answer this question as if you were the applicant.
  2. +
  3. Decide on what action to take based on your answer.
  4. +
+

+
+
+); diff --git a/packages/design/src/FormManager/FormEdit/components/PageSetEdit/index.tsx b/packages/design/src/FormManager/FormEdit/components/PageSetEdit/index.tsx index 9e6d0ac48..e2d2bedde 100644 --- a/packages/design/src/FormManager/FormEdit/components/PageSetEdit/index.tsx +++ b/packages/design/src/FormManager/FormEdit/components/PageSetEdit/index.tsx @@ -1,18 +1,18 @@ +import classNames from 'classnames'; import React from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { UniqueIdentifier } from '@dnd-kit/core'; import { getPattern, type PageSetProps } from '@gsa-tts/forms-core'; import { PatternEditComponent } from '../../types.js'; -import ActionBar from '../../../../Form/ActionBar/index.js'; -import classNames from 'classnames'; import styles from '../../../../Form/components/PageSet/PageMenu/pageMenuStyles.module.css'; import { DraggableList } from '../PreviewSequencePattern/DraggableList.js'; import { useFormManagerStore } from '../../../store.js'; -import { useSearchParams } from 'react-router-dom'; -import { UniqueIdentifier } from '@dnd-kit/core'; import { PageMenuProps } from '../../../../Form/components/PageSet/PageMenu/PageMenu.js'; import { renderEditPromptComponents } from '../../../manager-common.js'; +import ActionBar from '../../../../Form/ActionBar/index.js'; const PageSetEdit: PatternEditComponent = ({ context, diff --git a/packages/design/src/FormManager/FormEdit/components/index.ts b/packages/design/src/FormManager/FormEdit/components/index.ts index edbb5c832..736713264 100644 --- a/packages/design/src/FormManager/FormEdit/components/index.ts +++ b/packages/design/src/FormManager/FormEdit/components/index.ts @@ -15,7 +15,7 @@ import GenderIdPatternEdit from './GenderIdPatternEdit/index.js'; import NamePatternEdit from './NamePatternEdit/index.js'; import PackageDownloadPatternEdit from './PackageDownloadPatternEdit.js'; import PageSetEdit from './PageSetEdit/index.js'; -import { PageEdit } from './PageEdit.js'; +import { PageEdit } from './PageEdit/index.js'; import ParagraphPatternEdit from './ParagraphPatternEdit/index.js'; import { PatternPreviewSequence } from './PreviewSequencePattern/index.js'; import PhoneNumberPatternEdit from './PhoneNumberPatternEdit/index.js'; diff --git a/packages/design/src/test-form.ts b/packages/design/src/test-form.ts index fb808921c..606df174a 100644 --- a/packages/design/src/test-form.ts +++ b/packages/design/src/test-form.ts @@ -3,6 +3,7 @@ import { createFormSession, defaultFormConfig, type Blueprint, + type CheckboxPattern, type Pattern, } from '@gsa-tts/forms-core'; import { createTestBrowserFormService } from '@gsa-tts/forms-core/context'; @@ -39,6 +40,7 @@ export const createOnePageTwoPatternTestForm = () => { data: { title: 'Page 1', patterns: ['element-1', 'element-2'], + rules: [], }, } satisfies PagePattern, { @@ -86,6 +88,7 @@ export const createTwoPageTwoPatternTestForm = () => { data: { title: 'First page', patterns: ['element-1', 'element-2'], + rules: [], }, } satisfies PagePattern, { @@ -94,6 +97,7 @@ export const createTwoPageTwoPatternTestForm = () => { data: { title: 'Second page', patterns: [], + rules: [], }, } satisfies PagePattern, { @@ -158,6 +162,81 @@ export const createTwoPatternTestForm = () => { ); }; +export const createThreePageFormWithPageRules = () => { + return createForm( + { + title: 'Test form with page rules', + description: 'This form has three pages and page rules', + }, + { + root: 'root', + patterns: [ + { + type: 'page-set', + id: 'root', + data: { + pages: ['page-1', 'page-2', 'page-3'], + }, + } satisfies PageSetPattern, + { + type: 'page', + id: 'page-1', + data: { + title: 'First page', + patterns: ['checkbox-1', 'checkbox-2'], + rules: [ + { + patternId: 'checkbox-1', + condition: { value: 'on', operator: '=' }, + next: 'page-2', + }, + { + patternId: 'checkbox-1', + condition: { value: 'off', operator: '=' }, + next: 'page-3', + }, + ], + }, + } satisfies PagePattern, + { + type: 'page', + id: 'page-2', + data: { + title: 'Second page', + patterns: [], + rules: [], + }, + } satisfies PagePattern, + { + type: 'page', + id: 'page-3', + data: { + title: 'Third page', + patterns: [], + rules: [], + }, + } satisfies PagePattern, + { + type: 'checkbox', + id: 'checkbox-1', + data: { + label: 'Pattern 1', + defaultChecked: false, + }, + } satisfies CheckboxPattern, + { + type: 'checkbox', + id: 'checkbox-2', + data: { + label: 'Pattern 2', + defaultChecked: false, + }, + } satisfies CheckboxPattern, + ], + } + ); +}; + export const createSimpleTestBlueprint = (pattern: Pattern) => { return createForm( { diff --git a/packages/forms/src/builder/builder.test.ts b/packages/forms/src/builder/builder.test.ts index 180e72953..8d5e555c7 100644 --- a/packages/forms/src/builder/builder.test.ts +++ b/packages/forms/src/builder/builder.test.ts @@ -5,8 +5,8 @@ import { defaultFormConfig } from '../patterns/index.js'; import { type FieldsetPattern } from '../patterns/fieldset/config.js'; import { type FormSummaryPattern } from '../patterns/form-summary.js'; import { type InputPattern } from '../patterns/input/config.js'; -import { type PagePattern } from '../patterns/page/config.js'; -import { type PageSetPattern } from '../patterns/page-set/config.js'; +import { type PagePattern } from '../patterns/pages/page/config.js'; +import { type PageSetPattern } from '../patterns/pages/page-set/config.js'; import { type RadioGroupPattern } from '../patterns/radio-group.js'; import { BlueprintBuilder } from './index.js'; @@ -61,6 +61,7 @@ describe('form builder', () => { data: { title: 'Page 1', patterns: ['element-2', 'element-1'], + rules: [], }, } satisfies PagePattern, 'page-2': { @@ -69,6 +70,7 @@ describe('form builder', () => { data: { title: 'Page 2', patterns: ['element-3'], + rules: [], }, } satisfies PagePattern, 'element-1': { @@ -123,6 +125,7 @@ describe('form builder', () => { data: { title: 'Page 1', patterns: ['element-2'], + rules: [], }, } satisfies PagePattern, 'page-2': { @@ -131,6 +134,7 @@ describe('form builder', () => { data: { title: 'Page 2', patterns: ['element-1', 'element-3'], + rules: [], }, } satisfies PagePattern, 'element-1': { @@ -190,6 +194,7 @@ describe('form builder', () => { data: { title: 'Page 1', patterns: ['element-2'], + rules: [], }, } satisfies PagePattern, 'page-2': { @@ -198,6 +203,7 @@ describe('form builder', () => { data: { title: 'Page 2', patterns: ['element-3', 'element-1'], + rules: [], }, } satisfies PagePattern, 'element-1': { @@ -259,6 +265,7 @@ describe('form builder', () => { 'fieldset-1', 'radio-group-1', ], + rules: [], }, }, 'element-1': { @@ -340,6 +347,7 @@ describe('form builder', () => { 'fieldset-1', 'radio-group-1', ], + rules: [], }, }, 'element-1': { @@ -424,6 +432,7 @@ describe('form builder', () => { newPattern.id, 'radio-group-1', ], + rules: [], }, }, 'element-1': { @@ -504,6 +513,7 @@ describe('form builder', () => { 'radio-group-1', newPattern.id, ], + rules: [], }, }, 'element-1': { @@ -580,6 +590,7 @@ describe('form builder', () => { data: { title: 'Page 1', patterns: ['element-1'], + rules: [], }, } satisfies PagePattern, 'element-1': { @@ -617,6 +628,7 @@ export const createTestBlueprint = () => { data: { title: 'Page 1', patterns: ['element-1', 'element-2'], + rules: [], }, } satisfies PagePattern, { @@ -664,6 +676,7 @@ export const createTwoPageThreePatternTestForm = () => { data: { title: 'Page 1', patterns: ['element-1', 'element-2'], + rules: [], }, } satisfies PagePattern, { @@ -672,6 +685,7 @@ export const createTwoPageThreePatternTestForm = () => { data: { title: 'Page 2', patterns: ['element-3'], + rules: [], }, } satisfies PagePattern, { @@ -733,6 +747,7 @@ export const createTestBlueprintMultipleFieldsets = () => { 'fieldset-1', 'radio-group-1', ], + rules: [], }, } satisfies PagePattern, { diff --git a/packages/forms/src/builder/index.ts b/packages/forms/src/builder/index.ts index 4ed7912a3..1ffa050bf 100644 --- a/packages/forms/src/builder/index.ts +++ b/packages/forms/src/builder/index.ts @@ -11,6 +11,7 @@ import { updateFormSummary, } from '../blueprint.js'; import { addDocument, addParsedPdfToForm } from '../documents/document.js'; +import type { ParsedPdf } from '../documents/pdf/parsing-api.js'; import type { FormErrors } from '../error.js'; import { createDefaultPattern, @@ -22,10 +23,10 @@ import { type PatternMap, } from '../pattern.js'; import { type FieldsetPattern } from '../patterns/fieldset/config.js'; -import { type PageSetPattern } from '../patterns/page-set/config.js'; -import type { Blueprint, FormSummary } from '../types.js'; -import type { ParsedPdf } from '../documents/pdf/parsing-api.js'; +import { type PageSetPattern } from '../patterns/pages/page-set/config.js'; import { type RepeaterPattern } from '../patterns/repeater/index.js'; +import type { FormRoute } from '../route-data.js'; +import type { Blueprint, FormSummary } from '../types.js'; /** * Constructs and manipulates a Blueprint object for forms. A Blueprint @@ -183,3 +184,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); + } +} diff --git a/packages/forms/src/components.ts b/packages/forms/src/components.ts index 83f9142f2..4694e42b3 100644 --- a/packages/forms/src/components.ts +++ b/packages/forms/src/components.ts @@ -6,6 +6,7 @@ import { type PatternId, getPatternConfig, } from './pattern.js'; +import type { PageRule } from './patterns/pages/page/config.js'; import { type FormSession, nullSession, sessionIsComplete } from './session.js'; import { type ActionName } from './submission.js'; import { stateTerritoryOrMilitaryPostList } from './patterns/address/jurisdictions.js'; @@ -125,6 +126,8 @@ export type PageSetProps = PatternProps<{ export type PageProps = PatternProps<{ type: 'page'; title: string; + rules: PageRule[]; + ruleTargetOptions: { value: string; label: string }[]; }>; export type RadioGroupProps = PatternProps<{ diff --git a/packages/forms/src/documents/__tests__/document.test.ts b/packages/forms/src/documents/__tests__/document.test.ts index ccb42df12..58c1eac23 100644 --- a/packages/forms/src/documents/__tests__/document.test.ts +++ b/packages/forms/src/documents/__tests__/document.test.ts @@ -3,8 +3,8 @@ import { describe, expect, it } from 'vitest'; import { getPattern } from '../../index.js'; import { BlueprintBuilder } from '../../builder/index.js'; import { defaultFormConfig } from '../../patterns/index.js'; -import { type PageSetPattern } from '../../patterns/page-set/config.js'; -import { type PagePattern } from '../../patterns/page/config.js'; +import { type PageSetPattern } from '../../patterns/pages/page-set/config.js'; +import { type PagePattern } from '../../patterns/pages/page/config.js'; import { addDocument } from '../document.js'; import { loadSamplePDF } from './sample-data.js'; diff --git a/packages/forms/src/documents/pdf/parsing-api.ts b/packages/forms/src/documents/pdf/parsing-api.ts index e1aa41310..f5b6522a3 100644 --- a/packages/forms/src/documents/pdf/parsing-api.ts +++ b/packages/forms/src/documents/pdf/parsing-api.ts @@ -2,8 +2,8 @@ import * as z from 'zod'; import { type FieldsetPattern } from '../../patterns/fieldset/config.js'; import { type InputPattern } from '../../patterns/input/config.js'; -import { PagePattern } from '../../patterns/page/config.js'; -import { PageSetPattern } from '../../patterns/page-set/config.js'; +import { PagePattern } from '../../patterns/pages/page/config.js'; +import { PageSetPattern } from '../../patterns/pages/page-set/config.js'; import { type ParagraphPattern } from '../../patterns/paragraph.js'; import { type CheckboxPattern } from '../../patterns/checkbox.js'; import { type RadioGroupPattern } from '../../patterns/radio-group.js'; @@ -333,6 +333,7 @@ export const processApiResponse = async (json: any): Promise => { { title: `${page}`, patterns, + rules: [], }, undefined, idx diff --git a/packages/forms/src/index.ts b/packages/forms/src/index.ts index dc359170f..e904c65a6 100644 --- a/packages/forms/src/index.ts +++ b/packages/forms/src/index.ts @@ -16,12 +16,7 @@ export { attachmentFileTypeOptions, attachmentFileTypeMimes, } from './patterns/index.js'; -import { type PagePattern } from './patterns/page/config.js'; -import { type PageSetPattern } from './patterns/page-set/config.js'; export { type RichTextPattern } from './patterns/rich-text.js'; -import { type SequencePattern } from './patterns/sequence.js'; -import { FieldsetPattern } from './patterns/index.js'; -import { RepeaterPattern } from './patterns/index.js'; export { type FormRepository, createFormsRepository, diff --git a/packages/forms/src/pattern.ts b/packages/forms/src/pattern.ts index 11da11c03..c8fe80766 100644 --- a/packages/forms/src/pattern.ts +++ b/packages/forms/src/pattern.ts @@ -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 = { type: string; @@ -39,13 +40,20 @@ type RemoveChildPattern

= ( export abstract class PatternBuilder

{ public readonly id: PatternId; public readonly data: P['data']; + public abstract readonly type: P['type']; constructor(data: P['data'], id?: PatternId) { this.id = id || generatePatternId(); this.data = data; } - abstract toPattern(): P; + toPattern(): P { + return { + id: this.id, + type: this.type, + data: this.data, + } as P; + } } /** @@ -108,6 +116,7 @@ export type PatternConfig< ) => Pattern[]; removeChildPattern?: RemoveChildPattern; createPrompt: CreatePrompt; + getInitialFormRoute?: (pattern: ThisPattern) => FormRoute; }; export type FormConfig = { diff --git a/packages/forms/src/patterns/checkbox.ts b/packages/forms/src/patterns/checkbox.ts index bd78ab646..0d73de003 100644 --- a/packages/forms/src/patterns/checkbox.ts +++ b/packages/forms/src/patterns/checkbox.ts @@ -1,6 +1,10 @@ import * as z from 'zod'; -import { type Pattern, type PatternConfig } from '../pattern.js'; +import { + type Pattern, + PatternBuilder, + type PatternConfig, +} from '../pattern.js'; import { type CheckboxProps } from '../components.js'; import { getFormSessionError, getFormSessionValue } from '../session.js'; import { @@ -59,3 +63,7 @@ export const checkboxConfig: PatternConfig = { }; }, }; + +export class Checkbox extends PatternBuilder { + type = 'checkbox'; +} diff --git a/packages/forms/src/patterns/fieldset/builder.ts b/packages/forms/src/patterns/fieldset/builder.ts new file mode 100644 index 000000000..b4c40e174 --- /dev/null +++ b/packages/forms/src/patterns/fieldset/builder.ts @@ -0,0 +1,6 @@ +import { PatternBuilder } from '../../pattern'; +import type { FieldsetPattern } from './config'; + +export class FieldSet extends PatternBuilder { + type = 'fieldset'; +} diff --git a/packages/forms/src/patterns/index.ts b/packages/forms/src/patterns/index.ts index eb1b7835b..b952454c2 100644 --- a/packages/forms/src/patterns/index.ts +++ b/packages/forms/src/patterns/index.ts @@ -11,8 +11,8 @@ import { formSummaryConfig } from './form-summary.js'; import { genderIdConfig } from './gender-id/gender-id.js'; import { inputConfig } from './input/index.js'; import { packageDownloadConfig } from './package-download/index.js'; -import { pageConfig } from './page/index.js'; -import { pageSetConfig } from './page-set/index.js'; +import { pageConfig } from './pages/page/index.js'; +import { pageSetConfig } from './pages/page-set/index.js'; import { paragraphConfig } from './paragraph.js'; import { phoneNumberConfig } from './phone-number/phone-number.js'; import { radioGroupConfig } from './radio-group.js'; @@ -67,10 +67,10 @@ export * from './gender-id/gender-id.js'; export * from './input/index.js'; export { type InputPattern } from './input/config.js'; export * from './package-download/index.js'; -export * from './page/index.js'; -export { type PagePattern } from './page/config.js'; -export * from './page-set/index.js'; -export { type PageSetPattern } from './page-set/config.js'; +export * from './pages/page/index.js'; +export { type PagePattern } from './pages/page/config.js'; +export * from './pages/page-set/index.js'; +export { type PageSetPattern } from './pages/page-set/config.js'; export * from './paragraph.js'; export * from './phone-number/phone-number.js'; export * from './radio-group.js'; diff --git a/packages/forms/src/patterns/input/builder.ts b/packages/forms/src/patterns/input/builder.ts index 806a5745b..7327e9348 100644 --- a/packages/forms/src/patterns/input/builder.ts +++ b/packages/forms/src/patterns/input/builder.ts @@ -2,11 +2,5 @@ import { PatternBuilder } from '../../pattern'; import { type InputPattern } from './config'; export class Input extends PatternBuilder { - toPattern(): InputPattern { - return { - id: this.id, - type: 'input', - data: this.data, - }; - } + type = 'input'; } diff --git a/packages/forms/src/patterns/package-download/builder.ts b/packages/forms/src/patterns/package-download/builder.ts index 1abf256a9..0071028e7 100644 --- a/packages/forms/src/patterns/package-download/builder.ts +++ b/packages/forms/src/patterns/package-download/builder.ts @@ -2,11 +2,5 @@ import { type PackageDownloadPattern } from '.'; import { PatternBuilder } from '../../pattern'; export class PackageDownload extends PatternBuilder { - toPattern(): PackageDownloadPattern { - return { - id: this.id, - type: 'page-set', - data: this.data, - }; - } + type = 'package-download'; } diff --git a/packages/forms/src/patterns/package-download/submit.test.ts b/packages/forms/src/patterns/package-download/submit.test.ts index 26f19fab8..56d7b005e 100644 --- a/packages/forms/src/patterns/package-download/submit.test.ts +++ b/packages/forms/src/patterns/package-download/submit.test.ts @@ -6,8 +6,8 @@ import { type Blueprint, type FormSession, defaultFormConfig } from '../..'; import { downloadPackageHandler } from './submit'; import { PackageDownload } from './builder'; -import { PageSet } from '../page-set/builder'; -import { Page } from '../page/builder'; +import { PageSet } from '../pages/page-set/builder'; +import { Page } from '../pages/page/builder'; import { Input } from '../input/builder'; import { loadSamplePDF } from '../../documents/__tests__/sample-data'; @@ -85,7 +85,10 @@ const createTestForm = async (): Promise => { 'doj-pardon-marijuana/demo-application_for_certificate_of_pardon_for_simple_marijuana_possession.pdf' ); const input1 = new Input({ label: 'Input 1', required: true }, 'input-1'); - const page1 = new Page({ title: 'Page 1', patterns: [input1.id] }, 'page-1'); + const page1 = new Page( + { title: 'Page 1', patterns: [input1.id], rules: [] }, + 'page-1' + ); const pageSet = new PageSet({ pages: [page1.id] }, 'page-set'); return { summary: { diff --git a/packages/forms/src/patterns/page-set/builder.ts b/packages/forms/src/patterns/page-set/builder.ts deleted file mode 100644 index cdc17dbd5..000000000 --- a/packages/forms/src/patterns/page-set/builder.ts +++ /dev/null @@ -1,25 +0,0 @@ -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 { - addPage(page: Page) { - return new PageSet({ - ...this.data, - pages: [...this.data.pages, page.id], - }); - } - - toPattern(): PageSetPattern { - return { - id: this.id, - type: 'page-set', - data: this.data, - }; - } -} diff --git a/packages/forms/src/patterns/page/prompt.ts b/packages/forms/src/patterns/page/prompt.ts deleted file mode 100644 index 0427ec8b6..000000000 --- a/packages/forms/src/patterns/page/prompt.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { - type CreatePrompt, - type PageProps, - createPromptForPattern, -} from '../../components.js'; -import { getPattern } from '../../pattern.js'; - -import { type PagePattern } from './config.js'; - -export const createPrompt: CreatePrompt = ( - config, - session, - pattern, - options -) => { - const children = pattern.data.patterns.map((patternId: string) => { - const childPattern = getPattern(session.form, patternId); - return createPromptForPattern(config, session, childPattern, options); - }); - return { - props: { - _patternId: pattern.id, - type: 'page', - title: pattern.data.title, - } satisfies PageProps, - children, - }; -}; diff --git a/packages/forms/src/patterns/pages/form-client.ts b/packages/forms/src/patterns/pages/form-client.ts new file mode 100644 index 000000000..ed43f46f3 --- /dev/null +++ b/packages/forms/src/patterns/pages/form-client.ts @@ -0,0 +1,80 @@ +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 { + 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): Promise { + 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, + }); + } +} diff --git a/packages/forms/src/patterns/pages/page-set/builder.ts b/packages/forms/src/patterns/pages/page-set/builder.ts new file mode 100644 index 000000000..e260ae4f0 --- /dev/null +++ b/packages/forms/src/patterns/pages/page-set/builder.ts @@ -0,0 +1,14 @@ +import { PatternBuilder } from '../../../pattern'; +import { type Page } from '../page/builder'; +import { type PageSetPattern } from './config'; + +export class PageSet extends PatternBuilder { + type = 'page-set'; + + addPage(page: Page) { + return new PageSet({ + ...this.data, + pages: [...this.data.pages, page.id], + }); + } +} diff --git a/packages/forms/src/patterns/page-set/config.ts b/packages/forms/src/patterns/pages/page-set/config.ts similarity index 83% rename from packages/forms/src/patterns/page-set/config.ts rename to packages/forms/src/patterns/pages/page-set/config.ts index 0e8a7423e..6144ac6e0 100644 --- a/packages/forms/src/patterns/page-set/config.ts +++ b/packages/forms/src/patterns/pages/page-set/config.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; -import { type Pattern, type ParsePatternConfigData } from '../../pattern.js'; -import { safeZodParseFormErrors } from '../../util/zod.js'; +import { type Pattern, type ParsePatternConfigData } from '../../../pattern.js'; +import { safeZodParseFormErrors } from '../../../util/zod.js'; const configSchema = z.object({ pages: z.array(z.string()), diff --git a/packages/forms/src/patterns/page-set/index.ts b/packages/forms/src/patterns/pages/page-set/index.ts similarity index 77% rename from packages/forms/src/patterns/page-set/index.ts rename to packages/forms/src/patterns/pages/page-set/index.ts index e0ad47970..eddcf8f56 100644 --- a/packages/forms/src/patterns/page-set/index.ts +++ b/packages/forms/src/patterns/pages/page-set/index.ts @@ -1,4 +1,4 @@ -import { type PatternConfig, type PatternId } from '../../pattern.js'; +import { type PatternConfig, type PatternId } from '../../../pattern.js'; import { type PageSetPattern, parseConfigData } from './config.js'; import { createPrompt } from './prompt.js'; @@ -42,4 +42,13 @@ export const pageSetConfig: PatternConfig = { }, }; }, + getInitialFormRoute(pattern) { + if (pattern.data.pages.length === 0) { + throw new Error('No route for empty page-set.'); + } + return { + url: '#', + params: { page: '0' }, + }; + }, }; diff --git a/packages/forms/src/patterns/pages/page-set/prompt.test.ts b/packages/forms/src/patterns/pages/page-set/prompt.test.ts new file mode 100644 index 000000000..a51f9899f --- /dev/null +++ b/packages/forms/src/patterns/pages/page-set/prompt.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; + +import { defaultFormConfig } from '../..'; +import { createFormSession } from '../../../session'; + +import { Input } from '../../input/builder'; +import { Page } from '../page/builder'; +import type { Blueprint } from '../../../types'; + +import { PageSet } from './builder'; +import { createPrompt } from './prompt'; + +describe('Page prompt', () => { + it('works', async () => { + const form = await createTestForm(); + const session = createFormSession(form, { + params: { + page: '0', + }, + url: '', + }); + const pattern = form.patterns[form.root]; + const prompt = createPrompt(defaultFormConfig, session, pattern, { + validate: true, + }); + expect(prompt).toEqual({ + props: { + _patternId: 'page-set', + type: 'page-set', + actions: [ + { + type: 'submit', + submitAction: 'action/page-set/page-set', + text: 'Submit', + }, + ], + pages: [ + { + title: 'Page 1', + selected: true, + url: '?page=0', + visited: false, + }, + ], + }, + children: [ + { + props: { + _patternId: 'page-1', + type: 'page', + title: 'Page 1', + rules: [], + ruleTargetOptions: [], + }, + children: [ + { + props: { + _patternId: 'input-1', + type: 'input', + inputId: 'input-1', + label: 'Input 1', + required: true, + value: undefined, + }, + children: [], + }, + ], + }, + ], + }); + }); +}); + +const createTestForm = async (): Promise => { + const input1 = new Input({ label: 'Input 1', required: true }, 'input-1'); + const page1 = new Page( + { title: 'Page 1', patterns: [input1.id], rules: [] }, + 'page-1' + ); + const pageSet = new PageSet({ pages: [page1.id] }, 'page-set'); + return { + summary: { + title: 'Test Form', + description: 'A test form', + }, + root: 'page-set', + patterns: { + 'page-set': pageSet.toPattern(), + 'page-1': page1.toPattern(), + 'input-1': input1.toPattern(), + }, + outputs: [], + }; +}; diff --git a/packages/forms/src/patterns/page-set/prompt.ts b/packages/forms/src/patterns/pages/page-set/prompt.ts similarity index 89% rename from packages/forms/src/patterns/page-set/prompt.ts rename to packages/forms/src/patterns/pages/page-set/prompt.ts index c047120e3..e444a9d70 100644 --- a/packages/forms/src/patterns/page-set/prompt.ts +++ b/packages/forms/src/patterns/pages/page-set/prompt.ts @@ -1,18 +1,18 @@ import { z } from 'zod'; -import { type RouteData } from '../../route-data.js'; -import { safeZodParseFormErrors } from '../../util/zod.js'; +import { type RouteData } from '../../../route-data.js'; +import { safeZodParseFormErrors } from '../../../util/zod.js'; import { type PagePattern } from '../page/config.js'; -import { type ActionName, getActionString } from '../../submission.js'; +import { type ActionName, getActionString } from '../../../submission.js'; import { type CreatePrompt, type PageSetProps, type PromptAction, createPromptForPattern, -} from '../../components.js'; -import { getPattern } from '../../pattern.js'; -import { type FormSession } from '../../session.js'; +} from '../../../components.js'; +import { getPattern } from '../../../pattern.js'; +import { type FormSession } from '../../../session.js'; import { type PageSetPattern } from './config.js'; diff --git a/packages/forms/src/patterns/page/builder.ts b/packages/forms/src/patterns/pages/page/builder.ts similarity index 55% rename from packages/forms/src/patterns/page/builder.ts rename to packages/forms/src/patterns/pages/page/builder.ts index 0b4dc9e4b..1ec6eb547 100644 --- a/packages/forms/src/patterns/page/builder.ts +++ b/packages/forms/src/patterns/pages/page/builder.ts @@ -1,19 +1,13 @@ -import { PatternBuilder } from '../../pattern'; +import { PatternBuilder } from '../../../pattern'; import { type PagePattern } from './config'; export class Page extends PatternBuilder { + type = 'page'; + setTitle(title: string) { return new Page({ ...this.data, title, }); } - - toPattern(): PagePattern { - return { - id: this.id, - type: 'page', - data: this.data, - }; - } } diff --git a/packages/forms/src/patterns/page/config.ts b/packages/forms/src/patterns/pages/page/config.ts similarity index 70% rename from packages/forms/src/patterns/page/config.ts rename to packages/forms/src/patterns/pages/page/config.ts index 335737d57..02ae77f60 100644 --- a/packages/forms/src/patterns/page/config.ts +++ b/packages/forms/src/patterns/pages/page/config.ts @@ -1,7 +1,18 @@ import { z } from 'zod'; -import { type Pattern, type ParsePatternConfigData } from '../../pattern.js'; -import { safeZodParseFormErrors } from '../../util/zod.js'; +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(), + alertMessage: z.string().optional(), +}); +export type PageRule = z.infer; const configSchema = z.object({ title: z.string(), @@ -21,6 +32,7 @@ const configSchema = z.object({ ) .pipe(z.string().array()), ]), + rules: z.array(ruleSchema).default([]), }); type PageConfigSchema = z.infer; diff --git a/packages/forms/src/patterns/page/index.ts b/packages/forms/src/patterns/pages/page/index.ts similarity index 91% rename from packages/forms/src/patterns/page/index.ts rename to packages/forms/src/patterns/pages/page/index.ts index 364212314..cd7963aea 100644 --- a/packages/forms/src/patterns/page/index.ts +++ b/packages/forms/src/patterns/pages/page/index.ts @@ -1,4 +1,4 @@ -import { type PatternConfig } from '../../pattern.js'; +import { type PatternConfig } from '../../../pattern.js'; import { type PagePattern, parseConfigData } from './config.js'; import { createPrompt } from './prompt.js'; @@ -9,6 +9,7 @@ export const pageConfig: PatternConfig = { initial: { title: 'Untitled Page', patterns: [], + rules: [], }, createPrompt, parseConfigData, diff --git a/packages/forms/src/patterns/pages/page/prompt.ts b/packages/forms/src/patterns/pages/page/prompt.ts new file mode 100644 index 000000000..9c30256a2 --- /dev/null +++ b/packages/forms/src/patterns/pages/page/prompt.ts @@ -0,0 +1,60 @@ +import { + type CreatePrompt, + type PageProps, + createPromptForPattern, +} from '../../../components.js'; +import { getPattern, type Pattern, type PatternId } from '../../../pattern.js'; +import type { Blueprint } from '../../../types.js'; +import type { PageSetPattern } from '../page-set/config.js'; + +import { type PagePattern } from './config.js'; + +export const createPrompt: CreatePrompt = ( + config, + session, + pattern, + options +) => { + const children = pattern.data.patterns.map((patternId: string) => { + const childPattern = getPattern(session.form, patternId); + return createPromptForPattern(config, session, childPattern, options); + }); + return { + props: { + _patternId: pattern.id, + type: 'page', + title: pattern.data.title, + // REVISIT. We should probably have a way to pass in edit-specific + // options that are separate from createPrompt props. + rules: pattern.data.rules, + ruleTargetOptions: getPagePeers(session.form, pattern), + } satisfies PageProps, + children, + }; +}; + +const getPagePeers = (form: Blueprint, pattern: PagePattern) => { + // This code currently assumes that the parent of this page the root page set, + // and that the available target pages for a rule are the sublings of this page. + const pageSet = form.patterns[form.root]; + if (!isPageSet(pageSet)) { + throw new Error('Expected page set root'); + } + if (!pageSet.data.pages.includes(pattern.id)) { + throw new Error('Page is not a child of the root page set'); + } + + return pageSet.data.pages + .filter(pageId => pageId !== pattern.id) + .map(pageId => { + const page = getPattern(form, pageId); + return { + value: page.id, + label: `Go to: ${page.data.title}`, + }; + }); +}; + +const isPageSet = (pattern: Pattern): pattern is PageSetPattern => { + return pattern.type === 'page-set'; +}; diff --git a/packages/forms/src/patterns/pages/rules.ts b/packages/forms/src/patterns/pages/rules.ts new file mode 100644 index 000000000..3830962bc --- /dev/null +++ b/packages/forms/src/patterns/pages/rules.ts @@ -0,0 +1 @@ +import type { Pattern } from '../../pattern'; diff --git a/packages/forms/src/patterns/page-set/submit.test.ts b/packages/forms/src/patterns/pages/submit.test.ts similarity index 62% rename from packages/forms/src/patterns/page-set/submit.test.ts rename to packages/forms/src/patterns/pages/submit.test.ts index 24b9cc177..9e83900d1 100644 --- a/packages/forms/src/patterns/page-set/submit.test.ts +++ b/packages/forms/src/patterns/pages/submit.test.ts @@ -1,13 +1,18 @@ import { describe, expect, it } from 'vitest'; -import { type Blueprint, defaultFormConfig } from '../..'; -import { Input } from '../input/builder'; -import { Page } from '../page/builder'; +import { success } from '@gsa-tts/forms-common'; + +import { createTestBrowserFormService } from '../../context'; import { createFormSession } from '../../session'; +import type { Blueprint } from '../../types'; + +import { Input } from '../input/builder'; -import { PageSet } from './builder'; +import { PageSet } from './page-set/builder'; import { submitPage } from './submit'; -import { success } from '@gsa-tts/forms-common'; +import { defaultFormConfig } from '..'; +import { Page } from './page/builder'; +import { FormClient } from './form-client'; describe('Page-set submission', () => { it('stores session data for valid page data', async () => { @@ -136,7 +141,7 @@ describe('Page-set submission', () => { route: { url: '#', params: { - page: '1', + page: '2', }, }, form: session.form, @@ -145,14 +150,84 @@ describe('Page-set submission', () => { success: true, }); }); + + it('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({ + attachments: undefined, + 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 }, 'input-1'); const input2 = new Input({ label: 'label', required: true }, 'input-2'); - const page1 = new Page({ title: 'Page 1', patterns: [input1.id] }, 'page-1'); - const page2 = new Page({ title: 'Page 2', patterns: [input2.id] }, 'page-2'); - const pageSet = new PageSet({ pages: [page1.id, page2.id] }, 'page-set-1'); + const page1 = new Page( + { + 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 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', @@ -162,11 +237,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, + }; }; diff --git a/packages/forms/src/patterns/page-set/submit.ts b/packages/forms/src/patterns/pages/submit.ts similarity index 54% rename from packages/forms/src/patterns/page-set/submit.ts rename to packages/forms/src/patterns/pages/submit.ts index c9d082b4b..9e5154e05 100644 --- a/packages/forms/src/patterns/page-set/submit.ts +++ b/packages/forms/src/patterns/pages/submit.ts @@ -4,11 +4,14 @@ import { getPatternConfig, getPatternSafely, aggregatePatternSessionValues, + type PatternId, + type PatternValue, } from '../../pattern.js'; import { type FormSession } from '../../session'; import { type SubmitHandler } from '../../submission'; -import { type PagePattern } from '../page/config'; -import { type PageSetPattern } from './config'; +import { type PagePattern } from './page/config'; +import type { PageSetPattern } from './page-set/config.js'; +import type { FormError } from '../../error.js'; const getPage = (formSession: FormSession) => { const page = formSession.route?.params.page?.toString(); @@ -47,12 +50,12 @@ export const submitPage: SubmitHandler = async ( } ); - // Increment the page number if there are no errors and this isn't the last page. - const lastPage = opts.pattern.data.pages.length - 1; - const nextPage = - Object.values(result.errors).length === 0 && pageNumber < lastPage - ? pageNumber + 1 - : pageNumber; + const nextPage = getNextPage({ + pageSet: opts.pattern, + page: pagePattern.data, + pageNumber, + data: result, + }); return success({ session: { @@ -70,3 +73,41 @@ export const submitPage: SubmitHandler = async ( }, }); }; + +const getNextPage = (opts: { + pageSet: PageSetPattern; + page: PagePattern; + pageNumber: number; + data: { + values: Record; + errors: Record; + }; +}) => { + // Evaluate page rules + const ruleMatch = opts.page.data.rules + ? opts.page.data.rules.find(rule => { + if (rule.condition.operator === '=') { + const value = opts.data.values[rule.patternId]; + if (value === rule.condition.value) { + return true; + } + } else { + throw new Error( + `Unsupported rule operator: "${rule.condition.operator}"` + ); + } + }) + : undefined; + + // Get the page number for the 1st rule match, or the next page if no rules + // match. + const lastPage = opts.pageSet.data.pages.length - 1; + const nextPage = + Object.values(opts.data.errors).length === 0 && opts.pageNumber < lastPage + ? ruleMatch + ? opts.pageSet.data.pages.indexOf(ruleMatch.next) + : opts.pageNumber + 1 + : opts.pageNumber; + + return nextPage; +}; diff --git a/packages/forms/src/services/submit-form.test.ts b/packages/forms/src/services/submit-form.test.ts index aee9cc1fa..e160fba03 100644 --- a/packages/forms/src/services/submit-form.test.ts +++ b/packages/forms/src/services/submit-form.test.ts @@ -8,10 +8,16 @@ import { } from '../documents/__tests__/test-documents.js'; import { createFormSession } from '../session.js'; import { createForm } from '../blueprint.js'; -import { type PageSetPattern } from '../patterns/page-set/config.js'; -import { type PagePattern } from '../patterns/page/config.js'; +import { type PageSetPattern } from '../patterns/pages/page-set/config.js'; +import { type PagePattern } from '../patterns/pages/page/config.js'; import { type InputPattern } from '../patterns/input/config.js'; 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 { 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 () => { @@ -198,6 +204,7 @@ describe('multi-page form', () => { data: { title: 'Page 1', patterns: ['element-1'], + rules: [], }, } satisfies PagePattern, { @@ -216,6 +223,7 @@ describe('multi-page form', () => { data: { title: 'Page 2', patterns: ['element-2'], + rules: [], }, } satisfies PagePattern, { @@ -364,8 +372,59 @@ describe('multi-page form', () => { }, }); }); +}); + +describe('multi-page form with skip logic', () => { + const setupMultiPageFormWithSkipLogic = async () => { + const form = createMultiPageFormWithSkipLogic(); + const { ctx, id } = await setupTestForm(form); + const session = createFormSession(form); + const formSessionResult = await ctx.repository.upsertFormSession({ + formId: id, + data: session, + }); + if (!formSessionResult.success) { + expect.fail('upsertFormSession failed'); + } + return { ctx, id, formSessionResult, session }; + }; - // You can add more tests here using the setupMultiPageForm function + it('falls back to next page when rule does not match', async () => { + const { ctx, id, formSessionResult, session } = + await setupMultiPageFormWithSkipLogic(); + const result = await submitForm( + ctx, + formSessionResult.data.id, + id, + { + action: 'action/page-set/root', + }, + { url: '#', params: { page: '0' } } + ); + expect(result).toEqual({ + success: true, + data: { + attachments: undefined, + session: { + data: { + errors: {}, + values: { + checkbox1: false, + checkbox2: false, + }, + }, + form: session.form, + route: { + params: { + page: '1', + }, + url: '#', + }, + }, + sessionId: formSessionResult.data.id, + }, + }); + }); }); const setupTestForm = async (form?: Blueprint) => { @@ -389,6 +448,7 @@ const setupTestForm = async (form?: Blueprint) => { data: { title: 'Page 1', patterns: [], + rules: [], }, } satisfies PagePattern, ], @@ -426,6 +486,7 @@ const createOnePatternTestForm = () => { data: { title: 'Page 1', patterns: ['element-1', 'element-2'], + rules: [], }, } satisfies PagePattern, { @@ -452,3 +513,69 @@ const createOnePatternTestForm = () => { } ); }; + +const createMultiPageFormWithSkipLogic = () => { + const checkbox1 = new Checkbox( + { label: 'Checkbox1', defaultChecked: false }, + 'checkbox1' + ); + const checkbox2 = new Checkbox( + { label: 'Checkbox2', defaultChecked: false }, + 'checkbox2' + ); + const fieldset = new FieldSet({ + legend: 'Constraints', + patterns: [checkbox1.id, checkbox2.id], + }); + const page1 = new Page({ + title: 'Page 1', + patterns: [fieldset.id], + rules: [ + { + patternId: 'rule1', + condition: { operator: '=', value: '' }, + next: 'Eligible', + }, + ], + }); + const checkbox3 = new Checkbox({ label: 'Checkbox3', defaultChecked: false }); + const checkbox4 = new Checkbox({ label: 'Checkbox4', defaultChecked: false }); + const fieldset2 = new FieldSet({ + legend: 'Constraints', + patterns: [checkbox3.id, checkbox4.id], + }); + const page2 = new Page({ + title: 'Page 2', + patterns: [fieldset2.id], + rules: [], + }); + const page3 = new Page({ + title: 'Page 3', + patterns: [], + rules: [], + }); + const pageSet = new PageSet( + { + pages: [page1.id, page2.id, page3.id], + }, + 'root' + ); + const form = new Form(defaultFormConfig, { + summary: { + title: 'Test form', + description: 'Test description', + }, + root: pageSet.id, + patterns: { + [pageSet.id]: pageSet.toPattern(), + [page1.id]: page1.toPattern(), + [page2.id]: page2.toPattern(), + [page3.id]: page3.toPattern(), + [fieldset.id]: fieldset.toPattern(), + [checkbox1.id]: checkbox1.toPattern(), + [checkbox2.id]: checkbox2.toPattern(), + }, + outputs: [], + }); + return form.bp; +}; diff --git a/packages/forms/src/services/submit-form.ts b/packages/forms/src/services/submit-form.ts index 550b2f04c..50a8f51ca 100644 --- a/packages/forms/src/services/submit-form.ts +++ b/packages/forms/src/services/submit-form.ts @@ -1,7 +1,7 @@ import { failure, success, type Result } from '@gsa-tts/forms-common'; import { type FormServiceContext } from '../context/index.js'; -import { submitPage } from '../patterns/page-set/submit'; +import { submitPage } from '../patterns/pages/submit'; import { downloadPackageHandler } from '../patterns/package-download/submit'; import { repeaterAddRowHandler, diff --git a/packages/server/src/env.d.ts b/packages/server/src/env.d.ts index 34dc699de..83ac26e3c 100644 --- a/packages/server/src/env.d.ts +++ b/packages/server/src/env.d.ts @@ -15,7 +15,7 @@ namespace App { ctx?: import('./config/context.ts').AppContext | null; // Auth types from Lucia - session: import('@atj/auth').Session | null; - user: import('@atj/auth').User | null; + session: import('@gsa-tts/auth').Session | null; + user: import('@gsa-tts/auth').User | null; } } diff --git a/packages/server/src/pages/forms/[id].test.ts b/packages/server/src/pages/forms/[id].test.ts index a6f15c14e..7104af5a7 100644 --- a/packages/server/src/pages/forms/[id].test.ts +++ b/packages/server/src/pages/forms/[id].test.ts @@ -194,6 +194,7 @@ export const createTestBlueprint = () => { data: { title: 'Page 1', patterns: ['element-1', 'element-2'], + rules: [], }, } satisfies PagePattern, { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dd093cd8a..59deff33a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -390,9 +390,6 @@ importers: '@storybook/addon-essentials': specifier: ^8.4.7 version: 8.4.7(@types/react@18.3.18)(storybook@8.4.7(prettier@3.4.2)) - '@storybook/addon-interactions': - specifier: ^8.4.7 - version: 8.4.7(storybook@8.4.7(prettier@3.4.2)) '@storybook/addon-links': specifier: ^8.4.7 version: 8.4.7(react@18.3.1)(storybook@8.4.7(prettier@3.4.2)) @@ -3148,11 +3145,6 @@ packages: peerDependencies: storybook: ^8.4.7 - '@storybook/addon-interactions@8.4.7': - resolution: {integrity: sha512-fnufT3ym8ht3HHUIRVXAH47iOJW/QOb0VSM+j269gDuvyDcY03D1civCu1v+eZLGaXPKJ8vtjr0L8zKQ/4P0JQ==} - peerDependencies: - storybook: ^8.4.7 - '@storybook/addon-links@8.4.7': resolution: {integrity: sha512-L/1h4dMeMKF+MM0DanN24v5p3faNYbbtOApMgg7SlcBT/tgo3+cAjkgmNpYA8XtKnDezm+T2mTDhB8mmIRZpIQ==} peerDependencies: @@ -15078,15 +15070,6 @@ snapshots: '@storybook/global': 5.0.0 storybook: 8.4.7(prettier@3.4.2) - '@storybook/addon-interactions@8.4.7(storybook@8.4.7(prettier@3.4.2))': - dependencies: - '@storybook/global': 5.0.0 - '@storybook/instrumenter': 8.4.7(storybook@8.4.7(prettier@3.4.2)) - '@storybook/test': 8.4.7(storybook@8.4.7(prettier@3.4.2)) - polished: 4.3.1 - storybook: 8.4.7(prettier@3.4.2) - ts-dedent: 2.2.0 - '@storybook/addon-links@8.4.7(react@18.3.1)(storybook@8.4.7(prettier@3.4.2))': dependencies: '@storybook/csf': 0.1.12 @@ -24692,7 +24675,7 @@ snapshots: expect-type: 1.1.0 magic-string: 0.30.17 pathe: 1.1.2 - std-env: 3.8.0 + std-env: 3.8.1 tinybench: 2.9.0 tinyexec: 0.3.2 tinypool: 1.0.2