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

This commit is contained in:
kay delaney 2022-02-21 15:25:47 +00:00 committed by GitHub
parent 7c826cb43f
commit b6682cdcb9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 1596 additions and 42 deletions

View File

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

View File

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

View File

@ -33,7 +33,9 @@ const NavBarItem = ({
const { i18n } = useLingui();
const theme = useTheme2();
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
.filter((item) => !item.hideFromMenu)
.map((i) => ({ ...i, menuItemType: NavMenuItemType.Item }));

View File

@ -1,6 +1,6 @@
import React from 'react';
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 { NavBarMenu } from './NavBarMenu';

View File

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

View File

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

View File

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

View File

@ -1,15 +1,32 @@
import { createSlice } from '@reduxjs/toolkit';
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { NavModelItem } from '@grafana/data';
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({
name: 'navBarTree',
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;

File diff suppressed because it is too large Load Diff

35
public/test/redux-rtl.tsx Normal file
View File

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