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

bbovenzi pushed a commit to branch v3-4-test
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/v3-4-test by this push:
     new 2024aace3bc [v3-4-test] Show secondary states of mapped tasks in Grid 
view (#74883) (#75426)
2024aace3bc is described below

commit 2024aace3bc5c57c825f29351665dabba577353d
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Thu Oct 22 17:05:07 2026 -0400

    [v3-4-test] Show secondary states of mapped tasks in Grid view (#74883) 
(#75426)
    
    * Show mixed states for mapped tasks in the Grid view
    
    * Clarify the intent of Grid task cell tests
    
    * Show mapped task secondary state as a stacked card in the Grid view
    
    * Show every mapped task as a stacked card in the Grid view
    (cherry picked from commit c01a68cb1e30092e379ddf4459b8d2e1c7f3bc56)
    
    Co-authored-by: Zhen-Lun (Kevin) Hong <[email protected]>
---
 .../ui/src/layouts/Details/Grid/GridTI.test.tsx    | 92 +++++++++++++++++++++-
 .../airflow/ui/src/layouts/Details/Grid/GridTI.tsx | 46 ++++++++---
 .../ui/src/layouts/Details/Grid/constants.ts       |  2 +-
 3 files changed, 125 insertions(+), 15 deletions(-)

diff --git 
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridTI.test.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridTI.test.tsx
index 87104d168f3..ad9a51bcd96 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridTI.test.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridTI.test.tsx
@@ -27,7 +27,7 @@ import { TimezoneProvider } from "src/context/timezone";
 import { BaseWrapper } from "src/utils/Wrapper";
 
 import { GridTI } from "./GridTI";
-import { SELECTED_TASK_OUTLINE_COLOR } from "./constants";
+import { NOTE_GRADIENT, SELECTED_TASK_OUTLINE_COLOR } from "./constants";
 
 const colorModeMock = vi.fn<() => { colorMode: "dark" | "light" | undefined 
}>();
 
@@ -47,15 +47,26 @@ const taskInstance: LightGridTaskInstanceSummary = {
 
 const SELECTED_RUN_ID = "manual__2026-04-21T00:00:00+00:00";
 
+const buildStackShadow = (backCardState: string) =>
+  `0.5px 0.5px 0 0 var(--chakra-colors-bg), 2.5px 2.5px 0 0 
var(--chakra-colors-${backCardState}-solid)`;
+
 type RenderGridTIOptions = {
+  readonly hasNote?: boolean;
   readonly instance?: LightGridTaskInstanceSummary;
+  readonly isMapped?: boolean;
   readonly runId?: string;
   readonly taskId?: string;
 };
 
 const renderGridTI = (
   route: string,
-  { instance = taskInstance, runId = SELECTED_RUN_ID, taskId = "selected_task" 
}: RenderGridTIOptions = {},
+  {
+    hasNote,
+    instance = taskInstance,
+    isMapped,
+    runId = SELECTED_RUN_ID,
+    taskId = "selected_task",
+  }: RenderGridTIOptions = {},
 ) =>
   render(
     <BaseWrapper>
@@ -66,7 +77,9 @@ const renderGridTI = (
               element={
                 <GridTI
                   dagId="example_dag"
+                  hasNote={hasNote}
                   instance={{ ...instance, task_id: taskId }}
+                  isMapped={isMapped}
                   label={taskId}
                   runId={runId}
                   taskId={taskId}
@@ -81,6 +94,81 @@ const renderGridTI = (
   );
 
 describe("GridTI", () => {
+  it.each<{
+    childStates: Record<string, number>;
+    primaryState: LightGridTaskInstanceSummary["state"];
+    secondaryState: string;
+  }>([
+    { childStates: { skipped: 2, success: 1 }, primaryState: "success", 
secondaryState: "skipped" },
+    {
+      childStates: { failed: 1, skipped: 3, success: 2 },
+      primaryState: "failed",
+      secondaryState: "success",
+    },
+    {
+      childStates: { running: 1, scheduled: 2, success: 3 },
+      primaryState: "running",
+      secondaryState: "scheduled",
+    },
+    { childStates: { none: 2, success: 1 }, primaryState: null, 
secondaryState: "success" },
+  ])(
+    "shows the highest-priority secondary state when mapped instances have 
different states",
+    ({ childStates, primaryState, secondaryState }) => {
+      
renderGridTI(`/dags/example_dag/runs/${SELECTED_RUN_ID}/tasks/selected_task`, {
+        instance: { ...taskInstance, child_states: childStates, state: 
primaryState },
+        isMapped: true,
+      });
+
+      
expect(screen.getByTestId("task-state-badge").style.boxShadow).toBe(buildStackShadow(secondaryState));
+    },
+  );
+
+  it.each<{
+    childStates: Record<string, number> | null;
+    primaryState: LightGridTaskInstanceSummary["state"];
+  }>([
+    { childStates: null, primaryState: "success" },
+    { childStates: {}, primaryState: "success" },
+    { childStates: { success: 3 }, primaryState: "success" },
+    { childStates: { skipped: 0, success: 3 }, primaryState: "success" },
+    { childStates: { none: 2 }, primaryState: null },
+  ])(
+    "colors the back card with the primary state when mapped instances share 
one state",
+    ({ childStates, primaryState }) => {
+      
renderGridTI(`/dags/example_dag/runs/${SELECTED_RUN_ID}/tasks/selected_task`, {
+        instance: { ...taskInstance, child_states: childStates, state: 
primaryState },
+        isMapped: true,
+      });
+
+      expect(screen.getByTestId("task-state-badge").style.boxShadow).toBe(
+        buildStackShadow(primaryState ?? "none"),
+      );
+    },
+  );
+
+  it("shows a single card for unmapped tasks", () => {
+    
renderGridTI(`/dags/example_dag/runs/${SELECTED_RUN_ID}/tasks/selected_task`, {
+      instance: { ...taskInstance, child_states: { skipped: 2, success: 1 } },
+      isMapped: false,
+    });
+
+    expect(screen.getByTestId("task-state-badge").style.boxShadow).toBe("");
+  });
+
+  it.each([true, false])("preserves note markers and selection highlighting on 
task cells", (isMapped) => {
+    
renderGridTI(`/dags/example_dag/runs/${SELECTED_RUN_ID}/tasks/selected_task`, {
+      hasNote: true,
+      instance: { ...taskInstance, child_states: { skipped: 2, success: 1 } },
+      isMapped,
+    });
+
+    
expect(screen.getByTestId("task-state-badge")).toHaveAttribute("data-selected", 
"true");
+    
expect(screen.getByTestId("task-state-badge").style.backgroundImage).toBe(NOTE_GRADIENT);
+    expect(screen.getByTestId("task-state-badge").style.boxShadow).toBe(
+      isMapped ? buildStackShadow("skipped") : "",
+    );
+  });
+
   it("drops another execution's exact region when following an aggregate 
cell", () => {
     renderGridTI(
       
`/dags/example_dag/runs/${SELECTED_RUN_ID}/tasks/selected_task?region_id=11111111-1111-4111-8111-111111111111&region_index=3&try_number=2&log_level=error`,
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridTI.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridTI.tsx
index 5fc44cdb436..5bc78375e0e 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridTI.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridTI.tsx
@@ -27,8 +27,14 @@ import TaskInstanceTooltip from 
"src/components/TaskInstanceTooltip";
 import { useColorMode } from "src/context/colorMode";
 import { setExecutionCoordinates, stripExecutionParams } from 
"src/utils/executionParams";
 import { buildTaskInstanceUrl } from "src/utils/links";
+import { sortStateEntries } from "src/utils/stateUtils";
 
-import { NOTE_GRADIENT, SELECTED_TASK_OUTLINE_COLOR } from "./constants";
+import { NOTE_GRADIENT, SELECTED_TASK_OUTLINE_COLOR, TASK_BAR_HEIGHT_PX } from 
"./constants";
+
+const BACK_CARD_OFFSET_PX = 2.5;
+const STACK_GAP_SHADOW = "0.5px 0.5px 0 0 var(--chakra-colors-bg)";
+const buildBackCardShadow = (state: string) =>
+  `${STACK_GAP_SHADOW}, ${BACK_CARD_OFFSET_PX}px ${BACK_CARD_OFFSET_PX}px 0 0 
var(--chakra-colors-${state}-solid)`;
 
 type Props = {
   readonly dagId: string;
@@ -81,6 +87,13 @@ export const GridTI = ({
   const isSelectedRow = selectedTaskId === taskId || selectedGroupId === 
taskId;
   const isSelectedTaskInstance = selectedRunId === runId && isSelectedRow;
   const selectedOutlineColor = SELECTED_TASK_OUTLINE_COLOR[colorMode];
+  const frontCardState = instance.state ?? "none";
+  const backCardState = isMapped
+    ? (sortStateEntries(instance.child_states).find(([state]) => state !== 
frontCardState)?.[0] ??
+      frontCardState)
+    : undefined;
+  const badgeSize = `${isMapped ? TASK_BAR_HEIGHT_PX - 1 : 
TASK_BAR_HEIGHT_PX}px`;
+  const stackPadding = isMapped ? `${BACK_CARD_OFFSET_PX}px` : undefined;
 
   return (
     <Flex
@@ -94,9 +107,9 @@ export const GridTI = ({
       justifyContent="center"
       key={taskId}
       position="relative"
-      px="2px"
       py={0}
       transition="background-color 0.2s"
+      width="18px"
     >
       <TaskInstanceTooltip
         loopMaxIterations={loopMaxIterations}
@@ -105,7 +118,18 @@ export const GridTI = ({
         runId={runId}
         taskInstance={instance}
       >
-        <Box as="span" display="inline-block">
+        <Box
+          as="span"
+          borderRadius={4}
+          display="flex"
+          outlineColor={isSelectedTaskInstance ? selectedOutlineColor : 
undefined}
+          outlineOffset={isMapped ? "0px" : "1px"}
+          outlineStyle={isSelectedTaskInstance ? "solid" : undefined}
+          outlineWidth={isSelectedTaskInstance ? "2px" : undefined}
+          pb={stackPadding}
+          pr={stackPadding}
+          transition="outline-color 0.2s"
+        >
           <Link
             data-testid={`grid-${runId}-${taskId}`}
             id={`grid-${runId}-${taskId}`}
@@ -119,22 +143,20 @@ export const GridTI = ({
             <Badge
               alignItems="center"
               borderRadius={4}
-              colorPalette={instance.state ?? "none"}
+              colorPalette={frontCardState}
               data-selected={isSelectedTaskInstance || undefined}
               data-testid="task-state-badge"
               display="flex"
-              height="14px"
+              height={badgeSize}
               justifyContent="center"
               minH={0}
-              outlineColor={isSelectedTaskInstance ? selectedOutlineColor : 
undefined}
-              outlineOffset="1px"
-              outlineStyle={isSelectedTaskInstance ? "solid" : undefined}
-              outlineWidth={isSelectedTaskInstance ? "2px" : undefined}
               p={0}
-              style={hasNote ? { background: NOTE_GRADIENT } : undefined}
-              transition="outline-color 0.2s"
+              style={{
+                background: hasNote ? NOTE_GRADIENT : undefined,
+                boxShadow: backCardState === undefined ? undefined : 
buildBackCardShadow(backCardState),
+              }}
               variant="solid"
-              width="14px"
+              width={badgeSize}
             >
               <StateIcon size={10} state={instance.state} />
             </Badge>
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/constants.ts 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/constants.ts
index 1cc4d00019b..3bc66094b6c 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/constants.ts
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/constants.ts
@@ -19,7 +19,7 @@
 
 // Grid layout constants - shared between Grid and Gantt for alignment
 export const ROW_HEIGHT = 20;
-/** Height of a task bar / badge within a row — matches the GridTI badge 
height. */
+/** Height of a task bar / badge within a row — matches the unmapped GridTI 
badge height. */
 export const TASK_BAR_HEIGHT_PX = 14;
 export const GRID_HEADER_PADDING_PX = 16; // pt={4} = 4 * 4 = 16px
 export const GRID_HEADER_HEIGHT_PX = 100; // height="100px" for duration bars

Reply via email to