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'; ...@@ -18,6 +18,7 @@ export * from './datasource';
export * from './panel'; export * from './panel';
export * from './plugin'; export * from './plugin';
export * from './theme'; export * from './theme';
export * from './orgs';
import * as AppEvents from './appEvents'; import * as AppEvents from './appEvents';
import { AppEvent } 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) => ({ ...@@ -8,13 +8,12 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => ({
modal: css` modal: css`
position: fixed; position: fixed;
z-index: ${theme.zIndex.modal}; z-index: ${theme.zIndex.modal};
width: 100%;
background: ${theme.colors.pageBg}; background: ${theme.colors.pageBg};
box-shadow: 0 3px 7px rgba(0, 0, 0, 0.3); box-shadow: 0 3px 7px rgba(0, 0, 0, 0.3);
background-clip: padding-box; background-clip: padding-box;
outline: none; outline: none;
width: 750px;
max-width: 750px; max-width: 100%;
left: 0; left: 0;
right: 0; right: 0;
margin-left: auto; margin-left: auto;
...@@ -28,20 +27,25 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => ({ ...@@ -28,20 +27,25 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => ({
bottom: 0; bottom: 0;
left: 0; left: 0;
z-index: ${theme.zIndex.modalBackdrop}; z-index: ${theme.zIndex.modalBackdrop};
background-color: ${theme.colors.bodyBg}; background-color: ${theme.colors.blueFaint};
opacity: 0.8; opacity: 0.8;
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
`, `,
modalHeader: css` modalHeader: css`
background: ${theme.background.pageHeader}; background: ${theme.background.pageHeader};
box-shadow: ${theme.shadow.pageHeader}; box-shadow: ${theme.shadow.pageHeader};
border-bottom: 1px soliod ${theme.colors.pageHeaderBorder}; border-bottom: 1px solid ${theme.colors.pageHeaderBorder};
display: flex; display: flex;
`, `,
modalHeaderTitle: css` modalHeaderTitle: css`
font-size: ${theme.typography.heading.h3}; font-size: ${theme.typography.heading.h3};
padding-top: calc(${theme.spacing.d} * 0.75); padding-top: ${theme.spacing.sm};
margin: 0 calc(${theme.spacing.d} * 3) 0 calc(${theme.spacing.d} * 1.5); margin: 0 ${theme.spacing.md};
`,
modalHeaderIcon: css`
position: relative;
top: 2px;
padding-right: ${theme.spacing.md};
`, `,
modalHeaderClose: css` modalHeaderClose: css`
margin-left: auto; margin-left: auto;
...@@ -49,10 +53,14 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => ({ ...@@ -49,10 +53,14 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => ({
`, `,
modalContent: css` modalContent: css`
padding: calc(${theme.spacing.d} * 2); padding: calc(${theme.spacing.d} * 2);
overflow: auto;
width: 100%;
max-height: calc(90vh - ${theme.spacing.d} * 2);
`, `,
})); }));
interface Props { interface Props {
icon?: string;
title: string | JSX.Element; title: string | JSX.Element;
theme: GrafanaTheme; theme: GrafanaTheme;
...@@ -74,6 +82,18 @@ export class UnthemedModal extends React.PureComponent<Props> { ...@@ -74,6 +82,18 @@ export class UnthemedModal extends React.PureComponent<Props> {
this.onDismiss(); 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() { render() {
const { title, isOpen = false, theme } = this.props; const { title, isOpen = false, theme } = this.props;
const styles = getStyles(theme); const styles = getStyles(theme);
...@@ -84,16 +104,16 @@ export class UnthemedModal extends React.PureComponent<Props> { ...@@ -84,16 +104,16 @@ export class UnthemedModal extends React.PureComponent<Props> {
return ( return (
<Portal> <Portal>
<div className={cx(styles.modal)}> <div className={styles.modal}>
<div className={cx(styles.modalHeader)}> <div className={styles.modalHeader}>
{typeof title === 'string' ? <h2 className={cx(styles.modalHeaderTitle)}>{title}</h2> : <>{title}</>} {typeof title === 'string' ? this.renderDefaultHeader() : title}
<a className={cx(styles.modalHeaderClose)} onClick={this.onDismiss}> <a className={styles.modalHeaderClose} onClick={this.onDismiss}>
<i className="fa fa-remove" /> <i className="fa fa-remove" />
</a> </a>
</div> </div>
<div className={cx(styles.modalContent)}>{this.props.children}</div> <div className={styles.modalContent}>{this.props.children}</div>
</div> </div>
<div className={cx(styles.modalBackdrop)} onClick={this.props.onClickBackdrop || this.onClickBackdrop} /> <div className={styles.modalBackdrop} onClick={this.props.onClickBackdrop || this.onClickBackdrop} />
</Portal> </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'; ...@@ -3,13 +3,22 @@ import appEvents from '../../app_events';
import { User } from '../../services/context_srv'; import { User } from '../../services/context_srv';
import { NavModelItem } from '@grafana/data'; import { NavModelItem } from '@grafana/data';
import { CoreEvents } from 'app/types'; import { CoreEvents } from 'app/types';
import { OrgSwitcher } from '../OrgSwitcher';
export interface Props { export interface Props {
link: NavModelItem; link: NavModelItem;
user: User; 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) => { itemClicked = (event: React.SyntheticEvent, child: NavModelItem) => {
if (child.url === '/shortcuts') { if (child.url === '/shortcuts') {
event.preventDefault(); event.preventDefault();
...@@ -19,14 +28,16 @@ class BottomNavLinks extends PureComponent<Props> { ...@@ -19,14 +28,16 @@ class BottomNavLinks extends PureComponent<Props> {
} }
}; };
switchOrg = () => { toggleSwitcherModal = () => {
appEvents.emit(CoreEvents.showModal, { this.setState(prevState => ({
templateHtml: '<org-switcher dismiss="dismiss()"></org-switcher>', showSwitcherModal: !prevState.showSwitcherModal,
}); }));
}; };
render() { render() {
const { link, user } = this.props; const { link, user } = this.props;
const { showSwitcherModal } = this.state;
return ( return (
<div className="sidemenu-item dropdown dropup"> <div className="sidemenu-item dropdown dropup">
<a href={link.url} className="sidemenu-link" target={link.target}> <a href={link.url} className="sidemenu-link" target={link.target}>
...@@ -43,7 +54,7 @@ class BottomNavLinks extends PureComponent<Props> { ...@@ -43,7 +54,7 @@ class BottomNavLinks extends PureComponent<Props> {
)} )}
{link.showOrgSwitcher && ( {link.showOrgSwitcher && (
<li className="sidemenu-org-switcher"> <li className="sidemenu-org-switcher">
<a onClick={this.switchOrg}> <a onClick={this.toggleSwitcherModal}>
<div> <div>
<div className="sidemenu-org-switcher__org-name">{user.orgName}</div> <div className="sidemenu-org-switcher__org-name">{user.orgName}</div>
<div className="sidemenu-org-switcher__org-current">Current Org:</div> <div className="sidemenu-org-switcher__org-current">Current Org:</div>
...@@ -55,6 +66,9 @@ class BottomNavLinks extends PureComponent<Props> { ...@@ -55,6 +66,9 @@ class BottomNavLinks extends PureComponent<Props> {
</a> </a>
</li> </li>
)} )}
<OrgSwitcher onDismiss={this.toggleSwitcherModal} isOpen={showSwitcherModal} />
{link.children && {link.children &&
link.children.map((child, index) => { link.children.map((child, index) => {
if (!child.hideFromMenu) { if (!child.hideFromMenu) {
......
...@@ -15,6 +15,10 @@ exports[`Render should render children 1`] = ` ...@@ -15,6 +15,10 @@ exports[`Render should render children 1`] = `
className="dropdown-menu dropdown-menu--sidemenu" className="dropdown-menu dropdown-menu--sidemenu"
role="menu" role="menu"
> >
<OrgSwitcher
isOpen={false}
onDismiss={[Function]}
/>
<li <li
key="undefined-0" key="undefined-0"
> >
...@@ -62,6 +66,10 @@ exports[`Render should render component 1`] = ` ...@@ -62,6 +66,10 @@ exports[`Render should render component 1`] = `
className="dropdown-menu dropdown-menu--sidemenu" className="dropdown-menu dropdown-menu--sidemenu"
role="menu" role="menu"
> >
<OrgSwitcher
isOpen={false}
onDismiss={[Function]}
/>
<li <li
className="side-menu-header" className="side-menu-header"
> >
...@@ -118,6 +126,10 @@ exports[`Render should render organization switcher 1`] = ` ...@@ -118,6 +126,10 @@ exports[`Render should render organization switcher 1`] = `
</div> </div>
</a> </a>
</li> </li>
<OrgSwitcher
isOpen={false}
onDismiss={[Function]}
/>
<li <li
className="side-menu-header" className="side-menu-header"
> >
...@@ -153,6 +165,10 @@ exports[`Render should render subtitle 1`] = ` ...@@ -153,6 +165,10 @@ exports[`Render should render subtitle 1`] = `
subtitle subtitle
</span> </span>
</li> </li>
<OrgSwitcher
isOpen={false}
onDismiss={[Function]}
/>
<li <li
className="side-menu-header" className="side-menu-header"
> >
......
...@@ -39,7 +39,6 @@ import { contextSrv } from './services/context_srv'; ...@@ -39,7 +39,6 @@ import { contextSrv } from './services/context_srv';
import { KeybindingSrv } from './services/keybindingSrv'; import { KeybindingSrv } from './services/keybindingSrv';
import { NavModelSrv } from './nav_model_srv'; import { NavModelSrv } from './nav_model_srv';
import { geminiScrollbar } from './components/scroll/scroll'; import { geminiScrollbar } from './components/scroll/scroll';
import { orgSwitcher } from './components/org_switcher';
import { profiler } from './profiler'; import { profiler } from './profiler';
import { registerAngularDirectives } from './angular_wrappers'; import { registerAngularDirectives } from './angular_wrappers';
import { updateLegendValues } from './time_series2'; import { updateLegendValues } from './time_series2';
...@@ -72,7 +71,6 @@ export { ...@@ -72,7 +71,6 @@ export {
NavModelSrv, NavModelSrv,
NavModel, NavModel,
geminiScrollbar, geminiScrollbar,
orgSwitcher,
manageDashboardsDirective, manageDashboardsDirective,
TimeSeries, TimeSeries,
updateLegendValues, 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> { ...@@ -39,16 +39,7 @@ export class PanelInspector extends PureComponent<Props, State> {
// TODO? should we get the result with an observable once? // TODO? should we get the result with an observable once?
const data = (panel.getQueryRunner() as any).lastResult; const data = (panel.getQueryRunner() as any).lastResult;
return ( return (
<Modal <Modal title={panel.title} icon="fa fa-info-circle" onDismiss={this.onDismiss} isOpen={true}>
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}
>
<div className={bodyStyle}> <div className={bodyStyle}>
<JSONFormatter json={data} open={2} /> <JSONFormatter json={data} open={2} />
</div> </div>
......
...@@ -46,8 +46,8 @@ ...@@ -46,8 +46,8 @@
.modal-header-title { .modal-header-title {
font-size: $font-size-h3; font-size: $font-size-h3;
float: left; float: left;
padding-top: $spacer * 0.75; padding-top: $space-sm;
margin: 0 $spacer * 3 0 $spacer * 1.5; margin: 0 $space-md;
.gicon { .gicon {
position: relative; 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