Repository: apache/airflow
Pull request #49137: Update next run assets and asset expression types
Author: login: bbovenzi
Labels: name: area:API
description: Airflow's REST/HTTP API
name: area:UI
description: Related to UI/UX. For Frontend Developers.
Created: 2025-04-11T17:34:02Z | Merged: 2025-04-15T14:55:56Z

Sometimes the `asset_expression `isn't `any` or `all` but in fact just an `asset` or `alias`. This PR fixes a `cant read 0 of undefined` error that came from relying on only any and all relationships

Added `name` to the next events to avoid a roundabout lookup.

Render a single asset dep as just a link without the popover.


---
**^ Add meaningful description above**
Read the **[Pull Request Guidelines](https://github.com/apache/airflow/blob/main/contributing-docs/05_pull_requests.rst#pull-request-guidelines)** for more information.
In case of fundamental code changes, an Airflow Improvement Proposal ([AIP](https://cwiki.apache.org/confluence/display/AIRFLOW/Airflow+Improvement+Proposals)) is needed.
In case of a new dependency, check compliance with the [ASF 3rd Party License Policy](https://www.apache.org/legal/resolved.html#category-x).
In case of backwards incompatible changes please leave a note in a newsfragment file, named `{pr_number}.significant.rst` or `{issue_number}.significant.rst`, in [airflow-core/newsfragments](https://github.com/apache/airflow/tree/main/airflow-core/newsfragments).



Diff:
diff --git a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/assets.py b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/assets.py
index 1f35c5621bfa9..caa3d2b0a12d5 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/assets.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/assets.py
@@ -55,6 +55,7 @@ def next_run_assets(
             select(
                 AssetModel.id,
                 AssetModel.uri,
+                AssetModel.name,
                 func.max(AssetEvent.timestamp).label("lastUpdate"),
             )
             .join(DagScheduleAssetReference, DagScheduleAssetReference.asset_id == AssetModel.id)
@@ -79,9 +80,10 @@ def next_run_assets(
                 isouter=True,
             )
             .where(DagScheduleAssetReference.dag_id == dag_id, AssetModel.active.has())
-            .group_by(AssetModel.id, AssetModel.uri)
+            .group_by(AssetModel.id, AssetModel.uri, AssetModel.name)
             .order_by(AssetModel.uri)
         )
     ]
+
     data = {"asset_expression": dag_model.asset_expression, "events": events}
     return data
diff --git a/airflow-core/src/airflow/ui/src/components/AssetExpression/AssetExpression.tsx b/airflow-core/src/airflow/ui/src/components/AssetExpression/AssetExpression.tsx
index 2d402411ece3a..2c27e32d72784 100644
--- a/airflow-core/src/airflow/ui/src/components/AssetExpression/AssetExpression.tsx
+++ b/airflow-core/src/airflow/ui/src/components/AssetExpression/AssetExpression.tsx
@@ -38,9 +38,9 @@ export const AssetExpression = ({
 
   return (
     <>
-      {expression.any ? (
+      {"any" in expression ? (
         <OrGateNode>
-          {expression.any.map((item, index) => (
+          {expression.any?.map((item, index) => (
             // eslint-disable-next-line react/no-array-index-key
             <Fragment key={`any-${index}`}>
               {"asset" in item || "alias" in item ? (
@@ -61,9 +61,9 @@ export const AssetExpression = ({
           ))}
         </OrGateNode>
       ) : undefined}
-      {expression.all ? (
+      {"all" in expression ? (
         <AndGateNode>
-          {expression.all.map((item, index) => (
+          {expression.all?.map((item, index) => (
             // eslint-disable-next-line react/no-array-index-key
             <Box display="inline-block" key={`all-${index}`}>
               {"asset" in item || "alias" in item ? (
diff --git a/airflow-core/src/airflow/ui/src/components/AssetExpression/types.ts b/airflow-core/src/airflow/ui/src/components/AssetExpression/types.ts
index c74a68e156dd0..7328dcfcc0eec 100644
--- a/airflow-core/src/airflow/ui/src/components/AssetExpression/types.ts
+++ b/airflow-core/src/airflow/ui/src/components/AssetExpression/types.ts
@@ -33,11 +33,14 @@ type Alias = {
   };
 };
 
-export type NextRunEvent = { id: number; lastUpdate: string | null; uri: string };
+export type NextRunEvent = { id: number; lastUpdate: string | null; name: string | null; uri: string };
 
 export type AssetSummary = Alias | Asset;
 
-export type ExpressionType = {
-  all?: Array<AssetSummary | ExpressionType>;
-  any?: Array<AssetSummary | ExpressionType>;
-};
+export type ExpressionType =
+  | Alias
+  | Asset
+  | {
+      all?: Array<AssetSummary | ExpressionType>;
+      any?: Array<AssetSummary | ExpressionType>;
+    };
diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/AssetSchedule.tsx b/airflow-core/src/airflow/ui/src/pages/DagsList/AssetSchedule.tsx
index 854ca8cdb6ce7..84dcb66acd614 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagsList/AssetSchedule.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/AssetSchedule.tsx
@@ -16,11 +16,13 @@
  * specific language governing permissions and limitations
  * under the License.
  */
+import { HStack, Text, Link } from "@chakra-ui/react";
 import dayjs from "dayjs";
 import { FiDatabase } from "react-icons/fi";
+import { Link as RouterLink } from "react-router-dom";
 
 import { useAssetServiceNextRunAssets } from "openapi/queries";
-import type { AssetResponse, DAGWithLatestDagRunsResponse } from "openapi/requests/types.gen";
+import type { DAGWithLatestDagRunsResponse } from "openapi/requests/types.gen";
 import { AssetExpression, type ExpressionType } from "src/components/AssetExpression";
 import type { NextRunEvent } from "src/components/AssetExpression/types";
 import { Button, Popover } from "src/components/ui";
@@ -32,13 +34,9 @@ type Props = {
 export const AssetSchedule = ({ dag }: Props) => {
   const { data: nextRun, isLoading } = useAssetServiceNextRunAssets({ dagId: dag.dag_id });
 
-  const nextRunEvents =
-    nextRun !== undefined && "events" in nextRun ? (nextRun.events as Array<NextRunEvent>) : [];
+  const nextRunEvents = (nextRun?.events ?? []) as Array<NextRunEvent>;
 
   const pendingEvents = nextRunEvents.filter((ev) => {
-    if (ev.lastUpdate !== null && dag.latest_dag_runs[0]?.run_after === undefined) {
-      return true;
-    }
     if (ev.lastUpdate !== null && dag.latest_dag_runs[0]?.run_after !== undefined) {
       return dayjs(ev.lastUpdate).isAfter(dag.latest_dag_runs[0].run_after);
     }
@@ -46,25 +44,36 @@ export const AssetSchedule = ({ dag }: Props) => {
     return false;
   });
 
+  if (!nextRunEvents.length) {
+    return (
+      <HStack>
+        <FiDatabase style={{ display: "inline" }} />
+        <Text>{dag.timetable_summary}</Text>
+      </HStack>
+    );
+  }
+
+  const [asset] = nextRunEvents;
+
+  if (nextRunEvents.length === 1 && asset !== undefined) {
+    return (
+      <HStack>
+        <FiDatabase style={{ display: "inline" }} />
+        <Link asChild color="fg.info" display="block" py={2}>
+          <RouterLink to={`/assets/${asset.id}`}>{asset.name ?? asset.uri}</RouterLink>
+        </Link>
+      </HStack>
+    );
+  }
+
   return (
     // eslint-disable-next-line jsx-a11y/no-autofocus
     <Popover.Root autoFocus={false} lazyMount positioning={{ placement: "bottom-end" }} unmountOnExit>
       <Popover.Trigger asChild>
         <Button loading={isLoading} paddingInline={0} size="sm" variant="ghost">
           <FiDatabase style={{ display: "inline" }} />
-          {nextRunEvents.length === 1 ? (
-            ((nextRun?.asset_expression as { all: Array<{ asset: AssetResponse }> | undefined }).all?.at(0)
-              ?.asset.name ??
-            (nextRun?.asset_expression as { any: Array<{ asset: AssetResponse }> | undefined }).any?.at(0)
-              ?.asset.name ??
-            (nextRun?.asset_expression as { asset: AssetResponse | undefined }).asset?.name ??
-            nextRunEvents[0]?.uri)
-          ) : (
-            <>
-              {pendingEvents.length}
-              {nextRunEvents.length > 1 ? ` of ${nextRunEvents.length} ` : " "}assets updated
-            </>
-          )}
+          {pendingEvents.length}
+          {nextRunEvents.length > 1 ? ` of ${nextRunEvents.length} ` : " "}assets updated
         </Button>
       </Popover.Trigger>
       <Popover.Content css={{ "--popover-bg": "colors.bg.emphasized" }} width="fit-content">
diff --git a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_assets.py b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_assets.py
index c65b306e32a52..a75cadbd6c09e 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_assets.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_assets.py
@@ -62,7 +62,9 @@ def test_should_response_200(self, test_client, dag_maker):
                     }
                 ]
             },
-            "events": [{"id": mock.ANY, "uri": "s3://bucket/next-run-asset/1", "lastUpdate": None}],
+            "events": [
+                {"id": mock.ANY, "uri": "s3://bucket/next-run-asset/1", "name": "asset1", "lastUpdate": None}
+            ],
         }
 
     def test_should_respond_401(self, unauthenticated_test_client):


Reviews:
user: login: kaxil
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-11T19:19:53Z
user: login: pierrejeambrun
body: Nice, just a couple of nitpicks 
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-11T20:33:19Z
user: login: pierrejeambrun
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-11T20:33:45Z
user: login: aritra24
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-13T09:05:01Z
user: login: Lee-W
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-14T14:50:18Z
user: login: Lee-W
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-15T01:53:12Z

Review comments:
diff_hunk: @@ -32,35 +34,46 @@ type Props = {
 export const AssetSchedule = ({ dag }: Props) => {
   const { data: nextRun, isLoading } = useAssetServiceNextRunAssets({ dagId: dag.dag_id });
 
-  const nextRunEvents =
-    nextRun !== undefined && "events" in nextRun ? (nextRun.events as Array<NextRunEvent>) : [];
+  const nextRunEvents = (nextRun?.events ?? []) as Array<NextRunEvent>;
 
   const pendingEvents = nextRunEvents.filter((ev) => {
-    if (ev.lastUpdate !== null && dag.latest_dag_runs[0]?.run_after === undefined) {
-      return true;
-    }
     if (ev.lastUpdate !== null && dag.latest_dag_runs[0]?.run_after !== undefined) {
       return dayjs(ev.lastUpdate).isAfter(dag.latest_dag_runs[0]?.run_after);
     }
 
     return false;
   });
 
+  if (!nextRunEvents.length) {
+    return (
+      <HStack>
+        <FiDatabase style={{ display: "inline" }} />
+        <Text>{dag.timetable_summary}</Text>
+      </HStack>
+    );
+  }
+
+  if (nextRunEvents.length === 1 && nextRunEvents[0]) {
path: airflow-core/src/airflow/ui/src/pages/DagsList/AssetSchedule.tsx
user: login: pierrejeambrun
body: There is an item, do we need the check on the 0 value itself ?
created_at: 2025-04-11T20:32:07Z
author_association: MEMBER
diff_hunk: @@ -32,35 +34,46 @@ type Props = {
 export const AssetSchedule = ({ dag }: Props) => {
   const { data: nextRun, isLoading } = useAssetServiceNextRunAssets({ dagId: dag.dag_id });
 
-  const nextRunEvents =
-    nextRun !== undefined && "events" in nextRun ? (nextRun.events as Array<NextRunEvent>) : [];
+  const nextRunEvents = (nextRun?.events ?? []) as Array<NextRunEvent>;
 
   const pendingEvents = nextRunEvents.filter((ev) => {
-    if (ev.lastUpdate !== null && dag.latest_dag_runs[0]?.run_after === undefined) {
-      return true;
-    }
     if (ev.lastUpdate !== null && dag.latest_dag_runs[0]?.run_after !== undefined) {
       return dayjs(ev.lastUpdate).isAfter(dag.latest_dag_runs[0]?.run_after);
     }
 
     return false;
   });
 
+  if (!nextRunEvents.length) {
+    return (
+      <HStack>
+        <FiDatabase style={{ display: "inline" }} />
+        <Text>{dag.timetable_summary}</Text>
+      </HStack>
+    );
+  }
+
+  if (nextRunEvents.length === 1 && nextRunEvents[0]) {
+    return (
+      <HStack>
+        <FiDatabase style={{ display: "inline" }} />
+        <Link asChild color="fg.info" display="block" py={2}>
+          <RouterLink to={`/assets/${nextRunEvents[0].id}`}>
path: airflow-core/src/airflow/ui/src/pages/DagsList/AssetSchedule.tsx
user: login: pierrejeambrun
body: Maybe we can extract a variable from the [0] to limit the accessor
created_at: 2025-04-11T20:32:55Z
author_association: MEMBER

PR comments:
user: login: pierrejeambrun
created_at: 2025-04-15T13:56:14Z
body: CI failure seems unrelated
author_association: MEMBER
user: login: pierrejeambrun
created_at: 2025-04-15T14:47:09Z
body: That's an update to bundles that impacted the API test but was not detected by the selective checks I guess.
author_association: MEMBER
user: login: pierrejeambrun
created_at: 2025-04-15T14:55:51Z
body: Unrelated failure
author_association: MEMBER