diff --git a/airflow-core/docs/administration-and-deployment/plugins.rst b/airflow-core/docs/administration-and-deployment/plugins.rst index 8747707b4a461..2daa6036ddb11 100644 --- a/airflow-core/docs/administration-and-deployment/plugins.rst +++ b/airflow-core/docs/administration-and-deployment/plugins.rst @@ -109,7 +109,7 @@ looks like: # A list of dictionaries containing FastAPI middleware factory objects and some metadata. See the example below. fastapi_root_middlewares = [] # A list of dictionaries containing iframe views and some metadata. See the example below. - iframe_views = [] + external_views = [] # A callback to perform actions when Airflow starts and the plugin is loaded. # NOTE: Ensure your plugin has *args, and **kwargs in the method definition @@ -195,18 +195,24 @@ definitions in Airflow. } # Creating a iframe view that will be rendered in the Airflow UI. - iframe_view_with_metadata = { + external_view_with_metadata = { "name": "Name of the Iframe View as displayed in the UI", - # Source URL of the iframe. This URL can be templated using context variables, depending on the location where the iframe is rendered - # the context variables available will be different, i.e a subset of (DAG_ID, RUN_ID, TASK_ID, MAP_INDEX) - "src": "https://example.com/{DAG_ID}/{RUN_ID}/{TASK_ID}", + # Source URL of the external view. This URL can be templated using context variables, depending on the location where the external view is rendered + # the context variables available will be different, i.e a subset of (DAG_ID, RUN_ID, TASK_ID, MAP_INDEX). + "href": "https://example.com/{DAG_ID}/{RUN_ID}/{TASK_ID}", # Destination of the iframe view. This is used to determine where the iframe will be loaded in the UI. - # Supported locations are Literal["nav", "dag", "dag_run", "task", "task_instance"] + # Supported locations are Literal["nav", "dag", "dag_run", "task", "task_instance"], default to "nav". "destination": "dag_run", # Optional icon, url to an svg file. "icon": "https://example.com/icon.svg", - # Optional parameters, relative URL location when opening the iframe - "url_route": "/my_iframe_view", + # 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 parameters, relative URL location for the iframe rendering. If not provided, external view will be rendeded as an external link. Should + # not contain a leading slash. + "url_route": "my_iframe_view", + # 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", } @@ -216,7 +222,7 @@ definitions in Airflow. macros = [plugin_macro] fastapi_apps = [app_with_metadata] fastapi_root_middlewares = [middleware_with_metadata] - iframe_views = [iframe_view_with_metadata] + external_views = [external_view_with_metadata] .. seealso:: :doc:`/howto/define-extra-link` diff --git a/airflow-core/docs/howto/custom-view-plugin.rst b/airflow-core/docs/howto/custom-view-plugin.rst index 28f413b9b54a2..74a2891f47615 100644 --- a/airflow-core/docs/howto/custom-view-plugin.rst +++ b/airflow-core/docs/howto/custom-view-plugin.rst @@ -24,7 +24,7 @@ core UI using the Plugin manager. Plugins integrate with the Airflow core RestAPI. In this plugin, three object references are derived from the base class ``airflow.plugins_manager.AirflowPlugin``. -They are fastapi_apps, fastapi_root_middlewares and iframe_views. +They are fastapi_apps, fastapi_root_middlewares and external_views. Using fastapi_apps in Airflow plugin, the core RestAPI can be extended to support extra endpoints to serve custom static file or any other json/application responses. @@ -37,12 +37,12 @@ functionality to the entire FastAPI application, including core endpoints. In this object reference, the list of dictionaries with Middleware factories object, initialization parameters and some metadata information like the name are passed on. -Using iframe_views in Airflow plugin, allows to register custom views that are rendered in iframes in -the Airflow UI. This is useful for integrating external applications or custom dashboards into the Airflow UI. +Using external_views in Airflow plugin, allows to register custom views that are rendered in iframes or external link +in the Airflow UI. This is useful for integrating external applications or custom dashboards into the Airflow UI. In this object reference, the list of dictionaries with the view name, iframe src (templatable), destination and optional parameters like the icon and url_route are passed on. -Information and code samples to register ``fastapi_apps``, ``fastapi_root_middlewares`` and ``iframe_views`` are +Information and code samples to register ``fastapi_apps``, ``fastapi_root_middlewares`` and ``external_views`` are available in :doc:`plugin `. Support for Airflow 2 plugins 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 a1f51c04ab78d..daf712a8eda0e 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 @@ -19,7 +19,7 @@ from typing import Annotated, Any, Literal -from pydantic import BeforeValidator, ConfigDict, field_validator +from pydantic import BeforeValidator, ConfigDict, Field, field_validator, model_validator from airflow.api_fastapi.core_api.base import BaseModel from airflow.plugins_manager import AirflowPluginSource @@ -65,20 +65,22 @@ class AppBuilderMenuItemResponse(BaseModel): model_config = ConfigDict(extra="allow") name: str - href: str | None = None + href: str category: str | None = None -class IFrameViewsResponse(BaseModel): +class ExternalViewResponse(BaseModel): """Serializer for IFrame Plugin responses.""" model_config = ConfigDict(extra="allow") name: str - src: str + href: str icon: str | None = None + icon_dark_mode: str | None = None url_route: str | None = None - destination: Literal["nav", "dag", "dag_run", "task", "task_instance"] | None = None + category: str | None = None + destination: Literal["nav", "dag", "dag_run", "task", "task_instance"] = "nav" class PluginResponse(BaseModel): @@ -89,9 +91,13 @@ class PluginResponse(BaseModel): flask_blueprints: list[str] fastapi_apps: list[FastAPIAppResponse] fastapi_root_middlewares: list[FastAPIRootMiddlewareResponse] - iframe_views: list[IFrameViewsResponse] + external_views: list[ExternalViewResponse] = Field( + description="Aggregate all external views. Both 'external_views' and 'appbuilder_menu_items' are included here." + ) appbuilder_views: list[AppBuilderViewResponse] - appbuilder_menu_items: list[AppBuilderMenuItemResponse] + appbuilder_menu_items: list[AppBuilderMenuItemResponse] = Field( + deprecated="Kept for backward compatibility, use `external_views` instead.", + ) global_operator_extra_links: list[str] operator_extra_links: list[str] source: Annotated[str, BeforeValidator(coerce_to_string)] @@ -105,6 +111,12 @@ def convert_source(cls, data: Any) -> Any: return str(data) return data + @model_validator(mode="before") + @classmethod + def convert_external_views(cls, data: Any) -> Any: + data["external_views"] = [*data["external_views"], *data.get("appbuilder_menu_items", [])] + return data + class PluginCollectionResponse(BaseModel): """Plugin Collection serializer.""" 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 c67a089b0623e..478316b43369a 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 @@ -7029,9 +7029,7 @@ components: type: string title: Name href: - anyOf: - - type: string - - type: 'null' + type: string title: Href category: anyOf: @@ -7042,6 +7040,7 @@ components: type: object required: - name + - href title: AppBuilderMenuItemResponse description: Serializer for AppBuilder Menu Item responses. AppBuilderViewResponse: @@ -9293,6 +9292,51 @@ components: - url title: ExternalLogUrlResponse description: Response for the external log URL endpoint. + ExternalViewResponse: + properties: + name: + type: string + title: Name + href: + type: string + title: Href + icon: + anyOf: + - type: string + - type: 'null' + title: Icon + icon_dark_mode: + anyOf: + - type: string + - type: 'null' + title: Icon Dark Mode + url_route: + anyOf: + - type: string + - type: 'null' + title: Url Route + category: + anyOf: + - type: string + - type: 'null' + title: Category + destination: + type: string + enum: + - nav + - dag + - dag_run + - task + - task_instance + title: Destination + default: nav + additionalProperties: true + type: object + required: + - name + - href + title: ExternalViewResponse + description: Serializer for IFrame Plugin responses. ExtraLinkCollectionResponse: properties: extra_links: @@ -9386,42 +9430,6 @@ components: - triggerer title: HealthInfoResponse description: Health serializer for responses. - IFrameViewsResponse: - properties: - name: - type: string - title: Name - src: - type: string - title: Src - icon: - anyOf: - - type: string - - type: 'null' - title: Icon - url_route: - anyOf: - - type: string - - type: 'null' - title: Url Route - destination: - anyOf: - - type: string - enum: - - nav - - dag - - dag_run - - task - - task_instance - - type: 'null' - title: Destination - additionalProperties: true - type: object - required: - - name - - src - title: IFrameViewsResponse - description: Serializer for IFrame Plugin responses. ImportErrorCollectionResponse: properties: import_errors: @@ -9676,11 +9684,13 @@ components: $ref: '#/components/schemas/FastAPIRootMiddlewareResponse' type: array title: Fastapi Root Middlewares - iframe_views: + external_views: items: - $ref: '#/components/schemas/IFrameViewsResponse' + $ref: '#/components/schemas/ExternalViewResponse' type: array - title: Iframe Views + title: External Views + description: Aggregate all external views. Both 'external_views' and 'appbuilder_menu_items' + are included here. appbuilder_views: items: $ref: '#/components/schemas/AppBuilderViewResponse' @@ -9691,6 +9701,7 @@ components: $ref: '#/components/schemas/AppBuilderMenuItemResponse' type: array title: Appbuilder Menu Items + deprecated: true global_operator_extra_links: items: type: string @@ -9721,7 +9732,7 @@ components: - flask_blueprints - fastapi_apps - fastapi_root_middlewares - - iframe_views + - external_views - appbuilder_views - appbuilder_menu_items - global_operator_extra_links diff --git a/airflow-core/src/airflow/plugins_manager.py b/airflow-core/src/airflow/plugins_manager.py index 84cc92cd81723..8874864286b77 100644 --- a/airflow-core/src/airflow/plugins_manager.py +++ b/airflow-core/src/airflow/plugins_manager.py @@ -69,7 +69,7 @@ flask_blueprints: list[Any] | None = None fastapi_apps: list[Any] | None = None fastapi_root_middlewares: list[Any] | None = None -iframe_views: list[Any] | None = None +external_views: list[Any] | None = None menu_links: list[Any] | None = None flask_appbuilder_views: list[Any] | None = None flask_appbuilder_menu_links: list[Any] | None = None @@ -91,7 +91,7 @@ "flask_blueprints", "fastapi_apps", "fastapi_root_middlewares", - "iframe_views", + "external_views", "menu_links", "appbuilder_views", "appbuilder_menu_items", @@ -156,7 +156,7 @@ class AirflowPlugin: flask_blueprints: list[Any] = [] fastapi_apps: list[Any] = [] fastapi_root_middlewares: list[Any] = [] - iframe_views: list[Any] = [] + external_views: list[Any] = [] menu_links: list[Any] = [] appbuilder_views: list[Any] = [] appbuilder_menu_items: list[Any] = [] @@ -371,9 +371,9 @@ def ensure_plugins_loaded(): def initialize_ui_plugins(): """Collect extension points for the UI.""" global plugins - global iframe_views + global external_views - if iframe_views is not None: + if external_views is not None: return ensure_plugins_loaded() @@ -383,10 +383,10 @@ def initialize_ui_plugins(): log.debug("Initialize UI plugin") - iframe_views = [] + external_views = [] for plugin in plugins: - iframe_views.extend(plugin.iframe_views) + external_views.extend(plugin.external_views) def initialize_flask_plugins(): 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 93814056bb09c..6f5f33136e54f 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 @@ -7,14 +7,7 @@ export const $AppBuilderMenuItemResponse = { title: 'Name' }, href: { - anyOf: [ - { - type: 'string' - }, - { - type: 'null' - } - ], + type: 'string', title: 'Href' }, category: { @@ -31,7 +24,7 @@ export const $AppBuilderMenuItemResponse = { }, additionalProperties: true, type: 'object', - required: ['name'], + required: ['name', 'href'], title: 'AppBuilderMenuItemResponse', description: 'Serializer for AppBuilder Menu Item responses.' } as const; @@ -3269,6 +3262,74 @@ export const $ExternalLogUrlResponse = { description: 'Response for the external log URL endpoint.' } as const; +export const $ExternalViewResponse = { + properties: { + name: { + type: 'string', + title: 'Name' + }, + href: { + type: 'string', + title: 'Href' + }, + icon: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Icon' + }, + icon_dark_mode: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Icon Dark Mode' + }, + url_route: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Url Route' + }, + category: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Category' + }, + destination: { + type: 'string', + enum: ['nav', 'dag', 'dag_run', 'task', 'task_instance'], + title: 'Destination', + default: 'nav' + } + }, + additionalProperties: true, + type: 'object', + required: ['name', 'href'], + title: 'ExternalViewResponse', + description: 'Serializer for IFrame Plugin responses.' +} as const; + export const $ExtraLinkCollectionResponse = { properties: { extra_links: { @@ -3399,58 +3460,6 @@ export const $HealthInfoResponse = { description: 'Health serializer for responses.' } as const; -export const $IFrameViewsResponse = { - properties: { - name: { - type: 'string', - title: 'Name' - }, - src: { - type: 'string', - title: 'Src' - }, - icon: { - anyOf: [ - { - type: 'string' - }, - { - type: 'null' - } - ], - title: 'Icon' - }, - url_route: { - anyOf: [ - { - type: 'string' - }, - { - type: 'null' - } - ], - title: 'Url Route' - }, - destination: { - anyOf: [ - { - type: 'string', - enum: ['nav', 'dag', 'dag_run', 'task', 'task_instance'] - }, - { - type: 'null' - } - ], - title: 'Destination' - } - }, - additionalProperties: true, - type: 'object', - required: ['name', 'src'], - title: 'IFrameViewsResponse', - description: 'Serializer for IFrame Plugin responses.' -} as const; - export const $ImportErrorCollectionResponse = { properties: { import_errors: { @@ -3830,12 +3839,13 @@ export const $PluginResponse = { type: 'array', title: 'Fastapi Root Middlewares' }, - iframe_views: { + external_views: { items: { - '$ref': '#/components/schemas/IFrameViewsResponse' + '$ref': '#/components/schemas/ExternalViewResponse' }, type: 'array', - title: 'Iframe Views' + title: 'External Views', + description: "Aggregate all external views. Both 'external_views' and 'appbuilder_menu_items' are included here." }, appbuilder_views: { items: { @@ -3849,7 +3859,8 @@ export const $PluginResponse = { '$ref': '#/components/schemas/AppBuilderMenuItemResponse' }, type: 'array', - title: 'Appbuilder Menu Items' + title: 'Appbuilder Menu Items', + deprecated: true }, global_operator_extra_links: { items: { @@ -3885,7 +3896,7 @@ export const $PluginResponse = { } }, type: 'object', - required: ['name', 'macros', 'flask_blueprints', 'fastapi_apps', 'fastapi_root_middlewares', 'iframe_views', 'appbuilder_views', 'appbuilder_menu_items', 'global_operator_extra_links', 'operator_extra_links', 'source', 'listeners', 'timetables'], + required: ['name', 'macros', 'flask_blueprints', 'fastapi_apps', 'fastapi_root_middlewares', 'external_views', 'appbuilder_views', 'appbuilder_menu_items', 'global_operator_extra_links', 'operator_extra_links', 'source', 'listeners', 'timetables'], title: 'PluginResponse', description: 'Plugin serializer.' } as const; 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 92dee7ed04011..692a290abccfe 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 @@ -5,7 +5,7 @@ */ export type AppBuilderMenuItemResponse = { name: string; - href?: string | null; + href: string; category?: string | null; [key: string]: unknown | string; }; @@ -871,6 +871,22 @@ export type ExternalLogUrlResponse = { url: string; }; +/** + * Serializer for IFrame Plugin responses. + */ +export type ExternalViewResponse = { + name: string; + href: string; + icon?: string | null; + icon_dark_mode?: string | null; + url_route?: string | null; + category?: string | null; + destination?: 'nav' | 'dag' | 'dag_run' | 'task' | 'task_instance'; + [key: string]: unknown | string; +}; + +export type destination = 'nav' | 'dag' | 'dag_run' | 'task' | 'task_instance'; + /** * Extra Links Response. */ @@ -923,18 +939,6 @@ export type HealthInfoResponse = { dag_processor?: DagProcessorInfoResponse | null; }; -/** - * Serializer for IFrame Plugin responses. - */ -export type IFrameViewsResponse = { - name: string; - src: string; - icon?: string | null; - url_route?: string | null; - destination?: 'nav' | 'dag' | 'dag_run' | 'task' | 'task_instance' | null; - [key: string]: unknown | string; -}; - /** * Import Error Collection Response. */ @@ -1034,8 +1038,14 @@ export type PluginResponse = { flask_blueprints: Array<(string)>; fastapi_apps: Array; fastapi_root_middlewares: Array; - iframe_views: Array; + /** + * Aggregate all external views. Both 'external_views' and 'appbuilder_menu_items' are included here. + */ + external_views: Array; appbuilder_views: Array; + /** + * @deprecated + */ appbuilder_menu_items: Array; global_operator_extra_links: Array<(string)>; operator_extra_links: Array<(string)>; diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/NavButton.tsx b/airflow-core/src/airflow/ui/src/layouts/Nav/NavButton.tsx index 23f837cd7c62f..4ebaa90b7c3ea 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Nav/NavButton.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Nav/NavButton.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { Box, Button, type ButtonProps } from "@chakra-ui/react"; +import { Box, Button, Link, type ButtonProps } from "@chakra-ui/react"; import type { ReactElement } from "react"; import { NavLink } from "react-router-dom"; @@ -33,17 +33,18 @@ const styles = { type NavButtonProps = { readonly icon: ReactElement; + readonly isExternal?: boolean; readonly title?: string; readonly to?: string; } & ButtonProps; -export const NavButton = ({ icon, title, to, ...rest }: NavButtonProps) => +export const NavButton = ({ icon, isExternal = false, title, to, ...rest }: NavButtonProps) => to === undefined ? ( - ) : ( + ) : isExternal ? ( {({ isActive }: { readonly isActive: boolean }) => ( )} + ) : ( + + + ); diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/PluginMenuItem.tsx b/airflow-core/src/airflow/ui/src/layouts/Nav/PluginMenuItem.tsx new file mode 100644 index 0000000000000..28b777570501d --- /dev/null +++ b/airflow-core/src/airflow/ui/src/layouts/Nav/PluginMenuItem.tsx @@ -0,0 +1,92 @@ +/*! + * 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 { Box, Link, Image } from "@chakra-ui/react"; +import { LuPlug } from "react-icons/lu"; +import { Link as RouterLink } from "react-router-dom"; + +import type { ExternalViewResponse } from "openapi/requests/types.gen"; + +import { NavButton } from "./NavButton"; + +type Props = { readonly topLevel?: boolean } & ExternalViewResponse; + +export const PluginMenuItem = ({ href, icon, name, topLevel = false, url_route: urlRoute }: Props) => { + // External Link + if (urlRoute === undefined || urlRoute === null) { + return topLevel ? ( + + ) : ( + + ) + } + isExternal={true} + key={name} + title={name} + to={href} + /> + ) : ( + + + {name} + + + ); + } + + // Embedded External Link via iframes + if (topLevel) { + return ( + + ) : ( + + ) + } + key={name} + title={name} + to={`plugin/${urlRoute}`} + /> + ); + } + + return ( + + + {typeof icon === "string" ? ( + + ) : ( + + )} + {name} + + + ); +}; diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/PluginMenus.tsx b/airflow-core/src/airflow/ui/src/layouts/Nav/PluginMenus.tsx index 7ff5c58d3bbb7..13fde6fa66371 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Nav/PluginMenus.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Nav/PluginMenus.tsx @@ -16,98 +16,65 @@ * specific language governing permissions and limitations * under the License. */ -import { Box, Link, Image } from "@chakra-ui/react"; +import { Box } from "@chakra-ui/react"; import { useTranslation } from "react-i18next"; import { FiChevronRight } from "react-icons/fi"; import { LuPlug } from "react-icons/lu"; -import { Link as RouterLink } from "react-router-dom"; import { usePluginServiceGetPlugins } from "openapi/queries"; -import type { AppBuilderMenuItemResponse } from "openapi/requests/types.gen"; +import type { ExternalViewResponse } from "openapi/requests/types.gen"; import { Menu } from "src/components/ui"; import { NavButton } from "./NavButton"; +import { PluginMenuItem } from "./PluginMenuItem"; export const PluginMenus = () => { const { t: translate } = useTranslation("common"); const { data } = usePluginServiceGetPlugins(); - const menuPlugins = data?.plugins.filter((plugin) => plugin.appbuilder_menu_items.length > 0); - const iframePlugins = - data?.plugins.flatMap((plugin) => plugin.iframe_views).filter((view) => view.destination === "nav") ?? []; + const menuPlugins = + data?.plugins.flatMap((plugin) => plugin.external_views).filter((view) => view.destination === "nav") ?? + []; - // Only show iframe plugins in menu if there are more than 2 - const menuIframePlugins = iframePlugins.length > 2 ? iframePlugins : []; - const directIframePlugins = iframePlugins.length <= 2 ? iframePlugins : []; + // Only show external plugins in menu if there are more than 2 + const menuExternalViews = menuPlugins.length > 2 ? menuPlugins : []; + const directExternalViews = menuPlugins.length <= 2 ? menuPlugins : []; - if (data === undefined || (menuPlugins === undefined && iframePlugins.length === 0)) { + if (data === undefined || menuPlugins.length === 0) { return undefined; } - const categories: Record> = {}; - const buttons: Array = []; + const categories: Record> = {}; + const buttons: Array = []; - menuPlugins?.forEach((plugin) => { - plugin.appbuilder_menu_items.forEach((mi) => { - if (mi.category !== null && mi.category !== undefined) { - categories[mi.category] = [...(categories[mi.category] ?? []), mi]; - } else { - buttons.push(mi); - } - }); + menuPlugins.forEach((externalView) => { + if (externalView.category !== null && externalView.category !== undefined) { + categories[externalView.category] = [...(categories[externalView.category] ?? []), externalView]; + } else { + buttons.push(externalView); + } }); - if (!buttons.length && !Object.keys(categories).length && iframePlugins.length === 0) { + if (!buttons.length && !Object.keys(categories).length && menuPlugins.length === 0) { return undefined; } return ( <> - {directIframePlugins.map((plugin) => ( - - ) : ( - - ) - } - key={plugin.name} - title={plugin.name} - to={`plugin/${plugin.url_route ?? plugin.name.toLowerCase().replace(" ", "-")}`} - /> + {directExternalViews.map((externalView) => ( + ))} - {(menuIframePlugins.length > 0 || buttons.length > 0 || Object.keys(categories).length > 0) && ( + {menuExternalViews.length > 0 && ( } title={translate("nav.plugins")} /> - {menuIframePlugins.map((plugin) => ( - - - {typeof plugin.icon === "string" ? ( - - ) : ( - - )} - - {plugin.name} - - + {buttons.map((externalView) => ( + + ))} - {buttons.map(({ href, name }) => - href !== null && href !== undefined ? ( - - - {name} - - - ) : undefined, - )} {Object.entries(categories).map(([key, menuButtons]) => ( @@ -115,15 +82,11 @@ export const PluginMenus = () => { - {menuButtons.map(({ href, name }) => - href !== undefined && href !== null ? ( - - - {name} - - - ) : undefined, - )} + {menuButtons.map((externalView) => ( + + + + ))} ))} diff --git a/airflow-core/src/airflow/ui/src/pages/Iframe.tsx b/airflow-core/src/airflow/ui/src/pages/Iframe.tsx index 69327a6a999af..9256a463b08b9 100644 --- a/airflow-core/src/airflow/ui/src/pages/Iframe.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Iframe.tsx @@ -29,7 +29,7 @@ export const Iframe = () => { const { data: pluginData, isLoading } = usePluginServiceGetPlugins(); const iframeView = pluginData?.plugins - .flatMap((plugin) => plugin.iframe_views) + .flatMap((plugin) => plugin.external_views) .find((view) => (view.url_route ?? view.name.toLowerCase().replace(" ", "-")) === page); if (!iframeView) { @@ -48,7 +48,7 @@ export const Iframe = () => {