hmpo-form-wizard 15.0.6 → 17.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,7 +13,7 @@ jobs:
13
13
 
14
14
  strategy:
15
15
  matrix:
16
- node-version: [20.x, 22.x]
16
+ node-version: [24.x]
17
17
 
18
18
  steps:
19
19
  - uses: actions/checkout@v4
package/.nvmrc ADDED
@@ -0,0 +1 @@
1
+ v24.14.0
package/README.md CHANGED
@@ -177,11 +177,14 @@ Any of these options can also be provided as a third argument to the wizard to c
177
177
  * `resetJourney` - Reset the journey `journeyModel` when this step is accessed.
178
178
  * `skip` - A template is not rendered on a GET request. The `post()` lifecycle is called instead. Defaults to `false`.
179
179
  * `noPost` - Don't allow posting to this step. The post method is set to null and the step is completed if there is a next step
180
+ * `hub` - Mark this step as a hub: it is recorded in journey history on GET without storing a `next` pointer. This allows other steps to declare `prereqs` referencing this step without the hub influencing skip-ahead navigation. Use with `nonLinearJourney: true` on the wizard. Defaults to `false`.
181
+ * `setValuesOnSave` - Array of `{ key, value }` pairs to unconditionally set on the session when this step is submitted (in `saveValues`). Useful for recording completion state flags. e.g. `setValuesOnSave: [{ key: 'sectionComplete', value: 'completed' }]`
180
182
  * `forwardQuery` - forward the query params when internally redirecting. Defaults to `false`.
181
183
  * `editable` - This step is editable. This allows accessing this step with the `editSuffix` and sets the back link and next step to the `editBackStep`. Defaults to `false`.
182
184
  * `editSuffix` - Suffix to use for editing steps. Defaults to `/edit`.
183
185
  * `editBackStep` - Location to return to after editing a step. Defaults to `confirm`
184
186
  * `continueOnEdit` - While editing, if the step marked with this is evaluated to be the next step, continue to editing it instead of returning to `editBackStep`. Defaults to `false`.
187
+ * `nonLinearJourney` - Enable support for branching journeys. When `true`, edit mode allows access to previously-completed steps even when not reachable by normal chain-walking. Defaults to `false`. Set as a wizard-level option.
185
188
  * `fields` - specifies which of the fields from the field definition list are applied to this step. Form inputs which are not named on this list will not be processed. Default: `[]`
186
189
  * `template` - Specifies the template to render for GET requests to this step. Defaults to the route (without trailing slash)
187
190
  * `templatePath` - provides the location within `app.get('views')` that templates are stored.
@@ -360,6 +363,89 @@ These controllers can be overridden in a custom controller to provide additional
360
363
  > #### - `errorHandler(err, req, res, next)`
361
364
  > Additional error handling can be performed by overriding the `errorHandler`.
362
365
 
366
+ ## Non-linear journeys
367
+
368
+ By default, the wizard validates step access by walking a linear chain of `next` pointers in journey history. This works well for sequential journeys but breaks when a journey branches into multiple independent sections from a central step.
369
+
370
+ To support this pattern, two opt-in options are provided:
371
+
372
+ ### `hub: true` (step option)
373
+
374
+ Marks a step as a hub. On each GET request, the step is recorded in journey history with only `path` and `wizard` — no `next`. This allows other steps to declare `prereqs: ['hub-step']` to validate that the hub has been visited, without the hub interfering with skip-ahead navigation.
375
+
376
+ ```js
377
+ '/dashboard': {
378
+ noPost: true,
379
+ checkJourney: false,
380
+ hub: true
381
+ }
382
+ ```
383
+
384
+ The `checkJourney: false` is typically set alongside `hub: true` since hub steps are dispatch points, not part of a linear sequence.
385
+
386
+ ### `nonLinearJourney: true` (wizard option)
387
+
388
+ Enables two fallbacks for edit mode that the standard chain walk cannot handle:
389
+
390
+ 1. **Progress check**: When editing, a step that was previously completed (present in history with a `next` and not invalid) is allowed even if the chain walk cannot reach it.
391
+ 2. **Next step**: When editing, `editBackStep` is returned if the current step was previously visited, bypassing the normal reachability check.
392
+
393
+ Set at the wizard level so it applies to all steps:
394
+
395
+ ```js
396
+ app.use(wizard(steps, fields, {
397
+ name: 'my-wizard',
398
+ editBackStep: 'summary',
399
+ nonLinearJourney: true
400
+ }));
401
+ ```
402
+
403
+ ### Full example
404
+
405
+ ```js
406
+ // steps.js
407
+ module.exports = {
408
+ '/dashboard': {
409
+ noPost: true,
410
+ checkJourney: false,
411
+ hub: true
412
+ },
413
+ '/section-a': {
414
+ prereqs: ['dashboard'],
415
+ editable: true,
416
+ next: 'section-a-details'
417
+ },
418
+ '/section-a-details': {
419
+ editable: true,
420
+ next: 'dashboard',
421
+ setValuesOnSave: [{ key: 'sectionAComplete', value: 'completed' }]
422
+ },
423
+ '/section-b': {
424
+ prereqs: ['dashboard'],
425
+ editable: true,
426
+ next: 'section-b-details'
427
+ },
428
+ '/section-b-details': {
429
+ editable: true,
430
+ next: 'dashboard',
431
+ setValuesOnSave: [{ key: 'sectionBComplete', value: 'completed' }]
432
+ },
433
+ '/summary': {
434
+ checkJourney: false,
435
+ prereqs: ['dashboard']
436
+ }
437
+ }
438
+
439
+ // index.js
440
+ app.use(wizard(steps, fields, {
441
+ name: 'my-wizard',
442
+ editBackStep: 'summary',
443
+ nonLinearJourney: true
444
+ }));
445
+ ```
446
+
447
+ In this pattern, a user visits `/dashboard`, enters any section, completes it (which sets a flag via `setValuesOnSave`), and returns to `/dashboard`. They can later edit any previously-completed section step from `/summary` and be returned to `/summary` afterwards.
448
+
363
449
  ## Example app
364
450
 
365
451
  An example application can be found in [the ./example directory](./example). To run this, follow the instructions in the [README](./example/README.md).
@@ -94,11 +94,8 @@ class BaseController {
94
94
  rejectUnsupportedMethods(req, res, next) {
95
95
  const method = req.method.toLowerCase();
96
96
 
97
- const postBlocked = method === 'post'
98
- && (req.form.options.skip || req.form.options.noPost === true);
99
- const unsupported = typeof this[method] !== 'function' || postBlocked;
100
-
101
- if (unsupported) {
97
+ if ((typeof this[method] !== 'function') ||
98
+ (req.form.options.skip && method === 'post')) {
102
99
  return this.methodNotSupported(req, res, next);
103
100
  }
104
101
 
@@ -237,20 +234,28 @@ class BaseController {
237
234
 
238
235
  _checkStatus(req, res, next) {
239
236
  debug('%s #_checkStatus', req.originalUrl);
240
-
241
- const noPost = req.form.options.noPost === true || typeof this.post !== 'function';
242
237
  if (req.form.options.skip) {
243
- if (noPost) {
244
- return this.successHandler(req, res, next);
238
+ if (typeof this.post === 'function') {
239
+ return this.post(req, res, next);
245
240
  }
246
- return this.post(req, res, next);
241
+ return this.successHandler(req, res, next);
247
242
  }
248
243
 
249
244
  const pathname = url(req).pathname;
250
- if (noPost && res.locals.nextPage !== pathname && req.form.options.checkJourney) {
245
+ if (typeof this.post !== 'function' && res.locals.nextPage !== pathname && req.form.options.checkJourney) {
251
246
  this.setStepComplete(req, res);
252
247
  }
253
248
 
249
+ if (req.form.options.hub) {
250
+ let journeyHistory = req.journeyModel.get('history') || [];
251
+ if (!_.find(journeyHistory, { path: req.form.options.fullPath })) {
252
+ this.addJourneyHistoryStep(req, res, {
253
+ path: req.form.options.fullPath,
254
+ wizard: req.form.options.name
255
+ });
256
+ }
257
+ }
258
+
254
259
  next();
255
260
  }
256
261
 
@@ -354,6 +359,11 @@ class BaseController {
354
359
 
355
360
  saveValues(req, res, next) {
356
361
  debug('%s #saveValues', req.originalUrl);
362
+ if (req.form.options?.setValuesOnSave) {
363
+ for (const { key, value } of req.form.options.setValuesOnSave) {
364
+ req.form.values[key] = value;
365
+ }
366
+ }
357
367
  req.sessionModel.set(req.form.values);
358
368
  req.sessionModel.unset('errorValues');
359
369
  next();
@@ -36,6 +36,14 @@ module.exports = Controller => class extends Controller {
36
36
  return next();
37
37
  }
38
38
 
39
+ if (req.isEditing && req.form.options.nonLinearJourney) {
40
+ let visited = this.visitedJourneyStep(req, res, path);
41
+ if (visited && visited.next && !visited.invalid) {
42
+ debug('Step is allowed by edit history', path);
43
+ return next();
44
+ }
45
+ }
46
+
39
47
  debug('Step missing prereq', path);
40
48
 
41
49
  let err = new Error('Missing prereq for this step');
@@ -92,6 +100,12 @@ module.exports = Controller => class extends Controller {
92
100
  return this.walkJourneyHistory(req, res, step => step.path === path);
93
101
  }
94
102
 
103
+ // return step from history by direct lookup (no chain walking)
104
+ visitedJourneyStep(req, res, path) {
105
+ let journeyHistory = req.journeyModel.get('history') || [];
106
+ return _.find(journeyHistory, step => step.path === path);
107
+ }
108
+
95
109
  // return step that allows a given path
96
110
  allowedJourneyStep(req, res, path) {
97
111
  return this.walkJourneyHistory(req, res, step => step.next === path);
@@ -89,6 +89,15 @@ module.exports = Controller => class extends Controller {
89
89
  return editBackStep;
90
90
  }
91
91
 
92
+ // if the current step has been previously visited: return to edit back step
93
+ if (req.form.options.nonLinearJourney) {
94
+ let visitedStep = this.visitedJourneyStep(req, res, req.form.options.fullPath);
95
+ if (visitedStep && !visitedStep.invalid) {
96
+ debug('edit journey returning to edit back step via visited step', editBackStep);
97
+ return editBackStep;
98
+ }
99
+ }
100
+
92
101
  // go to the last step in history in edit mode to allow returning to edit-back-step
93
102
  let lastAllowedStep = this.lastAllowedStep(req, res);
94
103
  if (lastAllowedStep) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hmpo-form-wizard",
3
- "version": "15.0.6",
3
+ "version": "17.0.0",
4
4
  "description": "Routing and request handling for a multi-step form processes",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -17,7 +17,7 @@
17
17
  "author": "HMPO",
18
18
  "license": "MIT",
19
19
  "engines": {
20
- "node": "20.x || 22.x"
20
+ "node": "24.x"
21
21
  },
22
22
  "bugs": {
23
23
  "url": "https://github.com/HMPO/hmpo-form-wizard/issues"
@@ -28,7 +28,7 @@
28
28
  "csrf": "^3.1.0",
29
29
  "debug": "4.3.7",
30
30
  "deep-clone-merge": "^1.5.5",
31
- "hmpo-model": "^6.0.2",
31
+ "hmpo-model": "^7.0.1",
32
32
  "moment": "^2.30.1",
33
33
  "underscore": "^1.13.7"
34
34
  },
@@ -40,15 +40,15 @@
40
40
  "chai": "^4.5.0",
41
41
  "eslint": "^9.12.0",
42
42
  "express": "^4.21.2",
43
+ "globals": "^15.11.0",
44
+ "hmpo-reqres": "^3.0.0",
45
+ "husky": "^9.1.6",
43
46
  "json5": "^2.2.3",
44
47
  "mocha": "^10.7.3",
45
48
  "nyc": "^17.1.0",
46
49
  "proxyquire": "^2.1.3",
47
- "hmpo-reqres": "^2.0.0",
48
50
  "sinon": "^21.0.2",
49
- "sinon-chai": "^3.7.0",
50
- "husky": "^9.1.6",
51
- "globals": "^15.11.0"
51
+ "sinon-chai": "^3.7.0"
52
52
  },
53
53
  "overrides": {
54
54
  "serialize-javascript": "7.0.4"
@@ -182,6 +182,66 @@ describe('mixins/check-progress', () => {
182
182
  next.should.have.been.calledOnce;
183
183
  next.should.have.been.calledWithExactly();
184
184
  });
185
+
186
+ it('calls callback with no arguments if editing and step is visited in history with a next', () => {
187
+ req.isEditing = true;
188
+ controller.options.nonLinearJourney = true;
189
+ req.journeyModel.set('history', [
190
+ { path: '/base/hub', wizard: 'wizard' },
191
+ { path: '/base/teststep', next: '/base/nextstep', wizard: 'wizard' }
192
+ ]);
193
+ controller.checkJourneyProgress(req, res, next);
194
+ next.should.have.been.calledOnce;
195
+ next.should.have.been.calledWithExactly();
196
+ });
197
+
198
+ it('calls callback with MISSING_PREREQ if editing but step has no next in history', () => {
199
+ req.isEditing = true;
200
+ controller.options.nonLinearJourney = true;
201
+ req.journeyModel.set('history', [
202
+ { path: '/base/teststep', wizard: 'wizard' }
203
+ ]);
204
+ controller.checkJourneyProgress(req, res, next);
205
+ next.should.have.been.calledOnce;
206
+ next.args[0][0].should.be.an.instanceOf(Error);
207
+ next.args[0][0].code.should.equal('MISSING_PREREQ');
208
+ });
209
+
210
+ it('calls callback with MISSING_PREREQ if editing but step is invalid in history', () => {
211
+ req.isEditing = true;
212
+ controller.options.nonLinearJourney = true;
213
+ req.journeyModel.set('history', [
214
+ { path: '/base/teststep', next: '/base/nextstep', invalid: true }
215
+ ]);
216
+ controller.checkJourneyProgress(req, res, next);
217
+ next.should.have.been.calledOnce;
218
+ next.args[0][0].should.be.an.instanceOf(Error);
219
+ next.args[0][0].code.should.equal('MISSING_PREREQ');
220
+ });
221
+
222
+ it('does not use edit history fallback if not editing', () => {
223
+ controller.options.nonLinearJourney = true;
224
+ req.journeyModel.set('history', [
225
+ { path: '/base/hub', wizard: 'wizard' },
226
+ { path: '/base/teststep', next: '/base/nextstep', wizard: 'wizard' }
227
+ ]);
228
+ controller.checkJourneyProgress(req, res, next);
229
+ next.should.have.been.calledOnce;
230
+ next.args[0][0].should.be.an.instanceOf(Error);
231
+ next.args[0][0].code.should.equal('MISSING_PREREQ');
232
+ });
233
+
234
+ it('does not use edit history fallback if nonLinearJourney is not set', () => {
235
+ req.isEditing = true;
236
+ req.journeyModel.set('history', [
237
+ { path: '/base/hub', wizard: 'wizard' },
238
+ { path: '/base/teststep', next: '/base/nextstep', wizard: 'wizard' }
239
+ ]);
240
+ controller.checkJourneyProgress(req, res, next);
241
+ next.should.have.been.calledOnce;
242
+ next.args[0][0].should.be.an.instanceOf(Error);
243
+ next.args[0][0].code.should.equal('MISSING_PREREQ');
244
+ });
185
245
  });
186
246
 
187
247
  describe('checkProceedToNextStep', () => {
@@ -480,6 +540,38 @@ describe('mixins/check-progress', () => {
480
540
 
481
541
  });
482
542
 
543
+ describe('visitedJourneyStep', () => {
544
+ it('returns the step if it exists in history', () => {
545
+ req.journeyModel.set('history', [
546
+ { path: '/base/hub', wizard: 'wizard' },
547
+ { path: '/base/teststep', next: '/base/nextstep', wizard: 'wizard' }
548
+ ]);
549
+ let result = controller.visitedJourneyStep(req, res, '/base/teststep');
550
+ result.should.deep.equal({ path: '/base/teststep', next: '/base/nextstep', wizard: 'wizard' });
551
+ });
552
+
553
+ it('returns the step even if it has no next', () => {
554
+ req.journeyModel.set('history', [
555
+ { path: '/base/hub', wizard: 'wizard' }
556
+ ]);
557
+ let result = controller.visitedJourneyStep(req, res, '/base/hub');
558
+ result.should.deep.equal({ path: '/base/hub', wizard: 'wizard' });
559
+ });
560
+
561
+ it('returns undefined if step is not in history', () => {
562
+ req.journeyModel.set('history', [
563
+ { path: '/base/other', next: '/base/nextstep' }
564
+ ]);
565
+ let result = controller.visitedJourneyStep(req, res, '/base/teststep');
566
+ expect(result).to.be.undefined;
567
+ });
568
+
569
+ it('returns undefined if history is empty', () => {
570
+ let result = controller.visitedJourneyStep(req, res, '/base/teststep');
571
+ expect(result).to.be.undefined;
572
+ });
573
+ });
574
+
483
575
  describe('addJourneyHistoryStep', () => {
484
576
  it('creates a step history and adds step if there is no existing step history', () => {
485
577
  controller.addJourneyHistoryStep(req, res,
@@ -221,6 +221,41 @@ describe('mixins/edit-step', () => {
221
221
  controller.getNextStep(req, res).should.equal('/base/url/backstep');
222
222
  });
223
223
 
224
+ it('returns editBackStep if the current step has been previously visited', () => {
225
+ req.journeyModel.set('history', [
226
+ { path: '/base/url/hub' },
227
+ { path: '/base/url/path', next: '/base/url/nextstep' }
228
+ ]);
229
+ req.isEditing = true;
230
+ options.nonLinearJourney = true;
231
+ options.fullPath = '/base/url/path';
232
+ controller.getNextStepObject.returns({ url: 'nextstep', condition: {} });
233
+ controller.getNextStep(req, res).should.equal('/base/url/backstep');
234
+ });
235
+
236
+ it('does not return editBackStep via visited step if step is invalid', () => {
237
+ req.journeyModel.set('history', [
238
+ { path: '/base/url/hub' },
239
+ { path: '/base/url/path', next: '/base/url/nextstep', invalid: true }
240
+ ]);
241
+ req.isEditing = true;
242
+ options.nonLinearJourney = true;
243
+ options.fullPath = '/base/url/path';
244
+ controller.getNextStepObject.returns({ url: 'nextstep', condition: {} });
245
+ controller.getNextStep(req, res).should.not.equal('/base/url/backstep');
246
+ });
247
+
248
+ it('does not use visited step fallback if nonLinearJourney is not set', () => {
249
+ req.journeyModel.set('history', [
250
+ { path: '/base/url/hub' },
251
+ { path: '/base/url/path', next: '/base/url/nextstep' }
252
+ ]);
253
+ req.isEditing = true;
254
+ options.fullPath = '/base/url/path';
255
+ controller.getNextStepObject.returns({ url: 'nextstep', condition: {} });
256
+ controller.getNextStep(req, res).should.not.equal('/base/url/backstep');
257
+ });
258
+
224
259
  it('returns last valid next in history in edit mode with next arg if backstep is not valid', () => {
225
260
  req.journeyModel.set('history', [
226
261
  { path: '/base/url/a', next: '/base/url/b' },
@@ -4,6 +4,7 @@ const Controller = require('../../lib/controller');
4
4
  const ErrorClass = require('../../lib/error');
5
5
  const formatting = require('../../lib/formatting');
6
6
  const validation = require('../../lib/validation');
7
+ // const _ = require('underscore');
7
8
 
8
9
  const proxyquire = require('proxyquire');
9
10
  const express = require('express');
@@ -16,8 +17,6 @@ describe('Form Controller', () => {
16
17
  options = {
17
18
  route: '/route',
18
19
  checkJourney: true,
19
- skip: false,
20
- noPost: false,
21
20
  next: 'nextstep',
22
21
  template: 'template',
23
22
  fields: {
@@ -95,19 +94,12 @@ describe('Form Controller', () => {
95
94
  });
96
95
 
97
96
  it('should remove post method if noPost option is set', () =>{
98
- options.noPost = true;
97
+ options.noPost = true;
99
98
  let controller = new Controller(options);
100
99
  expect(controller.post).to.be.null;
101
100
  });
102
101
 
103
- it('should leave post method if noPost option is not set', () => {
104
- delete options.noPost;
105
- let controller = new Controller(options);
106
- controller.post.should.be.a('function');
107
- });
108
-
109
- it('should leave post method if noPost option is set to false', () =>{
110
- options.noPost = false;
102
+ it('should leave post method if noPost option is not set', () =>{
111
103
  let controller = new Controller(options);
112
104
  controller.post.should.be.a('function');
113
105
  });
@@ -332,14 +324,6 @@ describe('Form Controller', () => {
332
324
  next.should.not.have.been.called;
333
325
  });
334
326
 
335
- it('calls methodNotSupported for POSTing when noPost is set dynamically', () => {
336
- req.form.options.noPost = true;
337
- req.method = 'POST';
338
- controller.rejectUnsupportedMethods(req, res, next);
339
- controller.methodNotSupported.should.have.been.calledWithExactly(req, res, next);
340
- next.should.not.have.been.called;
341
- });
342
-
343
327
  it('does not call methodNotSupported for supported methods', () => {
344
328
  req.method = 'POST';
345
329
  controller.rejectUnsupportedMethods(req, res, next);
@@ -502,6 +486,7 @@ describe('Form Controller', () => {
502
486
  });
503
487
  });
504
488
 
489
+
505
490
  describe('_getErrors', () => {
506
491
  let controller;
507
492
 
@@ -745,9 +730,8 @@ describe('Form Controller', () => {
745
730
  sinon.stub(controller, 'successHandler');
746
731
  });
747
732
 
748
- it('should call post if skip is true and noPost is false', () => {
733
+ it('should call post if skip is true', () => {
749
734
  options.skip = true;
750
- options.noPost = false;
751
735
  controller._checkStatus(req, res, next);
752
736
  next.should.not.have.been.called;
753
737
  controller.post.should.have.been.calledWithExactly(req, res, next);
@@ -759,27 +743,17 @@ describe('Form Controller', () => {
759
743
  next.should.have.been.calledWithExactly();
760
744
  });
761
745
 
762
- it('should call the successHandler if skip is set and noPost is true', () => {
746
+ it('should call the successHandler if skip is set but there is no post method', () => {
763
747
  options.skip = true;
764
- options.noPost = true;
765
- controller._checkStatus(req, res, next);
766
- next.should.not.have.been.called;
767
- controller.successHandler.should.have.been.calledWithExactly(req, res, next);
768
- });
769
-
770
- it('should call successHandler if skip is true and post is not a function', () => {
771
- options.skip = true;
772
- options.noPost = false;
773
748
  controller.post = null;
774
749
  controller._checkStatus(req, res, next);
775
750
  next.should.not.have.been.called;
776
751
  controller.successHandler.should.have.been.calledWithExactly(req, res, next);
777
752
  });
778
753
 
779
- it('should call setStepComplete if the step has a next page, noPost is true and checkJourney is true', () => {
754
+ it('should call setStepComplete if the step has a next page and no post method', () => {
780
755
  res.locals.nextPage = '/next/page';
781
- options.noPost = true;
782
- options.checkJourney = true;
756
+ controller.post = null;
783
757
  controller._checkStatus(req, res, next);
784
758
  controller.setStepComplete.should.have.been.calledOnce;
785
759
  controller.setStepComplete.should.have.been.calledWithExactly(req, res);
@@ -788,19 +762,40 @@ describe('Form Controller', () => {
788
762
 
789
763
  it('should not call setStepComplete if the next page is the same as the current url', () => {
790
764
  res.locals.nextPage = '/base/route';
765
+ controller.post = null;
791
766
  controller._checkStatus(req, res, next);
792
767
  controller.setStepComplete.should.not.have.been.called;
793
768
  next.should.have.been.calledWithExactly();
794
769
  });
795
770
 
796
- it('should call setStepComplete if post is not a function and checkJourney is true', () => {
797
- res.locals.nextPage = '/next/page';
798
- options.noPost = false;
799
- options.checkJourney = true;
800
- controller.post = null;
771
+ it('should add hub step to history on first visit when hub option is true', () => {
772
+ sinon.stub(controller, 'addJourneyHistoryStep');
773
+ options.hub = true;
774
+ options.fullPath = '/base/route';
775
+ options.name = 'test-wizard';
801
776
  controller._checkStatus(req, res, next);
802
- controller.setStepComplete.should.have.been.calledOnce;
803
- controller.setStepComplete.should.have.been.calledWithExactly(req, res);
777
+ controller.addJourneyHistoryStep.should.have.been.calledOnce;
778
+ controller.addJourneyHistoryStep.should.have.been.calledWithExactly(req, res, {
779
+ path: '/base/route',
780
+ wizard: 'test-wizard'
781
+ });
782
+ next.should.have.been.calledWithExactly();
783
+ });
784
+
785
+ it('should not add hub step to history if already present', () => {
786
+ sinon.stub(controller, 'addJourneyHistoryStep');
787
+ options.hub = true;
788
+ options.fullPath = '/base/route';
789
+ req.journeyModel.set('history', [{ path: '/base/route' }]);
790
+ controller._checkStatus(req, res, next);
791
+ controller.addJourneyHistoryStep.should.not.have.been.called;
792
+ next.should.have.been.calledWithExactly();
793
+ });
794
+
795
+ it('should not add hub step to history when hub option is not set', () => {
796
+ sinon.stub(controller, 'addJourneyHistoryStep');
797
+ controller._checkStatus(req, res, next);
798
+ controller.addJourneyHistoryStep.should.not.have.been.called;
804
799
  next.should.have.been.calledWithExactly();
805
800
  });
806
801
  });
@@ -1199,6 +1194,28 @@ describe('Form Controller', () => {
1199
1194
  controller.saveValues(req, res, next);
1200
1195
  next.should.have.been.calledWithExactly();
1201
1196
  });
1197
+
1198
+ it('sets setValuesOnSave values in form values before saving', () => {
1199
+ req.form.options = { setValuesOnSave: [{ key: 'sectionComplete', value: true }] };
1200
+ controller.saveValues(req, res, next);
1201
+ req.sessionModel.get('sectionComplete').should.equal(true);
1202
+ });
1203
+
1204
+ it('sets multiple setValuesOnSave values', () => {
1205
+ req.form.options = { setValuesOnSave: [
1206
+ { key: 'sectionComplete', value: true },
1207
+ { key: 'sectionStatus', value: 'done' }
1208
+ ] };
1209
+ controller.saveValues(req, res, next);
1210
+ req.sessionModel.get('sectionComplete').should.equal(true);
1211
+ req.sessionModel.get('sectionStatus').should.equal('done');
1212
+ });
1213
+
1214
+ it('does not set values when setValuesOnSave is not configured', () => {
1215
+ req.form.options = {};
1216
+ controller.saveValues(req, res, next);
1217
+ expect(req.sessionModel.get('sectionComplete')).to.be.undefined;
1218
+ });
1202
1219
  });
1203
1220
 
1204
1221
  describe('successHandler', () => {
@@ -10,8 +10,6 @@ describe('Controller Lifecycle', () => {
10
10
  options = {
11
11
  route: '/route',
12
12
  checkJourney: true,
13
- skip: false,
14
- noPost: false,
15
13
  next: 'nextstep',
16
14
  template: 'template',
17
15
  fields: {
@@ -305,31 +303,8 @@ describe('Controller Lifecycle', () => {
305
303
  handler(req, res, next);
306
304
  });
307
305
 
308
- it('runs the successHandler if the skip option is set and noPost option is set', done => {
306
+ it('runs the successHandler if the skip options is set and there is no post handler', done => {
309
307
  controller.options.skip = true;
310
- controller.options.noPost = true;
311
-
312
- assert = () => {
313
- controller.successHandler.should.have.been
314
- .calledOnce
315
- .calledAfter(controller._checkStatus)
316
- .and.calledOn(controller)
317
- .and.calledWithExactly(req, res, sinon.match.func);
318
- controller.setStepComplete.should.have.been
319
- .calledOnce
320
- .calledAfter(controller.successHandler)
321
- .and.calledOn(controller)
322
- .and.calledWithExactly(req, res);
323
- controller.render.should.not.have.been.called;
324
- done();
325
- };
326
-
327
- handler(req, res, next);
328
- });
329
-
330
- it('runs the successHandler if skip is set and post is not a function', done => {
331
- controller.options.skip = true;
332
- controller.options.noPost = false;
333
308
  controller.post = null;
334
309
 
335
310
  assert = () => {
@@ -350,8 +325,8 @@ describe('Controller Lifecycle', () => {
350
325
  handler(req, res, next);
351
326
  });
352
327
 
353
- it('sets the step as complete if noPost option is set', done => {
354
- controller.options.noPost = true;
328
+ it('sets the step as complete if there is no post handler', done => {
329
+ controller.post = null;
355
330
 
356
331
  assert = () => {
357
332
  controller.setStepComplete.should.have.been