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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 235 additions and 5 deletions

View File

@ -6,10 +6,12 @@ import { selectors } from '@grafana/e2e-selectors';
import { useTheme2 } from '../../themes';
import { IconName } from '../../types';
import { ComponentSize } from '../../types/size';
import { Button, ButtonVariant } from '../Button';
import { Button, ButtonFill, ButtonVariant } from '../Button';
import { Select } from '../Select/Select';
export interface ValuePickerProps<T> {
/** Aria label applied to the input field */
['aria-label']?: string;
/** Label to display on the picker button */
label: string;
/** Icon to display on the picker button */
@ -28,9 +30,12 @@ export interface ValuePickerProps<T> {
isFullWidth?: boolean;
/** Control where the menu is rendered */
menuPlacement?: 'auto' | 'bottom' | 'top';
/** Which ButtonFill to use */
fill?: ButtonFill;
}
export function ValuePicker<T>({
'aria-label': ariaLabel,
label,
icon,
options,
@ -40,6 +45,7 @@ export function ValuePicker<T>({
size = 'sm',
isFullWidth = true,
menuPlacement,
fill,
}: ValuePickerProps<T>) {
const [isPicking, setIsPicking] = useState(false);
const theme = useTheme2();
@ -52,8 +58,9 @@ export function ValuePicker<T>({
icon={icon || 'plus'}
onClick={() => setIsPicking(true)}
variant={variant}
fill={fill}
fullWidth={isFullWidth}
aria-label={selectors.components.ValuePicker.button(label)}
aria-label={selectors.components.ValuePicker.button(ariaLabel ?? label)}
>
{label}
</Button>
@ -64,7 +71,7 @@ export function ValuePicker<T>({
<Select
placeholder={label}
options={options}
aria-label={selectors.components.ValuePicker.select(label)}
aria-label={selectors.components.ValuePicker.select(ariaLabel ?? label)}
isOpen
onCloseMenu={() => setIsPicking(false)}
autoFocus={true}

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,
{