From 062cd2346354a258a3a1cf5861cd4244dc0c1eba Mon Sep 17 00:00:00 2001 From: robbond Date: Thu, 9 Apr 2026 12:08:36 +0100 Subject: [PATCH] Refactor breadcrumbs to use factory-based route renderers --- components/breadcrumbs.js | 266 ++++----- i18n.js | 2 +- lib/routing/breadcrumbRendererFactories.js | 107 ++++ lib/routing/routeState.js | 8 + .../breadcrumb-route-maps-helper.test.cjs | 169 ++++-- .../breadcrumbs-route-map-structure.test.cjs | 128 ++++- tests/phase22/route-state-helper.test.cjs | 515 ++++++++++++------ 7 files changed, 813 insertions(+), 382 deletions(-) create mode 100644 lib/routing/breadcrumbRendererFactories.js diff --git a/components/breadcrumbs.js b/components/breadcrumbs.js index 591224b2..bf3e3909 100644 --- a/components/breadcrumbs.js +++ b/components/breadcrumbs.js @@ -17,6 +17,12 @@ import { buildBreadcrumbRendererMaps, resolveMappedRouteRenderer as resolveMappedRouteRendererByMaps } from "../lib/routing/breadcrumbRouteMaps"; +import { + createSimpleRouteRenderers, + createSimpleLinkTextPairRenderers, + createSimpleMyPortalRouteRenderers +} from "../lib/routing/breadcrumbRendererFactories"; + const Breadcrumbs = (props) => { const { currentView, @@ -98,133 +104,27 @@ const Breadcrumbs = (props) => { const getStaticTextCrumbLabel = (path) => { const staticTextCrumbByPath = { - "/account/register": t("account:register-new-account-heading"), - "/dns": t("dnsCommon:service-name"), - "/dnsapplications": t("dnsCommon:service-name"), - "/dns/application-process": "Guidance", - "/dns/help": "Help", - "/dns/contact-us": t("common:footer-contact-us-link-label"), - "/dns/applications": t("dnsApplications:page-title"), - "/help/cookies": t("cookies:cookie-breadcrumb"), - "/privacy": t("common:footer-privacy-link-label"), - "/accessibility": t("common:footer-accessibility-link-label"), - "/details-about-cookies": t("cookies:cookie-policy-title-heading"), - "/auth/signin": t("auth:auth-page-title"), - "/auth/verify-request": t("auth:auth-check-email-page-title"), - "/auth/error": t("auth:auth-error-signin-error-title") + "/account/register": () => + t("account:register-new-account-heading"), + "/dns": () => t("dnsCommon:service-name"), + "/dnsapplications": () => t("dnsCommon:service-name"), + "/dns/application-process": () => "Guidance", + "/dns/help": () => "Help", + "/dns/contact-us": () => t("common:footer-contact-us-link-label"), + "/dns/applications": () => t("dnsApplications:page-title"), + "/help/cookies": () => t("cookies:cookie-breadcrumb"), + "/privacy": () => t("common:footer-privacy-link-label"), + "/accessibility": () => t("common:footer-accessibility-link-label"), + "/details-about-cookies": () => + t("cookies:cookie-policy-title-heading"), + "/auth/signin": () => t("auth:auth-page-title"), + "/auth/verify-request": () => t("auth:auth-check-email-page-title"), + "/auth/error": () => t("auth:auth-error-signin-error-title") }; return staticTextCrumbByPath[path] || null; }; - const simpleRouteRenderersByPath = { - "/myportal": () => renderTextCrumb(t("myportal:page-title")), - "/searchresults": () => - renderTextCrumb(t("common:breadcrumb-search-results")), - "/advancedsearch": () => - renderTextCrumb(t("common:breadcrumb-advanced-search")), - "/addresssearch": () => - renderTextCrumb(t("common:breadcrumb-address-search")), - "/contactus": () => - renderTextCrumb(t("common:footer-contact-us-link-label")), - "/viewall": () => - renderMyPortalSectionCrumbs(t("common:breadcrumb-your-cases")), - "/myportal/dnsapplications": () => - renderMyPortalSectionCrumbs(t("dnsCommon:service-name")) - }; - - const simpleLinkTextPairRenderersByPath = { - "/addresssearchresults": () => ( - <> - {renderLinkCrumb( - isWelsh - ? router.locale + "/chwiliadcyfeiriadau" - : "/addresssearch", - t("common:breadcrumb-address-search") - )} - {renderTextCrumb(t("common:breadcrumb-address-search-results"))} - - ), - "/advancedsearchresults": () => ( - <> - {renderLinkCrumb( - isWelsh - ? router.locale + "/advancedsearch" - : "/advancedsearch", - t("common:breadcrumb-advanced-search") - )} - {renderTextCrumb( - t("common:breadcrumb-advanced-search-results") - )} - - ) - }; - - const simpleMyPortalRouteRenderersByPath = { - "/myportal/searchresults": () => ( - <> - {renderAnchorCrumb( - myPortalHref, - t("common:breadcrumb-my-portal") - )} - {renderTextCrumb(t("common:breadcrumb-search-results"))} - - ), - "/myportal/addresssearch": () => - renderMyPortalSectionCrumbs(t("common:breadcrumb-address-search")), - "/myportal/advancedsearch": () => - renderMyPortalSectionCrumbs(t("common:breadcrumb-advanced-search")), - "/myportal/contactus": () => - renderMyPortalSectionCrumbs( - t("common:footer-contact-us-link-label") - ), - "/myportal/viewall": () => - renderMyPortalSectionCrumbs( - getViewAllLabel(currentViewState.viewKey || router.query.key) - ), - "/myportal/advancedsearchresults": () => ( - <> - {renderMyPortalCrumb()} - {renderLinkCrumb( - isWelsh - ? router.locale + "/myportal/advancedsearch" - : "/myportal/advancedsearch", - t("common:breadcrumb-advanced-search") - )} - {renderTextCrumb( - t("common:breadcrumb-advanced-search-results") - )} - - ), - "/myportal/addresssearchresults": () => ( - <> - {renderMyPortalCrumb()} - {renderLinkCrumb( - isWelsh - ? "/" + router.locale + "/fymhorth/chwiliadcyfeiriadau" - : "/myportal/addresssearch", - t("common:breadcrumb-address-search") - )} - {renderTextCrumb(t("common:breadcrumb-address-search-results"))} - - ) - }; - - const mappedRouteRendererMaps = buildBreadcrumbRendererMaps({ - simpleRouteRenderersByPath, - simpleLinkTextPairRenderersByPath, - simpleMyPortalRouteRenderersByPath - }); - - const resolveMappedRouteRenderer = (path) => { - return resolveMappedRouteRendererByMaps(path, mappedRouteRendererMaps); - }; - - const renderMappedRoute = (path, resolver = resolveMappedRouteRenderer) => { - const routeRenderer = resolver(path); - return routeRenderer ? routeRenderer() : null; - }; - const renderMyPortalCrumb = () => (
  • @@ -288,7 +188,7 @@ const Breadcrumbs = (props) => { <> {renderLinkCrumb( isWelsh - ? router.locale + "/dnsapplications" + ? `${router.locale}/dnsapplications` : "/dnsapplications", t("dnsCommon:service-name") )} @@ -296,6 +196,52 @@ const Breadcrumbs = (props) => { ); + const simpleRouteRenderersByPath = createSimpleRouteRenderers({ + t, + renderTextCrumb, + renderMyPortalSectionCrumbs + }); + + const simpleLinkTextPairRenderersByPath = createSimpleLinkTextPairRenderers( + { + t, + router, + isWelsh, + renderLinkCrumb, + renderTextCrumb + } + ); + + const simpleMyPortalRouteRenderersByPath = + createSimpleMyPortalRouteRenderers({ + t, + router, + isWelsh, + myPortalHref, + currentViewState, + getViewAllLabel, + renderAnchorCrumb, + renderLinkCrumb, + renderTextCrumb, + renderMyPortalCrumb, + renderMyPortalSectionCrumbs + }); + + const mappedRouteRendererMaps = buildBreadcrumbRendererMaps({ + simpleRouteRenderersByPath, + simpleLinkTextPairRenderersByPath, + simpleMyPortalRouteRenderersByPath + }); + + const resolveMappedRouteRenderer = (path) => { + return resolveMappedRouteRendererByMaps(path, mappedRouteRendererMaps); + }; + + const renderMappedRoute = (path, resolver = resolveMappedRouteRenderer) => { + const routeRenderer = resolver(path); + return routeRenderer ? routeRenderer() : null; + }; + const { breadcrumbHref, caseResultsHref, breadcrumbLabel } = resolveCaseBreadcrumbState({ query: router.query, @@ -305,6 +251,9 @@ const Breadcrumbs = (props) => { addressLabel: t("common:breadcrumb-address-search-results"), defaultLabel: t("common:breadcrumb-search-results") }); + + const staticTextCrumbLabel = getStaticTextCrumbLabel(pathname); + return ( <>
  • )} + {isPath("/myportal/case") && ( <> {renderLinkCrumb( isWelsh - ? "/" + router.locale + "/fymhorth" + ? `/${router.locale}/fymhorth` : "/myportal", t("common:breadcrumb-my-portal") )} {renderCaseReferenceCrumb(currentReference)} )} + {isPath("/case/[ticketnumber]") && ( <> {renderLinkCrumb( @@ -394,24 +349,19 @@ const Breadcrumbs = (props) => { {renderCaseReferenceCrumb(fallbackSearchTitle)} )} + {isPath("/case/id/[incident]") && ( <> {renderLinkCrumb( isWelsh - ? "/" + - router.locale + - "/chwiliadcyfeiriadau" + ? `/${router.locale}/chwiliadcyfeiriadau` : "/addresssearch", t("common:breadcrumb-address-search") )} {renderLinkCrumb( isWelsh - ? "/" + - router.locale + - "/canlyniadaucyfeiriadau?" + - nestedSearchString - : "/addresssearchresults?" + - nestedSearchString, + ? `/${router.locale}/canlyniadaucyfeiriadau?${nestedSearchString}` + : `/addresssearchresults?${nestedSearchString}`, t( "common:breadcrumb-address-search-results" ) @@ -419,37 +369,31 @@ const Breadcrumbs = (props) => { {renderCaseReferenceCrumb(currentReference)} )} + {isPath("/myportal/case/id/[incident]") && ( <> - {" "} {renderMyPortalCrumb()} {renderLinkCrumb( isWelsh - ? "/" + - router.locale + - "/fymhorth/chwiliadcyfeiriadau" + ? `/${router.locale}/fymhorth/chwiliadcyfeiriadau` : "/myportal/addresssearch", t("common:breadcrumb-address-search") )} {renderLinkCrumb( isWelsh - ? "/" + - router.locale + - "/fymhorth/canlyniadaucyfeiriadau?" + - nestedSearchString - : "/myportal/addresssearchresults?" + - nestedSearchString, + ? `/${router.locale}/fymhorth/canlyniadaucyfeiriadau?${nestedSearchString}` + : `/myportal/addresssearchresults?${nestedSearchString}`, t( "common:breadcrumb-address-search-results" ), () => { - // spinnerState(); router.back(); } )} {renderCaseReferenceCrumb(currentReference)} )} + {isPath("/myportal/case/[ticketnumber]") && ( <> {renderMyPortalCrumb()} @@ -460,6 +404,7 @@ const Breadcrumbs = (props) => { {renderCaseReferenceCrumb(caseReferenceDisplay)} )} + {isPath("/myportal/dnsdetails") && ( <> {renderMyPortalCrumb()} @@ -470,11 +415,12 @@ const Breadcrumbs = (props) => { {renderCaseReferenceCrumb(currentReference)} )} + {isPath("/myportal/dns/[developmentName]") && ( <> {renderLinkCrumb( isWelsh - ? router.locale + "/fymhorth" + ? `${router.locale}/fymhorth` : "/myportal", t("common:breadcrumb-my-portal") )} @@ -485,28 +431,22 @@ const Breadcrumbs = (props) => { {renderCaseReferenceCrumb(caseReferenceDisplay)} )} + {isPath("/case") && ( <> {renderLinkCrumb( isWelsh - ? router.locale + - "/searchresults?q=" + - router.query.q + - "&page=" + - router.query.page - : "/searchresults?q=" + - router.query.q + - "&page=" + - router.query.page, + ? `${router.locale}/searchresults?q=${router.query.q}&page=${router.query.page}` + : `/searchresults?q=${router.query.q}&page=${router.query.page}`, t("common:breadcrumb-search-results"), () => { - // spinnerState(); router.back(); } )} {renderCaseReferenceCrumb(currentReference)} )} + {isPath("/myportal/representation") && ( <> {currentView.representationSubmit === true && @@ -515,6 +455,7 @@ const Breadcrumbs = (props) => { renderBackCrumb(() => { setRepresentationSubmit(qcount); })} + {currentView.representationSubmit !== true && hasRepDetails && repDetails?.representationType !== @@ -522,6 +463,7 @@ const Breadcrumbs = (props) => { renderBackCrumb(() => { setRepresentationSubmit(false); })} + {currentView.representationSubmit !== true && hasRepDetails && repDetails?.representationType === @@ -535,6 +477,7 @@ const Breadcrumbs = (props) => { })} )} + {isPath("/account/personaldetails") && ( <> {renderLinkCrumb( @@ -546,6 +489,7 @@ const Breadcrumbs = (props) => { )} + {isPath("/account/changepassword") && ( <> {renderLinkCrumb( @@ -557,15 +501,18 @@ const Breadcrumbs = (props) => { )} + {isPath("/dnsdetails") && renderDnsCaseReferenceCrumbs(currentReference)} + {isPath("/dns/[developmentName]") && renderDnsCaseReferenceCrumbs(caseReferenceDisplay)} + {isPath("/dns/application-view") && ( <> {renderLinkCrumb( isWelsh - ? router.locale + "/dns/applications" + ? `${router.locale}/dns/applications` : "/dns/applications", t("dnsApplicationView:page-parent-title") )} @@ -574,8 +521,9 @@ const Breadcrumbs = (props) => { )} - {getStaticTextCrumbLabel(pathname) && - renderTextCrumb(getStaticTextCrumbLabel(pathname))} + + {staticTextCrumbLabel && + renderTextCrumb(staticTextCrumbLabel)} @@ -595,13 +543,11 @@ const mapDispatchToProps = (dispatch) => { updateField: (form, field, newValue) => dispatch(change(form, field, newValue)), setRepresentationCapacity: (representationCapacity) => { - //dispatch(reset("representationForm")); dispatch(setRepresentationCapacity(representationCapacity)); }, setRepresentationSubmit: (representationSubmit) => { dispatch(setRepresentationSubmit(representationSubmit)); }, - setCurrentView: (currentView) => { dispatch(setCurrentView(currentView)); }, diff --git a/i18n.js b/i18n.js index 09c9872c..5b5abac8 100644 --- a/i18n.js +++ b/i18n.js @@ -16,7 +16,7 @@ module.exports = { "/myportal/contactus": ["newappeal", "account", "common"], "/error": ["common", "home", "myportal"], "/furtherdetails": ["common", "home", "myportal"], - "/myportal": ["myportal", "common", "home", "case"], + "/myportal": ["myportal", "common", "home", "case", "auth"], "/myportal/viewall": ["search", "myportal", "case"], "/myportal/advancedsearch": ["search", "myportal"], "/myportal/advancedsearchresults": ["search", "myportal", "case"], diff --git a/lib/routing/breadcrumbRendererFactories.js b/lib/routing/breadcrumbRendererFactories.js new file mode 100644 index 00000000..88ceb51e --- /dev/null +++ b/lib/routing/breadcrumbRendererFactories.js @@ -0,0 +1,107 @@ +import React from "react"; + +export const createSimpleRouteRenderers = ({ + t, + renderTextCrumb, + renderMyPortalSectionCrumbs +}) => ({ + "/myportal": () => renderTextCrumb(t("myportal:page-title")), + "/searchresults": () => + renderTextCrumb(t("common:breadcrumb-search-results")), + "/advancedsearch": () => + renderTextCrumb(t("common:breadcrumb-advanced-search")), + "/addresssearch": () => + renderTextCrumb(t("common:breadcrumb-address-search")), + "/contactus": () => + renderTextCrumb(t("common:footer-contact-us-link-label")), + "/viewall": () => + renderMyPortalSectionCrumbs(t("common:breadcrumb-your-cases")), + "/myportal/dnsapplications": () => + renderMyPortalSectionCrumbs(t("dnsCommon:service-name")) +}); + +export const createSimpleLinkTextPairRenderers = ({ + t, + router, + isWelsh, + renderLinkCrumb, + renderTextCrumb +}) => ({ + "/addresssearchresults": () => ( + <> + {renderLinkCrumb( + isWelsh + ? `/${router.locale}/chwiliadcyfeiriadau` + : "/addresssearch", + t("common:breadcrumb-address-search") + )} + {renderTextCrumb(t("common:breadcrumb-address-search-results"))} + + ), + "/advancedsearchresults": () => ( + <> + {renderLinkCrumb( + isWelsh + ? `/${router.locale}/advancedsearch` + : "/advancedsearch", + t("common:breadcrumb-advanced-search") + )} + {renderTextCrumb(t("common:breadcrumb-advanced-search-results"))} + + ) +}); + +export const createSimpleMyPortalRouteRenderers = ({ + t, + router, + isWelsh, + myPortalHref, + currentViewState, + getViewAllLabel, + renderAnchorCrumb, + renderLinkCrumb, + renderTextCrumb, + renderMyPortalCrumb, + renderMyPortalSectionCrumbs +}) => ({ + "/myportal/searchresults": () => ( + <> + {renderAnchorCrumb(myPortalHref, t("common:breadcrumb-my-portal"))} + {renderTextCrumb(t("common:breadcrumb-search-results"))} + + ), + "/myportal/addresssearch": () => + renderMyPortalSectionCrumbs(t("common:breadcrumb-address-search")), + "/myportal/advancedsearch": () => + renderMyPortalSectionCrumbs(t("common:breadcrumb-advanced-search")), + "/myportal/contactus": () => + renderMyPortalSectionCrumbs(t("common:footer-contact-us-link-label")), + "/myportal/viewall": () => + renderMyPortalSectionCrumbs( + getViewAllLabel(currentViewState.viewKey || router.query.key) + ), + "/myportal/advancedsearchresults": () => ( + <> + {renderMyPortalCrumb()} + {renderLinkCrumb( + isWelsh + ? `/${router.locale}/myportal/advancedsearch` + : "/myportal/advancedsearch", + t("common:breadcrumb-advanced-search") + )} + {renderTextCrumb(t("common:breadcrumb-advanced-search-results"))} + + ), + "/myportal/addresssearchresults": () => ( + <> + {renderMyPortalCrumb()} + {renderLinkCrumb( + isWelsh + ? `/${router.locale}/fymhorth/chwiliadcyfeiriadau` + : "/myportal/addresssearch", + t("common:breadcrumb-address-search") + )} + {renderTextCrumb(t("common:breadcrumb-address-search-results"))} + + ) +}); diff --git a/lib/routing/routeState.js b/lib/routing/routeState.js index e56ca3d3..5d334ba0 100644 --- a/lib/routing/routeState.js +++ b/lib/routing/routeState.js @@ -21,8 +21,16 @@ export const resolveSearchResultsHref = ({ const routeState = normalizeRouteStateQuery(query); const { viewAll, advanced, address, key } = routeState; const base = hasSession ? "/myportal" : ""; + const hasSearchQuery = + typeof query?.q === "string" && query.q.trim().length > 0; if (fallbackToMyPortalWhenNoFlags && !viewAll && !advanced && !address) { + if (hasSearchQuery) { + return { + pathname: `${base}/searchresults`, + query + }; + } return { pathname: "/myportal" }; diff --git a/tests/phase22/breadcrumb-route-maps-helper.test.cjs b/tests/phase22/breadcrumb-route-maps-helper.test.cjs index 211be03a..fbf951f4 100644 --- a/tests/phase22/breadcrumb-route-maps-helper.test.cjs +++ b/tests/phase22/breadcrumb-route-maps-helper.test.cjs @@ -1,49 +1,156 @@ const assert = require("assert"); - -const { - buildBreadcrumbRendererMaps, - getMappedRendererByPath, - resolveMappedRouteRenderer -} = require("../../lib/routing/breadcrumbRouteMaps"); +const fs = require("fs"); +const path = require("path"); +const vm = require("vm"); const tests = []; const test = (name, fn) => tests.push({ name, fn }); -test("breadcrumbRouteMaps/buildBreadcrumbRendererMaps preserves explicit map order", async () => { - const mapA = { "/a": () => "A" }; - const mapB = { "/b": () => "B" }; - const mapC = { "/c": () => "C" }; +const helperModulePath = path.join( + __dirname, + "..", + "..", + "lib", + "routing", + "breadcrumbRouteMaps.js" +); - const result = buildBreadcrumbRendererMaps({ - simpleRouteRenderersByPath: mapA, - simpleLinkTextPairRenderersByPath: mapB, - simpleMyPortalRouteRenderersByPath: mapC +const loadHelpers = () => { + const source = fs.readFileSync(helperModulePath, "utf8"); + + const transformedSource = ` +${source + .replace(/export const /g, "const ") + .replace(/export function /g, "function ")} + +module.exports = { + buildBreadcrumbRendererMaps, + resolveMappedRouteRenderer +}; +`; + + const sandbox = { + module: { exports: {} }, + exports: {}, + require, + __dirname: path.dirname(helperModulePath), + __filename: helperModulePath, + console + }; + + vm.runInNewContext(transformedSource, sandbox, { + filename: helperModulePath }); - assert.strictEqual(result[0], mapA); - assert.strictEqual(result[1], mapB); - assert.strictEqual(result[2], mapC); + return sandbox.module.exports; +}; + +test("buildBreadcrumbRendererMaps returns grouped maps in explicit precedence order", () => { + const { buildBreadcrumbRendererMaps } = loadHelpers(); + + const simpleRouteRenderersByPath = { + "/alpha": () => "alpha" + }; + const simpleLinkTextPairRenderersByPath = { + "/beta": () => "beta" + }; + const simpleMyPortalRouteRenderersByPath = { + "/gamma": () => "gamma" + }; + + const result = buildBreadcrumbRendererMaps({ + simpleRouteRenderersByPath, + simpleLinkTextPairRenderersByPath, + simpleMyPortalRouteRenderersByPath + }); + + assert.ok(Array.isArray(result), "Expected grouped maps array"); + assert.strictEqual(result.length, 3, "Expected three grouped route maps"); + assert.strictEqual( + result[0], + simpleRouteRenderersByPath, + "Expected simple route renderers first" + ); + assert.strictEqual( + result[1], + simpleLinkTextPairRenderersByPath, + "Expected simple link-text pair renderers second" + ); + assert.strictEqual( + result[2], + simpleMyPortalRouteRenderersByPath, + "Expected my-portal route renderers third" + ); }); -test("breadcrumbRouteMaps/getMappedRendererByPath returns mapped renderer or null", async () => { - const renderer = () => "ok"; - const map = { "/x": renderer }; +test("resolveMappedRouteRenderer returns renderer from first matching grouped map", () => { + const { buildBreadcrumbRendererMaps, resolveMappedRouteRenderer } = + loadHelpers(); - assert.strictEqual(getMappedRendererByPath("/x", map), renderer); - assert.strictEqual(getMappedRendererByPath("/missing", map), null); -}); - -test("breadcrumbRouteMaps/resolveMappedRouteRenderer honors map precedence", async () => { const firstRenderer = () => "first"; const secondRenderer = () => "second"; - const result = resolveMappedRouteRenderer("/same", [ - { "/same": firstRenderer }, - { "/same": secondRenderer } - ]); + const maps = buildBreadcrumbRendererMaps({ + simpleRouteRenderersByPath: { + "/known": firstRenderer + }, + simpleLinkTextPairRenderersByPath: { + "/known": secondRenderer + }, + simpleMyPortalRouteRenderersByPath: {} + }); - assert.strictEqual(result, firstRenderer); - assert.strictEqual(resolveMappedRouteRenderer("/none", []), null); + const result = resolveMappedRouteRenderer("/known", maps); + + assert.strictEqual( + result, + firstRenderer, + "Expected resolver to use first matching map by precedence order" + ); +}); + +test("resolveMappedRouteRenderer returns renderer from later grouped map when earlier maps do not match", () => { + const { buildBreadcrumbRendererMaps, resolveMappedRouteRenderer } = + loadHelpers(); + + const myPortalRenderer = () => "myportal"; + + const maps = buildBreadcrumbRendererMaps({ + simpleRouteRenderersByPath: {}, + simpleLinkTextPairRenderersByPath: {}, + simpleMyPortalRouteRenderersByPath: { + "/myportal/searchresults": myPortalRenderer + } + }); + + const result = resolveMappedRouteRenderer("/myportal/searchresults", maps); + + assert.strictEqual( + result, + myPortalRenderer, + "Expected resolver to return matching renderer from later grouped map" + ); +}); + +test("resolveMappedRouteRenderer returns null for unmapped path", () => { + const { buildBreadcrumbRendererMaps, resolveMappedRouteRenderer } = + loadHelpers(); + + const maps = buildBreadcrumbRendererMaps({ + simpleRouteRenderersByPath: { + "/known": () => "known" + }, + simpleLinkTextPairRenderersByPath: {}, + simpleMyPortalRouteRenderersByPath: {} + }); + + const result = resolveMappedRouteRenderer("/unknown", maps); + + assert.strictEqual( + result, + null, + "Expected null when no grouped map contains the path" + ); }); const run = async () => { diff --git a/tests/phase22/breadcrumbs-route-map-structure.test.cjs b/tests/phase22/breadcrumbs-route-map-structure.test.cjs index 51b3512d..e740838a 100644 --- a/tests/phase22/breadcrumbs-route-map-structure.test.cjs +++ b/tests/phase22/breadcrumbs-route-map-structure.test.cjs @@ -5,58 +5,75 @@ const path = require("path"); const tests = []; const test = (name, fn) => tests.push({ name, fn }); -const loadBreadcrumbSource = () => { - const filePath = path.join( - __dirname, - "..", - "..", - "components", - "breadcrumbs.js" - ); +const loadSource = (...parts) => { + const filePath = path.join(__dirname, "..", "..", ...parts); return fs.readFileSync(filePath, "utf8"); }; -test("breadcrumbs/simple-link-text map includes address and advanced search result routes", async () => { - const source = loadBreadcrumbSource(); +const loadBreadcrumbSource = () => loadSource("components", "breadcrumbs.js"); + +const loadFactorySource = () => + loadSource("lib", "routing", "breadcrumbRendererFactories.js"); + +test("breadcrumbs/factory module includes expected mapped route definitions", async () => { + const source = loadFactorySource(); + + assert.strictEqual( + source.includes("export const createSimpleRouteRenderers ="), + true, + "Expected simple route renderer factory export" + ); + + assert.strictEqual( + source.includes("export const createSimpleLinkTextPairRenderers ="), + true, + "Expected link-text pair renderer factory export" + ); + + assert.strictEqual( + source.includes("export const createSimpleMyPortalRouteRenderers ="), + true, + "Expected my-portal route renderer factory export" + ); assert.strictEqual( source.includes('"/addresssearchresults": () => ('), true, - "Expected /addresssearchresults to be mapped in getSimpleLinkTextPairRenderer" + "Expected /addresssearchresults to be mapped in createSimpleLinkTextPairRenderers" ); assert.strictEqual( source.includes('"/advancedsearchresults": () => ('), true, - "Expected /advancedsearchresults to be mapped in getSimpleLinkTextPairRenderer" + "Expected /advancedsearchresults to be mapped in createSimpleLinkTextPairRenderers" ); assert.strictEqual( source.includes('"/myportal/searchresults": () => ('), true, - "Expected /myportal/searchresults to be mapped in getSimpleMyPortalRouteRenderer" + "Expected /myportal/searchresults to be mapped in createSimpleMyPortalRouteRenderers" ); assert.strictEqual( source.includes('"/myportal/viewall": () =>'), true, - "Expected /myportal/viewall to be mapped in getSimpleMyPortalRouteRenderer" + "Expected /myportal/viewall to be mapped in createSimpleMyPortalRouteRenderers" ); assert.strictEqual( source.includes('"/myportal/advancedsearchresults": () => ('), true, - "Expected /myportal/advancedsearchresults to be mapped in getSimpleMyPortalRouteRenderer" + "Expected /myportal/advancedsearchresults to be mapped in createSimpleMyPortalRouteRenderers" ); assert.strictEqual( source.includes('"/myportal/addresssearchresults": () => ('), true, - "Expected /myportal/addresssearchresults to be mapped in getSimpleMyPortalRouteRenderer" + "Expected /myportal/addresssearchresults to be mapped in createSimpleMyPortalRouteRenderers" ); }); -test("breadcrumbs/merged resolver renders mapped routes through single helper invocation", async () => { +test("breadcrumbs/component composes mapped routes via imported factories and shared resolver", async () => { const source = loadBreadcrumbSource(); assert.strictEqual( @@ -66,30 +83,80 @@ test("breadcrumbs/merged resolver renders mapped routes through single helper in ); assert.strictEqual( - source.includes( - "const mappedRouteRendererMaps = buildBreadcrumbRendererMaps({" + source.includes('from "../lib/routing/breadcrumbRendererFactories"'), + true, + "Expected breadcrumbs to import breadcrumb renderer factories" + ); + + assert.strictEqual( + /createSimpleRouteRenderers/.test(source), + true, + "Expected breadcrumbs to reference createSimpleRouteRenderers" + ); + + assert.strictEqual( + /createSimpleLinkTextPairRenderers/.test(source), + true, + "Expected breadcrumbs to reference createSimpleLinkTextPairRenderers" + ); + + assert.strictEqual( + /createSimpleMyPortalRouteRenderers/.test(source), + true, + "Expected breadcrumbs to reference createSimpleMyPortalRouteRenderers" + ); + + assert.strictEqual( + /const\s+simpleRouteRenderersByPath\s*=\s*createSimpleRouteRenderers\s*\(/.test( + source + ), + true, + "Expected breadcrumbs to build simple route renderers from factory" + ); + + assert.strictEqual( + /const\s+simpleLinkTextPairRenderersByPath\s*=\s*createSimpleLinkTextPairRenderers\s*\(/.test( + source + ), + true, + "Expected breadcrumbs to build link-text pair renderers from factory" + ); + + assert.strictEqual( + /const\s+simpleMyPortalRouteRenderersByPath\s*=\s*createSimpleMyPortalRouteRenderers\s*\(/.test( + source + ), + true, + "Expected breadcrumbs to build my-portal route renderers from factory" + ); + + assert.strictEqual( + /const\s+mappedRouteRendererMaps\s*=\s*buildBreadcrumbRendererMaps\s*\(/.test( + source ), true, "Expected grouped mapped route renderer map composition to use helper builder" ); assert.strictEqual( - source.includes( - "return resolveMappedRouteRendererByMaps(path, mappedRouteRendererMaps);" + /return\s+resolveMappedRouteRendererByMaps\s*\(\s*path\s*,\s*mappedRouteRendererMaps\s*\)/.test( + source ), true, "Expected resolver to delegate to shared route-map resolver helper" ); assert.strictEqual( - source.includes("const resolveMappedRouteRenderer = (path) =>"), + /const\s+resolveMappedRouteRenderer\s*=\s*\(\s*path\s*\)\s*=>/.test( + source + ), true, "Expected unified mapped route resolver helper to exist" ); assert.strictEqual( - source.includes( - "const renderMappedRoute = (path, resolver = resolveMappedRouteRenderer) =>" + /const\s+renderMappedRoute\s*=\s*\(\s*path\s*,\s*resolver\s*=\s*resolveMappedRouteRenderer\s*\)\s*=>/.test( + source ), true, "Expected reusable renderMappedRoute helper to exist" @@ -112,13 +179,24 @@ test("breadcrumbs/merged resolver renders mapped routes through single helper in false, "Expected /myportal/addresssearchresults explicit branch to be removed after mapping" ); +}); + +test("breadcrumbs/map builder preserves route map ordering", async () => { + const source = loadBreadcrumbSource(); const mapBuilderCallIndex = source.indexOf( "const mappedRouteRendererMaps = buildBreadcrumbRendererMaps({" ); + + assert.notStrictEqual( + mapBuilderCallIndex, + -1, + "Expected grouped map builder call to exist" + ); + const mapBuilderSlice = source.slice( mapBuilderCallIndex, - mapBuilderCallIndex + 300 + mapBuilderCallIndex + 500 ); const simpleRouteOrderIndex = mapBuilderSlice.indexOf( diff --git a/tests/phase22/route-state-helper.test.cjs b/tests/phase22/route-state-helper.test.cjs index 71ef6719..83272777 100644 --- a/tests/phase22/route-state-helper.test.cjs +++ b/tests/phase22/route-state-helper.test.cjs @@ -1,187 +1,372 @@ +const assert = require("assert"); 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, resolveSearchBreadcrumbLabel, isDnsRoutePath, resolveCaseBreadcrumbState };\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 helperModulePath = path.join( + __dirname, + "..", + "..", + "lib", + "routing", + "routeState.js" +); - const normalized = mod.normalizeRouteStateQuery({ - va: "true", - adv: "false", - ads: "true", - key: "myCases" +const toPlain = (value) => JSON.parse(JSON.stringify(value)); + +const loadHelpers = () => { + const source = fs.readFileSync(helperModulePath, "utf8"); + + const transformedSource = ` +${source + .replace(/export const /g, "const ") + .replace(/export function /g, "function ")} + +module.exports = { + normalizeRouteStateQuery, + resolveSearchResultsHref, + resolveSearchBreadcrumbLabel, + isDnsRoutePath, + resolveCaseBreadcrumbState +}; +`; + + const sandbox = { + module: { exports: {} }, + exports: {}, + require, + __dirname: path.dirname(helperModulePath), + __filename: helperModulePath, + console + }; + + vm.runInNewContext(transformedSource, sandbox, { + filename: helperModulePath }); - assert.deepStrictEqual(JSON.parse(JSON.stringify(normalized)), { - viewAll: true, - advanced: false, - address: true, - key: "myCases" - }); + return sandbox.module.exports; +}; + +test("normalizeRouteStateQuery maps known boolean query flags", () => { + const { normalizeRouteStateQuery } = loadHelpers(); + + assert.deepStrictEqual( + toPlain( + normalizeRouteStateQuery({ + va: "true", + adv: "false", + ads: "true", + key: "myCases" + }) + ), + { + viewAll: true, + advanced: false, + address: true, + key: "myCases" + } + ); }); -test("routing/routeState resolves view-all href when va=true", async () => { - const mod = loadRouteStateModule(); +test("resolveSearchResultsHref falls back to myportal when no flags and no search query exist for signed-in user", () => { + const { resolveSearchResultsHref } = loadHelpers(); - 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" } - }); + assert.deepStrictEqual( + toPlain( + resolveSearchResultsHref({ + query: {}, + hasSession: true, + fallbackToMyPortalWhenNoFlags: true + }) + ), + { + pathname: "/myportal" + } + ); }); -test("routing/routeState resolves advanced/address/default result hrefs", async () => { - const mod = loadRouteStateModule(); +test("resolveSearchResultsHref preserves public searchresults route when search query exists and no flags are set", () => { + const { resolveSearchResultsHref } = loadHelpers(); - 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" } - }); + assert.deepStrictEqual( + toPlain( + resolveSearchResultsHref({ + query: { q: "abc" }, + hasSession: false, + fallbackToMyPortalWhenNoFlags: true + }) + ), + { + pathname: "/searchresults", + query: { q: "abc" } + } + ); }); -test("routing/routeState supports fallback to /myportal when no route flags", async () => { - const mod = loadRouteStateModule(); +test("resolveSearchResultsHref preserves myportal searchresults route when search query exists and no flags are set for signed-in user", () => { + const { resolveSearchResultsHref } = loadHelpers(); - 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" - }); -}); - -test("routing/routeState resolves breadcrumb label using key and route flags", async () => { - const mod = loadRouteStateModule(); - const getViewAllLabel = (key) => - ({ myCases: "My Cases", watchedCases: "Watched Cases" })[key] || null; - - const fromViewAll = mod.resolveSearchBreadcrumbLabel({ - query: { va: "true", key: "myCases" }, - getViewAllLabel, - advancedLabel: "Advanced", - addressLabel: "Address", - defaultLabel: "Search" - }); - const fromAdvanced = mod.resolveSearchBreadcrumbLabel({ - query: { adv: "true" }, - getViewAllLabel, - advancedLabel: "Advanced", - addressLabel: "Address", - defaultLabel: "Search" - }); - const fromDefault = mod.resolveSearchBreadcrumbLabel({ - query: {}, - getViewAllLabel, - advancedLabel: "Advanced", - addressLabel: "Address", - defaultLabel: "Search" - }); - - assert.strictEqual(fromViewAll, "My Cases"); - assert.strictEqual(fromAdvanced, "Advanced"); - assert.strictEqual(fromDefault, "Search"); -}); - -test("routing/routeState identifies dns and myportal dns route prefixes", async () => { - const mod = loadRouteStateModule(); - - assert.strictEqual(mod.isDnsRoutePath("/dns"), true); - assert.strictEqual(mod.isDnsRoutePath("/dns/development"), true); - assert.strictEqual(mod.isDnsRoutePath("/myportal/dns"), true); - assert.strictEqual(mod.isDnsRoutePath("/myportal/dns/applications"), true); - assert.strictEqual(mod.isDnsRoutePath("/case/123"), false); -}); - -test("routing/routeState resolves bundled case breadcrumb state", async () => { - const mod = loadRouteStateModule(); - const getViewAllLabel = (key) => ({ myCases: "My Cases" })[key] || null; - - const state = mod.resolveCaseBreadcrumbState({ - query: { va: "true", key: "myCases" }, - hasSession: true, - getViewAllLabel, - advancedLabel: "Advanced", - addressLabel: "Address", - defaultLabel: "Search" - }); - - assert.deepStrictEqual(JSON.parse(JSON.stringify(state)), { - breadcrumbHref: { - pathname: "/myportal/viewall", - query: { key: "myCases" } - }, - caseResultsHref: { + assert.deepStrictEqual( + toPlain( + resolveSearchResultsHref({ + query: { q: "abc" }, + hasSession: true, + fallbackToMyPortalWhenNoFlags: true + }) + ), + { pathname: "/myportal/searchresults", - query: { va: "true", key: "myCases" } - }, - breadcrumbLabel: "My Cases" - }); + query: { q: "abc" } + } + ); +}); + +test("resolveSearchResultsHref routes view-all requests to myportal viewall with key only", () => { + const { resolveSearchResultsHref } = loadHelpers(); + + assert.deepStrictEqual( + toPlain( + resolveSearchResultsHref({ + query: { + va: "true", + key: "mySubmittedReps", + q: "ignored" + }, + hasSession: true, + includeViewAll: true + }) + ), + { + pathname: "/myportal/viewall", + query: { key: "mySubmittedReps" } + } + ); +}); + +test("resolveSearchResultsHref routes advanced requests to advancedsearchresults and preserves query", () => { + const { resolveSearchResultsHref } = loadHelpers(); + + assert.deepStrictEqual( + toPlain( + resolveSearchResultsHref({ + query: { q: "abc", adv: "true", page: "2" }, + hasSession: false + }) + ), + { + pathname: "/advancedsearchresults", + query: { q: "abc", adv: "true", page: "2" } + } + ); +}); + +test("resolveSearchResultsHref routes address requests to addresssearchresults and preserves query", () => { + const { resolveSearchResultsHref } = loadHelpers(); + + assert.deepStrictEqual( + toPlain( + resolveSearchResultsHref({ + query: { postcode: "CF10", ads: "true" }, + hasSession: true + }) + ), + { + pathname: "/myportal/addresssearchresults", + query: { postcode: "CF10", ads: "true" } + } + ); +}); + +test("resolveSearchResultsHref routes dns requests to dnsapplications", () => { + const { resolveSearchResultsHref } = loadHelpers(); + + assert.deepStrictEqual( + toPlain( + resolveSearchResultsHref({ + query: { anything: "x" }, + hasSession: true, + isDnsRoute: true + }) + ), + { + pathname: "/myportal/dnsapplications" + } + ); + + assert.deepStrictEqual( + toPlain( + resolveSearchResultsHref({ + query: { anything: "x" }, + hasSession: false, + isDnsRoute: true + }) + ), + { + pathname: "/dnsapplications" + } + ); +}); + +test("resolveSearchResultsHref defaults to searchresults and preserves query when no special route applies", () => { + const { resolveSearchResultsHref } = loadHelpers(); + + assert.deepStrictEqual( + toPlain( + resolveSearchResultsHref({ + query: { q: "abc", page: "3" }, + hasSession: false + }) + ), + { + pathname: "/searchresults", + query: { q: "abc", page: "3" } + } + ); +}); + +test("resolveSearchBreadcrumbLabel prioritizes view-all label, then keyed label, then advanced/address/default", () => { + const { resolveSearchBreadcrumbLabel } = loadHelpers(); + + const getViewAllLabel = (key) => { + const labels = { + myCases: "My cases", + watchedCases: "Watched cases" + }; + return labels[key] || null; + }; + + assert.strictEqual( + resolveSearchBreadcrumbLabel({ + query: { va: "true", key: "myCases" }, + getViewAllLabel, + advancedLabel: "Advanced search results", + addressLabel: "Address search results", + defaultLabel: "Search results" + }), + "My cases" + ); + + assert.strictEqual( + resolveSearchBreadcrumbLabel({ + query: { key: "watchedCases" }, + getViewAllLabel, + advancedLabel: "Advanced search results", + addressLabel: "Address search results", + defaultLabel: "Search results" + }), + "Watched cases" + ); + + assert.strictEqual( + resolveSearchBreadcrumbLabel({ + query: { adv: "true" }, + getViewAllLabel, + advancedLabel: "Advanced search results", + addressLabel: "Address search results", + defaultLabel: "Search results" + }), + "Advanced search results" + ); + + assert.strictEqual( + resolveSearchBreadcrumbLabel({ + query: { ads: "true" }, + getViewAllLabel, + advancedLabel: "Advanced search results", + addressLabel: "Address search results", + defaultLabel: "Search results" + }), + "Address search results" + ); + + assert.strictEqual( + resolveSearchBreadcrumbLabel({ + query: {}, + getViewAllLabel, + advancedLabel: "Advanced search results", + addressLabel: "Address search results", + defaultLabel: "Search results" + }), + "Search results" + ); +}); + +test("isDnsRoutePath correctly identifies dns route families", () => { + const { isDnsRoutePath } = loadHelpers(); + + assert.strictEqual(isDnsRoutePath("/dns"), true); + assert.strictEqual(isDnsRoutePath("/dns/applications"), true); + assert.strictEqual(isDnsRoutePath("/myportal/dns"), true); + assert.strictEqual(isDnsRoutePath("/myportal/dns/example"), true); + + assert.strictEqual(isDnsRoutePath("/searchresults"), false); + assert.strictEqual(isDnsRoutePath("/myportal"), false); + assert.strictEqual(isDnsRoutePath("/myportal/case/[ticketnumber]"), false); +}); + +test("resolveCaseBreadcrumbState returns breadcrumb and case-results hrefs plus breadcrumb label", () => { + const { resolveCaseBreadcrumbState } = loadHelpers(); + + const getViewAllLabel = (key) => + key === "mySubmittedReps" ? "Submitted representations" : null; + + assert.deepStrictEqual( + toPlain( + resolveCaseBreadcrumbState({ + query: { q: "cas-", adv: "false" }, + hasSession: true, + getViewAllLabel, + advancedLabel: "Advanced search results", + addressLabel: "Address search results", + defaultLabel: "Search results" + }) + ), + { + breadcrumbHref: { + pathname: "/myportal/searchresults", + query: { q: "cas-", adv: "false" } + }, + caseResultsHref: { + pathname: "/myportal/searchresults", + query: { q: "cas-", adv: "false" } + }, + breadcrumbLabel: "Search results" + } + ); +}); + +test("resolveCaseBreadcrumbState distinguishes view-all breadcrumb from case-results href", () => { + const { resolveCaseBreadcrumbState } = loadHelpers(); + + const getViewAllLabel = (key) => + key === "mySubmittedReps" ? "Submitted representations" : null; + + assert.deepStrictEqual( + toPlain( + resolveCaseBreadcrumbState({ + query: { va: "true", key: "mySubmittedReps" }, + hasSession: true, + getViewAllLabel, + advancedLabel: "Advanced search results", + addressLabel: "Address search results", + defaultLabel: "Search results" + }) + ), + { + breadcrumbHref: { + pathname: "/myportal/viewall", + query: { key: "mySubmittedReps" } + }, + caseResultsHref: { + pathname: "/myportal/searchresults", + query: { va: "true", key: "mySubmittedReps" } + }, + breadcrumbLabel: "Submitted representations" + } + ); }); const run = async () => { @@ -193,7 +378,7 @@ const run = async () => { } console.log( - `Phase 22 route-state helper tests passed (${passed}/${tests.length}).` + `Phase 22 route-state-helper tests passed (${passed}/${tests.length}).` ); };