From 612e5882774129a6725d24b6682b1ace0fe60f17 Mon Sep 17 00:00:00 2001 From: Shivam <6463385+shivaam@users.noreply.github.com> Date: Sun, 2 Aug 2026 23:41:51 -0700 Subject: [PATCH 1/2] Add Dag ID filtering to Assets search Large Asset catalogs need a direct way to find the Assets related to a specific Dag while preserving shareable URL state. --- .../components/FilterBar/FilterBar.test.tsx | 21 ++++ .../ui/src/components/FilterBar/FilterBar.tsx | 32 +++-- .../src/pages/AssetsList/AssetsList.test.tsx | 110 ++++++++++++++++++ .../ui/src/pages/AssetsList/AssetsList.tsx | 10 +- 4 files changed, 161 insertions(+), 12 deletions(-) create mode 100644 airflow-core/src/airflow/ui/src/pages/AssetsList/AssetsList.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 index f7d261ac3e594..9b1ed143fa5e7 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 @@ -47,3 +47,24 @@ describe("FilterBar preset filters", () => { expect(screen.queryByTestId("preset-filters-button")).not.toBeInTheDocument(); }); }); + +describe("FilterBar URL synchronization", () => { + const configs = [{ key: "dag_id", label: "Dag ID", type: "text" as const }]; + + it("updates an existing filter when its external value changes", () => { + const { rerender } = render( + , + { wrapper }, + ); + + expect(screen.getByText("Dag ID: dag_a")).toBeInTheDocument(); + + rerender(); + + expect(screen.getByText("Dag ID: dag_b")).toBeInTheDocument(); + + rerender(); + + expect(screen.queryByText("Dag ID: dag_b")).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 00d99ab8b0167..ef44896c8f12b 100644 --- a/airflow-core/src/airflow/ui/src/components/FilterBar/FilterBar.tsx +++ b/airflow-core/src/airflow/ui/src/components/FilterBar/FilterBar.tsx @@ -90,19 +90,31 @@ export const FilterBar = ({ const existingKeys = new Set(prevFilters.map((filter) => filter.config.key)); const toAdd = pillsToAdd.filter((pill) => !existingKeys.has(pill.config.key)); - // Remove pills that had a committed value but whose URL param was cleared externally. - const afterRemove = prevFilters.filter((filter) => { - const pillHadValue = isValidFilterValue(filter.config.type, filter.value); - const urlValue = initialValues[filter.config.key]; - - return !pillHadValue || isValidFilterValue(filter.config.type, urlValue); - }); - - if (toAdd.length === 0 && afterRemove.length === prevFilters.length) { + // Keep committed pills synchronized with external URL changes, including browser history. + const synchronizedFilters = prevFilters + .filter((filter) => { + const pillHadValue = isValidFilterValue(filter.config.type, filter.value); + const urlValue = initialValues[filter.config.key]; + + return !pillHadValue || isValidFilterValue(filter.config.type, urlValue); + }) + .map((filter) => { + const urlValue = initialValues[filter.config.key]; + + return isValidFilterValue(filter.config.type, urlValue) && filter.value !== urlValue + ? { ...filter, value: urlValue } + : filter; + }); + + const filtersUnchanged = + synchronizedFilters.length === prevFilters.length && + synchronizedFilters.every((filter, index) => filter === prevFilters[index]); + + if (toAdd.length === 0 && filtersUnchanged) { return prevFilters; } - return [...afterRemove, ...toAdd]; + return [...synchronizedFilters, ...toAdd]; }); // configs is intentionally omitted — it is structurally stable across renders and including // it would risk infinite re-render loops. initialValuesKey captures all relevant URL changes. diff --git a/airflow-core/src/airflow/ui/src/pages/AssetsList/AssetsList.test.tsx b/airflow-core/src/airflow/ui/src/pages/AssetsList/AssetsList.test.tsx new file mode 100644 index 0000000000000..51a2acde10cb7 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/pages/AssetsList/AssetsList.test.tsx @@ -0,0 +1,110 @@ +/*! + * 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/vitest"; +import { render, screen } from "@testing-library/react"; +import type * as ReactRouterDom from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type * as OpenapiQueries from "openapi/queries"; +import type { FilterConfig } from "src/components/FilterBar"; +import { Wrapper } from "src/utils/Wrapper"; + +import { AssetsList } from "./AssetsList"; + +let mockSearchParams = new URLSearchParams(); + +vi.mock("react-router-dom", async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + useSearchParams: () => [mockSearchParams, vi.fn()] as const, + }; +}); + +vi.mock("openapi/queries", async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + useAssetServiceGetAssetsUi: vi.fn(), + }; +}); + +vi.mock("src/components/DataTable", () => ({ + DataTable: () => null, +})); + +vi.mock("src/components/FilterBar", () => ({ + FilterBar: ({ configs }: { readonly configs: Array }) => ( +
+ {configs.map(({ key, supportsAdvancedSearch }) => ( + + {key} + + ))} +
+ ), +})); + +vi.mock("src/components/SearchBar", () => ({ + SearchBar: () => null, +})); + +vi.mock("src/queries/useConfig", () => ({ + useConfig: (key: string) => (key === "fallback_page_limit" ? 50 : false), +})); + +const { useAssetServiceGetAssetsUi } = await import("openapi/queries"); + +const lastAssetsCall = () => vi.mocked(useAssetServiceGetAssetsUi).mock.calls.at(-1)?.[0]; + +describe("AssetsList filters", () => { + beforeEach(() => { + mockSearchParams = new URLSearchParams(); + vi.mocked(useAssetServiceGetAssetsUi).mockReturnValue({ + data: { assets: [], total_entries: 0 }, + error: null, + isLoading: false, + } as ReturnType); + }); + + it("offers an exact-match Dag ID filter", () => { + render(, { wrapper: Wrapper }); + + expect(screen.getByTestId("asset-filter-dag_id")).toHaveAttribute("data-advanced-search", "false"); + }); + + it("passes the selected Dag ID to the Assets API and omits it after clearing", () => { + mockSearchParams = new URLSearchParams("dag_id=consumer_dag"); + + const { rerender } = render(, { wrapper: Wrapper }); + + expect(lastAssetsCall()?.dagIds).toEqual(["consumer_dag"]); + + mockSearchParams = new URLSearchParams("dag_id="); + rerender(); + + expect(lastAssetsCall()?.dagIds).toBeUndefined(); + }); +}); diff --git a/airflow-core/src/airflow/ui/src/pages/AssetsList/AssetsList.tsx b/airflow-core/src/airflow/ui/src/pages/AssetsList/AssetsList.tsx index 3351810523112..b3bd5038ee298 100644 --- a/airflow-core/src/airflow/ui/src/pages/AssetsList/AssetsList.tsx +++ b/airflow-core/src/airflow/ui/src/pages/AssetsList/AssetsList.tsx @@ -38,6 +38,7 @@ import { useDocumentTitle, useFiltersHandler, type FilterableSearchParamsKeys } import { DependencyPopover } from "./DependencyPopover"; const assetsFilterKeys: Array = [ + SearchParamsKeys.DAG_ID, SearchParamsKeys.GROUP_PATTERN, SearchParamsKeys.LAST_ASSET_EVENT_TIMESTAMP_RANGE, ]; @@ -98,7 +99,7 @@ const createColumns = (translate: (key: string) => string): Array { const { t: translate } = useTranslation(["assets", "common"]); @@ -107,6 +108,7 @@ export const AssetsList = () => { const [searchParams, setSearchParams] = useSearchParams(); + const dagId = searchParams.get(DAG_ID); const namePattern = searchParams.get(NAME_PATTERN) ?? ""; const advancedSearch = useAdvancedSearch("assets"); @@ -116,6 +118,9 @@ export const AssetsList = () => { const orderBy = sort ? [`${sort.desc ? "-" : ""}${sort.id}`] : ["-last_asset_event_timestamp"]; const { filterConfigs, handleFiltersChange, initialValues } = useFiltersHandler(assetsFilterKeys); + const assetsFilterConfigs = filterConfigs.map((config) => + config.key === DAG_ID ? { ...config, supportsAdvancedSearch: false } : config, + ); const lastAssetEventTimestampGte = searchParams.get(SearchParamsKeys.LAST_ASSET_EVENT_TIMESTAMP_GTE); const lastAssetEventTimestampLte = searchParams.get(SearchParamsKeys.LAST_ASSET_EVENT_TIMESTAMP_LTE); @@ -128,6 +133,7 @@ export const AssetsList = () => { const { data, error, isLoading } = useAssetServiceGetAssetsUi({ ...groupArg, + dagIds: dagId === null || dagId === "" ? undefined : [dagId], lastAssetEventTimestampGte: lastAssetEventTimestampGte ?? undefined, lastAssetEventTimestampLte: lastAssetEventTimestampLte ?? undefined, limit: pagination.pageSize, @@ -164,7 +170,7 @@ export const AssetsList = () => { /> From 5c62bdf0bfdb9b70d158296bb8a25294b4d0571d Mon Sep 17 00:00:00 2001 From: Shivam <6463385+shivaam@users.noreply.github.com> Date: Sun, 2 Aug 2026 23:50:42 -0700 Subject: [PATCH 2/2] Document Assets Dag ID filtering --- airflow-core/newsfragments/70971.feature.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 airflow-core/newsfragments/70971.feature.rst diff --git a/airflow-core/newsfragments/70971.feature.rst b/airflow-core/newsfragments/70971.feature.rst new file mode 100644 index 0000000000000..338ebc9cf4ccb --- /dev/null +++ b/airflow-core/newsfragments/70971.feature.rst @@ -0,0 +1 @@ +Add a Dag ID filter to the Assets search page.