From aee74c52c851133161b40009c4802ef149ffeab0 Mon Sep 17 00:00:00 2001 From: Abhay Padamani <141122205+ktx-abhay@users.noreply.github.com> Date: Tue, 3 Nov 2026 06:22:35 +0000 Subject: [PATCH 1/7] fix(recent-searches): distinct toolbar icon, Logs button size, refetch a stale stream list on restore (#16223) --- .../pages/metricsPages/metricsPage.js | 3 - .../queryHistory/RecentSearchesButton.spec.ts | 39 ++-- .../queryHistory/RecentSearchesButton.vue | 86 ++------- .../RecentSearchesDayPicker.spec.ts | 166 ++++++++++++++--- .../queryHistory/RecentSearchesDayPicker.vue | 168 ++++++++++++------ .../queryHistory/RecentSearchesDrawer.spec.ts | 5 +- .../queryHistory/RecentSearchesDrawer.vue | 5 +- .../RecentSearchesPopover.spec.ts | 96 ++++------ .../queryHistory/RecentSearchesPopover.vue | 19 +- .../composables/useLogs.urlPrefetch.spec.ts | 2 +- .../useLogs/useStreamFields.spec.ts | 124 +++++++++++++ .../composables/useLogs/useStreamFields.ts | 58 +++++- web/src/plugins/logs/SearchBar.vue | 6 +- .../metrics/explorer/MetricsExplorer.vue | 3 - web/src/plugins/traces/SearchBar.vue | 5 - 15 files changed, 506 insertions(+), 279 deletions(-) diff --git a/tests/ui-testing/pages/metricsPages/metricsPage.js b/tests/ui-testing/pages/metricsPages/metricsPage.js index 11724938c0..bf94df1bc2 100644 --- a/tests/ui-testing/pages/metricsPages/metricsPage.js +++ b/tests/ui-testing/pages/metricsPages/metricsPage.js @@ -156,8 +156,6 @@ export class MetricsPage { this.toastMessage = page.locator('[data-test="o-toast-message"]'); this.historyButton = page.locator('[data-test="metrics-history-btn"]'); - this.historyPopover = page.locator('[data-test="recent-searches-popover"]'); - this.historyViewAll = this.historyPopover.locator('[data-test="recent-searches-view-all"]'); this.historyDrawer = page.locator('[data-test="recent-searches-drawer"]'); this.historySearch = this.historyDrawer.locator('[data-test="recent-searches-search"] input'); this.historyStarredOnly = this.historyDrawer.locator('[data-test="recent-searches-starred-only"]'); @@ -2806,7 +2804,6 @@ export class MetricsPage { async openQueryHistory() { await this.historyButton.click(); - await this.historyViewAll.click(); await this.historyDrawer.waitFor({ state: 'visible', timeout: 15000 }); } diff --git a/web/src/components/queryHistory/RecentSearchesButton.spec.ts b/web/src/components/queryHistory/RecentSearchesButton.spec.ts index 1d3d21a732..68e3ec2657 100644 --- a/web/src/components/queryHistory/RecentSearchesButton.spec.ts +++ b/web/src/components/queryHistory/RecentSearchesButton.spec.ts @@ -13,25 +13,40 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { mount } from "@vue/test-utils"; import store from "@/test/unit/helpers/store"; -vi.mock("@/services/query_history", () => ({ - default: { list: vi.fn().mockResolvedValue({ data: [] }) }, -})); - import RecentSearchesButton from "./RecentSearchesButton.vue"; +const mountButton = (attrs: Record = {}) => + mount(RecentSearchesButton, { + props: { dataTest: "clock" }, + attrs, + global: { plugins: [store], stubs: { OTooltip: true } }, + }); + describe("RecentSearchesButton", () => { - it("puts a caller's class on the clock button, since the popover renders no element", () => { - const wrapper = mount(RecentSearchesButton, { - props: { source: "logs", dataTest: "clock" }, - attrs: { class: "me-1" }, - global: { plugins: [store], stubs: { RecentSearchesPopover: true, OTooltip: true } }, - }); - const button = wrapper.find('[data-test="clock"]'); + afterEach(() => { + store.state.zoConfig.query_history_enabled = true; + }); + + it("puts a caller's class on the button", () => { + const button = mountButton({ class: "me-1" }).find('[data-test="clock"]'); expect(button.exists()).toBe(true); expect(button.classes()).toContain("me-1"); }); + + it("asks for the full Recent searches drawer on click", async () => { + const wrapper = mountButton(); + await wrapper.find('[data-test="clock"]').trigger("click"); + expect(wrapper.emitted("view-all")).toEqual([[null]]); + }); + + it("is hidden unless history is enabled", () => { + store.state.zoConfig.query_history_enabled = false; + expect(mountButton().find('[data-test="clock"]').exists()).toBe(false); + delete store.state.zoConfig.query_history_enabled; + expect(mountButton().find('[data-test="clock"]').exists()).toBe(false); + }); }); diff --git a/web/src/components/queryHistory/RecentSearchesButton.vue b/web/src/components/queryHistory/RecentSearchesButton.vue index 22e8f778ff..27e0379f0e 100644 --- a/web/src/components/queryHistory/RecentSearchesButton.vue +++ b/web/src/components/queryHistory/RecentSearchesButton.vue @@ -15,98 +15,32 @@ along with this program. If not, see . --> diff --git a/web/src/components/queryHistory/RecentSearchesDayPicker.spec.ts b/web/src/components/queryHistory/RecentSearchesDayPicker.spec.ts index 721ae41c4f..6cf41f8f2b 100644 --- a/web/src/components/queryHistory/RecentSearchesDayPicker.spec.ts +++ b/web/src/components/queryHistory/RecentSearchesDayPicker.spec.ts @@ -37,23 +37,153 @@ const mountPicker = (modelValue?: string, zone?: string) => const cell = (iso: string) => document.querySelector(`[data-test="daterangecalendar-cell-${iso}"]`); +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)).then(flushPromises); + const openCalendar = async (wrapper: ReturnType) => { await wrapper.find('[data-test="ran-on-trigger"]').trigger("click"); await flushPromises(); }; describe("RecentSearchesDayPicker", () => { - it("reads as a date field: the dd/mm/yyyy placeholder, then the chosen day in that format", () => { - const empty = mountPicker(); - const emptyValue = empty.find('[data-test="ran-on-value"]'); - expect(emptyValue.text()).toBe("dd/mm/yyyy"); - expect(emptyValue.classes()).toContain("text-input-placeholder"); - expect(empty.find('[data-test="ran-on-trigger"]').attributes("aria-label")).toBe( - "Filter by date run", + it("is a text field with the dd/mm/yyyy placeholder that shows the chosen day in that format", () => { + const empty = mountPicker().find('[data-test="ran-on-field"]'); + expect(empty.element.tagName).toBe("INPUT"); + expect(empty.attributes("placeholder")).toBe("dd/mm/yyyy"); + expect(empty.element.value).toBe(""); + const picked = mountPicker("2026-10-29").find('[data-test="ran-on-field"]'); + expect(picked.element.value).toBe("29/10/2026"); + }); + + it("applies a typed day on Enter and on leaving the field", async () => { + const wrapper = mountPicker(); + const field = wrapper.find('[data-test="ran-on-field"]'); + await field.setValue("05/10/2026"); + await field.trigger("keydown", { key: "Enter" }); + expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual(["2026-10-05"]); + await field.setValue("6/10/2026"); + await field.trigger("focusout"); + await settle(); + expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual(["2026-10-06"]); + }); + + it("refuses a typed day that is not a date or is after today, and marks the field", async () => { + const wrapper = mountPicker(); + const field = wrapper.find('[data-test="ran-on-field"]'); + await field.setValue("31/02/2026"); + await field.trigger("keydown", { key: "Enter" }); + await field.setValue(DateTime.local().plus({ days: 1 }).toFormat("dd/MM/yyyy")); + await field.trigger("keydown", { key: "Enter" }); + expect(wrapper.emitted("update:modelValue")).toBeUndefined(); + expect(field.attributes("aria-invalid")).toBe("true"); + }); + + it("drops the error mark when an invalid entry is cleared with no day applied", async () => { + const wrapper = mountPicker(); + const field = wrapper.find('[data-test="ran-on-field"]'); + await field.setValue("abc"); + await field.trigger("focusout"); + await settle(); + expect(field.attributes("aria-invalid")).toBe("true"); + await wrapper.find('[data-test="ran-on-clear"]').trigger("click"); + expect(field.attributes("aria-invalid")).toBeUndefined(); + }); + + it("shows the applied day again when the calendar picks it over invalid text", async () => { + const today = DateTime.local(); + const wrapper = mountPicker(today.toISODate()!); + const field = wrapper.find('[data-test="ran-on-field"]'); + await field.setValue("99/99"); + await field.trigger("keydown", { key: "Enter" }); + await openCalendar(wrapper); + cell(today.toISODate()!)!.click(); + await flushPromises(); + expect(field.element.value).toBe(today.toFormat("dd/MM/yyyy")); + expect(field.attributes("aria-invalid")).toBeUndefined(); + }); + + it("rewrites the applied day retyped in another shape", async () => { + const wrapper = mountPicker("2026-02-01"); + const field = wrapper.find('[data-test="ran-on-field"]'); + await field.setValue("1-2-2026"); + await field.trigger("keydown", { key: "Enter" }); + expect(field.element.value).toBe("01/02/2026"); + expect(wrapper.emitted("update:modelValue")).toBeUndefined(); + }); + + it("does not judge a half-typed day when focus moves to its calendar button", async () => { + const wrapper = mountPicker(); + const field = wrapper.find('[data-test="ran-on-field"]'); + await field.setValue("3/1"); + const trigger = wrapper.find('[data-test="ran-on-trigger"]'); + trigger.element.focus(); + await field.trigger("focusout", { relatedTarget: trigger.element }); + await settle(); + expect(field.attributes("aria-invalid")).toBeUndefined(); + }); + + it("does not judge a half-typed day when a press on the calendar button leaves focus nowhere", async () => { + const wrapper = mountPicker(); + const field = wrapper.find('[data-test="ran-on-field"]'); + await field.setValue("3/1"); + await wrapper.find('[data-test="ran-on-trigger"]').trigger("pointerdown"); + await field.trigger("focusout"); + await settle(); + expect(field.attributes("aria-invalid")).toBeUndefined(); + }); + + it("does not judge a half-typed day when Escape closes the calendar back onto its button", async () => { + const wrapper = mountPicker(); + const field = wrapper.find('[data-test="ran-on-field"]'); + await field.setValue("29/10"); + await openCalendar(wrapper); + document.activeElement!.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), ); - const picked = mountPicker("2026-10-29").find('[data-test="ran-on-value"]'); - expect(picked.text()).toBe("29/10/2026"); - expect(picked.classes()).toContain("text-input-text"); + await settle(); + expect(document.querySelector('[data-test="ran-on-calendar"]')).toBeNull(); + expect(field.attributes("aria-invalid")).toBeUndefined(); + }); + + it("judges the text once focus leaves the field from its calendar button", async () => { + const wrapper = mountPicker(); + const field = wrapper.find('[data-test="ran-on-field"]'); + await field.setValue("abc"); + const trigger = wrapper.find('[data-test="ran-on-trigger"]'); + trigger.element.focus(); + await settle(); + trigger.element.blur(); + await trigger.trigger("focusout", { relatedTarget: document.body }); + await settle(); + expect(field.attributes("aria-invalid")).toBe("true"); + }); + + it("sends a calendar pick once", async () => { + const wrapper = mountPicker(); + await openCalendar(wrapper); + cell(DateTime.local().toISODate()!)!.click(); + await settle(); + expect(wrapper.emitted("update:modelValue")).toHaveLength(1); + }); + + it("drops invalid text on reset", async () => { + const wrapper = mountPicker(); + const field = wrapper.find('[data-test="ran-on-field"]'); + await field.setValue("99/99/2026"); + await field.trigger("keydown", { key: "Enter" }); + expect(field.attributes("aria-invalid")).toBe("true"); + (wrapper.vm as unknown as { reset: () => void }).reset(); + await flushPromises(); + expect(field.element.value).toBe(""); + expect(field.attributes("aria-invalid")).toBeUndefined(); + }); + + it("clears the day when the typed text is emptied", async () => { + const wrapper = mountPicker("2026-10-27"); + const field = wrapper.find('[data-test="ran-on-field"]'); + await field.setValue(""); + await field.trigger("focusout"); + await settle(); + expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual([undefined]); }); it("opens the shared range calendar and picks one day with a single click", async () => { @@ -107,21 +237,7 @@ describe("RecentSearchesDayPicker", () => { } }); - it("joins the field and its clear button in a button group, without overriding the buttons' box", () => { - const wrapper = mountPicker("2026-10-27"); - const group = wrapper.find('[data-test="ran-on"]'); - expect(group.attributes("role")).toBe("group"); - const buttons = group.findAll(":scope > button"); - expect(buttons.map((b) => b.attributes("data-test"))).toEqual([ - "ran-on-trigger", - "ran-on-clear", - ]); - const forced = /^(h|rounded|border)(-\S*)?!$/; - buttons.forEach((b) => expect(b.classes().filter((c) => forced.test(c))).toEqual([])); - }); - - it("clears the day with its own button, shown only while a day is set", async () => { - expect(mountPicker().find('[data-test="ran-on-clear"]').exists()).toBe(false); + it("clears the day with the field's clear button", async () => { const wrapper = mountPicker("2026-10-27"); await wrapper.find('[data-test="ran-on-clear"]').trigger("click"); expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual([undefined]); diff --git a/web/src/components/queryHistory/RecentSearchesDayPicker.vue b/web/src/components/queryHistory/RecentSearchesDayPicker.vue index aa65d115d0..603f13b9a7 100644 --- a/web/src/components/queryHistory/RecentSearchesDayPicker.vue +++ b/web/src/components/queryHistory/RecentSearchesDayPicker.vue @@ -15,71 +15,65 @@ along with this program. If not, see . -->