Navigation: Show list of pinned items on MegaMenu (#90280)

* Navigation: Show list of pinned ites on the navigation

* Rename section to 'Bookmarks'

* Internationalization

* Rename everything to bookmarks

* Update public/app/core/reducers/navBarTree.ts

Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com>

* Ignore empty message as well

* Dont update navigation if there is an error patching

---------

Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com>
This commit is contained in:
Joao Silva 2024-07-22 11:43:40 +01:00 committed by GitHub
parent 38ac0f3506
commit 546f4aa700
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 127 additions and 27 deletions

View File

@ -53,7 +53,7 @@ Content-Type: application/json
"timezone": "utc", "timezone": "utc",
"weekStart": "", "weekStart": "",
"navbar": { "navbar": {
"savedItemIds": null "bookmarkIds": null
}, },
"queryHistory": { "queryHistory": {
"homeTab": "" "homeTab": ""
@ -142,7 +142,7 @@ Content-Type: application/json
"timezone": "", "timezone": "",
"weekStart": "", "weekStart": "",
"navbar": { "navbar": {
"savedItemIds": null "bookmarkIds": null
}, },
"queryHistory": { "queryHistory": {
"homeTab": "" "homeTab": ""

View File

@ -49,7 +49,7 @@ lineage: schemas: [{
} @cuetsy(kind="interface") } @cuetsy(kind="interface")
#NavbarPreference: { #NavbarPreference: {
savedItemIds: [...string] bookmarkIds: [...string]
} @cuetsy(kind="interface") } @cuetsy(kind="interface")
} }
}] }]

View File

@ -22,11 +22,11 @@ export interface CookiePreferences {
} }
export interface NavbarPreference { export interface NavbarPreference {
savedItemIds: Array<string>; bookmarkIds: Array<string>;
} }
export const defaultNavbarPreference: Partial<NavbarPreference> = { export const defaultNavbarPreference: Partial<NavbarPreference> = {
savedItemIds: [], bookmarkIds: [],
}; };
/** /**

View File

@ -18,7 +18,7 @@ type CookiePreferences struct {
// NavbarPreference defines model for NavbarPreference. // NavbarPreference defines model for NavbarPreference.
type NavbarPreference struct { type NavbarPreference struct {
SavedItemIds []string `json:"savedItemIds"` BookmarkIds []string `json:"bookmarkIds"`
} }
// QueryHistoryPreference defines model for QueryHistoryPreference. // QueryHistoryPreference defines model for QueryHistoryPreference.

View File

@ -12,6 +12,7 @@ const (
// any items with default weight. // any items with default weight.
WeightHome = (iota - 20) * 100 WeightHome = (iota - 20) * 100
WeightBookmarks
WeightSavedItems WeightSavedItems
WeightDashboard WeightDashboard
WeightExplore WeightExplore

View File

@ -163,6 +163,20 @@ func (s *ServiceImpl) GetNavTree(c *contextmodel.ReqContext, prefs *pref.Prefere
return nil, err return nil, err
} }
if s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagPinNavItems) {
bookmarks := s.buildBookmarksNavLinks(prefs, treeRoot)
treeRoot.AddSection(&navtree.NavLink{
Text: "Bookmarks",
Id: "bookmarks",
Icon: "bookmark",
SortWeight: navtree.WeightBookmarks,
Children: bookmarks,
EmptyMessageId: "bookmarks-empty",
Url: s.cfg.AppSubURL + "/bookmarks",
})
}
return treeRoot, nil return treeRoot, nil
} }
@ -316,6 +330,38 @@ func (s *ServiceImpl) buildStarredItemsNavLinks(c *contextmodel.ReqContext) ([]*
return starredItemsChildNavs, nil return starredItemsChildNavs, nil
} }
func (s *ServiceImpl) buildBookmarksNavLinks(prefs *pref.Preference, treeRoot *navtree.NavTreeRoot) []*navtree.NavLink {
bookmarksChildNavs := []*navtree.NavLink{}
bookmarkIds := prefs.JSONData.Navbar.BookmarkIds
if len(bookmarkIds) > 0 {
for _, id := range bookmarkIds {
item := treeRoot.FindById(id)
if item != nil {
bookmarksChildNavs = append(bookmarksChildNavs, &navtree.NavLink{
Id: item.Id,
Text: item.Text,
SubTitle: item.SubTitle,
Icon: item.Icon,
Img: item.Img,
Url: item.Url,
Target: item.Target,
HideFromTabs: item.HideFromTabs,
RoundIcon: item.RoundIcon,
IsSection: item.IsSection,
HighlightText: item.HighlightText,
HighlightID: item.HighlightID,
PluginID: item.PluginID,
IsCreateAction: item.IsCreateAction,
Keywords: item.Keywords,
})
}
}
}
return bookmarksChildNavs
}
func (s *ServiceImpl) buildDashboardNavLinks(c *contextmodel.ReqContext) []*navtree.NavLink { func (s *ServiceImpl) buildDashboardNavLinks(c *contextmodel.ReqContext) []*navtree.NavLink {
hasAccess := ac.HasAccess(s.accessControl, c) hasAccess := ac.HasAccess(s.accessControl, c)

View File

@ -98,7 +98,7 @@ type QueryHistoryPreference struct {
} }
type NavbarPreference struct { type NavbarPreference struct {
SavedItemIds []string `json:"savedItemIds"` BookmarkIds []string `json:"bookmarkIds"`
} }
func (j *PreferenceJSONData) FromDB(data []byte) error { func (j *PreferenceJSONData) FromDB(data []byte) error {

View File

@ -97,11 +97,11 @@ func GetPreferencesFor(ctx context.Context,
dto.Language = &preference.JSONData.Language dto.Language = &preference.JSONData.Language
} }
if preference.JSONData.Navbar.SavedItemIds != nil { if preference.JSONData.Navbar.BookmarkIds != nil {
dto.Navbar = &preferences.NavbarPreference{ dto.Navbar = &preferences.NavbarPreference{
SavedItemIds: []string{}, BookmarkIds: []string{},
} }
dto.Navbar.SavedItemIds = preference.JSONData.Navbar.SavedItemIds dto.Navbar.BookmarkIds = preference.JSONData.Navbar.BookmarkIds
} }
if preference.JSONData.QueryHistory.HomeTab != "" { if preference.JSONData.QueryHistory.HomeTab != "" {

View File

@ -71,8 +71,8 @@ func (s *Service) GetWithDefaults(ctx context.Context, query *pref.GetPreference
res.JSONData.QueryHistory.HomeTab = p.JSONData.QueryHistory.HomeTab res.JSONData.QueryHistory.HomeTab = p.JSONData.QueryHistory.HomeTab
} }
if p.JSONData.Navbar.SavedItemIds != nil { if p.JSONData.Navbar.BookmarkIds != nil {
res.JSONData.Navbar.SavedItemIds = p.JSONData.Navbar.SavedItemIds res.JSONData.Navbar.BookmarkIds = p.JSONData.Navbar.BookmarkIds
} }
if p.JSONData.CookiePreferences != nil { if p.JSONData.CookiePreferences != nil {
@ -174,11 +174,11 @@ func (s *Service) Patch(ctx context.Context, cmd *pref.PatchPreferenceCommand) e
preference.JSONData.Language = *cmd.Language preference.JSONData.Language = *cmd.Language
} }
if cmd.Navbar != nil && cmd.Navbar.SavedItemIds != nil { if cmd.Navbar != nil && cmd.Navbar.BookmarkIds != nil {
if preference.JSONData == nil { if preference.JSONData == nil {
preference.JSONData = &pref.PreferenceJSONData{} preference.JSONData = &pref.PreferenceJSONData{}
} }
preference.JSONData.Navbar.SavedItemIds = cmd.Navbar.SavedItemIds preference.JSONData.Navbar.BookmarkIds = cmd.Navbar.BookmarkIds
} }
if cmd.QueryHistory != nil { if cmd.QueryHistory != nil {

View File

@ -17050,7 +17050,7 @@
"type": "object", "type": "object",
"title": "NavbarPreference defines model for NavbarPreference.", "title": "NavbarPreference defines model for NavbarPreference.",
"properties": { "properties": {
"savedItemIds": { "bookmarkIds": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"

View File

@ -3,14 +3,15 @@ import { DOMAttributes } from '@react-types/shared';
import { memo, forwardRef, useCallback } from 'react'; import { memo, forwardRef, useCallback } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2, NavModelItem } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
import { config, reportInteraction } from '@grafana/runtime'; import { config, reportInteraction } from '@grafana/runtime';
import { CustomScrollbar, Icon, IconButton, useStyles2, Stack } from '@grafana/ui'; import { CustomScrollbar, Icon, IconButton, useStyles2, Stack } from '@grafana/ui';
import { useGrafana } from 'app/core/context/GrafanaContext'; import { useGrafana } from 'app/core/context/GrafanaContext';
import { t } from 'app/core/internationalization'; import { t } from 'app/core/internationalization';
import { setBookmark } from 'app/core/reducers/navBarTree';
import { usePatchUserPreferencesMutation } from 'app/features/preferences/api/index'; import { usePatchUserPreferencesMutation } from 'app/features/preferences/api/index';
import { useSelector } from 'app/types'; import { useDispatch, useSelector } from 'app/types';
import { MegaMenuItem } from './MegaMenuItem'; import { MegaMenuItem } from './MegaMenuItem';
import { usePinnedItems } from './hooks'; import { usePinnedItems } from './hooks';
@ -28,6 +29,7 @@ export const MegaMenu = memo(
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const location = useLocation(); const location = useLocation();
const { chrome } = useGrafana(); const { chrome } = useGrafana();
const dispatch = useDispatch();
const state = chrome.useState(); const state = chrome.useState();
const [patchPreferences] = usePatchUserPreferencesMutation(); const [patchPreferences] = usePatchUserPreferencesMutation();
const pinnedItems = usePinnedItems(); const pinnedItems = usePinnedItems();
@ -61,7 +63,8 @@ export const MegaMenu = memo(
[pinnedItems] [pinnedItems]
); );
const onPinItem = (id?: string) => { const onPinItem = (item: NavModelItem) => {
const id = item.id;
if (id && config.featureToggles.pinNavItems) { if (id && config.featureToggles.pinNavItems) {
const navItem = navTree.find((item) => item.id === id); const navItem = navTree.find((item) => item.id === id);
const isSaved = isPinned(id); const isSaved = isPinned(id);
@ -73,9 +76,13 @@ export const MegaMenu = memo(
patchPreferences({ patchPreferences({
patchPrefsCmd: { patchPrefsCmd: {
navbar: { navbar: {
savedItemIds: newItems, bookmarkIds: newItems,
}, },
}, },
}).then((data) => {
if (!data.error) {
dispatch(setBookmark({ item: item, isSaved: !isSaved }));
}
}); });
} }
}; };

View File

@ -19,7 +19,7 @@ interface Props {
activeItem?: NavModelItem; activeItem?: NavModelItem;
onClick?: () => void; onClick?: () => void;
level?: number; level?: number;
onPin: (id?: string) => void; onPin: (item: NavModelItem) => void;
isPinned: (id?: string) => boolean; isPinned: (id?: string) => boolean;
} }
@ -105,7 +105,7 @@ export function MegaMenuItem({ link, activeItem, level = 0, onClick, onPin, isPi
target={link.target} target={link.target}
url={link.url} url={link.url}
id={link.id} id={link.id}
onPin={onPin} onPin={() => onPin(link)}
isPinned={isPinned(link.id)} isPinned={isPinned(link.id)}
> >
<div <div

View File

@ -32,7 +32,8 @@ export function MegaMenuItemText({ children, isActive, onClick, target, url, id,
} }
{config.featureToggles.pinNavItems && ( {config.featureToggles.pinNavItems && (
<Icon <Icon
name={isPinned ? 'favorite' : 'star'} name="bookmark"
type={isPinned ? 'solid' : 'default'}
className={'pin-icon'} className={'pin-icon'}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();

View File

@ -5,7 +5,7 @@ import { useGetUserPreferencesQuery } from 'app/features/preferences/api';
export const usePinnedItems = () => { export const usePinnedItems = () => {
const preferences = useGetUserPreferencesQuery(); const preferences = useGetUserPreferencesQuery();
const pinnedItems = useMemo(() => preferences.data?.navbar?.savedItemIds || [], [preferences]); const pinnedItems = useMemo(() => preferences.data?.navbar?.bookmarkIds || [], [preferences]);
if (config.featureToggles.pinNavItems) { if (config.featureToggles.pinNavItems) {
return pinnedItems; return pinnedItems;

View File

@ -51,6 +51,31 @@ const navTreeSlice = createSlice({
} }
} }
}, },
setBookmark: (state, action: PayloadAction<{ item: NavModelItem; isSaved: boolean }>) => {
if (!config.featureToggles.pinNavItems) {
return;
}
const bookmarks = state.find((navItem) => navItem.id === 'bookmarks');
const { item, isSaved } = action.payload;
if (bookmarks) {
if (isSaved) {
if (!bookmarks.children) {
bookmarks.children = [];
}
const newBookmark: NavModelItem = {
...item,
// Clear the children, sortWeight and empty message of the item
children: [],
sortWeight: 0,
emptyMessageId: '',
emptyMessage: '',
};
bookmarks.children.push(newBookmark);
} else {
bookmarks.children = bookmarks.children?.filter((i) => i.id !== item.id) ?? [];
}
}
},
updateDashboardName: (state, action: PayloadAction<{ id: string; title: string; url: string }>) => { updateDashboardName: (state, action: PayloadAction<{ id: string; title: string; url: string }>) => {
const { id, title, url } = action.payload; const { id, title, url } = action.payload;
const starredItems = state.find((navItem) => navItem.id === 'starred'); const starredItems = state.find((navItem) => navItem.id === 'starred');
@ -73,5 +98,5 @@ const navTreeSlice = createSlice({
}, },
}); });
export const { setStarred, removePluginFromNavTree, updateDashboardName } = navTreeSlice.actions; export const { setStarred, removePluginFromNavTree, updateDashboardName, setBookmark } = navTreeSlice.actions;
export const navTreeReducer = navTreeSlice.reducer; export const navTreeReducer = navTreeSlice.reducer;

View File

@ -22,6 +22,10 @@ export function getNavTitle(navId: string | undefined) {
return t('nav.create-import.title', 'Import dashboard'); return t('nav.create-import.title', 'Import dashboard');
case 'alert': case 'alert':
return t('nav.create-alert.title', 'New alert rule'); return t('nav.create-alert.title', 'New alert rule');
case 'bookmarks':
return t('nav.bookmarks.title', 'Bookmarks');
case 'bookmarks-empty':
return t('nav.bookmarks-empty.title', 'Bookmark pages for them to appear here');
case 'starred': case 'starred':
return t('nav.starred.title', 'Starred'); return t('nav.starred.title', 'Starred');
case 'starred-empty': case 'starred-empty':

View File

@ -38,7 +38,7 @@ export type CookiePreferencesDefinesModelForCookiePreferences = {
}; };
}; };
export type NavbarPreferenceDefinesModelForNavbarPreference = { export type NavbarPreferenceDefinesModelForNavbarPreference = {
savedItemIds?: string[]; bookmarkIds?: string[];
}; };
export type QueryHistoryPreferenceDefinesModelForQueryHistoryPreference = { export type QueryHistoryPreferenceDefinesModelForQueryHistoryPreference = {
/** HomeTab one of: '' | 'query' | 'starred'; */ /** HomeTab one of: '' | 'query' | 'starred'; */
@ -66,7 +66,7 @@ export type ErrorResponseBody = {
/** a human readable version of the error */ /** a human readable version of the error */
message: string; message: string;
/** Status An optional status to denote the cause of the error. /** Status An optional status to denote the cause of the error.
For example, a 412 Precondition Failed error may include additional information of why that error happened. */ For example, a 412 Precondition Failed error may include additional information of why that error happened. */
status?: string; status?: string;
}; };

View File

@ -514,6 +514,10 @@ export function getAppRoutes(): RouteDescriptor[] {
() => import(/* webpackChunkName: "DataTrailsPage"*/ 'app/features/trails/DataTrailsPage') () => import(/* webpackChunkName: "DataTrailsPage"*/ 'app/features/trails/DataTrailsPage')
), ),
}, },
{
path: '/bookmarks',
component: () => <NavLandingPage navId="bookmarks" />,
},
...getPluginCatalogRoutes(), ...getPluginCatalogRoutes(),
...getSupportBundleRoutes(), ...getSupportBundleRoutes(),
...getAlertingRoutes(), ...getAlertingRoutes(),

View File

@ -1188,6 +1188,12 @@
"authentication": { "authentication": {
"title": "Authentication" "title": "Authentication"
}, },
"bookmarks": {
"title": "Bookmarks"
},
"bookmarks-empty": {
"title": "Bookmark pages for them to appear here"
},
"collector": { "collector": {
"title": "Collector" "title": "Collector"
}, },

View File

@ -1188,6 +1188,12 @@
"authentication": { "authentication": {
"title": "Åūŧĥęʼnŧįčäŧįőʼn" "title": "Åūŧĥęʼnŧįčäŧįőʼn"
}, },
"bookmarks": {
"title": "ßőőĸmäřĸş"
},
"bookmarks-empty": {
"title": "ßőőĸmäřĸ päģęş ƒőř ŧĥęm ŧő äppęäř ĥęřę"
},
"collector": { "collector": {
"title": "Cőľľęčŧőř" "title": "Cőľľęčŧőř"
}, },

View File

@ -7146,7 +7146,7 @@
}, },
"NavbarPreference": { "NavbarPreference": {
"properties": { "properties": {
"savedItemIds": { "bookmarkIds": {
"items": { "items": {
"type": "string" "type": "string"
}, },