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} /> );