Navigation: Proof-of-concept for pinning navbar items (#44775)

pull/45687/head
kay delaney 3 years ago committed by GitHub
parent 7c826cb43f
commit b6682cdcb9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      packages/grafana-data/src/types/navModel.ts
  2. 1
      packages/grafana-ui/src/types/icon.ts
  3. 4
      public/app/core/components/NavBar/NavBarItem.tsx
  4. 2
      public/app/core/components/NavBar/NavBarMenu.test.tsx
  5. 20
      public/app/core/components/NavBar/NavBarMenu.tsx
  6. 96
      public/app/core/components/NavBar/NavBarMenuItem.tsx
  7. 29
      public/app/core/components/NavBar/NavBarNext.tsx
  8. 27
      public/app/core/reducers/navBarTree.ts
  9. 1423
      public/test/mocks/navModel.ts
  10. 35
      public/test/redux-rtl.tsx

@ -22,6 +22,7 @@ export interface NavModelItem {
highlightText?: string; highlightText?: string;
highlightId?: string; highlightId?: string;
tabSuffix?: ComponentType<{ className?: string }>; tabSuffix?: ComponentType<{ className?: string }>;
hideFromNavbar?: boolean;
} }
export enum NavSection { export enum NavSection {

@ -5,6 +5,7 @@ export type IconSize = ComponentSize | 'xl' | 'xxl' | 'xxxl';
export const getAvailableIcons = () => export const getAvailableIcons = () =>
[ [
'anchor',
'angle-double-down', 'angle-double-down',
'angle-double-right', 'angle-double-right',
'angle-double-up', 'angle-double-up',

@ -33,7 +33,9 @@ const NavBarItem = ({
const { i18n } = useLingui(); const { i18n } = useLingui();
const theme = useTheme2(); const theme = useTheme2();
const menuItems = link.children ?? []; const menuItems = link.children ?? [];
const menuItemsSorted = reverseMenuDirection ? menuItems.reverse() : menuItems;
// Spreading `menuItems` here as otherwise we'd be mutating props
const menuItemsSorted = reverseMenuDirection ? [...menuItems].reverse() : menuItems;
const filteredItems = menuItemsSorted const filteredItems = menuItemsSorted
.filter((item) => !item.hideFromMenu) .filter((item) => !item.hideFromMenu)
.map((i) => ({ ...i, menuItemType: NavMenuItemType.Item })); .map((i) => ({ ...i, menuItemType: NavMenuItemType.Item }));

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { NavModelItem } from '@grafana/data'; import { NavModelItem } from '@grafana/data';
import { render, screen } from '@testing-library/react'; import { render, screen } from 'test/redux-rtl';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { NavBarMenu } from './NavBarMenu'; import { NavBarMenu } from './NavBarMenu';

@ -6,6 +6,9 @@ import { useDialog } from '@react-aria/dialog';
import { useOverlay } from '@react-aria/overlays'; import { useOverlay } from '@react-aria/overlays';
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { NavBarMenuItem } from './NavBarMenuItem'; import { NavBarMenuItem } from './NavBarMenuItem';
import { useDispatch } from 'react-redux';
import { togglePin } from 'app/core/reducers/navBarTree';
import { getConfig } from 'app/core/config';
export interface Props { export interface Props {
activeItem?: NavModelItem; activeItem?: NavModelItem;
@ -14,6 +17,11 @@ export interface Props {
} }
export function NavBarMenu({ activeItem, navItems, onClose }: Props) { export function NavBarMenu({ activeItem, navItems, onClose }: Props) {
const dispatch = useDispatch();
const toggleItemPin = (id: string) => {
dispatch(togglePin({ id }));
};
const theme = useTheme2(); const theme = useTheme2();
const styles = getStyles(theme); const styles = getStyles(theme);
const ref = useRef(null); const ref = useRef(null);
@ -27,6 +35,7 @@ export function NavBarMenu({ activeItem, navItems, onClose }: Props) {
ref ref
); );
const newNavigationEnabled = getConfig().featureToggles.newNavigation;
return ( return (
<FocusScope contain restoreFocus autoFocus> <FocusScope contain restoreFocus autoFocus>
<div data-testid="navbarmenu" className={styles.container} ref={ref} {...overlayProps} {...dialogProps}> <div data-testid="navbarmenu" className={styles.container} ref={ref} {...overlayProps} {...dialogProps}>
@ -37,8 +46,8 @@ export function NavBarMenu({ activeItem, navItems, onClose }: Props) {
<nav className={styles.content}> <nav className={styles.content}>
<CustomScrollbar> <CustomScrollbar>
<ul> <ul>
{navItems.map((link, index) => ( {navItems.map((link) => (
<div className={styles.section} key={index}> <div className={styles.section} key={link.text}>
<NavBarMenuItem <NavBarMenuItem
isActive={activeItem === link} isActive={activeItem === link}
onClick={() => { onClick={() => {
@ -50,12 +59,15 @@ export function NavBarMenu({ activeItem, navItems, onClose }: Props) {
text={link.text} text={link.text}
url={link.url} url={link.url}
isMobile={true} isMobile={true}
pinned={!link.hideFromNavbar}
canPin={newNavigationEnabled && link.id !== 'search'}
onTogglePin={() => link.id && toggleItemPin(link.id)}
/> />
{link.children?.map( {link.children?.map(
(childLink, childIndex) => (childLink) =>
!childLink.divider && ( !childLink.divider && (
<NavBarMenuItem <NavBarMenuItem
key={childIndex} key={childLink.text}
icon={childLink.icon as IconName} icon={childLink.icon as IconName}
isActive={activeItem === childLink} isActive={activeItem === childLink}
isDivider={childLink.divider} isDivider={childLink.divider}

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { Icon, IconName, Link, useTheme2 } from '@grafana/ui'; import { Icon, IconButton, IconName, Link, useTheme2 } from '@grafana/ui';
import { css } from '@emotion/css'; import { css, cx } from '@emotion/css';
export interface Props { export interface Props {
icon?: IconName; icon?: IconName;
@ -14,6 +14,9 @@ export interface Props {
url?: string; url?: string;
adjustHeightForBorder?: boolean; adjustHeightForBorder?: boolean;
isMobile?: boolean; isMobile?: boolean;
canPin?: boolean;
pinned?: boolean;
onTogglePin?: () => void;
} }
export function NavBarMenuItem({ export function NavBarMenuItem({
@ -26,10 +29,20 @@ export function NavBarMenuItem({
text, text,
url, url,
isMobile = false, isMobile = false,
canPin = false,
pinned = false,
onTogglePin,
}: Props) { }: Props) {
const theme = useTheme2(); const theme = useTheme2();
const styles = getStyles(theme, isActive, styleOverrides); const styles = getStyles(theme, isActive, styleOverrides);
const onClickPin = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
onTogglePin?.();
};
const linkContent = ( const linkContent = (
<div className={styles.linkContent}> <div className={styles.linkContent}>
<div> <div>
@ -60,30 +73,77 @@ export function NavBarMenuItem({
</a> </a>
); );
} }
if (isMobile) { if (isMobile) {
return isDivider ? ( return isDivider ? (
<li data-testid="dropdown-child-divider" className={styles.divider} tabIndex={-1} aria-disabled /> <li data-testid="dropdown-child-divider" className={styles.divider} tabIndex={-1} aria-disabled />
) : ( ) : (
<li>{element}</li> <li className={styles.listItem}>
{element}
{canPin && (
<IconButton
name="anchor"
className={cx('pin-button', styles.pinButton, { [styles.visible]: pinned })}
onClick={onClickPin}
tooltip={`${pinned ? 'Unpin' : 'Pin'} menu item`}
/>
)}
</li>
); );
} }
return isDivider ? ( return isDivider ? (
<div data-testid="dropdown-child-divider" className={styles.divider} tabIndex={-1} aria-disabled /> <div data-testid="dropdown-child-divider" className={styles.divider} tabIndex={-1} aria-disabled />
) : ( ) : (
<>{element}</> <div style={{ position: 'relative' }}>{element}</div>
); );
} }
NavBarMenuItem.displayName = 'NavBarMenuItem'; NavBarMenuItem.displayName = 'NavBarMenuItem';
const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive'], styleOverrides: Props['styleOverrides']) => ({ const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive'], styleOverrides: Props['styleOverrides']) => ({
visible: css`
color: ${theme.colors.text.primary} !important;
opacity: 100% !important;
`,
divider: css` divider: css`
border-bottom: 1px solid ${theme.colors.border.weak}; border-bottom: 1px solid ${theme.colors.border.weak};
height: 1px; height: 1px;
margin: ${theme.spacing(1)} 0; margin: ${theme.spacing(1)} 0;
overflow: hidden; overflow: hidden;
`, `,
listItem: css`
position: relative;
display: flex;
align-items: center;
&:hover,
&:focus-within {
color: ${theme.colors.text.primary};
> *:first-child::after {
background-color: ${theme.colors.action.hover};
}
}
> .pin-button {
opacity: 0;
}
&:hover > .pin-button,
&:focus-visible > .pin-button {
opacity: 100%;
}
`,
pinButton: css`
position: relative;
flex-shrink: 2;
color: ${theme.colors.text.secondary};
&:focus-visible {
opacity: 100%;
}
`,
element: css` element: css`
align-items: center; align-items: center;
background: none; background: none;
@ -93,22 +153,23 @@ const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive'], styleOverr
font-size: inherit; font-size: inherit;
height: 100%; height: 100%;
padding: 5px 12px 5px 10px; padding: 5px 12px 5px 10px;
position: relative;
text-align: left; text-align: left;
white-space: nowrap; white-space: nowrap;
width: 100%;
&:hover, &:focus-visible + .pin-button {
&:focus-visible { opacity: 100%;
background-color: ${theme.colors.action.hover};
color: ${theme.colors.text.primary};
} }
&:focus-visible { &:focus-visible {
outline: none;
box-shadow: none; box-shadow: none;
outline: 2px solid ${theme.colors.primary.main};
outline-offset: -2px; &::after {
transition: none; box-shadow: none;
outline: 2px solid ${theme.colors.primary.main};
outline-offset: -2px;
transition: none;
}
} }
&::before { &::before {
@ -123,6 +184,15 @@ const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive'], styleOverr
background-image: ${theme.colors.gradients.brandVertical}; background-image: ${theme.colors.gradients.brandVertical};
} }
&::after {
position: absolute;
content: '';
left: 0;
top: 0;
bottom: 0;
right: 0;
}
${styleOverrides}; ${styleOverrides};
`, `,
externalLinkIcon: css` externalLinkIcon: css`

@ -14,7 +14,7 @@ import { NavBarMenu } from './NavBarMenu';
import NavBarItem from './NavBarItem'; import NavBarItem from './NavBarItem';
import { NavBarItemWithoutMenu } from './NavBarItemWithoutMenu'; import { NavBarItemWithoutMenu } from './NavBarItemWithoutMenu';
import { Branding } from '../Branding/Branding'; import { Branding } from '../Branding/Branding';
import { connect, ConnectedProps } from 'react-redux'; import { useSelector } from 'react-redux';
const onOpenSearch = () => { const onOpenSearch = () => {
locationService.partial({ search: 'open' }); locationService.partial({ search: 'open' });
@ -27,17 +27,9 @@ const searchItem: NavModelItem = {
icon: 'search', icon: 'search',
}; };
const mapStateToProps = (state: StoreState) => ({ export const NavBarNext = React.memo(() => {
navBarTree: state.navBarTree, const navBarTree = useSelector((state: StoreState) => state.navBarTree);
});
const mapDispatchToProps = {};
const connector = connect(mapStateToProps, mapDispatchToProps);
export interface Props extends ConnectedProps<typeof connector> {}
export const NavBarNextUnconnected = React.memo(({ navBarTree }: Props) => {
const theme = useTheme2(); const theme = useTheme2();
const styles = getStyles(theme); const styles = getStyles(theme);
const location = useLocation(); const location = useLocation();
@ -48,12 +40,15 @@ export const NavBarNextUnconnected = React.memo(({ navBarTree }: Props) => {
}; };
const navTree = cloneDeep(navBarTree); const navTree = cloneDeep(navBarTree);
const coreItems = navTree.filter((item) => item.section === NavSection.Core); const coreItems = navTree.filter((item) => item.section === NavSection.Core);
const pinnedCoreItems = coreItems.filter((item) => !item.hideFromNavbar);
const pluginItems = navTree.filter((item) => item.section === NavSection.Plugin); const pluginItems = navTree.filter((item) => item.section === NavSection.Plugin);
const pinnedPluginItems = pluginItems.filter((item) => !item.hideFromNavbar);
const configItems = enrichConfigItems( const configItems = enrichConfigItems(
navTree.filter((item) => item.section === NavSection.Config), navTree.filter((item) => item.section === NavSection.Config),
location, location,
toggleSwitcherModal toggleSwitcherModal
); );
const pinnedConfigItems = configItems.filter((item) => !item.hideFromNavbar);
const activeItem = isSearchActive(location) ? searchItem : getActiveItem(navTree, location.pathname); const activeItem = isSearchActive(location) ? searchItem : getActiveItem(navTree, location.pathname);
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
@ -77,7 +72,7 @@ export const NavBarNextUnconnected = React.memo(({ navBarTree }: Props) => {
</NavBarSection> </NavBarSection>
<NavBarSection> <NavBarSection>
{coreItems.map((link, index) => ( {pinnedCoreItems.map((link, index) => (
<NavBarItem <NavBarItem
key={`${link.id}-${index}`} key={`${link.id}-${index}`}
isActive={isMatchOrChildMatch(link, activeItem)} isActive={isMatchOrChildMatch(link, activeItem)}
@ -89,9 +84,9 @@ export const NavBarNextUnconnected = React.memo(({ navBarTree }: Props) => {
))} ))}
</NavBarSection> </NavBarSection>
{pluginItems.length > 0 && ( {pinnedPluginItems.length > 0 && (
<NavBarSection> <NavBarSection>
{pluginItems.map((link, index) => ( {pinnedPluginItems.map((link, index) => (
<NavBarItem key={`${link.id}-${index}`} isActive={isMatchOrChildMatch(link, activeItem)} link={link}> <NavBarItem key={`${link.id}-${index}`} isActive={isMatchOrChildMatch(link, activeItem)} link={link}>
{link.icon && <Icon name={link.icon as IconName} size="xl" />} {link.icon && <Icon name={link.icon as IconName} size="xl" />}
{link.img && <img src={link.img} alt={`${link.text} logo`} />} {link.img && <img src={link.img} alt={`${link.text} logo`} />}
@ -103,7 +98,7 @@ export const NavBarNextUnconnected = React.memo(({ navBarTree }: Props) => {
<div className={styles.spacer} /> <div className={styles.spacer} />
<NavBarSection> <NavBarSection>
{configItems.map((link, index) => ( {pinnedConfigItems.map((link, index) => (
<NavBarItem <NavBarItem
key={`${link.id}-${index}`} key={`${link.id}-${index}`}
isActive={isMatchOrChildMatch(link, activeItem)} isActive={isMatchOrChildMatch(link, activeItem)}
@ -128,9 +123,7 @@ export const NavBarNextUnconnected = React.memo(({ navBarTree }: Props) => {
); );
}); });
NavBarNextUnconnected.displayName = 'NavBarNext'; NavBarNext.displayName = 'NavBarNext';
export const NavBarNext = connector(NavBarNextUnconnected);
const getStyles = (theme: GrafanaTheme2) => ({ const getStyles = (theme: GrafanaTheme2) => ({
search: css` search: css`

@ -1,15 +1,32 @@
import { createSlice } from '@reduxjs/toolkit'; import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { NavModelItem } from '@grafana/data'; import { NavModelItem } from '@grafana/data';
import config from 'app/core/config'; import config from 'app/core/config';
export const initialState: NavModelItem[] = config.bootData.navTree; const defaultPins = ['home', 'dashboards', 'explore', 'alerting'].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),
}));
const navTreeSlice = createSlice({ const navTreeSlice = createSlice({
name: 'navBarTree', name: 'navBarTree',
initialState, initialState,
reducers: {}, 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(',')
);
},
},
}); });
export const {} = navTreeSlice.actions; export const { togglePin } = navTreeSlice.actions;
export const navTreeReducer = navTreeSlice.reducer; export const navTreeReducer = navTreeSlice.reducer;

File diff suppressed because it is too large Load Diff

@ -0,0 +1,35 @@
import React from 'react';
import { render as rtlRender } from '@testing-library/react';
import { AnyAction, configureStore } from '@reduxjs/toolkit';
import { ThunkMiddlewareFor } from '@reduxjs/toolkit/dist/getDefaultMiddleware';
import { Provider } from 'react-redux';
import { createRootReducer } from 'app/core/reducers/root';
import { StoreState } from 'app/types';
import { mockNavModel } from './mocks/navModel';
function render(
ui: React.ReactElement,
{
preloadedState = { navIndex: mockNavModel },
store = configureStore<
StoreState,
AnyAction,
ReadonlyArray<ThunkMiddlewareFor<StoreState, { thunk: true; serializableCheck: false; immutableCheck: false }>>
>({
reducer: createRootReducer(),
preloadedState,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({ thunk: true, serializableCheck: false, immutableCheck: false }),
}),
...renderOptions
}: { preloadedState?: Partial<StoreState>; store?: ReturnType<typeof configureStore> } = {}
) {
function Wrapper({ children }: { children: React.ReactNode }) {
return <Provider store={store}>{children}</Provider>;
}
return rtlRender(ui, { wrapper: Wrapper, ...renderOptions });
}
export * from '@testing-library/react';
export { render };
Loading…
Cancel
Save