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 4db892e6f26 Progressively load interactive elements of Dag Card 
(#69806)
4db892e6f26 is described below

commit 4db892e6f261dfb1c8ec87f48feda6445cab60a9
Author: Shivam Rastogi <[email protected]>
AuthorDate: Thu Jul 23 07:15:17 2026 -0700

    Progressively load interactive elements of Dag Card (#69806)
    
    * Render DAG card content near the viewport
    
    * Preload DAG cards from their scroll root
    
    * Explain DAG card scroll root selection
---
 .../airflow/ui/src/hooks/useNearViewport.test.tsx  | 217 +++++++++++++++++++++
 .../src/airflow/ui/src/hooks/useNearViewport.ts    | 148 ++++++++++++++
 .../airflow/ui/src/pages/DagsList/DagCard.test.tsx | 131 ++++++++++++-
 .../src/airflow/ui/src/pages/DagsList/DagCard.tsx  |  64 +++---
 .../ui/src/pages/DagsList/DagCardActions.tsx       |  39 ++++
 5 files changed, 564 insertions(+), 35 deletions(-)

diff --git a/airflow-core/src/airflow/ui/src/hooks/useNearViewport.test.tsx 
b/airflow-core/src/airflow/ui/src/hooks/useNearViewport.test.tsx
new file mode 100644
index 00000000000..ab72fdebfbf
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/hooks/useNearViewport.test.tsx
@@ -0,0 +1,217 @@
+/*!
+ * 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 { act, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { useNearViewport } from "./useNearViewport";
+
+type MockObserver = {
+  callback: IntersectionObserverCallback;
+} & IntersectionObserver;
+
+const observers: Array<MockObserver> = [];
+
+const createEntry = (target: Element, isIntersecting: boolean): 
IntersectionObserverEntry => ({
+  boundingClientRect: target.getBoundingClientRect(),
+  intersectionRatio: isIntersecting ? 1 : 0,
+  intersectionRect: target.getBoundingClientRect(),
+  isIntersecting,
+  rootBounds: null,
+  target,
+  time: 0,
+});
+
+const takeNoRecords = () => [];
+
+const MockIntersectionObserver = function MockIntersectionObserver(
+  callback: IntersectionObserverCallback,
+  options?: IntersectionObserverInit,
+): IntersectionObserver {
+  const observer = {
+    callback,
+    disconnect: vi.fn(),
+    observe: vi.fn(),
+    root: options?.root ?? null,
+    rootMargin: options?.rootMargin ?? "0px",
+    scrollMargin: options?.scrollMargin ?? "0px",
+    takeRecords: vi.fn(takeNoRecords),
+    thresholds: [0],
+    unobserve: vi.fn(),
+  } satisfies MockObserver;
+
+  observers.push(observer);
+
+  return observer;
+};
+
+const installIntersectionObserver = () => {
+  vi.stubGlobal("IntersectionObserver", MockIntersectionObserver);
+};
+
+const TestCard = ({ name }: { readonly name: string }) => {
+  const { isNearViewport, ref } = useNearViewport<HTMLDivElement>();
+
+  return (
+    <div data-testid={`${name}-shell`} ref={ref}>
+      {name}
+      {isNearViewport ? <span>{`${name} controls`}</span> : <span>{`${name} 
placeholder`}</span>}
+    </div>
+  );
+};
+
+afterEach(() => {
+  observers.length = 0;
+  vi.unstubAllGlobals();
+});
+
+describe("useNearViewport", () => {
+  it("mounts deferred content when its shell approaches the viewport and keeps 
it mounted", () => {
+    installIntersectionObserver();
+    render(<TestCard name="first" />);
+
+    expect(screen.getByTestId("first-shell")).toBeInTheDocument();
+    expect(screen.getByText("first placeholder")).toBeInTheDocument();
+    expect(screen.queryByText("first controls")).not.toBeInTheDocument();
+    expect(observers).toHaveLength(1);
+    expect(observers[0]?.rootMargin).toBe("600px 0px");
+    expect(observers[0]?.scrollMargin).toBe("0px");
+
+    const [observer] = observers;
+    const shell = screen.getByTestId("first-shell");
+
+    if (observer === undefined) {
+      throw new Error("Expected an IntersectionObserver");
+    }
+
+    act(() => {
+      observer.callback([createEntry(shell, true)], observer);
+    });
+
+    expect(screen.getByText("first controls")).toBeInTheDocument();
+    expect(screen.queryByText("first placeholder")).not.toBeInTheDocument();
+    expect(observer.unobserve).toHaveBeenCalledWith(shell);
+
+    act(() => {
+      observer.callback([createEntry(shell, false)], observer);
+    });
+
+    expect(screen.getByText("first controls")).toBeInTheDocument();
+  });
+
+  it("shares one observer across multiple shells", () => {
+    installIntersectionObserver();
+    render(
+      <>
+        <TestCard name="first" />
+        <TestCard name="second" />
+      </>,
+    );
+
+    expect(observers).toHaveLength(1);
+    expect(observers[0]?.observe).toHaveBeenCalledTimes(2);
+
+    const [observer] = observers;
+    const secondShell = screen.getByTestId("second-shell");
+
+    if (observer === undefined) {
+      throw new Error("Expected an IntersectionObserver");
+    }
+
+    act(() => {
+      observer.callback([createEntry(secondShell, true)], observer);
+    });
+
+    expect(screen.getByText("first placeholder")).toBeInTheDocument();
+    expect(screen.getByText("second controls")).toBeInTheDocument();
+  });
+
+  it("uses the nearest scroll container as the preload root", () => {
+    installIntersectionObserver();
+    render(
+      <div
+        data-testid="scroll-root"
+        ref={(element) => {
+          if (element !== null) {
+            Object.defineProperties(element, {
+              clientHeight: { configurable: true, value: 100 },
+              scrollHeight: { configurable: true, value: 1000 },
+            });
+          }
+        }}
+        style={{ overflowY: "auto" }}
+      >
+        <TestCard name="first" />
+      </div>,
+    );
+
+    expect(observers).toHaveLength(1);
+    expect(observers[0]?.root).toBe(screen.getByTestId("scroll-root"));
+    expect(observers[0]?.rootMargin).toBe("600px 0px");
+  });
+
+  it("skips an overflow wrapper that does not actually scroll", () => {
+    installIntersectionObserver();
+    render(
+      <div
+        data-testid="scroll-root"
+        ref={(element) => {
+          if (element !== null) {
+            Object.defineProperties(element, {
+              clientHeight: { configurable: true, value: 100 },
+              scrollHeight: { configurable: true, value: 1000 },
+            });
+          }
+        }}
+        style={{ overflowY: "auto" }}
+      >
+        <div data-testid="overflow-wrapper" style={{ overflowY: "auto" }}>
+          <TestCard name="first" />
+        </div>
+      </div>,
+    );
+
+    expect(observers).toHaveLength(1);
+    expect(observers[0]?.root).toBe(screen.getByTestId("scroll-root"));
+    
expect(observers[0]?.root).not.toBe(screen.getByTestId("overflow-wrapper"));
+  });
+
+  it("unobserves a pending shell and releases the shared observer on unmount", 
() => {
+    installIntersectionObserver();
+    const { unmount } = render(<TestCard name="first" />);
+
+    const [observer] = observers;
+    const shell = screen.getByTestId("first-shell");
+
+    if (observer === undefined) {
+      throw new Error("Expected an IntersectionObserver");
+    }
+
+    unmount();
+
+    expect(observer.unobserve).toHaveBeenCalledWith(shell);
+    expect(observer.disconnect).toHaveBeenCalledOnce();
+  });
+
+  it("mounts content when IntersectionObserver is unavailable", async () => {
+    vi.stubGlobal("IntersectionObserver", undefined);
+    render(<TestCard name="first" />);
+
+    expect(await screen.findByText("first controls")).toBeInTheDocument();
+  });
+});
diff --git a/airflow-core/src/airflow/ui/src/hooks/useNearViewport.ts 
b/airflow-core/src/airflow/ui/src/hooks/useNearViewport.ts
new file mode 100644
index 00000000000..64785c6a1dd
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/hooks/useNearViewport.ts
@@ -0,0 +1,148 @@
+/*!
+ * 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 { startTransition, useCallback, useEffect, useRef, useState, type 
RefObject } from "react";
+
+const NEAR_VIEWPORT_MARGIN = "600px 0px";
+
+type ObserverState = {
+  readonly listeners: Map<Element, () => void>;
+  readonly observer: IntersectionObserver;
+};
+
+const observerStates = new Map<Element | null, ObserverState>();
+
+const getScrollRoot = (element: Element): Element | null => {
+  let ancestor = element.parentElement;
+  let overflowAncestor: Element | null = null;
+
+  // Use the nearest ancestor that actually scrolls, falling back to the 
nearest
+  // overflow container when its content currently fits without scrolling.
+  while (ancestor !== null) {
+    const { overflowY } = globalThis.getComputedStyle(ancestor);
+
+    if (/^(?:auto|overlay|scroll)$/u.test(overflowY)) {
+      overflowAncestor ??= ancestor;
+
+      if (ancestor.scrollHeight > ancestor.clientHeight) {
+        return ancestor;
+      }
+    }
+
+    ancestor = ancestor.parentElement;
+  }
+
+  return overflowAncestor;
+};
+
+const releaseObserverWhenIdle = (root: Element | null, state: ObserverState) 
=> {
+  if (state.listeners.size === 0) {
+    state.observer.disconnect();
+
+    if (observerStates.get(root) === state) {
+      observerStates.delete(root);
+    }
+  }
+};
+
+const getSharedObserver = (observerConstructor: typeof IntersectionObserver, 
root: Element | null) => {
+  const currentState = observerStates.get(root);
+
+  if (currentState !== undefined) {
+    return currentState;
+  }
+
+  const listeners = new Map<Element, () => void>();
+  const observer = new observerConstructor(
+    (entries) => {
+      entries.forEach((entry) => {
+        if (!entry.isIntersecting) {
+          return;
+        }
+
+        const listener = listeners.get(entry.target);
+
+        if (listener !== undefined) {
+          listeners.delete(entry.target);
+          observer.unobserve(entry.target);
+          listener();
+        }
+      });
+      const observerState = observerStates.get(root);
+
+      if (observerState !== undefined) {
+        releaseObserverWhenIdle(root, observerState);
+      }
+    },
+    { root, rootMargin: NEAR_VIEWPORT_MARGIN },
+  );
+
+  const state = { listeners, observer };
+
+  observerStates.set(root, state);
+
+  return state;
+};
+
+const observeNearViewport = (element: Element, listener: () => void) => {
+  const observerConstructor = (globalThis as { IntersectionObserver?: typeof 
IntersectionObserver })
+    .IntersectionObserver;
+
+  // If observation is unavailable, we immediately call the listener.
+  if (observerConstructor === undefined) {
+    listener();
+
+    return undefined;
+  }
+
+  const root = getScrollRoot(element);
+  const state = getSharedObserver(observerConstructor, root);
+
+  state.listeners.set(element, listener);
+  state.observer.observe(element);
+
+  return () => {
+    state.listeners.delete(element);
+    state.observer.unobserve(element);
+    releaseObserverWhenIdle(root, state);
+  };
+};
+
+export const useNearViewport = <TElement extends Element>(): {
+  readonly isNearViewport: boolean;
+  readonly ref: RefObject<TElement | null>;
+  readonly showContent: () => void;
+} => {
+  const ref = useRef<TElement>(null);
+  const [isNearViewport, setIsNearViewport] = useState(false);
+  const showContent = useCallback(() => setIsNearViewport(true), []);
+
+  useEffect(() => {
+    const element = ref.current;
+
+    if (isNearViewport || element === null) {
+      return undefined;
+    }
+
+    return observeNearViewport(element, () => {
+      startTransition(showContent);
+    });
+  }, [isNearViewport, showContent]);
+
+  return { isNearViewport, ref, showContent };
+};
diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.test.tsx 
b/airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.test.tsx
index 95a35080aed..04064406576 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.test.tsx
@@ -53,14 +53,85 @@ const GMTWrapper = ({ children }: PropsWithChildren) => (
   </BaseWrapper>
 );
 
+const takeNoObserverRecords = () => [];
+
+type MockDagCardObserver = {
+  callback: IntersectionObserverCallback;
+} & IntersectionObserver;
+
+const dagCardObservers: Array<MockDagCardObserver> = [];
+
+const MockDagCardIntersectionObserver = function 
MockDagCardIntersectionObserver(
+  nextCallback: IntersectionObserverCallback,
+  options?: IntersectionObserverInit,
+): IntersectionObserver {
+  const observer = {
+    callback: nextCallback,
+    disconnect: vi.fn(),
+    observe: vi.fn(),
+    root: options?.root ?? null,
+    rootMargin: options?.rootMargin ?? "0px",
+    scrollMargin: options?.scrollMargin ?? "0px",
+    takeRecords: vi.fn(takeNoObserverRecords),
+    thresholds: [0],
+    unobserve: vi.fn(),
+  } satisfies MockDagCardObserver;
+
+  dagCardObservers.push(observer);
+
+  return observer;
+};
+
+type CardContentMode = "fallback" | "hydrated" | "pending";
+
 // Render with the run-state-counts row in its loading state so it renders
 // skeletons rather than StateBadges. Without this, every card would emit
 // 4 extra "state-badge" testids and break getByTestId assertions in tests
 // that target the latest-run badge.
-const renderCard = (dag: DAGWithLatestDagRunsResponse) =>
-  render(<DagCard dag={dag} runStateCounts={undefined} runStateCountsLoading 
stateCountLimit={undefined} />, {
-    wrapper: GMTWrapper,
-  });
+const renderCard = (dag: DAGWithLatestDagRunsResponse, contentMode: 
CardContentMode = "hydrated") => {
+  dagCardObservers.length = 0;
+
+  if (contentMode === "fallback") {
+    vi.stubGlobal("IntersectionObserver", undefined);
+  } else {
+    vi.stubGlobal("IntersectionObserver", MockDagCardIntersectionObserver);
+  }
+
+  const result = render(
+    <DagCard dag={dag} runStateCounts={undefined} runStateCountsLoading 
stateCountLimit={undefined} />,
+    {
+      wrapper: GMTWrapper,
+    },
+  );
+
+  if (contentMode === "hydrated") {
+    const [observer] = dagCardObservers;
+    const card = screen.getByTestId("dag-card");
+
+    if (observer === undefined) {
+      throw new Error("Expected the Dag card to register an 
IntersectionObserver");
+    }
+
+    act(() => {
+      observer.callback(
+        [
+          {
+            boundingClientRect: card.getBoundingClientRect(),
+            intersectionRatio: 1,
+            intersectionRect: card.getBoundingClientRect(),
+            isIntersecting: true,
+            rootBounds: null,
+            target: card,
+            time: 0,
+          },
+        ],
+        observer,
+      );
+    });
+  }
+
+  return result;
+};
 
 const mockDag = {
   allowed_run_types: null,
@@ -141,9 +212,61 @@ beforeAll(async () => {
 
 afterEach(() => {
   vi.restoreAllMocks();
+  vi.unstubAllGlobals();
 });
 
 describe("DagCard", () => {
+  it("renders its shell before mounting near-viewport controls", () => {
+    dagCardObservers.length = 0;
+    vi.stubGlobal("IntersectionObserver", MockDagCardIntersectionObserver);
+    renderCard(mockDag, "pending");
+
+    const card = screen.getByTestId("dag-card");
+
+    expect(screen.getByTestId("dag-id")).toBeInTheDocument();
+    
expect(screen.getByTestId("schedule")).toHaveTextContent(mockDag.timetable_summary);
+    expect(screen.queryByTestId("toggle-pause")).not.toBeInTheDocument();
+    expect(screen.queryByTestId("recent-run")).not.toBeInTheDocument();
+
+    const [observer] = dagCardObservers;
+
+    if (observer === undefined) {
+      throw new Error("Expected the Dag card to register an 
IntersectionObserver");
+    }
+
+    act(() => {
+      observer.callback(
+        [
+          {
+            boundingClientRect: card.getBoundingClientRect(),
+            intersectionRatio: 1,
+            intersectionRect: card.getBoundingClientRect(),
+            isIntersecting: true,
+            rootBounds: null,
+            target: card,
+            time: 0,
+          },
+        ],
+        observer,
+      );
+    });
+
+    expect(screen.getByTestId("toggle-pause")).toBeInTheDocument();
+    
expect(screen.getAllByTestId("recent-run")).toHaveLength(mockDag.latest_dag_runs.length);
+  });
+
+  it("mounts deferred controls when keyboard focus enters the card", () => {
+    dagCardObservers.length = 0;
+    vi.stubGlobal("IntersectionObserver", MockDagCardIntersectionObserver);
+    renderCard(mockDag, "pending");
+
+    expect(screen.queryByTestId("toggle-pause")).not.toBeInTheDocument();
+
+    fireEvent.focus(screen.getByTestId("dag-id"));
+
+    expect(screen.getByTestId("toggle-pause")).toBeInTheDocument();
+  });
+
   it("DagCard should render without tags", () => {
     renderCard(mockDag);
     expect(screen.getByText(mockDag.dag_display_name)).toBeInTheDocument();
diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.tsx 
b/airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.tsx
index 1d77fb6660a..188b484fe8c 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.tsx
@@ -16,21 +16,18 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { Box, Flex, Grid, GridItem, HStack, Spinner } from "@chakra-ui/react";
+import { Box, Flex, Grid, GridItem, HStack, Spinner, Text } from 
"@chakra-ui/react";
 import { useTranslation } from "react-i18next";
 
 import type { DAGWithLatestDagRunsResponse } from "openapi/requests/types.gen";
-import { DeleteDagButton } from "src/components/DagActions/DeleteDagButton";
-import { FavoriteDagButton } from 
"src/components/DagActions/FavoriteDagButton";
 import DagRunInfo from "src/components/DagRunInfo";
-import { NeedsReviewBadge } from "src/components/NeedsReviewBadge";
 import { Stat } from "src/components/Stat";
-import { TogglePause } from "src/components/TogglePause";
-import { TriggerDAGButton } from "src/components/TriggerDag/TriggerDAGButton";
 import { RouterLink, Tooltip } from "src/components/ui";
+import { useNearViewport } from "src/hooks/useNearViewport";
 import { useConfig } from "src/queries/useConfig";
 import { isStatePending, useAutoRefresh } from "src/utils";
 
+import { DagCardActions } from "./DagCardActions";
 import { DagRunStateCounts } from "./DagRunStateCounts";
 import { DagTags } from "./DagTags";
 import { RecentRuns } from "./RecentRuns";
@@ -47,6 +44,7 @@ export const DagCard = ({ dag, runStateCounts, 
runStateCountsLoading, stateCount
   const { t: translate } = useTranslation(["common", "dag"]);
   const [latestRun] = dag.latest_dag_runs;
   const multiTeamEnabled = Boolean(useConfig("multi_team"));
+  const { isNearViewport, ref, showContent } = 
useNearViewport<HTMLDivElement>();
 
   const refetchInterval = useAutoRefresh({});
 
@@ -56,7 +54,10 @@ export const DagCard = ({ dag, runStateCounts, 
runStateCountsLoading, stateCount
       borderRadius={8}
       borderWidth={1}
       data-testid="dag-card"
+      minHeight="140px"
+      onFocusCapture={showContent}
       overflow="hidden"
+      ref={ref}
     >
       <Flex alignItems="center" bg="bg.muted" justifyContent="space-between" 
px={3} py={1}>
         <HStack>
@@ -67,17 +68,8 @@ export const DagCard = ({ dag, runStateCounts, 
runStateCountsLoading, stateCount
           </Tooltip>
           <DagTags tags={dag.tags} />
         </HStack>
-        <HStack gap={1}>
-          <NeedsReviewBadge pendingActions={dag.pending_actions} />
-          <TogglePause dagDisplayName={dag.dag_display_name} 
dagId={dag.dag_id} isPaused={dag.is_paused} />
-          <TriggerDAGButton
-            allowedRunTypes={dag.allowed_run_types}
-            dagDisplayName={dag.dag_display_name}
-            dagId={dag.dag_id}
-            isPaused={dag.is_paused}
-          />
-          <FavoriteDagButton dagId={dag.dag_id} isFavorite={dag.is_favorite} />
-          <DeleteDagButton dagDisplayName={dag.dag_display_name} 
dagId={dag.dag_id} />
+        <HStack data-testid="dag-card-actions" gap={1} minHeight="32px">
+          {isNearViewport ? <DagCardActions dag={dag} /> : undefined}
         </HStack>
       </Flex>
       <Grid
@@ -89,13 +81,17 @@ export const DagCard = ({ dag, runStateCounts, 
runStateCountsLoading, stateCount
       >
         <GridItem gridColumn={1} gridRow={1}>
           <Stat data-testid="schedule" 
label={translate("dagDetails.schedule")}>
-            <Schedule
-              assetExpression={dag.asset_expression}
-              dagId={dag.dag_id}
-              timetableDescription={dag.timetable_description}
-              timetablePartitioned={dag.timetable_partitioned}
-              timetableSummary={dag.timetable_summary}
-            />
+            {isNearViewport ? (
+              <Schedule
+                assetExpression={dag.asset_expression}
+                dagId={dag.dag_id}
+                timetableDescription={dag.timetable_description}
+                timetablePartitioned={dag.timetable_partitioned}
+                timetableSummary={dag.timetable_summary}
+              />
+            ) : (
+              <Text fontSize="sm">{dag.timetable_summary}</Text>
+            )}
           </Stat>
         </GridItem>
         <GridItem gridColumn={2} gridRow={1}>
@@ -144,15 +140,21 @@ export const DagCard = ({ dag, runStateCounts, 
runStateCountsLoading, stateCount
           gridRow="1 / 3"
           justifyContent="flex-end"
         >
-          <RecentRuns latestRuns={dag.latest_dag_runs} />
+          <Box minHeight="65px">
+            {isNearViewport ? <RecentRuns latestRuns={dag.latest_dag_runs} /> 
: undefined}
+          </Box>
         </GridItem>
         <GridItem alignSelf="end" gridColumn={1} gridRow={2}>
-          <DagRunStateCounts
-            counts={runStateCounts}
-            dagId={dag.dag_id}
-            isLoading={runStateCountsLoading}
-            stateCountLimit={stateCountLimit}
-          />
+          <Box minHeight="22px">
+            {isNearViewport ? (
+              <DagRunStateCounts
+                counts={runStateCounts}
+                dagId={dag.dag_id}
+                isLoading={runStateCountsLoading}
+                stateCountLimit={stateCountLimit}
+              />
+            ) : undefined}
+          </Box>
         </GridItem>
       </Grid>
     </Box>
diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/DagCardActions.tsx 
b/airflow-core/src/airflow/ui/src/pages/DagsList/DagCardActions.tsx
new file mode 100644
index 00000000000..95aa1f2c199
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagCardActions.tsx
@@ -0,0 +1,39 @@
+/*!
+ * 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 type { DAGWithLatestDagRunsResponse } from "openapi/requests/types.gen";
+import { DeleteDagButton } from "src/components/DagActions/DeleteDagButton";
+import { FavoriteDagButton } from 
"src/components/DagActions/FavoriteDagButton";
+import { NeedsReviewBadge } from "src/components/NeedsReviewBadge";
+import { TogglePause } from "src/components/TogglePause";
+import { TriggerDAGButton } from "src/components/TriggerDag/TriggerDAGButton";
+
+export const DagCardActions = ({ dag }: { readonly dag: 
DAGWithLatestDagRunsResponse }) => (
+  <>
+    <NeedsReviewBadge pendingActions={dag.pending_actions} />
+    <TogglePause dagDisplayName={dag.dag_display_name} dagId={dag.dag_id} 
isPaused={dag.is_paused} />
+    <TriggerDAGButton
+      allowedRunTypes={dag.allowed_run_types}
+      dagDisplayName={dag.dag_display_name}
+      dagId={dag.dag_id}
+      isPaused={dag.is_paused}
+    />
+    <FavoriteDagButton dagId={dag.dag_id} isFavorite={dag.is_favorite} />
+    <DeleteDagButton dagDisplayName={dag.dag_display_name} dagId={dag.dag_id} 
/>
+  </>
+);

Reply via email to