refactor(breadcrumbs): map step-back route branches into renderer group

This commit is contained in:
2026-04-09 15:03:19 +01:00
parent 0b905f9374
commit cebd47bf1c
6 changed files with 163 additions and 48 deletions
+11 -40
View File
@@ -215,6 +215,7 @@ const Breadcrumbs = (props) => {
simpleMyPortalRouteRenderersByPath, simpleMyPortalRouteRenderersByPath,
newAppealRouteRenderersByPath, newAppealRouteRenderersByPath,
callbackRouteRenderersByPath, callbackRouteRenderersByPath,
stepBackRouteRenderersByPath,
caseDetailRouteRenderersByPath, caseDetailRouteRenderersByPath,
detailAndAccountRouteRenderersByPath detailAndAccountRouteRenderersByPath
} = createMappedRouteRendererGroups({ } = createMappedRouteRendererGroups({
@@ -234,13 +235,21 @@ const Breadcrumbs = (props) => {
onBack: () => { onBack: () => {
router.back(); router.back();
}, },
currentSection: appealType.currentSection,
onStepBack: () => {
setCurrentSection(appealType.currentSection - 1);
},
onStepBackWithInlineClass: () => {
setCurrentSection(appealType.currentSection - 1);
},
renderAnchorCrumb, renderAnchorCrumb,
renderLinkCrumb, renderLinkCrumb,
renderTextCrumb, renderTextCrumb,
renderCaseReferenceCrumb, renderCaseReferenceCrumb,
renderMyPortalCrumb, renderMyPortalCrumb,
renderMyPortalSectionCrumbs, renderMyPortalSectionCrumbs,
renderDnsCaseReferenceCrumbs renderDnsCaseReferenceCrumbs,
renderBackCrumb
}); });
const mappedRouteRendererMaps = buildBreadcrumbRendererMaps({ const mappedRouteRendererMaps = buildBreadcrumbRendererMaps({
@@ -249,6 +258,7 @@ const Breadcrumbs = (props) => {
simpleMyPortalRouteRenderersByPath, simpleMyPortalRouteRenderersByPath,
newAppealRouteRenderersByPath, newAppealRouteRenderersByPath,
callbackRouteRenderersByPath, callbackRouteRenderersByPath,
stepBackRouteRenderersByPath,
caseDetailRouteRenderersByPath, caseDetailRouteRenderersByPath,
detailAndAccountRouteRenderersByPath detailAndAccountRouteRenderersByPath
}); });
@@ -274,45 +284,6 @@ const Breadcrumbs = (props) => {
{isPath("/newappeal") && <></>} {isPath("/newappeal") && <></>}
{isPath("/newappeal/[appealtypes]") && (
<>
{appealType.currentSection > 1 &&
(appealType.currentSection === 9999
? renderLinkCrumb(
"/",
t(
"common:service-name-breadcrumb"
)
)
: renderBackCrumb(
() =>
setCurrentSection(
appealType.currentSection -
1
),
"govuk-breadcrumbs__link-item"
))}
</>
)}
{isPath("/myportal/[appealtypes]") && (
<>
{appealType.currentSection > 1 &&
(appealType.currentSection === 9999
? renderLinkCrumb(
"/",
t(
"common:service-name-breadcrumb"
)
)
: renderBackCrumb(() =>
setCurrentSection(
appealType.currentSection - 1
)
))}
</>
)}
{isPath("/myportal/representation") && ( {isPath("/myportal/representation") && (
<> <>
{currentView.representationSubmit === true && {currentView.representationSubmit === true &&
+51 -1
View File
@@ -167,6 +167,42 @@ export const createCallbackRouteRenderers = ({
) )
}); });
export const createStepBackRouteRenderers = ({
t,
currentSection,
onStepBack,
onStepBackWithInlineClass,
renderLinkCrumb,
renderBackCrumb
}) => ({
"/newappeal/[appealtypes]": () => {
if (currentSection <= 1) {
return null;
}
if (currentSection === 9999) {
return renderLinkCrumb("/", t("common:service-name-breadcrumb"));
}
return renderBackCrumb(
onStepBackWithInlineClass,
"govuk-breadcrumbs__link-item"
);
},
"/myportal/[appealtypes]": () => {
if (currentSection <= 1) {
return null;
}
if (currentSection === 9999) {
return renderLinkCrumb("/", t("common:service-name-breadcrumb"));
}
return renderBackCrumb(onStepBack);
}
});
export const createCaseDetailRouteRenderers = ({ export const createCaseDetailRouteRenderers = ({
t, t,
router, router,
@@ -418,13 +454,17 @@ export const createMappedRouteRendererGroups = ({
nestedSearchString, nestedSearchString,
caseReferenceDisplay, caseReferenceDisplay,
onBack, onBack,
currentSection,
onStepBack,
onStepBackWithInlineClass,
renderAnchorCrumb, renderAnchorCrumb,
renderLinkCrumb, renderLinkCrumb,
renderTextCrumb, renderTextCrumb,
renderCaseReferenceCrumb, renderCaseReferenceCrumb,
renderMyPortalCrumb, renderMyPortalCrumb,
renderMyPortalSectionCrumbs, renderMyPortalSectionCrumbs,
renderDnsCaseReferenceCrumbs renderDnsCaseReferenceCrumbs,
renderBackCrumb
}) => { }) => {
const simpleRouteRenderersByPath = createSimpleRouteRenderers({ const simpleRouteRenderersByPath = createSimpleRouteRenderers({
t, t,
@@ -476,6 +516,15 @@ export const createMappedRouteRendererGroups = ({
onBack onBack
}); });
const stepBackRouteRenderersByPath = createStepBackRouteRenderers({
t,
currentSection,
onStepBack,
onStepBackWithInlineClass,
renderLinkCrumb,
renderBackCrumb
});
const caseDetailRouteRenderersByPath = createCaseDetailRouteRenderers({ const caseDetailRouteRenderersByPath = createCaseDetailRouteRenderers({
t, t,
router, router,
@@ -512,6 +561,7 @@ export const createMappedRouteRendererGroups = ({
simpleMyPortalRouteRenderersByPath, simpleMyPortalRouteRenderersByPath,
newAppealRouteRenderersByPath, newAppealRouteRenderersByPath,
callbackRouteRenderersByPath, callbackRouteRenderersByPath,
stepBackRouteRenderersByPath,
caseDetailRouteRenderersByPath, caseDetailRouteRenderersByPath,
detailAndAccountRouteRenderersByPath detailAndAccountRouteRenderersByPath
}; };
+2
View File
@@ -24,6 +24,7 @@ const buildBreadcrumbRendererMaps = ({
simpleMyPortalRouteRenderersByPath, simpleMyPortalRouteRenderersByPath,
newAppealRouteRenderersByPath, newAppealRouteRenderersByPath,
callbackRouteRenderersByPath, callbackRouteRenderersByPath,
stepBackRouteRenderersByPath,
caseDetailRouteRenderersByPath, caseDetailRouteRenderersByPath,
detailAndAccountRouteRenderersByPath detailAndAccountRouteRenderersByPath
}) => [ }) => [
@@ -32,6 +33,7 @@ const buildBreadcrumbRendererMaps = ({
simpleMyPortalRouteRenderersByPath, simpleMyPortalRouteRenderersByPath,
newAppealRouteRenderersByPath, newAppealRouteRenderersByPath,
callbackRouteRenderersByPath, callbackRouteRenderersByPath,
stepBackRouteRenderersByPath,
caseDetailRouteRenderersByPath, caseDetailRouteRenderersByPath,
detailAndAccountRouteRenderersByPath detailAndAccountRouteRenderersByPath
]; ];
+45
View File
@@ -709,6 +709,51 @@ Follow-ups:
- Next smaller slice candidate: add a focused helper test for empty-string/whitespace path lookups to assert strict null behavior for non-exact keys. - Next smaller slice candidate: add a focused helper test for empty-string/whitespace path lookups to assert strict null behavior for non-exact keys.
- Next larger slice candidate: evaluate whether `/newappeal/[appealtypes]` and `/myportal/[appealtypes]` back-link branches can be extracted with explicit callback/setter injection while preserving state-step semantics. - Next larger slice candidate: evaluate whether `/newappeal/[appealtypes]` and `/myportal/[appealtypes]` back-link branches can be extracted with explicit callback/setter injection while preserving state-step semantics.
### CL-22541-W: breadcrumbs larger slice — step-back route-group extraction (`/newappeal/[appealtypes]`, `/myportal/[appealtypes]`)
date: 2026-04-09
author: Cline
scope: `lib/routing/{breadcrumbRendererFactories,breadcrumbRouteMaps}.js`, `components/breadcrumbs.js`, `tests/phase22/{breadcrumb-route-maps-helper,breadcrumbs-route-map-structure}.test.cjs`
type: change
rationale: Execute the next larger extraction slice by moving step-based back-link branches into a dedicated mapped route group with explicit setter callback injection, preserving section-navigation semantics.
impact: Structural refactor with preserved back-link and step-state behavior; no intended route/auth/session/API/EN-CY/a11y behavior change.
status: completed
Summary:
- Added `createStepBackRouteRenderers(...)` in `lib/routing/breadcrumbRendererFactories.js` to map:
- `/newappeal/[appealtypes]`
- `/myportal/[appealtypes]`
- Preserved existing step semantics in mapped handlers:
- no crumb when `currentSection <= 1`
- service-name crumb when `currentSection === 9999`
- back-link crumb with existing class parity (`govuk-breadcrumbs__link-item` for new-appeal flow)
- Introduced explicit injected handlers (`onStepBack`, `onStepBackWithInlineClass`) and injected crumb renderer dependency (`renderBackCrumb`) into grouped factory composition.
- Extended map builder in `lib/routing/breadcrumbRouteMaps.js` with `stepBackRouteRenderersByPath` and updated precedence order:
- simple -> link-text -> myportal -> new-appeal -> callback -> step-back -> case detail -> detail/account.
- Updated `components/breadcrumbs.js`:
- pass `currentSection` and step-back callbacks into `createMappedRouteRendererGroups(...)`
- include step-back map in `buildBreadcrumbRendererMaps(...)`
- remove inline `isPath("/newappeal/[appealtypes]")` and `isPath("/myportal/[appealtypes]")` branches.
- Expanded tests:
- `tests/phase22/breadcrumb-route-maps-helper.test.cjs`
- updated grouped map count/order to include step-back map (8 total)
- `tests/phase22/breadcrumbs-route-map-structure.test.cjs`
- assert step-back factory export + mapped route presence
- assert grouped destructuring includes step-back map
- assert inline step-back branches are removed
- assert updated map-order invariant includes step-back group.
Validation:
- `npx eslint lib/routing/breadcrumbRendererFactories.js lib/routing/breadcrumbRouteMaps.js components/breadcrumbs.js tests/phase22/breadcrumb-route-maps-helper.test.cjs tests/phase22/breadcrumbs-route-map-structure.test.cjs tests/phase22/index.test.cjs` -> pass.
- `node tests/phase22/index.test.cjs` -> pass (combined suite).
Follow-ups:
- Next smaller slice candidate: add focused helper test for strict null behavior on empty-string/whitespace/non-exact path keys.
- Next larger slice candidate: evaluate extractability of `/myportal/representation` back-link states via explicit callback injection, only if questionnaire/submit side-effects remain parity-safe and readable.
### CL-00X: 22500 `components/elements/index.js` Phase 1 helper extraction ### CL-00X: 22500 `components/elements/index.js` Phase 1 helper extraction
date: 2026-04-07 date: 2026-04-07
@@ -63,6 +63,9 @@ test("buildBreadcrumbRendererMaps returns grouped maps in explicit precedence or
const callbackRouteRenderersByPath = { const callbackRouteRenderersByPath = {
"/theta": () => "theta" "/theta": () => "theta"
}; };
const stepBackRouteRenderersByPath = {
"/iota": () => "iota"
};
const caseDetailRouteRenderersByPath = { const caseDetailRouteRenderersByPath = {
"/delta": () => "delta" "/delta": () => "delta"
}; };
@@ -76,12 +79,13 @@ test("buildBreadcrumbRendererMaps returns grouped maps in explicit precedence or
simpleMyPortalRouteRenderersByPath, simpleMyPortalRouteRenderersByPath,
newAppealRouteRenderersByPath, newAppealRouteRenderersByPath,
callbackRouteRenderersByPath, callbackRouteRenderersByPath,
stepBackRouteRenderersByPath,
caseDetailRouteRenderersByPath, caseDetailRouteRenderersByPath,
detailAndAccountRouteRenderersByPath detailAndAccountRouteRenderersByPath
}); });
assert.ok(Array.isArray(result), "Expected grouped maps array"); assert.ok(Array.isArray(result), "Expected grouped maps array");
assert.strictEqual(result.length, 7, "Expected seven grouped route maps"); assert.strictEqual(result.length, 8, "Expected eight grouped route maps");
assert.strictEqual( assert.strictEqual(
result[0], result[0],
simpleRouteRenderersByPath, simpleRouteRenderersByPath,
@@ -109,13 +113,18 @@ test("buildBreadcrumbRendererMaps returns grouped maps in explicit precedence or
); );
assert.strictEqual( assert.strictEqual(
result[5], result[5],
caseDetailRouteRenderersByPath, stepBackRouteRenderersByPath,
"Expected case detail route renderers sixth" "Expected step-back route renderers sixth"
); );
assert.strictEqual( assert.strictEqual(
result[6], result[6],
caseDetailRouteRenderersByPath,
"Expected case detail route renderers seventh"
);
assert.strictEqual(
result[7],
detailAndAccountRouteRenderersByPath, detailAndAccountRouteRenderersByPath,
"Expected detail and account route renderers seventh" "Expected detail and account route renderers eighth"
); );
}); });
@@ -136,6 +145,7 @@ test("resolveMappedRouteRenderer returns renderer from first matching grouped ma
simpleMyPortalRouteRenderersByPath: {}, simpleMyPortalRouteRenderersByPath: {},
newAppealRouteRenderersByPath: {}, newAppealRouteRenderersByPath: {},
callbackRouteRenderersByPath: {}, callbackRouteRenderersByPath: {},
stepBackRouteRenderersByPath: {},
caseDetailRouteRenderersByPath: {}, caseDetailRouteRenderersByPath: {},
detailAndAccountRouteRenderersByPath: {} detailAndAccountRouteRenderersByPath: {}
}); });
@@ -164,6 +174,7 @@ test("resolveMappedRouteRenderer returns renderer from later grouped map when ea
simpleMyPortalRouteRenderersByPath: {}, simpleMyPortalRouteRenderersByPath: {},
caseDetailRouteRenderersByPath: {}, caseDetailRouteRenderersByPath: {},
callbackRouteRenderersByPath: {}, callbackRouteRenderersByPath: {},
stepBackRouteRenderersByPath: {},
detailAndAccountRouteRenderersByPath: { detailAndAccountRouteRenderersByPath: {
"/account/personaldetails": detailRenderer "/account/personaldetails": detailRenderer
} }
@@ -190,6 +201,7 @@ test("resolveMappedRouteRenderer returns null for unmapped path", () => {
simpleMyPortalRouteRenderersByPath: {}, simpleMyPortalRouteRenderersByPath: {},
newAppealRouteRenderersByPath: {}, newAppealRouteRenderersByPath: {},
callbackRouteRenderersByPath: {}, callbackRouteRenderersByPath: {},
stepBackRouteRenderersByPath: {},
caseDetailRouteRenderersByPath: {}, caseDetailRouteRenderersByPath: {},
detailAndAccountRouteRenderersByPath: {} detailAndAccountRouteRenderersByPath: {}
}); });
@@ -219,6 +231,7 @@ test("resolveMappedRouteRenderer skips invalid map entries and non-function rend
}, },
newAppealRouteRenderersByPath: undefined, newAppealRouteRenderersByPath: undefined,
callbackRouteRenderersByPath: {}, callbackRouteRenderersByPath: {},
stepBackRouteRenderersByPath: {},
caseDetailRouteRenderersByPath: {}, caseDetailRouteRenderersByPath: {},
detailAndAccountRouteRenderersByPath: {} detailAndAccountRouteRenderersByPath: {}
}); });
@@ -48,6 +48,12 @@ test("breadcrumbs/factory module includes expected mapped route definitions", as
"Expected callback route renderer factory export" "Expected callback route renderer factory export"
); );
assert.strictEqual(
source.includes("export const createStepBackRouteRenderers ="),
true,
"Expected step-back route renderer factory export"
);
assert.strictEqual( assert.strictEqual(
source.includes("export const createCaseDetailRouteRenderers ="), source.includes("export const createCaseDetailRouteRenderers ="),
true, true,
@@ -114,6 +120,18 @@ test("breadcrumbs/factory module includes expected mapped route definitions", as
"Expected /case to be mapped in createCallbackRouteRenderers" "Expected /case to be mapped in createCallbackRouteRenderers"
); );
assert.strictEqual(
source.includes('"/newappeal/[appealtypes]": () => {'),
true,
"Expected /newappeal/[appealtypes] to be mapped in createStepBackRouteRenderers"
);
assert.strictEqual(
source.includes('"/myportal/[appealtypes]": () => {'),
true,
"Expected /myportal/[appealtypes] to be mapped in createStepBackRouteRenderers"
);
assert.strictEqual( assert.strictEqual(
source.includes('"/case/[ticketnumber]": () =>'), source.includes('"/case/[ticketnumber]": () =>'),
true, true,
@@ -203,7 +221,7 @@ test("breadcrumbs/component composes mapped routes via imported factories and sh
); );
assert.strictEqual( assert.strictEqual(
/const\s+\{[\s\S]*simpleRouteRenderersByPath[\s\S]*newAppealRouteRenderersByPath[\s\S]*callbackRouteRenderersByPath[\s\S]*detailAndAccountRouteRenderersByPath[\s\S]*\}\s*=\s*createMappedRouteRendererGroups\s*\(/.test( /const\s+\{[\s\S]*simpleRouteRenderersByPath[\s\S]*newAppealRouteRenderersByPath[\s\S]*callbackRouteRenderersByPath[\s\S]*stepBackRouteRenderersByPath[\s\S]*detailAndAccountRouteRenderersByPath[\s\S]*\}\s*=\s*createMappedRouteRendererGroups\s*\(/.test(
source source
), ),
true, true,
@@ -290,6 +308,18 @@ test("breadcrumbs/component composes mapped routes via imported factories and sh
"Expected /newappeal/selectappeal explicit branch to be removed after mapping" "Expected /newappeal/selectappeal explicit branch to be removed after mapping"
); );
assert.strictEqual(
source.includes('isPath("/newappeal/[appealtypes]")'),
false,
"Expected /newappeal/[appealtypes] explicit branch to be removed after step-back mapping"
);
assert.strictEqual(
source.includes('isPath("/myportal/[appealtypes]")'),
false,
"Expected /myportal/[appealtypes] explicit branch to be removed after step-back mapping"
);
assert.strictEqual( assert.strictEqual(
source.includes('isPath("/myportal/case/[ticketnumber]")'), source.includes('isPath("/myportal/case/[ticketnumber]")'),
false, false,
@@ -372,6 +402,9 @@ test("breadcrumbs/map builder preserves route map ordering", async () => {
const callbackOrderIndex = mapBuilderSlice.indexOf( const callbackOrderIndex = mapBuilderSlice.indexOf(
"callbackRouteRenderersByPath" "callbackRouteRenderersByPath"
); );
const stepBackOrderIndex = mapBuilderSlice.indexOf(
"stepBackRouteRenderersByPath"
);
const caseDetailOrderIndex = mapBuilderSlice.indexOf( const caseDetailOrderIndex = mapBuilderSlice.indexOf(
"caseDetailRouteRenderersByPath" "caseDetailRouteRenderersByPath"
); );
@@ -385,10 +418,11 @@ test("breadcrumbs/map builder preserves route map ordering", async () => {
simpleMyPortalOrderIndex > simpleLinkTextPairOrderIndex && simpleMyPortalOrderIndex > simpleLinkTextPairOrderIndex &&
newAppealOrderIndex > simpleMyPortalOrderIndex && newAppealOrderIndex > simpleMyPortalOrderIndex &&
callbackOrderIndex > newAppealOrderIndex && callbackOrderIndex > newAppealOrderIndex &&
caseDetailOrderIndex > callbackOrderIndex && stepBackOrderIndex > callbackOrderIndex &&
caseDetailOrderIndex > stepBackOrderIndex &&
detailAndAccountOrderIndex > caseDetailOrderIndex, detailAndAccountOrderIndex > caseDetailOrderIndex,
true, true,
"Expected map builder call to preserve explicit route-map ordering (simple -> link-text -> myportal -> new-appeal -> callback -> case detail -> detail/account)" "Expected map builder call to preserve explicit route-map ordering (simple -> link-text -> myportal -> new-appeal -> callback -> step-back -> case detail -> detail/account)"
); );
}); });