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..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": {
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..f7d261ac3e594
--- /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 preset filters", () => {
+ it("shows the preset filters control by default", () => {
+ render(, { wrapper });
+
+ expect(screen.getByTestId("preset-filters-button")).toBeInTheDocument();
+ });
+
+ it("hides the preset filters control when showPresetFilters is false", () => {
+ render(, { 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 138ad02f0d1c3..00d99ab8b0167 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>(() =>
@@ -183,40 +185,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 && (
+
+ )}
+
+ {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 8e04bf53c720b..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,6 +56,8 @@ export type FilterBarProps = {
readonly initialValues?: Record;
readonly maxVisibleFilters?: number;
readonly onFiltersChange: (filters: Record) => 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 0000000000000..7d4f0790c884b
--- /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
{location.search}
;
+};
+
+const createWrapper =
+ (initialEntries: Array = ["/dags"]) =>
+ ({ children }: PropsWithChildren) => (
+
+
+ {children}
+
+
+
+ );
+
+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(, { 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(, {
+ 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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 0000000000000..7ebd0912cfe53
--- /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(tableSortKey(pathname), []);
+ const [presetFilters, setPresetFilters] = useLocalStorage>(
+ presetFiltersKey(pathname),
+ [],
+ );
+ const [defaultPresetName, setDefaultPresetName] = useLocalStorage(
+ presetFiltersDefaultKey(pathname),
+ null,
+ );
+ const [name, setName] = useState("");
+ const [open, setOpen] = useState(false);
+ const [presetToDelete, setPresetToDelete] = useState(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 = (
+
+ {translate("presetFilters.info.save")}
+ {translate("presetFilters.info.default")}
+ {translate("presetFilters.info.storage")}
+
+ );
+
+ return (
+ <>
+ setOpen(event.open)}
+ open={open}
+ positioning={{ placement: "bottom-start" }}
+ unmountOnExit
+ >
+
+
+
+
+
+
+
+
+ {translate("presetFilters.title")}
+
+
+
+
+
+
+
+
+ setName(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key === "Enter") {
+ handleSave();
+ }
+ }}
+ placeholder={translate("presetFilters.namePlaceholder")}
+ size="sm"
+ value={name}
+ />
+
+
+
+
+
+
+ {presetFilters.length === 0 ? (
+
+ {translate("presetFilters.empty")}
+
+ ) : (
+ presetFilters.map((preset) => (
+
+
+ toggleDefault(preset.name)}
+ size="sm"
+ variant="ghost"
+ >
+ {defaultPresetName === preset.name ? : }
+
+ setPresetToDelete(preset.name)}
+ size="sm"
+ variant="ghost"
+ >
+
+
+
+ ))
+ )}
+
+
+
+ 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 97285aef6fa80..0efb40eefd202 100644
--- a/airflow-core/src/airflow/ui/src/constants/localStorage.ts
+++ b/airflow-core/src/airflow/ui/src/constants/localStorage.ts
@@ -37,6 +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 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 d3531224948d1..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,6 +93,7 @@ export const TaskInstancesFilter = () => {
configs={filterConfigs}
initialValues={initialValues}
onFiltersChange={handleFiltersChange}
+ showPresetFilters={runId === undefined}
/>
);