Sidemenu: Refactor TopSectionItem and BottomNavLinks into SideMenuItem (#38755)

* Sidemenu: Refactor TopSectionItem and BottomNavLinks into SideMenuItem

* Update failing snapshot

* BottomSection: Convert tests to RTL + add some extra unit tests
This commit is contained in:
Ashley Harrison 2021-09-01 17:16:50 +01:00 committed by GitHub
parent 92934af741
commit 205c672417
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 261 additions and 348 deletions

View File

@ -1,115 +0,0 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { BrowserRouter } from 'react-router-dom';
import appEvents from '../../app_events';
import { ShowModalReactEvent } from '../../../types/events';
import { HelpModal } from '../help/HelpModal';
import BottomNavLinks from './BottomNavLinks';
jest.mock('../../app_events', () => ({
publish: jest.fn(),
}));
describe('BottomNavLinks', () => {
const mockUser = {
id: 1,
isGrafanaAdmin: false,
isSignedIn: false,
orgCount: 2,
orgRole: '',
orgId: 1,
login: 'hello',
orgName: 'mockOrganization',
timezone: 'UTC',
helpFlags1: 1,
lightTheme: false,
hasEditPermissionInFolders: false,
};
it('renders the link text', () => {
const mockLink = {
text: 'Hello',
};
render(
<BrowserRouter>
<BottomNavLinks link={mockLink} user={mockUser} />
</BrowserRouter>
);
const linkText = screen.getByText(mockLink.text);
expect(linkText).toBeInTheDocument();
});
it('attaches the link url to the text if provided', () => {
const mockLink = {
text: 'Hello',
url: '/route',
};
render(
<BrowserRouter>
<BottomNavLinks link={mockLink} user={mockUser} />
</BrowserRouter>
);
const link = screen.getByRole('link', { name: mockLink.text });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute('href', mockLink.url);
});
it('creates the correct children for the help link', () => {
const mockLink = {
id: 'help',
text: 'Hello',
};
render(
<BrowserRouter>
<BottomNavLinks link={mockLink} user={mockUser} />
</BrowserRouter>
);
const documentation = screen.getByRole('link', { name: 'Documentation' });
const support = screen.getByRole('link', { name: 'Support' });
const community = screen.getByRole('link', { name: 'Community' });
const keyboardShortcuts = screen.getByText('Keyboard shortcuts');
expect(documentation).toBeInTheDocument();
expect(support).toBeInTheDocument();
expect(community).toBeInTheDocument();
expect(keyboardShortcuts).toBeInTheDocument();
});
it('clicking the keyboard shortcuts button shows the modal', () => {
const mockLink = {
id: 'help',
text: 'Hello',
};
render(
<BrowserRouter>
<BottomNavLinks link={mockLink} user={mockUser} />
</BrowserRouter>
);
const keyboardShortcuts = screen.getByText('Keyboard shortcuts');
expect(keyboardShortcuts).toBeInTheDocument();
userEvent.click(keyboardShortcuts);
expect(appEvents.publish).toHaveBeenCalledWith(new ShowModalReactEvent({ component: HelpModal }));
});
it('shows the current organization and organization switcher if showOrgSwitcher is true', () => {
const mockLink = {
showOrgSwitcher: true,
text: 'Hello',
};
render(
<BrowserRouter>
<BottomNavLinks link={mockLink} user={mockUser} />
</BrowserRouter>
);
const currentOrg = screen.getByText(new RegExp(mockUser.orgName, 'i'));
const orgSwitcher = screen.getByText('Switch organization');
expect(currentOrg).toBeInTheDocument();
expect(orgSwitcher).toBeInTheDocument();
});
});

View File

@ -1,83 +0,0 @@
import React, { PureComponent } from 'react';
import appEvents from '../../app_events';
import { User } from '../../services/context_srv';
import { NavModelItem } from '@grafana/data';
import { Icon, IconName, Link } from '@grafana/ui';
import { OrgSwitcher } from '../OrgSwitcher';
import { getFooterLinks } from '../Footer/Footer';
import { ShowModalReactEvent } from '../../../types/events';
import { HelpModal } from '../help/HelpModal';
import SideMenuDropDown from './SideMenuDropDown';
export interface Props {
link: NavModelItem;
user: User;
}
interface State {
showSwitcherModal: boolean;
}
export default class BottomNavLinks extends PureComponent<Props, State> {
state: State = {
showSwitcherModal: false,
};
onOpenShortcuts = () => {
appEvents.publish(new ShowModalReactEvent({ component: HelpModal }));
};
toggleSwitcherModal = () => {
this.setState((prevState) => ({
showSwitcherModal: !prevState.showSwitcherModal,
}));
};
render() {
const { link, user } = this.props;
const { showSwitcherModal } = this.state;
let children = link.children || [];
if (link.id === 'help') {
children = [
...getFooterLinks(),
{
text: 'Keyboard shortcuts',
icon: 'keyboard',
onClick: this.onOpenShortcuts,
},
];
}
if (link.showOrgSwitcher) {
children = [
...children,
{
text: 'Switch organization',
icon: 'arrow-random',
onClick: this.toggleSwitcherModal,
},
];
}
return (
<div className="sidemenu-item dropdown dropup">
<Link href={link.url} className="sidemenu-link" target={link.target}>
<span className="icon-circle sidemenu-icon">
{link.icon && <Icon name={link.icon as IconName} size="xl" title="Help icon" />}
{link.img && <img src={link.img} alt="Profile picture" />}
</span>
</Link>
<SideMenuDropDown
headerText={link.text}
headerUrl={link.url}
items={children}
reverseDirection
subtitleText={link.showOrgSwitcher ? `Current Org.: ${user.orgName}` : link.subTitle}
/>
{showSwitcherModal && <OrgSwitcher onDismiss={this.toggleSwitcherModal} />}
</div>
);
}
}

View File

@ -1,7 +1,14 @@
import React from 'react';
import { shallow } from 'enzyme';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ShowModalReactEvent } from '../../../types/events';
import { HelpModal } from '../help/HelpModal';
import appEvents from '../../app_events';
import BottomSection from './BottomSection';
jest.mock('../../app_events', () => ({
publish: jest.fn(),
}));
jest.mock('../../config', () => ({
bootData: {
navTree: [
@ -10,6 +17,7 @@ jest.mock('../../config', () => ({
hideFromMenu: true,
},
{
id: 'help',
hideFromMenu: true,
},
{
@ -20,25 +28,56 @@ jest.mock('../../config', () => ({
},
],
},
user: {
orgCount: 5,
orgName: 'Grafana',
},
}));
jest.mock('app/core/services/context_srv', () => ({
contextSrv: {
sidemenu: true,
isSignedIn: false,
isSignedIn: true,
isGrafanaAdmin: false,
hasEditPermissionFolders: false,
user: {
orgCount: 5,
orgName: 'Grafana',
},
},
}));
describe('Render', () => {
it('should render component', () => {
const wrapper = shallow(<BottomSection />);
describe('BottomSection', () => {
it('should render the correct children', () => {
render(<BottomSection />);
expect(wrapper).toMatchSnapshot();
expect(screen.getByTestId('bottom-section-items').children.length).toBe(3);
});
it('creates the correct children for the help link', () => {
render(<BottomSection />);
const documentation = screen.getByRole('link', { name: 'Documentation' });
const support = screen.getByRole('link', { name: 'Support' });
const community = screen.getByRole('link', { name: 'Community' });
const keyboardShortcuts = screen.getByText('Keyboard shortcuts');
expect(documentation).toBeInTheDocument();
expect(support).toBeInTheDocument();
expect(community).toBeInTheDocument();
expect(keyboardShortcuts).toBeInTheDocument();
});
it('clicking the keyboard shortcuts button shows the modal', () => {
render(<BottomSection />);
const keyboardShortcuts = screen.getByText('Keyboard shortcuts');
expect(keyboardShortcuts).toBeInTheDocument();
userEvent.click(keyboardShortcuts);
expect(appEvents.publish).toHaveBeenCalledWith(new ShowModalReactEvent({ component: HelpModal }));
});
it('shows the current organization and organization switcher if showOrgSwitcher is true', () => {
render(<BottomSection />);
const currentOrg = screen.getByText(new RegExp('Grafana', 'i'));
const orgSwitcher = screen.getByText('Switch organization');
expect(currentOrg).toBeInTheDocument();
expect(orgSwitcher).toBeInTheDocument();
});
});

View File

@ -1,30 +1,85 @@
import React from 'react';
import { cloneDeep, find } from 'lodash';
import { SignIn } from './SignIn';
import BottomNavLinks from './BottomNavLinks';
import { contextSrv } from 'app/core/services/context_srv';
import config from '../../config';
import React, { useState } from 'react';
import { cloneDeep } from 'lodash';
import { NavModelItem } from '@grafana/data';
import { Icon, IconName } from '@grafana/ui';
import appEvents from '../../app_events';
import { SignIn } from './SignIn';
import SideMenuItem from './SideMenuItem';
import { ShowModalReactEvent } from '../../../types/events';
import { contextSrv } from 'app/core/services/context_srv';
import { OrgSwitcher } from '../OrgSwitcher';
import { getFooterLinks } from '../Footer/Footer';
import { HelpModal } from '../help/HelpModal';
import config from '../../config';
export default function BottomSection() {
const navTree: NavModelItem[] = cloneDeep(config.bootData.navTree);
const bottomNav: NavModelItem[] = navTree.filter((item) => item.hideFromMenu);
const bottomNav = navTree.filter((item) => item.hideFromMenu);
const isSignedIn = contextSrv.isSignedIn;
const user = contextSrv.user;
const [showSwitcherModal, setShowSwitcherModal] = useState(false);
const toggleSwitcherModal = () => {
setShowSwitcherModal(!showSwitcherModal);
};
const onOpenShortcuts = () => {
appEvents.publish(new ShowModalReactEvent({ component: HelpModal }));
};
if (user && user.orgCount > 1) {
const profileNode: any = find(bottomNav, { id: 'profile' });
const profileNode = bottomNav.find((bottomNavItem) => bottomNavItem.id === 'profile');
if (profileNode) {
profileNode.showOrgSwitcher = true;
profileNode.subTitle = `Current Org.: ${user?.orgName}`;
}
}
return (
<div className="sidemenu__bottom">
<div data-testid="bottom-section-items" className="sidemenu__bottom">
{!isSignedIn && <SignIn />}
{bottomNav.map((link, index) => {
return <BottomNavLinks link={link} user={user} key={`${link.url}-${index}`} />;
let menuItems = link.children || [];
if (link.id === 'help') {
menuItems = [
...getFooterLinks(),
{
text: 'Keyboard shortcuts',
icon: 'keyboard',
onClick: onOpenShortcuts,
},
];
}
if (link.showOrgSwitcher) {
menuItems = [
...menuItems,
{
text: 'Switch organization',
icon: 'arrow-random',
onClick: toggleSwitcherModal,
},
];
}
return (
<SideMenuItem
key={`${link.url}-${index}`}
label={link.text}
menuItems={menuItems}
menuSubTitle={link.subTitle}
onClick={link.onClick}
reverseMenuDirection
target={link.target}
url={link.url}
>
{link.icon && <Icon name={link.icon as IconName} size="xl" />}
{link.img && <img src={link.img} />}
</SideMenuItem>
);
})}
{showSwitcherModal && <OrgSwitcher onDismiss={toggleSwitcherModal} />}
</div>
);
}

View File

@ -0,0 +1,55 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { BrowserRouter } from 'react-router-dom';
import SideMenuItem from './SideMenuItem';
describe('SideMenuItem', () => {
it('renders the children', () => {
const mockLabel = 'Hello';
render(
<BrowserRouter>
<SideMenuItem label={mockLabel}>
<div data-testid="mockChild" />
</SideMenuItem>
</BrowserRouter>
);
const child = screen.getByTestId('mockChild');
expect(child).toBeInTheDocument();
});
it('wraps the children in a link to the url if provided', () => {
const mockLabel = 'Hello';
const mockUrl = '/route';
render(
<BrowserRouter>
<SideMenuItem label={mockLabel} url={mockUrl}>
<div data-testid="mockChild" />
</SideMenuItem>
</BrowserRouter>
);
const child = screen.getByTestId('mockChild');
expect(child).toBeInTheDocument();
userEvent.click(child);
expect(window.location.pathname).toEqual(mockUrl);
});
it('wraps the children in an onClick if provided', () => {
const mockLabel = 'Hello';
const mockOnClick = jest.fn();
render(
<BrowserRouter>
<SideMenuItem label={mockLabel} onClick={mockOnClick}>
<div data-testid="mockChild" />
</SideMenuItem>
</BrowserRouter>
);
const child = screen.getByTestId('mockChild');
expect(child).toBeInTheDocument();
userEvent.click(child);
expect(mockOnClick).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,67 @@
import React, { ReactNode } from 'react';
import SideMenuDropDown from './SideMenuDropDown';
import { Link, useStyles2 } from '@grafana/ui';
import { NavModelItem } from '@grafana/data';
import { css, cx } from '@emotion/css';
export interface Props {
children: ReactNode;
label: string;
menuItems?: NavModelItem[];
menuSubTitle?: string;
onClick?: () => void;
reverseMenuDirection?: boolean;
target?: HTMLAnchorElement['target'];
url?: string;
}
const SideMenuItem = ({
children,
label,
menuItems = [],
menuSubTitle,
onClick,
reverseMenuDirection = false,
target,
url,
}: Props) => {
const resetButtonStyles = useStyles2(
() =>
css`
background-color: transparent;
`
);
const anchor = url ? (
<Link
className="sidemenu-link"
href={url}
target={target}
aria-label={label}
onClick={onClick}
aria-haspopup="true"
>
<span className="icon-circle sidemenu-icon">{children}</span>
</Link>
) : (
<button className={cx(resetButtonStyles, 'sidemenu-link')} onClick={onClick} aria-label={label}>
<span className="icon-circle sidemenu-icon">{children}</span>
</button>
);
return (
<div className={cx('sidemenu-item', 'dropdown', { dropup: reverseMenuDirection })}>
{anchor}
<SideMenuDropDown
headerText={label}
headerUrl={url}
items={menuItems}
onHeaderClick={onClick}
reverseDirection={reverseMenuDirection}
subtitleText={menuSubTitle}
/>
</div>
);
};
export default SideMenuItem;

View File

@ -1,16 +1,14 @@
import React, { FC } from 'react';
import { cloneDeep, filter } from 'lodash';
import TopSectionItem from './TopSectionItem';
import config from '../../config';
import React from 'react';
import { cloneDeep } from 'lodash';
import { locationService } from '@grafana/runtime';
import { Icon, IconName } from '@grafana/ui';
import SideMenuItem from './SideMenuItem';
import config from '../../config';
import { NavModelItem } from '@grafana/data';
const TopSection: FC<any> = () => {
const navTree = cloneDeep(config.bootData.navTree);
const mainLinks = filter(navTree, (item) => !item.hideFromMenu);
const searchLink = {
text: 'Search dashboards',
icon: 'search',
};
const TopSection = () => {
const navTree: NavModelItem[] = cloneDeep(config.bootData.navTree);
const mainLinks = navTree.filter((item) => !item.hideFromMenu);
const onOpenSearch = () => {
locationService.partial({ search: 'open' });
@ -18,9 +16,22 @@ const TopSection: FC<any> = () => {
return (
<div data-testid="top-section-items" className="sidemenu__top">
<TopSectionItem link={searchLink} onClick={onOpenSearch} />
<SideMenuItem label="Search dashboards" onClick={onOpenSearch}>
<Icon name="search" size="xl" />
</SideMenuItem>
{mainLinks.map((link, index) => {
return <TopSectionItem link={link} key={`${link.id}-${index}`} />;
return (
<SideMenuItem
key={`${link.id}-${index}`}
label={link.text}
menuItems={link.children}
target={link.target}
url={link.url}
>
{link.icon && <Icon name={link.icon as IconName} size="xl" />}
{link.img && <img src={link.img} />}
</SideMenuItem>
);
})}
</div>
);

View File

@ -1,31 +0,0 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import TopSectionItem from './TopSectionItem';
import { MemoryRouter } from 'react-router-dom';
const setup = (propOverrides?: object) => {
const props = Object.assign(
{
link: {
text: 'Hello',
icon: 'cloud',
url: '/asd',
},
},
propOverrides
);
return render(
<MemoryRouter initialEntries={[{ pathname: '/', key: 'testKey' }]}>
<TopSectionItem {...props} />
</MemoryRouter>
);
};
describe('Render', () => {
it('should render component', () => {
setup();
expect(screen.getByText('Hello')).toBeInTheDocument();
expect(screen.getByRole('menu')).toHaveTextContent('Hello');
});
});

View File

@ -1,51 +0,0 @@
import React, { FC } from 'react';
import SideMenuDropDown from './SideMenuDropDown';
import { Icon, Link, useStyles2 } from '@grafana/ui';
import { NavModelItem } from '@grafana/data';
import { css, cx } from '@emotion/css';
export interface Props {
link: NavModelItem;
onClick?: () => void;
}
const TopSectionItem: FC<Props> = ({ link, onClick }) => {
const resetButtonStyles = useStyles2(
() =>
css`
background-color: transparent;
`
);
const linkContent = (
<span className="icon-circle sidemenu-icon">
{link.icon && <Icon name={link.icon as any} size="xl" />}
{link.img && <img src={link.img} />}
</span>
);
const anchor = link.url ? (
<Link
className="sidemenu-link"
href={link.url}
target={link.target}
aria-label={link.text}
onClick={onClick}
aria-haspopup="true"
>
{linkContent}
</Link>
) : (
<button className={cx(resetButtonStyles, 'sidemenu-link')} onClick={onClick} aria-label={link.text}>
{linkContent}
</button>
);
return (
<div className="sidemenu-item dropdown">
{anchor}
<SideMenuDropDown items={link.children} headerText={link.text} headerUrl={link.url} onHeaderClick={onClick} />
</div>
);
};
export default TopSectionItem;

View File

@ -1,34 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Render should render component 1`] = `
<div
className="sidemenu__bottom"
>
<SignIn />
<BottomNavLinks
key="undefined-0"
link={
Object {
"hideFromMenu": true,
"id": "profile",
}
}
/>
<BottomNavLinks
key="undefined-1"
link={
Object {
"hideFromMenu": true,
}
}
/>
<BottomNavLinks
key="undefined-2"
link={
Object {
"hideFromMenu": true,
}
}
/>
</div>
`;