Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions airflow-core/newsfragments/68760.significant.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
Custom graph colors return, using Chakra palette tokens

The ``ui_color`` and ``ui_fgcolor`` attributes on operators and TaskGroups color the graph view
again (the node fill from ``ui_color`` and the operator label from ``ui_fgcolor``), as they did in
Airflow 2. They were silently ignored since Airflow 3.0.

**What changed:**

- The value must now be a Chakra palette token such as ``blue.500`` (including tokens added through
custom UI theming). Tokens resolve through theme-controlled CSS variables, so the color stays
legible in both light and dark mode.
- Legacy values that cannot adapt to the theme (raw hex like ``#fff``, CSS names like
``CornflowerBlue``, or a bare family like ``blue``) are ignored by the graph.
- Setting a non-token ``ui_color``/``ui_fgcolor`` on a user-authored operator or TaskGroup emits a
warning at Dag parse time. Built-in operators and providers do not warn.

The node border keeps reflecting the task-instance run state; the custom color is applied to the
fill and label only.
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ class NodeResponse(BaseNodeResponse):
setup_teardown_type: Literal["setup", "teardown"] | None = None
operator: str | None = None
asset_condition_type: Literal["or-gate", "and-gate"] | None = None
ui_color: str | None = None
ui_fgcolor: str | None = None


class StructureDataResponse(BaseGraphResponse[EdgeResponse, NodeResponse]):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3319,6 +3319,16 @@ components:
- and-gate
- type: 'null'
title: Asset Condition Type
ui_color:
anyOf:
- type: string
- type: 'null'
title: Ui Color
ui_fgcolor:
anyOf:
- type: string
- type: 'null'
title: Ui Fgcolor
type: object
required:
- id
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from operator import methodcaller

from airflow.configuration import conf
from airflow.sdk.definitions._ui_color import is_chakra_color_token
from airflow.serialization.definitions.baseoperator import SerializedBaseOperator
from airflow.serialization.definitions.mappedoperator import SerializedMappedOperator, is_mapped

Expand All @@ -37,6 +38,20 @@ def get_task_group_children_getter() -> Callable:
return methodcaller("hierarchical_alphabetical_sort")


def _ui_colors(node) -> dict[str, str]:
"""
Return the node's ``ui_color``/``ui_fgcolor`` keys, keeping only valid Chakra color tokens.

Legacy values (raw hex, CSS names, the unset defaults) are dropped so the graph only ever
receives colors the UI can resolve against the theme.
"""
return {
key: value
for key in ("ui_color", "ui_fgcolor")
if is_chakra_color_token(value := getattr(node, key, None))
}


def task_group_to_dict(task_item_or_group, parent_group_is_mapped=False):
"""Create a nested dict representation of this TaskGroup and its children used to construct the Graph."""
if isinstance(task := task_item_or_group, (SerializedBaseOperator, SerializedMappedOperator)):
Expand All @@ -47,6 +62,7 @@ def task_group_to_dict(task_item_or_group, parent_group_is_mapped=False):
"label": task_display_name,
"operator": task.operator_name,
"type": "task",
**_ui_colors(task),
}
if task.is_setup:
node_operator["setup_teardown_type"] = "setup"
Expand Down Expand Up @@ -78,6 +94,7 @@ def task_group_to_dict(task_item_or_group, parent_group_is_mapped=False):
"is_mapped": mapped,
"children": children,
"type": "task",
**_ui_colors(task_group),
}
return node

Expand Down
22 changes: 22 additions & 0 deletions airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9849,6 +9849,28 @@ export const $NodeResponse = {
}
],
title: 'Asset Condition Type'
},
ui_color: {
anyOf: [
{
type: 'string'
},
{
type: 'null'
}
],
title: 'Ui Color'
},
ui_fgcolor: {
anyOf: [
{
type: 'string'
},
{
type: 'null'
}
],
title: 'Ui Fgcolor'
}
},
type: 'object',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2477,6 +2477,8 @@ export type NodeResponse = {
setup_teardown_type?: 'setup' | 'teardown' | null;
operator?: string | null;
asset_condition_type?: 'or-gate' | 'and-gate' | null;
ui_color?: string | null;
ui_fgcolor?: string | null;
};

export type OklchColor = string;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/*!
* 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 { render } from "@testing-library/react";
import { ReactFlowProvider } from "@xyflow/react";
import type { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";

import { Wrapper } from "src/utils/Wrapper";

import { TaskNode } from "./TaskNode";
import type { CustomNodeProps } from "./reactflowUtils";

vi.mock("src/context/groups", () => ({
useGroups: vi.fn(() => ({ toggleGroupId: vi.fn() })),
}));

const TestWrapper = ({ children }: { readonly children: ReactNode }) => (
<Wrapper>
<ReactFlowProvider>{children}</ReactFlowProvider>
</Wrapper>
);

// Chakra/Panda hashes color props into atomic class names rather than inline styles, so the resolved
// colour cannot be read back in jsdom. Instead we render two otherwise-identical nodes that differ
// only in the prop under test: any markup difference is attributable to that prop (hashing is
// deterministic), and identical markup proves the prop had no effect.
const renderHtml = (data: Partial<CustomNodeProps>): string => {
const { container } = render(
// The xyflow NodeProps surface is large; the component only reads `data` and `id`.
<TaskNode
{...({ data: { height: 80, id: "t1", label: "t1", type: "task", width: 200, ...data } } as never)}
/>,
{ wrapper: TestWrapper },
);

return container.innerHTML;
};

describe("TaskNode operator colors", () => {
it("tints a leaf task when ui_color is set", () => {
expect(renderHtml({ operator: "BashOperator", uiColor: "blue.500" })).not.toBe(
renderHtml({ operator: "BashOperator" }),
);
});

it("colors the operator text when ui_fgcolor is set", () => {
expect(renderHtml({ operator: "BashOperator", uiFgcolor: "red.700" })).not.toBe(
renderHtml({ operator: "BashOperator" }),
);
});

it("does not tint a group node (2.x parity: groups keep their own background)", () => {
expect(renderHtml({ isGroup: true, uiColor: "blue.500" })).toBe(renderHtml({ isGroup: true }));
});
});
16 changes: 13 additions & 3 deletions airflow-core/src/airflow/ui/src/components/Graph/TaskNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ export const TaskNode = ({
taskInstance,
team,
tooltip,
uiColor,
uiFgcolor,
width = 0,
},
id,
Expand Down Expand Up @@ -84,6 +86,14 @@ export const TaskNode = ({
.map(([_state, count]) => count)
.reduce((sum, val) => sum + val, 0);

// Custom operator fill (ui_color). Like Airflow 2.x, only leaf tasks are tinted and the colour is
// blended toward the theme background so it stays subtle against the run-state border and works in
// both light and dark mode. The value is a Chakra token, resolved through its CSS variable.
const customBg =
uiColor !== undefined && uiColor !== null && !isGroup
? `color-mix(in srgb, var(--chakra-colors-${uiColor.replaceAll(".", "-")}, transparent) 25%, var(--chakra-colors-bg))`
: undefined;

return (
<NodeWrapper>
<Flex alignItems="center" cursor="default" flexDirection="column" {...opacityStyle(isFiltered)}>
Expand All @@ -96,8 +106,8 @@ export const TaskNode = ({
tooltip={isGroup ? tooltip : undefined}
>
<Flex
// Alternate background color for nested open groups
bg={isOpen && depth !== undefined && depth % 2 === 0 ? "bg.muted" : "bg"}
// Custom operator fill, else alternate background color for nested open groups
bg={customBg ?? (isOpen && depth !== undefined && depth % 2 === 0 ? "bg.muted" : "bg")}
borderColor={
isSelected ? "blue.500" : taskInstance?.state ? `${taskInstance.state}.solid` : "border"
}
Expand Down Expand Up @@ -126,7 +136,7 @@ export const TaskNode = ({
</LinkOverlay>
</HStack>
<Text
color="fg.muted"
color={uiFgcolor ?? "fg.muted"}
fontSize="sm"
overflow="hidden"
textOverflow="ellipsis"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -303,3 +303,35 @@ describe("generateElkGraph — closed TaskGroup", () => {
expect(rootEdgeIds).toEqual(new Set(["start-group_a", "group_a-final_task"]));
});
});

describe("generateElkGraph — operator colors", () => {
it("maps ui_color/ui_fgcolor onto the formatted leaf node", () => {
const root = generateElkGraph({
direction: "RIGHT",
edges: [],
font: "12px sans-serif",
nodes: [buildNode({ id: "t1", label: "t1", ui_color: "blue.500", ui_fgcolor: "red.700" })],
openGroupIds: [],
});

const node = root.children?.[0] as FormattedNode;

expect(node.uiColor).toBe("blue.500");
expect(node.uiFgcolor).toBe("red.700");
});

it("leaves the colors undefined when the node has none", () => {
const root = generateElkGraph({
direction: "RIGHT",
edges: [],
font: "12px sans-serif",
nodes: [buildNode({ id: "t1", label: "t1" })],
openGroupIds: [],
});

const node = root.children?.[0] as FormattedNode;

expect(node.uiColor).toBeUndefined();
expect(node.uiFgcolor).toBeUndefined();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ export type FormattedNode = {
isOpen?: boolean;
setupTeardownType?: NodeResponse["setup_teardown_type"];
team?: string | null;
uiColor?: string | null;
uiFgcolor?: string | null;
} & ElkShape &
NodeResponse;

Expand Down Expand Up @@ -392,6 +394,8 @@ export const generateElkGraph = ({
team: node.team,
tooltip: node.tooltip,
type: node.type,
uiColor: node.ui_color,
uiFgcolor: node.ui_fgcolor,
width,
};
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ export type CustomNodeProps = {
team?: string | null;
tooltip?: string | null;
type: string;
uiColor?: string | null;
uiFgcolor?: string | null;
width?: number;
};

Expand Down
Loading
Loading