From 227e7d4429b60f5acd26f9306aee5589ec3e7f2e Mon Sep 17 00:00:00 2001 From: ktx-vaidehi Date: Tue, 3 Nov 2026 15:01:09 +0530 Subject: [PATCH] fix(ui): Auto Run does not wait on the filter builder's resting picker The builder reopens its field list after each condition, and live preview held any open listbox for up to 5 s, so a PromQL label pick ran the chart about 5 s late instead of right away as on main. The resting list is now marked and not waited on; a list mid-edit still is. --- .../filterBar/FilterBuilder.spec.ts | 27 +++++++++++++++++++ .../components/filterBar/QueryFilterBar.vue | 6 +++++ .../dashboard/usePanelLivePreview.spec.ts | 6 +++++ .../dashboard/usePanelLivePreview.ts | 4 +-- .../lib/forms/ChipInput/OChipInput.types.ts | 2 ++ web/src/lib/forms/ChipInput/OChipInput.vue | 2 ++ 6 files changed, 45 insertions(+), 2 deletions(-) diff --git a/web/src/components/filterBar/FilterBuilder.spec.ts b/web/src/components/filterBar/FilterBuilder.spec.ts index 808292b209..439f892945 100644 --- a/web/src/components/filterBar/FilterBuilder.spec.ts +++ b/web/src/components/filterBar/FilterBuilder.spec.ts @@ -123,6 +123,33 @@ async function addEq(field: string, value: string) { } describe("FilterBuilder", () => { + describe("resting list signal for live preview", () => { + const listbox = () => document.body.querySelector('[role="listbox"]'); + const resting = () => listbox()?.hasAttribute("data-list-resting"); + + it("marks only the list reopened after a commit as resting, never one mid-edit", async () => { + mountBuilder("promql-builder", []); + await find("input").trigger("click"); + await flushPromises(); + expect(resting()).toBe(true); + await type("lev"); + expect(resting()).toBe(false); + await type("level"); + await press("Enter"); + expect(listbox()).not.toBeNull(); + expect(resting()).toBe(false); + await pickRow("eq"); + expect(resting()).toBe(false); + await type("error"); + await press("Enter"); + expect(listbox()).not.toBeNull(); + expect(resting()).toBe(true); + await find("chip-level-value").trigger("click"); + await flushPromises(); + expect(resting()).toBe(false); + }); + }); + describe("server switch for the extended engine operators", () => { const EXTENDED = ["in", "not_in", "starts_with", "ends_with", "regex", "not_regex"]; diff --git a/web/src/components/filterBar/QueryFilterBar.vue b/web/src/components/filterBar/QueryFilterBar.vue index f1b18d73f7..43f73bc660 100644 --- a/web/src/components/filterBar/QueryFilterBar.vue +++ b/web/src/components/filterBar/QueryFilterBar.vue @@ -202,6 +202,11 @@ const emptyText = computed(() => { return t("filterBar.noOperators"); }); +// Reopened for the next condition with nothing typed: offering a pick, not holding an edit. +const resting = computed( + () => step.value === "field" && !text.value && !editingId.value && !draft.value, +); + const multiStep = computed(() => step.value === "value" && editor.multiValue.value); const selectedCount = computed(() => draft.value?.values.length ?? 0); @@ -370,6 +375,7 @@ const draftOperator = computed(() => { :loading="step === 'value' ? valuesLoading : step === 'field' && fieldsLoading" :auto-highlight="autoHighlight" :multiple="multiStep" + :resting="resting" :placeholder="placeholderText" :aria-label="t('filterBar.ariaLabel')" :disabled="disabled" diff --git a/web/src/composables/dashboard/usePanelLivePreview.spec.ts b/web/src/composables/dashboard/usePanelLivePreview.spec.ts index 119dba65dd..91fa30b489 100644 --- a/web/src/composables/dashboard/usePanelLivePreview.spec.ts +++ b/web/src/composables/dashboard/usePanelLivePreview.spec.ts @@ -185,6 +185,12 @@ describe("usePanelLivePreview", () => { '
'; expect(isPopupOpen()).toBe(true); }); + + it("does not hold for a resting list that only offers the next pick", () => { + document.body.innerHTML = + '
'; + expect(isPopupOpen()).toBe(false); + }); }); describe("live runs", () => { diff --git a/web/src/composables/dashboard/usePanelLivePreview.ts b/web/src/composables/dashboard/usePanelLivePreview.ts index 0a161d2d8b..e5bb1f587b 100644 --- a/web/src/composables/dashboard/usePanelLivePreview.ts +++ b/web/src/composables/dashboard/usePanelLivePreview.ts @@ -98,10 +98,10 @@ const withoutEditorMode = (panel: any) => queries: (panel.queries ?? []).map((query: any) => ({ ...query, customQuery: false })), }; -/** An open select list (field or function picker), but not menus, popovers or tooltips. */ +/** An open select list mid-pick (field or function picker), but not a resting list, menus, popovers or tooltips. */ export const isPopupOpen = (): boolean => Array.from(document.querySelectorAll("[data-reka-popper-content-wrapper]")).some((wrapper) => - wrapper.querySelector('[role="listbox"]'), + wrapper.querySelector('[role="listbox"]:not([data-list-resting])'), ); /** diff --git a/web/src/lib/forms/ChipInput/OChipInput.types.ts b/web/src/lib/forms/ChipInput/OChipInput.types.ts index 83e1fe460c..e68af1f9b2 100644 --- a/web/src/lib/forms/ChipInput/OChipInput.types.ts +++ b/web/src/lib/forms/ChipInput/OChipInput.types.ts @@ -58,6 +58,8 @@ export interface ChipInputProps { /** `plain` drops the input frame, for a field that sits inside an editor surface. */ appearance?: "field" | "plain"; ariaLabel?: I18nText; + /** The open list only offers a next pick and nothing is mid-edit; its listbox carries `data-list-resting` so watchers need not wait on it. */ + resting?: boolean; /** The default slot places the caret itself, by rendering its `caret` slot prop where typing should land. */ inlineCaret?: boolean; /** Lines of chips shown before the field scrolls inside itself; it grows freely when unset. */ diff --git a/web/src/lib/forms/ChipInput/OChipInput.vue b/web/src/lib/forms/ChipInput/OChipInput.vue index 557c8ff21e..46af531a5c 100644 --- a/web/src/lib/forms/ChipInput/OChipInput.vue +++ b/web/src/lib/forms/ChipInput/OChipInput.vue @@ -53,6 +53,7 @@ const props = withDefaults(defineProps(), { appearance: "field", ariaLabel: undefined, inlineCaret: false, + resting: false, maxRows: undefined, dataTest: undefined, }); @@ -611,6 +612,7 @@ function onFieldFocusOut() { ref="listRef" role="listbox" :aria-multiselectable="multiple || undefined" + :data-list-resting="resting ? '' : undefined" :aria-label="searchPlaceholder ?? ariaLabel" class="min-h-0 flex-1 overflow-y-auto p-1" >