22541 extract breadcrumb route-state helper and align phase22 auth locale test

This commit is contained in:
2026-04-09 09:23:22 +01:00
parent 3cea83b7e1
commit 1c06ef1639
7 changed files with 279 additions and 105 deletions
+22 -50
View File
@@ -12,6 +12,10 @@ import {
setRepresentationCapacity, setRepresentationCapacity,
setRepresentationSubmit setRepresentationSubmit
} from "../store/currentView/action"; } from "../store/currentView/action";
import {
normalizeRouteStateQuery,
resolveSearchResultsHref
} from "../lib/routing/routeState";
const Breadcrumbs = (props) => { const Breadcrumbs = (props) => {
const { const {
currentView, currentView,
@@ -69,9 +73,8 @@ const Breadcrumbs = (props) => {
? 9 ? 9
: ""; : "";
const { va, adv, ads, key } = router.query; const routeState = normalizeRouteStateQuery(router.query);
const isAdv = adv === "true"; const { viewAll, advanced, address, key } = routeState;
const isAds = ads === "true";
const hideServiceNamePaths = [ const hideServiceNamePaths = [
"/myportal/[appealtypes]", "/myportal/[appealtypes]",
@@ -95,55 +98,33 @@ const Breadcrumbs = (props) => {
return labelsByKey[viewKey] || null; return labelsByKey[viewKey] || null;
}; };
const breadcrumbHref = (() => { const breadcrumbHref = resolveSearchResultsHref({
if (va === "true") { query: router.query,
return { hasSession: Boolean(session),
pathname: "/myportal/viewall", includeViewAll: true,
query: { key } fallbackToMyPortalWhenNoFlags: true
}; });
}
const base = session ? "/myportal" : ""; const caseResultsHref = resolveSearchResultsHref({
query: router.query,
if (!va && !adv && !ads) { hasSession: Boolean(session),
return { includeViewAll: false,
pathname: "/myportal" fallbackToMyPortalWhenNoFlags: false
}; });
}
if (adv === "true") {
return {
pathname: `${base}/advancedsearchresults`,
query: router.query
};
}
if (ads === "true") {
return {
pathname: `${base}/addresssearchresults`,
query: router.query
};
}
return {
pathname: `${base}/searchresults`,
query: router.query
};
})();
const breadcrumbLabel = (() => { const breadcrumbLabel = (() => {
if (va === "true") { if (viewAll) {
return getViewAllLabel(key); return getViewAllLabel(key);
} }
const keyedLabel = getViewAllLabel(key); const keyedLabel = getViewAllLabel(key);
if (keyedLabel) return keyedLabel; if (keyedLabel) return keyedLabel;
if (isAdv) { if (advanced) {
return t("common:breadcrumb-advanced-search-results"); return t("common:breadcrumb-advanced-search-results");
} }
if (isAds) { if (address) {
return t("common:breadcrumb-address-search-results"); return t("common:breadcrumb-address-search-results");
} }
@@ -518,16 +499,7 @@ const Breadcrumbs = (props) => {
<> <>
<li className="govuk-breadcrumbs__list-item"> <li className="govuk-breadcrumbs__list-item">
<Link <Link
href={{ href={caseResultsHref}
pathname:
(session ? "/myportal" : "") +
(isAdv
? "/advancedsearchresults"
: isAds
? "/addresssearchresults"
: "/searchresults"),
query: router.query
}}
className="govuk-breadcrumbs__link" className="govuk-breadcrumbs__link"
> >
{breadcrumbLabel} {breadcrumbLabel}
+14 -49
View File
@@ -45,6 +45,7 @@ import {
showRepsEnded, showRepsEnded,
getBilingualText getBilingualText
} from "./summary/utils/helpers"; } from "./summary/utils/helpers";
import { resolveSearchResultsHref } from "../../lib/routing/routeState";
import CaseNoticeBanner from "./caseNoticeBanner"; import CaseNoticeBanner from "./caseNoticeBanner";
import WatchModal from "./watchmodal"; import WatchModal from "./watchmodal";
@@ -324,52 +325,19 @@ const CaseSummary = (props) => {
const zoom = isNaN(parseFloat(siteCoords.latitude)) ? 7 : 12; const zoom = isNaN(parseFloat(siteCoords.latitude)) ? 7 : 12;
const buildSearchHref = (router, session) => { const isDnsRoute =
const { va, adv, ads, key } = router.query; router.pathname === "/dns" ||
router.pathname.startsWith("/dns/") ||
router.pathname === "/myportal/dns" ||
router.pathname.startsWith("/myportal/dns/");
const isDnsRoute = const searchHref = resolveSearchResultsHref({
router.pathname === "/dns" || query: router.query,
router.pathname.startsWith("/dns/") || hasSession: Boolean(session),
router.pathname === "/myportal/dns" || includeViewAll: true,
router.pathname.startsWith("/myportal/dns/"); fallbackToMyPortalWhenNoFlags: true,
isDnsRoute
const base = session ? "/myportal" : ""; });
if (!va && !adv && !ads) {
return {
pathname: "/myportal"
};
}
if (isDnsRoute) {
return {
pathname: `${base}/dnsapplications`
};
}
if (va === "true") {
return {
pathname: "/myportal/viewall",
query: { key }
};
}
if (adv === "true") {
return {
pathname: `${base}/advancedsearchresults`,
query: router.query
};
}
if (ads === "true") {
return {
pathname: `${base}/addresssearchresults`,
query: router.query
};
}
return { pathname: `${base}/searchresults`, query: router.query };
};
let showDetailsBlock = let showDetailsBlock =
showDetails == true ? ( showDetails == true ? (
@@ -1043,10 +1011,7 @@ const CaseSummary = (props) => {
<Link <Link
className="govuk-button govuk-button--secondary" className="govuk-button govuk-button--secondary"
href={buildSearchHref( href={searchHref}
router,
session
)}
> >
{" "} {" "}
{t( {t(
+62
View File
@@ -0,0 +1,62 @@
export const normalizeRouteStateQuery = (query = {}) => {
const viewAll = query?.va === "true";
const advanced = query?.adv === "true";
const address = query?.ads === "true";
return {
viewAll,
advanced,
address,
key: query?.key
};
};
export const resolveSearchResultsHref = ({
query = {},
hasSession = false,
includeViewAll = true,
fallbackToMyPortalWhenNoFlags = false,
isDnsRoute = false
} = {}) => {
const routeState = normalizeRouteStateQuery(query);
const { viewAll, advanced, address, key } = routeState;
const base = hasSession ? "/myportal" : "";
if (fallbackToMyPortalWhenNoFlags && !viewAll && !advanced && !address) {
return {
pathname: "/myportal"
};
}
if (isDnsRoute) {
return {
pathname: `${base}/dnsapplications`
};
}
if (includeViewAll && viewAll) {
return {
pathname: "/myportal/viewall",
query: { key }
};
}
if (advanced) {
return {
pathname: `${base}/advancedsearchresults`,
query
};
}
if (address) {
return {
pathname: `${base}/addresssearchresults`,
query
};
}
return {
pathname: `${base}/searchresults`,
query
};
};
+34
View File
@@ -18,6 +18,40 @@ Follow-ups:
--- ---
### CL-22541: breadcrumb/back-link route-state helper extraction (`va/adv/ads/key`)
date: 2026-04-09
author: Cline
scope: `lib/routing/routeState.js`, `components/breadcrumbs.js`, `components/case/summary.js`, `tests/phase22/{route-state-helper.test.cjs,index.test.cjs}`
type: change
rationale: Action Priority 3 refactor backlog item by extracting duplicated route-state decision logic (`va/adv/ads/key`) into a shared pure helper and reusing it from breadcrumb and case-summary back-link flows to reduce drift/regression risk.
impact: Refactor-only decision-layer consolidation for search/case/myportal navigation; no auth/session/CSP/API contract changes; EN/CY behavior intended unchanged as helper only resolves route targets and preserves existing query forwarding behavior.
status: completed
Summary:
- Created branch from `origin/SIPS-Development`: `22541-breadcrumb-route-state-helper`.
- Added `lib/routing/routeState.js` with:
- `normalizeRouteStateQuery(query)`
- `resolveSearchResultsHref(options)`
- Updated `components/breadcrumbs.js` to consume the shared helper for:
- breadcrumb href resolution (`/myportal/viewall`, advanced/address/default results)
- case-results breadcrumb target on `/case/[ticketnumber]`
- preserving existing label selection behavior via normalized flags.
- Updated `components/case/summary.js` to replace local `buildSearchHref` with shared helper output (`searchHref`), including DNS-route override and no-flag fallback behavior.
- Added table-driven unit coverage in `tests/phase22/route-state-helper.test.cjs` and registered it in `tests/phase22/index.test.cjs`.
Validation:
- `npx eslint lib/routing/routeState.js components/breadcrumbs.js components/case/summary.js tests/phase22/route-state-helper.test.cjs tests/phase22/index.test.cjs` -> pass with 1 pre-existing warning in `components/case/summary.js` (`react-hooks/exhaustive-deps`).
- `node tests/phase22/route-state-helper.test.cjs` -> pass (5/5).
- `node tests/phase22/index.test.cjs` -> fails in existing auth suite (`tests/phase22/auth-redirect-safety.test.cjs`) with `ReferenceError: resolveLocale is not defined` in `pages/api/auth/[...nextauth].js`; unrelated to route-state changes.
Follow-ups:
- If needed, triage/fix the existing phase22 auth redirect test harness failure separately to restore full combined suite execution.
- Expand helper adoption in future slices to other navigation decision call sites if additional duplication emerges.
### 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
+6 -6
View File
@@ -21,7 +21,7 @@ const loadAuthInternals = () => {
source = source.replace(/import[\s\S]*?from\s+"[^"]+";\n?/g, ""); source = source.replace(/import[\s\S]*?from\s+"[^"]+";\n?/g, "");
source = source.replace( source = source.replace(
/export default NextAuthPEDW;\s*$/, /export default NextAuthPEDW;\s*$/,
"module.exports = { appendParamsAndPathToNewUrl, resolveLocale, authOptions, NextAuthPEDW };" "module.exports = { appendParamsAndPathToNewUrl, resolveRequestLocale, authOptions, NextAuthPEDW };"
); );
const context = { const context = {
@@ -68,11 +68,11 @@ const loadAuthInternals = () => {
return context.module.exports; return context.module.exports;
}; };
test("auth/resolveLocale prefers query then body then cookie then default", async () => { test("auth/resolveRequestLocale prefers query then body then cookie then default", async () => {
const mod = loadAuthInternals(); const mod = loadAuthInternals();
assert.strictEqual( assert.strictEqual(
mod.resolveLocale({ mod.resolveRequestLocale({
query: { locale: "cy" }, query: { locale: "cy" },
body: { locale: "en" }, body: { locale: "en" },
cookies: { pedw_locale: "en" } cookies: { pedw_locale: "en" }
@@ -81,7 +81,7 @@ test("auth/resolveLocale prefers query then body then cookie then default", asyn
); );
assert.strictEqual( assert.strictEqual(
mod.resolveLocale({ mod.resolveRequestLocale({
body: { locale: "cy" }, body: { locale: "cy" },
cookies: { pedw_locale: "en" } cookies: { pedw_locale: "en" }
}), }),
@@ -89,13 +89,13 @@ test("auth/resolveLocale prefers query then body then cookie then default", asyn
); );
assert.strictEqual( assert.strictEqual(
mod.resolveLocale({ mod.resolveRequestLocale({
cookies: { pedw_locale: "cy" } cookies: { pedw_locale: "cy" }
}), }),
"cy" "cy"
); );
assert.strictEqual(mod.resolveLocale({}), "en"); assert.strictEqual(mod.resolveRequestLocale({}), "en");
}); });
test("auth/redirect callback keeps relative URLs on same base", async () => { test("auth/redirect callback keeps relative URLs on same base", async () => {
+2
View File
@@ -6,6 +6,7 @@ const runPortalServiceTests = require("./portal-service-behaviour.test.cjs");
const runAuthRedirectSafetyTests = require("./auth-redirect-safety.test.cjs"); const runAuthRedirectSafetyTests = require("./auth-redirect-safety.test.cjs");
const runI18nRouteParityTests = require("./i18n-route-parity.test.cjs"); const runI18nRouteParityTests = require("./i18n-route-parity.test.cjs");
const runAzurestorageHelperTests = require("./azurestorage-helper-behaviour.test.cjs"); const runAzurestorageHelperTests = require("./azurestorage-helper-behaviour.test.cjs");
const runRouteStateHelperTests = require("./route-state-helper.test.cjs");
const run = async () => { const run = async () => {
await runCoreTokenTests(); await runCoreTokenTests();
@@ -16,6 +17,7 @@ const run = async () => {
await runAuthRedirectSafetyTests(); await runAuthRedirectSafetyTests();
await runI18nRouteParityTests(); await runI18nRouteParityTests();
await runAzurestorageHelperTests(); await runAzurestorageHelperTests();
await runRouteStateHelperTests();
console.log("Phase 22 combined suite passed."); console.log("Phase 22 combined suite passed.");
}; };
+139
View File
@@ -0,0 +1,139 @@
const fs = require("fs");
const path = require("path");
const vm = require("vm");
const assert = require("assert");
const rootDir = path.resolve(__dirname, "..", "..");
const loadRouteStateModule = () => {
const filePath = path.join(rootDir, "lib", "routing", "routeState.js");
let source = fs.readFileSync(filePath, "utf8");
source = source.replace(/export const\s+/g, "const ");
source +=
"\nmodule.exports = { normalizeRouteStateQuery, resolveSearchResultsHref };\n";
const context = {
module: { exports: {} },
exports: {},
require
};
vm.runInNewContext(source, context, { filename: filePath });
return context.module.exports;
};
const tests = [];
const test = (name, fn) => tests.push({ name, fn });
test("routing/routeState normalizes va/adv/ads flags and key", async () => {
const mod = loadRouteStateModule();
const normalized = mod.normalizeRouteStateQuery({
va: "true",
adv: "false",
ads: "true",
key: "myCases"
});
assert.deepStrictEqual(JSON.parse(JSON.stringify(normalized)), {
viewAll: true,
advanced: false,
address: true,
key: "myCases"
});
});
test("routing/routeState resolves view-all href when va=true", async () => {
const mod = loadRouteStateModule();
const href = mod.resolveSearchResultsHref({
query: { va: "true", key: "myCases", adv: "true" },
hasSession: true
});
assert.deepStrictEqual(JSON.parse(JSON.stringify(href)), {
pathname: "/myportal/viewall",
query: { key: "myCases" }
});
});
test("routing/routeState resolves advanced/address/default result hrefs", async () => {
const mod = loadRouteStateModule();
const advancedHref = mod.resolveSearchResultsHref({
query: { adv: "true", q: "abc" },
hasSession: false
});
const addressHref = mod.resolveSearchResultsHref({
query: { ads: "true", q: "abc" },
hasSession: true
});
const defaultHref = mod.resolveSearchResultsHref({
query: { q: "abc" },
hasSession: true
});
assert.deepStrictEqual(JSON.parse(JSON.stringify(advancedHref)), {
pathname: "/advancedsearchresults",
query: { adv: "true", q: "abc" }
});
assert.deepStrictEqual(JSON.parse(JSON.stringify(addressHref)), {
pathname: "/myportal/addresssearchresults",
query: { ads: "true", q: "abc" }
});
assert.deepStrictEqual(JSON.parse(JSON.stringify(defaultHref)), {
pathname: "/myportal/searchresults",
query: { q: "abc" }
});
});
test("routing/routeState supports fallback to /myportal when no route flags", async () => {
const mod = loadRouteStateModule();
const href = mod.resolveSearchResultsHref({
query: { q: "abc" },
hasSession: false,
fallbackToMyPortalWhenNoFlags: true
});
assert.deepStrictEqual(JSON.parse(JSON.stringify(href)), {
pathname: "/myportal"
});
});
test("routing/routeState supports DNS route override", async () => {
const mod = loadRouteStateModule();
const href = mod.resolveSearchResultsHref({
query: { adv: "true", q: "abc" },
hasSession: true,
isDnsRoute: true
});
assert.deepStrictEqual(JSON.parse(JSON.stringify(href)), {
pathname: "/myportal/dnsapplications"
});
});
const run = async () => {
let passed = 0;
for (const currentTest of tests) {
await currentTest.fn();
passed += 1;
}
console.log(
`Phase 22 route-state helper tests passed (${passed}/${tests.length}).`
);
};
module.exports = run;
if (require.main === module) {
run().catch((error) => {
console.error(error);
process.exit(1);
});
}