Commit a093fbb5 by kay delaney Committed by Torkel Ödegaard

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
parent 5cd4ffff
......@@ -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';
......
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>
);
}
......
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>
);
}
}
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,
......
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');
});
});
});
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;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment