mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
3990d2b2b3
commit
9c50131c2c
@ -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}
|
||||
|
@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
@ -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',
|
||||
}),
|
||||
});
|
@ -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();
|
||||
});
|
||||
});
|
@ -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} />;
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { UserOrg } from 'app/types';
|
||||
|
||||
export interface OrganizationBaseProps {
|
||||
orgs: UserOrg[];
|
||||
onSelectChange: (option: SelectableValue<UserOrg>) => void;
|
||||
}
|
@ -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',
|
||||
|
@ -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,
|
||||
{
|
||||
|
Loading…
Reference in New Issue
Block a user