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
pull/49431/head
Ashley Harrison 4 years ago committed by GitHub
parent 8c753999df
commit ce86b4ebe7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      packages/grafana-data/src/types/navModel.ts
  2. 61
      pkg/api/index.go
  3. 16
      public/app/core/components/NavBar/Next/NavBarItemMenu.tsx
  4. 32
      public/app/core/components/NavBar/Next/NavBarMenu.tsx
  5. 19
      public/app/core/components/NavBar/Next/NavBarMenuItem.tsx
  6. 49
      public/app/core/reducers/navBarTree.ts
  7. 2
      public/app/features/commandPalette/actions/global.static.actions.ts
  8. 5
      public/app/features/dashboard/components/DashNav/DashNav.tsx
  9. 14
      public/app/features/dashboard/components/SaveDashboard/useDashboardSave.tsx
  10. 5
      public/app/plugins/panel/dashlist/DashList.tsx

@ -29,7 +29,6 @@ export interface NavModelItem extends NavLinkDTO {
highlightText?: string;
highlightId?: string;
tabSuffix?: ComponentType<{ className?: string }>;
hideFromNavbar?: boolean;
showIconInNavbar?: boolean;
}

@ -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 {

@ -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<NavModelItem> {
onNavigate: (item: NavModelItem) => void;
@ -52,9 +53,7 @@ export function NavBarItemMenu(props: NavBarItemMenuProps): ReactElement | null
const menuSubTitle = section.value.subTitle;
const sectionComponent = (
<NavBarItemMenuItem key={section.key} item={section} state={state} onNavigate={onNavigate} />
);
const headerComponent = <NavBarItemMenuItem key={section.key} item={section} state={state} onNavigate={onNavigate} />;
const itemComponents = items.map((item) => (
<NavBarItemMenuItem key={getNavModelItemKey(item.value)} item={item} state={state} onNavigate={onNavigate} />
@ -66,7 +65,14 @@ export function NavBarItemMenu(props: NavBarItemMenuProps): ReactElement | null
</li>
);
const menu = [sectionComponent, itemComponents, subTitleComponent];
const contents = [itemComponents, subTitleComponent];
const contentComponent = (
<NavBarScrollContainer key="scrollContainer">
{reverseMenuDirection ? contents.reverse() : contents}
</NavBarScrollContainer>
);
const menu = [headerComponent, contentComponent];
return (
<ul className={styles.menu} ref={ref} {...mergeProps(menuProps, contextMenuProps)} tabIndex={menuHasFocus ? 0 : -1}>
@ -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};

@ -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({
</ul>
</CollapsibleNavItem>
);
} else if (link.id === 'saved-items') {
return (
<CollapsibleNavItem
onClose={onClose}
link={link}
isActive={isMatchOrChildMatch(link, activeItem)}
className={styles.savedItems}
>
<em className={styles.savedItemsText}>No saved items</em>
</CollapsibleNavItem>
);
} else {
return (
<li className={styles.flex}>
@ -270,7 +261,7 @@ function NavItem({
}}
isActive={link === activeItem}
>
<div className={styles.savedItemsMenuItemWrapper}>
<div className={styles.itemWithoutMenuContent}>
<div className={styles.iconContainer}>{getLinkIcon(link)}</div>
<span className={styles.linkText}>{link.text}</span>
</div>
@ -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),

@ -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 = (
<div className={styles.linkContent}>
{icon && <Icon data-testid="dropdown-child-icon" name={icon} />}
<span>{text}</span>
<div className={styles.linkText}>{text}</div>
{target === '_blank' && (
<Icon data-testid="external-link-icon" name="external-link-alt" className={styles.externalLinkIcon} />
)}
@ -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': {

@ -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;

@ -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);
}
});

@ -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>((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();
});

@ -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 };
};

@ -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<PanelOptions>) {
const [dashboards, setDashboards] = useState(new Map<number, Dashboard>());
const dispatch = useDispatch();
useEffect(() => {
fetchDashboards(props.options, props.replaceVariables).then((dashes) => {
setDashboards(dashes);
@ -89,6 +92,7 @@ export function DashList(props: PanelProps<PanelOptions>) {
}, [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<PanelOptions>) {
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(() => {

Loading…
Cancel
Save