mirror of
https://github.com/grafana/grafana.git
synced 2025-02-10 23:55:47 -06:00
Migration: Migrate org switcher to react (#19607)
* Migration: Migrate org switcher to react * Improve modal overflow behavior * Updated modal backdrop * Renamed type * Modal: Refactoring and reducing duplication
This commit is contained in:
parent
5cd4ffffe3
commit
a093fbb51a
@ -18,6 +18,7 @@ export * from './datasource';
|
||||
export * from './panel';
|
||||
export * from './plugin';
|
||||
export * from './theme';
|
||||
export * from './orgs';
|
||||
|
||||
import * as AppEvents from './appEvents';
|
||||
import { AppEvent } from './appEvents';
|
||||
|
11
packages/grafana-data/src/types/orgs.ts
Normal file
11
packages/grafana-data/src/types/orgs.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export interface UserOrgDTO {
|
||||
orgId: number;
|
||||
name: string;
|
||||
role: OrgRole;
|
||||
}
|
||||
|
||||
export enum OrgRole {
|
||||
Admin = 'Admin',
|
||||
Editor = 'Editor',
|
||||
Viewer = 'Viewer',
|
||||
}
|
@ -8,13 +8,12 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => ({
|
||||
modal: css`
|
||||
position: fixed;
|
||||
z-index: ${theme.zIndex.modal};
|
||||
width: 100%;
|
||||
background: ${theme.colors.pageBg};
|
||||
box-shadow: 0 3px 7px rgba(0, 0, 0, 0.3);
|
||||
background-clip: padding-box;
|
||||
outline: none;
|
||||
|
||||
max-width: 750px;
|
||||
width: 750px;
|
||||
max-width: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-left: auto;
|
||||
@ -28,20 +27,25 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => ({
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: ${theme.zIndex.modalBackdrop};
|
||||
background-color: ${theme.colors.bodyBg};
|
||||
background-color: ${theme.colors.blueFaint};
|
||||
opacity: 0.8;
|
||||
backdrop-filter: blur(4px);
|
||||
`,
|
||||
modalHeader: css`
|
||||
background: ${theme.background.pageHeader};
|
||||
box-shadow: ${theme.shadow.pageHeader};
|
||||
border-bottom: 1px soliod ${theme.colors.pageHeaderBorder};
|
||||
border-bottom: 1px solid ${theme.colors.pageHeaderBorder};
|
||||
display: flex;
|
||||
`,
|
||||
modalHeaderTitle: css`
|
||||
font-size: ${theme.typography.heading.h3};
|
||||
padding-top: calc(${theme.spacing.d} * 0.75);
|
||||
margin: 0 calc(${theme.spacing.d} * 3) 0 calc(${theme.spacing.d} * 1.5);
|
||||
padding-top: ${theme.spacing.sm};
|
||||
margin: 0 ${theme.spacing.md};
|
||||
`,
|
||||
modalHeaderIcon: css`
|
||||
position: relative;
|
||||
top: 2px;
|
||||
padding-right: ${theme.spacing.md};
|
||||
`,
|
||||
modalHeaderClose: css`
|
||||
margin-left: auto;
|
||||
@ -49,10 +53,14 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => ({
|
||||
`,
|
||||
modalContent: css`
|
||||
padding: calc(${theme.spacing.d} * 2);
|
||||
overflow: auto;
|
||||
width: 100%;
|
||||
max-height: calc(90vh - ${theme.spacing.d} * 2);
|
||||
`,
|
||||
}));
|
||||
|
||||
interface Props {
|
||||
icon?: string;
|
||||
title: string | JSX.Element;
|
||||
theme: GrafanaTheme;
|
||||
|
||||
@ -74,6 +82,18 @@ export class UnthemedModal extends React.PureComponent<Props> {
|
||||
this.onDismiss();
|
||||
};
|
||||
|
||||
renderDefaultHeader() {
|
||||
const { title, icon, theme } = this.props;
|
||||
const styles = getStyles(theme);
|
||||
|
||||
return (
|
||||
<h2 className={styles.modalHeaderTitle}>
|
||||
{icon && <i className={cx(icon, styles.modalHeaderIcon)} />}
|
||||
{title}
|
||||
</h2>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { title, isOpen = false, theme } = this.props;
|
||||
const styles = getStyles(theme);
|
||||
@ -84,16 +104,16 @@ export class UnthemedModal extends React.PureComponent<Props> {
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<div className={cx(styles.modal)}>
|
||||
<div className={cx(styles.modalHeader)}>
|
||||
{typeof title === 'string' ? <h2 className={cx(styles.modalHeaderTitle)}>{title}</h2> : <>{title}</>}
|
||||
<a className={cx(styles.modalHeaderClose)} onClick={this.onDismiss}>
|
||||
<div className={styles.modal}>
|
||||
<div className={styles.modalHeader}>
|
||||
{typeof title === 'string' ? this.renderDefaultHeader() : title}
|
||||
<a className={styles.modalHeaderClose} onClick={this.onDismiss}>
|
||||
<i className="fa fa-remove" />
|
||||
</a>
|
||||
</div>
|
||||
<div className={cx(styles.modalContent)}>{this.props.children}</div>
|
||||
<div className={styles.modalContent}>{this.props.children}</div>
|
||||
</div>
|
||||
<div className={cx(styles.modalBackdrop)} onClick={this.props.onClickBackdrop || this.onClickBackdrop} />
|
||||
<div className={styles.modalBackdrop} onClick={this.props.onClickBackdrop || this.onClickBackdrop} />
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
81
public/app/core/components/OrgSwitcher.tsx
Normal file
81
public/app/core/components/OrgSwitcher.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
import React from 'react';
|
||||
|
||||
import { getBackendSrv } from '@grafana/runtime';
|
||||
import { UserOrgDTO } from '@grafana/data';
|
||||
import { Modal, Button } from '@grafana/ui';
|
||||
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import config from 'app/core/config';
|
||||
|
||||
interface Props {
|
||||
onDismiss: () => void;
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
interface State {
|
||||
orgs: UserOrgDTO[];
|
||||
}
|
||||
|
||||
export class OrgSwitcher extends React.PureComponent<Props, State> {
|
||||
state: State = {
|
||||
orgs: [],
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.getUserOrgs();
|
||||
}
|
||||
|
||||
getUserOrgs = async () => {
|
||||
const orgs: UserOrgDTO[] = await getBackendSrv().get('/api/user/orgs');
|
||||
this.setState({
|
||||
orgs: orgs.sort((a, b) => a.orgId - b.orgId),
|
||||
});
|
||||
};
|
||||
|
||||
setCurrentOrg = async (org: UserOrgDTO) => {
|
||||
await getBackendSrv().post(`/api/user/using/${org.orgId}`);
|
||||
this.setWindowLocation(`${config.appSubUrl}${config.appSubUrl.endsWith('/') ? '' : '/'}?orgId=${org.orgId}`);
|
||||
};
|
||||
|
||||
setWindowLocation(href: string) {
|
||||
window.location.href = href;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { onDismiss, isOpen } = this.props;
|
||||
const { orgs } = this.state;
|
||||
|
||||
const currentOrgId = contextSrv.user.orgId;
|
||||
|
||||
return (
|
||||
<Modal title="Switch Organization" icon="fa fa-random" onDismiss={onDismiss} isOpen={isOpen}>
|
||||
<table className="filter-table form-inline">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Role</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{orgs.map(org => (
|
||||
<tr key={org.orgId}>
|
||||
<td>{org.name}</td>
|
||||
<td>{org.role}</td>
|
||||
<td className="text-right">
|
||||
{org.orgId === currentOrgId ? (
|
||||
<Button size="sm">Current</Button>
|
||||
) : (
|
||||
<Button variant="inverse" size="sm" onClick={() => this.setCurrentOrg(org)}>
|
||||
Switch to
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,84 +0,0 @@
|
||||
import coreModule from 'app/core/core_module';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import config from 'app/core/config';
|
||||
import { BackendSrv } from '../services/backend_srv';
|
||||
|
||||
const template = `
|
||||
<div class="modal-body">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-header-title">
|
||||
<i class="fa fa-random"></i>
|
||||
<span class="p-l-1">Switch Organization</span>
|
||||
</h2>
|
||||
|
||||
<a class="modal-header-close" ng-click="ctrl.dismiss();">
|
||||
<i class="fa fa-remove"></i>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="modal-content modal-content--has-scroll" grafana-scrollbar>
|
||||
<table class="filter-table form-inline">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Role</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr ng-repeat="org in ctrl.orgs">
|
||||
<td>{{org.name}}</td>
|
||||
<td>{{org.role}}</td>
|
||||
<td class="text-right">
|
||||
<span class="btn btn-primary btn-small" ng-show="org.orgId === ctrl.currentOrgId">
|
||||
Current
|
||||
</span>
|
||||
<a ng-click="ctrl.setUsingOrg(org)" class="btn btn-inverse btn-small" ng-show="org.orgId !== ctrl.currentOrgId">
|
||||
Switch to
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
export class OrgSwitchCtrl {
|
||||
orgs: any[];
|
||||
currentOrgId: any;
|
||||
|
||||
/** @ngInject */
|
||||
constructor(private backendSrv: BackendSrv) {
|
||||
this.currentOrgId = contextSrv.user.orgId;
|
||||
this.getUserOrgs();
|
||||
}
|
||||
|
||||
getUserOrgs() {
|
||||
this.backendSrv.get('/api/user/orgs').then((orgs: any) => {
|
||||
this.orgs = orgs;
|
||||
});
|
||||
}
|
||||
|
||||
setUsingOrg(org: any) {
|
||||
return this.backendSrv.post('/api/user/using/' + org.orgId).then(() => {
|
||||
this.setWindowLocation(config.appSubUrl + (config.appSubUrl.endsWith('/') ? '' : '/') + '?orgId=' + org.orgId);
|
||||
});
|
||||
}
|
||||
|
||||
setWindowLocation(href: string) {
|
||||
window.location.href = href;
|
||||
}
|
||||
}
|
||||
|
||||
export function orgSwitcher() {
|
||||
return {
|
||||
restrict: 'E',
|
||||
template: template,
|
||||
controller: OrgSwitchCtrl,
|
||||
bindToController: true,
|
||||
controllerAs: 'ctrl',
|
||||
scope: { dismiss: '&' },
|
||||
};
|
||||
}
|
||||
|
||||
coreModule.directive('orgSwitcher', orgSwitcher);
|
@ -3,13 +3,22 @@ import appEvents from '../../app_events';
|
||||
import { User } from '../../services/context_srv';
|
||||
import { NavModelItem } from '@grafana/data';
|
||||
import { CoreEvents } from 'app/types';
|
||||
import { OrgSwitcher } from '../OrgSwitcher';
|
||||
|
||||
export interface Props {
|
||||
link: NavModelItem;
|
||||
user: User;
|
||||
}
|
||||
|
||||
class BottomNavLinks extends PureComponent<Props> {
|
||||
interface State {
|
||||
showSwitcherModal: boolean;
|
||||
}
|
||||
|
||||
class BottomNavLinks extends PureComponent<Props, State> {
|
||||
state: State = {
|
||||
showSwitcherModal: false,
|
||||
};
|
||||
|
||||
itemClicked = (event: React.SyntheticEvent, child: NavModelItem) => {
|
||||
if (child.url === '/shortcuts') {
|
||||
event.preventDefault();
|
||||
@ -19,14 +28,16 @@ class BottomNavLinks extends PureComponent<Props> {
|
||||
}
|
||||
};
|
||||
|
||||
switchOrg = () => {
|
||||
appEvents.emit(CoreEvents.showModal, {
|
||||
templateHtml: '<org-switcher dismiss="dismiss()"></org-switcher>',
|
||||
});
|
||||
toggleSwitcherModal = () => {
|
||||
this.setState(prevState => ({
|
||||
showSwitcherModal: !prevState.showSwitcherModal,
|
||||
}));
|
||||
};
|
||||
|
||||
render() {
|
||||
const { link, user } = this.props;
|
||||
const { showSwitcherModal } = this.state;
|
||||
|
||||
return (
|
||||
<div className="sidemenu-item dropdown dropup">
|
||||
<a href={link.url} className="sidemenu-link" target={link.target}>
|
||||
@ -43,7 +54,7 @@ class BottomNavLinks extends PureComponent<Props> {
|
||||
)}
|
||||
{link.showOrgSwitcher && (
|
||||
<li className="sidemenu-org-switcher">
|
||||
<a onClick={this.switchOrg}>
|
||||
<a onClick={this.toggleSwitcherModal}>
|
||||
<div>
|
||||
<div className="sidemenu-org-switcher__org-name">{user.orgName}</div>
|
||||
<div className="sidemenu-org-switcher__org-current">Current Org:</div>
|
||||
@ -55,6 +66,9 @@ class BottomNavLinks extends PureComponent<Props> {
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
|
||||
<OrgSwitcher onDismiss={this.toggleSwitcherModal} isOpen={showSwitcherModal} />
|
||||
|
||||
{link.children &&
|
||||
link.children.map((child, index) => {
|
||||
if (!child.hideFromMenu) {
|
||||
|
@ -15,6 +15,10 @@ exports[`Render should render children 1`] = `
|
||||
className="dropdown-menu dropdown-menu--sidemenu"
|
||||
role="menu"
|
||||
>
|
||||
<OrgSwitcher
|
||||
isOpen={false}
|
||||
onDismiss={[Function]}
|
||||
/>
|
||||
<li
|
||||
key="undefined-0"
|
||||
>
|
||||
@ -62,6 +66,10 @@ exports[`Render should render component 1`] = `
|
||||
className="dropdown-menu dropdown-menu--sidemenu"
|
||||
role="menu"
|
||||
>
|
||||
<OrgSwitcher
|
||||
isOpen={false}
|
||||
onDismiss={[Function]}
|
||||
/>
|
||||
<li
|
||||
className="side-menu-header"
|
||||
>
|
||||
@ -118,6 +126,10 @@ exports[`Render should render organization switcher 1`] = `
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
<OrgSwitcher
|
||||
isOpen={false}
|
||||
onDismiss={[Function]}
|
||||
/>
|
||||
<li
|
||||
className="side-menu-header"
|
||||
>
|
||||
@ -153,6 +165,10 @@ exports[`Render should render subtitle 1`] = `
|
||||
subtitle
|
||||
</span>
|
||||
</li>
|
||||
<OrgSwitcher
|
||||
isOpen={false}
|
||||
onDismiss={[Function]}
|
||||
/>
|
||||
<li
|
||||
className="side-menu-header"
|
||||
>
|
||||
|
@ -39,7 +39,6 @@ import { contextSrv } from './services/context_srv';
|
||||
import { KeybindingSrv } from './services/keybindingSrv';
|
||||
import { NavModelSrv } from './nav_model_srv';
|
||||
import { geminiScrollbar } from './components/scroll/scroll';
|
||||
import { orgSwitcher } from './components/org_switcher';
|
||||
import { profiler } from './profiler';
|
||||
import { registerAngularDirectives } from './angular_wrappers';
|
||||
import { updateLegendValues } from './time_series2';
|
||||
@ -72,7 +71,6 @@ export {
|
||||
NavModelSrv,
|
||||
NavModel,
|
||||
geminiScrollbar,
|
||||
orgSwitcher,
|
||||
manageDashboardsDirective,
|
||||
TimeSeries,
|
||||
updateLegendValues,
|
||||
|
51
public/app/core/specs/OrgSwitcher.test.tsx
Normal file
51
public/app/core/specs/OrgSwitcher.test.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
// @ts-ignore
|
||||
import { getBackendSrv } from '@grafana/runtime/src/services/backendSrv';
|
||||
import { OrgSwitcher } from '../components/OrgSwitcher';
|
||||
import { shallow } from 'enzyme';
|
||||
import { OrgRole } from '@grafana/data';
|
||||
|
||||
const getMock = jest.fn(() => Promise.resolve([]));
|
||||
const postMock = jest.fn();
|
||||
|
||||
jest.mock('@grafana/runtime/src/services/backendSrv', () => ({
|
||||
getBackendSrv: () => ({
|
||||
get: getMock,
|
||||
post: postMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('app/core/services/context_srv', () => ({
|
||||
contextSrv: {
|
||||
user: { orgId: 1 },
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('app/core/config', () => {
|
||||
return {
|
||||
appSubUrl: '/subUrl',
|
||||
};
|
||||
});
|
||||
|
||||
let wrapper;
|
||||
let orgSwitcher: OrgSwitcher;
|
||||
|
||||
describe('OrgSwitcher', () => {
|
||||
describe('when switching org', () => {
|
||||
beforeEach(async () => {
|
||||
wrapper = shallow(<OrgSwitcher onDismiss={() => {}} isOpen={true} />);
|
||||
orgSwitcher = wrapper.instance() as OrgSwitcher;
|
||||
orgSwitcher.setWindowLocation = jest.fn();
|
||||
wrapper.update();
|
||||
await orgSwitcher.setCurrentOrg({ name: 'mock org', orgId: 2, role: OrgRole.Viewer });
|
||||
});
|
||||
|
||||
it('should switch orgId in call to backend', () => {
|
||||
expect(postMock).toBeCalledWith('/api/user/using/2');
|
||||
});
|
||||
|
||||
it('should switch orgId in url and redirect to home page', () => {
|
||||
expect(orgSwitcher.setWindowLocation).toBeCalledWith('/subUrl/?orgId=2');
|
||||
});
|
||||
});
|
||||
});
|
@ -1,48 +0,0 @@
|
||||
import { OrgSwitchCtrl } from '../components/org_switcher';
|
||||
// @ts-ignore
|
||||
import q from 'q';
|
||||
|
||||
jest.mock('app/core/services/context_srv', () => ({
|
||||
contextSrv: {
|
||||
user: { orgId: 1 },
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('app/core/config', () => {
|
||||
return {
|
||||
appSubUrl: '/subUrl',
|
||||
};
|
||||
});
|
||||
|
||||
describe('OrgSwitcher', () => {
|
||||
describe('when switching org', () => {
|
||||
let expectedHref: string;
|
||||
let expectedUsingUrl: string;
|
||||
|
||||
beforeEach(() => {
|
||||
const backendSrvStub: any = {
|
||||
get: (url: string) => {
|
||||
return q.resolve([]);
|
||||
},
|
||||
post: (url: string) => {
|
||||
expectedUsingUrl = url;
|
||||
return q.resolve({});
|
||||
},
|
||||
};
|
||||
|
||||
const orgSwitcherCtrl = new OrgSwitchCtrl(backendSrvStub);
|
||||
|
||||
orgSwitcherCtrl.setWindowLocation = href => (expectedHref = href);
|
||||
|
||||
return orgSwitcherCtrl.setUsingOrg({ orgId: 2 });
|
||||
});
|
||||
|
||||
it('should switch orgId in call to backend', () => {
|
||||
expect(expectedUsingUrl).toBe('/api/user/using/2');
|
||||
});
|
||||
|
||||
it('should switch orgId in url and redirect to home page', () => {
|
||||
expect(expectedHref).toBe('/subUrl/?orgId=2');
|
||||
});
|
||||
});
|
||||
});
|
@ -39,16 +39,7 @@ export class PanelInspector extends PureComponent<Props, State> {
|
||||
// TODO? should we get the result with an observable once?
|
||||
const data = (panel.getQueryRunner() as any).lastResult;
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div className="modal-header-title">
|
||||
<i className="fa fa-info-circle" />
|
||||
<span className="p-l-1">{panel.title ? panel.title : 'Panel'}</span>
|
||||
</div>
|
||||
}
|
||||
onDismiss={this.onDismiss}
|
||||
isOpen={true}
|
||||
>
|
||||
<Modal title={panel.title} icon="fa fa-info-circle" onDismiss={this.onDismiss} isOpen={true}>
|
||||
<div className={bodyStyle}>
|
||||
<JSONFormatter json={data} open={2} />
|
||||
</div>
|
||||
|
@ -46,8 +46,8 @@
|
||||
.modal-header-title {
|
||||
font-size: $font-size-h3;
|
||||
float: left;
|
||||
padding-top: $spacer * 0.75;
|
||||
margin: 0 $spacer * 3 0 $spacer * 1.5;
|
||||
padding-top: $space-sm;
|
||||
margin: 0 $space-md;
|
||||
|
||||
.gicon {
|
||||
position: relative;
|
||||
|
Loading…
Reference in New Issue
Block a user