diff --git a/src/api/client.ts b/src/api/client.ts index b6e59df..c1ad8d8 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -97,7 +97,7 @@ function statsRegionParams(r: StatsRegion): { iatas?: string; region?: string } export function getPackets( iatas: string[] | undefined, - params?: { cursor?: number; limit?: number; payloadTypes?: number[]; routeTypes?: number[]; scopes?: string[] }, + params?: { cursor?: number; limit?: number; payloadTypes?: number[]; routeTypes?: number[]; scopes?: string[]; hashPrefix?: string }, ): Promise> { if (noIatas(iatas)) return Promise.resolve(emptyPage()); return request("/packets", { @@ -107,6 +107,7 @@ export function getPackets( payloadTypes: params?.payloadTypes?.length ? params.payloadTypes.join(",") : undefined, routeTypes: params?.routeTypes?.length ? params.routeTypes.join(",") : undefined, scopes: params?.scopes?.length ? params.scopes.join(",") : undefined, + hashPrefix: params?.hashPrefix, }); } diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx index 139a9cf..7e06337 100644 --- a/src/components/SearchBar.tsx +++ b/src/components/SearchBar.tsx @@ -50,6 +50,12 @@ export function SearchBar({ value, onChange, fields, field, onFieldChange, hideF function handleChange(val: string) { setLocalValue(val); if (debounceRef.current) clearTimeout(debounceRef.current); + // clearing should restore the full list right away + if (val === "") { + debounceRef.current = null; + onChange(val); + return; + } debounceRef.current = setTimeout(() => { onChange(val); }, 300); diff --git a/src/features/packets/types.ts b/src/features/packets/types.ts index 351f9ac..47647bf 100644 --- a/src/features/packets/types.ts +++ b/src/features/packets/types.ts @@ -16,6 +16,7 @@ export interface PacketServerFilter { payloadTypes?: number[]; routeTypes?: number[]; scopes?: string[]; + hashPrefix?: string; } export const EMPTY_FILTERS: PacketFilterState = { diff --git a/src/features/packets/usePacketFilters.ts b/src/features/packets/usePacketFilters.ts index 9cc46c7..a968818 100644 --- a/src/features/packets/usePacketFilters.ts +++ b/src/features/packets/usePacketFilters.ts @@ -118,11 +118,18 @@ export function usePacketFilters() { // The /packets endpoint accepts comma-separated payloadTypes/routeTypes/scopes, so any selected // dimension goes server-side and pagination pulls the correctly-filtered set from the full history. // (observers has no server param, so it stays client-side in matchesFilters, as does the live buffer.) +// A 4-16 char hex hash search also goes server-side so older packets can be found. +const HASH_PREFIX_RE = /^[0-9a-f]{4,16}$/i; + export function toServerFilter(filters: PacketFilterState): PacketServerFilter | null { const serverFilter: PacketServerFilter = {}; if (filters.payloadTypes.length > 0) serverFilter.payloadTypes = filters.payloadTypes; if (filters.routeTypes.length > 0) serverFilter.routeTypes = filters.routeTypes; if (filters.scopes.length > 0) serverFilter.scopes = filters.scopes; + const hashSearch = filters.search.trim(); + if (filters.searchField === "hash" && HASH_PREFIX_RE.test(hashSearch)) { + serverFilter.hashPrefix = hashSearch.toLowerCase(); + } return Object.keys(serverFilter).length > 0 ? serverFilter : null; } @@ -151,7 +158,7 @@ export function matchesFilters( return false; } if (filters.search && filters.searchField === "hash") { - const q = filters.search.toLowerCase(); + const q = filters.search.trim().toLowerCase(); if (!packet.packetHash.toLowerCase().includes(q)) { return false; } diff --git a/tests/api/client.test.ts b/tests/api/client.test.ts index a637077..c92cbe5 100644 --- a/tests/api/client.test.ts +++ b/tests/api/client.test.ts @@ -51,6 +51,15 @@ describe("getPackets", () => { expect(url.searchParams.has("scopes")).toBe(false); expect(url.searchParams.get("cursor")).toBe("100"); expect(url.searchParams.get("limit")).toBe("50"); + expect(url.searchParams.has("hashPrefix")).toBe(false); + }); + + it("forwards hashPrefix", async () => { + const getUrl = mockFetchOnce({ items: [], nextCursor: null, hasMore: false }); + + await getPackets(["YOW"], { hashPrefix: "6ddf62" }); + + expect(new URL(getUrl()).searchParams.get("hashPrefix")).toBe("6ddf62"); }); }); diff --git a/tests/components/SearchBar.compact.test.tsx b/tests/components/SearchBar.compact.test.tsx index 2d56d21..21e6e4f 100644 --- a/tests/components/SearchBar.compact.test.tsx +++ b/tests/components/SearchBar.compact.test.tsx @@ -18,3 +18,17 @@ describe("compact observer search", () => { } finally { vi.useRealTimers(); } }); }); + +describe("clearing the search", () => { + it("reports an emptied box immediately instead of waiting out the debounce", () => { + vi.useFakeTimers(); + try { + const change = vi.fn(); + render( {}} hideField inputLabel="Search" />); + fireEvent.change(screen.getByRole("textbox", { name: "Search" }), { target: { value: "" } }); + expect(change).toHaveBeenCalledExactlyOnceWith(""); + act(() => vi.advanceTimersByTime(300)); + expect(change).toHaveBeenCalledOnce(); + } finally { vi.useRealTimers(); } + }); +}); diff --git a/tests/features/packets/PacketList.test.tsx b/tests/features/packets/PacketList.test.tsx index 6601562..06f1405 100644 --- a/tests/features/packets/PacketList.test.tsx +++ b/tests/features/packets/PacketList.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach } from "vitest"; -import { render, screen, fireEvent, within } from "@testing-library/react"; +import { render, screen, fireEvent, within, act } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { PacketList } from "../../../src/features/packets/PacketList"; @@ -367,6 +367,34 @@ describe("PacketList selected packet outside the loaded results", () => { }); }); +describe("PacketList hash search", () => { + afterEach(() => { + vi.useRealTimers(); + usePackets.mockImplementation(basePackets); + }); + + it("sends a typed hex hash search server-side once the search box settles", () => { + vi.useFakeTimers(); + renderList(); + fireEvent.change(screen.getByPlaceholderText("Search by hash..."), { target: { value: "6DDF" } }); + expect(usePackets).toHaveBeenLastCalledWith(false, null); + act(() => { vi.runOnlyPendingTimers(); }); + expect(usePackets).toHaveBeenLastCalledWith(false, { hashPrefix: "6ddf" }); + }); + + it("starts with the URL's hash search already server-side", () => { + renderList("/?q=6ddf62"); + expect(usePackets).toHaveBeenLastCalledWith(false, { hashPrefix: "6ddf62" }); + }); + + it("still filters live packets client-side while a hash search is active", () => { + usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("6ddf0001"), packet("aa110002")] })); + renderList("/?q=6ddf"); + expect(screen.getByRole("button", { name: "6ddf0001" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "aa110002" })).toBeNull(); + }); +}); + describe("PacketList in French", () => { it("translates the banners and the selected-packet card", async () => { await i18n.changeLanguage("fr"); diff --git a/tests/features/packets/usePacketFilters.test.ts b/tests/features/packets/usePacketFilters.test.ts index d6de664..3dca1d1 100644 --- a/tests/features/packets/usePacketFilters.test.ts +++ b/tests/features/packets/usePacketFilters.test.ts @@ -21,6 +21,13 @@ function pkt(over: Partial): PacketSummary { }; } +describe("matchesFilters — hash search", () => { + it("matches a trimmed, case-insensitive substring of the hash", () => { + expect(matchesFilters(pkt({ packetHash: "abcd1234" }), { ...EMPTY_FILTERS, search: " CD12 " })).toBe(true); + expect(matchesFilters(pkt({ packetHash: "abcd1234" }), { ...EMPTY_FILTERS, search: "ffff" })).toBe(false); + }); +}); + describe("matchesFilters — scope", () => { it("ignores scope when no scope filter is set", () => { expect(matchesFilters(pkt({ scope: "#bc" }), EMPTY_FILTERS)).toBe(true); @@ -83,6 +90,23 @@ describe("toServerFilter", () => { expect(toServerFilter({ ...EMPTY_FILTERS, observers: ["o1"] })).toBeNull(); expect(toServerFilter({ ...EMPTY_FILTERS, search: "ab" })).toBeNull(); }); + + it.each(["abcd", "ABCDE", "6ddf62fb5b9a969e", " abcd "])("sends a %j hash search server-side as a trimmed lowercase hashPrefix", (search) => { + expect(toServerFilter({ ...EMPTY_FILTERS, search })).toEqual({ hashPrefix: search.trim().toLowerCase() }); + }); + + it.each(["abc", "6ddf62fb5b9a969e0", "abcg", "ab cd"])("keeps the %j hash search client-side only", (search) => { + expect(toServerFilter({ ...EMPTY_FILTERS, search })).toBeNull(); + }); + + it("never sends a path search as a hashPrefix", () => { + expect(toServerFilter({ ...EMPTY_FILTERS, search: "abcd", searchField: "path" })).toBeNull(); + }); + + it("combines hashPrefix with the other server filters", () => { + expect(toServerFilter({ ...EMPTY_FILTERS, payloadTypes: [4] as PayloadTypeValue[], search: "abcd" })) + .toEqual({ payloadTypes: [4], hashPrefix: "abcd" }); + }); }); function routerAt(url: string) {