From 452b2127f96794aed8646e6462449c9dc7e3e46c Mon Sep 17 00:00:00 2001 From: David Middleton Date: Mon, 18 Aug 2025 17:14:51 +0100 Subject: [PATCH 1/3] TGC-796: Allow dynamic summaryPath config for back links --- .../engine/models/SummaryViewModel.test.ts | 49 +++++++++++++++++++ .../plugins/engine/models/SummaryViewModel.ts | 17 +++++-- 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/src/server/plugins/engine/models/SummaryViewModel.test.ts b/src/server/plugins/engine/models/SummaryViewModel.test.ts index 32ac820c1..843ebaeb7 100644 --- a/src/server/plugins/engine/models/SummaryViewModel.test.ts +++ b/src/server/plugins/engine/models/SummaryViewModel.test.ts @@ -310,3 +310,52 @@ describe('SummaryPageController', () => { }) }) }) + +describe('SummaryViewModel (summaryPath handling)', () => { + const itemId = 'pizza-001' + let model: FormModel + let basePage: PageControllerClass + let request: FormContextRequest + let context: FormContext + + beforeEach(() => { + model = new FormModel(definition, { basePath }) + basePage = createPage(model, definition.pages[2]) + request = buildFormContextRequest({ + method: 'get', + url: new URL(`${basePath}/summary`, 'http://example.com'), + path: `${basePath}/summary`, + params: { path: 'pizza-order', slug: 'repeat' }, + query: {}, + app: { model } + }) + }) + + it('should use page.getSummaryPath(request) when present', () => { + const summaryPathFromRequest = `${basePath}/custom-summary-path?param=value` + const getSummaryPathMock = jest + .spyOn(basePage, 'getSummaryPath') + .mockImplementation((req?: FormContextRequest) => { + return req ? summaryPathFromRequest : `${basePath}/custom-summary-path` + }) + + context = model.getFormContext(request, { + $$__referenceNumber: 'test1', + orderType: 'delivery', + pizza: [{ toppings: 'Cheese', quantity: 1, itemId }] + }) + + const viewModel = new SummaryViewModel(request, basePage, context) + expect(getSummaryPathMock).toHaveBeenCalledWith(request) + + // SummaryPath should be used as returnPath for ItemField/ItemRepeat + const pizzaSection = viewModel.details.find((x) => x.title === 'Food') ?? { + items: [] + } + const pizzaItem = pizzaSection.items[0] + // Should get correct returnPath in hrefs + expect(pizzaItem.href).toMatch(encodeURIComponent(summaryPathFromRequest)) + + getSummaryPathMock.mockRestore() + }) +}) diff --git a/src/server/plugins/engine/models/SummaryViewModel.ts b/src/server/plugins/engine/models/SummaryViewModel.ts index 6af914a90..a3244e13c 100644 --- a/src/server/plugins/engine/models/SummaryViewModel.ts +++ b/src/server/plugins/engine/models/SummaryViewModel.ts @@ -119,6 +119,8 @@ export class SummaryViewModel { const { context, errors } = this const { relevantPages, state } = context + const summaryPath = this.page.getSummaryPath(request) + const details: Detail[] = [] ;[undefined, ...sections].forEach((section) => { @@ -135,12 +137,19 @@ export class SummaryViewModel { items.push( ItemRepeat(page, state, { path: page.getSummaryPath(request), + returnPath: summaryPath, errors }) ) } else { for (const field of collection.fields) { - items.push(ItemField(page, state, field, { path, errors })) + items.push( + ItemField(page, state, field, { + path, + returnPath: summaryPath, + errors + }) + ) } } }) @@ -167,6 +176,7 @@ function ItemRepeat( state: FormState, options: { path: string + returnPath: string | undefined errors?: FormSubmissionError[] } ): DetailItemRepeat { @@ -182,7 +192,7 @@ function ItemRepeat( title: values.length ? `${unit} added` : unit, value: values.length ? `You added ${values.length} ${unit}` : '', href: getPageHref(page, options.path, { - returnUrl: getPageHref(page, page.getSummaryPath()) + returnUrl: getPageHref(page, options.returnPath ?? page.getSummaryPath()) }), state, page, @@ -206,6 +216,7 @@ function ItemField( field: Field, options: { path: string + returnPath: string | undefined errors?: FormSubmissionError[] } ): DetailItemField { @@ -216,7 +227,7 @@ function ItemField( error: field.getFirstError(options.errors), value: getAnswer(field, state), href: getPageHref(page, options.path, { - returnUrl: getPageHref(page, page.getSummaryPath()) + returnUrl: getPageHref(page, options.returnPath ?? page.getSummaryPath()) }), state, page, From 11e2f077dbd11d1b5d130d8f15dc5f0ac71e324a Mon Sep 17 00:00:00 2001 From: David Middleton Date: Tue, 19 Aug 2025 08:27:43 +0100 Subject: [PATCH 2/3] Update coverage --- .../engine/models/SummaryViewModel.test.ts | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/src/server/plugins/engine/models/SummaryViewModel.test.ts b/src/server/plugins/engine/models/SummaryViewModel.test.ts index 843ebaeb7..51319df7b 100644 --- a/src/server/plugins/engine/models/SummaryViewModel.test.ts +++ b/src/server/plugins/engine/models/SummaryViewModel.test.ts @@ -3,6 +3,7 @@ import { FormModel, SummaryViewModel } from '~/src/server/plugins/engine/models/index.js' +import { type DetailItem } from '~/src/server/plugins/engine/models/types.js' import { SummaryPageController } from '~/src/server/plugins/engine/pageControllers/SummaryPageController.js' import { buildFormContextRequest } from '~/src/server/plugins/engine/pageControllers/__stubs__/request.js' import { serverWithSaveAndReturn } from '~/src/server/plugins/engine/pageControllers/__stubs__/server.js' @@ -358,4 +359,64 @@ describe('SummaryViewModel (summaryPath handling)', () => { getSummaryPathMock.mockRestore() }) + + it('should use summaryPath as returnUrl for ItemField hrefs (non-repeat pages)', () => { + const summaryPathFromRequest = `${basePath}/custom-summary-path?param=value` + const getSummaryPathMock = jest + .spyOn(basePage, 'getSummaryPath') + .mockImplementation((req?: FormContextRequest) => { + return req ? summaryPathFromRequest : `${basePath}/custom-summary-path` + }) + + context = model.getFormContext(request, { + $$__referenceNumber: 'test2', + orderType: 'delivery', + pizza: [{ toppings: 'Cheese', quantity: 1, itemId }] + }) + + const viewModel = new SummaryViewModel(request, basePage, context) + + // First summary section contains a non-repeat ItemField (delivery-or-collection) + const firstSection = viewModel.details[0] + const firstItem = firstSection.items[0] + + expect(firstItem.href).toContain(encodeURIComponent(summaryPathFromRequest)) + + getSummaryPathMock.mockRestore() + }) + + it('should use summaryPath as returnUrl for ItemRepeat subItems hrefs', () => { + const summaryPathFromRequest = `${basePath}/custom-summary-path?param=value` + const getSummaryPathMock = jest + .spyOn(basePage, 'getSummaryPath') + .mockImplementation((req?: FormContextRequest) => { + return req ? summaryPathFromRequest : `${basePath}/custom-summary-path` + }) + + context = model.getFormContext(request, { + $$__referenceNumber: 'test3', + orderType: 'delivery', + pizza: [ + { toppings: 'Cheese', quantity: 1, itemId }, + { toppings: 'Ham', quantity: 2, itemId: 'pizza-002' } + ] + }) + + const viewModel = new SummaryViewModel(request, basePage, context) + + // Find the repeat item (Food section) + const pizzaSection = viewModel.details.find((x) => x.title === 'Food') + const repeatItem: DetailItem | undefined = pizzaSection?.items[0] + + // subItems is an array of arrays of ItemField; flatten and check all hrefs + const subItems = ( + repeatItem && 'subItems' in repeatItem ? repeatItem.subItems : [] + ).flat() + expect(subItems.length).toBeGreaterThan(0) + for (const subItem of subItems) { + expect(subItem.href).toContain(encodeURIComponent(summaryPathFromRequest)) + } + + getSummaryPathMock.mockRestore() + }) }) From ec779702e50a8330fd537b6c3e79d965ad260dc9 Mon Sep 17 00:00:00 2001 From: David Middleton Date: Tue, 19 Aug 2025 11:09:34 +0100 Subject: [PATCH 3/3] Update coverage --- .../engine/models/SummaryViewModel.test.ts | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/src/server/plugins/engine/models/SummaryViewModel.test.ts b/src/server/plugins/engine/models/SummaryViewModel.test.ts index 51319df7b..a29115af8 100644 --- a/src/server/plugins/engine/models/SummaryViewModel.test.ts +++ b/src/server/plugins/engine/models/SummaryViewModel.test.ts @@ -420,3 +420,90 @@ describe('SummaryViewModel (summaryPath handling)', () => { getSummaryPathMock.mockRestore() }) }) + +describe('SummaryViewModel (summaryPath fallback)', () => { + const itemId = 'pizza-001' + let model: FormModel + let basePage: PageControllerClass + let request: FormContextRequest + let context: FormContext + + beforeEach(() => { + model = new FormModel(definition, { basePath }) + basePage = createPage(model, definition.pages[2]) + request = buildFormContextRequest({ + method: 'get', + url: new URL(`${basePath}/summary`, 'http://example.com'), + path: `${basePath}/summary`, + params: { path: 'pizza-order', slug: 'repeat' }, + query: {}, + app: { model } + }) + }) + + it('should fallback to page.getSummaryPath() when returnPath is undefined for ItemField', () => { + // Force summaryPath from request to be undefined so items must fall back to their own page.getSummaryPath() + const getSummaryPathMock = jest + .spyOn(basePage, 'getSummaryPath') + .mockImplementation((req?: FormContextRequest) => { + return req ? (undefined as unknown as string) : `${basePath}/summary` + }) + + context = model.getFormContext(request, { + $$__referenceNumber: 'test-fallback-1', + orderType: 'delivery', + pizza: [{ toppings: 'Cheese', quantity: 1, itemId }] + }) + + const viewModel = new SummaryViewModel(request, basePage, context) + + // First summary section contains a non-repeat ItemField + const firstSection = viewModel.details[0] + const firstItem = firstSection.items[0] + + // Should include default summary path in returnUrl + expect(firstItem.href).toContain(encodeURIComponent(`${basePath}/summary`)) + + getSummaryPathMock.mockRestore() + }) + + it('should fallback to page.getSummaryPath() when returnPath is undefined for ItemRepeat and subItems', () => { + // Force summaryPath from request to be undefined so items must fall back to their own page.getSummaryPath() + const getSummaryPathMock = jest + .spyOn(basePage, 'getSummaryPath') + .mockImplementation((req?: FormContextRequest) => { + return req ? (undefined as unknown as string) : `${basePath}/summary` + }) + + context = model.getFormContext(request, { + $$__referenceNumber: 'test-fallback-2', + orderType: 'delivery', + pizza: [ + { toppings: 'Cheese', quantity: 1, itemId }, + { toppings: 'Ham', quantity: 2, itemId: 'pizza-002' } + ] + }) + + const viewModel = new SummaryViewModel(request, basePage, context) + + // Repeat section (Food) + const pizzaSection = viewModel.details.find((x) => x.title === 'Food') + const repeatItem: DetailItem | undefined = pizzaSection?.items[0] + + expect(repeatItem?.href ?? '').toContain( + encodeURIComponent(`${basePath}/summary`) + ) + + // subItems is an array of arrays of ItemField; flatten and check all hrefs + const subItems = ( + repeatItem && 'subItems' in repeatItem ? repeatItem.subItems : [] + ).flat() + + expect(subItems.length).toBeGreaterThan(0) + for (const subItem of subItems) { + expect(subItem.href).toContain(encodeURIComponent(`${basePath}/summary`)) + } + + getSummaryPathMock.mockRestore() + }) +})