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

kaxilnaik 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 62969b6b652 Add asset expression schedule popover (#47645)
62969b6b652 is described below

commit 62969b6b65208cd062dd949b0abdc302715031fa
Author: Brent Bovenzi <[email protected]>
AuthorDate: Wed Mar 12 03:27:41 2025 -0400

    Add asset expression schedule popover (#47645)
    
    For asset triggered dags, add a popover to the Schedule component to see a 
visualization of the asset conditions to trigger the dag.
    - For regularly scheduled dags, the schedule is just text with a tooltip. 
For Asset-triggered dags, it is a button to open a popover
    - Added `asset_expression` to `ui/dags`
    
    
    - To Do: Add event info and links to the asset details page when we add 
`asset_id` to `asset_expression`. Right now there is no good way to connect an 
expression to its actual asset. We can use this to show the queue for the next 
dag run, or if a dag run is selected, what events caused that dag to trigger. 
We could also add the Asset Event Creation trigger inside this popover.
    
    <img width="399" alt="Screenshot 2025-03-11 at 5 12 25 PM" 
src="https://github.com/user-attachments/assets/bb46c5eb-79a8-46fb-baf0-16f8650c32f0";
 />
    <img width="798" alt="Screenshot 2025-03-11 at 5 12 36 PM" 
src="https://github.com/user-attachments/assets/ed7af4aa-4106-423b-9a40-a1aa80b3e899";
 />
---
 airflow/api_fastapi/core_api/datamodels/ui/dags.py |  1 +
 .../api_fastapi/core_api/openapi/v1-generated.yaml |  6 ++
 airflow/api_fastapi/core_api/routes/ui/dags.py     |  2 +
 airflow/ui/openapi-gen/requests/schemas.gen.ts     | 12 ++++
 airflow/ui/openapi-gen/requests/types.gen.ts       |  3 +
 .../AssetExpression/AndGateNode.tsx}               | 48 +++++++++----
 .../components/AssetExpression/AssetExpression.tsx | 79 ++++++++++++++++++++++
 .../src/components/AssetExpression/AssetNode.tsx   | 59 ++++++++++++++++
 .../AssetExpression/OrGateNode.tsx}                | 23 +++----
 .../AssetExpression/index.ts}                      | 16 +----
 .../AssetExpression/types.ts}                      | 23 +++----
 airflow/ui/src/pages/Dag/Header.tsx                | 13 +---
 airflow/ui/src/pages/DagsList/DagCard.test.tsx     |  1 +
 airflow/ui/src/pages/DagsList/Schedule.tsx         | 35 ++++++++--
 airflow/ui/src/queries/useDags.tsx                 |  2 +
 15 files changed, 252 insertions(+), 71 deletions(-)

diff --git a/airflow/api_fastapi/core_api/datamodels/ui/dags.py 
b/airflow/api_fastapi/core_api/datamodels/ui/dags.py
index 991f5096b3d..08588ef7856 100644
--- a/airflow/api_fastapi/core_api/datamodels/ui/dags.py
+++ b/airflow/api_fastapi/core_api/datamodels/ui/dags.py
@@ -25,6 +25,7 @@ from airflow.api_fastapi.core_api.datamodels.dags import 
DAGResponse
 class DAGWithLatestDagRunsResponse(DAGResponse):
     """DAG with latest dag runs response serializer."""
 
+    asset_expression: dict | None
     latest_dag_runs: list[DAGRunResponse]
 
 
diff --git a/airflow/api_fastapi/core_api/openapi/v1-generated.yaml 
b/airflow/api_fastapi/core_api/openapi/v1-generated.yaml
index 58b65bca378..0eab45da497 100644
--- a/airflow/api_fastapi/core_api/openapi/v1-generated.yaml
+++ b/airflow/api_fastapi/core_api/openapi/v1-generated.yaml
@@ -9050,6 +9050,11 @@ components:
             type: string
           type: array
           title: Owners
+        asset_expression:
+          anyOf:
+          - type: object
+          - type: 'null'
+          title: Asset Expression
         latest_dag_runs:
           items:
             $ref: '#/components/schemas/DAGRunResponse'
@@ -9083,6 +9088,7 @@ components:
       - next_dagrun_data_interval_end
       - next_dagrun_run_after
       - owners
+      - asset_expression
       - latest_dag_runs
       - file_token
       title: DAGWithLatestDagRunsResponse
diff --git a/airflow/api_fastapi/core_api/routes/ui/dags.py 
b/airflow/api_fastapi/core_api/routes/ui/dags.py
index 11258f590f9..1c2282cb4e4 100644
--- a/airflow/api_fastapi/core_api/routes/ui/dags.py
+++ b/airflow/api_fastapi/core_api/routes/ui/dags.py
@@ -148,9 +148,11 @@ def recent_dag_runs(
         dag_run_response = DAGRunResponse.model_validate(dag_run)
         if dag_id not in dag_runs_by_dag_id:
             dag_response = DAGResponse.model_validate(dag)
+            dag_model: DagModel = session.get(DagModel, dag.dag_id)
             dag_runs_by_dag_id[dag_id] = 
DAGWithLatestDagRunsResponse.model_validate(
                 {
                     **dag_response.model_dump(),
+                    "asset_expression": dag_model.asset_expression,
                     "latest_dag_runs": [dag_run_response],
                 }
             )
diff --git a/airflow/ui/openapi-gen/requests/schemas.gen.ts 
b/airflow/ui/openapi-gen/requests/schemas.gen.ts
index 6b67a04c0be..6756181f8f8 100644
--- a/airflow/ui/openapi-gen/requests/schemas.gen.ts
+++ b/airflow/ui/openapi-gen/requests/schemas.gen.ts
@@ -2878,6 +2878,17 @@ export const $DAGWithLatestDagRunsResponse = {
       type: "array",
       title: "Owners",
     },
+    asset_expression: {
+      anyOf: [
+        {
+          type: "object",
+        },
+        {
+          type: "null",
+        },
+      ],
+      title: "Asset Expression",
+    },
     latest_dag_runs: {
       items: {
         $ref: "#/components/schemas/DAGRunResponse",
@@ -2915,6 +2926,7 @@ export const $DAGWithLatestDagRunsResponse = {
     "next_dagrun_data_interval_end",
     "next_dagrun_run_after",
     "owners",
+    "asset_expression",
     "latest_dag_runs",
     "file_token",
   ],
diff --git a/airflow/ui/openapi-gen/requests/types.gen.ts 
b/airflow/ui/openapi-gen/requests/types.gen.ts
index 5a6a61057ec..1ed2a13c959 100644
--- a/airflow/ui/openapi-gen/requests/types.gen.ts
+++ b/airflow/ui/openapi-gen/requests/types.gen.ts
@@ -750,6 +750,9 @@ export type DAGWithLatestDagRunsResponse = {
   next_dagrun_data_interval_end: string | null;
   next_dagrun_run_after: string | null;
   owners: Array<string>;
+  asset_expression: {
+    [key: string]: unknown;
+  } | null;
   latest_dag_runs: Array<DAGRunResponse>;
   /**
    * Return file token.
diff --git a/airflow/ui/src/pages/DagsList/Schedule.tsx 
b/airflow/ui/src/components/AssetExpression/AndGateNode.tsx
similarity index 51%
copy from airflow/ui/src/pages/DagsList/Schedule.tsx
copy to airflow/ui/src/components/AssetExpression/AndGateNode.tsx
index 9f0ff07da82..37fd8ee8b6c 100644
--- a/airflow/ui/src/pages/DagsList/Schedule.tsx
+++ b/airflow/ui/src/components/AssetExpression/AndGateNode.tsx
@@ -16,18 +16,38 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { Text } from "@chakra-ui/react";
+import { Box, VStack, Badge } from "@chakra-ui/react";
+import type { PropsWithChildren } from "react";
+import { TbLogicAnd } from "react-icons/tb";
 
-import type { DAGWithLatestDagRunsResponse } from "openapi/requests/types.gen";
-import { Tooltip } from "src/components/ui";
-
-type Props = {
-  readonly dag: DAGWithLatestDagRunsResponse;
-};
-
-export const Schedule = ({ dag }: Props) =>
-  Boolean(dag.timetable_summary) && dag.timetable_description !== "Never, 
external triggers only" ? (
-    <Tooltip content={dag.timetable_description}>
-      <Text fontSize="sm">{dag.timetable_summary}</Text>
-    </Tooltip>
-  ) : undefined;
+export const AndGateNode = ({ children }: PropsWithChildren) => (
+  <Box
+    bg="bg.emphasized"
+    border="2px dashed"
+    borderRadius="lg"
+    display="inline-block"
+    minW="fit-content"
+    p={4}
+    position="relative"
+  >
+    <Badge
+      alignItems="center"
+      borderRadius="full"
+      display="flex"
+      fontSize="sm"
+      gap={1}
+      left="50%"
+      position="absolute"
+      px={3}
+      py={1}
+      top="-3"
+      transform="translateX(-50%)"
+    >
+      <TbLogicAnd size={18} />
+      AND
+    </Badge>
+    <VStack align="center" gap={4} mt={3}>
+      {children}
+    </VStack>
+  </Box>
+);
diff --git a/airflow/ui/src/components/AssetExpression/AssetExpression.tsx 
b/airflow/ui/src/components/AssetExpression/AssetExpression.tsx
new file mode 100644
index 00000000000..0c4f9c7caf8
--- /dev/null
+++ b/airflow/ui/src/components/AssetExpression/AssetExpression.tsx
@@ -0,0 +1,79 @@
+/*!
+ * 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 { Box, Badge } from "@chakra-ui/react";
+import { Fragment } from "react";
+import { TbLogicOr } from "react-icons/tb";
+
+import type { QueuedEventResponse } from "openapi/requests/types.gen";
+
+import { AndGateNode } from "./AndGateNode";
+import { AssetNode } from "./AssetNode";
+import { OrGateNode } from "./OrGateNode";
+import type { ExpressionType } from "./types";
+
+export const AssetExpression = ({
+  events,
+  expression,
+}: {
+  readonly events?: Array<QueuedEventResponse>;
+  readonly expression: ExpressionType | null;
+}) => {
+  if (expression === null) {
+    return undefined;
+  }
+
+  return (
+    <>
+      {expression.any ? (
+        <OrGateNode>
+          {expression.any.map((item, index) => (
+            // eslint-disable-next-line react/no-array-index-key
+            <Fragment key={`any-${index}`}>
+              {"asset" in item ? (
+                <AssetNode asset={item.asset} />
+              ) : (
+                <AssetExpression events={events} expression={item} />
+              )}
+              {expression.any && index === expression.any.length - 1 ? 
undefined : (
+                <Badge alignItems="center" borderRadius="full" fontSize="sm" 
px={3} py={1}>
+                  <TbLogicOr size={18} />
+                  OR
+                </Badge>
+              )}
+            </Fragment>
+          ))}
+        </OrGateNode>
+      ) : undefined}
+      {expression.all ? (
+        <AndGateNode>
+          {expression.all.map((item, index) => (
+            // eslint-disable-next-line react/no-array-index-key
+            <Box display="inline-block" key={`all-${index}`}>
+              {"asset" in item ? (
+                <AssetNode asset={item.asset} />
+              ) : (
+                <AssetExpression events={events} expression={item} />
+              )}
+            </Box>
+          ))}
+        </AndGateNode>
+      ) : undefined}
+    </>
+  );
+};
diff --git a/airflow/ui/src/components/AssetExpression/AssetNode.tsx 
b/airflow/ui/src/components/AssetExpression/AssetNode.tsx
new file mode 100644
index 00000000000..accec69af6a
--- /dev/null
+++ b/airflow/ui/src/components/AssetExpression/AssetNode.tsx
@@ -0,0 +1,59 @@
+/*!
+ * 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 { Box, Text, VStack, HStack } from "@chakra-ui/react";
+import { FiDatabase } from "react-icons/fi";
+
+import type { QueuedEventResponse } from "openapi/requests/types.gen";
+
+import Time from "../Time";
+import type { AssetSummary } from "./types";
+
+export const AssetNode = ({
+  asset,
+  event,
+}: {
+  readonly asset: AssetSummary["asset"];
+  readonly event?: QueuedEventResponse;
+}) => (
+  <Box
+    bg="bg.muted"
+    border="1px solid"
+    borderRadius="md"
+    borderWidth={Boolean(event?.created_at) ? 3 : 1}
+    display="inline-block"
+    minW="fit-content"
+    p={3}
+    position="relative"
+  >
+    <VStack align="start" gap={2}>
+      <HStack gap={2}>
+        <FiDatabase />
+        {/* TODO add events back in when asset_expression contains asset_id */}
+        {/* {event?.id === undefined ? ( */}
+        <Text fontSize="sm">{asset.uri}</Text>
+        {/* ) : (
+          <Link asChild color="fg.info" display="block" py={2}>
+            <RouterLink to={`/assets/${event.id}`}>{asset.uri}</RouterLink>
+          </Link>
+        )} */}
+      </HStack>
+      <Time datetime={event?.created_at} />
+    </VStack>
+  </Box>
+);
diff --git a/airflow/ui/src/pages/DagsList/Schedule.tsx 
b/airflow/ui/src/components/AssetExpression/OrGateNode.tsx
similarity index 61%
copy from airflow/ui/src/pages/DagsList/Schedule.tsx
copy to airflow/ui/src/components/AssetExpression/OrGateNode.tsx
index 9f0ff07da82..1b6a7065cca 100644
--- a/airflow/ui/src/pages/DagsList/Schedule.tsx
+++ b/airflow/ui/src/components/AssetExpression/OrGateNode.tsx
@@ -16,18 +16,13 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { Text } from "@chakra-ui/react";
+import { Box, HStack } from "@chakra-ui/react";
+import type { PropsWithChildren } from "react";
 
-import type { DAGWithLatestDagRunsResponse } from "openapi/requests/types.gen";
-import { Tooltip } from "src/components/ui";
-
-type Props = {
-  readonly dag: DAGWithLatestDagRunsResponse;
-};
-
-export const Schedule = ({ dag }: Props) =>
-  Boolean(dag.timetable_summary) && dag.timetable_description !== "Never, 
external triggers only" ? (
-    <Tooltip content={dag.timetable_description}>
-      <Text fontSize="sm">{dag.timetable_summary}</Text>
-    </Tooltip>
-  ) : undefined;
+export const OrGateNode = ({ children }: PropsWithChildren) => (
+  <Box bg="bg.emphasized" border="2px dashed" borderRadius="lg" 
minW="fit-content" p={4} position="relative">
+    <HStack align="center" gap={4} mt={3}>
+      {children}
+    </HStack>
+  </Box>
+);
diff --git a/airflow/ui/src/pages/DagsList/Schedule.tsx 
b/airflow/ui/src/components/AssetExpression/index.ts
similarity index 61%
copy from airflow/ui/src/pages/DagsList/Schedule.tsx
copy to airflow/ui/src/components/AssetExpression/index.ts
index 9f0ff07da82..c54f53ab6c5 100644
--- a/airflow/ui/src/pages/DagsList/Schedule.tsx
+++ b/airflow/ui/src/components/AssetExpression/index.ts
@@ -16,18 +16,6 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { Text } from "@chakra-ui/react";
 
-import type { DAGWithLatestDagRunsResponse } from "openapi/requests/types.gen";
-import { Tooltip } from "src/components/ui";
-
-type Props = {
-  readonly dag: DAGWithLatestDagRunsResponse;
-};
-
-export const Schedule = ({ dag }: Props) =>
-  Boolean(dag.timetable_summary) && dag.timetable_description !== "Never, 
external triggers only" ? (
-    <Tooltip content={dag.timetable_description}>
-      <Text fontSize="sm">{dag.timetable_summary}</Text>
-    </Tooltip>
-  ) : undefined;
+export { AssetExpression } from "./AssetExpression";
+export type { ExpressionType, AssetSummary } from "./types";
diff --git a/airflow/ui/src/pages/DagsList/Schedule.tsx 
b/airflow/ui/src/components/AssetExpression/types.ts
similarity index 61%
copy from airflow/ui/src/pages/DagsList/Schedule.tsx
copy to airflow/ui/src/components/AssetExpression/types.ts
index 9f0ff07da82..512fcece5df 100644
--- a/airflow/ui/src/pages/DagsList/Schedule.tsx
+++ b/airflow/ui/src/components/AssetExpression/types.ts
@@ -16,18 +16,17 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { Text } from "@chakra-ui/react";
 
-import type { DAGWithLatestDagRunsResponse } from "openapi/requests/types.gen";
-import { Tooltip } from "src/components/ui";
-
-type Props = {
-  readonly dag: DAGWithLatestDagRunsResponse;
+export type AssetSummary = {
+  asset: {
+    group: string;
+    name: string;
+    timestamp?: string;
+    uri: string;
+  };
 };
 
-export const Schedule = ({ dag }: Props) =>
-  Boolean(dag.timetable_summary) && dag.timetable_description !== "Never, 
external triggers only" ? (
-    <Tooltip content={dag.timetable_description}>
-      <Text fontSize="sm">{dag.timetable_summary}</Text>
-    </Tooltip>
-  ) : undefined;
+export type ExpressionType = {
+  all?: Array<AssetSummary | ExpressionType>;
+  any?: Array<AssetSummary | ExpressionType>;
+};
diff --git a/airflow/ui/src/pages/Dag/Header.tsx 
b/airflow/ui/src/pages/Dag/Header.tsx
index 80c21ed8fa1..f5c10d71c86 100644
--- a/airflow/ui/src/pages/Dag/Header.tsx
+++ b/airflow/ui/src/pages/Dag/Header.tsx
@@ -16,8 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { Text } from "@chakra-ui/react";
-import { FiBookOpen, FiCalendar } from "react-icons/fi";
+import { FiBookOpen } from "react-icons/fi";
 import { useParams } from "react-router-dom";
 
 import type { DAGDetailsResponse, DAGWithLatestDagRunsResponse } from 
"openapi/requests/types.gen";
@@ -27,9 +26,9 @@ import DisplayMarkdownButton from 
"src/components/DisplayMarkdownButton";
 import { HeaderCard } from "src/components/HeaderCard";
 import MenuButton from "src/components/Menu/MenuButton";
 import { TogglePause } from "src/components/TogglePause";
-import { Tooltip } from "src/components/ui";
 
 import { DagTags } from "../DagsList/DagTags";
+import { Schedule } from "../DagsList/Schedule";
 
 export const Header = ({
   dag,
@@ -47,13 +46,7 @@ export const Header = ({
   const stats = [
     {
       label: "Schedule",
-      value: Boolean(dag?.timetable_summary) ? (
-        <Tooltip content={dag?.timetable_description}>
-          <Text fontSize="sm">
-            <FiCalendar style={{ display: "inline" }} /> 
{dag?.timetable_summary}
-          </Text>
-        </Tooltip>
-      ) : undefined,
+      value: dag === undefined ? undefined : <Schedule dag={dag} />,
     },
     {
       label: "Latest Run",
diff --git a/airflow/ui/src/pages/DagsList/DagCard.test.tsx 
b/airflow/ui/src/pages/DagsList/DagCard.test.tsx
index 67c25f0929d..e2360517806 100644
--- a/airflow/ui/src/pages/DagsList/DagCard.test.tsx
+++ b/airflow/ui/src/pages/DagsList/DagCard.test.tsx
@@ -27,6 +27,7 @@ import { Wrapper } from "src/utils/Wrapper";
 import { DagCard } from "./DagCard";
 
 const mockDag = {
+  asset_expression: null,
   dag_display_name: "nested_groups",
   dag_id: "nested_groups",
   description: null,
diff --git a/airflow/ui/src/pages/DagsList/Schedule.tsx 
b/airflow/ui/src/pages/DagsList/Schedule.tsx
index 9f0ff07da82..3c3946a32ee 100644
--- a/airflow/ui/src/pages/DagsList/Schedule.tsx
+++ b/airflow/ui/src/pages/DagsList/Schedule.tsx
@@ -17,17 +17,38 @@
  * under the License.
  */
 import { Text } from "@chakra-ui/react";
+import { FiCalendar } from "react-icons/fi";
 
-import type { DAGWithLatestDagRunsResponse } from "openapi/requests/types.gen";
-import { Tooltip } from "src/components/ui";
+import type { DAGDetailsResponse, DAGWithLatestDagRunsResponse } from 
"openapi/requests/types.gen";
+import { AssetExpression, type ExpressionType } from 
"src/components/AssetExpression";
+import { Button, Popover, Tooltip } from "src/components/ui";
 
 type Props = {
-  readonly dag: DAGWithLatestDagRunsResponse;
+  readonly dag: DAGDetailsResponse | DAGWithLatestDagRunsResponse;
 };
 
 export const Schedule = ({ dag }: Props) =>
-  Boolean(dag.timetable_summary) && dag.timetable_description !== "Never, 
external triggers only" ? (
-    <Tooltip content={dag.timetable_description}>
-      <Text fontSize="sm">{dag.timetable_summary}</Text>
-    </Tooltip>
+  Boolean(dag.timetable_summary) ? (
+    dag.asset_expression === null ? (
+      <Tooltip content={dag.timetable_description}>
+        <Text fontSize="sm">
+          <FiCalendar style={{ display: "inline" }} /> {dag.timetable_summary}
+        </Text>
+      </Tooltip>
+    ) : (
+      // eslint-disable-next-line jsx-a11y/no-autofocus
+      <Popover.Root autoFocus={false} lazyMount positioning={{ placement: 
"bottom-end" }} unmountOnExit>
+        <Popover.Trigger asChild>
+          <Button size="sm" variant="ghost">
+            <FiCalendar style={{ display: "inline" }} /> 
{dag.timetable_summary}
+          </Button>
+        </Popover.Trigger>
+        <Popover.Content width="fit-content">
+          <Popover.Arrow />
+          <Popover.Body>
+            <AssetExpression expression={dag.asset_expression as 
ExpressionType} />
+          </Popover.Body>
+        </Popover.Content>
+      </Popover.Root>
+    )
   ) : undefined;
diff --git a/airflow/ui/src/queries/useDags.tsx 
b/airflow/ui/src/queries/useDags.tsx
index b3a5af8f0a3..d4f58dc985e 100644
--- a/airflow/ui/src/queries/useDags.tsx
+++ b/airflow/ui/src/queries/useDags.tsx
@@ -68,6 +68,8 @@ export const useDags = (
     const dagWithRuns = runsData?.dags.find((runsDag) => runsDag.dag_id === 
dag.dag_id);
 
     return {
+      // eslint-disable-next-line unicorn/no-null
+      asset_expression: null,
       latest_dag_runs: [],
       ...dagWithRuns,
       ...dag,

Reply via email to