|
@@ -43,19 +43,56 @@ function normalizeTrimmedOrNull(value) {
|
|
|
return s ? s : null;
|
|
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);
|
|
|
|
|
|
|
+function normalizeBranchId(value) {
|
|
|
|
|
+ if (typeof value !== "string") return null;
|
|
|
|
|
+
|
|
|
|
|
+ const trimmed = value.trim();
|
|
|
|
|
+ if (!trimmed) return null;
|
|
|
|
|
+
|
|
|
|
|
+ // Keep it lenient (fail-open): uppercase for consistency.
|
|
|
|
|
+ // Validation is enforced later (backend + error mapping).
|
|
|
|
|
+ return trimmed.toUpperCase();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function toBranchNumber(branchId) {
|
|
|
|
|
+ const m = /^NL(\d+)$/i.exec(String(branchId || "").trim());
|
|
|
|
|
+ if (!m) return null;
|
|
|
|
|
+
|
|
|
|
|
+ const n = Number(m[1]);
|
|
|
|
|
+ return Number.isInteger(n) ? n : null;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function compareBranchIds(a, b) {
|
|
|
|
|
+ const aa = String(a || "");
|
|
|
|
|
+ const bb = String(b || "");
|
|
|
|
|
+
|
|
|
|
|
+ const na = toBranchNumber(aa);
|
|
|
|
|
+ const nb = toBranchNumber(bb);
|
|
|
|
|
+
|
|
|
|
|
+ // Prefer numeric ordering for NLxx patterns when possible.
|
|
|
|
|
+ if (na !== null && nb !== null) return na - nb;
|
|
|
|
|
+
|
|
|
|
|
+ // Stable fallback:
|
|
|
|
|
+ // - valid NL<num> come before unknown shapes
|
|
|
|
|
+ // - otherwise lexicographic
|
|
|
|
|
+ if (na !== null && nb === null) return -1;
|
|
|
|
|
+ if (na === null && nb !== null) return 1;
|
|
|
|
|
+
|
|
|
|
|
+ return aa.localeCompare(bb, "en");
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function uniqueSorted(items) {
|
|
|
|
|
+ const cleaned = [];
|
|
|
|
|
+
|
|
|
|
|
+ for (const it of Array.isArray(items) ? items : []) {
|
|
|
|
|
+ const id = normalizeBranchId(String(it));
|
|
|
|
|
+ if (!id) continue;
|
|
|
|
|
+ cleaned.push(id);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- return out;
|
|
|
|
|
|
|
+ const unique = Array.from(new Set(cleaned));
|
|
|
|
|
+ unique.sort(compareBranchIds);
|
|
|
|
|
+ return unique;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
@@ -68,12 +105,12 @@ export function parseBranchesCsv(raw) {
|
|
|
const s = typeof raw === "string" ? raw.trim() : "";
|
|
const s = typeof raw === "string" ? raw.trim() : "";
|
|
|
if (!s) return [];
|
|
if (!s) return [];
|
|
|
|
|
|
|
|
- const parts = s
|
|
|
|
|
- .split(",")
|
|
|
|
|
- .map((x) => String(x).trim())
|
|
|
|
|
- .filter(Boolean);
|
|
|
|
|
-
|
|
|
|
|
- return uniqueStable(parts);
|
|
|
|
|
|
|
+ return uniqueSorted(
|
|
|
|
|
+ s
|
|
|
|
|
+ .split(",")
|
|
|
|
|
+ .map((x) => String(x).trim())
|
|
|
|
|
+ .filter(Boolean)
|
|
|
|
|
+ );
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
@@ -85,10 +122,7 @@ export function parseBranchesCsv(raw) {
|
|
|
export function serializeBranchesCsv(branches) {
|
|
export function serializeBranchesCsv(branches) {
|
|
|
if (!Array.isArray(branches) || branches.length === 0) return null;
|
|
if (!Array.isArray(branches) || branches.length === 0) return null;
|
|
|
|
|
|
|
|
- const cleaned = uniqueStable(
|
|
|
|
|
- branches.map((b) => String(b).trim()).filter(Boolean)
|
|
|
|
|
- );
|
|
|
|
|
-
|
|
|
|
|
|
|
+ const cleaned = uniqueSorted(branches);
|
|
|
return cleaned.length > 0 ? cleaned.join(",") : null;
|
|
return cleaned.length > 0 ? cleaned.join(",") : null;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -107,15 +141,10 @@ function normalizeLimit(raw) {
|
|
|
/**
|
|
/**
|
|
|
* Parse search URL state from query params.
|
|
* Parse search URL state from query params.
|
|
|
*
|
|
*
|
|
|
- * Precedence (robust + shareable):
|
|
|
|
|
|
|
+ * Precedence:
|
|
|
* 1) scope=all -> ALL
|
|
* 1) scope=all -> ALL
|
|
|
* 2) scope=multi OR branches=... -> MULTI
|
|
* 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).
|
|
|
|
|
- * - limit is restricted to SEARCH_LIMITS for predictable UX and to avoid backend 400s.
|
|
|
|
|
|
|
+ * 3) otherwise -> SINGLE (routeBranch is the source of truth)
|
|
|
*
|
|
*
|
|
|
* @param {URLSearchParams|Record<string, any>|any} searchParams
|
|
* @param {URLSearchParams|Record<string, any>|any} searchParams
|
|
|
* @param {{ routeBranch?: string|null }=} options
|
|
* @param {{ routeBranch?: string|null }=} options
|
|
@@ -133,7 +162,7 @@ export function parseSearchUrlState(searchParams, { routeBranch = null } = {}) {
|
|
|
const q = normalizeTrimmedOrNull(readParam(searchParams, "q"));
|
|
const q = normalizeTrimmedOrNull(readParam(searchParams, "q"));
|
|
|
|
|
|
|
|
const scopeRaw = normalizeTrimmedOrNull(readParam(searchParams, "scope"));
|
|
const scopeRaw = normalizeTrimmedOrNull(readParam(searchParams, "scope"));
|
|
|
- const branchRaw = normalizeTrimmedOrNull(readParam(searchParams, "branch"));
|
|
|
|
|
|
|
+ const branchRaw = normalizeBranchId(readParam(searchParams, "branch"));
|
|
|
|
|
|
|
|
const branches = parseBranchesCsv(readParam(searchParams, "branches"));
|
|
const branches = parseBranchesCsv(readParam(searchParams, "branches"));
|
|
|
|
|
|
|
@@ -151,7 +180,7 @@ export function parseSearchUrlState(searchParams, { routeBranch = null } = {}) {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
if (scope === SEARCH_SCOPE.SINGLE) {
|
|
if (scope === SEARCH_SCOPE.SINGLE) {
|
|
|
- const fallbackRouteBranch = normalizeTrimmedOrNull(routeBranch);
|
|
|
|
|
|
|
+ const fallbackRouteBranch = normalizeBranchId(routeBranch);
|
|
|
const branch = branchRaw || fallbackRouteBranch || null;
|
|
const branch = branchRaw || fallbackRouteBranch || null;
|
|
|
|
|
|
|
|
return {
|
|
return {
|
|
@@ -195,12 +224,14 @@ export function parseSearchUrlState(searchParams, { routeBranch = null } = {}) {
|
|
|
* Stable param ordering:
|
|
* Stable param ordering:
|
|
|
* - q
|
|
* - q
|
|
|
* - scope
|
|
* - scope
|
|
|
- * - branch
|
|
|
|
|
* - branches
|
|
* - branches
|
|
|
* - limit
|
|
* - limit
|
|
|
* - from
|
|
* - from
|
|
|
* - to
|
|
* - to
|
|
|
*
|
|
*
|
|
|
|
|
+ * SINGLE policy:
|
|
|
|
|
+ * - We do NOT emit `branch=` because the path segment `/:branch/search` is the source of truth.
|
|
|
|
|
+ *
|
|
|
* @param {{
|
|
* @param {{
|
|
|
* q?: string|null,
|
|
* q?: string|null,
|
|
|
* scope?: "single"|"multi"|"all"|string|null,
|
|
* scope?: "single"|"multi"|"all"|string|null,
|
|
@@ -236,9 +267,7 @@ export function serializeSearchUrlState(state) {
|
|
|
const csv = serializeBranchesCsv(s.branches);
|
|
const csv = serializeBranchesCsv(s.branches);
|
|
|
if (csv) params.set("branches", csv);
|
|
if (csv) params.set("branches", csv);
|
|
|
} else {
|
|
} else {
|
|
|
- // SINGLE
|
|
|
|
|
- const branch = normalizeTrimmedOrNull(s.branch);
|
|
|
|
|
- if (branch) params.set("branch", branch);
|
|
|
|
|
|
|
+ // SINGLE: no `branch=` in URL (path is SoT)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// Only include non-default limit to keep URLs shorter.
|
|
// Only include non-default limit to keep URLs shorter.
|