From 27dd098e9a17c634b12f25dccd999850fc128424 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Thu, 3 Sep 2026 16:48:41 +0200 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20Per-browser=20search=20history?= =?UTF-8?q?=20under=20the=20search=20field,=20with=20rerun?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Any query that used search text or an endpoint is remembered in this browser (most recent first, capped, no duplicates) together with the time-range expressions it ran with: the range is part of a search's identity, entries show it worded like the picker ("now-1h", "start to end"), and rerunning restores it so an entry is a true "run this exact query again". The history floats under the search field while it has focus, the way a browser's address bar does; typing narrows the list to entries whose search text or endpoint match, Escape dismisses it and a click in the field brings it back. It is not a Popover API popover: light dismiss would close it on a click in the very field it belongs to. --- .../src/components/audit/FiltersPanel.vue | 5 +- .../components/audit/SearchHistory.spec.ts | 125 +++++++++++ .../src/components/audit/SearchHistory.vue | 209 ++++++++++++++++++ .../src/components/audit/searchHistory.ts | 67 ++++++ .../audit/searchHistoryStorage.spec.ts | 89 ++++++++ src/Frontend/src/stores/AuditStore.spec.ts | 15 ++ src/Frontend/src/stores/AuditStore.ts | 12 + 7 files changed, 521 insertions(+), 1 deletion(-) create mode 100644 src/Frontend/src/components/audit/SearchHistory.spec.ts create mode 100644 src/Frontend/src/components/audit/SearchHistory.vue create mode 100644 src/Frontend/src/components/audit/searchHistory.ts create mode 100644 src/Frontend/src/components/audit/searchHistoryStorage.spec.ts diff --git a/src/Frontend/src/components/audit/FiltersPanel.vue b/src/Frontend/src/components/audit/FiltersPanel.vue index 19d97892b..d716ef1f4 100644 --- a/src/Frontend/src/components/audit/FiltersPanel.vue +++ b/src/Frontend/src/components/audit/FiltersPanel.vue @@ -5,6 +5,7 @@ import { useAuditStore } from "@/stores/AuditStore"; import ListFilterSelector from "@/components/audit/ListFilterSelector.vue"; import { computed } from "vue"; import SuperDatePicker from "@/components/audit/SuperDatePicker.vue"; +import SearchHistory from "@/components/audit/SearchHistory.vue"; const store = useAuditStore(); const { messageFilterString, selectedEndpointName, endpoints } = storeToRefs(store); @@ -18,7 +19,9 @@ const endpointNames = computed(() => {
- + + +
Check the documentation to see the available filtering options
diff --git a/src/Frontend/src/components/audit/SearchHistory.spec.ts b/src/Frontend/src/components/audit/SearchHistory.spec.ts new file mode 100644 index 000000000..9be46167a --- /dev/null +++ b/src/Frontend/src/components/audit/SearchHistory.spec.ts @@ -0,0 +1,125 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/vue"; +import { createTestingPinia } from "@pinia/testing"; +import SearchHistory from "@/components/audit/SearchHistory.vue"; +import { useAuditStore } from "@/stores/AuditStore"; + +function renderHistory(entries: { search: string; endpoint: string; from?: string; to?: string; at: string }[]) { + const pinia = createTestingPinia({ createSpy: vi.fn, initialState: { AuditStore: { searchHistory: entries } } }); + // The history wraps the search field it belongs to + render(SearchHistory, { global: { plugins: [pinia] }, slots: { default: '' } }); + return useAuditStore(pinia); +} + +const searchField = () => screen.getByLabelText("Search messages") as HTMLInputElement; +const openHistory = () => fireEvent.focusIn(searchField()); +const panel = () => screen.queryByRole("listbox", { name: "Recent searches" }); + +describe("FEATURE: Search history panel under the search field", () => { + beforeEach(() => { + localStorage.clear(); + }); + + test("EXAMPLE: Entries list the search, the endpoint and when they ran", async () => { + renderHistory([{ search: "orders", endpoint: "Sales.Endpoint", at: new Date().toISOString() }]); + + await openHistory(); + + expect(screen.getByText("orders")).toBeInTheDocument(); + expect(screen.getByText("@ Sales.Endpoint")).toBeInTheDocument(); + }); + + test("EXAMPLE: Entries show the time range they ran with, worded like the picker", async () => { + renderHistory([ + { search: "orders", endpoint: "", from: "now-1h", to: "now", at: new Date().toISOString() }, + { search: "invoices", endpoint: "", from: "2026-09-01 08:00", to: "2026-09-01 12:00", at: new Date().toISOString() }, + ]); + + await openHistory(); + + expect(screen.getByText("now-1h")).toBeInTheDocument(); + expect(screen.getByText("2026-09-01 08:00 to 2026-09-01 12:00")).toBeInTheDocument(); + }); + + test("EXAMPLE: Clicking an entry reruns it, time range included", async () => { + const store = renderHistory([{ search: "orders", endpoint: "Sales.Endpoint", from: "now-1h", to: "now", at: new Date().toISOString() }]); + + await openHistory(); + await fireEvent.click(screen.getByText("orders")); + + expect(store.messageFilterString).toBe("orders"); + expect(store.selectedEndpointName).toBe("Sales.Endpoint"); + expect(store.timeRangeFrom).toBe("now-1h"); + expect(store.timeRangeTo).toBe("now"); + }); + + test("EXAMPLE: Rerunning an entry recorded before ranges were captured leaves the current range alone", async () => { + const store = renderHistory([{ search: "orders", endpoint: "", at: new Date().toISOString() }]); + store.timeRangeFrom = "now-6h"; + store.timeRangeTo = "now"; + + await openHistory(); + await fireEvent.click(screen.getByText("orders")); + + expect(store.timeRangeFrom).toBe("now-6h"); + expect(store.timeRangeTo).toBe("now"); + }); + + test("EXAMPLE: The panel opens when the search field receives focus and closes on Escape", async () => { + renderHistory([{ search: "orders", endpoint: "", at: new Date().toISOString() }]); + + expect(panel()).not.toBeInTheDocument(); + + await openHistory(); + expect(panel()).toBeInTheDocument(); + + await fireEvent.keyDown(searchField(), { key: "Escape" }); + expect(panel()).not.toBeInTheDocument(); + }); + + test("EXAMPLE: Clicking the search field re-opens the panel after it was dismissed", async () => { + renderHistory([{ search: "orders", endpoint: "", at: new Date().toISOString() }]); + + await openHistory(); + await fireEvent.keyDown(searchField(), { key: "Escape" }); + await fireEvent.click(searchField()); + + expect(panel()).toBeInTheDocument(); + }); + + test("EXAMPLE: Typing narrows the panel to the searches that match", async () => { + renderHistory([ + { search: "orders", endpoint: "", at: new Date().toISOString() }, + { search: "invoices", endpoint: "Billing", at: new Date().toISOString() }, + ]); + + await openHistory(); + await fireEvent.input(searchField(), { target: { value: "inv" } }); + + expect(screen.queryByText("orders")).not.toBeInTheDocument(); + expect(screen.getByText("invoices")).toBeInTheDocument(); + + await fireEvent.input(searchField(), { target: { value: "bill" } }); + expect(screen.getByText("invoices")).toBeInTheDocument(); + + await fireEvent.input(searchField(), { target: { value: "zzz" } }); + expect(panel()).not.toBeInTheDocument(); + }); + + test("EXAMPLE: An empty history shows no panel, even on focus", async () => { + renderHistory([]); + + await openHistory(); + + expect(panel()).not.toBeInTheDocument(); + }); + + test("EXAMPLE: Clear history goes through the store", async () => { + const store = renderHistory([{ search: "orders", endpoint: "", at: new Date().toISOString() }]); + + await openHistory(); + await fireEvent.click(screen.getByText("Clear history")); + + expect(store.clearSearchHistory).toHaveBeenCalled(); + }); +}); diff --git a/src/Frontend/src/components/audit/SearchHistory.vue b/src/Frontend/src/components/audit/SearchHistory.vue new file mode 100644 index 000000000..e41cf692d --- /dev/null +++ b/src/Frontend/src/components/audit/SearchHistory.vue @@ -0,0 +1,209 @@ + + + + + diff --git a/src/Frontend/src/components/audit/searchHistory.ts b/src/Frontend/src/components/audit/searchHistory.ts new file mode 100644 index 000000000..9c53224af --- /dev/null +++ b/src/Frontend/src/components/audit/searchHistory.ts @@ -0,0 +1,67 @@ +// Per-browser history of audit searches (search text and/or endpoint), most +// recently used first. Re-running or re-entering an existing search bumps it to +// the front instead of duplicating it; the least recently used entry falls off +// once the list is full. + +export interface SearchHistoryEntry { + search: string; + endpoint: string; + // Time-range expressions the search ran with ("" on both = no time filter). + // Absent on entries recorded before ranges were captured; rerunning those + // leaves the current range untouched. + from?: string; + to?: string; + at: string; // ISO timestamp of last use +} + +const STORAGE_KEY = "audit.searchHistory"; +export const searchHistoryLimit = 10; + +export function loadSearchHistory(): SearchHistoryEntry[] { + try { + const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? ""); + if (Array.isArray(stored)) { + const optionalString = (v: unknown) => v === undefined || typeof v === "string"; + return stored.filter((e) => e && typeof e.search === "string" && typeof e.endpoint === "string" && typeof e.at === "string" && optionalString(e.from) && optionalString(e.to)); + } + } catch { + // fall through to empty history + } + return []; +} + +function save(entries: SearchHistoryEntry[]): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(entries)); + } catch { + // storage unavailable: history just doesn't persist + } +} + +// Records a use of (search, endpoint, time range) and returns the updated +// history. The range is part of a search's identity: the same text over a +// different window is a different query. +export function recordSearch(search: string, endpoint: string, range: { from: string; to: string }, now: () => Date = () => new Date()): SearchHistoryEntry[] { + const trimmedSearch = search.trim(); + const trimmedEndpoint = endpoint.trim(); + if (trimmedSearch === "" && trimmedEndpoint === "") { + return loadSearchHistory(); + } + + const from = range.from.trim(); + const to = range.to.trim(); + const entries = loadSearchHistory().filter((e) => !(e.search === trimmedSearch && e.endpoint === trimmedEndpoint && e.from === from && e.to === to)); + entries.unshift({ search: trimmedSearch, endpoint: trimmedEndpoint, from, to, at: now().toISOString() }); + const capped = entries.slice(0, searchHistoryLimit); + save(capped); + return capped; +} + +export function clearSearchHistory(): SearchHistoryEntry[] { + try { + localStorage.removeItem(STORAGE_KEY); + } catch { + // ignore + } + return []; +} diff --git a/src/Frontend/src/components/audit/searchHistoryStorage.spec.ts b/src/Frontend/src/components/audit/searchHistoryStorage.spec.ts new file mode 100644 index 000000000..2fd033d60 --- /dev/null +++ b/src/Frontend/src/components/audit/searchHistoryStorage.spec.ts @@ -0,0 +1,89 @@ +import { beforeEach, describe, expect, test } from "vitest"; +import { clearSearchHistory, loadSearchHistory, recordSearch, searchHistoryLimit } from "@/components/audit/searchHistory"; + +const at = (iso: string) => () => new Date(iso); +const last6h = { from: "now-6h", to: "now" }; +const lastHour = { from: "now-1h", to: "now" }; + +describe("FEATURE: Audit search history", () => { + beforeEach(() => { + localStorage.clear(); + }); + + test("EXAMPLE: The most recent search is listed first", () => { + recordSearch("orders", "", last6h, at("2026-09-03T10:00:00Z")); + recordSearch("", "Sales.Endpoint", last6h, at("2026-09-03T11:00:00Z")); + + const history = loadSearchHistory(); + expect(history.map((e) => e.search)).toEqual(["", "orders"]); + expect(history[0].endpoint).toBe("Sales.Endpoint"); + }); + + test("EXAMPLE: The time range the search ran with is captured", () => { + recordSearch("orders", "", lastHour, at("2026-09-03T10:00:00Z")); + + expect(loadSearchHistory()[0]).toMatchObject({ search: "orders", from: "now-1h", to: "now" }); + }); + + test("EXAMPLE: Re-running a search bumps it to the front instead of duplicating", () => { + recordSearch("orders", "", last6h, at("2026-09-03T10:00:00Z")); + recordSearch("invoices", "", last6h, at("2026-09-03T11:00:00Z")); + const history = recordSearch("orders", "", last6h, at("2026-09-03T12:00:00Z")); + + expect(history).toHaveLength(2); + expect(history[0]).toMatchObject({ search: "orders", at: "2026-09-03T12:00:00.000Z" }); + }); + + test("EXAMPLE: The same text on a different endpoint is a different search", () => { + recordSearch("orders", "A", last6h, at("2026-09-03T10:00:00Z")); + const history = recordSearch("orders", "B", last6h, at("2026-09-03T11:00:00Z")); + + expect(history).toHaveLength(2); + }); + + test("EXAMPLE: The same text over a different time range is a different search", () => { + recordSearch("orders", "", last6h, at("2026-09-03T10:00:00Z")); + const history = recordSearch("orders", "", lastHour, at("2026-09-03T11:00:00Z")); + + expect(history).toHaveLength(2); + expect(history[0].from).toBe("now-1h"); + }); + + test("EXAMPLE: Entries recorded before ranges were captured still load", () => { + localStorage.setItem("audit.searchHistory", JSON.stringify([{ search: "orders", endpoint: "", at: "2026-09-03T10:00:00.000Z" }])); + + const history = loadSearchHistory(); + expect(history).toHaveLength(1); + expect(history[0].from).toBeUndefined(); + }); + + test("EXAMPLE: The least recently used entry falls off when the list is full", () => { + for (let i = 0; i < searchHistoryLimit + 1; i++) { + recordSearch(`term-${i}`, "", last6h, at(`2026-09-03T10:${String(i).padStart(2, "0")}:00Z`)); + } + + const history = loadSearchHistory(); + expect(history).toHaveLength(searchHistoryLimit); + expect(history.some((e) => e.search === "term-0")).toBe(false); + expect(history[0].search).toBe(`term-${searchHistoryLimit}`); + }); + + test("EXAMPLE: Queries without search text or endpoint are not recorded", () => { + recordSearch(" ", "", last6h, at("2026-09-03T10:00:00Z")); + + expect(loadSearchHistory()).toHaveLength(0); + }); + + test("EXAMPLE: Corrupt storage yields an empty history", () => { + localStorage.setItem("audit.searchHistory", "{nonsense"); + + expect(loadSearchHistory()).toEqual([]); + }); + + test("EXAMPLE: Clearing empties the history", () => { + recordSearch("orders", "", last6h, at("2026-09-03T10:00:00Z")); + + expect(clearSearchHistory()).toEqual([]); + expect(loadSearchHistory()).toEqual([]); + }); +}); diff --git a/src/Frontend/src/stores/AuditStore.spec.ts b/src/Frontend/src/stores/AuditStore.spec.ts index 4cccb8d3f..dad1797a7 100644 --- a/src/Frontend/src/stores/AuditStore.spec.ts +++ b/src/Frontend/src/stores/AuditStore.spec.ts @@ -118,6 +118,21 @@ describe("AuditStore refresh", () => { expect(store.queryDurationMs).toBeGreaterThanOrEqual(0); }); + test("a query with search text or endpoint is recorded in the search history", async () => { + fetchTypedFromServiceControl.mockResolvedValue([responseWithTotalCount(0), []]); + const store = useAuditStore(); + + await store.refresh(); // no search, no endpoint: nothing recorded + expect(store.searchHistory).toHaveLength(0); + + store.messageFilterString = "orders"; + await store.refresh(); + await store.refresh(); // repeat does not duplicate + + expect(store.searchHistory).toHaveLength(1); + expect(store.searchHistory[0]).toMatchObject({ search: "orders", endpoint: "" }); + }); + test("cancelQuery aborts the query in flight without reporting a failure", async () => { const store = useAuditStore(); diff --git a/src/Frontend/src/stores/AuditStore.ts b/src/Frontend/src/stores/AuditStore.ts index e1877774f..b2aad3255 100644 --- a/src/Frontend/src/stores/AuditStore.ts +++ b/src/Frontend/src/stores/AuditStore.ts @@ -7,6 +7,7 @@ import type { DateRange } from "@/types/date"; import serviceControlClient from "@/components/serviceControlClient"; import auditClient from "@/components/audit/auditClient"; import { loadDefaultRange, resolveTimeRange } from "@/components/audit/timeRange"; +import { clearSearchHistory, loadSearchHistory, recordSearch } from "@/components/audit/searchHistory"; export enum FieldNames { TimeSent = "time_sent", @@ -37,6 +38,7 @@ export const useAuditStore = defineStore("AuditStore", () => { // of the query that produced the current results const queryStartedAt = ref(null); const queryDurationMs = ref(null); + const searchHistory = ref(loadSearchHistory()); let activeQuery: AbortController | null = null; async function loadEndpoints() { @@ -63,6 +65,10 @@ export const useAuditStore = defineStore("AuditStore", () => { const started = performance.now(); queryStartedAt.value = Date.now(); + if (messageFilterString.value.trim() !== "" || selectedEndpointName.value.trim() !== "") { + searchHistory.value = recordSearch(messageFilterString.value, selectedEndpointName.value, { from: timeRangeFrom.value, to: timeRangeTo.value }); + } + try { const [response, data] = await auditClient.getMessages( { @@ -107,6 +113,10 @@ export const useAuditStore = defineStore("AuditStore", () => { // Stops the in-flight query, e.g. when the view showing the results is left. // The abort propagates through the ServiceControl API and terminates the // database query, so a backgrounded view does not keep load on the server. + function clearHistory() { + searchHistory.value = clearSearchHistory(); + } + function cancelQuery() { activeQuery?.abort(); activeQuery = null; @@ -140,6 +150,8 @@ export const useAuditStore = defineStore("AuditStore", () => { queryFailed, queryStartedAt, queryDurationMs, + searchHistory, + clearSearchHistory: clearHistory, }; }); From 96f3680f78906cb07d69d1aed27475fe82801e6f Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Wed, 9 Sep 2026 14:04:54 +0200 Subject: [PATCH 2/2] =?UTF-8?q?=E2=9C=A8=20Make=20the=20recent-searches=20?= =?UTF-8?q?panel=20a=20combobox=20that=20closes=20once=20the=20search=20is?= =?UTF-8?q?=20submitted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel stayed open while results were on screen, and its entries were ordinary buttons after the field, so Tab walked into the history instead of leaving it. It now behaves like an address bar's suggestions: the entries are out of the tab order, Arrow Up/Down move a highlight, Enter reruns the highlighted entry, Escape and Tab close, and so does focus leaving the field and the panel. Submitting a search closes it as well, whether by Enter or by the debounced text reaching the store; typing again brings it back, filtered by the new text. The field announces itself as a combobox controlling the list, with the highlighted entry as its active descendant. --- .../components/audit/SearchHistory.spec.ts | 104 ++++++++++++++++++ .../src/components/audit/SearchHistory.vue | 99 +++++++++++++++-- 2 files changed, 194 insertions(+), 9 deletions(-) diff --git a/src/Frontend/src/components/audit/SearchHistory.spec.ts b/src/Frontend/src/components/audit/SearchHistory.spec.ts index 9be46167a..2ae1d02d0 100644 --- a/src/Frontend/src/components/audit/SearchHistory.spec.ts +++ b/src/Frontend/src/components/audit/SearchHistory.spec.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import { render, screen, fireEvent } from "@testing-library/vue"; +import { nextTick } from "vue"; import { createTestingPinia } from "@pinia/testing"; import SearchHistory from "@/components/audit/SearchHistory.vue"; import { useAuditStore } from "@/stores/AuditStore"; @@ -122,4 +123,107 @@ describe("FEATURE: Search history panel under the search field", () => { expect(store.clearSearchHistory).toHaveBeenCalled(); }); + + describe("RULE: The panel closes once the search is submitted, and typing brings it back", () => { + test("EXAMPLE: The search reaching the store closes the panel", async () => { + const store = renderHistory([{ search: "orders", endpoint: "", at: new Date().toISOString() }]); + await openHistory(); + expect(panel()).toBeInTheDocument(); + + // what the debounced search field does once the user pauses + store.messageFilterString = "ord"; + await nextTick(); + + expect(panel()).not.toBeInTheDocument(); + }); + + test("EXAMPLE: Typing again after that reopens the panel", async () => { + const store = renderHistory([{ search: "orders", endpoint: "", at: new Date().toISOString() }]); + await openHistory(); + store.messageFilterString = "ord"; + await nextTick(); + + await fireEvent.input(searchField(), { target: { value: "orde" } }); + + expect(panel()).toBeInTheDocument(); + }); + + test("EXAMPLE: Enter submits the search and closes the panel", async () => { + renderHistory([{ search: "orders", endpoint: "", at: new Date().toISOString() }]); + await openHistory(); + + await fireEvent.keyDown(searchField(), { key: "Enter" }); + + expect(panel()).not.toBeInTheDocument(); + }); + }); + + describe("RULE: The panel is a combobox: arrows move, Enter picks, Tab and blur leave", () => { + const entries = () => [ + { search: "orders", endpoint: "", at: new Date().toISOString() }, + { search: "invoices", endpoint: "Billing", at: new Date().toISOString() }, + ]; + + test("EXAMPLE: Entries are not tab stops, and Tab closes the panel", async () => { + renderHistory(entries()); + await openHistory(); + + const options = screen.getAllByRole("option"); + expect(options.every((option) => option.getAttribute("tabindex") === "-1")).toBe(true); + + await fireEvent.keyDown(searchField(), { key: "Tab" }); + expect(panel()).not.toBeInTheDocument(); + }); + + test("EXAMPLE: Arrow keys move a highlight and Enter reruns the highlighted entry", async () => { + const store = renderHistory(entries()); + await openHistory(); + + await fireEvent.keyDown(searchField(), { key: "ArrowDown" }); + await fireEvent.keyDown(searchField(), { key: "ArrowDown" }); + expect(screen.getAllByRole("option")[1]).toHaveAttribute("aria-selected", "true"); + + await fireEvent.keyDown(searchField(), { key: "Enter" }); + + expect(store.messageFilterString).toBe("invoices"); + expect(store.selectedEndpointName).toBe("Billing"); + expect(panel()).not.toBeInTheDocument(); + }); + + test("EXAMPLE: Arrow Down opens a closed panel", async () => { + renderHistory(entries()); + await openHistory(); + await fireEvent.keyDown(searchField(), { key: "Escape" }); + expect(panel()).not.toBeInTheDocument(); + + await fireEvent.keyDown(searchField(), { key: "ArrowDown" }); + + expect(panel()).toBeInTheDocument(); + }); + + test("EXAMPLE: The field announces itself as a combobox that controls the list", async () => { + renderHistory(entries()); + await openHistory(); + + const field = searchField(); + expect(field).toHaveAttribute("role", "combobox"); + expect(field).toHaveAttribute("aria-expanded", "true"); + expect(field.getAttribute("aria-controls")).toBe(panel()!.id); + + await fireEvent.keyDown(field, { key: "ArrowDown" }); + expect(field.getAttribute("aria-activedescendant")).toBe(screen.getAllByRole("option")[0].id); + }); + + test("EXAMPLE: Focus leaving the field and the panel closes it", async () => { + renderHistory(entries()); + const outside = document.createElement("button"); + document.body.appendChild(outside); + await openHistory(); + + await fireEvent.focusOut(searchField(), { relatedTarget: outside }); + + expect(panel()).not.toBeInTheDocument(); + outside.remove(); + }); + }); }); diff --git a/src/Frontend/src/components/audit/SearchHistory.vue b/src/Frontend/src/components/audit/SearchHistory.vue index e41cf692d..62648675d 100644 --- a/src/Frontend/src/components/audit/SearchHistory.vue +++ b/src/Frontend/src/components/audit/SearchHistory.vue @@ -1,5 +1,5 @@