From ce86b4ebe7a39e7d4c95314fecc4fd490426543c Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Mon, 23 May 2022 16:45:46 +0100 Subject: [PATCH] Navigation: Show starred items in the NavBar (#49219) * switch saved items to starred items * hook up redux properly * Better query + hook up DashList * update initial state so it's never undefined * update GetDashboard call * use new star service * add scroll + maxwidth to navbar hover menu, sort starred items alphabetically * increase height, revert changes to CustomScrollbar * ellipsis! * update starred dashboard name in navtree * sort after renaming * limit to first 50 starred dashboards found --- packages/grafana-data/src/types/navModel.ts | 1 - pkg/api/index.go | 61 +++++++++++++------ .../components/NavBar/Next/NavBarItemMenu.tsx | 16 +++-- .../components/NavBar/Next/NavBarMenu.tsx | 32 +++------- .../components/NavBar/Next/NavBarMenuItem.tsx | 19 +++--- public/app/core/reducers/navBarTree.ts | 49 +++++++++------ .../actions/global.static.actions.ts | 2 +- .../dashboard/components/DashNav/DashNav.tsx | 5 +- .../SaveDashboard/useDashboardSave.tsx | 14 ++++- .../app/plugins/panel/dashlist/DashList.tsx | 5 ++ 10 files changed, 128 insertions(+), 76 deletions(-) diff --git a/packages/grafana-data/src/types/navModel.ts b/packages/grafana-data/src/types/navModel.ts index e706dde235f..986007310e0 100644 --- a/packages/grafana-data/src/types/navModel.ts +++ b/packages/grafana-data/src/types/navModel.ts @@ -29,7 +29,6 @@ export interface NavModelItem extends NavLinkDTO { highlightText?: string; highlightId?: string; tabSuffix?: ComponentType<{ className?: string }>; - hideFromNavbar?: boolean; showIconInNavbar?: boolean; } diff --git a/pkg/api/index.go b/pkg/api/index.go index 62d7e414a79..ae153feccdd 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -16,6 +16,7 @@ import ( "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/featuremgmt" pref "github.com/grafana/grafana/pkg/services/preference" + "github.com/grafana/grafana/pkg/services/star" "github.com/grafana/grafana/pkg/setting" ) @@ -173,18 +174,18 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool, prefs * navTree := []*dtos.NavLink{} if hs.Features.IsEnabled(featuremgmt.FlagSavedItems) { - savedItemsLinks, err := hs.buildSavedItemsNavLinks(c, prefs) + starredItemsLinks, err := hs.buildStarredItemsNavLinks(c, prefs) if err != nil { return nil, err } navTree = append(navTree, &dtos.NavLink{ - Text: "Saved items", - Id: "saved-items", - Icon: "bookmark", + Text: "Starred", + Id: "starred", + Icon: "star", SortWeight: dtos.WeightSavedItems, Section: dtos.NavSectionCore, - Children: savedItemsLinks, + Children: starredItemsLinks, }) } @@ -411,24 +412,50 @@ func (hs *HTTPServer) addHelpLinks(navTree []*dtos.NavLink, c *models.ReqContext return navTree } -func (hs *HTTPServer) buildSavedItemsNavLinks(c *models.ReqContext, prefs *pref.Preference) ([]*dtos.NavLink, error) { - savedItemsChildNavs := []*dtos.NavLink{} +func (hs *HTTPServer) buildStarredItemsNavLinks(c *models.ReqContext, prefs *pref.Preference) ([]*dtos.NavLink, error) { + starredItemsChildNavs := []*dtos.NavLink{} - // query preferences table for any saved items - savedItems := prefs.JSONData.Navbar.SavedItems + query := star.GetUserStarsQuery{ + UserID: c.SignedInUser.UserId, + } + + starredDashboardResult, err := hs.starService.GetByUser(c.Req.Context(), &query) + if err != nil { + return nil, err + } - if len(savedItems) > 0 { - for _, savedItem := range savedItems { - savedItemsChildNavs = append(savedItemsChildNavs, &dtos.NavLink{ - Id: savedItem.ID, - Text: savedItem.Text, - Url: savedItem.Url, - Target: savedItem.Target, + starredDashboards := []*models.Dashboard{} + starredDashboardsCounter := 0 + for dashboardId := range starredDashboardResult.UserStars { + // Set a loose limit to the first 50 starred dashboards found + if starredDashboardsCounter > 50 { + break + } + starredDashboardsCounter++ + query := &models.GetDashboardQuery{ + Id: dashboardId, + OrgId: c.OrgId, + } + err := hs.dashboardService.GetDashboard(c.Req.Context(), query) + if err == nil { + starredDashboards = append(starredDashboards, query.Result) + } + } + + if len(starredDashboards) > 0 { + sort.Slice(starredDashboards, func(i, j int) bool { + return starredDashboards[i].Title < starredDashboards[j].Title + }) + for _, starredItem := range starredDashboards { + starredItemsChildNavs = append(starredItemsChildNavs, &dtos.NavLink{ + Id: starredItem.Uid, + Text: starredItem.Title, + Url: starredItem.GetUrl(), }) } } - return savedItemsChildNavs, nil + return starredItemsChildNavs, nil } func (hs *HTTPServer) buildDashboardNavLinks(c *models.ReqContext, hasEditPerm bool) []*dtos.NavLink { diff --git a/public/app/core/components/NavBar/Next/NavBarItemMenu.tsx b/public/app/core/components/NavBar/Next/NavBarItemMenu.tsx index a865dac6196..08feb876127 100644 --- a/public/app/core/components/NavBar/Next/NavBarItemMenu.tsx +++ b/public/app/core/components/NavBar/Next/NavBarItemMenu.tsx @@ -12,6 +12,7 @@ import { useNavBarItemMenuContext } from '../context'; import { getNavModelItemKey } from '../utils'; import { NavBarItemMenuItem } from './NavBarItemMenuItem'; +import { NavBarScrollContainer } from './NavBarScrollContainer'; export interface NavBarItemMenuProps extends SpectrumMenuProps { onNavigate: (item: NavModelItem) => void; @@ -52,9 +53,7 @@ export function NavBarItemMenu(props: NavBarItemMenuProps): ReactElement | null const menuSubTitle = section.value.subTitle; - const sectionComponent = ( - - ); + const headerComponent = ; const itemComponents = items.map((item) => ( @@ -66,7 +65,14 @@ export function NavBarItemMenu(props: NavBarItemMenuProps): ReactElement | null ); - const menu = [sectionComponent, itemComponents, subTitleComponent]; + const contents = [itemComponents, subTitleComponent]; + const contentComponent = ( + + {reverseMenuDirection ? contents.reverse() : contents} + + ); + + const menu = [headerComponent, contentComponent]; return (
    @@ -84,6 +90,8 @@ function getStyles(theme: GrafanaTheme2, reverseDirection?: boolean) { display: flex; flex-direction: column; list-style: none; + max-height: 400px; + max-width: 300px; min-width: 140px; transition: ${theme.transitions.create('opacity')}; z-index: ${theme.zIndex.sidemenu}; diff --git a/public/app/core/components/NavBar/Next/NavBarMenu.tsx b/public/app/core/components/NavBar/Next/NavBarMenu.tsx index 27b9ac872c5..12c826aa3ca 100644 --- a/public/app/core/components/NavBar/Next/NavBarMenu.tsx +++ b/public/app/core/components/NavBar/Next/NavBarMenu.tsx @@ -16,6 +16,8 @@ import { NavBarItemWithoutMenu } from './NavBarItemWithoutMenu'; import { NavBarMenuItem } from './NavBarMenuItem'; import { NavBarToggle } from './NavBarToggle'; +const MENU_WIDTH = '350px'; + export interface Props { activeItem?: NavModelItem; isOpen: boolean; @@ -129,7 +131,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ itemList: css({ display: 'grid', gridAutoRows: `minmax(${theme.spacing(6)}, auto)`, - minWidth: '300px', + minWidth: MENU_WIDTH, }), menuCollapseIcon: css({ position: 'absolute', @@ -167,7 +169,7 @@ const getAnimStyles = (theme: GrafanaTheme2, animationDuration: number) => { boxShadow: theme.shadows.z3, width: '100%', [theme.breakpoints.up('md')]: { - width: '300px', + width: MENU_WIDTH, }, }; @@ -244,17 +246,6 @@ function NavItem({
); - } else if (link.id === 'saved-items') { - return ( - - No saved items - - ); } else { return (
  • @@ -270,7 +261,7 @@ function NavItem({ }} isActive={link === activeItem} > -
    +
    {getLinkIcon(link)}
    {link.text}
    @@ -287,18 +278,11 @@ const getNavItemStyles = (theme: GrafanaTheme2) => ({ }), item: css({ padding: `${theme.spacing(1)} ${theme.spacing(1.5)}`, + width: `calc(100% - ${theme.spacing(3)})`, '&::before': { display: 'none', }, }), - savedItems: css({ - background: theme.colors.background.secondary, - }), - savedItemsText: css({ - display: 'block', - paddingBottom: theme.spacing(2), - color: theme.colors.text.secondary, - }), flex: css({ display: 'flex', }), @@ -318,7 +302,7 @@ const getNavItemStyles = (theme: GrafanaTheme2) => ({ display: 'flex', placeContent: 'center', }), - savedItemsMenuItemWrapper: css({ + itemWithoutMenuContent: css({ display: 'grid', gridAutoFlow: 'column', gridTemplateColumns: `${theme.spacing(7)} auto`, @@ -388,7 +372,7 @@ const getCollapsibleStyles = (theme: GrafanaTheme2) => ({ position: 'relative', display: 'grid', gridAutoFlow: 'column', - gridTemplateColumns: `${theme.spacing(7)} auto`, + gridTemplateColumns: `${theme.spacing(7)} minmax(calc(${MENU_WIDTH} - ${theme.spacing(7)}), auto)`, }), collapsibleMenuItem: css({ height: theme.spacing(6), diff --git a/public/app/core/components/NavBar/Next/NavBarMenuItem.tsx b/public/app/core/components/NavBar/Next/NavBarMenuItem.tsx index 08f54daeb99..8eb61b400ae 100644 --- a/public/app/core/components/NavBar/Next/NavBarMenuItem.tsx +++ b/public/app/core/components/NavBar/Next/NavBarMenuItem.tsx @@ -29,12 +29,12 @@ export function NavBarMenuItem({ isMobile = false, }: Props) { const theme = useTheme2(); - const styles = getStyles(theme, isActive, Boolean(icon)); + const styles = getStyles(theme, isActive); const elStyle = cx(styles.element, styleOverrides); const linkContent = (
    {icon && } - {text} +
    {text}
    {target === '_blank' && ( )} @@ -77,12 +77,17 @@ export function NavBarMenuItem({ NavBarMenuItem.displayName = 'NavBarMenuItem'; -const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive'], hasIcon: boolean) => ({ +const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive']) => ({ linkContent: css({ - display: 'grid', - placeItems: 'center', - gridAutoFlow: 'column', + alignItems: 'center', + display: 'flex', gap: '0.5rem', + width: '100%', + }), + linkText: css({ + textOverflow: 'ellipsis', + overflow: 'hidden', + whiteSpace: 'nowrap', }), externalLinkIcon: css({ color: theme.colors.text.secondary, @@ -98,7 +103,7 @@ const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive'], hasIcon: b fontSize: 'inherit', height: '100%', overflowWrap: 'anywhere', - padding: !hasIcon ? `${theme.spacing(0.5, 2)}` : '5px 12px 5px 10px', + padding: theme.spacing(0.5, 2), textAlign: 'left', width: '100%', '&:hover, &:focus-visible': { diff --git a/public/app/core/reducers/navBarTree.ts b/public/app/core/reducers/navBarTree.ts index 3611e2501e4..6262c13879d 100644 --- a/public/app/core/reducers/navBarTree.ts +++ b/public/app/core/reducers/navBarTree.ts @@ -3,33 +3,42 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { NavModelItem } from '@grafana/data'; import config from 'app/core/config'; -const defaultPins = ((config.bootData?.navTree as NavModelItem[]) ?? []).map((n) => n.id).join(','); -const storedPins = (window.localStorage.getItem('pinnedNavItems') ?? defaultPins).split(','); - -export const initialState: NavModelItem[] = ((config.bootData?.navTree ?? []) as NavModelItem[]).map( - (n: NavModelItem) => ({ - ...n, - hideFromNavbar: n.id === undefined || !storedPins.includes(n.id), - }) -); +export const initialState: NavModelItem[] = config.bootData?.navTree ?? []; const navTreeSlice = createSlice({ name: 'navBarTree', initialState, reducers: { - togglePin: (state, action: PayloadAction<{ id: string }>) => { - const navItemIndex = state.findIndex((navItem) => navItem.id === action.payload.id); - state[navItemIndex].hideFromNavbar = !state[navItemIndex].hideFromNavbar; - window.localStorage.setItem( - 'pinnedNavItems', - state - .filter((n) => !n.hideFromNavbar) - .map((n) => n.id) - .join(',') - ); + setStarred: (state, action: PayloadAction<{ id: string; title: string; url: string; isStarred: boolean }>) => { + const starredItems = state.find((navItem) => navItem.id === 'starred'); + const { id, title, url, isStarred } = action.payload; + if (isStarred) { + const newStarredItem: NavModelItem = { + id, + text: title, + url, + }; + starredItems?.children?.push(newStarredItem); + starredItems?.children?.sort((a, b) => a.text.localeCompare(b.text)); + } else { + const index = starredItems?.children?.findIndex((item) => item.id === id) ?? -1; + if (index > -1) { + starredItems?.children?.splice(index, 1); + } + } + }, + updateDashboardName: (state, action: PayloadAction<{ id: string; title: string; url: string }>) => { + const { id, title, url } = action.payload; + const starredItems = state.find((navItem) => navItem.id === 'starred'); + const navItem = starredItems?.children?.find((navItem) => navItem.id === id); + if (navItem) { + navItem.text = title; + navItem.url = url; + starredItems?.children?.sort((a, b) => a.text.localeCompare(b.text)); + } }, }, }); -export const { togglePin } = navTreeSlice.actions; +export const { setStarred, updateDashboardName } = navTreeSlice.actions; export const navTreeReducer = navTreeSlice.reducer; diff --git a/public/app/features/commandPalette/actions/global.static.actions.ts b/public/app/features/commandPalette/actions/global.static.actions.ts index ba4e745b039..17b3d2a323b 100644 --- a/public/app/features/commandPalette/actions/global.static.actions.ts +++ b/public/app/features/commandPalette/actions/global.static.actions.ts @@ -144,7 +144,7 @@ export default (navBarTree: NavModelItem[]) => { navBarActionMap.forEach((navBarAction) => { const navBarItem = navBarTree.find((navBarItem) => navBarItem.url === navBarAction.url); - if (navBarItem && !navBarItem.hideFromNavbar) { + if (navBarItem) { navBarActions.push(...navBarAction.actions); } }); diff --git a/public/app/features/dashboard/components/DashNav/DashNav.tsx b/public/app/features/dashboard/components/DashNav/DashNav.tsx index 6b822f315a8..baf9919d11a 100644 --- a/public/app/features/dashboard/components/DashNav/DashNav.tsx +++ b/public/app/features/dashboard/components/DashNav/DashNav.tsx @@ -14,6 +14,7 @@ import { playlistSrv } from 'app/features/playlist/PlaylistSrv'; import { updateTimeZoneForSession } from 'app/features/profile/state/reducers'; import { KioskMode } from 'app/types'; +import { setStarred } from '../../../../core/reducers/navBarTree'; import { getDashboardSrv } from '../../services/DashboardSrv'; import { DashboardModel } from '../../state'; @@ -21,6 +22,7 @@ import { DashNavButton } from './DashNavButton'; import { DashNavTimeControls } from './DashNavTimeControls'; const mapDispatchToProps = { + setStarred, updateTimeZoneForSession, }; @@ -60,9 +62,10 @@ export const DashNav = React.memo((props) => { const onStarDashboard = () => { const dashboardSrv = getDashboardSrv(); - const { dashboard } = props; + const { dashboard, setStarred } = props; dashboardSrv.starDashboard(dashboard.id, dashboard.meta.isStarred).then((newState: any) => { + setStarred({ id: dashboard.uid, title: dashboard.title, url: dashboard.meta.url ?? '', isStarred: newState }); dashboard.meta.isStarred = newState; forceUpdate(); }); diff --git a/public/app/features/dashboard/components/SaveDashboard/useDashboardSave.tsx b/public/app/features/dashboard/components/SaveDashboard/useDashboardSave.tsx index 6c4237f855f..d864cc9f0a3 100644 --- a/public/app/features/dashboard/components/SaveDashboard/useDashboardSave.tsx +++ b/public/app/features/dashboard/components/SaveDashboard/useDashboardSave.tsx @@ -1,4 +1,5 @@ import { useEffect } from 'react'; +import { useDispatch } from 'react-redux'; import useAsyncFn from 'react-use/lib/useAsyncFn'; import { locationUtil } from '@grafana/data'; @@ -6,6 +7,7 @@ import { locationService, reportInteraction } from '@grafana/runtime'; import appEvents from 'app/core/app_events'; import { useAppNotification } from 'app/core/copy/appNotification'; import { contextSrv } from 'app/core/core'; +import { updateDashboardName } from 'app/core/reducers/navBarTree'; import { DashboardModel } from 'app/features/dashboard/state'; import { saveDashboard as saveDashboardApiCall } from 'app/features/manage-dashboards/state/actions'; import { DashboardSavedEvent } from 'app/types/events'; @@ -30,6 +32,7 @@ export const useDashboardSave = (dashboard: DashboardModel) => { await saveDashboard(clone, options, dashboard), [] ); + const dispatch = useDispatch(); const notifyApp = useAppNotification(); useEffect(() => { @@ -51,8 +54,17 @@ export const useDashboardSave = (dashboard: DashboardModel) => { if (newUrl !== currentPath) { setTimeout(() => locationService.replace(newUrl)); } + if (dashboard.meta.isStarred) { + dispatch( + updateDashboardName({ + id: dashboard.uid, + title: dashboard.title, + url: newUrl, + }) + ); + } } - }, [dashboard, state, notifyApp]); + }, [dashboard, state, notifyApp, dispatch]); return { state, onDashboardSave }; }; diff --git a/public/app/plugins/panel/dashlist/DashList.tsx b/public/app/plugins/panel/dashlist/DashList.tsx index 54c797899be..255ecffdb78 100644 --- a/public/app/plugins/panel/dashlist/DashList.tsx +++ b/public/app/plugins/panel/dashlist/DashList.tsx @@ -1,11 +1,13 @@ import { css, cx } from '@emotion/css'; import { take } from 'lodash'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useDispatch } from 'react-redux'; import { GrafanaTheme2, InterpolateFunction, PanelProps } from '@grafana/data'; import { CustomScrollbar, stylesFactory, useStyles2 } from '@grafana/ui'; import { Icon, IconProps } from '@grafana/ui/src/components/Icon/Icon'; import { getFocusStyles } from '@grafana/ui/src/themes/mixins'; +import { setStarred } from 'app/core/reducers/navBarTree'; import { getBackendSrv } from 'app/core/services/backend_srv'; import impressionSrv from 'app/core/services/impression_srv'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; @@ -82,6 +84,7 @@ async function fetchDashboards(options: PanelOptions, replaceVars: InterpolateFu export function DashList(props: PanelProps) { const [dashboards, setDashboards] = useState(new Map()); + const dispatch = useDispatch(); useEffect(() => { fetchDashboards(props.options, props.replaceVariables).then((dashes) => { setDashboards(dashes); @@ -89,6 +92,7 @@ export function DashList(props: PanelProps) { }, [props.options, props.replaceVariables, props.renderCounter]); const toggleDashboardStar = async (e: React.SyntheticEvent, dash: Dashboard) => { + const { uid, title, url } = dash; e.preventDefault(); e.stopPropagation(); @@ -96,6 +100,7 @@ export function DashList(props: PanelProps) { const updatedDashboards = new Map(dashboards); updatedDashboards.set(dash.id, { ...dash, isStarred }); setDashboards(updatedDashboards); + dispatch(setStarred({ id: uid ?? '', title, url, isStarred })); }; const [starredDashboards, recentDashboards, searchedDashboards] = useMemo(() => {