Explorar o código

RHL-024 feat(search): implement URL state management for search parameters with parsing and serialization functions

Code_Uwe hai 4 semanas
pai
achega
231cf34c82
Modificáronse 2 ficheiros con 380 adicións e 0 borrados
  1. 218 0
      lib/frontend/search/urlState.js
  2. 162 0
      lib/frontend/search/urlState.test.js

+ 218 - 0
lib/frontend/search/urlState.js

@@ -0,0 +1,218 @@
+export const SEARCH_SCOPE = Object.freeze({
+	SINGLE: "single",
+	MULTI: "multi",
+	ALL: "all",
+});
+
+/**
+ * Read a query parameter from either:
+ * - URLSearchParams (client-side `useSearchParams()`)
+ * - Next.js server `searchParams` object (plain object with string or string[])
+ *
+ * @param {any} searchParams
+ * @param {string} key
+ * @returns {string|null}
+ */
+function readParam(searchParams, key) {
+	if (!searchParams) return null;
+
+	// URLSearchParams-like object: has .get()
+	if (typeof searchParams.get === "function") {
+		const value = searchParams.get(key);
+		return typeof value === "string" ? value : null;
+	}
+
+	// Next.js server searchParams: plain object
+	const raw = searchParams[key];
+
+	if (Array.isArray(raw)) {
+		return typeof raw[0] === "string" ? raw[0] : null;
+	}
+
+	return typeof raw === "string" ? raw : null;
+}
+
+function normalizeTrimmedOrNull(value) {
+	if (typeof value !== "string") return null;
+	const s = value.trim();
+	return s ? s : null;
+}
+
+function uniqueStable(items) {
+	const out = [];
+	const seen = new Set();
+
+	for (const it of items) {
+		const s = String(it);
+		if (!s) continue;
+		if (seen.has(s)) continue;
+		seen.add(s);
+		out.push(s);
+	}
+
+	return out;
+}
+
+/**
+ * Parse comma-separated branches param (branches=NL01,NL02).
+ *
+ * @param {string|null} raw
+ * @returns {string[]}
+ */
+export function parseBranchesCsv(raw) {
+	const s = typeof raw === "string" ? raw.trim() : "";
+	if (!s) return [];
+
+	const parts = s
+		.split(",")
+		.map((x) => String(x).trim())
+		.filter(Boolean);
+
+	return uniqueStable(parts);
+}
+
+/**
+ * Serialize branches as comma-separated string.
+ *
+ * @param {string[]|null|undefined} branches
+ * @returns {string|null}
+ */
+export function serializeBranchesCsv(branches) {
+	if (!Array.isArray(branches) || branches.length === 0) return null;
+
+	const cleaned = uniqueStable(
+		branches.map((b) => String(b).trim()).filter(Boolean)
+	);
+
+	return cleaned.length > 0 ? cleaned.join(",") : null;
+}
+
+/**
+ * Parse search URL state from query params.
+ *
+ * Precedence (robust + shareable):
+ * 1) scope=all        -> ALL
+ * 2) scope=multi OR branches=... -> MULTI
+ * 3) branch=... or fallback routeBranch -> SINGLE
+ *
+ * Notes:
+ * - For SINGLE we default to the route branch if branch param is missing.
+ * - For MULTI/ALL we intentionally return branch=null (UI is route-context but API scope is not single).
+ *
+ * @param {URLSearchParams|Record<string, any>|any} searchParams
+ * @param {{ routeBranch?: string|null }=} options
+ * @returns {{
+ *   q: string|null,
+ *   scope: "single"|"multi"|"all",
+ *   branch: string|null,
+ *   branches: string[],
+ *   from: string|null,
+ *   to: string|null
+ * }}
+ */
+export function parseSearchUrlState(searchParams, { routeBranch = null } = {}) {
+	const q = normalizeTrimmedOrNull(readParam(searchParams, "q"));
+
+	const scopeRaw = normalizeTrimmedOrNull(readParam(searchParams, "scope"));
+	const branchRaw = normalizeTrimmedOrNull(readParam(searchParams, "branch"));
+
+	const branches = parseBranchesCsv(readParam(searchParams, "branches"));
+
+	const from = normalizeTrimmedOrNull(readParam(searchParams, "from"));
+	const to = normalizeTrimmedOrNull(readParam(searchParams, "to"));
+
+	let scope = SEARCH_SCOPE.SINGLE;
+
+	if (scopeRaw === "all") {
+		scope = SEARCH_SCOPE.ALL;
+	} else if (scopeRaw === "multi" || branches.length > 0) {
+		scope = SEARCH_SCOPE.MULTI;
+	}
+
+	if (scope === SEARCH_SCOPE.SINGLE) {
+		const fallbackRouteBranch = normalizeTrimmedOrNull(routeBranch);
+		const branch = branchRaw || fallbackRouteBranch || null;
+
+		return {
+			q,
+			scope,
+			branch,
+			branches: [],
+			from,
+			to,
+		};
+	}
+
+	if (scope === SEARCH_SCOPE.MULTI) {
+		return {
+			q,
+			scope,
+			branch: null,
+			branches,
+			from,
+			to,
+		};
+	}
+
+	// ALL
+	return {
+		q,
+		scope: SEARCH_SCOPE.ALL,
+		branch: null,
+		branches: [],
+		from,
+		to,
+	};
+}
+
+/**
+ * Serialize search URL state into a stable query string (no leading "?").
+ *
+ * Stable param ordering:
+ * - q
+ * - scope
+ * - branch
+ * - branches
+ * - from
+ * - to
+ *
+ * @param {{
+ *   q?: string|null,
+ *   scope?: "single"|"multi"|"all"|string|null,
+ *   branch?: string|null,
+ *   branches?: string[]|null,
+ *   from?: string|null,
+ *   to?: string|null
+ * }} state
+ * @returns {string}
+ */
+export function serializeSearchUrlState(state) {
+	const s = state || {};
+	const params = new URLSearchParams();
+
+	const q = normalizeTrimmedOrNull(s.q);
+	const from = normalizeTrimmedOrNull(s.from);
+	const to = normalizeTrimmedOrNull(s.to);
+
+	if (q) params.set("q", q);
+
+	// Scope: we only emit what the UI needs for shareability.
+	if (s.scope === SEARCH_SCOPE.ALL) {
+		params.set("scope", "all");
+	} else if (s.scope === SEARCH_SCOPE.MULTI) {
+		params.set("scope", "multi");
+
+		const csv = serializeBranchesCsv(s.branches);
+		if (csv) params.set("branches", csv);
+	} else {
+		// SINGLE
+		const branch = normalizeTrimmedOrNull(s.branch);
+		if (branch) params.set("branch", branch);
+	}
+
+	// from/to are additive for RHL-025. We allow carrying them already.
+	if (from) params.set("from", from);
+	if (to) params.set("to", to);
+
+	return params.toString();
+}

+ 162 - 0
lib/frontend/search/urlState.test.js

@@ -0,0 +1,162 @@
+/* @vitest-environment node */
+
+import { describe, it, expect } from "vitest";
+import {
+	SEARCH_SCOPE,
+	parseBranchesCsv,
+	serializeBranchesCsv,
+	parseSearchUrlState,
+	serializeSearchUrlState,
+} from "./urlState.js";
+
+describe("lib/frontend/search/urlState", () => {
+	describe("parseBranchesCsv / serializeBranchesCsv", () => {
+		it("parses CSV into a unique, trimmed list", () => {
+			expect(parseBranchesCsv(" NL06, NL20 ,NL06,, ")).toEqual([
+				"NL06",
+				"NL20",
+			]);
+		});
+
+		it("serializes branches into CSV with stable order and dedupe", () => {
+			expect(serializeBranchesCsv(["NL20", " NL06 ", "NL20"])).toBe(
+				"NL20,NL06"
+			);
+		});
+
+		it("returns null when serializing empty branches", () => {
+			expect(serializeBranchesCsv([])).toBe(null);
+			expect(serializeBranchesCsv(null)).toBe(null);
+		});
+	});
+
+	describe("parseSearchUrlState", () => {
+		it("defaults to SINGLE with routeBranch when no params are present", () => {
+			const sp = new URLSearchParams();
+			const state = parseSearchUrlState(sp, { routeBranch: "NL01" });
+
+			expect(state).toEqual({
+				q: null,
+				scope: SEARCH_SCOPE.SINGLE,
+				branch: "NL01",
+				branches: [],
+				from: null,
+				to: null,
+			});
+		});
+
+		it("parses SINGLE with explicit branch param", () => {
+			const sp = new URLSearchParams({ q: " test ", branch: "NL02" });
+			const state = parseSearchUrlState(sp, { routeBranch: "NL01" });
+
+			expect(state.scope).toBe(SEARCH_SCOPE.SINGLE);
+			expect(state.q).toBe("test");
+			expect(state.branch).toBe("NL02");
+			expect(state.branches).toEqual([]);
+		});
+
+		it("parses ALL when scope=all is set (highest precedence)", () => {
+			const sp = new URLSearchParams({
+				q: "x",
+				scope: "all",
+				branch: "NL01",
+				branches: "NL06,NL20",
+			});
+
+			const state = parseSearchUrlState(sp, { routeBranch: "NL99" });
+
+			expect(state).toEqual({
+				q: "x",
+				scope: SEARCH_SCOPE.ALL,
+				branch: null,
+				branches: [],
+				from: null,
+				to: null,
+			});
+		});
+
+		it("parses MULTI when scope=multi is set", () => {
+			const sp = new URLSearchParams({
+				q: " reifen ",
+				scope: "multi",
+				branches: "NL06, NL20, NL06",
+			});
+
+			const state = parseSearchUrlState(sp, { routeBranch: "NL01" });
+
+			expect(state.scope).toBe(SEARCH_SCOPE.MULTI);
+			expect(state.q).toBe("reifen");
+			expect(state.branch).toBe(null);
+			expect(state.branches).toEqual(["NL06", "NL20"]);
+		});
+
+		it("parses MULTI when branches=... is present even without scope", () => {
+			const sp = new URLSearchParams({
+				q: "x",
+				branches: "NL06,NL20",
+			});
+
+			const state = parseSearchUrlState(sp, { routeBranch: "NL01" });
+
+			expect(state.scope).toBe(SEARCH_SCOPE.MULTI);
+			expect(state.branches).toEqual(["NL06", "NL20"]);
+		});
+
+		it("keeps from/to when provided", () => {
+			const sp = new URLSearchParams({
+				q: "x",
+				branch: "NL01",
+				from: "2025-12-01",
+				to: "2025-12-31",
+			});
+
+			const state = parseSearchUrlState(sp, { routeBranch: "NL01" });
+
+			expect(state.from).toBe("2025-12-01");
+			expect(state.to).toBe("2025-12-31");
+		});
+	});
+
+	describe("serializeSearchUrlState", () => {
+		it("serializes SINGLE as q + branch (no scope param)", () => {
+			const qs = serializeSearchUrlState({
+				q: "bridgestone",
+				scope: SEARCH_SCOPE.SINGLE,
+				branch: "NL01",
+			});
+
+			expect(qs).toBe("q=bridgestone&branch=NL01");
+		});
+
+		it("serializes MULTI as q + scope=multi + branches", () => {
+			const qs = serializeSearchUrlState({
+				q: "reifen",
+				scope: SEARCH_SCOPE.MULTI,
+				branches: ["NL06", "NL20"],
+			});
+
+			expect(qs).toBe("q=reifen&scope=multi&branches=NL06%2CNL20");
+		});
+
+		it("serializes ALL as q + scope=all", () => {
+			const qs = serializeSearchUrlState({
+				q: "x",
+				scope: SEARCH_SCOPE.ALL,
+			});
+
+			expect(qs).toBe("q=x&scope=all");
+		});
+
+		it("includes from/to when present (future-proof for RHL-025)", () => {
+			const qs = serializeSearchUrlState({
+				q: "x",
+				scope: SEARCH_SCOPE.SINGLE,
+				branch: "NL01",
+				from: "2025-12-01",
+				to: "2025-12-31",
+			});
+
+			expect(qs).toBe("q=x&branch=NL01&from=2025-12-01&to=2025-12-31");
+		});
+	});
+});