This is an automated email from the ASF dual-hosted git repository.
pierrejeambrun pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new ff684847b12 Add preset filters to persist table filters and sorting
(#68484)
ff684847b12 is described below
commit ff684847b12f4cdce1718c2a6e75d378bae4ddef
Author: Pierre Jeambrun <[email protected]>
AuthorDate: Tue Jul 7 01:21:17 2026 +0200
Add preset filters to persist table filters and sorting (#68484)
* UI: Add saved views to persist table filters and sorting
* 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.
* 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.
* 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.
* 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.
* 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.
* 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.
---
.../airflow/ui/public/i18n/locales/en/common.json | 17 ++
.../ui/src/components/FilterBar/FilterBar.test.tsx | 49 ++++
.../ui/src/components/FilterBar/FilterBar.tsx | 77 +++---
.../airflow/ui/src/components/FilterBar/types.ts | 2 +
.../ui/src/components/PresetFiltersMenu.test.tsx | 284 +++++++++++++++++++++
.../ui/src/components/PresetFiltersMenu.tsx | 269 +++++++++++++++++++
.../src/airflow/ui/src/constants/localStorage.ts | 4 +
.../pages/TaskInstances/TaskInstancesFilter.tsx | 1 +
8 files changed, 667 insertions(+), 36 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 8feb70b42fb..e27deafb42b 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
@@ -195,6 +195,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": {
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 00000000000..f7d261ac3e5
--- /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) => (
+ <BaseWrapper>
+ <MemoryRouter initialEntries={["/dags"]}>{children}</MemoryRouter>
+ </BaseWrapper>
+);
+
+afterEach(cleanup);
+
+describe("FilterBar preset filters", () => {
+ it("shows the preset filters control by default", () => {
+ render(<FilterBar configs={[]} onFiltersChange={vi.fn()} />, { wrapper });
+
+ expect(screen.getByTestId("preset-filters-button")).toBeInTheDocument();
+ });
+
+ it("hides the preset filters control when showPresetFilters is false", () =>
{
+ render(<FilterBar configs={[]} onFiltersChange={vi.fn()}
showPresetFilters={false} />, { wrapper });
+
+
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 138ad02f0d1..00d99ab8b01 100644
--- a/airflow-core/src/airflow/ui/src/components/FilterBar/FilterBar.tsx
+++ b/airflow-core/src/airflow/ui/src/components/FilterBar/FilterBar.tsx
@@ -16,12 +16,13 @@
* 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";
import { useDebouncedCallback } from "use-debounce";
+import { PresetFiltersMenu } from "src/components/PresetFiltersMenu";
import { Menu } from "src/components/ui";
import { getDefaultFilterIcon } from "./defaultIcons";
@@ -43,6 +44,7 @@ export const FilterBar = ({
initialValues = defaultInitialValues,
maxVisibleFilters = 10,
onFiltersChange,
+ showPresetFilters = true,
}: FilterBarProps) => {
const { t: translate } = useTranslation(["admin", "common"]);
const [filters, setFilters] = useState<Array<FilterState>>(() =>
@@ -183,40 +185,43 @@ export const FilterBar = ({
};
return (
- <HStack gap={2} wrap="wrap">
- {filters.slice(0, maxVisibleFilters).map(renderFilter)}
- {availableConfigs.length > 0 && (
- <Menu.Root>
- <Menu.Trigger asChild>
- <Button
- _hover={{ bg: "colorPalette.subtle" }}
- bg="gray.muted"
- borderRadius="full"
- data-testid="add-filter-button"
- variant="outline"
- >
- <MdAdd />
- {translate("common:filter")}
- </Button>
- </Menu.Trigger>
- <Menu.Content>
- {availableConfigs.map((config) => (
- <Menu.Item key={config.key} onClick={() => addFilter(config)}
value={config.key}>
- <HStack gap={2}>
- {getFilterIcon(config)}
- {config.label}
- </HStack>
- </Menu.Item>
- ))}
- </Menu.Content>
- </Menu.Root>
- )}
- {filters.length > 0 && (
- <Button borderRadius="full" colorPalette="gray" onClick={resetFilters}
variant="outline">
- <MdClear />
- {translate("common:reset")}
- </Button>
- )}
- </HStack>
+ <Flex align="flex-start" gap={2} justify="space-between" width="100%">
+ <HStack flex="1" gap={2} minW={0} wrap="wrap">
+ {filters.slice(0, maxVisibleFilters).map(renderFilter)}
+ {availableConfigs.length > 0 && (
+ <Menu.Root>
+ <Menu.Trigger asChild>
+ <Button
+ _hover={{ bg: "colorPalette.subtle" }}
+ bg="gray.muted"
+ borderRadius="full"
+ data-testid="add-filter-button"
+ variant="outline"
+ >
+ <MdAdd />
+ {translate("common:filter")}
+ </Button>
+ </Menu.Trigger>
+ <Menu.Content>
+ {availableConfigs.map((config) => (
+ <Menu.Item key={config.key} onClick={() => addFilter(config)}
value={config.key}>
+ <HStack gap={2}>
+ {getFilterIcon(config)}
+ {config.label}
+ </HStack>
+ </Menu.Item>
+ ))}
+ </Menu.Content>
+ </Menu.Root>
+ )}
+ {filters.length > 0 && (
+ <Button borderRadius="full" colorPalette="gray"
onClick={resetFilters} variant="outline">
+ <MdClear />
+ {translate("common:reset")}
+ </Button>
+ )}
+ </HStack>
+ {showPresetFilters ? <PresetFiltersMenu /> : undefined}
+ </Flex>
);
};
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 8e04bf53c72..2524fe64f43 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<string, FilterValue>;
readonly maxVisibleFilters?: number;
readonly onFiltersChange: (filters: Record<string, FilterValue>) => void;
+ // 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/PresetFiltersMenu.test.tsx
b/airflow-core/src/airflow/ui/src/components/PresetFiltersMenu.test.tsx
new file mode 100644
index 00000000000..7d4f0790c88
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/PresetFiltersMenu.test.tsx
@@ -0,0 +1,284 @@
+/*!
+ * 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 { PresetFiltersMenu } from "./PresetFiltersMenu";
+
+const LocationProbe = () => {
+ const location = useLocation();
+
+ return <div data-testid="location-search">{location.search}</div>;
+};
+
+const createWrapper =
+ (initialEntries: Array<string> = ["/dags"]) =>
+ ({ children }: PropsWithChildren) => (
+ <BaseWrapper>
+ <MemoryRouter initialEntries={initialEntries}>
+ {children}
+ <LocationProbe />
+ </MemoryRouter>
+ </BaseWrapper>
+ );
+
+const openMenu = async () => {
+ fireEvent.click(screen.getByTestId("preset-filters-button"));
+
+ return screen.findByTestId("preset-filter-name");
+};
+
+afterEach(() => {
+ cleanup();
+ localStorage.clear();
+});
+
+describe("PresetFiltersMenu", () => {
+ it("shows an empty state when no preset filters are saved", async () => {
+ render(<PresetFiltersMenu />, { wrapper: createWrapper() });
+ await openMenu();
+
+ expect(screen.getByText("presetFilters.empty")).toBeInTheDocument();
+ });
+
+ it("saves the current preset from the URL, dropping pagination and keeping
the sort", async () => {
+ render(<PresetFiltersMenu />, {
+ 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("preset-filter-save"));
+
+ await waitFor(() => {
+ 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 preset with a blank name", async () => {
+ render(<PresetFiltersMenu />, { wrapper:
createWrapper(["/dags?state=running"]) });
+ const input = await openMenu();
+
+ fireEvent.change(input, { target: { value: " " } });
+
+ expect(screen.getByTestId("preset-filter-save")).toBeDisabled();
+ });
+
+ 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(<PresetFiltersMenu />, { wrapper: createWrapper(["/dags"]) });
+ const input = await openMenu();
+
+ fireEvent.change(input, { target: { value: "My preset" } });
+
+ expect(screen.getByTestId("preset-filter-save")).toBeDisabled();
+ });
+
+ it("overwrites an existing preset with the same name", async () => {
+ localStorage.setItem(
+ "dags-preset-filters",
+ JSON.stringify([{ name: "Running runs", search: "state=queued" }]),
+ );
+
+ render(<PresetFiltersMenu />, { wrapper:
createWrapper(["/dags?state=running"]) });
+ const input = await openMenu();
+
+ fireEvent.change(input, { target: { value: "Running runs" } });
+ fireEvent.click(screen.getByTestId("preset-filter-save"));
+
+ await waitFor(() => {
+ expect(JSON.parse(localStorage.getItem("dags-preset-filters") ??
"[]")).toEqual([
+ { name: "Running runs", search: "state=running" },
+ ]);
+ });
+ });
+
+ it("restores a preset to the URL and the table sort", async () => {
+ localStorage.setItem(
+ "dags-preset-filters",
+ JSON.stringify([{ name: "Successful runs", search:
"state=success&sort=-run_after" }]),
+ );
+
+ render(<PresetFiltersMenu />, { wrapper: createWrapper(["/dags"]) });
+ await openMenu();
+
+ fireEvent.click(screen.getByText("Successful runs"));
+
+ await waitFor(() => {
+
expect(screen.getByTestId("location-search")).toHaveTextContent("state=success");
+ });
+
expect(screen.getByTestId("location-search")).toHaveTextContent("sort=-run_after");
+ expect(JSON.parse(localStorage.getItem("dags-table-sort") ??
"[]")).toEqual([
+ { desc: true, id: "run_after" },
+ ]);
+ });
+
+ it("deletes a preset after confirming", async () => {
+ localStorage.setItem(
+ "dags-preset-filters",
+ JSON.stringify([
+ { name: "Successful runs", search: "state=success" },
+ { name: "Failed runs", search: "state=failed" },
+ ]),
+ );
+
+ render(<PresetFiltersMenu />, { wrapper: createWrapper(["/dags"]) });
+ await openMenu();
+
+ 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-preset-filters") ??
"[]")).toEqual([
+ { name: "Failed runs", search: "state=failed" },
+ ]);
+ });
+ });
+
+ it("keeps the preset when the delete is cancelled", async () => {
+ localStorage.setItem(
+ "dags-preset-filters",
+ JSON.stringify([{ name: "Successful runs", search: "state=success" }]),
+ );
+
+ render(<PresetFiltersMenu />, { wrapper: createWrapper(["/dags"]) });
+ await openMenu();
+
+ 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-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-preset-filters",
+ JSON.stringify([{ name: "Running runs", search: "state=running" }]),
+ );
+
+ render(<PresetFiltersMenu />, { wrapper:
createWrapper(["/dags?state=running"]) });
+ const input = await openMenu();
+
+ fireEvent.change(input, { target: { value: "Another name" } });
+
+ expect(screen.getByTestId("preset-filter-save")).toBeDisabled();
+ });
+
+ it("restores the default preset automatically on a bare page load", async ()
=> {
+ localStorage.setItem(
+ "dags-preset-filters",
+ JSON.stringify([{ name: "Successful runs", search:
"state=success&sort=-run_after" }]),
+ );
+ localStorage.setItem("dags-preset-filters-default",
JSON.stringify("Successful runs"));
+
+ render(<PresetFiltersMenu />, { wrapper: createWrapper(["/dags"]) });
+
+ await waitFor(() => {
+
expect(screen.getByTestId("location-search")).toHaveTextContent("state=success");
+ });
+
expect(screen.getByTestId("location-search")).toHaveTextContent("sort=-run_after");
+ expect(JSON.parse(localStorage.getItem("dags-table-sort") ??
"[]")).toEqual([
+ { desc: true, id: "run_after" },
+ ]);
+ });
+
+ it("does not restore the default preset when the URL carries a non-default
sort", async () => {
+ localStorage.setItem(
+ "dags-preset-filters",
+ JSON.stringify([{ name: "Successful runs", search: "state=success" }]),
+ );
+ localStorage.setItem("dags-preset-filters-default",
JSON.stringify("Successful runs"));
+
+ render(<PresetFiltersMenu />, { 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 preset when the page already has filters",
async () => {
+ localStorage.setItem(
+ "dags-preset-filters",
+ JSON.stringify([{ name: "Successful runs", search: "state=success" }]),
+ );
+ localStorage.setItem("dags-preset-filters-default",
JSON.stringify("Successful runs"));
+
+ render(<PresetFiltersMenu />, { 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 preset as the default and back", async () => {
+ localStorage.setItem(
+ "dags-preset-filters",
+ JSON.stringify([{ name: "Successful runs", search: "state=success" }]),
+ );
+
+ render(<PresetFiltersMenu />, { wrapper: createWrapper(["/dags"]) });
+ await openMenu();
+
+ fireEvent.click(screen.getByLabelText("presetFilters.setDefault"));
+ await waitFor(() => {
+ expect(JSON.parse(localStorage.getItem("dags-preset-filters-default") ??
"null")).toBe(
+ "Successful runs",
+ );
+ });
+
+ fireEvent.click(screen.getByLabelText("presetFilters.unsetDefault"));
+ await waitFor(() => {
+ expect(JSON.parse(localStorage.getItem("dags-preset-filters-default") ??
"null")).toBeNull();
+ });
+ });
+
+ it("clears the stored default when the default preset is deleted", async ()
=> {
+ localStorage.setItem(
+ "dags-preset-filters",
+ JSON.stringify([{ name: "Successful runs", search: "state=success" }]),
+ );
+ 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(<PresetFiltersMenu />, { wrapper:
createWrapper(["/dags?state=running"]) });
+ await openMenu();
+
+ fireEvent.click(screen.getByLabelText("Delete preset filter"));
+ fireEvent.click(await screen.findByTestId("delete-confirm-button"));
+
+ await waitFor(() => {
+ expect(JSON.parse(localStorage.getItem("dags-preset-filters") ??
"[]")).toEqual([]);
+ });
+ expect(JSON.parse(localStorage.getItem("dags-preset-filters-default") ??
"null")).toBeNull();
+ });
+});
diff --git a/airflow-core/src/airflow/ui/src/components/PresetFiltersMenu.tsx
b/airflow-core/src/airflow/ui/src/components/PresetFiltersMenu.tsx
new file mode 100644
index 00000000000..7ebd0912cfe
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/PresetFiltersMenu.tsx
@@ -0,0 +1,269 @@
+/*!
+ * 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 { Box, Button, HStack, Input, Text, VStack } from "@chakra-ui/react";
+import type { SortingState } from "@tanstack/react-table";
+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 { presetFiltersDefaultKey, presetFiltersKey, tableSortKey } from
"src/constants/localStorage";
+import { SearchParamsKeys } from "src/constants/searchParams";
+
+type PresetFilter = {
+ readonly name: string;
+ readonly search: string;
+};
+
+const normalizeSearch = (value: string) => {
+ const params = new URLSearchParams(value);
+
+ params.sort();
+
+ return params.toString();
+};
+
+export const PresetFiltersMenu = () => {
+ const { t: translate } = useTranslation("common");
+ const { pathname } = useLocation();
+ const [searchParams, setSearchParams] = useSearchParams();
+ const [, setSorting] = useLocalStorage<SortingState>(tableSortKey(pathname),
[]);
+ const [presetFilters, setPresetFilters] =
useLocalStorage<Array<PresetFilter>>(
+ presetFiltersKey(pathname),
+ [],
+ );
+ const [defaultPresetName, setDefaultPresetName] = useLocalStorage<string |
null>(
+ presetFiltersDefaultKey(pathname),
+ null,
+ );
+ const [name, setName] = useState("");
+ const [open, setOpen] = useState(false);
+ const [presetToDelete, setPresetToDelete] = useState<string |
undefined>(undefined);
+
+ const presetParams = new URLSearchParams(searchParams);
+
+ presetParams.delete(SearchParamsKeys.OFFSET);
+ presetParams.delete(SearchParamsKeys.CURSOR);
+ // 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();
+
+ const duplicatePreset = presetFilters.find(
+ (preset) => normalizeSearch(preset.search) === normalizeSearch(search),
+ );
+
+ const handleSave = () => {
+ const trimmedName = name.trim();
+
+ if (trimmedName === "" || !hasPresetToSave || duplicatePreset !==
undefined) {
+ return;
+ }
+
+ setPresetFilters((prev) =>
+ prev.some((preset) => preset.name === trimmedName)
+ ? prev.map((preset) => (preset.name === trimmedName ? { name:
trimmedName, search } : preset))
+ : [...prev, { name: trimmedName, search }],
+ );
+ setName("");
+ };
+
+ const applyPreset = (preset: PresetFilter) => {
+ const params = new URLSearchParams(preset.search);
+
+ // 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)
+ .map((sort) => ({ desc: sort.startsWith("-"), id: sort.replace("-",
"") })),
+ );
+ setSearchParams(params);
+ setOpen(false);
+ };
+
+ const deletePreset = (presetName: string) => {
+ setPresetFilters((prev) => prev.filter((preset) => preset.name !==
presetName));
+ setDefaultPresetName((previous) => (previous === presetName ? null :
previous));
+ };
+
+ const toggleDefault = (presetName: string) => {
+ setDefaultPresetName((previous) => (previous === presetName ? null :
presetName));
+ };
+
+ // 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 wins; only pagination is
ignored, since any
+ // other param makes the landing explicit and should be left alone.
+ useEffect(() => {
+ const target =
+ 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) {
+ applyPreset(target);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [pathname]);
+
+ let saveHint: string | undefined;
+
+ if (!hasPresetToSave) {
+ saveHint = translate("presetFilters.nothingToSave");
+ } else if (duplicatePreset !== undefined) {
+ saveHint = translate("presetFilters.duplicate", { name:
duplicatePreset.name });
+ }
+
+ const infoContent = (
+ <VStack alignItems="start" gap={2} maxW="260px">
+ <Text fontSize="xs">{translate("presetFilters.info.save")}</Text>
+ <Text fontSize="xs">{translate("presetFilters.info.default")}</Text>
+ <Text fontSize="xs">{translate("presetFilters.info.storage")}</Text>
+ </VStack>
+ );
+
+ return (
+ <>
+ <Popover.Root
+ lazyMount
+ onOpenChange={(event) => setOpen(event.open)}
+ open={open}
+ positioning={{ placement: "bottom-start" }}
+ unmountOnExit
+ >
+ <Popover.Trigger asChild>
+ <Button
+ borderRadius="full"
+ colorPalette="gray"
+ data-testid="preset-filters-button"
+ variant="outline"
+ >
+ <LuBookmark />
+ {translate("presetFilters.title")}
+ </Button>
+ </Popover.Trigger>
+ <Popover.Content width="xs">
+ <Popover.Arrow />
+ <VStack align="stretch" gap={2} p={4}>
+ <HStack gap={1}>
+ <Text fontSize="sm" fontWeight="medium">
+ {translate("presetFilters.title")}
+ </Text>
+ <Tooltip content={infoContent}>
+ <Box as="span" color="fg.muted" cursor="pointer">
+ <LuInfo />
+ </Box>
+ </Tooltip>
+ </HStack>
+ <HStack>
+ <Input
+ data-testid="preset-filter-name"
+ onChange={(event) => setName(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key === "Enter") {
+ handleSave();
+ }
+ }}
+ placeholder={translate("presetFilters.namePlaceholder")}
+ size="sm"
+ value={name}
+ />
+ <Tooltip content={saveHint ?? ""} disabled={saveHint ===
undefined}>
+ <Box>
+ <Button
+ data-testid="preset-filter-save"
+ disabled={!hasPresetToSave || name.trim() === "" ||
duplicatePreset !== undefined}
+ onClick={handleSave}
+ size="sm"
+ >
+ {translate("presetFilters.save")}
+ </Button>
+ </Box>
+ </Tooltip>
+ </HStack>
+ {presetFilters.length === 0 ? (
+ <Text color="fg.muted" fontSize="sm" py={1}>
+ {translate("presetFilters.empty")}
+ </Text>
+ ) : (
+ presetFilters.map((preset) => (
+ <HStack gap={1} key={preset.name}>
+ <Button
+ flex="1"
+ justifyContent="flex-start"
+ minW={0}
+ onClick={() => applyPreset(preset)}
+ size="sm"
+ variant="ghost"
+ >
+ <Text truncate>{preset.name}</Text>
+ </Button>
+ <IconButton
+ label={
+ defaultPresetName === preset.name
+ ? translate("presetFilters.unsetDefault")
+ : translate("presetFilters.setDefault")
+ }
+ onClick={() => toggleDefault(preset.name)}
+ size="sm"
+ variant="ghost"
+ >
+ {defaultPresetName === preset.name ? <MdPushPin /> :
<MdOutlinePushPin />}
+ </IconButton>
+ <IconButton
+ aria-label="Delete preset filter"
+ onClick={() => setPresetToDelete(preset.name)}
+ size="sm"
+ variant="ghost"
+ >
+ <FiTrash2 />
+ </IconButton>
+ </HStack>
+ ))
+ )}
+ </VStack>
+ </Popover.Content>
+ </Popover.Root>
+ <DeleteDialog
+ isDeleting={false}
+ onClose={() => setPresetToDelete(undefined)}
+ onDelete={() => {
+ if (presetToDelete !== undefined) {
+ deletePreset(presetToDelete);
+ }
+ setPresetToDelete(undefined);
+ }}
+ 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 acce227b947..72ff5b8327b 100644
--- a/airflow-core/src/airflow/ui/src/constants/localStorage.ts
+++ b/airflow-core/src/airflow/ui/src/constants/localStorage.ts
@@ -38,6 +38,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 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 d3531224948..2e14d51c7b3 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}
+ showPresetFilters={runId === undefined}
/>
</VStack>
);