From 08272dbacbe172aaeb1d9bed532868cc4853ecc0 Mon Sep 17 00:00:00 2001 From: Brent Bovenzi Date: Wed, 2 Jul 2025 15:50:48 -0400 Subject: [PATCH] Add external view plugin categories to admin, browse, docs, user --- .../ui/src/layouts/Nav/AdminButton.tsx | 26 ++++-- .../ui/src/layouts/Nav/BrowseButton.tsx | 26 ++++-- .../airflow/ui/src/layouts/Nav/DocsButton.tsx | 11 ++- .../src/airflow/ui/src/layouts/Nav/Nav.tsx | 37 +++++++-- .../ui/src/layouts/Nav/PluginMenuItem.tsx | 83 +++++++------------ .../ui/src/layouts/Nav/PluginMenus.tsx | 76 +++++++++-------- .../ui/src/layouts/Nav/TimezoneMenuItem.tsx | 55 ++++++++++++ .../ui/src/layouts/Nav/UserSettingsButton.tsx | 42 +++------- .../src/airflow/ui/src/pages/Iframe.tsx | 2 +- 9 files changed, 218 insertions(+), 140 deletions(-) create mode 100644 airflow-core/src/airflow/ui/src/layouts/Nav/TimezoneMenuItem.tsx diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/AdminButton.tsx b/airflow-core/src/airflow/ui/src/layouts/Nav/AdminButton.tsx index a54c144a04aea..383a880d3d253 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Nav/AdminButton.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Nav/AdminButton.tsx @@ -18,12 +18,13 @@ */ import { useTranslation } from "react-i18next"; import { FiSettings } from "react-icons/fi"; -import { Link } from "react-router-dom"; +import { Link as RouterLink } from "react-router-dom"; -import type { MenuItem } from "openapi/requests/types.gen"; +import type { MenuItem, ExternalViewResponse } from "openapi/requests/types.gen"; import { Menu } from "src/components/ui"; import { NavButton } from "./NavButton"; +import { PluginMenuItem } from "./PluginMenuItem"; const links = [ { @@ -52,19 +53,25 @@ const links = [ }, ]; -export const AdminButton = ({ authorizedMenuItems }: { readonly authorizedMenuItems: Array }) => { +export const AdminButton = ({ + authorizedMenuItems, + externalViews, +}: { + readonly authorizedMenuItems: Array; + readonly externalViews: Array; +}) => { const { t: translate } = useTranslation("common"); const menuItems = links .filter(({ title }) => authorizedMenuItems.includes(title as MenuItem)) .map((link) => ( - + {translate(`admin.${link.title}`)} - + )); - if (!menuItems.length) { + if (!menuItems.length && !externalViews.length) { return undefined; } @@ -73,7 +80,12 @@ export const AdminButton = ({ authorizedMenuItems }: { readonly authorizedMenuIt } title={translate("nav.admin")} /> - {menuItems} + + {menuItems} + {externalViews.map((view) => ( + + ))} + ); }; diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/BrowseButton.tsx b/airflow-core/src/airflow/ui/src/layouts/Nav/BrowseButton.tsx index b0bbdb47c2e8b..09b986a55f75b 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Nav/BrowseButton.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Nav/BrowseButton.tsx @@ -18,12 +18,13 @@ */ import { useTranslation } from "react-i18next"; import { FiGlobe } from "react-icons/fi"; -import { Link } from "react-router-dom"; +import { Link as RouterLink } from "react-router-dom"; -import type { MenuItem } from "openapi/requests/types.gen"; +import type { MenuItem, ExternalViewResponse } from "openapi/requests/types.gen"; import { Menu } from "src/components/ui"; import { NavButton } from "./NavButton"; +import { PluginMenuItem } from "./PluginMenuItem"; const links = [ { @@ -38,19 +39,25 @@ const links = [ }, ]; -export const BrowseButton = ({ authorizedMenuItems }: { readonly authorizedMenuItems: Array }) => { +export const BrowseButton = ({ + authorizedMenuItems, + externalViews, +}: { + readonly authorizedMenuItems: Array; + readonly externalViews: Array; +}) => { const { t: translate } = useTranslation("common"); const menuItems = links .filter(({ title }) => authorizedMenuItems.includes(title as MenuItem)) .map((link) => ( - + {translate(`browse.${link.key}`)} - + )); - if (!menuItems.length) { + if (!menuItems.length && !externalViews.length) { return undefined; } @@ -59,7 +66,12 @@ export const BrowseButton = ({ authorizedMenuItems }: { readonly authorizedMenuI } title={translate("nav.browse")} /> - {menuItems} + + {menuItems} + {externalViews.map((view) => ( + + ))} + ); }; diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/DocsButton.tsx b/airflow-core/src/airflow/ui/src/layouts/Nav/DocsButton.tsx index 6fef8bbb0e25b..6044b0e448c59 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Nav/DocsButton.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Nav/DocsButton.tsx @@ -18,12 +18,14 @@ */ import { Link } from "@chakra-ui/react"; import { useTranslation } from "react-i18next"; -import { FiBookOpen } from "react-icons/fi"; +import { FiBookOpen, FiExternalLink } from "react-icons/fi"; +import type { ExternalViewResponse } from "openapi/requests/types.gen"; import { Menu } from "src/components/ui"; import { useConfig } from "src/queries/useConfig"; import { NavButton } from "./NavButton"; +import { PluginMenuItem } from "./PluginMenuItem"; const baseUrl = document.querySelector("base")?.href ?? "http://localhost:8080/"; @@ -43,9 +45,11 @@ const links = [ ]; export const DocsButton = ({ + externalViews, showAPI, version, }: { + readonly externalViews: Array; readonly showAPI?: boolean; readonly version?: string; }) => { @@ -71,6 +75,7 @@ export const DocsButton = ({ target="_blank" > {translate(`docs.${link.key}`)} + ))} @@ -78,9 +83,13 @@ export const DocsButton = ({ {version} + )} + {externalViews.map((view) => ( + + ))} ); diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/Nav.tsx b/airflow-core/src/airflow/ui/src/layouts/Nav/Nav.tsx index 606326235e46f..7f3046a7a70a5 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Nav/Nav.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Nav/Nav.tsx @@ -21,7 +21,11 @@ import { useTranslation } from "react-i18next"; import { FiDatabase, FiHome } from "react-icons/fi"; import { NavLink } from "react-router-dom"; -import { useAuthLinksServiceGetAuthMenus, useVersionServiceGetVersion } from "openapi/queries"; +import { + useAuthLinksServiceGetAuthMenus, + useVersionServiceGetVersion, + usePluginServiceGetPlugins, +} from "openapi/queries"; import { AirflowPin } from "src/assets/AirflowPin"; import { DagIcon } from "src/assets/DagIcon"; @@ -36,8 +40,21 @@ import { UserSettingsButton } from "./UserSettingsButton"; export const Nav = () => { const { data } = useVersionServiceGetVersion(); const { data: authLinks } = useAuthLinksServiceGetAuthMenus(); + const { data: pluginData } = usePluginServiceGetPlugins(); const { t: translate } = useTranslation("common"); + // Get external views with nav destination + const navExternalViews = + pluginData?.plugins + .flatMap((plugin) => plugin.external_views) + .filter((view) => view.destination === "nav") ?? []; + + // Categorize external views by their category + const browseViews = navExternalViews.filter((view) => view.category?.toLowerCase() === "browse"); + const adminViews = navExternalViews.filter((view) => view.category?.toLowerCase() === "admin"); + const docsViews = navExternalViews.filter((view) => view.category?.toLowerCase() === "docs"); + const userViews = navExternalViews.filter((view) => view.category?.toLowerCase() === "user"); + return ( { title={translate("nav.assets")} to="assets" /> - - + + - - + + ); diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/PluginMenuItem.tsx b/airflow-core/src/airflow/ui/src/layouts/Nav/PluginMenuItem.tsx index c9d0d041310cf..429c41c977d6e 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Nav/PluginMenuItem.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Nav/PluginMenuItem.tsx @@ -16,7 +16,8 @@ * specific language governing permissions and limitations * under the License. */ -import { Box, Link, Image } from "@chakra-ui/react"; +import { Link, Image, Menu } from "@chakra-ui/react"; +import { FiExternalLink } from "react-icons/fi"; import { LuPlug } from "react-icons/lu"; import { RiArchiveStackLine } from "react-icons/ri"; import { Link as RouterLink } from "react-router-dom"; @@ -28,24 +29,32 @@ 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 ? ( + const pluginIcon = + typeof icon === "string" ? ( + + ) : urlRoute === "legacy-fab-views" ? ( + + ) : ( + + ); + + const isExternal = urlRoute === undefined || urlRoute === null; + + if (topLevel) { + return ( - ) : ( - - ) - } - isExternal={true} + icon={pluginIcon} + isExternal={isExternal} key={name} title={name} - to={href} + to={isExternal ? href : `plugin/${urlRoute}`} /> - ) : ( - + ); + } + + return ( + + {isExternal ? ( + {pluginIcon} {name} + - - ); - } - - // Embedded External Link via iframes - if (topLevel) { - return ( - - ) : ( - - ) - } - key={name} - title={name} - to={`plugin/${urlRoute}`} - /> - ); - } - - return ( - - - - {typeof icon === "string" ? ( - - ) : urlRoute === "legacy-fab-views" ? ( - - ) : ( - - )} + ) : ( + + {pluginIcon} {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 f853fad75f5f3..b8b1833930dc7 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Nav/PluginMenus.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Nav/PluginMenus.tsx @@ -28,6 +28,9 @@ import { Menu } from "src/components/ui"; import { NavButton } from "./NavButton"; import { PluginMenuItem } from "./PluginMenuItem"; +// Define existing button categories to filter out +const existingCategories = ["user", "docs", "admin", "browse"]; + export const PluginMenus = () => { const { t: translate } = useTranslation("common"); const { data } = usePluginServiceGetPlugins(); @@ -36,6 +39,13 @@ export const PluginMenus = () => { data?.plugins.flatMap((plugin) => plugin.external_views).filter((view) => view.destination === "nav") ?? []; + // Filter out plugins with categories that match existing buttons + menuPlugins = menuPlugins.filter((view) => { + const category = view.category?.toLowerCase(); + + return category === undefined || !existingCategories.includes(category); + }); + const hasLegacyViews = ( data?.plugins @@ -61,10 +71,6 @@ export const PluginMenus = () => { return undefined; } - // Only show external plugins in menu if there are more than 2 - const menuExternalViews = menuPlugins.length > 2 ? menuPlugins : []; - const directExternalViews = menuPlugins.length <= 2 ? menuPlugins : []; - const categories: Record> = {}; const buttons: Array = []; @@ -80,40 +86,32 @@ export const PluginMenus = () => { return undefined; } - return ( - <> - {directExternalViews.map((externalView) => ( - - ))} - {menuExternalViews.length > 0 && ( - - - } title={translate("nav.plugins")} /> - - - {buttons.map((externalView) => ( - - - - ))} - {Object.entries(categories).map(([key, menuButtons]) => ( - - - {key} - - - - {menuButtons.map((externalView) => ( - - - - ))} - - - ))} - - - )} - + // Show plugins in menu if there are more than 2 + return menuPlugins.length > 2 ? ( + + + } title={translate("nav.plugins")} /> + + + {buttons.map((externalView) => ( + + ))} + {Object.entries(categories).map(([key, menuButtons]) => ( + + + {key} + + + + {menuButtons.map((externalView) => ( + + ))} + + + ))} + + + ) : ( + menuPlugins.map((plugin) => ) ); }; diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/TimezoneMenuItem.tsx b/airflow-core/src/airflow/ui/src/layouts/Nav/TimezoneMenuItem.tsx new file mode 100644 index 0000000000000..360560ca199f9 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/layouts/Nav/TimezoneMenuItem.tsx @@ -0,0 +1,55 @@ +/*! + * 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 dayjs from "dayjs"; +import timezone from "dayjs/plugin/timezone"; +import utc from "dayjs/plugin/utc"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { FiClock } from "react-icons/fi"; + +import { Menu } from "src/components/ui"; +import { useTimezone } from "src/context/timezone"; + +dayjs.extend(utc); +dayjs.extend(timezone); + +export const TimezoneMenuItem = ({ onOpen }: { readonly onOpen: () => void }) => { + const { t: translate } = useTranslation(); + const { selectedTimezone } = useTimezone(); + const [time, setTime] = useState(dayjs()); + + useEffect(() => { + const updateTime = () => { + setTime(dayjs()); + }; + + updateTime(); + + const interval = setInterval(updateTime, 1000); + + return () => clearInterval(interval); + }, [selectedTimezone]); + + return ( + + + {translate("timezone")}: {dayjs(time).tz(selectedTimezone).format("HH:mm z (Z)")} + + ); +}; diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/UserSettingsButton.tsx b/airflow-core/src/airflow/ui/src/layouts/Nav/UserSettingsButton.tsx index 3b9321525ad58..dbb2afe4c22d3 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Nav/UserSettingsButton.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Nav/UserSettingsButton.tsx @@ -17,50 +17,34 @@ * under the License. */ import { useDisclosure } from "@chakra-ui/react"; -import dayjs from "dayjs"; -import timezone from "dayjs/plugin/timezone"; -import utc from "dayjs/plugin/utc"; -import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { FiClock, FiGrid, FiLogOut, FiMoon, FiSun, FiUser, FiGlobe } from "react-icons/fi"; +import { FiGrid, FiLogOut, FiMoon, FiSun, FiUser, FiGlobe } from "react-icons/fi"; import { MdOutlineAccountTree } from "react-icons/md"; import { useLocalStorage } from "usehooks-ts"; +import type { ExternalViewResponse } from "openapi/requests/types.gen"; import { Menu } from "src/components/ui"; import { useColorMode } from "src/context/colorMode/useColorMode"; -import { useTimezone } from "src/context/timezone"; import LanguageModal from "./LanguageModal"; import LogoutModal from "./LogoutModal"; import { NavButton } from "./NavButton"; +import { PluginMenuItem } from "./PluginMenuItem"; +import { TimezoneMenuItem } from "./TimezoneMenuItem"; import TimezoneModal from "./TimezoneModal"; -dayjs.extend(utc); -dayjs.extend(timezone); - -export const UserSettingsButton = () => { +export const UserSettingsButton = ({ + externalViews, +}: { + readonly externalViews: Array; +}) => { const { t: translate } = useTranslation(); const { colorMode, toggleColorMode } = useColorMode(); const { onClose: onCloseTimezone, onOpen: onOpenTimezone, open: isOpenTimezone } = useDisclosure(); const { onClose: onCloseLogout, onOpen: onOpenLogout, open: isOpenLogout } = useDisclosure(); const { onClose: onCloseLanguage, onOpen: onOpenLanguage, open: isOpenLanguage } = useDisclosure(); - const { selectedTimezone } = useTimezone(); const [dagView, setDagView] = useLocalStorage<"graph" | "grid">("default_dag_view", "grid"); - const [time, setTime] = useState(dayjs()); - - useEffect(() => { - const updateTime = () => { - setTime(dayjs()); - }; - - updateTime(); - - const interval = setInterval(updateTime, 1000); - - return () => clearInterval(interval); - }, [selectedTimezone]); - return ( @@ -100,10 +84,10 @@ export const UserSettingsButton = () => { )} - - - {translate("timezone")}: {dayjs(time).tz(selectedTimezone).format("HH:mm z (Z)")} - + + {externalViews.map((view) => ( + + ))} {translate("logout")} diff --git a/airflow-core/src/airflow/ui/src/pages/Iframe.tsx b/airflow-core/src/airflow/ui/src/pages/Iframe.tsx index c544668b2ae5a..bac9843ce85b6 100644 --- a/airflow-core/src/airflow/ui/src/pages/Iframe.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Iframe.tsx @@ -26,7 +26,7 @@ import { ProgressBar } from "src/components/ui"; import { ErrorPage } from "./Error"; export const Iframe = ({ sandbox = "allow-same-origin allow-forms" }: { readonly sandbox: string }) => { - const { t: translate } = useTranslation("common"); + const { t: translate } = useTranslation(); const { page } = useParams(); const { data: pluginData, isLoading } = usePluginServiceGetPlugins();