This is an automated email from the ASF dual-hosted git repository. vatsrahul1001 pushed a commit to branch v3-4-test in repository https://gitbox.apache.org/repos/asf/airflow.git
commit 5a1e913af12a7d383c48cb29bb0de2199efcd68e Author: Shahar Epstein <[email protected]> AuthorDate: Fri Nov 6 12:42:32 2026 +0200 [v3-4-test] UI: Fix select filter pills staying open in Safari (#76013) (#76589) (cherry picked from commit ecb957345445feba9265310d60fac3b1b0106e98) Co-authored-by: Yuseok Jo <[email protected]> --- .../ui/src/components/FilterBar/FilterBar.test.tsx | 20 ++++++++++++++++ .../components/FilterBar/filters/LoopFilter.tsx | 17 +++++--------- .../FilterBar/filters/RunStateFilter.tsx | 27 ++++++++++------------ .../components/FilterBar/filters/SelectFilter.tsx | 21 ++++++++--------- .../src/airflow/ui/tests/e2e/pages/DagsPage.ts | 4 ++-- 5 files changed, 49 insertions(+), 40 deletions(-) diff --git a/airflow-core/src/airflow/ui/src/components/FilterBar/FilterBar.test.tsx b/airflow-core/src/airflow/ui/src/components/FilterBar/FilterBar.test.tsx index 0e865dc450d..8f880f64337 100644 --- a/airflow-core/src/airflow/ui/src/components/FilterBar/FilterBar.test.tsx +++ b/airflow-core/src/airflow/ui/src/components/FilterBar/FilterBar.test.tsx @@ -145,6 +145,26 @@ describe("FilterBar select filters", () => { expect(valueText).not.toBeNull(); expect(globalThis.getComputedStyle(valueText as HTMLElement).overflow).toBe("visible"); }); + + it("collapses the pill after a selection even when focus stays inside it", async () => { + // WebKit hands focus back to the select trigger, so blurring the active element leaves the pill open. + const blur = vi.spyOn(HTMLElement.prototype, "blur").mockImplementation(() => undefined); + const selectConfig: FilterConfig = { + key: "state", + label: "State", + options: [{ label: "Failed", value: "failed" }], + type: "select", + }; + + render(<FilterBar configs={[selectConfig]} onFiltersChange={vi.fn()} />, { wrapper }); + + fireEvent.click(screen.getByTestId("add-filter-button")); + fireEvent.click(await screen.findByTestId("add-filter-state")); + fireEvent.click(await screen.findByTestId("state-filter-failed")); + + await waitFor(() => expect(screen.getByTestId("state-pill")).toBeInTheDocument()); + blur.mockRestore(); + }); }); describe("FilterBar multiselect filters", () => { diff --git a/airflow-core/src/airflow/ui/src/components/FilterBar/filters/LoopFilter.tsx b/airflow-core/src/airflow/ui/src/components/FilterBar/filters/LoopFilter.tsx index 0e45a6a9aa2..a48473c0c9e 100644 --- a/airflow-core/src/airflow/ui/src/components/FilterBar/filters/LoopFilter.tsx +++ b/airflow-core/src/airflow/ui/src/components/FilterBar/filters/LoopFilter.tsx @@ -37,14 +37,6 @@ import { FilterPill } from "../FilterPill"; import type { FilterPluginProps } from "../types"; import { isLoopFilterValue } from "./loopParams"; -const collapsePill = () => { - setTimeout(() => { - const activeElement = document.activeElement as HTMLElement; - - activeElement.blur(); - }, 0); -}; - /** * One pill, one list: every loop in the run and the passes it ran, as single choices. * @@ -80,7 +72,6 @@ export const LoopFilter = ({ filter, onChange, onRemove }: FilterPluginProps) => } hasJustSelected.current = true; onChange(decodeLoopOption(choice)); - collapsePill(); }; return ( @@ -130,8 +121,12 @@ export const LoopFilter = ({ filter, onChange, onRemove }: FilterPluginProps) => flex="1" h="full" onOpenChange={({ open }) => { - if (!open && !hasJustSelected.current) { - onRequestClose(); + if (!open) { + if (hasJustSelected.current) { + setTimeout(onRequestClose, 0); + } else { + onRequestClose(); + } } hasJustSelected.current = false; }} diff --git a/airflow-core/src/airflow/ui/src/components/FilterBar/filters/RunStateFilter.tsx b/airflow-core/src/airflow/ui/src/components/FilterBar/filters/RunStateFilter.tsx index bd99830d15b..3e9a59c7c34 100644 --- a/airflow-core/src/airflow/ui/src/components/FilterBar/filters/RunStateFilter.tsx +++ b/airflow-core/src/airflow/ui/src/components/FilterBar/filters/RunStateFilter.tsx @@ -45,14 +45,6 @@ type RunStateFilterConfig = { options: Array<SelectOption>; }; -const collapsePill = () => { - setTimeout(() => { - const activeElement = document.activeElement as HTMLElement; - - activeElement.blur(); - }, 0); -}; - /** * One pill filtering Dags on a run state matched within a lookback: the latest run only, a * recent time window, or the whole run history. Committed as a composite value that the @@ -79,7 +71,6 @@ export const RunStateFilter = ({ filter, onChange, onRemove }: FilterPluginProps } hasJustSelected.current = true; onChange({ lookback: value?.lookback ?? pendingLookback, state: newState }); - collapsePill(); }; const handleLookbackChange = ({ value: selected }: { value: Array<string> }) => { @@ -92,7 +83,6 @@ export const RunStateFilter = ({ filter, onChange, onRemove }: FilterPluginProps setPendingLookback(newLookback); if (value !== undefined) { onChange({ lookback: newLookback, state: value.state }); - collapsePill(); } }; @@ -164,12 +154,16 @@ export const RunStateFilter = ({ filter, onChange, onRemove }: FilterPluginProps defaultOpen={value === undefined} flex="1" h="full" - // Dismissing the menu hands focus back to the trigger inside the pill, so no blur - // fires and the pill would stay in edit mode. A close that follows a selection is - // left alone: the blur that selection triggers collapses the pill instead. + // Closing the menu hands focus back to the trigger inside the pill, so no blur fires + // and the pill would stay in edit mode. A close that follows a selection waits a tick, + // so the pill sees the chosen value instead of dropping the filter as abandoned. onOpenChange={({ open }) => { - if (!open && !hasJustSelected.current) { - onRequestClose(); + if (!open) { + if (hasJustSelected.current) { + setTimeout(onRequestClose, 0); + } else { + onRequestClose(); + } } hasJustSelected.current = false; }} @@ -199,6 +193,9 @@ export const RunStateFilter = ({ filter, onChange, onRemove }: FilterPluginProps // Unlike the state select, closing this one without picking anything is not an // abandoned filter: the state select is the pill's primary control. if (!open) { + if (hasJustSelected.current && value !== undefined) { + setTimeout(onRequestClose, 0); + } hasJustSelected.current = false; } }} diff --git a/airflow-core/src/airflow/ui/src/components/FilterBar/filters/SelectFilter.tsx b/airflow-core/src/airflow/ui/src/components/FilterBar/filters/SelectFilter.tsx index e1b50eaa0c4..877166d6c6c 100644 --- a/airflow-core/src/airflow/ui/src/components/FilterBar/filters/SelectFilter.tsx +++ b/airflow-core/src/airflow/ui/src/components/FilterBar/filters/SelectFilter.tsx @@ -46,13 +46,6 @@ export const SelectFilter = ({ filter, onChange, onRemove }: FilterPluginProps) hasJustSelected.current = true; onChange(newValue); - - // Trigger blur to close the editing mode after selection - setTimeout(() => { - const activeElement = document.activeElement as HTMLElement; - - activeElement.blur(); - }, 0); }; const hasValue = filter.value !== null && filter.value !== undefined && filter.value !== ""; @@ -107,12 +100,16 @@ export const SelectFilter = ({ filter, onChange, onRemove }: FilterPluginProps) // open straight onto the options instead of making the user click again. defaultOpen={!hasValue} h="full" - // Dismissing the menu hands focus back to the trigger inside the pill, so no blur - // fires and the pill would stay in edit mode. A close that follows a selection is - // left alone: the blur that selection triggers collapses the pill instead. + // Closing the menu hands focus back to the trigger inside the pill, so no blur fires + // and the pill would stay in edit mode. A close that follows a selection waits a tick, + // so the pill sees the chosen value instead of dropping the filter as abandoned. onOpenChange={({ open }) => { - if (!open && !hasJustSelected.current) { - onRequestClose(); + if (!open) { + if (hasJustSelected.current) { + setTimeout(onRequestClose, 0); + } else { + onRequestClose(); + } } hasJustSelected.current = false; }} diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/DagsPage.ts b/airflow-core/src/airflow/ui/tests/e2e/pages/DagsPage.ts index 537e2f88af5..a8bb5ec681f 100644 --- a/airflow-core/src/airflow/ui/tests/e2e/pages/DagsPage.ts +++ b/airflow-core/src/airflow/ui/tests/e2e/pages/DagsPage.ts @@ -130,8 +130,8 @@ export class DagsPage extends BasePage { await this.page.getByTestId("add-filter-run_state").click(); } await this.page.getByTestId(`run_state-filter-${status}`).click(); - // Selecting blurs the pill, which collapses it ~150ms later. Hand back a settled bar - // instead of one mid-transition. + // Selecting collapses the pill a tick later. Hand back a settled bar instead of one + // mid-transition. await expect(this.runStatePill).toBeVisible({ timeout: 5000 }); } await responsePromise;
