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

shahar1 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 1aff39582b5 [v3-4-test] Let plugin tabs show their icon beside the 
label (#76842) (#76985)
1aff39582b5 is described below

commit 1aff39582b56bf812119db14db902971f431ab14
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Fri Nov 13 08:59:35 2026 +0200

    [v3-4-test] Let plugin tabs show their icon beside the label (#76842) 
(#76985)
    
    * 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
    (cherry picked from commit 7dfc57945e83945d44c7369e26a0fd0f6d4170eb)
    
    Co-authored-by: Kevin Li <[email protected]>
---
 .../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 b1c2b5c3407..f0761e25437 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
@@ -16482,6 +16482,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
@@ -17643,6 +17649,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 8ba458c15dc..08807f6db11 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
@@ -5970,6 +5970,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'
@@ -7638,6 +7650,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 6f088ae943a..cec6222a349 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
@@ -1627,6 +1627,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;
@@ -2081,6 +2082,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 12bec95bbc4..fdd0893169d 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
@@ -107,6 +107,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"],
@@ -128,6 +129,7 @@ class TestGetPlugins:
                         "icon_dark_mode": None,
                         "name": "Google",
                         "nav_top_level": False,
+                        "show_icon_with_label": False,
                         "url_route": None,
                         "applies_to": None,
                     },
@@ -140,6 +142,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 b73934b862d..c2cef3cfc95 100644
--- a/airflow-ctl/src/airflowctl/api/datamodels/generated.py
+++ b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
@@ -1308,6 +1308,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"
 
@@ -2545,6 +2546,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):

Reply via email to