Navigation: Added organisation switcher next to grafana logo (#56361)

* added org dropdown to topnav

* render icon and dropdown for mobile screen

* remove switch org from profile node

* adjust styles to be mobile first

* add test for select

* hide profile node only when topnav is on

* replace margin with gap instead

* improve tests

* add aria labels

* fix broken test
This commit is contained in:
Leo
2022-10-07 17:52:13 +02:00
committed by GitHub
parent 3990d2b2b3
commit 9c50131c2c
8 changed files with 235 additions and 5 deletions

View File

@@ -0,0 +1,26 @@
import React from 'react';
import { ValuePicker } from '@grafana/ui';
import { UserOrg } from 'app/types';
import { OrganizationBaseProps } from './types';
export function OrganizationPicker({ orgs, onSelectChange }: OrganizationBaseProps) {
return (
<ValuePicker<UserOrg>
aria-label="Change organization"
variant="secondary"
size="md"
label=""
fill="text"
isFullWidth={false}
options={orgs.map((org) => ({
label: org.name,
description: org.role,
value: org,
}))}
onChange={onSelectChange}
icon="building"
/>
);
}

View File

@@ -0,0 +1,46 @@
import { css } from '@emotion/css';
import React, { useState } from 'react';
import { SelectableValue, GrafanaTheme2 } from '@grafana/data';
import { Icon, Select, useStyles2 } from '@grafana/ui';
import { contextSrv } from 'app/core/services/context_srv';
import { UserOrg } from 'app/types';
import { OrganizationBaseProps } from './types';
export function OrganizationSelect({ orgs, onSelectChange }: OrganizationBaseProps) {
const styles = useStyles2(getStyles);
const { orgName: name, orgId, orgRole: role } = contextSrv.user;
const [value, setValue] = useState<SelectableValue<UserOrg>>(() => ({
label: name,
value: { role, orgId, name },
description: role,
}));
const onChange = (option: SelectableValue<UserOrg>) => {
setValue(option);
onSelectChange(option);
};
return (
<Select<UserOrg>
aria-label="Change organization"
width={'auto'}
value={value}
prefix={<Icon name="building" />}
className={styles.select}
options={orgs.map((org) => ({
label: org.name,
description: org.role,
value: org,
}))}
onChange={onChange}
/>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
select: css({
border: 'none',
background: 'none',
}),
});

View File

@@ -0,0 +1,95 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { Provider } from 'react-redux';
import { OrgRole } from '@grafana/data';
import { configureStore } from 'app/store/configureStore';
import * as appTypes from 'app/types';
import { OrganizationSwitcher } from './OrganizationSwitcher';
jest.mock('app/features/org/state/actions', () => ({
...jest.requireActual('app/features/org/state/actions'),
getUserOrganizations: jest.fn(),
setUserOrganization: jest.fn(),
}));
jest.mock('app/types', () => ({
...jest.requireActual('app/types'),
useDispatch: () => jest.fn(),
}));
const renderWithProvider = ({ initialState }: { initialState?: Partial<appTypes.StoreState> }) => {
const store = configureStore(initialState);
render(
<Provider store={store}>
<OrganizationSwitcher />
</Provider>
);
};
describe('OrganisationSwitcher', () => {
it('should only render if more than one organisations', () => {
renderWithProvider({
initialState: {
organization: {
organization: { name: 'test', id: 1 },
userOrgs: [
{ orgId: 1, name: 'test', role: OrgRole.Admin },
{ orgId: 2, name: 'test2', role: OrgRole.Admin },
],
},
},
});
expect(screen.getByRole('combobox', { name: 'Change organization' })).toBeInTheDocument();
});
it('should not render if there is only one organisation', () => {
renderWithProvider({
initialState: {
organization: {
organization: { name: 'test', id: 1 },
userOrgs: [{ orgId: 1, name: 'test', role: OrgRole.Admin }],
},
},
});
expect(screen.queryByRole('combobox', { name: 'Change organization' })).not.toBeInTheDocument();
});
it('should not render if there is no organisation available', () => {
renderWithProvider({
initialState: {
organization: {
organization: { name: 'test', id: 1 },
userOrgs: [],
},
},
});
expect(screen.queryByRole('combobox', { name: 'Change organization' })).not.toBeInTheDocument();
});
it('should render a picker in mobile screen', () => {
(window.matchMedia as jest.Mock).mockImplementation(() => ({
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
matches: () => true,
}));
renderWithProvider({
initialState: {
organization: {
organization: { name: 'test', id: 1 },
userOrgs: [
{ orgId: 1, name: 'test', role: OrgRole.Admin },
{ orgId: 2, name: 'test2', role: OrgRole.Admin },
],
},
},
});
expect(screen.getByRole('button', { name: /change organization/i })).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,45 @@
import React, { useEffect, useState } from 'react';
import { SelectableValue } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { useTheme2 } from '@grafana/ui';
import { getUserOrganizations, setUserOrganization } from 'app/features/org/state/actions';
import { useDispatch, useSelector, UserOrg } from 'app/types';
import { OrganizationPicker } from './OrganizationPicker';
import { OrganizationSelect } from './OrganizationSelect';
export function OrganizationSwitcher() {
const theme = useTheme2();
const dispatch = useDispatch();
const orgs = useSelector((state) => state.organization.userOrgs);
const [isSmallScreen, setIsSmallScreen] = useState(
window.matchMedia(`(max-width: ${theme.breakpoints.values.sm}px)`).matches
);
const onSelectChange = (option: SelectableValue<UserOrg>) => {
if (option.value) {
setUserOrganization(option.value.orgId);
locationService.partial({ orgId: option.value.orgId }, true);
// TODO how to reload the current page
window.location.reload();
}
};
useEffect(() => {
dispatch(getUserOrganizations());
}, [dispatch]);
useEffect(() => {
const mediaQuery = window.matchMedia(`(max-width: ${theme.breakpoints.values.sm}px)`);
const onMediaQueryChange = (e: MediaQueryListEvent) => setIsSmallScreen(e.matches);
mediaQuery.addEventListener('change', onMediaQueryChange);
return () => mediaQuery.removeEventListener('change', onMediaQueryChange);
}, [isSmallScreen, theme.breakpoints.values.sm]);
if (orgs?.length <= 1) {
return null;
}
const Switcher = isSmallScreen ? OrganizationPicker : OrganizationSelect;
return <Switcher orgs={orgs} onSelectChange={onSelectChange} />;
}

View File

@@ -0,0 +1,7 @@
import { SelectableValue } from '@grafana/data';
import { UserOrg } from 'app/types';
export interface OrganizationBaseProps {
orgs: UserOrg[];
onSelectChange: (option: SelectableValue<UserOrg>) => void;
}

View File

@@ -7,6 +7,7 @@ import { contextSrv } from 'app/core/core';
import { useSelector } from 'app/types';
import { NewsContainer } from './News/NewsContainer';
import { OrganizationSwitcher } from './Organization/OrganizationSwitcher';
import { SignInLink } from './TopBar/SignInLink';
import { TopNavBarMenu } from './TopBar/TopNavBarMenu';
import { TopSearchBarInput } from './TopSearchBarInput';
@@ -25,6 +26,7 @@ export function TopSearchBar() {
<a className={styles.logo} href="/" title="Go to home">
<Icon name="grafana" size="xl" />
</a>
<OrganizationSwitcher />
</div>
<div className={styles.searchWrapper}>
<TopSearchBarInput />
@@ -65,6 +67,8 @@ const getStyles = (theme: GrafanaTheme2) => {
}),
leftContent: css({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
}),
logo: css({
display: 'flex',

View File

@@ -25,7 +25,7 @@ export const enrichConfigItems = (items: NavModelItem[], location: Location<unkn
appEvents.publish(new ShowModalReactEvent({ component: OrgSwitcher }));
};
if (user && user.orgCount > 1) {
if (!config.featureToggles.topnav && user && user.orgCount > 1) {
const profileNode = items.find((bottomNavItem) => bottomNavItem.id === 'profile');
if (profileNode) {
profileNode.showOrgSwitcher = true;
@@ -61,7 +61,7 @@ export const enrichConfigItems = (items: NavModelItem[], location: Location<unkn
];
}
if (link.showOrgSwitcher) {
if (!config.featureToggles.topnav && link.showOrgSwitcher) {
link.children = [
...menuItems,
{