Skip to content

Commit 98c9025

Browse files
committed
add decision nodes
1 parent 800e80b commit 98c9025

File tree

9 files changed

+373
-74
lines changed

9 files changed

+373
-74
lines changed

README.md

+11-2
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,9 @@ type FormPage<DataT, ComponentProps, ErrorList> = {
8282
isComplete: (data: DeepPartial<DataT>) => boolean;
8383
// determines if this should be a final step in the flow
8484
isFinal?: (data: DeepPartial<DataT>) => boolean;
85-
// if you need to break the flow of the sequence, this makes that possible
86-
alternateNextPage?: (data: DeepPartial<DataT>) => Boolean
85+
// if you need to break the flow of the sequence, this makes that possible.
86+
// Undefined will go to the next page in the sequence
87+
selectNextPage?: (data: DeepPartial<DataT>) => Boolean
8788
// Mounted inputs are automatically validated.
8889
// If you need specific validation logic, put it here.
8990
validate?: (data: DeepPartial<DataT>) => ErrorList | undefined;
@@ -102,6 +103,14 @@ export type FormSequence<DataT, ComponentProps, ErrorList> = {
102103
// determines if this sequence is needed
103104
isRequired?: isRequiredPredicate<DataT>;
104105
};
106+
107+
export type DecisionNode<DataT> = {
108+
id: string,
109+
// determines whether or not this decision is needed
110+
isRequired?: (data: DeepPartial<DataT>) => boolean | undefined
111+
// where this node should redirect to. Undefined will go to the next page in the sequence
112+
selectNextPage?: (data: DeepPartial<DataT>) => Boolean
113+
}
105114
```
106115
107116
[View the docs](https://stutrek.github.io/react-multi-page-form/)

docs/src/app/docs/api/page.mdx

+16-1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ The API consists of a few items:
88

99
- pages
1010
- sequences
11+
- decision nodes
1112
- `useMultiPageForm` and `useMultiPageHookForm` React hooks
1213

1314
## Pages
@@ -26,7 +27,7 @@ type HookFormPage<DataT, ComponentProps = { hookForm: UseFormReturn }> = {
2627
isFinal?: (data: DeepPartial<DataT>) => boolean; // return true if this should be the final page of the form.
2728
isRequired?: (data: DeepPartial<DataT>) => boolean | undefined; // Determines if this page is needed based on form data. Default: () => true
2829
validate?: (data: DeepPartial<DataT>) => Promise<FieldErrors | undefined>; // Determines whether or not to continue.
29-
alternateNextPage?: (data: DeepPartial<DataT>) => pageId; // Useful if you need to override the default order of pages
30+
selectNextPage?: (data: DeepPartial<DataT>) => pageId; // Useful if you need to override the default order of pages
3031

3132
// event handlers
3233
onArrive?: (data: DeepPartial<DataT>) => void; // Function to execute upon arriving at this page.
@@ -67,6 +68,20 @@ export type FormSequence<DataT, ComponentProps, ErrorList> = {
6768
};
6869
```
6970
71+
## Decision Nodes
72+
73+
If you need to make a decision on which page comes next isolate from a page the user will see, decision nodes will help.
74+
75+
```ts
76+
export type DecisionNode<DataT> = {
77+
id: string,
78+
// determines whether or not this decision is needed
79+
isRequired?: (data: DeepPartial<DataT>) => boolean | undefined
80+
// where this node should redirect to. Undefined will go to the next page in the sequence
81+
selectNextPage?: (data: DeepPartial<DataT>) => Boolean
82+
}
83+
```
84+
7085
### Example
7186
7287
```ts

src/__tests__/decisionNodes.spec.tsx

+224
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
// tests/followSequenceWithDecisionNodes.test.js
2+
3+
import { followSequence } from '../testUtils/followSequence';
4+
import type { DecisionNode, FormPage } from '../types';
5+
6+
// Helper function to create FormPages
7+
const createPage = (id: string, overrides = {}) =>
8+
({
9+
id,
10+
isComplete: () => false,
11+
...overrides,
12+
}) as unknown as FormPage<any, any, any>;
13+
14+
// Helper function to create DecisionNodes
15+
const createDecisionNode = (id: string, overrides = {}) =>
16+
({
17+
id,
18+
...overrides,
19+
}) as DecisionNode<any>;
20+
21+
describe('followSequence with DecisionNodes', () => {
22+
it('navigates through a DecisionNode using selectNextPage', () => {
23+
const data = {};
24+
const sequence = [
25+
createPage('page1'),
26+
createDecisionNode('decision1', {
27+
selectNextPage: () => 'page3',
28+
}),
29+
createPage('page2'),
30+
createPage('page3'),
31+
];
32+
33+
const visitedPages = followSequence(sequence, data);
34+
35+
// Expect visited pages to include only FormPages, in the order determined by the DecisionNode
36+
expect(visitedPages.map((page) => page.id)).toEqual(['page1', 'page3']);
37+
});
38+
39+
it('skips DecisionNode when isRequired returns false', () => {
40+
const data = {};
41+
const sequence = [
42+
createPage('page1'),
43+
createDecisionNode('decision1', {
44+
isRequired: () => false,
45+
selectNextPage: () => 'page3',
46+
}),
47+
createPage('page2'),
48+
createPage('page3'),
49+
];
50+
51+
const visitedPages = followSequence(sequence, data);
52+
53+
// DecisionNode is skipped; navigation proceeds as normal
54+
expect(visitedPages.map((page) => page.id)).toEqual([
55+
'page1',
56+
'page2',
57+
'page3',
58+
]);
59+
});
60+
61+
it('proceeds to next item when selectNextPage returns undefined', () => {
62+
const data = {};
63+
const sequence = [
64+
createPage('page1'),
65+
createDecisionNode('decision1', {
66+
selectNextPage: () => undefined,
67+
}),
68+
createPage('page2'),
69+
createPage('page3'),
70+
];
71+
72+
const visitedPages = followSequence(sequence, data);
73+
74+
// Navigation proceeds to the next item
75+
expect(visitedPages.map((page) => page.id)).toEqual([
76+
'page1',
77+
'page2',
78+
'page3',
79+
]);
80+
});
81+
82+
it('skips over decision nodes when they are skipped', () => {
83+
const data = {};
84+
const sequence = [
85+
createPage('page1'),
86+
createDecisionNode('decision1', {
87+
selectNextPage: () => 'page2',
88+
}),
89+
createDecisionNode('decision2', {
90+
selectNextPage: () => 'page4',
91+
}),
92+
createPage('page2'),
93+
createPage('page3'),
94+
createPage('page4'),
95+
];
96+
97+
const visitedPages = followSequence(sequence, data);
98+
99+
// Navigation should be: page1 -> page2 -> page4
100+
expect(visitedPages.map((page) => page.id)).toEqual([
101+
'page1',
102+
'page2',
103+
'page3',
104+
'page4',
105+
]);
106+
});
107+
108+
it('handles DecisionNode at the beginning of the sequence', () => {
109+
const data = {};
110+
const sequence = [
111+
createDecisionNode('decision1', {
112+
selectNextPage: () => 'page2',
113+
}),
114+
createPage('page1'),
115+
createPage('page2'),
116+
];
117+
118+
const visitedPages = followSequence(sequence, data);
119+
120+
// Should start at decision1, then go to page2
121+
expect(visitedPages.map((page) => page.id)).toEqual(['page2']);
122+
});
123+
124+
it('throws error when selectNextPage returns invalid page ID', () => {
125+
const data = {};
126+
const sequence = [
127+
createPage('page1'),
128+
createDecisionNode('decision1', {
129+
selectNextPage: () => 'invalidPage',
130+
}),
131+
createPage('page2'),
132+
];
133+
134+
expect(() => followSequence(sequence, data)).toThrow(
135+
'Next page "invalidPage" not found.',
136+
);
137+
});
138+
139+
it('handles data-dependent selectNextPage functions', () => {
140+
const data = { skipPage2: true };
141+
const sequence = [
142+
createPage('page1'),
143+
createDecisionNode('decision1', {
144+
selectNextPage: (choiceData: typeof data) =>
145+
choiceData.skipPage2 ? 'page3' : 'page2',
146+
}),
147+
createPage('page2'),
148+
createPage('page3'),
149+
];
150+
151+
const visitedPages = followSequence(sequence, data);
152+
153+
// DecisionNode directs to page3 based on data
154+
expect(visitedPages.map((page) => page.id)).toEqual(['page1', 'page3']);
155+
});
156+
157+
it('handles DecisionNode as the last item in the sequence', () => {
158+
const data = {};
159+
const sequence = [
160+
createPage('page1'),
161+
createPage('page2'),
162+
createDecisionNode('decision1', {
163+
selectNextPage: () => 'page3',
164+
}),
165+
createPage('page3'),
166+
];
167+
168+
const visitedPages = followSequence(sequence, data);
169+
170+
// Navigation proceeds to page3 after the decision
171+
expect(visitedPages.map((page) => page.id)).toEqual([
172+
'page1',
173+
'page2',
174+
'page3',
175+
]);
176+
});
177+
178+
it('integrates DecisionNode with FormPage having alternateNextPage', () => {
179+
const data = {};
180+
const sequence = [
181+
createPage('page1'),
182+
createDecisionNode('decision1', {
183+
selectNextPage: () => 'page3',
184+
}),
185+
createPage('page2', {
186+
alternateNextPage: () => 'page4',
187+
}),
188+
createPage('page3'),
189+
createPage('page4'),
190+
];
191+
192+
const visitedPages = followSequence(sequence, data);
193+
194+
// Expected navigation: page1 -> page3 -> page4
195+
expect(visitedPages.map((page) => page.id)).toEqual([
196+
'page1',
197+
'page3',
198+
'page4',
199+
]);
200+
});
201+
202+
it('supports DecisionNode with isRequired depending on data', () => {
203+
const data = { includeDecision: false };
204+
const sequence = [
205+
createPage('page1'),
206+
createDecisionNode('decision1', {
207+
isRequired: (decisionData: typeof data) =>
208+
decisionData.includeDecision,
209+
selectNextPage: () => 'page3',
210+
}),
211+
createPage('page2'),
212+
createPage('page3'),
213+
];
214+
215+
const visitedPages = followSequence(sequence, data);
216+
217+
// DecisionNode is skipped; navigation proceeds as normal
218+
expect(visitedPages.map((page) => page.id)).toEqual([
219+
'page1',
220+
'page2',
221+
'page3',
222+
]);
223+
});
224+
});

src/__tests__/getNextPageIndex.spec.ts

+14-16
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,11 @@ describe('getNextPageIndex', () => {
4949
expect(result).toBe(undefined);
5050
});
5151

52-
it('navigates to alternateNextPage when defined and exists', () => {
52+
it('navigates to selectNextPage when defined and exists', () => {
5353
const data = {};
5454
const pages = [
5555
createPage('page1', {
56-
alternateNextPage: () => 'page3',
56+
selectNextPage: () => 'page3',
5757
}),
5858
createPage('page2'),
5959
createPage('page3'),
@@ -70,11 +70,11 @@ describe('getNextPageIndex', () => {
7070
expect(result).toBe(2); // Index of 'page3'
7171
});
7272

73-
it('throws error when alternateNextPage returns non-existent page', () => {
73+
it('throws error when selectNextPage returns non-existent page', () => {
7474
const data = {};
7575
const pages = [
7676
createPage('page1', {
77-
alternateNextPage: () => 'pageX',
77+
selectNextPage: () => 'pageX',
7878
}),
7979
createPage('page2'),
8080
];
@@ -83,16 +83,14 @@ describe('getNextPageIndex', () => {
8383

8484
expect(() =>
8585
getNextPageIndex(data, pages, currentPageIndex, toNextIncomplete),
86-
).toThrowErrorMatchingInlineSnapshot(
87-
`"Alternate next page "pageX" not found."`,
88-
);
86+
).toThrowErrorMatchingInlineSnapshot(`"Next page "pageX" not found."`);
8987
});
9088

91-
it('skips completed alternateNextPage when toNextIncomplete is true', () => {
89+
it('skips completed selectNextPage when toNextIncomplete is true', () => {
9290
const data = {};
9391
const pages = [
9492
createPage('page1', {
95-
alternateNextPage: () => 'page3',
93+
selectNextPage: () => 'page3',
9694
}),
9795
createPage('page2'),
9896
createPage('page3', {
@@ -112,7 +110,7 @@ describe('getNextPageIndex', () => {
112110
expect(result).toBe(3); // Should skip 'page3' and return index of 'page4'
113111
});
114112

115-
it('returns nextPageIndex when no alternateNextPage and next page is required', () => {
113+
it('returns nextPageIndex when no selectNextPage and next page is required', () => {
116114
const data = {};
117115
const pages = [
118116
createPage('page1'),
@@ -369,14 +367,14 @@ describe('getNextPageIndex', () => {
369367
expect(result).toBe(undefined);
370368
});
371369

372-
it('handles recursive alternateNextPage leading to an incomplete page', () => {
370+
it('handles recursive selectNextPage leading to an incomplete page', () => {
373371
const data = {};
374372
const pages = [
375373
createPage('page1', {
376-
alternateNextPage: () => 'page2',
374+
selectNextPage: () => 'page2',
377375
}),
378376
createPage('page2', {
379-
alternateNextPage: () => 'page3',
377+
selectNextPage: () => 'page3',
380378
isComplete: () => true,
381379
}),
382380
createPage('page3', {
@@ -395,15 +393,15 @@ describe('getNextPageIndex', () => {
395393
expect(result).toBe(2); // Index of 'page3'
396394
});
397395

398-
it('throws error on circular alternateNextPage references', () => {
396+
it('throws error on circular selectNextPage references', () => {
399397
const data = {};
400398
const pages = [
401399
createPage('page1', {
402-
alternateNextPage: () => 'page2',
400+
selectNextPage: () => 'page2',
403401
isComplete: () => true,
404402
}),
405403
createPage('page2', {
406-
alternateNextPage: () => 'page1',
404+
selectNextPage: () => 'page1',
407405
isComplete: () => true,
408406
}),
409407
];

0 commit comments

Comments
 (0)