Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions e2e/cypress/e2e/runner/RepeatingSectionPageControllers.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Created by calum-ukhsa at 27/03/2026
Feature: New page controllers that allow for repeating sections of form data

Scenario: RepeatingSection page controllers work as expected
Given the form "repeating-sections" exists
And I navigate to the "repeating-sections" form
When I enter "Joe" for "First name"
And I enter "Bloggs" for "Last name"
And I continue
Then I see "Check these details are correct before continuing"
And I see "Joe"
And I see "Bloggs"
Then I choose "Yes"
And I continue
And I enter "Joel" for "First name"
And I continue
Then I see "Joel"
62 changes: 62 additions & 0 deletions e2e/cypress/fixtures/repeating-sections.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
{
"startPage": "/name",
"pages": [
{
"title": "Name",
"path": "/name",
"components": [
{
"name": "first_name",
"options": {},
"type": "TextField",
"title": "First name"
},
{
"name": "last_name",
"options": { "required": false, "optionalText": false },
"type": "TextField",
"title": "Last name"
}
],
"controller": "RepeatingSectionPageController",
"next": [{ "path": "/repeating" }],
"section": "PeopleYouLiveWith"
},
{
"path": "/repeating",
"title": "Do you live with anyone else?",
"controller": "RepeatingSectionSummaryPageController",
"components": [
{
"name": "PeopleYouLiveWithContainer",
"options": {},
"type": "TextField",
"title": "Person you live with",
"schema": {}
}
],
"next": [{ "path": "/summary" }]
},
{
"title": "Summary",
"path": "/summary",
"controller": "./pages/summary.js",
"components": [],
"next": []
}
],
"lists": [],
"sections": [
{
"name": "PeopleYouLiveWith",
"title": "People you live with",
"hideTitle": false
}
],
"conditions": [],
"fees": [],
"outputs": [],
"version": 2,
"skipSummary": false,
"feeOptions": {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { HapiRequest, HapiResponseToolkit } from "server/types";
import { PageController } from "./PageController";

export class RepeatingSectionPageController extends PageController {
makePostRouteHandler() {
return async (request: HapiRequest, h: HapiResponseToolkit) => {
const modifyUpdate = (update) => {
const sectionName = this.section.name;
const formDataSection = Object.keys(update[sectionName]).map((key) => {
return {
name: key,
value: update[sectionName][key],
label: this.components.items.find((item) => item.name === key)!
.title,
};
});
const newObj = {};
newObj[sectionName + "Container"] = {
repeatingSections: [formDataSection],
};
return newObj;
};

await this.handlePostRequest(request, h, {
arrayMerge: true,
modifyUpdate,
});

return super.makePostRouteHandler()(request, h);
};
}
}
Original file line number Diff line number Diff line change
@@ -1,65 +1,152 @@
import { HapiRequest, HapiResponseToolkit } from "server/types";
import { SummaryViewModel } from "../models";
import { PageController } from "./PageController";

/**
* RepeatingSectionSummaryPageController is for pages summarising a set of sections
*/
export class RepeatingSectionSummaryPageController extends PageController {
makeGetRouteHandler() {
return async (request: HapiRequest, h: HapiResponseToolkit) => {
const { remove, returnUrl } = request.query;
const { cacheService } = request.services([]);
const state = await cacheService.getState(request);
const noInt = (str: string) => str.replace(/\d+/g, "");
const int = (str: string) => parseInt(str.replace(/^\D+/g, ""));

const { title, model } = this;
const summary = new SummaryViewModel(title, model, state, request);
const summaryFiltered = summary.details.filter(
(detail) =>
detail.title &&
detail.title.match(new RegExp(`${title} \\d`)) &&
detail.items[0].value
);

if (remove && state[remove]) {
const newState = {};
Object.entries(state).forEach(([key, value]) => {
if (key.includes(noInt(remove))) {
const nextSection = state[noInt(key) + (int(key) + 1)];
if (int(key) < int(remove)) newState[key] = value;
else if (nextSection) newState[key] = nextSection;
else newState[key] = null;
}
});
await cacheService.mergeState(request, newState);
let param = "";
if (returnUrl) param = `?returnUrl=${encodeURIComponent(returnUrl)}`;

if (int(this.path) === summaryFiltered.length) {
const newPath = noInt(this.path) + (int(this.path) - 1);
return h.redirect(
`/${this.model.basePath}${newPath}${param}`
);
}
return h.redirect(`/${this.model.basePath}${this.path}${param}`);
}

this.details = summaryFiltered.map((detail) => {
if (returnUrl) return detail;
const currentPath = this.path.replace("/", "");
return {
...detail,
card: detail.items[0].url.replace("summary", currentPath),
};
});
this.returnUrl = returnUrl;
return super.makeGetRouteHandler()(request, h);
};
}

get viewName() {
return "repeating-section-summary";
}
}
import { PageController } from "server/plugins/engine/pageControllers/PageController";
import { FormModel } from "server/plugins/engine/models";
import { FormComponent } from "../components";
import { summaryDetailsTransformationMap } from "../models/SummaryViewModel.detailsTransformationMap";
import {
HapiRequest,
HapiResponseToolkit,
HapiLifecycleMethod,
} from "server/types";
import { RepeatingFieldPage } from "@xgovformbuilder/model";
import { clone, reach } from "hoek";

export class RepeatingSectionSummaryPageController extends PageController {
private getRoute!: HapiLifecycleMethod;
private postRoute!: HapiLifecycleMethod;
inputComponent: FormComponent;

constructor(model: FormModel, pageDef: RepeatingFieldPage) {
super(model, pageDef);
const inputComponent = this.components?.items[0];
if (!inputComponent) {
throw Error(
"RepeatingSectionSummaryPageController initialisation failed, no input component was found"
);
}
this.inputComponent = inputComponent as FormComponent;
}

get getRouteHandler() {
this.getRoute ??= this.makeGetRouteHandler();
return this.getRoute;
}

get postRouteHandler() {
this.postRoute ??= this.makePostRouteHandler();
return this.postRoute;
}

/**
* The controller which is used when Page["controller"] is defined as "./pages/summary.js"
*/

/**
* Returns an async function. This is called in plugin.ts when there is a GET request at `/{id}/{path*}`,
*/
makeGetRouteHandler() {
return async (request: HapiRequest, h: HapiResponseToolkit) => {
const { cacheService } = request.services([]);
const state = await cacheService.getState(request);

const { removeAtIndex } = request.query;
if (removeAtIndex ?? false) {
return this.removeAtIndex(request, h);
}

const viewModel = this.getViewModel(state, request.query.error);
return h.view("repeating-section-summary", viewModel);
};
}

getViewModel(formData, error) {
const baseViewModel = super.getViewModel(formData);
const path = ["", this.inputComponent.name].filter(Boolean).join(".");
const answers = reach(formData, path)?.repeatingSections;
const cards = Array.isArray(answers) && this.getCardsFromAnswers(answers);
const errorMsg = "Select 'yes' to add another";

return {
...baseViewModel,
details: cards,
errors: error && {
titleText: "There is a problem",
errorList: [
{ path: "next", href: "#next", name: "next", text: errorMsg },
],
},
};
}

getCardsFromAnswers(answers) {
const { title = "", name = "" } = this.inputComponent;

const summaryDetails = answers?.map((section, i) => {
return {
name: `${name}${i + 1}`,
title: `${title} ${i + 1}`,
items: section,
};
});
let transformedDetails = summaryDetails;
const transformDetails =
summaryDetailsTransformationMap[this.model.basePath];
if (transformDetails) {
try {
transformedDetails = transformDetails(clone(summaryDetails));
} catch (err) {}
}

return transformedDetails.map((section) => {
return {
name: section.name,
title: section.title,
rows: section.items.map((item) => {
return {
key: { text: item.label },
value: { text: item.value },
actions: {},
};
}),
};
});
}

async removeAtIndex(request, h) {
const { query } = request;
const { removeAtIndex } = query;
const { cacheService } = request.services([]);
let state = await cacheService.getState(request);
const key = this.inputComponent.name;
const answers = state[key]?.repeatingSections;
answers?.splice(removeAtIndex, 1);
await cacheService.mergeState(request, {
[key]: { repeatingSections: answers },
});
return h.redirect("?removed=true");
}

/**
* Returns an async function. This is called in plugin.ts when there is a POST request at `/{id}/{path*}`.
* If a form is incomplete, a user will be redirected to the start page.
*/
makePostRouteHandler() {
return async (request: HapiRequest, h: HapiResponseToolkit) => {
if (!request.payload?.next) {
return h.redirect("?error=true");
} else if (request.payload?.next === "increment") {
const newObj = {};
const sliceEnd = 0 - "Container".length;
const sectionToEmpty = this.inputComponent.name.slice(0, sliceEnd);
newObj[sectionToEmpty] = null;
const { cacheService } = request.services([]);

await cacheService.mergeState(request, newObj);
const state = await cacheService.getState(request);
const progress = state.progress || [];
return h.redirect(progress[progress.length - 1]);
}

return h.redirect(this.getNext(request.payload));
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { MiniSummaryPageController } from "./MiniSummaryPageController";
import { Page } from "@xgovformbuilder/model";
import { UploadPageController } from "server/plugins/engine/pageControllers/UploadPageController";
import { MultiStartPageController } from "server/plugins/engine/pageControllers/MultiStartPageController";
import { RepeatingSectionPageController } from "./RepeatingSectionPageController";
import { RepeatingSectionSummaryPageController } from "./RepeatingSectionSummaryPageController";

const PageControllers = {
Expand Down
1 change: 1 addition & 0 deletions runner/src/server/plugins/engine/pageControllers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ export { StartPageController } from "./StartPageController";
export { SummaryPageController } from "./SummaryPageController";
export { PageControllerBase } from "./PageControllerBase";
export { MiniSummaryPageController } from "./MiniSummaryPageController";
export { RepeatingSectionPageController } from "./RepeatingSectionPageController";
export { RepeatingSectionSummaryPageController } from "./RepeatingSectionSummaryPageController";
export { getPageController, controllerNameFromPath } from "./helpers";
Loading
Loading