From 0401fded8c3bf752c61998dc71b1ce34f1e7c8b2 Mon Sep 17 00:00:00 2001 From: pierrejeambrun Date: Fri, 12 Jun 2026 23:32:02 +0200 Subject: [PATCH 1/7] UI: Add saved views to persist table filters and sorting --- .../ui/public/i18n/locales/en/common.json | 6 + .../ui/src/components/FilterBar/FilterBar.tsx | 2 + .../ui/src/components/SavedViewsMenu.test.tsx | 150 ++++++++++++++++ .../ui/src/components/SavedViewsMenu.tsx | 163 ++++++++++++++++++ .../airflow/ui/src/constants/localStorage.ts | 1 + 5 files changed, 322 insertions(+) create mode 100644 airflow-core/src/airflow/ui/src/components/SavedViewsMenu.test.tsx create mode 100644 airflow-core/src/airflow/ui/src/components/SavedViewsMenu.tsx diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json index 3aaa5115bb2f5..22335c88d88e0 100644 --- a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json +++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json @@ -203,6 +203,12 @@ "manual": "Manual", "scheduled": "Scheduled" }, + "savedViews": { + "empty": "No saved views yet", + "namePlaceholder": "View name", + "save": "Save", + "title": "Views" + }, "scroll": { "direction": { "bottom": "bottom", diff --git a/airflow-core/src/airflow/ui/src/components/FilterBar/FilterBar.tsx b/airflow-core/src/airflow/ui/src/components/FilterBar/FilterBar.tsx index 138ad02f0d1c3..8aa23efc6772f 100644 --- a/airflow-core/src/airflow/ui/src/components/FilterBar/FilterBar.tsx +++ b/airflow-core/src/airflow/ui/src/components/FilterBar/FilterBar.tsx @@ -22,6 +22,7 @@ import { useTranslation } from "react-i18next"; import { MdAdd, MdClear } from "react-icons/md"; import { useDebouncedCallback } from "use-debounce"; +import { SavedViewsMenu } from "src/components/SavedViewsMenu"; import { Menu } from "src/components/ui"; import { getDefaultFilterIcon } from "./defaultIcons"; @@ -217,6 +218,7 @@ export const FilterBar = ({ {translate("common:reset")} )} + ); }; diff --git a/airflow-core/src/airflow/ui/src/components/SavedViewsMenu.test.tsx b/airflow-core/src/airflow/ui/src/components/SavedViewsMenu.test.tsx new file mode 100644 index 0000000000000..038770dacca1b --- /dev/null +++ b/airflow-core/src/airflow/ui/src/components/SavedViewsMenu.test.tsx @@ -0,0 +1,150 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import "@testing-library/jest-dom"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import type { PropsWithChildren } from "react"; +import { MemoryRouter, useLocation } from "react-router-dom"; +import { afterEach, describe, expect, it } from "vitest"; + +import { BaseWrapper } from "src/utils/Wrapper"; + +import { SavedViewsMenu } from "./SavedViewsMenu"; + +const LocationProbe = () => { + const location = useLocation(); + + return
{location.search}
; +}; + +const createWrapper = + (initialEntries: Array = ["/dags"]) => + ({ children }: PropsWithChildren) => ( + + + {children} + + + + ); + +const openMenu = async () => { + fireEvent.click(screen.getByTestId("saved-views-button")); + + return screen.findByTestId("saved-view-name"); +}; + +afterEach(() => { + cleanup(); + localStorage.clear(); +}); + +describe("SavedViewsMenu", () => { + it("shows an empty state when no views are saved", async () => { + render(, { wrapper: createWrapper() }); + await openMenu(); + + expect(screen.getByText("savedViews.empty")).toBeInTheDocument(); + }); + + it("saves the current view, dropping pagination and baking in the active sort", async () => { + localStorage.setItem("dags-table-sort", JSON.stringify([{ desc: true, id: "start_date" }])); + + render(, { wrapper: createWrapper(["/dags?state=running&offset=40"]) }); + const input = await openMenu(); + + fireEvent.change(input, { target: { value: "Running runs" } }); + fireEvent.click(screen.getByTestId("saved-view-save")); + + await waitFor(() => { + expect(JSON.parse(localStorage.getItem("dags-saved-views") ?? "[]")).toEqual([ + { name: "Running runs", search: "state=running&sort=-start_date" }, + ]); + }); + }); + + it("does not save a view with a blank name", async () => { + render(, { wrapper: createWrapper(["/dags?state=running"]) }); + const input = await openMenu(); + + fireEvent.change(input, { target: { value: " " } }); + + expect(screen.getByTestId("saved-view-save")).toBeDisabled(); + }); + + it("overwrites an existing view with the same name", async () => { + localStorage.setItem( + "dags-saved-views", + JSON.stringify([{ name: "Running runs", search: "state=queued" }]), + ); + + render(, { wrapper: createWrapper(["/dags?state=running"]) }); + const input = await openMenu(); + + fireEvent.change(input, { target: { value: "Running runs" } }); + fireEvent.click(screen.getByTestId("saved-view-save")); + + await waitFor(() => { + expect(JSON.parse(localStorage.getItem("dags-saved-views") ?? "[]")).toEqual([ + { name: "Running runs", search: "state=running" }, + ]); + }); + }); + + it("restores a saved view to the URL and the table sort", async () => { + localStorage.setItem( + "dags-saved-views", + JSON.stringify([{ name: "Successful runs", search: "state=success&sort=-run_after" }]), + ); + + render(, { wrapper: createWrapper(["/dags"]) }); + await openMenu(); + + fireEvent.click(screen.getByText("Successful runs")); + + await waitFor(() => { + expect(screen.getByTestId("location-search")).toHaveTextContent("state=success&sort=-run_after"); + }); + expect(JSON.parse(localStorage.getItem("dags-table-sort") ?? "[]")).toEqual([ + { desc: true, id: "run_after" }, + ]); + }); + + it("deletes a saved view", async () => { + localStorage.setItem( + "dags-saved-views", + JSON.stringify([ + { name: "Successful runs", search: "state=success" }, + { name: "Failed runs", search: "state=failed" }, + ]), + ); + + render(, { wrapper: createWrapper(["/dags"]) }); + await openMenu(); + + fireEvent.click(screen.getAllByLabelText("Delete view")[0] as HTMLElement); + + await waitFor(() => { + expect(screen.queryByText("Successful runs")).not.toBeInTheDocument(); + }); + expect(screen.getByText("Failed runs")).toBeInTheDocument(); + expect(JSON.parse(localStorage.getItem("dags-saved-views") ?? "[]")).toEqual([ + { name: "Failed runs", search: "state=failed" }, + ]); + }); +}); diff --git a/airflow-core/src/airflow/ui/src/components/SavedViewsMenu.tsx b/airflow-core/src/airflow/ui/src/components/SavedViewsMenu.tsx new file mode 100644 index 0000000000000..ae5222f337d23 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/components/SavedViewsMenu.tsx @@ -0,0 +1,163 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { Button, HStack, Input, Text, VStack } from "@chakra-ui/react"; +import type { SortingState } from "@tanstack/react-table"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { FiTrash2 } from "react-icons/fi"; +import { LuBookmark } from "react-icons/lu"; +import { useLocation, useSearchParams } from "react-router-dom"; +import { useLocalStorage } from "usehooks-ts"; + +import { IconButton, Popover } from "src/components/ui"; +import { savedViewsKey, tableSortKey } from "src/constants/localStorage"; +import { SearchParamsKeys } from "src/constants/searchParams"; + +type SavedView = { + readonly name: string; + readonly search: string; +}; + +// A "view" is the table's current URL query string — filters, search and page size all live there. +// Sorting is mirrored to localStorage and is frequently absent from the URL, so it is baked into the +// snapshot explicitly. Pagination position (offset/cursor) is dropped so a restored view starts on page one. +export const SavedViewsMenu = () => { + const { t: translate } = useTranslation("common"); + const { pathname } = useLocation(); + const [searchParams, setSearchParams] = useSearchParams(); + const [sorting, setSorting] = useLocalStorage(tableSortKey(pathname), []); + const [savedViews, setSavedViews] = useLocalStorage>(savedViewsKey(pathname), []); + const [name, setName] = useState(""); + const [open, setOpen] = useState(false); + + const handleSave = () => { + const trimmedName = name.trim(); + + if (trimmedName === "") { + return; + } + + const params = new URLSearchParams(searchParams); + + params.delete(SearchParamsKeys.OFFSET); + params.delete(SearchParamsKeys.CURSOR); + // The active sort lives in the URL when set there, otherwise only in localStorage — bake it in + // either way so a restored view orders the table the same as when it was saved. + if (params.getAll(SearchParamsKeys.SORT).length === 0) { + sorting.forEach(({ desc, id }) => params.append(SearchParamsKeys.SORT, `${desc ? "-" : ""}${id}`)); + } + const search = params.toString(); + + setSavedViews((prev) => + prev.some((view) => view.name === trimmedName) + ? prev.map((view) => (view.name === trimmedName ? { name: trimmedName, search } : view)) + : [...prev, { name: trimmedName, search }], + ); + setName(""); + }; + + const applyView = (view: SavedView) => { + const params = new URLSearchParams(view.search); + + setSorting( + params + .getAll(SearchParamsKeys.SORT) + .map((sort) => ({ desc: sort.startsWith("-"), id: sort.replace("-", "") })), + ); + setSearchParams(params); + setOpen(false); + }; + + const deleteView = (viewName: string) => { + setSavedViews((prev) => prev.filter((view) => view.name !== viewName)); + }; + + return ( + setOpen(event.open)} + open={open} + positioning={{ placement: "bottom-start" }} + unmountOnExit + > + + + + + + + + + setName(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + handleSave(); + } + }} + placeholder={translate("savedViews.namePlaceholder")} + size="sm" + /> + + + {savedViews.length === 0 ? ( + + {translate("savedViews.empty")} + + ) : ( + savedViews.map((view) => ( + + + deleteView(view.name)} + size="sm" + variant="ghost" + > + + + + )) + )} + + + + + ); +}; diff --git a/airflow-core/src/airflow/ui/src/constants/localStorage.ts b/airflow-core/src/airflow/ui/src/constants/localStorage.ts index 97285aef6fa80..db36b56a9ab4d 100644 --- a/airflow-core/src/airflow/ui/src/constants/localStorage.ts +++ b/airflow-core/src/airflow/ui/src/constants/localStorage.ts @@ -37,6 +37,7 @@ export const allGroupsKey = (dagId: string) => `${dagId}/all-groups`; // Page-scoped keys export const tableSortKey = (pageName: string) => `${pageName.replaceAll("/", "-").slice(1)}-table-sort`; +export const savedViewsKey = (pageName: string) => `${pageName.replaceAll("/", "-").slice(1)}-saved-views`; // SearchBar advanced (substring) toggle, scoped per searchbar via a caller-provided id. export const advancedSearchKey = (id: string) => `advanced_search-${id}`; From c898b9b11879fd798099df897ef288e40332dd2f Mon Sep 17 00:00:00 2001 From: pierrejeambrun Date: Thu, 18 Jun 2026 12:41:19 +0200 Subject: [PATCH 2/7] Refine saved table views placement and save/delete guards The Views control belongs anchored to the right of the filter bar so it stays put as filters wrap, instead of drifting next to them. Saving is blocked on the default view and when an identical setup is already stored, since those only ever create empty or duplicate entries. Deleting now asks for confirmation so a stray click can't discard a view, and the name field clears on save to make it obvious the view was stored. --- .../ui/public/i18n/locales/en/common.json | 5 + .../ui/src/components/FilterBar/FilterBar.tsx | 74 ++++----- .../ui/src/components/SavedViewsMenu.test.tsx | 55 ++++++- .../ui/src/components/SavedViewsMenu.tsx | 143 ++++++++++++------ 4 files changed, 193 insertions(+), 84 deletions(-) diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json index 22335c88d88e0..af175a15e5b3b 100644 --- a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json +++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json @@ -204,8 +204,13 @@ "scheduled": "Scheduled" }, "savedViews": { + "deleteTitle": "Delete view", + "deleteWarning": "Saved views are stored in this browser only.", + "duplicate": "The view \"{{name}}\" already saves this exact setup.", "empty": "No saved views yet", + "info": "Save the current filters and sorting as a named view, then switch back to it anytime. Views are stored in this browser.", "namePlaceholder": "View name", + "nothingToSave": "Nothing to save on the default view. Apply filters or sorting to save a view.", "save": "Save", "title": "Views" }, diff --git a/airflow-core/src/airflow/ui/src/components/FilterBar/FilterBar.tsx b/airflow-core/src/airflow/ui/src/components/FilterBar/FilterBar.tsx index 8aa23efc6772f..b84af990102ee 100644 --- a/airflow-core/src/airflow/ui/src/components/FilterBar/FilterBar.tsx +++ b/airflow-core/src/airflow/ui/src/components/FilterBar/FilterBar.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { Button, HStack } from "@chakra-ui/react"; +import { Button, Flex, HStack } from "@chakra-ui/react"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { MdAdd, MdClear } from "react-icons/md"; @@ -184,41 +184,43 @@ export const FilterBar = ({ }; return ( - - {filters.slice(0, maxVisibleFilters).map(renderFilter)} - {availableConfigs.length > 0 && ( - - - - - - {availableConfigs.map((config) => ( - addFilter(config)} value={config.key}> - - {getFilterIcon(config)} - {config.label} - - - ))} - - - )} - {filters.length > 0 && ( - - )} + + + {filters.slice(0, maxVisibleFilters).map(renderFilter)} + {availableConfigs.length > 0 && ( + + + + + + {availableConfigs.map((config) => ( + addFilter(config)} value={config.key}> + + {getFilterIcon(config)} + {config.label} + + + ))} + + + )} + {filters.length > 0 && ( + + )} + - + ); }; diff --git a/airflow-core/src/airflow/ui/src/components/SavedViewsMenu.test.tsx b/airflow-core/src/airflow/ui/src/components/SavedViewsMenu.test.tsx index 038770dacca1b..f5a28e6237bd5 100644 --- a/airflow-core/src/airflow/ui/src/components/SavedViewsMenu.test.tsx +++ b/airflow-core/src/airflow/ui/src/components/SavedViewsMenu.test.tsx @@ -76,6 +76,8 @@ describe("SavedViewsMenu", () => { { name: "Running runs", search: "state=running&sort=-start_date" }, ]); }); + // The input is reset to its placeholder so it is clear the view was saved. + expect(input).toHaveValue(""); }); it("does not save a view with a blank name", async () => { @@ -87,6 +89,18 @@ describe("SavedViewsMenu", () => { expect(screen.getByTestId("saved-view-save")).toBeDisabled(); }); + it("disables save on the default view, even with a stored sort and a typed name", async () => { + // A bare table page keeps a default sort in localStorage but no URL filters — still nothing to save. + localStorage.setItem("dags-table-sort", JSON.stringify([{ desc: true, id: "run_after" }])); + + render(, { wrapper: createWrapper(["/dags"]) }); + const input = await openMenu(); + + fireEvent.change(input, { target: { value: "My view" } }); + + expect(screen.getByTestId("saved-view-save")).toBeDisabled(); + }); + it("overwrites an existing view with the same name", async () => { localStorage.setItem( "dags-saved-views", @@ -125,7 +139,7 @@ describe("SavedViewsMenu", () => { ]); }); - it("deletes a saved view", async () => { + it("deletes a saved view after confirming", async () => { localStorage.setItem( "dags-saved-views", JSON.stringify([ @@ -138,13 +152,46 @@ describe("SavedViewsMenu", () => { await openMenu(); fireEvent.click(screen.getAllByLabelText("Delete view")[0] as HTMLElement); + fireEvent.click(await screen.findByTestId("delete-confirm-button")); + + await waitFor(() => { + expect(JSON.parse(localStorage.getItem("dags-saved-views") ?? "[]")).toEqual([ + { name: "Failed runs", search: "state=failed" }, + ]); + }); + }); + + it("keeps the saved view when the delete is cancelled", async () => { + localStorage.setItem( + "dags-saved-views", + JSON.stringify([{ name: "Successful runs", search: "state=success" }]), + ); + + render(, { wrapper: createWrapper(["/dags"]) }); + await openMenu(); + + fireEvent.click(screen.getByLabelText("Delete view")); + fireEvent.click(await screen.findByTestId("delete-cancel-button")); await waitFor(() => { - expect(screen.queryByText("Successful runs")).not.toBeInTheDocument(); + expect(screen.queryByTestId("delete-confirm-button")).not.toBeInTheDocument(); }); - expect(screen.getByText("Failed runs")).toBeInTheDocument(); expect(JSON.parse(localStorage.getItem("dags-saved-views") ?? "[]")).toEqual([ - { name: "Failed runs", search: "state=failed" }, + { name: "Successful runs", search: "state=success" }, ]); }); + + it("blocks save when the exact setup is already saved under another name", async () => { + localStorage.setItem( + "dags-saved-views", + JSON.stringify([{ name: "Running runs", search: "state=running" }]), + ); + + render(, { wrapper: createWrapper(["/dags?state=running"]) }); + const input = await openMenu(); + + fireEvent.change(input, { target: { value: "Another name" } }); + + expect(screen.getByTestId("saved-view-save")).toBeDisabled(); + }); }); diff --git a/airflow-core/src/airflow/ui/src/components/SavedViewsMenu.tsx b/airflow-core/src/airflow/ui/src/components/SavedViewsMenu.tsx index ae5222f337d23..a87ef870f288e 100644 --- a/airflow-core/src/airflow/ui/src/components/SavedViewsMenu.tsx +++ b/airflow-core/src/airflow/ui/src/components/SavedViewsMenu.tsx @@ -16,16 +16,17 @@ * specific language governing permissions and limitations * under the License. */ -import { Button, HStack, Input, Text, VStack } from "@chakra-ui/react"; +import { Box, Button, HStack, Input, Text, VStack } from "@chakra-ui/react"; import type { SortingState } from "@tanstack/react-table"; import { useState } from "react"; import { useTranslation } from "react-i18next"; import { FiTrash2 } from "react-icons/fi"; -import { LuBookmark } from "react-icons/lu"; +import { LuBookmark, LuInfo } from "react-icons/lu"; import { useLocation, useSearchParams } from "react-router-dom"; import { useLocalStorage } from "usehooks-ts"; -import { IconButton, Popover } from "src/components/ui"; +import DeleteDialog from "src/components/DeleteDialog"; +import { IconButton, Popover, Tooltip } from "src/components/ui"; import { savedViewsKey, tableSortKey } from "src/constants/localStorage"; import { SearchParamsKeys } from "src/constants/searchParams"; @@ -34,6 +35,15 @@ type SavedView = { readonly search: string; }; +// Query-string param order is not significant, so views are compared by their normalized (sorted) params. +const normalizeSearch = (value: string) => { + const params = new URLSearchParams(value); + + params.sort(); + + return params.toString(); +}; + // A "view" is the table's current URL query string — filters, search and page size all live there. // Sorting is mirrored to localStorage and is frequently absent from the URL, so it is baked into the // snapshot explicitly. Pagination position (offset/cursor) is dropped so a restored view starts on page one. @@ -45,25 +55,33 @@ export const SavedViewsMenu = () => { const [savedViews, setSavedViews] = useLocalStorage>(savedViewsKey(pathname), []); const [name, setName] = useState(""); const [open, setOpen] = useState(false); + const [viewToDelete, setViewToDelete] = useState(undefined); + + const viewParams = new URLSearchParams(searchParams); + + viewParams.delete(SearchParamsKeys.OFFSET); + viewParams.delete(SearchParamsKeys.CURSOR); + // The default view (no filters or search applied) has nothing worth persisting — only the URL query + // counts, so the bare table page always blocks save even when a default sort sits in localStorage. + const hasViewToSave = [...viewParams].length > 0; + + // The active sort lives in the URL when set there, otherwise only in localStorage — bake it in + // either way so a restored view orders the table the same as when it was saved. + if (viewParams.getAll(SearchParamsKeys.SORT).length === 0) { + sorting.forEach(({ desc, id }) => viewParams.append(SearchParamsKeys.SORT, `${desc ? "-" : ""}${id}`)); + } + const search = viewParams.toString(); + + // Block saving a setup that is already persisted under another name, and surface which one. + const duplicateView = savedViews.find((view) => normalizeSearch(view.search) === normalizeSearch(search)); const handleSave = () => { const trimmedName = name.trim(); - if (trimmedName === "") { + if (trimmedName === "" || !hasViewToSave || duplicateView !== undefined) { return; } - const params = new URLSearchParams(searchParams); - - params.delete(SearchParamsKeys.OFFSET); - params.delete(SearchParamsKeys.CURSOR); - // The active sort lives in the URL when set there, otherwise only in localStorage — bake it in - // either way so a restored view orders the table the same as when it was saved. - if (params.getAll(SearchParamsKeys.SORT).length === 0) { - sorting.forEach(({ desc, id }) => params.append(SearchParamsKeys.SORT, `${desc ? "-" : ""}${id}`)); - } - const search = params.toString(); - setSavedViews((prev) => prev.some((view) => view.name === trimmedName) ? prev.map((view) => (view.name === trimmedName ? { name: trimmedName, search } : view)) @@ -88,24 +106,42 @@ export const SavedViewsMenu = () => { setSavedViews((prev) => prev.filter((view) => view.name !== viewName)); }; + let saveHint: string | undefined; + + if (!hasViewToSave) { + saveHint = translate("savedViews.nothingToSave"); + } else if (duplicateView !== undefined) { + saveHint = translate("savedViews.duplicate", { name: duplicateView.name }); + } + return ( - setOpen(event.open)} - open={open} - positioning={{ placement: "bottom-start" }} - unmountOnExit - > - - - - - - - + <> + setOpen(event.open)} + open={open} + positioning={{ placement: "bottom-start" }} + unmountOnExit + > + + + + + + + + + {translate("savedViews.title")} + + + + + + + { }} placeholder={translate("savedViews.namePlaceholder")} size="sm" + value={name} /> - + + + + + {savedViews.length === 0 ? ( @@ -146,7 +187,7 @@ export const SavedViewsMenu = () => { deleteView(view.name)} + onClick={() => setViewToDelete(view.name)} size="sm" variant="ghost" > @@ -156,8 +197,22 @@ export const SavedViewsMenu = () => { )) )} - - - + + + setViewToDelete(undefined)} + onDelete={() => { + if (viewToDelete !== undefined) { + deleteView(viewToDelete); + } + setViewToDelete(undefined); + }} + open={viewToDelete !== undefined} + resourceName={viewToDelete ?? ""} + title={translate("savedViews.deleteTitle")} + warningText={translate("savedViews.deleteWarning")} + /> + ); }; From bd80683c762c526c10b6613e7854b03e37d543af Mon Sep 17 00:00:00 2001 From: pierrejeambrun Date: Thu, 18 Jun 2026 14:50:23 +0200 Subject: [PATCH 3/7] Add a default saved view restored automatically on page load Users who always open a table on the same filtered view had to re-select it on every visit. Pinning a saved view as the default restores it automatically when the page is opened with no filters, and it can be unpinned to return to the empty default. Deep links that already carry their own filters still take precedence, so shared URLs are never overridden. --- .../ui/public/i18n/locales/en/common.json | 10 +- .../ui/src/components/SavedViewsMenu.test.tsx | 91 ++++++++++++++++++- .../ui/src/components/SavedViewsMenu.tsx | 58 +++++++++++- .../airflow/ui/src/constants/localStorage.ts | 2 + 4 files changed, 155 insertions(+), 6 deletions(-) diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json index af175a15e5b3b..32032d8a258ca 100644 --- a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json +++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json @@ -208,11 +208,17 @@ "deleteWarning": "Saved views are stored in this browser only.", "duplicate": "The view \"{{name}}\" already saves this exact setup.", "empty": "No saved views yet", - "info": "Save the current filters and sorting as a named view, then switch back to it anytime. Views are stored in this browser.", + "info": { + "default": "Pin a view as the default and it is shown automatically when you land on this page with no filters applied.", + "save": "Save the current filters and sorting as a named view, then switch back to it anytime.", + "storage": "Views are stored in this browser." + }, "namePlaceholder": "View name", "nothingToSave": "Nothing to save on the default view. Apply filters or sorting to save a view.", "save": "Save", - "title": "Views" + "setDefault": "Set as default view", + "title": "Views", + "unsetDefault": "Unset default view" }, "scroll": { "direction": { diff --git a/airflow-core/src/airflow/ui/src/components/SavedViewsMenu.test.tsx b/airflow-core/src/airflow/ui/src/components/SavedViewsMenu.test.tsx index f5a28e6237bd5..8110410472598 100644 --- a/airflow-core/src/airflow/ui/src/components/SavedViewsMenu.test.tsx +++ b/airflow-core/src/airflow/ui/src/components/SavedViewsMenu.test.tsx @@ -131,9 +131,11 @@ describe("SavedViewsMenu", () => { fireEvent.click(screen.getByText("Successful runs")); + // The sort is restored to localStorage (where the table reads it), not left in the URL. await waitFor(() => { - expect(screen.getByTestId("location-search")).toHaveTextContent("state=success&sort=-run_after"); + expect(screen.getByTestId("location-search")).toHaveTextContent("state=success"); }); + expect(screen.getByTestId("location-search")).not.toHaveTextContent("sort"); expect(JSON.parse(localStorage.getItem("dags-table-sort") ?? "[]")).toEqual([ { desc: true, id: "run_after" }, ]); @@ -194,4 +196,91 @@ describe("SavedViewsMenu", () => { expect(screen.getByTestId("saved-view-save")).toBeDisabled(); }); + + it("restores the default view automatically on a bare page load", async () => { + localStorage.setItem( + "dags-saved-views", + JSON.stringify([{ name: "Successful runs", search: "state=success&sort=-run_after" }]), + ); + localStorage.setItem("dags-saved-views-default", JSON.stringify("Successful runs")); + + render(, { wrapper: createWrapper(["/dags"]) }); + + // The default view is restored with its filters in the URL and its sort in localStorage only. + await waitFor(() => { + expect(screen.getByTestId("location-search")).toHaveTextContent("state=success"); + }); + expect(screen.getByTestId("location-search")).not.toHaveTextContent("sort"); + expect(JSON.parse(localStorage.getItem("dags-table-sort") ?? "[]")).toEqual([ + { desc: true, id: "run_after" }, + ]); + }); + + it("restores the default view even when only a leftover sort is in the URL", async () => { + localStorage.setItem( + "dags-saved-views", + JSON.stringify([{ name: "Successful runs", search: "state=success" }]), + ); + localStorage.setItem("dags-saved-views-default", JSON.stringify("Successful runs")); + + render(, { wrapper: createWrapper(["/dags?sort=-run_after"]) }); + + await waitFor(() => { + expect(screen.getByTestId("location-search")).toHaveTextContent("state=success"); + }); + }); + + it("does not restore the default view when the page already has filters", async () => { + localStorage.setItem( + "dags-saved-views", + JSON.stringify([{ name: "Successful runs", search: "state=success" }]), + ); + localStorage.setItem("dags-saved-views-default", JSON.stringify("Successful runs")); + + render(, { wrapper: createWrapper(["/dags?state=running"]) }); + await openMenu(); + + expect(screen.getByTestId("location-search")).toHaveTextContent("state=running"); + expect(screen.getByTestId("location-search")).not.toHaveTextContent("state=success"); + }); + + it("toggles a view as the default and back", async () => { + localStorage.setItem( + "dags-saved-views", + JSON.stringify([{ name: "Successful runs", search: "state=success" }]), + ); + + render(, { wrapper: createWrapper(["/dags"]) }); + await openMenu(); + + fireEvent.click(screen.getByLabelText("savedViews.setDefault")); + await waitFor(() => { + expect(JSON.parse(localStorage.getItem("dags-saved-views-default") ?? "null")).toBe("Successful runs"); + }); + + fireEvent.click(screen.getByLabelText("savedViews.unsetDefault")); + await waitFor(() => { + expect(JSON.parse(localStorage.getItem("dags-saved-views-default") ?? "null")).toBeNull(); + }); + }); + + it("clears the stored default when the default view is deleted", async () => { + localStorage.setItem( + "dags-saved-views", + JSON.stringify([{ name: "Successful runs", search: "state=success" }]), + ); + localStorage.setItem("dags-saved-views-default", JSON.stringify("Successful runs")); + + // A non-bare URL so the default isn't auto-restored before we delete it. + render(, { wrapper: createWrapper(["/dags?state=running"]) }); + await openMenu(); + + fireEvent.click(screen.getByLabelText("Delete view")); + fireEvent.click(await screen.findByTestId("delete-confirm-button")); + + await waitFor(() => { + expect(JSON.parse(localStorage.getItem("dags-saved-views") ?? "[]")).toEqual([]); + }); + expect(JSON.parse(localStorage.getItem("dags-saved-views-default") ?? "null")).toBeNull(); + }); }); diff --git a/airflow-core/src/airflow/ui/src/components/SavedViewsMenu.tsx b/airflow-core/src/airflow/ui/src/components/SavedViewsMenu.tsx index a87ef870f288e..79ffbdacba385 100644 --- a/airflow-core/src/airflow/ui/src/components/SavedViewsMenu.tsx +++ b/airflow-core/src/airflow/ui/src/components/SavedViewsMenu.tsx @@ -18,16 +18,17 @@ */ import { Box, Button, HStack, Input, Text, VStack } from "@chakra-ui/react"; import type { SortingState } from "@tanstack/react-table"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { FiTrash2 } from "react-icons/fi"; import { LuBookmark, LuInfo } from "react-icons/lu"; +import { MdOutlinePushPin, MdPushPin } from "react-icons/md"; import { useLocation, useSearchParams } from "react-router-dom"; import { useLocalStorage } from "usehooks-ts"; import DeleteDialog from "src/components/DeleteDialog"; import { IconButton, Popover, Tooltip } from "src/components/ui"; -import { savedViewsKey, tableSortKey } from "src/constants/localStorage"; +import { savedViewsDefaultKey, savedViewsKey, tableSortKey } from "src/constants/localStorage"; import { SearchParamsKeys } from "src/constants/searchParams"; type SavedView = { @@ -53,6 +54,10 @@ export const SavedViewsMenu = () => { const [searchParams, setSearchParams] = useSearchParams(); const [sorting, setSorting] = useLocalStorage(tableSortKey(pathname), []); const [savedViews, setSavedViews] = useLocalStorage>(savedViewsKey(pathname), []); + const [defaultViewName, setDefaultViewName] = useLocalStorage( + savedViewsDefaultKey(pathname), + null, + ); const [name, setName] = useState(""); const [open, setOpen] = useState(false); const [viewToDelete, setViewToDelete] = useState(undefined); @@ -98,14 +103,41 @@ export const SavedViewsMenu = () => { .getAll(SearchParamsKeys.SORT) .map((sort) => ({ desc: sort.startsWith("-"), id: sort.replace("-", "") })), ); + // The sort lives in localStorage like the rest of the app expects — keep it out of the URL, or it + // lingers as a `sort` query param after filters are cleared/reset (reset clears filters, not sort). + params.delete(SearchParamsKeys.SORT); setSearchParams(params); setOpen(false); }; const deleteView = (viewName: string) => { setSavedViews((prev) => prev.filter((view) => view.name !== viewName)); + setDefaultViewName((previous) => (previous === viewName ? null : previous)); + }; + + const toggleDefault = (viewName: string) => { + setDefaultViewName((previous) => (previous === viewName ? null : viewName)); }; + // Restore the default view when arriving on a page with no filters, so navigation lands on it + // automatically. The decision keys off filters only — pagination and a leftover sort don't count — + // so it isn't entangled with the table's sort handling. Keyed on pathname: we deliberately don't + // re-run on the user's later edits or clears, and we never override filters from a deep link. + useEffect(() => { + const target = + defaultViewName === null ? undefined : savedViews.find((view) => view.name === defaultViewName); + const filters = new URLSearchParams(searchParams); + + filters.delete(SearchParamsKeys.OFFSET); + filters.delete(SearchParamsKeys.CURSOR); + filters.delete(SearchParamsKeys.SORT); + + if (target !== undefined && [...filters].length === 0) { + applyView(target); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pathname]); + let saveHint: string | undefined; if (!hasViewToSave) { @@ -114,6 +146,14 @@ export const SavedViewsMenu = () => { saveHint = translate("savedViews.duplicate", { name: duplicateView.name }); } + const infoContent = ( + + {translate("savedViews.info.save")} + {translate("savedViews.info.default")} + {translate("savedViews.info.storage")} + + ); + return ( <> { {translate("savedViews.title")} - + @@ -185,6 +225,18 @@ export const SavedViewsMenu = () => { > {view.name} + toggleDefault(view.name)} + size="sm" + variant="ghost" + > + {defaultViewName === view.name ? : } + setViewToDelete(view.name)} diff --git a/airflow-core/src/airflow/ui/src/constants/localStorage.ts b/airflow-core/src/airflow/ui/src/constants/localStorage.ts index db36b56a9ab4d..e7b9ea9abdedd 100644 --- a/airflow-core/src/airflow/ui/src/constants/localStorage.ts +++ b/airflow-core/src/airflow/ui/src/constants/localStorage.ts @@ -38,6 +38,8 @@ export const allGroupsKey = (dagId: string) => `${dagId}/all-groups`; // Page-scoped keys export const tableSortKey = (pageName: string) => `${pageName.replaceAll("/", "-").slice(1)}-table-sort`; export const savedViewsKey = (pageName: string) => `${pageName.replaceAll("/", "-").slice(1)}-saved-views`; +export const savedViewsDefaultKey = (pageName: string) => + `${pageName.replaceAll("/", "-").slice(1)}-saved-views-default`; // SearchBar advanced (substring) toggle, scoped per searchbar via a caller-provided id. export const advancedSearchKey = (id: string) => `advanced_search-${id}`; From b44b2d092b20c0de99f29f779a512da684731465 Mon Sep 17 00:00:00 2001 From: pierrejeambrun Date: Thu, 18 Jun 2026 16:38:00 +0200 Subject: [PATCH 4/7] Hide saved views on a Dag run's task instances Saved views are scoped per page URL, so on a Dag run's task instances (a per-run URL) they would be siloed per run and never reused. Hide the Views control there via a FilterBar opt-out, keeping it on the global and per-Dag tables where the URL is stable. Also trims the saved-views comments down to the ones that explain a non-obvious decision. --- .../components/FilterBar/FilterBar.test.tsx | 49 +++++++++++++++++++ .../ui/src/components/FilterBar/FilterBar.tsx | 3 +- .../ui/src/components/FilterBar/types.ts | 2 + .../ui/src/components/SavedViewsMenu.test.tsx | 4 -- .../ui/src/components/SavedViewsMenu.tsx | 20 ++------ .../TaskInstances/TaskInstancesFilter.tsx | 1 + 6 files changed, 59 insertions(+), 20 deletions(-) create mode 100644 airflow-core/src/airflow/ui/src/components/FilterBar/FilterBar.test.tsx 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 new file mode 100644 index 0000000000000..314867e0cbc9e --- /dev/null +++ b/airflow-core/src/airflow/ui/src/components/FilterBar/FilterBar.test.tsx @@ -0,0 +1,49 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import "@testing-library/jest-dom"; +import { cleanup, render, screen } from "@testing-library/react"; +import type { PropsWithChildren } from "react"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { BaseWrapper } from "src/utils/Wrapper"; + +import { FilterBar } from "./FilterBar"; + +const wrapper = ({ children }: PropsWithChildren) => ( + + {children} + +); + +afterEach(cleanup); + +describe("FilterBar saved views", () => { + it("shows the saved views control by default", () => { + render(, { wrapper }); + + expect(screen.getByTestId("saved-views-button")).toBeInTheDocument(); + }); + + it("hides the saved views control when showSavedViews is false", () => { + render(, { wrapper }); + + expect(screen.queryByTestId("saved-views-button")).not.toBeInTheDocument(); + }); +}); diff --git a/airflow-core/src/airflow/ui/src/components/FilterBar/FilterBar.tsx b/airflow-core/src/airflow/ui/src/components/FilterBar/FilterBar.tsx index b84af990102ee..8363bfbe4ee14 100644 --- a/airflow-core/src/airflow/ui/src/components/FilterBar/FilterBar.tsx +++ b/airflow-core/src/airflow/ui/src/components/FilterBar/FilterBar.tsx @@ -44,6 +44,7 @@ export const FilterBar = ({ initialValues = defaultInitialValues, maxVisibleFilters = 10, onFiltersChange, + showSavedViews = true, }: FilterBarProps) => { const { t: translate } = useTranslation(["admin", "common"]); const [filters, setFilters] = useState>(() => @@ -220,7 +221,7 @@ export const FilterBar = ({ )} - + {showSavedViews ? : undefined} ); }; diff --git a/airflow-core/src/airflow/ui/src/components/FilterBar/types.ts b/airflow-core/src/airflow/ui/src/components/FilterBar/types.ts index 8e04bf53c720b..5d0c1981e26ec 100644 --- a/airflow-core/src/airflow/ui/src/components/FilterBar/types.ts +++ b/airflow-core/src/airflow/ui/src/components/FilterBar/types.ts @@ -56,6 +56,8 @@ export type FilterBarProps = { readonly initialValues?: Record; readonly maxVisibleFilters?: number; readonly onFiltersChange: (filters: Record) => void; + // Hide the Views control where saved views aren't useful (e.g. a Dag run's per-run task instances). + readonly showSavedViews?: boolean; }; export type FilterPluginProps = { diff --git a/airflow-core/src/airflow/ui/src/components/SavedViewsMenu.test.tsx b/airflow-core/src/airflow/ui/src/components/SavedViewsMenu.test.tsx index 8110410472598..1c1ff7b41aa7e 100644 --- a/airflow-core/src/airflow/ui/src/components/SavedViewsMenu.test.tsx +++ b/airflow-core/src/airflow/ui/src/components/SavedViewsMenu.test.tsx @@ -76,7 +76,6 @@ describe("SavedViewsMenu", () => { { name: "Running runs", search: "state=running&sort=-start_date" }, ]); }); - // The input is reset to its placeholder so it is clear the view was saved. expect(input).toHaveValue(""); }); @@ -90,7 +89,6 @@ describe("SavedViewsMenu", () => { }); it("disables save on the default view, even with a stored sort and a typed name", async () => { - // A bare table page keeps a default sort in localStorage but no URL filters — still nothing to save. localStorage.setItem("dags-table-sort", JSON.stringify([{ desc: true, id: "run_after" }])); render(, { wrapper: createWrapper(["/dags"]) }); @@ -131,7 +129,6 @@ describe("SavedViewsMenu", () => { fireEvent.click(screen.getByText("Successful runs")); - // The sort is restored to localStorage (where the table reads it), not left in the URL. await waitFor(() => { expect(screen.getByTestId("location-search")).toHaveTextContent("state=success"); }); @@ -206,7 +203,6 @@ describe("SavedViewsMenu", () => { render(, { wrapper: createWrapper(["/dags"]) }); - // The default view is restored with its filters in the URL and its sort in localStorage only. await waitFor(() => { expect(screen.getByTestId("location-search")).toHaveTextContent("state=success"); }); diff --git a/airflow-core/src/airflow/ui/src/components/SavedViewsMenu.tsx b/airflow-core/src/airflow/ui/src/components/SavedViewsMenu.tsx index 79ffbdacba385..c32a5ba256af3 100644 --- a/airflow-core/src/airflow/ui/src/components/SavedViewsMenu.tsx +++ b/airflow-core/src/airflow/ui/src/components/SavedViewsMenu.tsx @@ -36,7 +36,6 @@ type SavedView = { readonly search: string; }; -// Query-string param order is not significant, so views are compared by their normalized (sorted) params. const normalizeSearch = (value: string) => { const params = new URLSearchParams(value); @@ -45,9 +44,6 @@ const normalizeSearch = (value: string) => { return params.toString(); }; -// A "view" is the table's current URL query string — filters, search and page size all live there. -// Sorting is mirrored to localStorage and is frequently absent from the URL, so it is baked into the -// snapshot explicitly. Pagination position (offset/cursor) is dropped so a restored view starts on page one. export const SavedViewsMenu = () => { const { t: translate } = useTranslation("common"); const { pathname } = useLocation(); @@ -66,18 +62,15 @@ export const SavedViewsMenu = () => { viewParams.delete(SearchParamsKeys.OFFSET); viewParams.delete(SearchParamsKeys.CURSOR); - // The default view (no filters or search applied) has nothing worth persisting — only the URL query - // counts, so the bare table page always blocks save even when a default sort sits in localStorage. + // Only URL filters count, so the bare page blocks save even when a sort sits in localStorage. const hasViewToSave = [...viewParams].length > 0; - // The active sort lives in the URL when set there, otherwise only in localStorage — bake it in - // either way so a restored view orders the table the same as when it was saved. + // The sort is often only in localStorage, not the URL — bake it into the snapshot so it is restored. if (viewParams.getAll(SearchParamsKeys.SORT).length === 0) { sorting.forEach(({ desc, id }) => viewParams.append(SearchParamsKeys.SORT, `${desc ? "-" : ""}${id}`)); } const search = viewParams.toString(); - // Block saving a setup that is already persisted under another name, and surface which one. const duplicateView = savedViews.find((view) => normalizeSearch(view.search) === normalizeSearch(search)); const handleSave = () => { @@ -103,8 +96,7 @@ export const SavedViewsMenu = () => { .getAll(SearchParamsKeys.SORT) .map((sort) => ({ desc: sort.startsWith("-"), id: sort.replace("-", "") })), ); - // The sort lives in localStorage like the rest of the app expects — keep it out of the URL, or it - // lingers as a `sort` query param after filters are cleared/reset (reset clears filters, not sort). + // Keep the sort out of the URL (it lives in localStorage), or it lingers as a `sort` param after reset. params.delete(SearchParamsKeys.SORT); setSearchParams(params); setOpen(false); @@ -119,10 +111,8 @@ export const SavedViewsMenu = () => { setDefaultViewName((previous) => (previous === viewName ? null : viewName)); }; - // Restore the default view when arriving on a page with no filters, so navigation lands on it - // automatically. The decision keys off filters only — pagination and a leftover sort don't count — - // so it isn't entangled with the table's sort handling. Keyed on pathname: we deliberately don't - // re-run on the user's later edits or clears, and we never override filters from a deep link. + // Restore the default view on a filterless landing. Keyed on pathname so the user's later edits/clears + // don't re-trigger it and a deep link's filters win; sort and pagination don't count toward "filterless". useEffect(() => { const target = defaultViewName === null ? undefined : savedViews.find((view) => view.name === defaultViewName); diff --git a/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstancesFilter.tsx b/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstancesFilter.tsx index d3531224948d1..9c95a6a28bda5 100644 --- a/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstancesFilter.tsx +++ b/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstancesFilter.tsx @@ -93,6 +93,7 @@ export const TaskInstancesFilter = () => { configs={filterConfigs} initialValues={initialValues} onFiltersChange={handleFiltersChange} + showSavedViews={runId === undefined} /> ); From c45f71ecc2a1bfcadb997ce2e3930e239380cc7f Mon Sep 17 00:00:00 2001 From: pierrejeambrun Date: Mon, 6 Jul 2026 16:51:38 +0200 Subject: [PATCH 5/7] Treat table sort as a normal URL param in saved views The table already persists a non-default sort as a sort query param via useTableURLState, so a saved view can capture and restore it like any other filter instead of special-casing it through localStorage. This drops the bake-from-localStorage on save and the strip-from-URL on apply, so an applied view's URL round-trips and stays shareable, and a non-default sort now counts as an explicit landing that the default-view restore leaves alone. --- .../ui/src/components/SavedViewsMenu.test.tsx | 20 +++++++------- .../ui/src/components/SavedViewsMenu.tsx | 27 +++++++++---------- 2 files changed, 22 insertions(+), 25 deletions(-) diff --git a/airflow-core/src/airflow/ui/src/components/SavedViewsMenu.test.tsx b/airflow-core/src/airflow/ui/src/components/SavedViewsMenu.test.tsx index 1c1ff7b41aa7e..ae6cd5c0ac7c9 100644 --- a/airflow-core/src/airflow/ui/src/components/SavedViewsMenu.test.tsx +++ b/airflow-core/src/airflow/ui/src/components/SavedViewsMenu.test.tsx @@ -62,10 +62,10 @@ describe("SavedViewsMenu", () => { expect(screen.getByText("savedViews.empty")).toBeInTheDocument(); }); - it("saves the current view, dropping pagination and baking in the active sort", async () => { - localStorage.setItem("dags-table-sort", JSON.stringify([{ desc: true, id: "start_date" }])); - - render(, { wrapper: createWrapper(["/dags?state=running&offset=40"]) }); + it("saves the current view from the URL, dropping pagination and keeping the sort", async () => { + render(, { + wrapper: createWrapper(["/dags?state=running&sort=-start_date&offset=40"]), + }); const input = await openMenu(); fireEvent.change(input, { target: { value: "Running runs" } }); @@ -132,7 +132,7 @@ describe("SavedViewsMenu", () => { await waitFor(() => { expect(screen.getByTestId("location-search")).toHaveTextContent("state=success"); }); - expect(screen.getByTestId("location-search")).not.toHaveTextContent("sort"); + expect(screen.getByTestId("location-search")).toHaveTextContent("sort=-run_after"); expect(JSON.parse(localStorage.getItem("dags-table-sort") ?? "[]")).toEqual([ { desc: true, id: "run_after" }, ]); @@ -206,13 +206,13 @@ describe("SavedViewsMenu", () => { await waitFor(() => { expect(screen.getByTestId("location-search")).toHaveTextContent("state=success"); }); - expect(screen.getByTestId("location-search")).not.toHaveTextContent("sort"); + expect(screen.getByTestId("location-search")).toHaveTextContent("sort=-run_after"); expect(JSON.parse(localStorage.getItem("dags-table-sort") ?? "[]")).toEqual([ { desc: true, id: "run_after" }, ]); }); - it("restores the default view even when only a leftover sort is in the URL", async () => { + it("does not restore the default view when the URL carries a non-default sort", async () => { localStorage.setItem( "dags-saved-views", JSON.stringify([{ name: "Successful runs", search: "state=success" }]), @@ -220,10 +220,10 @@ describe("SavedViewsMenu", () => { localStorage.setItem("dags-saved-views-default", JSON.stringify("Successful runs")); render(, { wrapper: createWrapper(["/dags?sort=-run_after"]) }); + await openMenu(); - await waitFor(() => { - expect(screen.getByTestId("location-search")).toHaveTextContent("state=success"); - }); + expect(screen.getByTestId("location-search")).toHaveTextContent("sort=-run_after"); + expect(screen.getByTestId("location-search")).not.toHaveTextContent("state=success"); }); it("does not restore the default view when the page already has filters", async () => { diff --git a/airflow-core/src/airflow/ui/src/components/SavedViewsMenu.tsx b/airflow-core/src/airflow/ui/src/components/SavedViewsMenu.tsx index c32a5ba256af3..c97b9f27e830a 100644 --- a/airflow-core/src/airflow/ui/src/components/SavedViewsMenu.tsx +++ b/airflow-core/src/airflow/ui/src/components/SavedViewsMenu.tsx @@ -48,7 +48,7 @@ export const SavedViewsMenu = () => { const { t: translate } = useTranslation("common"); const { pathname } = useLocation(); const [searchParams, setSearchParams] = useSearchParams(); - const [sorting, setSorting] = useLocalStorage(tableSortKey(pathname), []); + const [, setSorting] = useLocalStorage(tableSortKey(pathname), []); const [savedViews, setSavedViews] = useLocalStorage>(savedViewsKey(pathname), []); const [defaultViewName, setDefaultViewName] = useLocalStorage( savedViewsDefaultKey(pathname), @@ -62,13 +62,9 @@ export const SavedViewsMenu = () => { viewParams.delete(SearchParamsKeys.OFFSET); viewParams.delete(SearchParamsKeys.CURSOR); - // Only URL filters count, so the bare page blocks save even when a sort sits in localStorage. + // Pagination doesn't define a view; filters and a non-default sort (both live in the URL) do. So a + // bare page has no params left and can't be saved. const hasViewToSave = [...viewParams].length > 0; - - // The sort is often only in localStorage, not the URL — bake it into the snapshot so it is restored. - if (viewParams.getAll(SearchParamsKeys.SORT).length === 0) { - sorting.forEach(({ desc, id }) => viewParams.append(SearchParamsKeys.SORT, `${desc ? "-" : ""}${id}`)); - } const search = viewParams.toString(); const duplicateView = savedViews.find((view) => normalizeSearch(view.search) === normalizeSearch(search)); @@ -91,13 +87,14 @@ export const SavedViewsMenu = () => { const applyView = (view: SavedView) => { const params = new URLSearchParams(view.search); + // The sort rides in the URL like any other param. Mirror it into localStorage as well, so the + // table's fallback default (used when the URL carries no sort) matches the view rather than a + // leftover sort from before. setSorting( params .getAll(SearchParamsKeys.SORT) .map((sort) => ({ desc: sort.startsWith("-"), id: sort.replace("-", "") })), ); - // Keep the sort out of the URL (it lives in localStorage), or it lingers as a `sort` param after reset. - params.delete(SearchParamsKeys.SORT); setSearchParams(params); setOpen(false); }; @@ -112,17 +109,17 @@ export const SavedViewsMenu = () => { }; // Restore the default view on a filterless landing. Keyed on pathname so the user's later edits/clears - // don't re-trigger it and a deep link's filters win; sort and pagination don't count toward "filterless". + // don't re-trigger it and a deep link's params win; only pagination is ignored — a non-default sort, + // like any filter, is an explicit landing that should be left alone. useEffect(() => { const target = defaultViewName === null ? undefined : savedViews.find((view) => view.name === defaultViewName); - const filters = new URLSearchParams(searchParams); + const params = new URLSearchParams(searchParams); - filters.delete(SearchParamsKeys.OFFSET); - filters.delete(SearchParamsKeys.CURSOR); - filters.delete(SearchParamsKeys.SORT); + params.delete(SearchParamsKeys.OFFSET); + params.delete(SearchParamsKeys.CURSOR); - if (target !== undefined && [...filters].length === 0) { + if (target !== undefined && [...params].length === 0) { applyView(target); } // eslint-disable-next-line react-hooks/exhaustive-deps From 6a825e88f64e417c45769ce95c2cb317f9e4442e Mon Sep 17 00:00:00 2001 From: pierrejeambrun Date: Mon, 6 Jul 2026 17:10:30 +0200 Subject: [PATCH 6/7] Rename saved views to preset filters The generic name "View" collides with the grid/gantt/graph views in the UI. "Preset Filters" describes what the feature actually is: a named snapshot of a table's filters and sort. --- .../ui/public/i18n/locales/en/common.json | 34 ++-- .../components/FilterBar/FilterBar.test.tsx | 12 +- .../ui/src/components/FilterBar/FilterBar.tsx | 6 +- .../ui/src/components/FilterBar/types.ts | 4 +- ...nu.test.tsx => PresetFiltersMenu.test.tsx} | 132 ++++++++-------- ...vedViewsMenu.tsx => PresetFiltersMenu.tsx} | 146 ++++++++++-------- .../airflow/ui/src/constants/localStorage.ts | 7 +- .../TaskInstances/TaskInstancesFilter.tsx | 2 +- 8 files changed, 179 insertions(+), 164 deletions(-) rename airflow-core/src/airflow/ui/src/components/{SavedViewsMenu.test.tsx => PresetFiltersMenu.test.tsx} (56%) rename airflow-core/src/airflow/ui/src/components/{SavedViewsMenu.tsx => PresetFiltersMenu.tsx} (55%) diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json index 32032d8a258ca..331ce3857c0ce 100644 --- a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json +++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json @@ -194,6 +194,23 @@ }, "pendingDagRun_one": "{{count}} Pending Dag Run", "pendingDagRun_other": "{{count}} Pending Dag Runs", + "presetFilters": { + "deleteTitle": "Delete preset filter", + "deleteWarning": "Preset filters are stored in this browser only.", + "duplicate": "The preset filter \"{{name}}\" already saves this exact setup.", + "empty": "No preset filters yet", + "info": { + "default": "Pin a preset filter as the default and it is applied automatically when you land on this page with no filters applied.", + "save": "Save the current filters and sorting as a named preset filter, then switch back to it anytime.", + "storage": "Preset filters are stored in this browser." + }, + "namePlaceholder": "Preset filter name", + "nothingToSave": "Nothing to save. Apply filters or sorting to save a preset filter.", + "save": "Save", + "setDefault": "Set as default", + "title": "Preset Filters", + "unsetDefault": "Unset default" + }, "reset": "Reset", "runId": "Run ID", "runTypes": { @@ -203,23 +220,6 @@ "manual": "Manual", "scheduled": "Scheduled" }, - "savedViews": { - "deleteTitle": "Delete view", - "deleteWarning": "Saved views are stored in this browser only.", - "duplicate": "The view \"{{name}}\" already saves this exact setup.", - "empty": "No saved views yet", - "info": { - "default": "Pin a view as the default and it is shown automatically when you land on this page with no filters applied.", - "save": "Save the current filters and sorting as a named view, then switch back to it anytime.", - "storage": "Views are stored in this browser." - }, - "namePlaceholder": "View name", - "nothingToSave": "Nothing to save on the default view. Apply filters or sorting to save a view.", - "save": "Save", - "setDefault": "Set as default view", - "title": "Views", - "unsetDefault": "Unset default view" - }, "scroll": { "direction": { "bottom": "bottom", 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 314867e0cbc9e..f7d261ac3e594 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 @@ -34,16 +34,16 @@ const wrapper = ({ children }: PropsWithChildren) => ( afterEach(cleanup); -describe("FilterBar saved views", () => { - it("shows the saved views control by default", () => { +describe("FilterBar preset filters", () => { + it("shows the preset filters control by default", () => { render(, { wrapper }); - expect(screen.getByTestId("saved-views-button")).toBeInTheDocument(); + expect(screen.getByTestId("preset-filters-button")).toBeInTheDocument(); }); - it("hides the saved views control when showSavedViews is false", () => { - render(, { wrapper }); + it("hides the preset filters control when showPresetFilters is false", () => { + render(, { wrapper }); - expect(screen.queryByTestId("saved-views-button")).not.toBeInTheDocument(); + expect(screen.queryByTestId("preset-filters-button")).not.toBeInTheDocument(); }); }); diff --git a/airflow-core/src/airflow/ui/src/components/FilterBar/FilterBar.tsx b/airflow-core/src/airflow/ui/src/components/FilterBar/FilterBar.tsx index 8363bfbe4ee14..00d99ab8b0167 100644 --- a/airflow-core/src/airflow/ui/src/components/FilterBar/FilterBar.tsx +++ b/airflow-core/src/airflow/ui/src/components/FilterBar/FilterBar.tsx @@ -22,7 +22,7 @@ import { useTranslation } from "react-i18next"; import { MdAdd, MdClear } from "react-icons/md"; import { useDebouncedCallback } from "use-debounce"; -import { SavedViewsMenu } from "src/components/SavedViewsMenu"; +import { PresetFiltersMenu } from "src/components/PresetFiltersMenu"; import { Menu } from "src/components/ui"; import { getDefaultFilterIcon } from "./defaultIcons"; @@ -44,7 +44,7 @@ export const FilterBar = ({ initialValues = defaultInitialValues, maxVisibleFilters = 10, onFiltersChange, - showSavedViews = true, + showPresetFilters = true, }: FilterBarProps) => { const { t: translate } = useTranslation(["admin", "common"]); const [filters, setFilters] = useState>(() => @@ -221,7 +221,7 @@ export const FilterBar = ({ )} - {showSavedViews ? : undefined} + {showPresetFilters ? : undefined} ); }; diff --git a/airflow-core/src/airflow/ui/src/components/FilterBar/types.ts b/airflow-core/src/airflow/ui/src/components/FilterBar/types.ts index 5d0c1981e26ec..2524fe64f4373 100644 --- a/airflow-core/src/airflow/ui/src/components/FilterBar/types.ts +++ b/airflow-core/src/airflow/ui/src/components/FilterBar/types.ts @@ -56,8 +56,8 @@ export type FilterBarProps = { readonly initialValues?: Record; readonly maxVisibleFilters?: number; readonly onFiltersChange: (filters: Record) => void; - // Hide the Views control where saved views aren't useful (e.g. a Dag run's per-run task instances). - readonly showSavedViews?: boolean; + // Hide the Preset Filters control where they aren't useful (e.g. a Dag run's per-run task instances). + readonly showPresetFilters?: boolean; }; export type FilterPluginProps = { diff --git a/airflow-core/src/airflow/ui/src/components/SavedViewsMenu.test.tsx b/airflow-core/src/airflow/ui/src/components/PresetFiltersMenu.test.tsx similarity index 56% rename from airflow-core/src/airflow/ui/src/components/SavedViewsMenu.test.tsx rename to airflow-core/src/airflow/ui/src/components/PresetFiltersMenu.test.tsx index ae6cd5c0ac7c9..7d4f0790c884b 100644 --- a/airflow-core/src/airflow/ui/src/components/SavedViewsMenu.test.tsx +++ b/airflow-core/src/airflow/ui/src/components/PresetFiltersMenu.test.tsx @@ -24,7 +24,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { BaseWrapper } from "src/utils/Wrapper"; -import { SavedViewsMenu } from "./SavedViewsMenu"; +import { PresetFiltersMenu } from "./PresetFiltersMenu"; const LocationProbe = () => { const location = useLocation(); @@ -44,9 +44,9 @@ const createWrapper = ); const openMenu = async () => { - fireEvent.click(screen.getByTestId("saved-views-button")); + fireEvent.click(screen.getByTestId("preset-filters-button")); - return screen.findByTestId("saved-view-name"); + return screen.findByTestId("preset-filter-name"); }; afterEach(() => { @@ -54,77 +54,77 @@ afterEach(() => { localStorage.clear(); }); -describe("SavedViewsMenu", () => { - it("shows an empty state when no views are saved", async () => { - render(, { wrapper: createWrapper() }); +describe("PresetFiltersMenu", () => { + it("shows an empty state when no preset filters are saved", async () => { + render(, { wrapper: createWrapper() }); await openMenu(); - expect(screen.getByText("savedViews.empty")).toBeInTheDocument(); + expect(screen.getByText("presetFilters.empty")).toBeInTheDocument(); }); - it("saves the current view from the URL, dropping pagination and keeping the sort", async () => { - render(, { + it("saves the current preset from the URL, dropping pagination and keeping the sort", async () => { + render(, { wrapper: createWrapper(["/dags?state=running&sort=-start_date&offset=40"]), }); const input = await openMenu(); fireEvent.change(input, { target: { value: "Running runs" } }); - fireEvent.click(screen.getByTestId("saved-view-save")); + fireEvent.click(screen.getByTestId("preset-filter-save")); await waitFor(() => { - expect(JSON.parse(localStorage.getItem("dags-saved-views") ?? "[]")).toEqual([ + expect(JSON.parse(localStorage.getItem("dags-preset-filters") ?? "[]")).toEqual([ { name: "Running runs", search: "state=running&sort=-start_date" }, ]); }); expect(input).toHaveValue(""); }); - it("does not save a view with a blank name", async () => { - render(, { wrapper: createWrapper(["/dags?state=running"]) }); + it("does not save a preset with a blank name", async () => { + render(, { wrapper: createWrapper(["/dags?state=running"]) }); const input = await openMenu(); fireEvent.change(input, { target: { value: " " } }); - expect(screen.getByTestId("saved-view-save")).toBeDisabled(); + expect(screen.getByTestId("preset-filter-save")).toBeDisabled(); }); - it("disables save on the default view, even with a stored sort and a typed name", async () => { + it("disables save on a bare page, even with a stored sort and a typed name", async () => { localStorage.setItem("dags-table-sort", JSON.stringify([{ desc: true, id: "run_after" }])); - render(, { wrapper: createWrapper(["/dags"]) }); + render(, { wrapper: createWrapper(["/dags"]) }); const input = await openMenu(); - fireEvent.change(input, { target: { value: "My view" } }); + fireEvent.change(input, { target: { value: "My preset" } }); - expect(screen.getByTestId("saved-view-save")).toBeDisabled(); + expect(screen.getByTestId("preset-filter-save")).toBeDisabled(); }); - it("overwrites an existing view with the same name", async () => { + it("overwrites an existing preset with the same name", async () => { localStorage.setItem( - "dags-saved-views", + "dags-preset-filters", JSON.stringify([{ name: "Running runs", search: "state=queued" }]), ); - render(, { wrapper: createWrapper(["/dags?state=running"]) }); + render(, { wrapper: createWrapper(["/dags?state=running"]) }); const input = await openMenu(); fireEvent.change(input, { target: { value: "Running runs" } }); - fireEvent.click(screen.getByTestId("saved-view-save")); + fireEvent.click(screen.getByTestId("preset-filter-save")); await waitFor(() => { - expect(JSON.parse(localStorage.getItem("dags-saved-views") ?? "[]")).toEqual([ + expect(JSON.parse(localStorage.getItem("dags-preset-filters") ?? "[]")).toEqual([ { name: "Running runs", search: "state=running" }, ]); }); }); - it("restores a saved view to the URL and the table sort", async () => { + it("restores a preset to the URL and the table sort", async () => { localStorage.setItem( - "dags-saved-views", + "dags-preset-filters", JSON.stringify([{ name: "Successful runs", search: "state=success&sort=-run_after" }]), ); - render(, { wrapper: createWrapper(["/dags"]) }); + render(, { wrapper: createWrapper(["/dags"]) }); await openMenu(); fireEvent.click(screen.getByText("Successful runs")); @@ -138,70 +138,70 @@ describe("SavedViewsMenu", () => { ]); }); - it("deletes a saved view after confirming", async () => { + it("deletes a preset after confirming", async () => { localStorage.setItem( - "dags-saved-views", + "dags-preset-filters", JSON.stringify([ { name: "Successful runs", search: "state=success" }, { name: "Failed runs", search: "state=failed" }, ]), ); - render(, { wrapper: createWrapper(["/dags"]) }); + render(, { wrapper: createWrapper(["/dags"]) }); await openMenu(); - fireEvent.click(screen.getAllByLabelText("Delete view")[0] as HTMLElement); + fireEvent.click(screen.getAllByLabelText("Delete preset filter")[0] as HTMLElement); fireEvent.click(await screen.findByTestId("delete-confirm-button")); await waitFor(() => { - expect(JSON.parse(localStorage.getItem("dags-saved-views") ?? "[]")).toEqual([ + expect(JSON.parse(localStorage.getItem("dags-preset-filters") ?? "[]")).toEqual([ { name: "Failed runs", search: "state=failed" }, ]); }); }); - it("keeps the saved view when the delete is cancelled", async () => { + it("keeps the preset when the delete is cancelled", async () => { localStorage.setItem( - "dags-saved-views", + "dags-preset-filters", JSON.stringify([{ name: "Successful runs", search: "state=success" }]), ); - render(, { wrapper: createWrapper(["/dags"]) }); + render(, { wrapper: createWrapper(["/dags"]) }); await openMenu(); - fireEvent.click(screen.getByLabelText("Delete view")); + fireEvent.click(screen.getByLabelText("Delete preset filter")); fireEvent.click(await screen.findByTestId("delete-cancel-button")); await waitFor(() => { expect(screen.queryByTestId("delete-confirm-button")).not.toBeInTheDocument(); }); - expect(JSON.parse(localStorage.getItem("dags-saved-views") ?? "[]")).toEqual([ + expect(JSON.parse(localStorage.getItem("dags-preset-filters") ?? "[]")).toEqual([ { name: "Successful runs", search: "state=success" }, ]); }); it("blocks save when the exact setup is already saved under another name", async () => { localStorage.setItem( - "dags-saved-views", + "dags-preset-filters", JSON.stringify([{ name: "Running runs", search: "state=running" }]), ); - render(, { wrapper: createWrapper(["/dags?state=running"]) }); + render(, { wrapper: createWrapper(["/dags?state=running"]) }); const input = await openMenu(); fireEvent.change(input, { target: { value: "Another name" } }); - expect(screen.getByTestId("saved-view-save")).toBeDisabled(); + expect(screen.getByTestId("preset-filter-save")).toBeDisabled(); }); - it("restores the default view automatically on a bare page load", async () => { + it("restores the default preset automatically on a bare page load", async () => { localStorage.setItem( - "dags-saved-views", + "dags-preset-filters", JSON.stringify([{ name: "Successful runs", search: "state=success&sort=-run_after" }]), ); - localStorage.setItem("dags-saved-views-default", JSON.stringify("Successful runs")); + localStorage.setItem("dags-preset-filters-default", JSON.stringify("Successful runs")); - render(, { wrapper: createWrapper(["/dags"]) }); + render(, { wrapper: createWrapper(["/dags"]) }); await waitFor(() => { expect(screen.getByTestId("location-search")).toHaveTextContent("state=success"); @@ -212,71 +212,73 @@ describe("SavedViewsMenu", () => { ]); }); - it("does not restore the default view when the URL carries a non-default sort", async () => { + it("does not restore the default preset when the URL carries a non-default sort", async () => { localStorage.setItem( - "dags-saved-views", + "dags-preset-filters", JSON.stringify([{ name: "Successful runs", search: "state=success" }]), ); - localStorage.setItem("dags-saved-views-default", JSON.stringify("Successful runs")); + localStorage.setItem("dags-preset-filters-default", JSON.stringify("Successful runs")); - render(, { wrapper: createWrapper(["/dags?sort=-run_after"]) }); + render(, { wrapper: createWrapper(["/dags?sort=-run_after"]) }); await openMenu(); expect(screen.getByTestId("location-search")).toHaveTextContent("sort=-run_after"); expect(screen.getByTestId("location-search")).not.toHaveTextContent("state=success"); }); - it("does not restore the default view when the page already has filters", async () => { + it("does not restore the default preset when the page already has filters", async () => { localStorage.setItem( - "dags-saved-views", + "dags-preset-filters", JSON.stringify([{ name: "Successful runs", search: "state=success" }]), ); - localStorage.setItem("dags-saved-views-default", JSON.stringify("Successful runs")); + localStorage.setItem("dags-preset-filters-default", JSON.stringify("Successful runs")); - render(, { wrapper: createWrapper(["/dags?state=running"]) }); + render(, { wrapper: createWrapper(["/dags?state=running"]) }); await openMenu(); expect(screen.getByTestId("location-search")).toHaveTextContent("state=running"); expect(screen.getByTestId("location-search")).not.toHaveTextContent("state=success"); }); - it("toggles a view as the default and back", async () => { + it("toggles a preset as the default and back", async () => { localStorage.setItem( - "dags-saved-views", + "dags-preset-filters", JSON.stringify([{ name: "Successful runs", search: "state=success" }]), ); - render(, { wrapper: createWrapper(["/dags"]) }); + render(, { wrapper: createWrapper(["/dags"]) }); await openMenu(); - fireEvent.click(screen.getByLabelText("savedViews.setDefault")); + fireEvent.click(screen.getByLabelText("presetFilters.setDefault")); await waitFor(() => { - expect(JSON.parse(localStorage.getItem("dags-saved-views-default") ?? "null")).toBe("Successful runs"); + expect(JSON.parse(localStorage.getItem("dags-preset-filters-default") ?? "null")).toBe( + "Successful runs", + ); }); - fireEvent.click(screen.getByLabelText("savedViews.unsetDefault")); + fireEvent.click(screen.getByLabelText("presetFilters.unsetDefault")); await waitFor(() => { - expect(JSON.parse(localStorage.getItem("dags-saved-views-default") ?? "null")).toBeNull(); + expect(JSON.parse(localStorage.getItem("dags-preset-filters-default") ?? "null")).toBeNull(); }); }); - it("clears the stored default when the default view is deleted", async () => { + it("clears the stored default when the default preset is deleted", async () => { localStorage.setItem( - "dags-saved-views", + "dags-preset-filters", JSON.stringify([{ name: "Successful runs", search: "state=success" }]), ); - localStorage.setItem("dags-saved-views-default", JSON.stringify("Successful runs")); + localStorage.setItem("dags-preset-filters-default", JSON.stringify("Successful runs")); // A non-bare URL so the default isn't auto-restored before we delete it. - render(, { wrapper: createWrapper(["/dags?state=running"]) }); + render(, { wrapper: createWrapper(["/dags?state=running"]) }); await openMenu(); - fireEvent.click(screen.getByLabelText("Delete view")); + fireEvent.click(screen.getByLabelText("Delete preset filter")); fireEvent.click(await screen.findByTestId("delete-confirm-button")); await waitFor(() => { - expect(JSON.parse(localStorage.getItem("dags-saved-views") ?? "[]")).toEqual([]); + expect(JSON.parse(localStorage.getItem("dags-preset-filters") ?? "[]")).toEqual([]); }); - expect(JSON.parse(localStorage.getItem("dags-saved-views-default") ?? "null")).toBeNull(); + expect(JSON.parse(localStorage.getItem("dags-preset-filters-default") ?? "null")).toBeNull(); }); }); diff --git a/airflow-core/src/airflow/ui/src/components/SavedViewsMenu.tsx b/airflow-core/src/airflow/ui/src/components/PresetFiltersMenu.tsx similarity index 55% rename from airflow-core/src/airflow/ui/src/components/SavedViewsMenu.tsx rename to airflow-core/src/airflow/ui/src/components/PresetFiltersMenu.tsx index c97b9f27e830a..e57304f51577d 100644 --- a/airflow-core/src/airflow/ui/src/components/SavedViewsMenu.tsx +++ b/airflow-core/src/airflow/ui/src/components/PresetFiltersMenu.tsx @@ -28,10 +28,10 @@ import { useLocalStorage } from "usehooks-ts"; import DeleteDialog from "src/components/DeleteDialog"; import { IconButton, Popover, Tooltip } from "src/components/ui"; -import { savedViewsDefaultKey, savedViewsKey, tableSortKey } from "src/constants/localStorage"; +import { presetFiltersDefaultKey, presetFiltersKey, tableSortKey } from "src/constants/localStorage"; import { SearchParamsKeys } from "src/constants/searchParams"; -type SavedView = { +type PresetFilter = { readonly name: string; readonly search: string; }; @@ -44,51 +44,56 @@ const normalizeSearch = (value: string) => { return params.toString(); }; -export const SavedViewsMenu = () => { +export const PresetFiltersMenu = () => { const { t: translate } = useTranslation("common"); const { pathname } = useLocation(); const [searchParams, setSearchParams] = useSearchParams(); const [, setSorting] = useLocalStorage(tableSortKey(pathname), []); - const [savedViews, setSavedViews] = useLocalStorage>(savedViewsKey(pathname), []); - const [defaultViewName, setDefaultViewName] = useLocalStorage( - savedViewsDefaultKey(pathname), + const [presetFilters, setPresetFilters] = useLocalStorage>( + presetFiltersKey(pathname), + [], + ); + const [defaultPresetName, setDefaultPresetName] = useLocalStorage( + presetFiltersDefaultKey(pathname), null, ); const [name, setName] = useState(""); const [open, setOpen] = useState(false); - const [viewToDelete, setViewToDelete] = useState(undefined); + const [presetToDelete, setPresetToDelete] = useState(undefined); - const viewParams = new URLSearchParams(searchParams); + const presetParams = new URLSearchParams(searchParams); - viewParams.delete(SearchParamsKeys.OFFSET); - viewParams.delete(SearchParamsKeys.CURSOR); - // Pagination doesn't define a view; filters and a non-default sort (both live in the URL) do. So a + presetParams.delete(SearchParamsKeys.OFFSET); + presetParams.delete(SearchParamsKeys.CURSOR); + // Pagination doesn't define a preset; filters and a non-default sort (both live in the URL) do. So a // bare page has no params left and can't be saved. - const hasViewToSave = [...viewParams].length > 0; - const search = viewParams.toString(); + const hasPresetToSave = [...presetParams].length > 0; + const search = presetParams.toString(); - const duplicateView = savedViews.find((view) => normalizeSearch(view.search) === normalizeSearch(search)); + const duplicatePreset = presetFilters.find( + (preset) => normalizeSearch(preset.search) === normalizeSearch(search), + ); const handleSave = () => { const trimmedName = name.trim(); - if (trimmedName === "" || !hasViewToSave || duplicateView !== undefined) { + if (trimmedName === "" || !hasPresetToSave || duplicatePreset !== undefined) { return; } - setSavedViews((prev) => - prev.some((view) => view.name === trimmedName) - ? prev.map((view) => (view.name === trimmedName ? { name: trimmedName, search } : view)) + setPresetFilters((prev) => + prev.some((preset) => preset.name === trimmedName) + ? prev.map((preset) => (preset.name === trimmedName ? { name: trimmedName, search } : preset)) : [...prev, { name: trimmedName, search }], ); setName(""); }; - const applyView = (view: SavedView) => { - const params = new URLSearchParams(view.search); + const applyPreset = (preset: PresetFilter) => { + const params = new URLSearchParams(preset.search); // The sort rides in the URL like any other param. Mirror it into localStorage as well, so the - // table's fallback default (used when the URL carries no sort) matches the view rather than a + // table's fallback default (used when the URL carries no sort) matches the preset rather than a // leftover sort from before. setSorting( params @@ -99,45 +104,47 @@ export const SavedViewsMenu = () => { setOpen(false); }; - const deleteView = (viewName: string) => { - setSavedViews((prev) => prev.filter((view) => view.name !== viewName)); - setDefaultViewName((previous) => (previous === viewName ? null : previous)); + const deletePreset = (presetName: string) => { + setPresetFilters((prev) => prev.filter((preset) => preset.name !== presetName)); + setDefaultPresetName((previous) => (previous === presetName ? null : previous)); }; - const toggleDefault = (viewName: string) => { - setDefaultViewName((previous) => (previous === viewName ? null : viewName)); + const toggleDefault = (presetName: string) => { + setDefaultPresetName((previous) => (previous === presetName ? null : presetName)); }; - // Restore the default view on a filterless landing. Keyed on pathname so the user's later edits/clears - // don't re-trigger it and a deep link's params win; only pagination is ignored — a non-default sort, - // like any filter, is an explicit landing that should be left alone. + // Restore the default preset on a filterless landing. Keyed on pathname so the user's later + // edits/clears don't re-trigger it and a deep link's params win; only pagination is ignored — a + // non-default sort, like any filter, is an explicit landing that should be left alone. useEffect(() => { const target = - defaultViewName === null ? undefined : savedViews.find((view) => view.name === defaultViewName); + defaultPresetName === null + ? undefined + : presetFilters.find((preset) => preset.name === defaultPresetName); const params = new URLSearchParams(searchParams); params.delete(SearchParamsKeys.OFFSET); params.delete(SearchParamsKeys.CURSOR); if (target !== undefined && [...params].length === 0) { - applyView(target); + applyPreset(target); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [pathname]); let saveHint: string | undefined; - if (!hasViewToSave) { - saveHint = translate("savedViews.nothingToSave"); - } else if (duplicateView !== undefined) { - saveHint = translate("savedViews.duplicate", { name: duplicateView.name }); + if (!hasPresetToSave) { + saveHint = translate("presetFilters.nothingToSave"); + } else if (duplicatePreset !== undefined) { + saveHint = translate("presetFilters.duplicate", { name: duplicatePreset.name }); } const infoContent = ( - {translate("savedViews.info.save")} - {translate("savedViews.info.default")} - {translate("savedViews.info.storage")} + {translate("presetFilters.info.save")} + {translate("presetFilters.info.default")} + {translate("presetFilters.info.storage")} ); @@ -151,9 +158,14 @@ export const SavedViewsMenu = () => { unmountOnExit > - @@ -161,7 +173,7 @@ export const SavedViewsMenu = () => { - {translate("savedViews.title")} + {translate("presetFilters.title")} @@ -171,62 +183,62 @@ export const SavedViewsMenu = () => { setName(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") { handleSave(); } }} - placeholder={translate("savedViews.namePlaceholder")} + placeholder={translate("presetFilters.namePlaceholder")} size="sm" value={name} /> - {savedViews.length === 0 ? ( + {presetFilters.length === 0 ? ( - {translate("savedViews.empty")} + {translate("presetFilters.empty")} ) : ( - savedViews.map((view) => ( - + presetFilters.map((preset) => ( + toggleDefault(view.name)} + onClick={() => toggleDefault(preset.name)} size="sm" variant="ghost" > - {defaultViewName === view.name ? : } + {defaultPresetName === preset.name ? : } setViewToDelete(view.name)} + aria-label="Delete preset filter" + onClick={() => setPresetToDelete(preset.name)} size="sm" variant="ghost" > @@ -240,17 +252,17 @@ export const SavedViewsMenu = () => { setViewToDelete(undefined)} + onClose={() => setPresetToDelete(undefined)} onDelete={() => { - if (viewToDelete !== undefined) { - deleteView(viewToDelete); + if (presetToDelete !== undefined) { + deletePreset(presetToDelete); } - setViewToDelete(undefined); + setPresetToDelete(undefined); }} - open={viewToDelete !== undefined} - resourceName={viewToDelete ?? ""} - title={translate("savedViews.deleteTitle")} - warningText={translate("savedViews.deleteWarning")} + open={presetToDelete !== undefined} + resourceName={presetToDelete ?? ""} + title={translate("presetFilters.deleteTitle")} + warningText={translate("presetFilters.deleteWarning")} /> ); diff --git a/airflow-core/src/airflow/ui/src/constants/localStorage.ts b/airflow-core/src/airflow/ui/src/constants/localStorage.ts index e7b9ea9abdedd..0efb40eefd202 100644 --- a/airflow-core/src/airflow/ui/src/constants/localStorage.ts +++ b/airflow-core/src/airflow/ui/src/constants/localStorage.ts @@ -37,9 +37,10 @@ export const allGroupsKey = (dagId: string) => `${dagId}/all-groups`; // Page-scoped keys export const tableSortKey = (pageName: string) => `${pageName.replaceAll("/", "-").slice(1)}-table-sort`; -export const savedViewsKey = (pageName: string) => `${pageName.replaceAll("/", "-").slice(1)}-saved-views`; -export const savedViewsDefaultKey = (pageName: string) => - `${pageName.replaceAll("/", "-").slice(1)}-saved-views-default`; +export const presetFiltersKey = (pageName: string) => + `${pageName.replaceAll("/", "-").slice(1)}-preset-filters`; +export const presetFiltersDefaultKey = (pageName: string) => + `${pageName.replaceAll("/", "-").slice(1)}-preset-filters-default`; // SearchBar advanced (substring) toggle, scoped per searchbar via a caller-provided id. export const advancedSearchKey = (id: string) => `advanced_search-${id}`; diff --git a/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstancesFilter.tsx b/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstancesFilter.tsx index 9c95a6a28bda5..2e14d51c7b3a5 100644 --- a/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstancesFilter.tsx +++ b/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstancesFilter.tsx @@ -93,7 +93,7 @@ export const TaskInstancesFilter = () => { configs={filterConfigs} initialValues={initialValues} onFiltersChange={handleFiltersChange} - showSavedViews={runId === undefined} + showPresetFilters={runId === undefined} /> ); From a4334044f1c0ae01b02b09a40ad0b57df736da15 Mon Sep 17 00:00:00 2001 From: pierrejeambrun Date: Mon, 6 Jul 2026 17:34:05 +0200 Subject: [PATCH 7/7] Clarify preset filter comments on param handling The filterless check and save guard treat every URL param the same, so their comments no longer single out sort. The applyPreset comment now explains the one genuinely sort-specific spot: the table caches sort in localStorage, so a preset must mirror it there. --- .../ui/src/components/PresetFiltersMenu.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/airflow-core/src/airflow/ui/src/components/PresetFiltersMenu.tsx b/airflow-core/src/airflow/ui/src/components/PresetFiltersMenu.tsx index e57304f51577d..7ebd0912cfe53 100644 --- a/airflow-core/src/airflow/ui/src/components/PresetFiltersMenu.tsx +++ b/airflow-core/src/airflow/ui/src/components/PresetFiltersMenu.tsx @@ -65,8 +65,8 @@ export const PresetFiltersMenu = () => { presetParams.delete(SearchParamsKeys.OFFSET); presetParams.delete(SearchParamsKeys.CURSOR); - // Pagination doesn't define a preset; filters and a non-default sort (both live in the URL) do. So a - // bare page has no params left and can't be saved. + // Pagination doesn't define a preset; any other URL param does. So a bare page has no params + // left and can't be saved. const hasPresetToSave = [...presetParams].length > 0; const search = presetParams.toString(); @@ -92,9 +92,9 @@ export const PresetFiltersMenu = () => { const applyPreset = (preset: PresetFilter) => { const params = new URLSearchParams(preset.search); - // The sort rides in the URL like any other param. Mirror it into localStorage as well, so the - // table's fallback default (used when the URL carries no sort) matches the preset rather than a - // leftover sort from before. + // Unlike filters, the table (useTableURLState) also caches the sort in localStorage and falls + // back to it when the URL has no sort. Mirror the preset's sort there too, or a preset without a + // sort would inherit a stale one. setSorting( params .getAll(SearchParamsKeys.SORT) @@ -114,8 +114,8 @@ export const PresetFiltersMenu = () => { }; // Restore the default preset on a filterless landing. Keyed on pathname so the user's later - // edits/clears don't re-trigger it and a deep link's params win; only pagination is ignored — a - // non-default sort, like any filter, is an explicit landing that should be left alone. + // edits/clears don't re-trigger it and a deep link wins; only pagination is ignored, since any + // other param makes the landing explicit and should be left alone. useEffect(() => { const target = defaultPresetName === null