diff --git a/pkg/api/index.go b/pkg/api/index.go index fbe90024fc7..7a074352435 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -99,12 +99,17 @@ func (hs *HTTPServer) getAppLinks(c *models.ReqContext) ([]*dtos.NavLink, error) appLink := &dtos.NavLink{ Text: plugin.Name, Id: "plugin-page-" + plugin.ID, - Url: path.Join(hs.Cfg.AppSubURL, plugin.DefaultNavURL), Img: plugin.Info.Logos.Small, Section: dtos.NavSectionPlugin, SortWeight: dtos.WeightPlugin, } + if hs.Features.IsEnabled(featuremgmt.FlagTopnav) { + appLink.Url = path.Join(hs.Cfg.AppSubURL, "a", plugin.ID) + } else { + appLink.Url = path.Join(hs.Cfg.AppSubURL, plugin.DefaultNavURL) + } + for _, include := range plugin.Includes { if !c.HasUserRole(include.Role) { continue @@ -117,7 +122,7 @@ func (hs *HTTPServer) getAppLinks(c *models.ReqContext) ([]*dtos.NavLink, error) Url: hs.Cfg.AppSubURL + include.Path, Text: include.Name, } - if include.DefaultNav { + if include.DefaultNav && !hs.Features.IsEnabled(featuremgmt.FlagTopnav) { appLink.Url = link.Url // Overwrite the hardcoded page logic } } else { @@ -298,6 +303,11 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool, prefs * SortWeight: dtos.WeightConfig, Children: configNodes, } + if hs.Features.IsEnabled(featuremgmt.FlagTopnav) { + configNode.Url = "/admin" + } else { + configNode.Url = configNodes[0].Url + } navTree = append(navTree, configNode) } @@ -312,6 +322,7 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool, prefs * // Move server admin into Configuration and rename to administration if configNode != nil && serverAdminNode != nil { configNode.Text = "Administration" + serverAdminNode.Url = "/admin" configNode.Children = append(configNode.Children, serverAdminNode) adminNodeIndex := len(navTree) - 1 navTree = navTree[:adminNodeIndex] @@ -412,7 +423,6 @@ func (hs *HTTPServer) setupConfigNodes(c *models.ReqContext) ([]*dtos.NavLink, e Url: hs.Cfg.AppSubURL + "/org/serviceaccounts", }) } - return configNodes, nil } @@ -570,18 +580,21 @@ func (hs *HTTPServer) buildLegacyAlertNavLinks(c *models.ReqContext) []*dtos.Nav }) } - return []*dtos.NavLink{ - { - Text: "Alerting", - SubTitle: "Alert rules and notifications", - Id: "alerting-legacy", - Icon: "bell", - Url: hs.Cfg.AppSubURL + "/alerting/list", - Children: alertChildNavs, - Section: dtos.NavSectionCore, - SortWeight: dtos.WeightAlerting, - }, + var alertNav = dtos.NavLink{ + Text: "Alerting", + SubTitle: "Alert rules and notifications", + Id: "alerting-legacy", + Icon: "bell", + Children: alertChildNavs, + Section: dtos.NavSectionCore, + SortWeight: dtos.WeightAlerting, } + if hs.Features.IsEnabled(featuremgmt.FlagTopnav) { + alertNav.Url = hs.Cfg.AppSubURL + "/alerting" + } else { + alertNav.Url = hs.Cfg.AppSubURL + "/alerting/list" + } + return []*dtos.NavLink{&alertNav} } func (hs *HTTPServer) buildAlertNavLinks(c *models.ReqContext) []*dtos.NavLink { @@ -626,18 +639,23 @@ func (hs *HTTPServer) buildAlertNavLinks(c *models.ReqContext) []*dtos.NavLink { } if len(alertChildNavs) > 0 { - return []*dtos.NavLink{ - { - Text: "Alerting", - SubTitle: "Alert rules and notifications", - Id: "alerting", - Icon: "bell", - Url: hs.Cfg.AppSubURL + "/alerting/list", - Children: alertChildNavs, - Section: dtos.NavSectionCore, - SortWeight: dtos.WeightAlerting, - }, + var alertNav = dtos.NavLink{ + Text: "Alerting", + SubTitle: "Alert rules and notifications", + Id: "alerting", + Icon: "bell", + Children: alertChildNavs, + Section: dtos.NavSectionCore, + SortWeight: dtos.WeightAlerting, } + + if hs.Features.IsEnabled(featuremgmt.FlagTopnav) { + alertNav.Url = hs.Cfg.AppSubURL + "/alerting" + } else { + alertNav.Url = hs.Cfg.AppSubURL + "/alerting/list" + } + + return []*dtos.NavLink{&alertNav} } return nil } diff --git a/public/app/core/components/AppChrome/NavLandingPage.tsx b/public/app/core/components/AppChrome/NavLandingPage.tsx new file mode 100644 index 00000000000..97360816691 --- /dev/null +++ b/public/app/core/components/AppChrome/NavLandingPage.tsx @@ -0,0 +1,82 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { toIconName, useStyles2 } from '@grafana/ui'; +import { Page } from 'app/core/components/Page/Page'; +import { useNavModel } from 'app/core/hooks/useNavModel'; + +import { NavLandingPageCard } from './NavLandingPageCard'; + +interface Props { + navId: string; +} + +export function NavLandingPage({ navId }: Props) { + const { node } = useNavModel(navId); + const styles = useStyles2(getStyles); + const directChildren = node.children?.filter((child) => !child.hideFromTabs && !child.children); + const nestedChildren = node.children?.filter((child) => child.children && child.children.length); + + return ( + + +
+ {directChildren && directChildren.length > 0 && ( +
+ {directChildren?.map((child) => ( + + ))} +
+ )} + {nestedChildren?.map((child) => ( +
+
+

{child.text}

+
+
{child.subTitle}
+
+ {child.children?.map((child) => ( + + ))} +
+
+ ))} +
+
+
+ ); +} + +const getStyles = (theme: GrafanaTheme2) => ({ + content: css({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(2), + }), + grid: css({ + display: 'grid', + gap: theme.spacing(2), + gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', + gridAutoRows: '200px', + padding: theme.spacing(2, 1), + }), + nestedTitle: css({ + margin: theme.spacing(2, 0), + }), + nestedDescription: css({ + color: theme.colors.text.secondary, + }), +}); diff --git a/public/app/core/components/AppChrome/NavLandingPageCard.tsx b/public/app/core/components/AppChrome/NavLandingPageCard.tsx new file mode 100644 index 00000000000..a9ed8c98909 --- /dev/null +++ b/public/app/core/components/AppChrome/NavLandingPageCard.tsx @@ -0,0 +1,29 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Card, Icon, IconName, useStyles2 } from '@grafana/ui'; + +interface Props { + description?: string; + icon?: IconName; + text: string; + url: string; +} + +export function NavLandingPageCard({ description, icon, text, url }: Props) { + const styles = useStyles2(getStyles); + return ( + + {text} + {icon && } + {description} + + ); +} + +const getStyles = (theme: GrafanaTheme2) => ({ + card: css({ + marginBottom: 0, + }), +}); diff --git a/public/app/features/alerting/routes.tsx b/public/app/features/alerting/routes.tsx index e85f2ed3130..e0fccbfbc3a 100644 --- a/public/app/features/alerting/routes.tsx +++ b/public/app/features/alerting/routes.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { Redirect } from 'react-router-dom'; import { OrgRole } from '@grafana/data'; +import { NavLandingPage } from 'app/core/components/AppChrome/NavLandingPage'; import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport'; import { config } from 'app/core/config'; import { RouteDescriptor } from 'app/core/navigation/types'; @@ -13,8 +14,8 @@ import { evaluateAccess } from './unified/utils/access-control'; const commonRoutes: RouteDescriptor[] = [ { path: '/alerting', - // eslint-disable-next-line react/display-name - component: () => , + component: () => + config.featureToggles.topnav ? : , }, ]; diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx index e4b1b63238f..947c8e19dde 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Redirect } from 'react-router-dom'; +import { NavLandingPage } from 'app/core/components/AppChrome/NavLandingPage'; import ErrorPage from 'app/core/components/ErrorPage/ErrorPage'; import { LoginPage } from 'app/core/components/Login/LoginPage'; import config from 'app/core/config'; @@ -12,6 +13,7 @@ import { getRoutes as getDataConnectionsRoutes } from 'app/features/data-connect import { DATASOURCES_ROUTES } from 'app/features/datasources/constants'; import { getLiveRoutes } from 'app/features/live/pages/routes'; import { getRoutes as getPluginCatalogRoutes } from 'app/features/plugins/admin/routes'; +import AppRootPage from 'app/features/plugins/components/AppRootPage'; import { getProfileRoutes } from 'app/features/profile/routes'; import { ServiceAccountPage } from 'app/features/serviceaccounts/ServiceAccountPage'; import { AccessControlAction, DashboardRoutes } from 'app/types'; @@ -20,9 +22,33 @@ import { SafeDynamicImport } from '../core/components/DynamicImports/SafeDynamic import { RouteDescriptor } from '../core/navigation/types'; import { getPublicDashboardRoutes } from '../features/dashboard/routes'; +import { pluginHasRootPage } from './utils'; + export const extraRoutes: RouteDescriptor[] = []; export function getAppRoutes(): RouteDescriptor[] { + const topnavRoutes: RouteDescriptor[] = config.featureToggles.topnav + ? [ + { + path: '/apps', + component: () => , + }, + { + path: '/a/:pluginId', + exact: true, + component: (props) => { + const hasRoot = pluginHasRootPage(props.match.params.pluginId, config.bootData.navTree); + const hasQueryParams = Object.keys(props.queryParams).length > 0; + return hasRoot || hasQueryParams ? ( + + ) : ( + + ); + }, + }, + ] + : []; + return [ { path: '/', @@ -179,6 +205,7 @@ export function getAppRoutes(): RouteDescriptor[] { : import(/* webpackChunkName: "explore-feature-toggle-page" */ 'app/features/explore/FeatureTogglePage') ), }, + ...topnavRoutes, { path: '/a/:pluginId/', exact: false, @@ -272,8 +299,7 @@ export function getAppRoutes(): RouteDescriptor[] { { path: '/admin', - // eslint-disable-next-line react/display-name - component: () => , + component: () => (config.featureToggles.topnav ? : ), }, { path: '/admin/settings', diff --git a/public/app/routes/utils.ts b/public/app/routes/utils.ts index e6b3acf55be..745776d76d9 100644 --- a/public/app/routes/utils.ts +++ b/public/app/routes/utils.ts @@ -1,3 +1,14 @@ +import { NavLinkDTO } from '@grafana/data'; + export function isSoloRoute(path: string): boolean { return /(d-solo|dashboard-solo)/.test(path?.toLowerCase()); } + +export function pluginHasRootPage(pluginId: string, navTree: NavLinkDTO[]): boolean { + return Boolean( + navTree + .find((navLink) => navLink.id === 'apps') + ?.children?.find((app) => app.id === `plugin-page-${pluginId}`) + ?.children?.some((page) => page.url?.endsWith(`/a/${pluginId}`)) + ); +}