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:
@@ -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,
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user