This is an automated email from the ASF dual-hosted git repository.
eladkal 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 a3e07159a0b Add Dag ID filtering to Assets search (#70971)
a3e07159a0b is described below
commit a3e07159a0bf986b9e5ac1dab3730c7a10cf5762
Author: Shivam Rastogi <[email protected]>
AuthorDate: Fri Oct 2 02:18:33 2026 -0700
Add Dag ID filtering to Assets search (#70971)
* 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.
* Test combined Asset relationship and event filters
Protect independent URL-backed filters when users narrow Asset catalogs and
remove individual selections.
---
.../ui/src/components/FilterBar/FilterBar.test.tsx | 21 ++++++
.../ui/src/components/FilterBar/FilterBar.tsx | 31 +++++---
.../ui/src/pages/AssetsList/AssetsList.test.tsx | 85 +++++++++++++++++++++-
.../airflow/ui/src/pages/AssetsList/AssetsList.tsx | 10 ++-
4 files changed, 134 insertions(+), 13 deletions(-)
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 3710f2ed6e4..4b726fbe37d 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
@@ -258,3 +258,24 @@ describe("FilterBar keyboard handling", () => {
await waitFor(() =>
expect(document.querySelector('input[id^="react-select"]')).not.toBeNull());
});
});
+
+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", async () =>
{
+ const { rerender } = render(
+ <FilterBar configs={configs} initialValues={{ dag_id: "dag_a" }}
onFiltersChange={vi.fn()} />,
+ { wrapper },
+ );
+
+ expect(screen.getByTestId("dag_id-pill")).toHaveTextContent("Dag ID:
dag_a");
+
+ rerender(<FilterBar configs={configs} initialValues={{ dag_id: "dag_b" }}
onFiltersChange={vi.fn()} />);
+
+ await waitFor(() =>
expect(screen.getByTestId("dag_id-pill")).toHaveTextContent("Dag ID: dag_b"));
+
+ rerender(<FilterBar configs={configs} initialValues={{}}
onFiltersChange={vi.fn()} />);
+
+ await waitFor(() =>
expect(screen.queryByTestId("dag_id-pill")).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 e3de2316203..91cae568161 100644
--- a/airflow-core/src/airflow/ui/src/components/FilterBar/FilterBar.tsx
+++ b/airflow-core/src/airflow/ui/src/components/FilterBar/FilterBar.tsx
@@ -94,19 +94,30 @@ 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) {
+ 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
index ab00103a478..bc6ab0ac605 100644
--- a/airflow-core/src/airflow/ui/src/pages/AssetsList/AssetsList.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/AssetsList/AssetsList.test.tsx
@@ -17,7 +17,7 @@
* under the License.
*/
import "@testing-library/jest-dom";
-import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { fireEvent, render, screen, waitFor, within } from
"@testing-library/react";
import { delay, http, HttpResponse } from "msw";
import { setupServer, type SetupServer } from "msw/node";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
@@ -56,6 +56,37 @@ describe("AssetsList columns", () => {
});
describe("AssetsList filtering", () => {
+ it.each(["true", "false"])("preserves has_events=%s when removing Dag ID",
async (hasEvents) => {
+ const requests: Array<{ dagIds: Array<string>; hasEvents: string | null }>
= [];
+
+ server.use(
+ http.get("/ui/assets", ({ request }) => {
+ const params = new URL(request.url).searchParams;
+
+ requests.push({ dagIds: params.getAll("dag_ids"), hasEvents:
params.get("has_events") });
+
+ return HttpResponse.json({ assets: [], total_entries: 0 });
+ }),
+ );
+
+ render(<AppWrapper
initialEntries={[`/assets?dag_id=consumer_dag&has_events=${hasEvents}`]} />);
+
+ await waitFor(() => expect(requests.at(-1)).toEqual({ dagIds:
["consumer_dag"], hasEvents }));
+
+ const dagIdPill = await screen.findByTestId("dag_id-pill");
+
+ fireEvent.click(within(dagIdPill).getByRole("button", { name: /Remove .*
filter/u }));
+
+ await waitFor(() => expect(requests.at(-1)).toEqual({ dagIds: [],
hasEvents }));
+ expect(screen.getByTestId("has_events-pill")).toBeInTheDocument();
+
+ fireEvent.click(
+ within(screen.getByTestId("has_events-pill")).getByRole("button", {
name: /Remove .* filter/u }),
+ );
+
+ await waitFor(() => expect(requests.at(-1)).toEqual({ dagIds: [],
hasEvents: null }));
+ });
+
it.each([
{ expectedLabel: "yes", hasEvents: "true" },
{ expectedLabel: "no", hasEvents: "false" },
@@ -104,4 +135,56 @@ describe("AssetsList filtering", () => {
expect(screen.getByText("asset_with_dependencies")).toBeInTheDocument();
expect(screen.queryAllByTestId("skeleton")).toHaveLength(0);
});
+
+ it("offers Dag ID as an exact-match filter", async () => {
+ render(<AppWrapper initialEntries={["/assets"]} />);
+
+ fireEvent.click(await screen.findByTestId("add-filter-button"));
+ fireEvent.click(await screen.findByTestId("add-filter-dag_id"));
+
+ expect(await screen.findByTestId("filter-pill-input")).toBeInTheDocument();
+ // The page search keeps its own advanced-search toggle. An exact-match
Dag ID
+ // filter must not add a second toggle inside its editor.
+ expect(screen.getAllByTestId("advanced-search-toggle")).toHaveLength(1);
+ });
+
+ it("passes the selected Dag ID to the Assets API and omits it after
clearing", async () => {
+ const requestedDagIds: Array<Array<string>> = [];
+
+ server.use(
+ http.get("/ui/assets", ({ request }) => {
+ requestedDagIds.push(new
URL(request.url).searchParams.getAll("dag_ids"));
+
+ return HttpResponse.json({ assets: [], total_entries: 0 });
+ }),
+ );
+
+ render(<AppWrapper initialEntries={["/assets?dag_id=consumer_dag"]} />);
+
+ await waitFor(() =>
expect(requestedDagIds.at(-1)).toEqual(["consumer_dag"]));
+
+ const dagIdPill = await screen.findByTestId("dag_id-pill");
+
+ fireEvent.click(within(dagIdPill).getByRole("button", { name: /Remove .*
filter/u }));
+
+ await waitFor(() => expect(requestedDagIds.at(-1)).toEqual([]));
+ expect(screen.queryByTestId("dag_id-pill")).not.toBeInTheDocument();
+ });
+
+ it("ignores an empty Dag ID query parameter", async () => {
+ const requestedDagIds: Array<Array<string>> = [];
+
+ server.use(
+ http.get("/ui/assets", ({ request }) => {
+ requestedDagIds.push(new
URL(request.url).searchParams.getAll("dag_ids"));
+
+ return HttpResponse.json({ assets: [], total_entries: 0 });
+ }),
+ );
+
+ render(<AppWrapper initialEntries={["/assets?dag_id="]} />);
+
+ await waitFor(() => expect(requestedDagIds.at(-1)).toEqual([]));
+ expect(screen.queryByTestId("dag_id-pill")).not.toBeInTheDocument();
+ });
});
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 ec81acb41f6..2e30e31ec98 100644
--- a/airflow-core/src/airflow/ui/src/pages/AssetsList/AssetsList.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/AssetsList/AssetsList.tsx
@@ -44,6 +44,7 @@ import { useDocumentTitle, useFiltersHandler, type
FilterableSearchParamsKeys }
import { DependencyPopover } from "./DependencyPopover";
const assetsFilterKeys: Array<FilterableSearchParamsKeys> = [
+ SearchParamsKeys.DAG_ID,
SearchParamsKeys.GROUP_PATTERN,
SearchParamsKeys.HAS_EVENTS,
SearchParamsKeys.LAST_ASSET_EVENT_TIMESTAMP_RANGE,
@@ -129,7 +130,7 @@ const createColumns = (translate: TFunction):
Array<ColumnDef<AssetResponse>> =>
},
];
-const { NAME_PATTERN, OFFSET }: SearchParamsKeysType = SearchParamsKeys;
+const { DAG_ID, NAME_PATTERN, OFFSET }: SearchParamsKeysType =
SearchParamsKeys;
export const AssetsList = () => {
const { t: translate } = useTranslation(["assets", "common"]);
@@ -138,6 +139,7 @@ export const AssetsList = () => {
const [searchParams, setSearchParams] = useSearchParams();
+ const dagId = searchParams.get(DAG_ID);
const namePattern = searchParams.get(NAME_PATTERN) ?? "";
const advancedSearch = useAdvancedSearch("assets");
@@ -147,6 +149,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 hasEventsParam = searchParams.get(SearchParamsKeys.HAS_EVENTS);
let hasEvents = undefined;
@@ -169,6 +174,7 @@ export const AssetsList = () => {
const { data, error, isFetching, isLoading } = useAssetServiceGetAssetsUi(
{
...groupArg,
+ dagIds: dagId === null || dagId === "" ? undefined : [dagId],
hasEvents,
lastAssetEventTimestampGte: lastAssetEventTimestampGte ?? undefined,
lastAssetEventTimestampLte: lastAssetEventTimestampLte ?? undefined,
@@ -212,7 +218,7 @@ export const AssetsList = () => {
placeholder={translate("searchPlaceholder")}
/>
<FilterBar
- configs={filterConfigs}
+ configs={assetsFilterConfigs}
initialValues={initialValues}
onFiltersChange={handleFiltersChange}
/>