Kaynağa Gözat

RHL-037 feat(branchSwitch): implement branch switching and search URL handling functions with tests

Code_Uwe 3 hafta önce
ebeveyn
işleme
3d119739dc

+ 91 - 0
lib/frontend/quickNav/branchSwitch.js

@@ -0,0 +1,91 @@
+import { isValidBranchParam } from "@/lib/frontend/params";
+import { branchPath } from "@/lib/frontend/routes";
+import {
+	parseSearchUrlState,
+	serializeSearchUrlState,
+	SEARCH_SCOPE,
+} from "@/lib/frontend/search/urlState";
+
+export function readRouteBranchFromPathname(pathname) {
+	if (typeof pathname !== "string" || !pathname.startsWith("/")) return null;
+
+	const seg = pathname.split("/").filter(Boolean)[0] || null;
+	return seg && isValidBranchParam(seg) ? seg : null;
+}
+
+export function isSearchRoutePathname(pathname) {
+	if (typeof pathname !== "string" || !pathname.startsWith("/")) return false;
+
+	const parts = pathname.split("/").filter(Boolean);
+	return parts.length >= 2 && parts[1] === "search";
+}
+
+export function replaceBranchInPathnameOrFallback(pathname, nextBranch) {
+	const current = readRouteBranchFromPathname(pathname);
+	if (!current) return branchPath(nextBranch);
+
+	const parts = pathname.split("/").filter(Boolean);
+	if (parts.length === 0) return branchPath(nextBranch);
+
+	parts[0] = nextBranch;
+	return `/${parts.join("/")}`;
+}
+
+export function buildNextSearchQueryString({
+	currentSearch,
+	currentRouteBranch,
+	nextBranch,
+}) {
+	const sp = new URLSearchParams(currentSearch || "");
+	const parsed = parseSearchUrlState(sp, { routeBranch: currentRouteBranch });
+
+	const nextState =
+		parsed.scope === SEARCH_SCOPE.SINGLE
+			? { ...parsed, branch: nextBranch }
+			: parsed;
+
+	const qs = serializeSearchUrlState(nextState);
+	return qs ? `?${qs}` : "";
+}
+
+export function buildNextUrlForBranchSwitch({ pathname, search, nextBranch }) {
+	if (!isValidBranchParam(nextBranch)) return null;
+
+	const currentRouteBranch = readRouteBranchFromPathname(pathname);
+
+	// Outside a branch route -> go to branch root
+	if (!currentRouteBranch) return branchPath(nextBranch);
+
+	const nextPathname = replaceBranchInPathnameOrFallback(pathname, nextBranch);
+
+	const nextSearch = isSearchRoutePathname(pathname)
+		? buildNextSearchQueryString({
+				currentSearch: search,
+				currentRouteBranch,
+				nextBranch,
+		  })
+		: search || "";
+
+	return `${nextPathname}${nextSearch}`;
+}
+
+export function safeReadLocalStorageBranch(storageKey) {
+	if (typeof window === "undefined") return null;
+
+	try {
+		const raw = window.localStorage.getItem(String(storageKey || ""));
+		return raw && isValidBranchParam(raw) ? raw : null;
+	} catch {
+		return null;
+	}
+}
+
+export function safeWriteLocalStorageBranch(storageKey, branch) {
+	if (typeof window === "undefined") return;
+
+	try {
+		window.localStorage.setItem(String(storageKey || ""), String(branch));
+	} catch {
+		// ignore
+	}
+}

+ 73 - 0
lib/frontend/quickNav/branchSwitch.test.js

@@ -0,0 +1,73 @@
+/* @vitest-environment node */
+
+import { describe, it, expect } from "vitest";
+import {
+	readRouteBranchFromPathname,
+	isSearchRoutePathname,
+	replaceBranchInPathnameOrFallback,
+	buildNextSearchQueryString,
+	buildNextUrlForBranchSwitch,
+} from "./branchSwitch.js";
+
+describe("lib/frontend/quickNav/branchSwitch", () => {
+	it("readRouteBranchFromPathname returns the first segment when valid", () => {
+		expect(readRouteBranchFromPathname("/NL01")).toBe("NL01");
+		expect(readRouteBranchFromPathname("/NL01/2025/12/31")).toBe("NL01");
+		expect(readRouteBranchFromPathname("/search")).toBe(null);
+		expect(readRouteBranchFromPathname("/nl01")).toBe(null);
+	});
+
+	it("isSearchRoutePathname detects /:branch/search", () => {
+		expect(isSearchRoutePathname("/NL01/search")).toBe(true);
+		expect(isSearchRoutePathname("/NL01/search/extra")).toBe(true);
+		expect(isSearchRoutePathname("/NL01")).toBe(false);
+		expect(isSearchRoutePathname("/search")).toBe(false);
+	});
+
+	it("replaceBranchInPathnameOrFallback replaces only the first segment", () => {
+		expect(replaceBranchInPathnameOrFallback("/NL01/2025/12/31", "NL20")).toBe(
+			"/NL20/2025/12/31"
+		);
+
+		// No valid branch -> fallback to branch root
+		expect(replaceBranchInPathnameOrFallback("/search", "NL20")).toBe("/NL20");
+	});
+
+	it("buildNextSearchQueryString drops legacy SINGLE branch= and keeps other params", () => {
+		const qs = buildNextSearchQueryString({
+			currentSearch: "?q=x&branch=NL01&limit=200",
+			currentRouteBranch: "NL01",
+			nextBranch: "NL20",
+		});
+
+		expect(qs).toBe("?q=x&limit=200");
+	});
+
+	it("buildNextSearchQueryString keeps MULTI branches (sorted deterministically)", () => {
+		const qs = buildNextSearchQueryString({
+			currentSearch: "?q=x&scope=multi&branches=NL20,NL06",
+			currentRouteBranch: "NL01",
+			nextBranch: "NL02",
+		});
+
+		expect(qs).toBe("?q=x&scope=multi&branches=NL06%2CNL20");
+	});
+
+	it("buildNextUrlForBranchSwitch preserves deep path and normalizes Search URLs", () => {
+		expect(
+			buildNextUrlForBranchSwitch({
+				pathname: "/NL01/2025/12/31",
+				search: "",
+				nextBranch: "NL20",
+			})
+		).toBe("/NL20/2025/12/31");
+
+		expect(
+			buildNextUrlForBranchSwitch({
+				pathname: "/NL01/search",
+				search: "?q=x&branch=NL01&limit=200",
+				nextBranch: "NL20",
+			})
+		).toBe("/NL20/search?q=x&limit=200");
+	});
+});