This is an automated email from the ASF dual-hosted git repository. vatsrahul1001 pushed a commit to branch backport-d1b359bec7-v3-3-test in repository https://gitbox.apache.org/repos/asf/airflow.git
commit 6b96e36b19bbc7b445e8d08600e247a8a2f7b956 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 (cherry picked from commit d1b359bec74ea373b770d340218496c352f65a78) --- .../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 | 31 +++++++-- 8 files changed, 262 insertions(+), 5 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 a5d044e6264..00082cebebd 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": { @@ -50,5 +53,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..e643259343e --- /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("/api/v2/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 150b57ba968..f9748d44014 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 { Flex, Heading, useDisclosure, 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 { useAssetServiceGetAssets } 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"; @@ -40,10 +42,7 @@ import { DependencyPopover } from "./DependencyPopover"; type AssetRow = { row: { original: AssetResponse } }; -const createColumns = ( - translate: (key: string) => string, - open?: boolean, -): Array<ColumnDef<AssetResponse>> => [ +const createColumns = (translate: TFunction, open?: boolean): Array<ColumnDef<AssetResponse>> => [ { accessorKey: "name", cell: ({ row: { original } }: AssetRow) => ( @@ -91,9 +90,33 @@ const createColumns = ( 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: "", },
