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 7821429f6b9 Add support of different graph directions in the Asset 
graph view. (#65948)
7821429f6b9 is described below

commit 7821429f6b92cc53ec22d41796db40f12583cc74
Author: smit-makadia-infocusp 
<[email protected]>
AuthorDate: Tue May 5 21:04:27 2026 +0530

    Add support of different graph directions in the Asset graph view. (#65948)
    
    * Add direction functionality in asset graph view.
    
    * Extract the direction dropdown to its own shared component for asset and 
dag graphs.
    
    * Add namespaces to use for translations.
    
    * Formatting changes.
    
    * Update airflow-core/src/airflow/ui/src/pages/Asset/AssetGraph.tsx
    
    * Update airflow-core/src/airflow/ui/src/components/Graph/useGraphLayout.ts
    
    * Update airflow-core/src/airflow/ui/src/layouts/Details/Graph/Graph.tsx
    
    * Fix compile, format, and lint issues found in checks.
    
    * Remove an extra white space.
    
    ---------
    
    Co-authored-by: Brent Bovenzi <[email protected]>
---
 .../ui/src/components/Graph/DirectionDropdown.tsx  | 78 ++++++++++++++++++++++
 .../ui/src/components/Graph/elkGraphUtils.ts       |  2 +-
 .../ui/src/components/Graph/useGraphLayout.ts      | 14 +---
 .../airflow/ui/src/layouts/Details/Graph/Graph.tsx |  3 +-
 .../ui/src/layouts/Details/PanelButtons.tsx        | 42 +-----------
 .../src/airflow/ui/src/pages/Asset/AssetGraph.tsx  |  7 +-
 .../ui/src/pages/Asset/AssetPanelButtons.tsx       | 75 +++++++++++++++------
 airflow-core/src/airflow/ui/testsSetup.ts          |  1 -
 8 files changed, 147 insertions(+), 75 deletions(-)

diff --git 
a/airflow-core/src/airflow/ui/src/components/Graph/DirectionDropdown.tsx 
b/airflow-core/src/airflow/ui/src/components/Graph/DirectionDropdown.tsx
new file mode 100644
index 00000000000..b1f608a445d
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/Graph/DirectionDropdown.tsx
@@ -0,0 +1,78 @@
+/*!
+ * 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 { createListCollection, Select, type SelectValueChangeDetails } from 
"@chakra-ui/react";
+import { useTranslation } from "react-i18next";
+import { useLocalStorage } from "usehooks-ts";
+
+import { directionKey } from "src/constants/localStorage";
+
+export type Direction = "DOWN" | "LEFT" | "RIGHT" | "UP";
+
+export const DirectionDropdown = ({ graphId }: { readonly graphId: string }) 
=> {
+  const { t: translate } = useTranslation(["components", "dag"]);
+
+  const [direction, setDirection] = 
useLocalStorage<Direction>(directionKey(graphId), "RIGHT");
+
+  const directionOptions = () =>
+    createListCollection({
+      items: [
+        { label: translate("graph.directionRight"), value: "RIGHT" as 
Direction },
+        { label: translate("graph.directionLeft"), value: "LEFT" as Direction 
},
+        { label: translate("graph.directionUp"), value: "UP" as Direction },
+        { label: translate("graph.directionDown"), value: "DOWN" as Direction 
},
+      ],
+    });
+
+  const handleDirectionUpdate = (
+    event: SelectValueChangeDetails<{ label: string; value: Array<string> }>,
+  ) => {
+    if (event.value[0] !== undefined) {
+      setDirection(event.value[0] as Direction);
+    }
+  };
+
+  return (
+    <Select.Root
+      // @ts-expect-error The expected option type is incorrect
+      collection={directionOptions()}
+      onValueChange={handleDirectionUpdate}
+      size="sm"
+      value={[direction]}
+    >
+      <Select.Label 
fontSize="xs">{translate("dag:panel.graphDirection.label")}</Select.Label>
+      <Select.Control>
+        <Select.Trigger>
+          <Select.ValueText />
+        </Select.Trigger>
+        <Select.IndicatorGroup>
+          <Select.Indicator />
+        </Select.IndicatorGroup>
+      </Select.Control>
+      <Select.Positioner>
+        <Select.Content>
+          {directionOptions().items.map((option) => (
+            <Select.Item item={option} key={option.value}>
+              {option.label}
+            </Select.Item>
+          ))}
+        </Select.Content>
+      </Select.Positioner>
+    </Select.Root>
+  );
+};
diff --git a/airflow-core/src/airflow/ui/src/components/Graph/elkGraphUtils.ts 
b/airflow-core/src/airflow/ui/src/components/Graph/elkGraphUtils.ts
index 0ab685cf37e..b307482ba75 100644
--- a/airflow-core/src/airflow/ui/src/components/Graph/elkGraphUtils.ts
+++ b/airflow-core/src/airflow/ui/src/components/Graph/elkGraphUtils.ts
@@ -20,7 +20,7 @@ import type { ElkNode, ElkExtendedEdge, ElkShape } from 
"elkjs";
 
 import type { EdgeResponse, NodeResponse } from "openapi/requests/types.gen";
 
-import type { Direction } from "./useGraphLayout";
+import type { Direction } from "./DirectionDropdown";
 
 // ---------------------------------------------------------------------------
 // Types
diff --git a/airflow-core/src/airflow/ui/src/components/Graph/useGraphLayout.ts 
b/airflow-core/src/airflow/ui/src/components/Graph/useGraphLayout.ts
index 9ffeb0fb673..6d989e4f606 100644
--- a/airflow-core/src/airflow/ui/src/components/Graph/useGraphLayout.ts
+++ b/airflow-core/src/airflow/ui/src/components/Graph/useGraphLayout.ts
@@ -16,7 +16,6 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { createListCollection } from "@chakra-ui/react";
 import { useQuery } from "@tanstack/react-query";
 import ELK, { type ElkNode } from "elkjs";
 // ?raw imports the file content as a plain string without any transformation.
@@ -26,10 +25,10 @@ import ELK, { type ElkNode } from "elkjs";
 // URL-based worker approaches (?worker, ?worker&inline, new URL()) resolve to
 // the Vite origin which browsers reject for Workers.
 import ElkWorkerSource from "elkjs/lib/elk-worker.min.js?raw";
-import type { TFunction } from "i18next";
 
 import type { NodeResponse, StructureDataResponse } from 
"openapi/requests/types.gen";
 
+import type { Direction } from "./DirectionDropdown";
 import { generateElkGraph } from "./elkGraphUtils";
 import { flattenGraph, formatFlowEdges } from "./reactflowUtils";
 
@@ -43,17 +42,6 @@ const elk = new ELK({
   workerFactory: () => new Worker(elkWorkerBlobUrl, { type: "classic" }),
 });
 
-export type Direction = "DOWN" | "LEFT" | "RIGHT" | "UP";
-export const directionOptions = (translate: TFunction) =>
-  createListCollection({
-    items: [
-      { label: translate("graph.directionRight"), value: "RIGHT" as Direction 
},
-      { label: translate("graph.directionLeft"), value: "LEFT" as Direction },
-      { label: translate("graph.directionUp"), value: "UP" as Direction },
-      { label: translate("graph.directionDown"), value: "DOWN" as Direction },
-    ],
-  });
-
 export type LayoutNode = ElkNode & NodeResponse;
 
 type LayoutProps = {
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Graph/Graph.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Details/Graph/Graph.tsx
index 0cfb3e0d8e2..8c2ff657453 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Graph/Graph.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Graph/Graph.tsx
@@ -24,10 +24,11 @@ import { useParams } from "react-router-dom";
 import { useLocalStorage } from "usehooks-ts";
 
 import { useDagRunServiceGetDagRun, useStructureServiceStructureData } from 
"openapi/queries";
+import type { Direction } from "src/components/Graph/DirectionDropdown";
 import { DownloadButton } from "src/components/Graph/DownloadButton";
 import { edgeTypes, nodeTypes } from "src/components/Graph/graphTypes";
 import type { CustomNodeProps } from "src/components/Graph/reactflowUtils";
-import { type Direction, useGraphLayout } from 
"src/components/Graph/useGraphLayout";
+import { useGraphLayout } from "src/components/Graph/useGraphLayout";
 import { dependenciesKey, directionKey } from "src/constants/localStorage";
 import { useColorMode } from "src/context/colorMode";
 import { useGroups } from "src/context/groups";
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx
index 0ea9e142279..cf5e22608a8 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx
@@ -40,12 +40,12 @@ import { useParams } from "react-router-dom";
 import { useLocalStorage } from "usehooks-ts";
 
 import { DagVersionSelect } from "src/components/DagVersionSelect";
-import { directionOptions, type Direction } from 
"src/components/Graph/useGraphLayout";
+import { DirectionDropdown } from "src/components/Graph/DirectionDropdown";
 import { GraphTaskFilters } from "src/components/GraphTaskFilters";
 import { Tooltip } from "src/components/ui";
 import { type ButtonGroupOption, ButtonGroupToggle } from 
"src/components/ui/ButtonGroupToggle";
 import type { DagView } from "src/constants/dagView";
-import { dependenciesKey, directionKey } from "src/constants/localStorage";
+import { dependenciesKey } from "src/constants/localStorage";
 import type { VersionIndicatorOptions } from 
"src/constants/showVersionIndicatorOptions";
 import { useContainerWidth } from "src/utils/useContainerWidth";
 
@@ -116,7 +116,6 @@ export const PanelButtons = ({
     dependenciesKey(dagId),
     "tasks",
   );
-  const [direction, setDirection] = 
useLocalStorage<Direction>(directionKey(dagId), "RIGHT");
   const containerRef = useRef<HTMLDivElement>(null);
   const containerWidth = useContainerWidth(containerRef);
   const handleLimitChange = (event: SelectValueChangeDetails<{ label: string; 
value: Array<string> }>) => {
@@ -146,14 +145,6 @@ export const PanelButtons = ({
     }
   };
 
-  const handleDirectionUpdate = (
-    event: SelectValueChangeDetails<{ label: string; value: Array<string> }>,
-  ) => {
-    if (event.value[0] !== undefined) {
-      setDirection(event.value[0] as Direction);
-    }
-  };
-
   const handleFocus = (view: string) => {
     if (panelGroupRef.current) {
       const newLayout = view === "graph" ? [70, 30] : [30, 70];
@@ -277,34 +268,7 @@ export const PanelButtons = ({
                           </Select.Positioner>
                         </Select.Root>
 
-                        <Select.Root
-                          // @ts-expect-error The expected option type is 
incorrect
-                          collection={directionOptions(translate)}
-                          onValueChange={handleDirectionUpdate}
-                          size="sm"
-                          value={[direction]}
-                        >
-                          <Select.Label fontSize="xs">
-                            {translate("dag:panel.graphDirection.label")}
-                          </Select.Label>
-                          <Select.Control>
-                            <Select.Trigger>
-                              <Select.ValueText />
-                            </Select.Trigger>
-                            <Select.IndicatorGroup>
-                              <Select.Indicator />
-                            </Select.IndicatorGroup>
-                          </Select.Control>
-                          <Select.Positioner>
-                            <Select.Content>
-                              {directionOptions(translate).items.map((option) 
=> (
-                                <Select.Item item={option} key={option.value}>
-                                  {option.label}
-                                </Select.Item>
-                              ))}
-                            </Select.Content>
-                          </Select.Positioner>
-                        </Select.Root>
+                        <DirectionDropdown graphId={dagId} />
                       </>
                     ) : (
                       <>
diff --git a/airflow-core/src/airflow/ui/src/pages/Asset/AssetGraph.tsx 
b/airflow-core/src/airflow/ui/src/pages/Asset/AssetGraph.tsx
index 5251117ccb7..c0f5218e5c6 100644
--- a/airflow-core/src/airflow/ui/src/pages/Asset/AssetGraph.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Asset/AssetGraph.tsx
@@ -20,12 +20,15 @@ import { useToken } from "@chakra-ui/react";
 import { ReactFlow, Controls, Background, MiniMap, type Node as ReactFlowNode 
} from "@xyflow/react";
 import "@xyflow/react/dist/style.css";
 import { useParams } from "react-router-dom";
+import { useLocalStorage } from "usehooks-ts";
 
 import type { AssetResponse } from "openapi/requests/types.gen";
+import type { Direction } from "src/components/Graph/DirectionDropdown";
 import { DownloadButton } from "src/components/Graph/DownloadButton";
 import { edgeTypes, nodeTypes } from "src/components/Graph/graphTypes";
 import type { CustomNodeProps } from "src/components/Graph/reactflowUtils";
 import { useGraphLayout } from "src/components/Graph/useGraphLayout";
+import { directionKey } from "src/constants/localStorage";
 import { useColorMode } from "src/context/colorMode";
 import { useDependencyGraph } from "src/queries/useDependencyGraph";
 import { getReactFlowThemeStyle } from "src/theme";
@@ -45,9 +48,11 @@ export const AssetGraph = ({
     dependencyType,
   });
 
+  const [direction] = useLocalStorage<Direction>(directionKey(assetId ?? ""), 
"RIGHT");
+
   const { data: layoutData } = useGraphLayout({
     ...graphData,
-    direction: "RIGHT",
+    direction,
     openGroupIds: [],
   });
 
diff --git a/airflow-core/src/airflow/ui/src/pages/Asset/AssetPanelButtons.tsx 
b/airflow-core/src/airflow/ui/src/pages/Asset/AssetPanelButtons.tsx
index c8b3178fa2f..c63a1dcafb2 100644
--- a/airflow-core/src/airflow/ui/src/pages/Asset/AssetPanelButtons.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Asset/AssetPanelButtons.tsx
@@ -16,8 +16,12 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { Box, Button, ButtonGroup } from "@chakra-ui/react";
+import { Box, Button, ButtonGroup, Flex, IconButton, Popover, Portal } from 
"@chakra-ui/react";
 import { useTranslation } from "react-i18next";
+import { MdSettings } from "react-icons/md";
+import { useParams } from "react-router-dom";
+
+import { DirectionDropdown } from "src/components/Graph/DirectionDropdown";
 
 type Props = {
   readonly dependencyType: "data" | "scheduling";
@@ -26,27 +30,60 @@ type Props = {
 
 export const AssetPanelButtons = ({ dependencyType, setDependencyType }: 
Props) => {
   const { t: translate } = useTranslation(["assets"]);
+  const { assetId } = useParams();
 
   return (
     <Box borderRadius="md" position="absolute" px={2} py={1} right={2} top={1} 
zIndex={1}>
-      <ButtonGroup attached size="sm" variant="outline">
-        <Button
-          bg={dependencyType === "scheduling" ? "brand.500" : "bg.subtle"}
-          color={dependencyType === "scheduling" ? "white" : "fg.default"}
-          colorPalette="brand"
-          onClick={() => setDependencyType("scheduling")}
-        >
-          {translate("assets:scheduling")}
-        </Button>
-        <Button
-          bg={dependencyType === "data" ? "brand.500" : "bg.subtle"}
-          color={dependencyType === "data" ? "white" : "fg.default"}
-          colorPalette="brand"
-          onClick={() => setDependencyType("data")}
-        >
-          {translate("assets:taskDependencies")}
-        </Button>
-      </ButtonGroup>
+      <Flex justifyContent="space-between">
+        <ButtonGroup attached size="sm" variant="outline">
+          <Button
+            bg={dependencyType === "scheduling" ? "brand.500" : "bg.subtle"}
+            color={dependencyType === "scheduling" ? "white" : "fg.default"}
+            colorPalette="brand"
+            onClick={() => setDependencyType("scheduling")}
+          >
+            {translate("assets:scheduling")}
+          </Button>
+          <Button
+            bg={dependencyType === "data" ? "brand.500" : "bg.subtle"}
+            color={dependencyType === "data" ? "white" : "fg.default"}
+            colorPalette="brand"
+            onClick={() => setDependencyType("data")}
+          >
+            {translate("assets:taskDependencies")}
+          </Button>
+        </ButtonGroup>
+        <Popover.Root positioning={{ placement: "bottom-end" }}>
+          <Popover.Trigger asChild>
+            <IconButton
+              aria-label={translate("dag:panel.buttons.options")}
+              colorPalette="brand"
+              size="md"
+              title={translate("dag:panel.buttons.options")}
+              variant="ghost"
+            >
+              <MdSettings />
+            </IconButton>
+          </Popover.Trigger>
+          <Portal>
+            <Popover.Positioner>
+              <Popover.Content>
+                <Popover.Arrow />
+                <Popover.Body
+                  display="flex"
+                  flexDirection="column"
+                  gap={4}
+                  maxH="70vh"
+                  overflowY="auto"
+                  p={2}
+                >
+                  <DirectionDropdown graphId={assetId ?? ""} />
+                </Popover.Body>
+              </Popover.Content>
+            </Popover.Positioner>
+          </Portal>
+        </Popover.Root>
+      </Flex>
     </Box>
   );
 };
diff --git a/airflow-core/src/airflow/ui/testsSetup.ts 
b/airflow-core/src/airflow/ui/testsSetup.ts
index 1bb26b7d5ba..bbb226c7c91 100644
--- a/airflow-core/src/airflow/ui/testsSetup.ts
+++ b/airflow-core/src/airflow/ui/testsSetup.ts
@@ -28,7 +28,6 @@ import { handlers } from "src/mocks/handlers";
 // happy-dom. Mock useGraphLayout so the Worker is never constructed. Any
 // test that specifically exercises graph layout should override this mock.
 vi.mock("src/components/Graph/useGraphLayout", () => ({
-  directionOptions: () => ({ items: [] }),
   useGraphLayout: vi.fn().mockReturnValue({ data: undefined, isPending: false 
}),
 }));
 

Reply via email to