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

Reply via email to