This is an automated email from the ASF dual-hosted git repository.

bbovenzi 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 d1b359bec74 Add Consuming Tasks, Aliases and Watchers to assets pages 
(#71468)
d1b359bec74 is described below

commit d1b359bec74ea373b770d340218496c352f65a78
Author: Brent Bovenzi <[email protected]>
AuthorDate: Wed Aug 12 16:31:42 2026 -0400

    Add Consuming Tasks, Aliases and Watchers to assets pages (#71468)
    
    * Add Alises and Watchers to assets pages
    
    * Fix tests
---
 .../airflow/ui/public/i18n/locales/en/assets.json  |  8 ++-
 .../ui/src/components/Assets/ListPopover.tsx       | 80 ++++++++++++++++++++++
 .../src/airflow/ui/src/mocks/handlers/assets.ts    | 50 ++++++++++++++
 .../src/airflow/ui/src/mocks/handlers/index.ts     |  2 +
 .../src/airflow/ui/src/pages/Asset/Header.test.tsx | 44 ++++++++++++
 .../src/airflow/ui/src/pages/Asset/Header.tsx      |  9 +++
 .../ui/src/pages/AssetsList/AssetsList.test.tsx    | 43 ++++++++++++
 .../airflow/ui/src/pages/AssetsList/AssetsList.tsx | 28 +++++++-
 8 files changed, 262 insertions(+), 2 deletions(-)

diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/assets.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/en/assets.json
index 0b963c05f2a..273a15f57e7 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/assets.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/assets.json
@@ -1,5 +1,8 @@
 {
   "additional_data": "Additional Data",
+  "alias_one": "Alias",
+  "alias_other": "Aliases",
+  "aliases": "Aliases",
   "asset_many": "Assets",
   "asset_one": "Asset",
   "assetStateStore": {
@@ -53,5 +56,8 @@
   "scheduledDags": "Scheduled Dags",
   "scheduling": "Scheduling",
   "searchPlaceholder": "Search Assets",
-  "taskDependencies": "Task Dependencies"
+  "taskDependencies": "Task Dependencies",
+  "watcher_one": "Watcher",
+  "watcher_other": "Watchers",
+  "watchers": "Watchers"
 }
diff --git a/airflow-core/src/airflow/ui/src/components/Assets/ListPopover.tsx 
b/airflow-core/src/airflow/ui/src/components/Assets/ListPopover.tsx
new file mode 100644
index 00000000000..4b5917be6dd
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/Assets/ListPopover.tsx
@@ -0,0 +1,80 @@
+/*!
+ * 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 { Button, Text } from "@chakra-ui/react";
+import type { ReactNode } from "react";
+import { useTranslation } from "react-i18next";
+
+import type { AssetAliasResponse, AssetWatcherResponse } from 
"openapi/requests/types.gen";
+import Time from "src/components/Time";
+import { Popover } from "src/components/ui";
+
+type ListPopoverProps = {
+  readonly items: Array<{ key: string; label: ReactNode }>;
+  readonly noun: string;
+};
+
+const ListPopover = ({ items, noun }: ListPopoverProps) => (
+  // eslint-disable-next-line jsx-a11y/no-autofocus
+  <Popover.Root autoFocus={false} lazyMount unmountOnExit>
+    <Popover.Trigger asChild disabled={items.length === 0}>
+      <Button variant="outline">
+        {items.length} {noun}
+      </Button>
+    </Popover.Trigger>
+    <Popover.Content css={{ "--popover-bg": "colors.bg.emphasized" }} 
width="fit-content">
+      <Popover.Arrow />
+      <Popover.Body>
+        {items.map(({ key, label }) => (
+          <Text key={key} py={2}>
+            {label}
+          </Text>
+        ))}
+      </Popover.Body>
+    </Popover.Content>
+  </Popover.Root>
+);
+
+export const AliasesPopover = ({ aliases }: { readonly aliases: 
Array<AssetAliasResponse> }) => {
+  const { t: translate } = useTranslation("assets");
+
+  return (
+    <ListPopover
+      items={aliases.map((alias) => ({ key: String(alias.id), label: 
alias.name }))}
+      noun={translate("alias", { count: aliases.length })}
+    />
+  );
+};
+
+export const WatchersPopover = ({ watchers }: { readonly watchers: 
Array<AssetWatcherResponse> }) => {
+  const { t: translate } = useTranslation("assets");
+
+  return (
+    <ListPopover
+      items={watchers.map((watcher) => ({
+        key: String(watcher.trigger_id),
+        label: (
+          <>
+            {watcher.name} <Time datetime={watcher.created_date} />
+          </>
+        ),
+      }))}
+      noun={translate("watcher", { count: watchers.length })}
+    />
+  );
+};
diff --git a/airflow-core/src/airflow/ui/src/mocks/handlers/assets.ts 
b/airflow-core/src/airflow/ui/src/mocks/handlers/assets.ts
new file mode 100644
index 00000000000..cf668954abd
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/mocks/handlers/assets.ts
@@ -0,0 +1,50 @@
+/*!
+ * 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 { http, HttpResponse, type HttpHandler } from "msw";
+
+const assetWithDependencies = {
+  aliases: [{ group: "alias_group", id: 1, name: "my_alias" }],
+  consuming_tasks: [{ created_at: "2025-01-01T00:00:00Z", dag_id: 
"consumer_dag", task_id: "consumer_task" }],
+  created_at: "2025-01-01T00:00:00Z",
+  extra: { owner: "data_team" },
+  group: "asset_group",
+  id: 1,
+  last_asset_event: { id: 10, timestamp: "2025-01-15T00:00:00Z" },
+  name: "asset_with_dependencies",
+  producing_tasks: [],
+  scheduled_dags: [],
+  updated_at: "2025-01-02T00:00:00Z",
+  uri: "s3://bucket/asset",
+  watchers: [{ created_date: "2025-01-01T00:00:00Z", name: "my_watcher", 
trigger_id: 5 }],
+};
+
+const plainAsset = {
+  ...assetWithDependencies,
+  aliases: [],
+  id: 2,
+  name: "plain_asset",
+  watchers: [],
+};
+
+export const handlers: Array<HttpHandler> = [
+  http.get("/ui/assets", () => HttpResponse.json({ assets: 
[assetWithDependencies], total_entries: 1 })),
+  http.get("/api/v2/assets/:assetId", ({ params }) =>
+    HttpResponse.json(params.assetId === "2" ? plainAsset : 
assetWithDependencies),
+  ),
+];
diff --git a/airflow-core/src/airflow/ui/src/mocks/handlers/index.ts 
b/airflow-core/src/airflow/ui/src/mocks/handlers/index.ts
index 9f60605f2b6..3d9e36b8602 100644
--- a/airflow-core/src/airflow/ui/src/mocks/handlers/index.ts
+++ b/airflow-core/src/airflow/ui/src/mocks/handlers/index.ts
@@ -16,6 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
+import { handlers as assetsHandlers } from "./assets";
 import { handlers as configHandlers } from "./config";
 import { handlers as dagHandlers } from "./dag";
 import { handlers as dagRunsHandlers } from "./dag_runs";
@@ -23,6 +24,7 @@ import { handlers as dagsHandlers } from "./dags";
 import { handlers as logHandlers } from "./log";
 
 export const handlers = [
+  ...assetsHandlers,
   ...configHandlers,
   ...dagHandlers,
   ...dagRunsHandlers,
diff --git a/airflow-core/src/airflow/ui/src/pages/Asset/Header.test.tsx 
b/airflow-core/src/airflow/ui/src/pages/Asset/Header.test.tsx
new file mode 100644
index 00000000000..1db8677dfff
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/Asset/Header.test.tsx
@@ -0,0 +1,44 @@
+/*!
+ * 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 { render, screen, waitFor } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { AppWrapper } from "src/utils/AppWrapper";
+
+// The assets mock handler (see src/mocks/handlers/assets.ts) serves asset 1 
with one
+// alias and one watcher, and asset 2 with neither.
+describe("Asset header", () => {
+  it("lists the aliases and watchers of an asset", async () => {
+    render(<AppWrapper initialEntries={["/assets/1"]} />);
+
+    await waitFor(() => expect(screen.getByRole("button", { name: "1 alias" 
})).toBeInTheDocument());
+
+    expect(screen.getByRole("button", { name: "1 watcher" 
})).toBeInTheDocument();
+  });
+
+  it("omits the aliases and watchers stats for an asset that has none", async 
() => {
+    render(<AppWrapper initialEntries={["/assets/2"]} />);
+
+    await waitFor(() => 
expect(screen.getAllByText("plain_asset").length).toBeGreaterThan(0));
+
+    expect(screen.queryByRole("button", { name: /alias/iu 
})).not.toBeInTheDocument();
+    expect(screen.queryByRole("button", { name: /watcher/iu 
})).not.toBeInTheDocument();
+  });
+});
diff --git a/airflow-core/src/airflow/ui/src/pages/Asset/Header.tsx 
b/airflow-core/src/airflow/ui/src/pages/Asset/Header.tsx
index 6eb0929b580..c9dd67119b0 100644
--- a/airflow-core/src/airflow/ui/src/pages/Asset/Header.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Asset/Header.tsx
@@ -20,6 +20,7 @@ import { useTranslation } from "react-i18next";
 import { FiDatabase } from "react-icons/fi";
 
 import type { AssetResponse } from "openapi/requests/types.gen";
+import { AliasesPopover, WatchersPopover } from 
"src/components/Assets/ListPopover";
 import { HeaderCard } from "src/components/HeaderCard";
 
 import { DependencyPopover } from "../AssetsList/DependencyPopover";
@@ -27,6 +28,9 @@ import { DependencyPopover } from 
"../AssetsList/DependencyPopover";
 export const Header = ({ asset }: { readonly asset?: AssetResponse }) => {
   const { t: translate } = useTranslation("assets");
 
+  const aliases = asset?.aliases ?? [];
+  const watchers = asset?.watchers ?? [];
+
   const stats = [
     { label: translate("group"), value: asset?.group },
     {
@@ -41,6 +45,11 @@ export const Header = ({ asset }: { readonly asset?: 
AssetResponse }) => {
       label: translate("scheduledDags"),
       value: <DependencyPopover dependencies={asset?.scheduled_dags ?? []} 
type="Dag" />,
     },
+    // Only assets that actually have them — unlike the dependency stats 
above, most assets have neither.
+    ...(aliases.length ? [{ label: translate("aliases"), value: 
<AliasesPopover aliases={aliases} /> }] : []),
+    ...(watchers.length
+      ? [{ label: translate("watchers"), value: <WatchersPopover 
watchers={watchers} /> }]
+      : []),
   ];
 
   return <HeaderCard icon={<FiDatabase />} stats={stats} title={asset?.name} 
/>;
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 00000000000..7763a9e92e3
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/AssetsList/AssetsList.test.tsx
@@ -0,0 +1,43 @@
+/*!
+ * 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 { render, screen, waitFor } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { AppWrapper } from "src/utils/AppWrapper";
+
+// The assets mock handler (see src/mocks/handlers/assets.ts) returns a single 
asset
+// with one consuming task, one alias and one watcher.
+describe("AssetsList columns", () => {
+  it("shows the consuming tasks of an asset", async () => {
+    render(<AppWrapper initialEntries={["/assets"]} />);
+
+    await waitFor(() => 
expect(screen.getByText("asset_with_dependencies")).toBeInTheDocument());
+
+    expect(screen.getByRole("button", { name: "1 task" })).toBeInTheDocument();
+  });
+
+  it("lists the aliases and watchers of an asset", async () => {
+    render(<AppWrapper initialEntries={["/assets"]} />);
+
+    await waitFor(() => expect(screen.getByRole("button", { name: "1 alias" 
})).toBeInTheDocument());
+
+    expect(screen.getByRole("button", { name: "1 watcher" 
})).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 33518105231..0f37d43db4b 100644
--- a/airflow-core/src/airflow/ui/src/pages/AssetsList/AssetsList.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/AssetsList/AssetsList.tsx
@@ -18,11 +18,13 @@
  */
 import { Heading, VStack } from "@chakra-ui/react";
 import type { ColumnDef } from "@tanstack/react-table";
+import type { TFunction } from "i18next";
 import { useTranslation } from "react-i18next";
 import { useSearchParams } from "react-router-dom";
 
 import { useAssetServiceGetAssetsUi } from "openapi/queries";
 import type { AssetResponse } from "openapi/requests/types.gen";
+import { AliasesPopover, WatchersPopover } from 
"src/components/Assets/ListPopover";
 import { DataTable } from "src/components/DataTable";
 import { useTableURLState } from "src/components/DataTable/useTableUrlState";
 import { ErrorAlert } from "src/components/ErrorAlert";
@@ -44,7 +46,7 @@ const assetsFilterKeys: Array<FilterableSearchParamsKeys> = [
 
 type AssetRow = { row: { original: AssetResponse } };
 
-const createColumns = (translate: (key: string) => string): 
Array<ColumnDef<AssetResponse>> => [
+const createColumns = (translate: TFunction): Array<ColumnDef<AssetResponse>> 
=> [
   {
     accessorKey: "name",
     cell: ({ row: { original } }: AssetRow) => (
@@ -90,9 +92,33 @@ const createColumns = (translate: (key: string) => string): 
Array<ColumnDef<Asse
     enableSorting: false,
     header: () => translate("producingTasks"),
   },
+  {
+    accessorKey: "consuming_tasks",
+    cell: ({ row: { original } }: AssetRow) =>
+      original.consuming_tasks.length ? (
+        <DependencyPopover dependencies={original.consuming_tasks} type="Task" 
/>
+      ) : undefined,
+    enableSorting: false,
+    header: () => translate("consumingTasks"),
+  },
+  {
+    accessorKey: "aliases",
+    cell: ({ row: { original } }: AssetRow) =>
+      original.aliases.length ? <AliasesPopover aliases={original.aliases} /> 
: undefined,
+    enableSorting: false,
+    header: () => translate("aliases"),
+  },
+  {
+    accessorKey: "watchers",
+    cell: ({ row: { original } }: AssetRow) =>
+      original.watchers.length ? <WatchersPopover watchers={original.watchers} 
/> : undefined,
+    enableSorting: false,
+    header: () => translate("watchers"),
+  },
   {
     accessorKey: "trigger",
     cell: ({ row }) => <CreateAssetEvent asset={row.original} />,
+    enableHiding: false,
     enableSorting: false,
     header: "",
   },

Reply via email to