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
This commit is contained in:
@@ -29,7 +29,6 @@ export interface NavModelItem extends NavLinkDTO {
|
||||
highlightText?: string;
|
||||
highlightId?: string;
|
||||
tabSuffix?: ComponentType<{ className?: string }>;
|
||||
hideFromNavbar?: boolean;
|
||||
showIconInNavbar?: boolean;
|
||||
}
|
||||
|
||||
|
||||
+44
-17
@@ -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,
|
||||
}
|
||||
|
||||
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,
|
||||
starredDashboardResult, err := hs.starService.GetByUser(c.Req.Context(), &query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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(() => {
|
||||
|
||||
Reference in New Issue
Block a user