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 c01a68cb1e3 Show secondary states of mapped tasks in Grid view (#74883)
c01a68cb1e3 is described below
commit c01a68cb1e30092e379ddf4459b8d2e1c7f3bc56
Author: Zhen-Lun (Kevin) Hong <[email protected]>
AuthorDate: Fri Oct 23 03:04:40 2026 +0800
Show secondary states of mapped tasks in Grid view (#74883)
* 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
---
.../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®ion_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