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 && Profile picture} - + + {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`] = ` -
      - - - -
        -
      • - - Hello - -
      • -
      -
      -`; - -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; } } }