diff --git a/packages/grafana-data/src/types/navModel.ts b/packages/grafana-data/src/types/navModel.ts
index ff6ca48f501..814e648985d 100644
--- a/packages/grafana-data/src/types/navModel.ts
+++ b/packages/grafana-data/src/types/navModel.ts
@@ -14,6 +14,7 @@ export interface NavModelItem {
target?: string;
parentItem?: NavModelItem;
showOrgSwitcher?: boolean;
+ onClick?: () => void;
}
/**
diff --git a/pkg/api/index.go b/pkg/api/index.go
index 859efa7cb0e..2b18c839377 100644
--- a/pkg/api/index.go
+++ b/pkg/api/index.go
@@ -35,7 +35,7 @@ func (hs *HTTPServer) getProfileNode(c *models.ReqContext) *dtos.NavLink {
if setting.AddChangePasswordLink() {
children = append(children, &dtos.NavLink{
Text: "Change password", Id: "change-password", Url: hs.Cfg.AppSubURL + "/profile/password",
- Icon: "lock", HideFromMenu: true,
+ Icon: "lock",
})
}
diff --git a/public/app/core/components/sidemenu/BottomNavLinks.test.tsx b/public/app/core/components/sidemenu/BottomNavLinks.test.tsx
index 2f8030b6615..f7d991feece 100644
--- a/public/app/core/components/sidemenu/BottomNavLinks.test.tsx
+++ b/public/app/core/components/sidemenu/BottomNavLinks.test.tsx
@@ -1,101 +1,115 @@
import React from 'react';
-import { shallow } from 'enzyme';
-import BottomNavLinks from './BottomNavLinks';
+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(),
}));
-const setup = (propOverrides?: object) => {
- const props = Object.assign(
- {
- link: {
- text: 'Hello',
- },
- user: {
- id: 1,
- isGrafanaAdmin: false,
- isSignedIn: false,
- orgCount: 2,
- orgRole: '',
- orgId: 1,
- login: 'hello',
- orgName: 'Grafana',
- timezone: 'UTC',
- helpFlags1: 1,
- lightTheme: false,
- hasEditPermissionInFolders: false,
- },
- },
- propOverrides
- );
- return shallow();
-};
+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,
+ };
-describe('Render', () => {
- it('should render component', () => {
- const wrapper = setup();
+ it('renders the link text', () => {
+ const mockLink = {
+ text: 'Hello',
+ };
- expect(wrapper).toMatchSnapshot();
+ render(
+
+
+
+ );
+ const linkText = screen.getByText(mockLink.text);
+ expect(linkText).toBeInTheDocument();
});
- it('should render organization switcher', () => {
- const wrapper = setup({
- link: {
- showOrgSwitcher: true,
- },
- });
+ it('attaches the link url to the text if provided', () => {
+ const mockLink = {
+ text: 'Hello',
+ url: '/route',
+ };
- wrapper.find('.sidemenu-org-switcher a').simulate('click');
- expect(wrapper).toMatchSnapshot();
+ render(
+
+
+
+ );
+ const link = screen.getByRole('link', { name: mockLink.text });
+ expect(link).toBeInTheDocument();
+ expect(link).toHaveAttribute('href', mockLink.url);
});
- it('should render subtitle', () => {
- const wrapper = setup({
- link: {
- subTitle: 'subtitle',
- },
- });
+ it('creates the correct children for the help link', () => {
+ const mockLink = {
+ id: 'help',
+ text: 'Hello',
+ };
- expect(wrapper).toMatchSnapshot();
+ render(
+
+
+
+ );
+ 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('should render children', () => {
- const wrapper = setup({
- link: {
- children: [
- {
- id: '1',
- },
- {
- id: '2',
- },
- {
- id: '3',
- },
- {
- id: '4',
- hideFromMenu: true,
- },
- ],
- },
- });
+ it('clicking the keyboard shortcuts button shows the modal', () => {
+ const mockLink = {
+ id: 'help',
+ text: 'Hello',
+ };
- expect(wrapper).toMatchSnapshot();
- });
-});
-
-describe('Functions', () => {
- describe('item clicked', () => {
- const wrapper = setup();
- it('should emit show modal event if url matches shortcut', () => {
- const instance = wrapper.instance() as BottomNavLinks;
- instance.onOpenShortcuts();
-
- expect(appEvents.publish).toHaveBeenCalledWith(new ShowModalReactEvent({ component: HelpModal }));
- });
+ render(
+
+
+
+ );
+ 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(
+
+
+
+ );
+ const currentOrg = screen.getByText(new RegExp(mockUser.orgName, 'i'));
+ const orgSwitcher = screen.getByText('Switch organization');
+ expect(currentOrg).toBeInTheDocument();
+ expect(orgSwitcher).toBeInTheDocument();
});
});
diff --git a/public/app/core/components/sidemenu/BottomNavLinks.tsx b/public/app/core/components/sidemenu/BottomNavLinks.tsx
index f750328e7bb..9b2b0801ab3 100644
--- a/public/app/core/components/sidemenu/BottomNavLinks.tsx
+++ b/public/app/core/components/sidemenu/BottomNavLinks.tsx
@@ -1,5 +1,4 @@
import React, { PureComponent } from 'react';
-import { css } from '@emotion/css';
import appEvents from '../../app_events';
import { User } from '../../services/context_srv';
import { NavModelItem } from '@grafana/data';
@@ -8,6 +7,7 @@ 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;
@@ -36,14 +36,29 @@ export default class BottomNavLinks extends PureComponent {
render() {
const { link, user } = this.props;
const { showSwitcherModal } = this.state;
- const subMenuIconClassName = css`
- margin-right: 8px;
- `;
let children = link.children || [];
if (link.id === 'help') {
- children = getFooterLinks();
+ 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 (
@@ -54,52 +69,14 @@ export default class BottomNavLinks extends PureComponent {
{link.img &&
}
-
+
+ {showSwitcherModal && }
);
}
diff --git a/public/app/core/components/sidemenu/DropDownChild.test.tsx b/public/app/core/components/sidemenu/DropDownChild.test.tsx
index ab8c0027a15..99bf45df025 100644
--- a/public/app/core/components/sidemenu/DropDownChild.test.tsx
+++ b/public/app/core/components/sidemenu/DropDownChild.test.tsx
@@ -14,7 +14,7 @@ describe('DropDownChild', () => {
expect(text).toBeInTheDocument();
});
- it('attaches the link to the text if provided', () => {
+ it('attaches the url to the text if provided', () => {
render(
@@ -22,6 +22,7 @@ describe('DropDownChild', () => {
);
const link = screen.getByRole('link', { name: mockText });
expect(link).toBeInTheDocument();
+ expect(link).toHaveAttribute('href', mockUrl);
});
it('displays an icon if a valid icon is provided', () => {
diff --git a/public/app/core/components/sidemenu/DropDownChild.tsx b/public/app/core/components/sidemenu/DropDownChild.tsx
index 4c6e2c59311..9935f6efadd 100644
--- a/public/app/core/components/sidemenu/DropDownChild.tsx
+++ b/public/app/core/components/sidemenu/DropDownChild.tsx
@@ -5,11 +5,13 @@ import { Icon, IconName, Link, useTheme2 } from '@grafana/ui';
export interface Props {
isDivider?: boolean;
icon?: IconName;
+ onClick?: () => void;
+ target?: HTMLAnchorElement['target'];
text: string;
url?: string;
}
-const DropDownChild = ({ isDivider = false, icon, text, url }: Props) => {
+const DropDownChild = ({ isDivider = false, icon, onClick, target, text, url }: Props) => {
const theme = useTheme2();
const iconClassName = css`
margin-right: ${theme.spacing(1)};
@@ -22,7 +24,18 @@ const DropDownChild = ({ isDivider = false, icon, text, url }: Props) => {
>
);
- const anchor = url ? {linkContent} : {linkContent};
+ let anchor = {linkContent};
+ if (url) {
+ anchor = url.startsWith('/') ? (
+
+ {linkContent}
+
+ ) : (
+
+ {linkContent}
+
+ );
+ }
return isDivider ? : {anchor};
};
diff --git a/public/app/core/components/sidemenu/SideMenuDropDown.test.tsx b/public/app/core/components/sidemenu/SideMenuDropDown.test.tsx
index f00cc4913f8..287427ff9b2 100644
--- a/public/app/core/components/sidemenu/SideMenuDropDown.test.tsx
+++ b/public/app/core/components/sidemenu/SideMenuDropDown.test.tsx
@@ -23,7 +23,7 @@ describe('SideMenuDropDown', () => {
expect(text).toBeInTheDocument();
});
- it('attaches the link to the header text if provided', () => {
+ it('attaches the header url to the header text if provided', () => {
render(
@@ -31,6 +31,7 @@ describe('SideMenuDropDown', () => {
);
const link = screen.getByRole('link', { name: mockHeaderText });
expect(link).toBeInTheDocument();
+ expect(link).toHaveAttribute('href', mockHeaderUrl);
});
it('calls the onHeaderClick function when the header is clicked', () => {
diff --git a/public/app/core/components/sidemenu/SideMenuDropDown.tsx b/public/app/core/components/sidemenu/SideMenuDropDown.tsx
index 3387f20ef43..2e6e6219f0c 100644
--- a/public/app/core/components/sidemenu/SideMenuDropDown.tsx
+++ b/public/app/core/components/sidemenu/SideMenuDropDown.tsx
@@ -2,15 +2,25 @@ import React from 'react';
import DropDownChild from './DropDownChild';
import { NavModelItem } from '@grafana/data';
import { IconName, Link } from '@grafana/ui';
+import { css } from '@emotion/css';
interface Props {
- items?: NavModelItem[];
headerText: string;
headerUrl?: string;
+ items?: NavModelItem[];
onHeaderClick?: () => void;
+ reverseDirection?: boolean;
+ subtitleText?: string;
}
-const SideMenuDropDown = ({ items = [], headerText, headerUrl, onHeaderClick }: Props) => {
+const SideMenuDropDown = ({
+ headerText,
+ headerUrl,
+ items = [],
+ onHeaderClick,
+ reverseDirection = false,
+ subtitleText,
+}: Props) => {
const headerContent = {headerText};
const header = headerUrl ? (
@@ -22,8 +32,12 @@ const SideMenuDropDown = ({ items = [], headerText, headerUrl, onHeaderClick }:
);
+ const menuClass = css`
+ flex-direction: ${reverseDirection ? 'column-reverse' : 'column'};
+ `;
+
return (
-
+
- {header}
{items
.filter((item) => !item.hideFromMenu)
@@ -32,10 +46,17 @@ const SideMenuDropDown = ({ items = [], headerText, headerUrl, onHeaderClick }:
key={`${child.url}-${index}`}
isDivider={child.divider}
icon={child.icon as IconName}
+ onClick={child.onClick}
+ target={child.target}
text={child.text}
url={child.url}
/>
))}
+ {subtitleText && (
+ -
+ {subtitleText}
+
+ )}
);
};
diff --git a/public/app/core/components/sidemenu/__snapshots__/BottomNavLinks.test.tsx.snap b/public/app/core/components/sidemenu/__snapshots__/BottomNavLinks.test.tsx.snap
deleted file mode 100644
index 6026adf7c95..00000000000
--- a/public/app/core/components/sidemenu/__snapshots__/BottomNavLinks.test.tsx.snap
+++ /dev/null
@@ -1,176 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`Render should render children 1`] = `
-
-
-
-
-
- -
-
-
- -
-
-
- -
-
-
- -
-
-
- -
-
-
-
-
-`;
-
-exports[`Render should render component 1`] = `
-
-`;
-
-exports[`Render should render organization switcher 1`] = `
-
-`;
-
-exports[`Render should render subtitle 1`] = `
-
-
-
-
-
- -
-
- subtitle
-
-
- -
-
-
-
-
-`;
diff --git a/public/sass/components/_sidemenu.scss b/public/sass/components/_sidemenu.scss
index 487c59a6fdf..9271b49dcd9 100644
--- a/public/sass/components/_sidemenu.scss
+++ b/public/sass/components/_sidemenu.scss
@@ -67,7 +67,7 @@ $mobile-menu-breakpoint: md;
.dropdown-menu {
border: none;
margin: 0;
- display: block;
+ display: flex;
opacity: 0;
top: 0px;
// important to overlap it otherwise it can be hidden
@@ -262,7 +262,7 @@ li.sidemenu-org-switcher {
}
.dropdown-menu--sidemenu {
- display: block;
+ display: flex;
position: unset;
width: 100%;
float: none;
@@ -276,7 +276,7 @@ li.sidemenu-org-switcher {
.sidemenu__bottom {
.dropdown-menu--sidemenu {
display: flex;
- flex-direction: column-reverse;
+ flex-direction: column;
}
}
}