feat(search): stock orders by product/supplier; results open in a new tab
STOCK ORDERS The Stock Orders source matched only `purchase_number` and `status`, which made stock POs findable only by our own internal sequence number. Live probe on 2026-10-14: searching "saw chain", "protech" or any line description returned ZERO stock-order hits, while the literal "PO26101404STK" matched. An operator hunting a stock order thinks in product or supplier, not in our numbering, so the source now also matches the resolved supplier name and every line's description, sku, supplier_sku and product_code. Verified live: "pro-tech" -> 13 stock orders (supplier name) "clamp" -> 1 (line description) "chelsea boot" -> 4 (line description) "M36 NUT" -> 2 (line description) Also fixed the matched-surface label: a header match was always tagged "PO number", so a supplier-name hit claimed to have matched the PO number. A "why did this match" tag that's wrong teaches the operator the tags are noise and stops them reading any of them. Both new collections join the cache warm so the first search doesn't pay for them. RESULTS OPEN IN A NEW TAB Switched from next/link to a plain `<a target="_blank" rel="noopener noreferrer">`. next/link does a client-side route change, which UNMOUNTS the search page — so the query, the expanded result set and the scroll position are all gone when you come back. A real anchor in a new tab leaves this tab mounted and untouched, which is the behaviour that was asked for: click a result, look at it, click back to the search tab, and you are exactly where you were. Verified by test: dropping `target` fails the new-tab assertion while the rest of the suite stays green.
This commit is contained in:
parent
f355895c75
commit
f1dc78c9a0
3 changed files with 220 additions and 30 deletions
|
|
@ -138,6 +138,12 @@ describe("UniversalSearchPage — the never-hide-a-cap invariant", () => {
|
|||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
// The session-expiry test swaps the pocketbase module's default
|
||||
// export for a token-less one. Without restoring it that stub
|
||||
// leaks into every later test, which then render no results at all
|
||||
// and fail for a reason that has nothing to do with what they
|
||||
// assert.
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function type(value: string) {
|
||||
|
|
@ -219,3 +225,56 @@ describe("UniversalSearchPage — the never-hide-a-cap invariant", () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("UniversalSearchPage — results open in a new tab", () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
async function type(value: string) {
|
||||
const input = screen.getByPlaceholderText(/Order no, quote no/i);
|
||||
fireEvent.change(input, { target: { value } });
|
||||
await vi.advanceTimersByTimeAsync(400);
|
||||
}
|
||||
|
||||
it("uses a real anchor with target=_blank, not a client-side route change", async () => {
|
||||
// next/link unmounts this page on navigation, which destroys the
|
||||
// query and the result set. A plain anchor in a new tab leaves the
|
||||
// search tab mounted, so clicking away and back returns to exactly
|
||||
// where you were — the behaviour the operator asked for.
|
||||
fetchMock.mockResolvedValue(jsonResponse(RESULT));
|
||||
|
||||
render(<UniversalSearchPage />);
|
||||
await type("truco");
|
||||
|
||||
const link = await screen.findByText("ORD-1");
|
||||
const anchor = link.closest("a");
|
||||
|
||||
expect(anchor).not.toBeNull();
|
||||
expect(anchor).toHaveAttribute("href", "/orders/o1");
|
||||
expect(anchor).toHaveAttribute("target", "_blank");
|
||||
// Required: without noopener the opened page can reach back through
|
||||
// window.opener.
|
||||
expect(anchor).toHaveAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
|
||||
it("keeps the query in the input after a result is opened", async () => {
|
||||
// The whole point of the new tab: returning to this tab must not
|
||||
// have reset the search.
|
||||
fetchMock.mockResolvedValue(jsonResponse(RESULT));
|
||||
|
||||
render(<UniversalSearchPage />);
|
||||
await type("truco");
|
||||
|
||||
await screen.findByText("ORD-1");
|
||||
expect(
|
||||
(screen.getByPlaceholderText(/Order no, quote no/i) as HTMLInputElement).value,
|
||||
).toBe("truco");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Search as SearchIcon, X, Loader2, AlertTriangle, ArrowUpRight } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
|
@ -282,8 +281,30 @@ export default function UniversalSearchPage() {
|
|||
<ul className="divide-y rounded-md border">
|
||||
{group.hits.map((hit) => (
|
||||
<li key={hit.id}>
|
||||
<Link
|
||||
{/*
|
||||
Deliberately a plain <a target="_blank">, not
|
||||
next/link.
|
||||
|
||||
next/link does a CLIENT-SIDE route change, which
|
||||
unmounts this page — so the query, the expanded
|
||||
result set and the scroll position are gone when
|
||||
you come back, and "going back" means a router
|
||||
back that re-runs the search anyway.
|
||||
|
||||
A real anchor with target="_blank" opens the record
|
||||
in a new tab and leaves this tab mounted and
|
||||
untouched. Clicking away and back returns to
|
||||
exactly where you were, results and all.
|
||||
|
||||
rel="noopener noreferrer" is required: without it
|
||||
the opened page gets a handle on this window via
|
||||
window.opener.
|
||||
*/}
|
||||
<a
|
||||
href={hit.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={`Open ${hit.title} in a new tab`}
|
||||
className="flex items-center gap-3 px-3 py-2.5 hover:bg-muted/50"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
|
|
@ -307,7 +328,7 @@ export default function UniversalSearchPage() {
|
|||
</span>
|
||||
)}
|
||||
<ArrowUpRight className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</Link>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -605,6 +605,135 @@ async function searchReferenceCollection<T extends Record<string, unknown>>(
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stock purchase orders.
|
||||
*
|
||||
* Matching the header number alone made these effectively unfindable by
|
||||
* anything a person actually remembers: live probe showed "saw chain",
|
||||
* "protech" and product descriptions returning ZERO Stock Orders hits
|
||||
* while only the literal `PO26101404STK` matched. An operator hunting a
|
||||
* stock order thinks in product or supplier, not in our internal
|
||||
* sequence number, so the source now matches:
|
||||
* - purchase_number / status (header)
|
||||
* - supplier name (resolved from `supplier` on the header)
|
||||
* - every line's description / sku / supplier_sku / product_code
|
||||
*
|
||||
* The whole collection is small (165 headers, a few hundred lines), so
|
||||
* this loads it into the shared reference cache and matches in memory
|
||||
* rather than doing the chunked id dance the orders source needs.
|
||||
*/
|
||||
async function searchStockPurchases(
|
||||
pb: AdminPocketBase,
|
||||
ql: string,
|
||||
): Promise<SourceResult> {
|
||||
const headers = await loadReferenceRows<Record<string, unknown>>(
|
||||
pb,
|
||||
"stock_purchases",
|
||||
"id,purchase_number,status,supplier",
|
||||
);
|
||||
if (headers.length === 0) {
|
||||
return { type: "stock_purchase", label: "Stock Orders", hits: [], total: 0, truncated: false };
|
||||
}
|
||||
|
||||
const lines = await loadReferenceRows<Record<string, unknown>>(
|
||||
pb,
|
||||
"stock_purchase_items",
|
||||
"id,purchase,description,sku,supplier_sku,product_code",
|
||||
);
|
||||
const supplierIds = Array.from(
|
||||
new Set(headers.map((h) => String(h.supplier ?? "")).filter(Boolean)),
|
||||
);
|
||||
const supplierNames = await fetchSupplierNameMap(pb, supplierIds);
|
||||
|
||||
const linesByPurchase = new Map<string, Record<string, unknown>[]>();
|
||||
for (const line of lines) {
|
||||
const key = String(line.purchase ?? "");
|
||||
if (!key) continue;
|
||||
const bucket = linesByPurchase.get(key);
|
||||
if (bucket) bucket.push(line);
|
||||
else linesByPurchase.set(key, [line]);
|
||||
}
|
||||
|
||||
const scored: { hit: UniversalHit; score: number }[] = [];
|
||||
for (const header of headers) {
|
||||
const id = String(header.id);
|
||||
const poLines = linesByPurchase.get(id) ?? [];
|
||||
const supplierName = supplierNames.get(String(header.supplier ?? ""));
|
||||
|
||||
let score = bestScore(
|
||||
[
|
||||
[String(header.purchase_number ?? ""), "id"],
|
||||
[supplierName, "name"],
|
||||
[String(header.status ?? ""), "body"],
|
||||
],
|
||||
ql,
|
||||
);
|
||||
// Label the surface that ACTUALLY matched, not just "a header
|
||||
// matched" — a hit tagged "PO number" when the query was a supplier
|
||||
// name tells the operator the tag is noise and stops them trusting
|
||||
// any of them.
|
||||
let matchedOn: string | undefined;
|
||||
if (score > 0) {
|
||||
matchedOn = scoreField(String(header.purchase_number ?? ""), ql, "id") > 0
|
||||
? "PO number"
|
||||
: scoreField(supplierName, ql, "name") > 0
|
||||
? "supplier"
|
||||
: "status";
|
||||
}
|
||||
|
||||
if (score === 0) {
|
||||
for (const line of poLines) {
|
||||
const s = bestScore(
|
||||
[
|
||||
[String(line.sku ?? ""), "id"],
|
||||
[String(line.supplier_sku ?? ""), "id"],
|
||||
[String(line.description ?? ""), "name"],
|
||||
],
|
||||
ql,
|
||||
);
|
||||
if (s > 0) {
|
||||
score = s;
|
||||
matchedOn =
|
||||
String(line.sku ?? "").toLowerCase().includes(ql) ||
|
||||
String(line.supplier_sku ?? "").toLowerCase().includes(ql)
|
||||
? "SKU"
|
||||
: "item";
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (score === 0) continue;
|
||||
|
||||
scored.push({
|
||||
score,
|
||||
hit: {
|
||||
id,
|
||||
title: String(header.purchase_number ?? id),
|
||||
subtitle: [
|
||||
`Stock PO · ${header.status ?? ""}`,
|
||||
supplierName ? String(supplierNames.get(String(header.supplier))) : "",
|
||||
poLines[0] ? String(poLines[0].description ?? "") : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · "),
|
||||
meta: `${poLines.length} line${poLines.length === 1 ? "" : "s"}`,
|
||||
href: `/stock-orders/${id}`,
|
||||
matchedOn,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
scored.sort((a, b) => b.score - a.score);
|
||||
return {
|
||||
type: "stock_purchase",
|
||||
label: "Stock Orders",
|
||||
hits: scored.slice(0, HITS_PER_GROUP).map((s) => s.hit),
|
||||
total: scored.length,
|
||||
truncated: scored.length > HITS_PER_GROUP,
|
||||
};
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* Orchestrator
|
||||
* ========================================================================== */
|
||||
|
|
@ -665,8 +794,13 @@ export async function warmUniversalSearchCaches(): Promise<void> {
|
|||
loadReferenceRows(pb, "customers", "id,customer,email,number"),
|
||||
|
||||
loadReferenceRows(pb, "stock_items", "id,name,sku,description,unit"),
|
||||
loadReferenceRows(pb, "purchase_orders", "id,po_number,status"),
|
||||
loadReferenceRows(pb, "stock_purchases", "id,purchase_number,status"),
|
||||
loadReferenceRows(pb, "purchase_orders", "id,po_number,status,order"),
|
||||
loadReferenceRows(pb, "stock_purchases", "id,purchase_number,status,supplier"),
|
||||
loadReferenceRows(
|
||||
pb,
|
||||
"stock_purchase_items",
|
||||
"id,purchase,description,sku,supplier_sku,product_code",
|
||||
),
|
||||
]);
|
||||
} catch {
|
||||
// Best-effort. The first real search will simply rebuild.
|
||||
|
|
@ -744,31 +878,7 @@ export async function universalSearch(
|
|||
),
|
||||
|
||||
runSource("stock_purchase", "Stock Orders", () =>
|
||||
searchReferenceCollection<Record<string, unknown>>(
|
||||
pb,
|
||||
{
|
||||
type: "stock_purchase",
|
||||
label: "Stock Orders",
|
||||
collection: "stock_purchases",
|
||||
fields: "id,purchase_number,status",
|
||||
score: (r, q) =>
|
||||
bestScore(
|
||||
[
|
||||
[String(r.purchase_number ?? ""), "id"],
|
||||
[String(r.status ?? ""), "body"],
|
||||
],
|
||||
q,
|
||||
),
|
||||
build: (r) => ({
|
||||
id: String(r.id),
|
||||
title: String(r.purchase_number ?? r.id),
|
||||
subtitle: `Stock PO · ${r.status ?? ""}`,
|
||||
href: `/stock-orders/${r.id}`,
|
||||
matchedOn: "PO number",
|
||||
}),
|
||||
},
|
||||
ql,
|
||||
),
|
||||
searchStockPurchases(pb, ql),
|
||||
),
|
||||
|
||||
runSource("supplier", "Suppliers", () =>
|
||||
|
|
|
|||
Loading…
Reference in a new issue