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 7dfc57945e8 Let plugin tabs show their icon beside the label (#76842)
7dfc57945e8 is described below
commit 7dfc57945e83945d44c7369e26a0fd0f6d4170eb
Author: Kevin Li <[email protected]>
AuthorDate: Thu Nov 12 11:39:01 2026 -0500
Let plugin tabs show their icon beside the label (#76842)
* Add show_icon_with_label option for plugin views
Plugin tabs share the tab bar with core tabs and look identical to them at
normal widths: a plugin's icon only appears once the bar collapses to
icons. Plugin authors have no way to mark their tabs as coming from a
plugin. This adds an opt-in key so a plugin can ask for its icon to be
shown beside its label. It defaults to False, so existing plugins are
unchanged.
* Show plugin tab icons beside the label when requested
A plugin tab's icon only appeared once the tab bar collapsed to icons, so
at normal widths a plugin tab looked exactly like a core tab. Plugins that opt
in with show_icon_with_label now keep their icon next to the label.
* Keep tab widths fixed when the active tab turns bold
The active tab is bold and bold text is wider, so selecting a tab widened
it and pushed every tab after it a few pixels sideways.
* Note that show_icon_with_label ships in Airflow 3.4.1
---
.../docs/administration-and-deployment/plugins.rst | 12 ++++++
.../api_fastapi/core_api/datamodels/plugins.py | 3 ++
.../core_api/openapi/v2-rest-api-generated.yaml | 12 ++++++
.../airflow/ui/openapi-gen/requests/schemas.gen.ts | 24 ++++++++++++
.../airflow/ui/openapi-gen/requests/types.gen.ts | 2 +
.../airflow/ui/src/hooks/usePluginTabs.test.tsx | 39 +++++++++++++++++++-
.../src/airflow/ui/src/hooks/usePluginTabs.tsx | 3 ++
.../ui/src/layouts/Details/NavTabs.test.tsx | 32 +++++++++++++++-
.../src/airflow/ui/src/layouts/Details/NavTabs.tsx | 43 +++++++++++++++++++++-
.../core_api/routes/public/test_plugins.py | 3 ++
.../src/airflowctl/api/datamodels/generated.py | 2 +
.../plugins_manager/plugins_manager.py | 1 +
12 files changed, 173 insertions(+), 3 deletions(-)
diff --git a/airflow-core/docs/administration-and-deployment/plugins.rst
b/airflow-core/docs/administration-and-deployment/plugins.rst
index 91ca8806c09..2cb7b787322 100644
--- a/airflow-core/docs/administration-and-deployment/plugins.rst
+++ b/airflow-core/docs/administration-and-deployment/plugins.rst
@@ -327,6 +327,12 @@ definitions in Airflow.
"icon": "https://example.com/icon.svg",
# Optional dark icon for the dark theme, url to an svg file. If not
provided, "icon" will be used for both light and dark themes.
"icon_dark_mode": "https://example.com/dark_icon.svg",
+ # Optional flag, only relevant when the view renders as a tab
(destination "dag", "dag_run", "task",
+ # "task_instance" or "asset"). When True, the tab shows its icon
beside the name. When False, the tab shows
+ # only its name. Either way, when the tab bar is 600px wide or less,
tabs show only their icon. Has no
+ # effect without "icon" (an "icon_dark_mode" alone is not enough).
Defaults to False. Supported in
+ # Airflow 3.4.1 and later; earlier versions ignore it.
+ "show_icon_with_label": True,
# Optional parameters, relative URL location for the External View
rendering. If not provided, external view will be rendered as an external link.
If provided
# will be rendered inside an Iframe in the UI. Should not contain a
leading slash.
"url_route": "my_external_view",
@@ -366,6 +372,12 @@ definitions in Airflow.
"icon": "https://example.com/icon.svg",
# Optional dark icon for the dark theme, url to an svg file. If not
provided, "icon" will be used for both light and dark themes.
"icon_dark_mode": "https://example.com/dark_icon.svg",
+ # Optional flag, only relevant when the app renders as a tab
(destination "dag", "dag_run", "task",
+ # "task_instance" or "asset"). When True, the tab shows its icon
beside the name. When False, the tab shows
+ # only its name. Either way, when the tab bar is 600px wide or less,
tabs show only their icon. Has no
+ # effect without "icon" (an "icon_dark_mode" alone is not enough).
Defaults to False. Supported in
+ # Airflow 3.4.1 and later; earlier versions ignore it.
+ "show_icon_with_label": True,
# URL route for the React app, relative to the Airflow UI base URL.
Should not contain a leading slash.
"url_route": "my_react_app",
# Optional category, only relevant for destination "nav". This is used
to group the react apps in the navigation bar. We will match the existing
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/plugins.py
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/plugins.py
index aa83ef02854..1c14cb74227 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/plugins.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/plugins.py
@@ -152,6 +152,9 @@ class BaseUIResponse(BaseModel):
# skipped there rather than failing the match). Omitting `applies_to`
shows the item
# everywhere. Display gating only, not an authorization boundary.
applies_to: PluginAppliesToResponse | None = None
+ # Only read by plugin tabs: show the icon beside the label at full width,
not only when the
+ # tab bar collapses to icons. Has no effect without an `icon`.
+ show_icon_with_label: bool | None = False
class ExternalViewResponse(BaseUIResponse):
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
index 194eb2fb1c1..afb27e8f61e 100644
---
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
+++
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
@@ -16843,6 +16843,12 @@ components:
anyOf:
- $ref: '#/components/schemas/PluginAppliesToResponse'
- type: 'null'
+ show_icon_with_label:
+ anyOf:
+ - type: boolean
+ - type: 'null'
+ title: Show Icon With Label
+ default: false
href:
type: string
title: Href
@@ -18546,6 +18552,12 @@ components:
anyOf:
- $ref: '#/components/schemas/PluginAppliesToResponse'
- type: 'null'
+ show_icon_with_label:
+ anyOf:
+ - type: boolean
+ - type: 'null'
+ title: Show Icon With Label
+ default: false
bundle_url:
type: string
title: Bundle Url
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
index 1f13f5d7946..c6e92d0992e 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
@@ -6045,6 +6045,18 @@ export const $ExternalViewResponse = {
}
]
},
+ show_icon_with_label: {
+ anyOf: [
+ {
+ type: 'boolean'
+ },
+ {
+ type: 'null'
+ }
+ ],
+ title: 'Show Icon With Label',
+ default: false
+ },
href: {
type: 'string',
title: 'Href'
@@ -8714,6 +8726,18 @@ export const $ReactAppResponse = {
}
]
},
+ show_icon_with_label: {
+ anyOf: [
+ {
+ type: 'boolean'
+ },
+ {
+ type: 'null'
+ }
+ ],
+ title: 'Show Icon With Label',
+ default: false
+ },
bundle_url: {
type: 'string',
title: 'Bundle Url'
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
index 31e1e3402f6..2b99df5aa74 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
@@ -1655,6 +1655,7 @@ export type ExternalViewResponse = {
category?: string | null;
nav_top_level?: boolean | null;
applies_to?: PluginAppliesToResponse | null;
+ show_icon_with_label?: boolean | null;
href: string;
destination?: 'nav' | 'dag' | 'dag_run' | 'task' | 'task_instance' |
'asset' | 'base';
[key: string]: unknown | string;
@@ -2291,6 +2292,7 @@ export type ReactAppResponse = {
category?: string | null;
nav_top_level?: boolean | null;
applies_to?: PluginAppliesToResponse | null;
+ show_icon_with_label?: boolean | null;
bundle_url: string;
destination?: 'nav' | 'dag' | 'dag_run' | 'task' | 'task_instance' |
'asset' | 'base' | 'dashboard' | 'dag_overview' | 'task_overview';
[key: string]: unknown | string;
diff --git a/airflow-core/src/airflow/ui/src/hooks/usePluginTabs.test.tsx
b/airflow-core/src/airflow/ui/src/hooks/usePluginTabs.test.tsx
index 0796bd4df22..cbd0bb806a1 100644
--- a/airflow-core/src/airflow/ui/src/hooks/usePluginTabs.test.tsx
+++ b/airflow-core/src/airflow/ui/src/hooks/usePluginTabs.test.tsx
@@ -30,8 +30,10 @@ vi.mock("openapi/queries", () => ({
usePluginServiceGetPlugins: vi.fn(),
}));
+const { colorModeState } = vi.hoisted(() => ({ colorModeState: { value:
"light" } }));
+
vi.mock("src/context/colorMode", () => ({
- useColorMode: () => ({ colorMode: "light" }),
+ useColorMode: () => ({ colorMode: colorModeState.value }),
}));
vi.mock("src/hooks/usePluginAppliesToContext", () => ({
@@ -63,6 +65,7 @@ const dag = {
describe("usePluginTabs", () => {
beforeEach(() => {
vi.clearAllMocks();
+ colorModeState.value = "light";
mockUseContext.mockReturnValue({ dag, isLoading: false });
});
@@ -119,4 +122,38 @@ describe("usePluginTabs", () => {
expect(mockUseContext).toHaveBeenCalledWith(enabled);
});
+
+ it.each<{
+ colorMode: string;
+ expected: boolean;
+ label: string;
+ overrides: Partial<ExternalViewResponse>;
+ }>([
+ { colorMode: "light", expected: false, label: "the flag is unset",
overrides: { icon: "/icon.svg" } },
+ {
+ colorMode: "light",
+ expected: true,
+ label: "the flag is on with an icon",
+ overrides: { icon: "/icon.svg", show_icon_with_label: true },
+ },
+ {
+ colorMode: "light",
+ expected: false,
+ label: "the flag is on without an icon",
+ overrides: { show_icon_with_label: true },
+ },
+ {
+ colorMode: "dark",
+ expected: false,
+ label: "the flag is on with only a dark icon, in dark mode",
+ overrides: { icon_dark_mode: "/dark.svg", show_icon_with_label: true },
+ },
+ ])("sets showIconWithLabel when $label", ({ colorMode, expected, overrides
}) => {
+ colorModeState.value = colorMode;
+ setPlugins([makeView(overrides)]);
+
+ const { result } = renderHook(() => usePluginTabs("dag_run"));
+
+ expect(result.current[0]?.showIconWithLabel).toBe(expected);
+ });
});
diff --git a/airflow-core/src/airflow/ui/src/hooks/usePluginTabs.tsx
b/airflow-core/src/airflow/ui/src/hooks/usePluginTabs.tsx
index e9f2a81d215..e2c135c4d99 100644
--- a/airflow-core/src/airflow/ui/src/hooks/usePluginTabs.tsx
+++ b/airflow-core/src/airflow/ui/src/hooks/usePluginTabs.tsx
@@ -29,6 +29,7 @@ import { getPluginIconSrc } from "src/utils/pluginIcon";
type TabPlugin = {
icon: ReactNode;
label: string;
+ showIconWithLabel: boolean;
value: string;
};
@@ -45,6 +46,7 @@ export const usePluginTabs = (destination: string):
Array<TabPlugin> => {
.filter((view) => Boolean(view.url_route))
.map((view) => {
const iconSrc = getPluginIconSrc(view, colorMode);
+ const hasIconInEveryTheme = Boolean(view.icon);
const icon =
iconSrc === undefined ? (
@@ -56,6 +58,7 @@ export const usePluginTabs = (destination: string):
Array<TabPlugin> => {
return {
icon,
label: view.name,
+ showIconWithLabel: view.show_icon_with_label === true &&
hasIconInEveryTheme,
value: `plugin/${view.url_route}`,
};
});
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/NavTabs.test.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/NavTabs.test.tsx
index bb5ca2cad59..a7c630feec2 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/NavTabs.test.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/NavTabs.test.tsx
@@ -180,12 +180,42 @@ describe("NavTabs", () => {
renderTabs("/dag/tab8");
expect(getRowLabels()).toEqual(["Tab 0", "Tab 1", "Tab 2", "Tab 3", "Tab
4", "Tab 8"]);
- expect(within(screen.getByRole("link", { name: "Tab 8" })).getByText("Tab
8")).toHaveAttribute(
+ expect(screen.getByRole("link", { name: "Tab 8"
}).firstElementChild).toHaveAttribute(
"aria-current",
"page",
);
});
+ it("renders each label's text once while carrying it for the bold-width
copy", () => {
+ vi.mocked(useContainerWidth).mockReturnValue(TABS.length * TAB_WIDTH);
+
+ renderTabs("/dag/tab0");
+
+ const link = screen.getByRole("link", { name: "Tab 1" });
+
+ expect(within(link).getByText("Tab 1")).toHaveAttribute("data-label", "Tab
1");
+ expect(link).toHaveTextContent(/^Tab 1$/u);
+ });
+
+ it("shows a tab's icon beside its label when showIconWithLabel is set", ()
=> {
+ vi.mocked(useContainerWidth).mockReturnValue(TABS.length * TAB_WIDTH);
+
+ renderTabs("/dag/tab0", [
+ {
+ icon: <img alt="Warehouse" src="/icon.svg" />,
+ label: "Warehouse",
+ showIconWithLabel: true,
+ value: "plugin/warehouse",
+ },
+ ]);
+
+ const link = screen.getByRole("link", { name: "Warehouse" });
+
+ expect(link).toHaveTextContent("Warehouse");
+ expect(within(link).getByRole("img", { hidden: true
})).toBeInTheDocument();
+ expect(within(link).queryByRole("img")).not.toBeInTheDocument();
+ });
+
it("shows icons and an icon-only More trigger in a narrow panel", async ()
=> {
vi.mocked(useContainerWidth).mockReturnValue(400);
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/NavTabs.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/NavTabs.tsx
index 7c472d163ee..55fb68857f4 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/NavTabs.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/NavTabs.tsx
@@ -33,6 +33,7 @@ export type NavTab = {
/** Additional route segments that should also mark this tab as active. */
readonly matchPaths?: Array<string>;
readonly search?: string;
+ readonly showIconWithLabel?: boolean;
readonly value: string;
};
@@ -44,6 +45,27 @@ const INDICATOR_HEIGHT = "2px";
const trimTrailingSlash = (path: string) => path.replace(/\/$/u, "");
+const reserveBoldWidth = {
+ content: "attr(data-label)",
+ fontWeight: "bold",
+ height: 0,
+ overflow: "hidden",
+ visibility: "hidden",
+};
+
+const TabLabel = ({ label }: { readonly label: string }) => (
+ <Box
+ _after={reserveBoldWidth}
+ alignItems="center"
+ as="span"
+ data-label={label}
+ display="flex"
+ flexDirection="column"
+ >
+ {label}
+ </Box>
+);
+
const TabContent = ({ active, children, ...rest }: { readonly active: boolean
} & CenterProps) => (
<Center
_focus={{ color: active ? "fg" : "brand.solid" }}
@@ -53,6 +75,7 @@ const TabContent = ({ active, children, ...rest }: { readonly
active: boolean }
color={active ? "fg" : "fg.muted"}
fontSize="md"
fontWeight={active ? "bold" : "medium"}
+ gap={2}
height="40px"
mb={`-${INDICATOR_HEIGHT}`}
px={4}
@@ -89,7 +112,25 @@ export const NavTabs = ({ tabs }: Props) => {
);
});
const showLabels = containerWidth > 600;
- const getTabContent = ({ icon, label }: NavTab) => (showLabels ||
!Boolean(icon) ? label : icon);
+ const getTabContent = ({ icon, label, showIconWithLabel = false }: NavTab):
ReactNode => {
+ if (!Boolean(icon)) {
+ return <TabLabel label={label} />;
+ }
+ if (!showLabels) {
+ return icon;
+ }
+
+ return showIconWithLabel ? (
+ <>
+ <Box aria-hidden as="span" display="flex" flexShrink={0}>
+ {icon}
+ </Box>
+ <TabLabel label={label} />
+ </>
+ ) : (
+ <TabLabel label={label} />
+ );
+ };
const moreLabel = showLabels ? (
<HStack as="span" gap={1}>
{translate("moreTabs")}
diff --git
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_plugins.py
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_plugins.py
index 0bdbfa31576..bda5541ff2a 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_plugins.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_plugins.py
@@ -108,6 +108,7 @@ class TestGetPlugins:
"destination": "dag",
"category": "browse",
"nav_top_level": False,
+ "show_icon_with_label": False,
"applies_to": {
"dag.tags.name": ["ml", "production"],
"dag.dag_id": ["example_dag"],
@@ -129,6 +130,7 @@ class TestGetPlugins:
"icon_dark_mode": None,
"name": "Google",
"nav_top_level": False,
+ "show_icon_with_label": False,
"url_route": None,
"applies_to": None,
},
@@ -141,6 +143,7 @@ class TestGetPlugins:
"label": "The Apache Software Foundation",
"name": "apache",
"nav_top_level": False,
+ "show_icon_with_label": False,
"url_route": None,
"applies_to": None,
},
diff --git a/airflow-ctl/src/airflowctl/api/datamodels/generated.py
b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
index 4afb2311d34..b44e53ea579 100644
--- a/airflow-ctl/src/airflowctl/api/datamodels/generated.py
+++ b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
@@ -1436,6 +1436,7 @@ class ReactAppResponse(BaseModel):
category: Annotated[str | None, Field(title="Category")] = None
nav_top_level: Annotated[bool | None, Field(title="Nav Top Level")] = False
applies_to: PluginAppliesToResponse | None = None
+ show_icon_with_label: Annotated[bool | None, Field(title="Show Icon With
Label")] = False
bundle_url: Annotated[str, Field(title="Bundle Url")]
destination: Annotated[Destination3 | None, Field(title="Destination")] =
"nav"
@@ -2721,6 +2722,7 @@ class ExternalViewResponse(BaseModel):
category: Annotated[str | None, Field(title="Category")] = None
nav_top_level: Annotated[bool | None, Field(title="Nav Top Level")] = False
applies_to: PluginAppliesToResponse | None = None
+ show_icon_with_label: Annotated[bool | None, Field(title="Show Icon With
Label")] = False
href: Annotated[str, Field(title="Href")]
destination: Annotated[Destination | None, Field(title="Destination")] =
"nav"
diff --git
a/shared/plugins_manager/src/airflow_shared/plugins_manager/plugins_manager.py
b/shared/plugins_manager/src/airflow_shared/plugins_manager/plugins_manager.py
index bf115a5140c..c42a636b50c 100644
---
a/shared/plugins_manager/src/airflow_shared/plugins_manager/plugins_manager.py
+++
b/shared/plugins_manager/src/airflow_shared/plugins_manager/plugins_manager.py
@@ -105,6 +105,7 @@ class _BaseUIDict(TypedDict):
category: NotRequired[str | None]
nav_top_level: NotRequired[bool | None]
applies_to: NotRequired[AppliesToDict | None]
+ show_icon_with_label: NotRequired[bool | None]
class ExternalViewDict(_BaseUIDict):