From f7938461c30c61d0181dbb7b85dfeae7b60bedec Mon Sep 17 00:00:00 2001 From: Stuart Buckingham Date: Wed, 20 May 2026 23:53:18 -0500 Subject: [PATCH 1/5] Add nav_top_level option for plugin nav items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allows plugin authors to set nav_top_level=True on external_views or react_apps so the item always appears directly on the navigation toolbar rather than being grouped into the Plugins submenu. Remaining non-promoted items follow the existing rule: 2+ items go into a submenu, a single item is also shown on the toolbar (no one-item submenu). Backwards compatible — omitting the flag preserves the current behaviour exactly. --- .../administration-and-deployment/plugins.rst | 10 ++ .../core_api/datamodels/plugins.py | 1 + .../openapi/v2-rest-api-generated.yaml | 12 ++ .../ui/openapi-gen/requests/schemas.gen.ts | 24 +++ .../ui/openapi-gen/requests/types.gen.ts | 2 + .../ui/src/layouts/Nav/PluginMenus.test.tsx | 152 ++++++++++++++++++ .../ui/src/layouts/Nav/PluginMenus.tsx | 80 +++++---- .../airflowctl/api/datamodels/generated.py | 2 + 8 files changed, 248 insertions(+), 35 deletions(-) create mode 100644 airflow-core/src/airflow/ui/src/layouts/Nav/PluginMenus.test.tsx diff --git a/airflow-core/docs/administration-and-deployment/plugins.rst b/airflow-core/docs/administration-and-deployment/plugins.rst index 4f616157bcc43..d8952d74d03c8 100644 --- a/airflow-core/docs/administration-and-deployment/plugins.rst +++ b/airflow-core/docs/administration-and-deployment/plugins.rst @@ -252,6 +252,11 @@ definitions in Airflow. # Optional category, only relevant for destination "nav". This is used to group the external links in the navigation bar. We will match the existing # menus of ["browse", "docs", "admin", "user"] and if there's no match then create a new menu. "category": "browse", + # Optional flag, only relevant for destination "nav". When True, this item is always rendered directly on the + # navigation toolbar instead of inside the "Plugins" submenu. When two or more non-promoted items remain they + # are still grouped into the submenu; a single remaining non-promoted item is also shown on the toolbar. + # Defaults to False. + "nav_top_level": True, } # Note: The React app integration is experimental and interfaces might change in future versions. @@ -277,6 +282,11 @@ definitions in Airflow. # Optional category, only relevant for destination "nav". This is used to group the react apps in the navigation bar. We will match the existing # menus of ["browse", "docs", "admin", "user"] and if there's no match then create a new menu. "category": "browse", + # Optional flag, only relevant for destination "nav". When True, this item is always rendered directly on the + # navigation toolbar instead of inside the "Plugins" submenu. When two or more non-promoted items remain they + # are still grouped into the submenu; a single remaining non-promoted item is also shown on the toolbar. + # Defaults to False. + "nav_top_level": True, } 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 e7fa0fe276a79..2bddb29ac9672 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 @@ -82,6 +82,7 @@ class BaseUIResponse(BaseModel): icon_dark_mode: str | None = None url_route: str | None = None category: str | None = None + nav_top_level: 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 00a9c60d85838..18fde9ef30d56 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 @@ -13758,6 +13758,12 @@ components: - type: string - type: 'null' title: Category + nav_top_level: + anyOf: + - type: boolean + - type: 'null' + title: Nav Top Level + default: false href: type: string title: Href @@ -14703,6 +14709,12 @@ components: - type: string - type: 'null' title: Category + nav_top_level: + anyOf: + - type: boolean + - type: 'null' + title: Nav Top Level + 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 108480b3dd175..ae5be57b49743 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 @@ -3972,6 +3972,18 @@ export const $ExternalViewResponse = { ], title: 'Category' }, + nav_top_level: { + anyOf: [ + { + type: 'boolean' + }, + { + type: 'null' + } + ], + title: 'Nav Top Level', + default: false + }, href: { type: 'string', title: 'Href' @@ -5341,6 +5353,18 @@ export const $ReactAppResponse = { ], title: 'Category' }, + nav_top_level: { + anyOf: [ + { + type: 'boolean' + }, + { + type: 'null' + } + ], + title: 'Nav Top Level', + 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 1ea5961b91921..5cff273348943 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 @@ -1013,6 +1013,7 @@ export type ExternalViewResponse = { icon_dark_mode?: string | null; url_route?: string | null; category?: string | null; + nav_top_level?: boolean | null; href: string; destination?: 'nav' | 'dag' | 'dag_run' | 'task' | 'task_instance' | 'base'; [key: string]: unknown | string; @@ -1386,6 +1387,7 @@ export type ReactAppResponse = { icon_dark_mode?: string | null; url_route?: string | null; category?: string | null; + nav_top_level?: boolean | null; bundle_url: string; destination?: 'nav' | 'dag' | 'dag_run' | 'task' | 'task_instance' | 'base' | 'dashboard'; [key: string]: unknown | string; diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/PluginMenus.test.tsx b/airflow-core/src/airflow/ui/src/layouts/Nav/PluginMenus.test.tsx new file mode 100644 index 0000000000000..5f8933eecdf58 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/layouts/Nav/PluginMenus.test.tsx @@ -0,0 +1,152 @@ +/*! + * 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 "@testing-library/jest-dom"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import type { ExternalViewResponse } from "openapi/requests/types.gen"; +import { Wrapper } from "src/utils/Wrapper"; + +import { PluginMenus } from "./PluginMenus"; + +const makePlugin = (name: string, overrides: Partial = {}): ExternalViewResponse => ({ + destination: "nav", + href: `/plugin/${name}`, + name, + url_route: name, + ...overrides, +}); + +// Top-level (toolbar) plugin items render as links with aria-label. +// The submenu trigger renders as a