mirror of
https://github.com/grafana/grafana.git
synced 2025-01-07 22:53:56 -06:00
Navigation: Proof-of-concept for pinning navbar items (#44775)
This commit is contained in:
parent
7c826cb43f
commit
b6682cdcb9
@ -22,6 +22,7 @@ export interface NavModelItem {
|
||||
highlightText?: string;
|
||||
highlightId?: string;
|
||||
tabSuffix?: ComponentType<{ className?: string }>;
|
||||
hideFromNavbar?: boolean;
|
||||
}
|
||||
|
||||
export enum NavSection {
|
||||
|
@ -5,6 +5,7 @@ export type IconSize = ComponentSize | 'xl' | 'xxl' | 'xxxl';
|
||||
|
||||
export const getAvailableIcons = () =>
|
||||
[
|
||||
'anchor',
|
||||
'angle-double-down',
|
||||
'angle-double-right',
|
||||
'angle-double-up',
|
||||
|
@ -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 }));
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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}
|
||||
|
@ -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`
|
||||
|
@ -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`
|
||||
|
@ -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;
|
||||
|
1423
public/test/mocks/navModel.ts
Normal file
1423
public/test/mocks/navModel.ts
Normal file
File diff suppressed because it is too large
Load Diff
35
public/test/redux-rtl.tsx
Normal file
35
public/test/redux-rtl.tsx
Normal 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 };
|
Loading…
Reference in New Issue
Block a user