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}
/>
+ </>
+);