mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Merge branch '7883_new_url_structure' into 7883_frontend_step2
This commit is contained in:
@@ -24,6 +24,7 @@ describe('AlertRuleList', () => {
|
||||
evalData: {},
|
||||
executionError: '',
|
||||
dashboardUri: 'db/mygool',
|
||||
canEdit: true,
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
@@ -147,7 +147,8 @@ export class AlertRuleItem extends React.Component<AlertRuleItemProps, any> {
|
||||
<div className="alert-rule-item__body">
|
||||
<div className="alert-rule-item__header">
|
||||
<div className="alert-rule-item__name">
|
||||
<a href={ruleUrl}>{this.renderText(rule.name)}</a>
|
||||
{rule.canEdit && <a href={ruleUrl}>{this.renderText(rule.name)}</a>}
|
||||
{!rule.canEdit && <span>{this.renderText(rule.name)}</span>}
|
||||
</div>
|
||||
<div className="alert-rule-item__text">
|
||||
<span className={`${rule.stateClass}`}>{this.renderText(rule.stateText)}</span>
|
||||
@@ -156,17 +157,30 @@ export class AlertRuleItem extends React.Component<AlertRuleItemProps, any> {
|
||||
</div>
|
||||
{rule.info && <div className="small muted alert-rule-item__info">{this.renderText(rule.info)}</div>}
|
||||
</div>
|
||||
|
||||
<div className="alert-rule-item__actions">
|
||||
<a
|
||||
<button
|
||||
className="btn btn-small btn-inverse alert-list__btn width-2"
|
||||
title="Pausing an alert rule prevents it from executing"
|
||||
onClick={this.toggleState}
|
||||
disabled={!rule.canEdit}
|
||||
>
|
||||
<i className={stateClass} />
|
||||
</a>
|
||||
<a className="btn btn-small btn-inverse alert-list__btn width-2" href={ruleUrl} title="Edit alert rule">
|
||||
<i className="icon-gf icon-gf-settings" />
|
||||
</a>
|
||||
</button>
|
||||
{rule.canEdit && (
|
||||
<a className="btn btn-small btn-inverse alert-list__btn width-2" href={ruleUrl} title="Edit alert rule">
|
||||
<i className="icon-gf icon-gf-settings" />
|
||||
</a>
|
||||
)}
|
||||
{!rule.canEdit && (
|
||||
<button
|
||||
className="btn btn-small btn-inverse alert-list__btn width-2"
|
||||
title="Edit alert rule"
|
||||
disabled={true}
|
||||
>
|
||||
<i className="icon-gf icon-gf-settings" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -80,15 +80,16 @@ exports[`AlertRuleList should render 1 rule 1`] = `
|
||||
<div
|
||||
className="alert-rule-item__actions"
|
||||
>
|
||||
<a
|
||||
<button
|
||||
className="btn btn-small btn-inverse alert-list__btn width-2"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
title="Pausing an alert rule prevents it from executing"
|
||||
>
|
||||
<i
|
||||
className="fa fa-pause"
|
||||
/>
|
||||
</a>
|
||||
</button>
|
||||
<a
|
||||
className="btn btn-small btn-inverse alert-list__btn width-2"
|
||||
href="dashboard/db/mygool?panelId=3&fullscreen&edit&tab=alert"
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { SearchStore } from './../stores/SearchStore/SearchStore';
|
||||
import { ServerStatsStore } from './../stores/ServerStatsStore/ServerStatsStore';
|
||||
import { NavStore } from './../stores/NavStore/NavStore';
|
||||
import { PermissionsStore } from './../stores/PermissionsStore/PermissionsStore';
|
||||
import { AlertListStore } from './../stores/AlertListStore/AlertListStore';
|
||||
import { ViewStore } from './../stores/ViewStore/ViewStore';
|
||||
import { FolderStore } from './../stores/FolderStore/FolderStore';
|
||||
|
||||
interface IContainerProps {
|
||||
search: typeof SearchStore.Type;
|
||||
serverStats: typeof ServerStatsStore.Type;
|
||||
nav: typeof NavStore.Type;
|
||||
alertList: typeof AlertListStore.Type;
|
||||
permissions: typeof PermissionsStore.Type;
|
||||
view: typeof ViewStore.Type;
|
||||
folder: typeof FolderStore.Type;
|
||||
backendSrv: any;
|
||||
}
|
||||
|
||||
export default IContainerProps;
|
||||
|
||||
49
public/app/containers/ManageDashboards/FolderPermissions.tsx
Normal file
49
public/app/containers/ManageDashboards/FolderPermissions.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React, { Component } from 'react';
|
||||
import { inject, observer } from 'mobx-react';
|
||||
import { toJS } from 'mobx';
|
||||
import IContainerProps from 'app/containers/IContainerProps';
|
||||
import PageHeader from 'app/core/components/PageHeader/PageHeader';
|
||||
import Permissions from 'app/core/components/Permissions/Permissions';
|
||||
import Tooltip from 'app/core/components/Tooltip/Tooltip';
|
||||
import PermissionsInfo from 'app/core/components/Permissions/PermissionsInfo';
|
||||
@inject('nav', 'folder', 'view', 'permissions')
|
||||
@observer
|
||||
export class FolderPermissions extends Component<IContainerProps, any> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.loadStore();
|
||||
}
|
||||
|
||||
loadStore() {
|
||||
const { nav, folder, view } = this.props;
|
||||
return folder.load(view.routeParams.get('slug') as string).then(res => {
|
||||
return nav.initFolderNav(toJS(folder.folder), 'manage-folder-permissions');
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { nav, folder, permissions, backendSrv } = this.props;
|
||||
|
||||
if (!folder.folder || !nav.main) {
|
||||
return <h2>Loading</h2>;
|
||||
}
|
||||
|
||||
const dashboardId = folder.folder.id;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader model={nav as any} />
|
||||
<div className="page-container page-body">
|
||||
<div className="page-sub-heading">
|
||||
<h2 className="d-inline-block">Folder Permissions</h2>
|
||||
<Tooltip className="page-sub-heading-icon" placement="auto" content={PermissionsInfo}>
|
||||
<i className="gicon gicon-question gicon--has-hover" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<Permissions permissions={permissions} isFolder={true} dashboardId={dashboardId} backendSrv={backendSrv} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import React from 'react';
|
||||
import { FolderSettings } from './FolderSettings';
|
||||
import { RootStore } from 'app/stores/RootStore/RootStore';
|
||||
import { backendSrv } from 'test/mocks/common';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
describe('FolderSettings', () => {
|
||||
let wrapper;
|
||||
let page;
|
||||
|
||||
beforeAll(() => {
|
||||
backendSrv.getDashboard.mockReturnValue(
|
||||
Promise.resolve({
|
||||
dashboard: {
|
||||
id: 1,
|
||||
title: 'Folder Name',
|
||||
},
|
||||
meta: {
|
||||
slug: 'folder-name',
|
||||
canSave: true,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const store = RootStore.create(
|
||||
{},
|
||||
{
|
||||
backendSrv: backendSrv,
|
||||
}
|
||||
);
|
||||
|
||||
wrapper = shallow(<FolderSettings backendSrv={backendSrv} {...store} />);
|
||||
return wrapper
|
||||
.dive()
|
||||
.instance()
|
||||
.loadStore()
|
||||
.then(() => {
|
||||
page = wrapper.dive();
|
||||
});
|
||||
});
|
||||
|
||||
it('should set the title input field', () => {
|
||||
const titleInput = page.find('.gf-form-input');
|
||||
expect(titleInput).toHaveLength(1);
|
||||
expect(titleInput.prop('value')).toBe('Folder Name');
|
||||
});
|
||||
|
||||
it('should update title and enable save button when changed', () => {
|
||||
const titleInput = page.find('.gf-form-input');
|
||||
const disabledSubmitButton = page.find('button[type="submit"]');
|
||||
expect(disabledSubmitButton.prop('disabled')).toBe(true);
|
||||
|
||||
titleInput.simulate('change', { target: { value: 'New Title' } });
|
||||
|
||||
const updatedTitleInput = page.find('.gf-form-input');
|
||||
expect(updatedTitleInput.prop('value')).toBe('New Title');
|
||||
const enabledSubmitButton = page.find('button[type="submit"]');
|
||||
expect(enabledSubmitButton.prop('disabled')).toBe(false);
|
||||
});
|
||||
|
||||
it('should disable save button if title is changed back to old title', () => {
|
||||
const titleInput = page.find('.gf-form-input');
|
||||
|
||||
titleInput.simulate('change', { target: { value: 'Folder Name' } });
|
||||
|
||||
const enabledSubmitButton = page.find('button[type="submit"]');
|
||||
expect(enabledSubmitButton.prop('disabled')).toBe(true);
|
||||
});
|
||||
|
||||
it('should disable save button if title is changed to empty string', () => {
|
||||
const titleInput = page.find('.gf-form-input');
|
||||
|
||||
titleInput.simulate('change', { target: { value: '' } });
|
||||
|
||||
const enabledSubmitButton = page.find('button[type="submit"]');
|
||||
expect(enabledSubmitButton.prop('disabled')).toBe(true);
|
||||
});
|
||||
});
|
||||
153
public/app/containers/ManageDashboards/FolderSettings.tsx
Normal file
153
public/app/containers/ManageDashboards/FolderSettings.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import React from 'react';
|
||||
import { inject, observer } from 'mobx-react';
|
||||
import { toJS } from 'mobx';
|
||||
import PageHeader from 'app/core/components/PageHeader/PageHeader';
|
||||
import IContainerProps from 'app/containers/IContainerProps';
|
||||
import { getSnapshot } from 'mobx-state-tree';
|
||||
import appEvents from 'app/core/app_events';
|
||||
|
||||
@inject('nav', 'folder', 'view')
|
||||
@observer
|
||||
export class FolderSettings extends React.Component<IContainerProps, any> {
|
||||
formSnapshot: any;
|
||||
dashboard: any;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.loadStore();
|
||||
}
|
||||
|
||||
loadStore() {
|
||||
const { nav, folder, view } = this.props;
|
||||
|
||||
return folder.load(view.routeParams.get('slug') as string).then(res => {
|
||||
this.formSnapshot = getSnapshot(folder);
|
||||
this.dashboard = res.dashboard;
|
||||
|
||||
return nav.initFolderNav(toJS(folder.folder), 'manage-folder-settings');
|
||||
});
|
||||
}
|
||||
|
||||
onTitleChange(evt) {
|
||||
this.props.folder.setTitle(this.getFormSnapshot().folder.title, evt.target.value);
|
||||
}
|
||||
|
||||
getFormSnapshot() {
|
||||
if (!this.formSnapshot) {
|
||||
this.formSnapshot = getSnapshot(this.props.folder);
|
||||
}
|
||||
|
||||
return this.formSnapshot;
|
||||
}
|
||||
|
||||
save(evt) {
|
||||
if (evt) {
|
||||
evt.stopPropagation();
|
||||
evt.preventDefault();
|
||||
}
|
||||
|
||||
const { nav, folder, view } = this.props;
|
||||
|
||||
folder
|
||||
.saveFolder(this.dashboard, { overwrite: false })
|
||||
.then(newUrl => {
|
||||
view.updatePathAndQuery(newUrl, '', '');
|
||||
|
||||
appEvents.emit('dashboard-saved');
|
||||
appEvents.emit('alert-success', ['Folder saved']);
|
||||
})
|
||||
.then(() => {
|
||||
return nav.initFolderNav(toJS(folder.folder), 'manage-folder-settings');
|
||||
})
|
||||
.catch(this.handleSaveFolderError);
|
||||
}
|
||||
|
||||
delete(evt) {
|
||||
if (evt) {
|
||||
evt.stopPropagation();
|
||||
evt.preventDefault();
|
||||
}
|
||||
|
||||
const { folder, view } = this.props;
|
||||
const title = folder.folder.title;
|
||||
|
||||
appEvents.emit('confirm-modal', {
|
||||
title: 'Delete',
|
||||
text: `Do you want to delete this folder and all its dashboards?`,
|
||||
icon: 'fa-trash',
|
||||
yesText: 'Delete',
|
||||
onConfirm: () => {
|
||||
return this.props.folder.deleteFolder().then(() => {
|
||||
appEvents.emit('alert-success', ['Folder Deleted', `${title} has been deleted`]);
|
||||
view.updatePathAndQuery('dashboards', '', '');
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
handleSaveFolderError(err) {
|
||||
if (err.data && err.data.status === 'version-mismatch') {
|
||||
err.isHandled = true;
|
||||
|
||||
appEvents.emit('confirm-modal', {
|
||||
title: 'Conflict',
|
||||
text: 'Someone else has updated this folder.',
|
||||
text2: 'Would you still like to save this folder?',
|
||||
yesText: 'Save & Overwrite',
|
||||
icon: 'fa-warning',
|
||||
onConfirm: () => {
|
||||
this.props.folder.saveFolder(this.dashboard, { overwrite: true });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (err.data && err.data.status === 'name-exists') {
|
||||
err.isHandled = true;
|
||||
|
||||
appEvents.emit('alert-error', ['A folder or dashboard with this name exists already.']);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { nav, folder } = this.props;
|
||||
|
||||
if (!folder.folder || !nav.main) {
|
||||
return <h2>Loading</h2>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader model={nav as any} />
|
||||
<div className="page-container page-body">
|
||||
<h2 className="page-sub-heading">Folder Settings</h2>
|
||||
|
||||
<div className="section gf-form-group">
|
||||
<form name="folderSettingsForm" onSubmit={this.save.bind(this)}>
|
||||
<div className="gf-form">
|
||||
<label className="gf-form-label width-7">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
className="gf-form-input width-30"
|
||||
value={folder.folder.title}
|
||||
onChange={this.onTitleChange.bind(this)}
|
||||
/>
|
||||
</div>
|
||||
<div className="gf-form-button-row">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-success"
|
||||
disabled={!folder.folder.canSave || !folder.folder.hasChanged}
|
||||
>
|
||||
<i className="fa fa-trash" /> Save
|
||||
</button>
|
||||
<button className="btn btn-danger" onClick={this.delete.bind(this)} disabled={!folder.folder.canSave}>
|
||||
<i className="fa fa-trash" /> Delete
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ describe('ServerStats', () => {
|
||||
}
|
||||
);
|
||||
|
||||
const page = renderer.create(<ServerStats {...store} />);
|
||||
const page = renderer.create(<ServerStats backendSrv={backendSrv} {...store} />);
|
||||
|
||||
setTimeout(() => {
|
||||
expect(page.toJSON()).toMatchSnapshot();
|
||||
|
||||
@@ -41,9 +41,9 @@ exports[`ServerStats Should render table with stats 1`] = `
|
||||
/>
|
||||
<select
|
||||
className="gf-select-nav gf-form-input"
|
||||
defaultValue="/url/server-stats"
|
||||
id="page-header-select-nav"
|
||||
onChange={[Function]}
|
||||
value="/url/server-stats"
|
||||
>
|
||||
<option
|
||||
value="/url/server-stats"
|
||||
|
||||
@@ -4,8 +4,9 @@ import PageHeader from './components/PageHeader/PageHeader';
|
||||
import EmptyListCTA from './components/EmptyListCTA/EmptyListCTA';
|
||||
import LoginBackground from './components/Login/LoginBackground';
|
||||
import { SearchResult } from './components/search/SearchResult';
|
||||
import UserPicker from './components/UserPicker/UserPicker';
|
||||
import { TagFilter } from './components/TagFilter/TagFilter';
|
||||
import UserPicker from './components/Picker/UserPicker';
|
||||
import DashboardPermissions from './components/Permissions/DashboardPermissions';
|
||||
|
||||
export function registerAngularDirectives() {
|
||||
react2AngularDirective('passwordStrength', PasswordStrength, ['password']);
|
||||
@@ -13,10 +14,17 @@ export function registerAngularDirectives() {
|
||||
react2AngularDirective('emptyListCta', EmptyListCTA, ['model']);
|
||||
react2AngularDirective('loginBackground', LoginBackground, []);
|
||||
react2AngularDirective('searchResult', SearchResult, []);
|
||||
react2AngularDirective('selectUserPicker', UserPicker, ['backendSrv', 'teamId', 'refreshList']);
|
||||
react2AngularDirective('tagFilter', TagFilter, [
|
||||
'tags',
|
||||
['onSelect', { watchDepth: 'reference' }],
|
||||
['tagOptions', { watchDepth: 'reference' }],
|
||||
]);
|
||||
react2AngularDirective('selectUserPicker', UserPicker, ['backendSrv', 'handlePicked']);
|
||||
react2AngularDirective('dashboardPermissions', DashboardPermissions, [
|
||||
'backendSrv',
|
||||
'dashboardId',
|
||||
'folderTitle',
|
||||
'folderSlug',
|
||||
'folderId',
|
||||
]);
|
||||
}
|
||||
|
||||
53
public/app/core/components/PageHeader/PageHeader.jest.tsx
Normal file
53
public/app/core/components/PageHeader/PageHeader.jest.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import PageHeader from './PageHeader';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
describe('PageHeader', () => {
|
||||
let wrapper;
|
||||
|
||||
describe('when the nav tree has a node with a title', () => {
|
||||
beforeAll(() => {
|
||||
const nav = {
|
||||
main: {
|
||||
icon: 'fa fa-folder-open',
|
||||
id: 'node',
|
||||
subTitle: 'node subtitle',
|
||||
url: '',
|
||||
text: 'node',
|
||||
},
|
||||
node: {},
|
||||
};
|
||||
wrapper = shallow(<PageHeader model={nav as any} />);
|
||||
});
|
||||
|
||||
it('should render the title', () => {
|
||||
const title = wrapper.find('.page-header__title');
|
||||
expect(title.text()).toBe('node');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the nav tree has a node with breadcrumbs and a title', () => {
|
||||
beforeAll(() => {
|
||||
const nav = {
|
||||
main: {
|
||||
icon: 'fa fa-folder-open',
|
||||
id: 'child',
|
||||
subTitle: 'child subtitle',
|
||||
url: '',
|
||||
text: 'child',
|
||||
breadcrumbs: [{ title: 'Parent', url: 'parentUrl' }],
|
||||
},
|
||||
node: {},
|
||||
};
|
||||
wrapper = shallow(<PageHeader model={nav as any} />);
|
||||
});
|
||||
|
||||
it('should render the title with breadcrumbs first and then title last', () => {
|
||||
const title = wrapper.find('.page-header__title');
|
||||
expect(title.text()).toBe('Parent / child');
|
||||
|
||||
const parentLink = wrapper.find('.page-header__title > a.text-link');
|
||||
expect(parentLink.prop('href')).toBe('parentUrl');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,55 +1,15 @@
|
||||
import React from 'react';
|
||||
import { observer } from 'mobx-react';
|
||||
import { NavModel, NavModelItem } from '../../nav_model_srv';
|
||||
import classNames from 'classnames';
|
||||
import appEvents from 'app/core/app_events';
|
||||
import { toJS } from 'mobx';
|
||||
|
||||
export interface IProps {
|
||||
model: NavModel;
|
||||
}
|
||||
|
||||
function TabItem(tab: NavModelItem) {
|
||||
if (tab.hideFromTabs) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let tabClasses = classNames({
|
||||
'gf-tabs-link': true,
|
||||
active: tab.active,
|
||||
});
|
||||
|
||||
return (
|
||||
<li className="gf-tabs-item" key={tab.url}>
|
||||
<a className={tabClasses} target={tab.target} href={tab.url}>
|
||||
<i className={tab.icon} />
|
||||
{tab.text}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectOption(navItem: NavModelItem) {
|
||||
if (navItem.hideFromTabs) {
|
||||
// TODO: Rename hideFromTabs => hideFromNav
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<option key={navItem.url} value={navItem.url}>
|
||||
{navItem.text}
|
||||
</option>
|
||||
);
|
||||
}
|
||||
|
||||
function Navigation({ main }: { main: NavModelItem }) {
|
||||
return (
|
||||
<nav>
|
||||
<SelectNav customCss="page-header__select-nav" main={main} />
|
||||
<Tabs customCss="page-header__tabs" main={main} />
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectNav({ main, customCss }: { main: NavModelItem; customCss: string }) {
|
||||
const SelectNav = ({ main, customCss }: { main: NavModelItem; customCss: string }) => {
|
||||
const defaultSelectedItem = main.children.find(navItem => {
|
||||
return navItem.active === true;
|
||||
});
|
||||
@@ -66,26 +26,81 @@ function SelectNav({ main, customCss }: { main: NavModelItem; customCss: string
|
||||
{/* Label to make it clickable */}
|
||||
<select
|
||||
className="gf-select-nav gf-form-input"
|
||||
defaultValue={defaultSelectedItem.url}
|
||||
value={defaultSelectedItem.url}
|
||||
onChange={gotoUrl}
|
||||
id="page-header-select-nav"
|
||||
>
|
||||
{main.children.map(SelectOption)}
|
||||
{main.children.map((navItem: NavModelItem) => {
|
||||
if (navItem.hideFromTabs) {
|
||||
// TODO: Rename hideFromTabs => hideFromNav
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<option key={navItem.url} value={navItem.url}>
|
||||
{navItem.text}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
function Tabs({ main, customCss }: { main: NavModelItem; customCss: string }) {
|
||||
return <ul className={`gf-tabs ${customCss}`}>{main.children.map(TabItem)}</ul>;
|
||||
}
|
||||
const Tabs = ({ main, customCss }: { main: NavModelItem; customCss: string }) => {
|
||||
return (
|
||||
<ul className={`gf-tabs ${customCss}`}>
|
||||
{main.children.map((tab, idx) => {
|
||||
if (tab.hideFromTabs) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tabClasses = classNames({
|
||||
'gf-tabs-link': true,
|
||||
active: tab.active,
|
||||
});
|
||||
|
||||
return (
|
||||
<li className="gf-tabs-item" key={tab.url}>
|
||||
<a className={tabClasses} target={tab.target} href={tab.url}>
|
||||
<i className={tab.icon} />
|
||||
{tab.text}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
const Navigation = ({ main }: { main: NavModelItem }) => {
|
||||
return (
|
||||
<nav>
|
||||
<SelectNav customCss="page-header__select-nav" main={main} />
|
||||
<Tabs customCss="page-header__tabs" main={main} />
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
@observer
|
||||
export default class PageHeader extends React.Component<IProps, any> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
renderBreadcrumb(breadcrumbs) {
|
||||
shouldComponentUpdate() {
|
||||
//Hack to re-render on changed props from angular with the @observer decorator
|
||||
return true;
|
||||
}
|
||||
|
||||
renderTitle(title: string, breadcrumbs: any[]) {
|
||||
if (!title && (!breadcrumbs || breadcrumbs.length === 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!breadcrumbs || breadcrumbs.length === 0) {
|
||||
return <h1 className="page-header__title">{title}</h1>;
|
||||
}
|
||||
|
||||
const breadcrumbsResult = [];
|
||||
for (let i = 0; i < breadcrumbs.length; i++) {
|
||||
const bc = breadcrumbs[i];
|
||||
@@ -99,7 +114,9 @@ export default class PageHeader extends React.Component<IProps, any> {
|
||||
breadcrumbsResult.push(<span key={i}> / {bc.title}</span>);
|
||||
}
|
||||
}
|
||||
return breadcrumbsResult;
|
||||
breadcrumbsResult.push(<span key={breadcrumbs.length + 1}> / {title}</span>);
|
||||
|
||||
return <h1 className="page-header__title">{breadcrumbsResult}</h1>;
|
||||
}
|
||||
|
||||
renderHeaderTitle(main) {
|
||||
@@ -111,11 +128,7 @@ export default class PageHeader extends React.Component<IProps, any> {
|
||||
</span>
|
||||
|
||||
<div className="page-header__info-block">
|
||||
{main.text && <h1 className="page-header__title">{main.text}</h1>}
|
||||
{main.breadcrumbs &&
|
||||
main.breadcrumbs.length > 0 && (
|
||||
<h1 className="page-header__title">{this.renderBreadcrumb(main.breadcrumbs)}</h1>
|
||||
)}
|
||||
{this.renderTitle(main.text, main.breadcrumbs)}
|
||||
{main.subTitle && <div className="page-header__sub-title">{main.subTitle}</div>}
|
||||
{main.subType && (
|
||||
<div className="page-header__stamps">
|
||||
@@ -135,12 +148,14 @@ export default class PageHeader extends React.Component<IProps, any> {
|
||||
return null;
|
||||
}
|
||||
|
||||
const main = toJS(model.main); // Convert to JS if its a mobx observable
|
||||
|
||||
return (
|
||||
<div className="page-header-canvas">
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
{this.renderHeaderTitle(model.main)}
|
||||
{model.main.children && <Navigation main={model.main} />}
|
||||
{this.renderHeaderTitle(main)}
|
||||
{main.children && <Navigation main={main} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import React, { Component } from 'react';
|
||||
import { store } from 'app/stores/store';
|
||||
import Permissions from 'app/core/components/Permissions/Permissions';
|
||||
import Tooltip from 'app/core/components/Tooltip/Tooltip';
|
||||
import PermissionsInfo from 'app/core/components/Permissions/PermissionsInfo';
|
||||
|
||||
export interface IProps {
|
||||
dashboardId: number;
|
||||
folderId: number;
|
||||
folderTitle: string;
|
||||
folderSlug: string;
|
||||
backendSrv: any;
|
||||
}
|
||||
|
||||
class DashboardPermissions extends Component<IProps, any> {
|
||||
permissions: any;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.permissions = store.permissions;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { dashboardId, folderTitle, folderSlug, folderId, backendSrv } = this.props;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="dashboard-settings__header">
|
||||
<h3 className="d-inline-block">Permissions</h3>
|
||||
<Tooltip className="page-sub-heading-icon" placement="auto" content={PermissionsInfo}>
|
||||
<i className="gicon gicon-question gicon--has-hover" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Permissions
|
||||
permissions={this.permissions}
|
||||
isFolder={false}
|
||||
dashboardId={dashboardId}
|
||||
folderInfo={{ title: folderTitle, slug: folderSlug, id: folderId }}
|
||||
backendSrv={backendSrv}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default DashboardPermissions;
|
||||
@@ -0,0 +1,40 @@
|
||||
import React, { Component } from 'react';
|
||||
import DescriptionPicker from 'app/core/components/Picker/DescriptionPicker';
|
||||
import { permissionOptions } from 'app/stores/PermissionsStore/PermissionsStore';
|
||||
|
||||
export interface IProps {
|
||||
item: any;
|
||||
}
|
||||
|
||||
export default class DisabledPermissionListItem extends Component<IProps, any> {
|
||||
render() {
|
||||
const { item } = this.props;
|
||||
|
||||
return (
|
||||
<tr className="gf-form-disabled">
|
||||
<td style={{ width: '100%' }}>
|
||||
<i className={`fa--permissions-list ${item.icon}`} />
|
||||
<span dangerouslySetInnerHTML={{ __html: item.nameHtml }} />
|
||||
</td>
|
||||
<td />
|
||||
<td className="query-keyword">Can</td>
|
||||
<td>
|
||||
<div className="gf-form">
|
||||
<DescriptionPicker
|
||||
optionsWithDesc={permissionOptions}
|
||||
handlePicked={() => {}}
|
||||
value={item.permission}
|
||||
disabled={true}
|
||||
className={'gf-form-input--form-dropdown-right'}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<button className="btn btn-inverse btn-small">
|
||||
<i className="fa fa-lock" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
}
|
||||
5
public/app/core/components/Permissions/FolderInfo.ts
Normal file
5
public/app/core/components/Permissions/FolderInfo.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface FolderInfo {
|
||||
title: string;
|
||||
id: number;
|
||||
slug: string;
|
||||
}
|
||||
73
public/app/core/components/Permissions/Permissions.jest.tsx
Normal file
73
public/app/core/components/Permissions/Permissions.jest.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React from 'react';
|
||||
import Permissions from './Permissions';
|
||||
import { RootStore } from 'app/stores/RootStore/RootStore';
|
||||
import { backendSrv } from 'test/mocks/common';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
describe('Permissions', () => {
|
||||
let wrapper;
|
||||
|
||||
beforeAll(() => {
|
||||
backendSrv.get.mockReturnValue(
|
||||
Promise.resolve([
|
||||
{ id: 2, dashboardId: 1, role: 'Viewer', permission: 1, permissionName: 'View' },
|
||||
{ id: 3, dashboardId: 1, role: 'Editor', permission: 1, permissionName: 'Edit' },
|
||||
{
|
||||
id: 4,
|
||||
dashboardId: 1,
|
||||
userId: 2,
|
||||
userLogin: 'danlimerick',
|
||||
userEmail: 'dan.limerick@gmail.com',
|
||||
permission: 4,
|
||||
permissionName: 'Admin',
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
backendSrv.post = jest.fn();
|
||||
|
||||
const store = RootStore.create(
|
||||
{},
|
||||
{
|
||||
backendSrv: backendSrv,
|
||||
}
|
||||
);
|
||||
|
||||
wrapper = shallow(<Permissions backendSrv={backendSrv} isFolder={true} dashboardId={1} {...store} />);
|
||||
return wrapper.instance().loadStore(1, true);
|
||||
});
|
||||
|
||||
describe('when permission for a user is added', () => {
|
||||
it('should save permission to db', () => {
|
||||
const userItem = {
|
||||
id: 2,
|
||||
login: 'user2',
|
||||
};
|
||||
|
||||
wrapper
|
||||
.instance()
|
||||
.userPicked(userItem)
|
||||
.then(() => {
|
||||
expect(backendSrv.post.mock.calls.length).toBe(1);
|
||||
expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when permission for team is added', () => {
|
||||
it('should save permission to db', () => {
|
||||
const teamItem = {
|
||||
id: 2,
|
||||
name: 'ug1',
|
||||
};
|
||||
|
||||
wrapper
|
||||
.instance()
|
||||
.teamPicked(teamItem)
|
||||
.then(() => {
|
||||
expect(backendSrv.post.mock.calls.length).toBe(1);
|
||||
expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
161
public/app/core/components/Permissions/Permissions.tsx
Normal file
161
public/app/core/components/Permissions/Permissions.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import React, { Component } from 'react';
|
||||
import PermissionsList from './PermissionsList';
|
||||
import { observer } from 'mobx-react';
|
||||
import UserPicker, { User } from 'app/core/components/Picker/UserPicker';
|
||||
import TeamPicker, { Team } from 'app/core/components/Picker/TeamPicker';
|
||||
import { aclTypes } from 'app/stores/PermissionsStore/PermissionsStore';
|
||||
import { FolderInfo } from './FolderInfo';
|
||||
|
||||
export interface DashboardAcl {
|
||||
id?: number;
|
||||
dashboardId?: number;
|
||||
userId?: number;
|
||||
userLogin?: string;
|
||||
userEmail?: string;
|
||||
teamId?: number;
|
||||
team?: string;
|
||||
permission?: number;
|
||||
permissionName?: string;
|
||||
role?: string;
|
||||
icon?: string;
|
||||
nameHtml?: string;
|
||||
inherited?: boolean;
|
||||
sortName?: string;
|
||||
sortRank?: number;
|
||||
}
|
||||
|
||||
export interface IProps {
|
||||
dashboardId: number;
|
||||
folderInfo?: FolderInfo;
|
||||
permissions?: any;
|
||||
isFolder: boolean;
|
||||
backendSrv: any;
|
||||
}
|
||||
|
||||
@observer
|
||||
class Permissions extends Component<IProps, any> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const { dashboardId, isFolder, folderInfo } = this.props;
|
||||
this.permissionChanged = this.permissionChanged.bind(this);
|
||||
this.typeChanged = this.typeChanged.bind(this);
|
||||
this.removeItem = this.removeItem.bind(this);
|
||||
this.userPicked = this.userPicked.bind(this);
|
||||
this.teamPicked = this.teamPicked.bind(this);
|
||||
this.loadStore(dashboardId, isFolder, folderInfo && folderInfo.id === 0);
|
||||
}
|
||||
|
||||
loadStore(dashboardId, isFolder, isInRoot = false) {
|
||||
return this.props.permissions.load(dashboardId, isFolder, isInRoot);
|
||||
}
|
||||
|
||||
permissionChanged(index: number, permission: number, permissionName: string) {
|
||||
const { permissions } = this.props;
|
||||
permissions.updatePermissionOnIndex(index, permission, permissionName);
|
||||
}
|
||||
|
||||
removeItem(index: number) {
|
||||
const { permissions } = this.props;
|
||||
permissions.removeStoreItem(index);
|
||||
}
|
||||
|
||||
resetNewType() {
|
||||
const { permissions } = this.props;
|
||||
permissions.resetNewType();
|
||||
}
|
||||
|
||||
typeChanged(evt) {
|
||||
const { value } = evt.target;
|
||||
const { permissions, dashboardId } = this.props;
|
||||
|
||||
if (value === 'Viewer' || value === 'Editor') {
|
||||
permissions.addStoreItem({ permission: 1, role: value, dashboardId: dashboardId }, dashboardId);
|
||||
this.resetNewType();
|
||||
return;
|
||||
}
|
||||
|
||||
permissions.setNewType(value);
|
||||
}
|
||||
|
||||
userPicked(user: User) {
|
||||
const { permissions, dashboardId } = this.props;
|
||||
return permissions.addStoreItem({
|
||||
userId: user.id,
|
||||
userLogin: user.login,
|
||||
permission: 1,
|
||||
dashboardId: dashboardId,
|
||||
});
|
||||
}
|
||||
|
||||
teamPicked(team: Team) {
|
||||
const { permissions, dashboardId } = this.props;
|
||||
return permissions.addStoreItem({
|
||||
teamId: team.id,
|
||||
team: team.name,
|
||||
permission: 1,
|
||||
dashboardId: dashboardId,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { permissions, folderInfo, backendSrv } = this.props;
|
||||
|
||||
return (
|
||||
<div className="gf-form-group">
|
||||
<PermissionsList
|
||||
permissions={permissions.items}
|
||||
removeItem={this.removeItem}
|
||||
permissionChanged={this.permissionChanged}
|
||||
fetching={permissions.fetching}
|
||||
folderInfo={folderInfo}
|
||||
/>
|
||||
<div className="gf-form-inline">
|
||||
<form name="addPermission" className="gf-form-group">
|
||||
<h6 className="muted">Add Permission For</h6>
|
||||
<div className="gf-form-inline">
|
||||
<div className="gf-form">
|
||||
<div className="gf-form-select-wrapper">
|
||||
<select
|
||||
className="gf-form-input gf-size-auto"
|
||||
value={permissions.newType}
|
||||
onChange={this.typeChanged}
|
||||
>
|
||||
{aclTypes.map((option, idx) => {
|
||||
return (
|
||||
<option key={idx} value={option.value}>
|
||||
{option.text}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{permissions.newType === 'User' ? (
|
||||
<div className="gf-form">
|
||||
<UserPicker backendSrv={backendSrv} handlePicked={this.userPicked} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{permissions.newType === 'Group' ? (
|
||||
<div className="gf-form">
|
||||
<TeamPicker backendSrv={backendSrv} handlePicked={this.teamPicked} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
{permissions.error ? (
|
||||
<div className="gf-form width-17">
|
||||
<span ng-if="ctrl.error" className="text-error p-l-1">
|
||||
<i className="fa fa-warning" />
|
||||
{permissions.error}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Permissions;
|
||||
13
public/app/core/components/Permissions/PermissionsInfo.tsx
Normal file
13
public/app/core/components/Permissions/PermissionsInfo.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
|
||||
export default () => {
|
||||
return (
|
||||
<div className="">
|
||||
<h5>What are Permissions?</h5>
|
||||
<p>
|
||||
An Access Control List (ACL) model is used to limit access to Dashboard Folders. A user or a Team can be
|
||||
assigned permissions for a folder or for a single dashboard.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
64
public/app/core/components/Permissions/PermissionsList.tsx
Normal file
64
public/app/core/components/Permissions/PermissionsList.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import React, { Component } from 'react';
|
||||
import PermissionsListItem from './PermissionsListItem';
|
||||
import DisabledPermissionsListItem from './DisabledPermissionsListItem';
|
||||
import { observer } from 'mobx-react';
|
||||
import { FolderInfo } from './FolderInfo';
|
||||
|
||||
export interface IProps {
|
||||
permissions: any[];
|
||||
removeItem: any;
|
||||
permissionChanged: any;
|
||||
fetching: boolean;
|
||||
folderInfo?: FolderInfo;
|
||||
}
|
||||
|
||||
@observer
|
||||
class PermissionsList extends Component<IProps, any> {
|
||||
render() {
|
||||
const { permissions, removeItem, permissionChanged, fetching, folderInfo } = this.props;
|
||||
|
||||
return (
|
||||
<table className="filter-table gf-form-group">
|
||||
<tbody>
|
||||
<DisabledPermissionsListItem
|
||||
key={0}
|
||||
item={{
|
||||
nameHtml: 'Everyone with <span class="query-keyword">Admin</span> Role',
|
||||
permission: 4,
|
||||
icon: 'fa fa-fw fa-street-view',
|
||||
}}
|
||||
/>
|
||||
{permissions.map((item, idx) => {
|
||||
return (
|
||||
<PermissionsListItem
|
||||
key={idx + 1}
|
||||
item={item}
|
||||
itemIndex={idx}
|
||||
removeItem={removeItem}
|
||||
permissionChanged={permissionChanged}
|
||||
folderInfo={folderInfo}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{fetching === true && permissions.length < 1 ? (
|
||||
<tr>
|
||||
<td colSpan={4}>
|
||||
<em>Loading permissions...</em>
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
|
||||
{fetching === false && permissions.length < 1 ? (
|
||||
<tr>
|
||||
<td colSpan={4}>
|
||||
<em>No permissions are set. Will only be accessible by admins.</em>
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default PermissionsList;
|
||||
@@ -0,0 +1,65 @@
|
||||
import React from 'react';
|
||||
import { observer } from 'mobx-react';
|
||||
import DescriptionPicker from 'app/core/components/Picker/DescriptionPicker';
|
||||
import { permissionOptions } from 'app/stores/PermissionsStore/PermissionsStore';
|
||||
|
||||
const setClassNameHelper = inherited => {
|
||||
return inherited ? 'gf-form-disabled' : '';
|
||||
};
|
||||
|
||||
export default observer(({ item, removeItem, permissionChanged, itemIndex, folderInfo }) => {
|
||||
const handleRemoveItem = evt => {
|
||||
evt.preventDefault();
|
||||
removeItem(itemIndex);
|
||||
};
|
||||
|
||||
const handleChangePermission = permissionOption => {
|
||||
permissionChanged(itemIndex, permissionOption.value, permissionOption.label);
|
||||
};
|
||||
|
||||
const inheritedFromRoot = item.dashboardId === -1 && folderInfo && folderInfo.id === 0;
|
||||
|
||||
return (
|
||||
<tr className={setClassNameHelper(item.inherited)}>
|
||||
<td style={{ width: '100%' }}>
|
||||
<i className={`fa--permissions-list ${item.icon}`} />
|
||||
<span dangerouslySetInnerHTML={{ __html: item.nameHtml }} />
|
||||
</td>
|
||||
<td>
|
||||
{item.inherited &&
|
||||
folderInfo && (
|
||||
<em className="muted no-wrap">
|
||||
Inherited from folder{' '}
|
||||
<a className="text-link" href={`dashboards/folder/${folderInfo.id}/${folderInfo.slug}/permissions`}>
|
||||
{folderInfo.title}
|
||||
</a>{' '}
|
||||
</em>
|
||||
)}
|
||||
{inheritedFromRoot && <em className="muted no-wrap">Default Permission</em>}
|
||||
</td>
|
||||
<td className="query-keyword">Can</td>
|
||||
<td>
|
||||
<div className="gf-form">
|
||||
<DescriptionPicker
|
||||
optionsWithDesc={permissionOptions}
|
||||
handlePicked={handleChangePermission}
|
||||
value={item.permission}
|
||||
disabled={item.inherited}
|
||||
className={'gf-form-input--form-dropdown-right'}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{!item.inherited ? (
|
||||
<a className="btn btn-danger btn-small" onClick={handleRemoveItem}>
|
||||
<i className="fa fa-remove" />
|
||||
</a>
|
||||
) : (
|
||||
<button className="btn btn-inverse btn-small">
|
||||
<i className="fa fa-lock" />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
56
public/app/core/components/Picker/DescriptionOption.tsx
Normal file
56
public/app/core/components/Picker/DescriptionOption.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import React, { Component } from 'react';
|
||||
|
||||
export interface IProps {
|
||||
onSelect: any;
|
||||
onFocus: any;
|
||||
option: any;
|
||||
isFocused: any;
|
||||
className: any;
|
||||
}
|
||||
|
||||
class DescriptionOption extends Component<IProps, any> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.handleMouseDown = this.handleMouseDown.bind(this);
|
||||
this.handleMouseEnter = this.handleMouseEnter.bind(this);
|
||||
this.handleMouseMove = this.handleMouseMove.bind(this);
|
||||
}
|
||||
|
||||
handleMouseDown(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.props.onSelect(this.props.option, event);
|
||||
}
|
||||
|
||||
handleMouseEnter(event) {
|
||||
this.props.onFocus(this.props.option, event);
|
||||
}
|
||||
|
||||
handleMouseMove(event) {
|
||||
if (this.props.isFocused) {
|
||||
return;
|
||||
}
|
||||
this.props.onFocus(this.props.option, event);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { option, children, className } = this.props;
|
||||
return (
|
||||
<button
|
||||
onMouseDown={this.handleMouseDown}
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
onMouseMove={this.handleMouseMove}
|
||||
title={option.title}
|
||||
className={`description-picker-option__button btn btn-link ${className} width-19`}
|
||||
>
|
||||
<div className="gf-form">{children}</div>
|
||||
<div className="gf-form">
|
||||
<div className="muted width-17">{option.description}</div>
|
||||
{className.indexOf('is-selected') > -1 && <i className="fa fa-check" aria-hidden="true" />}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default DescriptionOption;
|
||||
48
public/app/core/components/Picker/DescriptionPicker.tsx
Normal file
48
public/app/core/components/Picker/DescriptionPicker.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import React, { Component } from 'react';
|
||||
import Select from 'react-select';
|
||||
import DescriptionOption from './DescriptionOption';
|
||||
|
||||
export interface IProps {
|
||||
optionsWithDesc: OptionWithDescription[];
|
||||
handlePicked: (permission) => void;
|
||||
value: number;
|
||||
disabled: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface OptionWithDescription {
|
||||
value: any;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
class DescriptionPicker extends Component<IProps, any> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {};
|
||||
}
|
||||
|
||||
render() {
|
||||
const { optionsWithDesc, handlePicked, value, disabled, className } = this.props;
|
||||
|
||||
return (
|
||||
<div className="permissions-picker">
|
||||
<Select
|
||||
value={value}
|
||||
valueKey="value"
|
||||
multi={false}
|
||||
clearable={false}
|
||||
labelKey="label"
|
||||
options={optionsWithDesc}
|
||||
onChange={handlePicked}
|
||||
className={`width-7 gf-form-input gf-form-input--form-dropdown ${className || ''}`}
|
||||
optionComponent={DescriptionOption}
|
||||
placeholder="Choose"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default DescriptionPicker;
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import renderer from 'react-test-renderer';
|
||||
import UserPickerOption from './UserPickerOption';
|
||||
import PickerOption from './PickerOption';
|
||||
|
||||
const model = {
|
||||
onSelect: () => {},
|
||||
@@ -14,9 +14,9 @@ const model = {
|
||||
className: 'class-for-user-picker',
|
||||
};
|
||||
|
||||
describe('UserPickerOption', () => {
|
||||
describe('PickerOption', () => {
|
||||
it('renders correctly', () => {
|
||||
const tree = renderer.create(<UserPickerOption {...model} />).toJSON();
|
||||
const tree = renderer.create(<PickerOption {...model} />).toJSON();
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
79
public/app/core/components/Picker/TeamPicker.tsx
Normal file
79
public/app/core/components/Picker/TeamPicker.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import React, { Component } from 'react';
|
||||
import Select from 'react-select';
|
||||
import PickerOption from './PickerOption';
|
||||
import withPicker from './withPicker';
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
export interface IProps {
|
||||
backendSrv: any;
|
||||
isLoading: boolean;
|
||||
toggleLoading: any;
|
||||
handlePicked: (user) => void;
|
||||
}
|
||||
|
||||
export interface Team {
|
||||
id: number;
|
||||
label: string;
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
}
|
||||
|
||||
class TeamPicker extends Component<IProps, any> {
|
||||
debouncedSearch: any;
|
||||
backendSrv: any;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {};
|
||||
this.search = this.search.bind(this);
|
||||
|
||||
this.debouncedSearch = debounce(this.search, 300, {
|
||||
leading: true,
|
||||
trailing: false,
|
||||
});
|
||||
}
|
||||
|
||||
search(query?: string) {
|
||||
const { toggleLoading, backendSrv } = this.props;
|
||||
|
||||
toggleLoading(true);
|
||||
return backendSrv.get(`/api/teams/search?perpage=10&page=1&query=${query}`).then(result => {
|
||||
const teams = result.teams.map(team => {
|
||||
return {
|
||||
id: team.id,
|
||||
label: team.name,
|
||||
name: team.name,
|
||||
avatarUrl: team.avatarUrl,
|
||||
};
|
||||
});
|
||||
|
||||
toggleLoading(false);
|
||||
return { options: teams };
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const AsyncComponent = this.state.creatable ? Select.AsyncCreatable : Select.Async;
|
||||
const { isLoading, handlePicked } = this.props;
|
||||
|
||||
return (
|
||||
<div className="user-picker">
|
||||
<AsyncComponent
|
||||
valueKey="id"
|
||||
multi={false}
|
||||
labelKey="label"
|
||||
cache={false}
|
||||
isLoading={isLoading}
|
||||
loadOptions={this.debouncedSearch}
|
||||
loadingPlaceholder="Loading..."
|
||||
onChange={handlePicked}
|
||||
className="width-8 gf-form-input gf-form-input--form-dropdown"
|
||||
optionComponent={PickerOption}
|
||||
placeholder="Choose"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withPicker(TeamPicker);
|
||||
@@ -8,8 +8,7 @@ const model = {
|
||||
return new Promise((resolve, reject) => {});
|
||||
},
|
||||
},
|
||||
refreshList: () => {},
|
||||
teamId: '1',
|
||||
handlePicked: () => {},
|
||||
};
|
||||
|
||||
describe('UserPicker', () => {
|
||||
79
public/app/core/components/Picker/UserPicker.tsx
Normal file
79
public/app/core/components/Picker/UserPicker.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import React, { Component } from 'react';
|
||||
import Select from 'react-select';
|
||||
import PickerOption from './PickerOption';
|
||||
import withPicker from './withPicker';
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
export interface IProps {
|
||||
backendSrv: any;
|
||||
isLoading: boolean;
|
||||
toggleLoading: any;
|
||||
handlePicked: (user) => void;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
label: string;
|
||||
avatarUrl: string;
|
||||
login: string;
|
||||
}
|
||||
|
||||
class UserPicker extends Component<IProps, any> {
|
||||
debouncedSearch: any;
|
||||
backendSrv: any;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {};
|
||||
this.search = this.search.bind(this);
|
||||
|
||||
this.debouncedSearch = debounce(this.search, 300, {
|
||||
leading: true,
|
||||
trailing: false,
|
||||
});
|
||||
}
|
||||
|
||||
search(query?: string) {
|
||||
const { toggleLoading, backendSrv } = this.props;
|
||||
|
||||
toggleLoading(true);
|
||||
return backendSrv.get(`/api/users/search?perpage=10&page=1&query=${query}`).then(result => {
|
||||
const users = result.users.map(user => {
|
||||
return {
|
||||
id: user.id,
|
||||
label: `${user.login} - ${user.email}`,
|
||||
avatarUrl: user.avatarUrl,
|
||||
login: user.login,
|
||||
};
|
||||
});
|
||||
toggleLoading(false);
|
||||
return { options: users };
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const AsyncComponent = this.state.creatable ? Select.AsyncCreatable : Select.Async;
|
||||
const { isLoading, handlePicked } = this.props;
|
||||
|
||||
return (
|
||||
<div className="user-picker">
|
||||
<AsyncComponent
|
||||
valueKey="id"
|
||||
multi={false}
|
||||
labelKey="label"
|
||||
cache={false}
|
||||
isLoading={isLoading}
|
||||
loadOptions={this.debouncedSearch}
|
||||
loadingPlaceholder="Loading..."
|
||||
noResultsText="No users found"
|
||||
onChange={handlePicked}
|
||||
className="width-8 gf-form-input gf-form-input--form-dropdown"
|
||||
optionComponent={PickerOption}
|
||||
placeholder="Choose"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withPicker(UserPicker);
|
||||
@@ -1,6 +1,6 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`UserPickerOption renders correctly 1`] = `
|
||||
exports[`PickerOption renders correctly 1`] = `
|
||||
<button
|
||||
className="user-picker-option__button btn btn-link class-for-user-picker"
|
||||
onMouseDown={[Function]}
|
||||
32
public/app/core/components/Picker/withPicker.tsx
Normal file
32
public/app/core/components/Picker/withPicker.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React, { Component } from 'react';
|
||||
|
||||
export interface IProps {
|
||||
backendSrv: any;
|
||||
handlePicked: (data) => void;
|
||||
}
|
||||
|
||||
export default function withPicker(WrappedComponent) {
|
||||
return class WithPicker extends Component<IProps, any> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.toggleLoading = this.toggleLoading.bind(this);
|
||||
|
||||
this.state = {
|
||||
isLoading: false,
|
||||
};
|
||||
}
|
||||
|
||||
toggleLoading(isLoading) {
|
||||
this.setState(prevState => {
|
||||
return {
|
||||
...prevState,
|
||||
isLoading: isLoading,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return <WrappedComponent toggleLoading={this.toggleLoading} isLoading={this.state.isLoading} {...this.props} />;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -6,7 +6,7 @@ describe('Popover', () => {
|
||||
it('renders correctly', () => {
|
||||
const tree = renderer
|
||||
.create(
|
||||
<Popover placement="auto" content="Popover text">
|
||||
<Popover className="test-class" placement="auto" content="Popover text">
|
||||
<button>Button with Popover</button>
|
||||
</Popover>
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ describe('Tooltip', () => {
|
||||
it('renders correctly', () => {
|
||||
const tree = renderer
|
||||
.create(
|
||||
<Tooltip placement="auto" content="Tooltip text">
|
||||
<Tooltip className="test-class" placement="auto" content="Tooltip text">
|
||||
<a href="http://www.grafana.com">Link with tooltip</a>
|
||||
</Tooltip>
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
exports[`Popover renders correctly 1`] = `
|
||||
<div
|
||||
className="popper__manager"
|
||||
className="popper__manager test-class"
|
||||
>
|
||||
<div
|
||||
className="popper__target"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
exports[`Tooltip renders correctly 1`] = `
|
||||
<div
|
||||
className="popper__manager"
|
||||
className="popper__manager test-class"
|
||||
>
|
||||
<div
|
||||
className="popper__target"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Manager, Popper, Arrow } from 'react-popper';
|
||||
interface IwithTooltipProps {
|
||||
placement?: string;
|
||||
content: string | ((props: any) => JSX.Element);
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function withTooltip(WrappedComponent) {
|
||||
@@ -39,10 +40,10 @@ export default function withTooltip(WrappedComponent) {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { content } = this.props;
|
||||
const { content, className } = this.props;
|
||||
|
||||
return (
|
||||
<Manager className="popper__manager">
|
||||
<Manager className={`popper__manager ${className || ''}`}>
|
||||
<WrappedComponent {...this.props} tooltipSetState={this.setState} />
|
||||
{this.state.show ? (
|
||||
<Popper placement={this.state.placement} className="popper">
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import { debounce } from 'lodash';
|
||||
import Select from 'react-select';
|
||||
import UserPickerOption from './UserPickerOption';
|
||||
|
||||
export interface IProps {
|
||||
backendSrv: any;
|
||||
teamId: string;
|
||||
refreshList: any;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
name: string;
|
||||
login: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
class UserPicker extends Component<IProps, any> {
|
||||
debouncedSearchUsers: any;
|
||||
backendSrv: any;
|
||||
teamId: string;
|
||||
refreshList: any;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.backendSrv = this.props.backendSrv;
|
||||
this.teamId = this.props.teamId;
|
||||
this.refreshList = this.props.refreshList;
|
||||
|
||||
this.searchUsers = this.searchUsers.bind(this);
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
this.addUser = this.addUser.bind(this);
|
||||
this.toggleLoading = this.toggleLoading.bind(this);
|
||||
|
||||
this.debouncedSearchUsers = debounce(this.searchUsers, 300, {
|
||||
leading: true,
|
||||
trailing: false,
|
||||
});
|
||||
|
||||
this.state = {
|
||||
multi: false,
|
||||
isLoading: false,
|
||||
};
|
||||
}
|
||||
|
||||
handleChange(user) {
|
||||
this.addUser(user.id);
|
||||
}
|
||||
|
||||
toggleLoading(isLoading) {
|
||||
this.setState(prevState => {
|
||||
return {
|
||||
...prevState,
|
||||
isLoading: isLoading,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
addUser(userId) {
|
||||
this.toggleLoading(true);
|
||||
this.backendSrv.post(`/api/teams/${this.teamId}/members`, { userId: userId }).then(() => {
|
||||
this.refreshList();
|
||||
this.toggleLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
searchUsers(query) {
|
||||
this.toggleLoading(true);
|
||||
|
||||
return this.backendSrv.get(`/api/users/search?perpage=10&page=1&query=${query}`).then(result => {
|
||||
const users = result.users.map(user => {
|
||||
return {
|
||||
id: user.id,
|
||||
label: `${user.login} - ${user.email}`,
|
||||
avatarUrl: user.avatarUrl,
|
||||
};
|
||||
});
|
||||
this.toggleLoading(false);
|
||||
return { options: users };
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const AsyncComponent = this.state.creatable ? Select.AsyncCreatable : Select.Async;
|
||||
|
||||
return (
|
||||
<div className="user-picker">
|
||||
<AsyncComponent
|
||||
valueKey="id"
|
||||
multi={this.state.multi}
|
||||
labelKey="label"
|
||||
cache={false}
|
||||
isLoading={this.state.isLoading}
|
||||
loadOptions={this.debouncedSearchUsers}
|
||||
loadingPlaceholder="Loading..."
|
||||
noResultsText="No users found"
|
||||
onChange={this.handleChange}
|
||||
className="width-8 gf-form-input gf-form-input--form-dropdown"
|
||||
optionComponent={UserPickerOption}
|
||||
placeholder="Choose"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default UserPicker;
|
||||
@@ -5,11 +5,11 @@
|
||||
<i class="gf-form-input-icon fa fa-search"></i>
|
||||
</label>
|
||||
<div class="page-action-bar__spacer"></div>
|
||||
<a class="btn btn-success" ng-href="{{ctrl.createDashboardUrl()}}">
|
||||
<a class="btn btn-success" ng-href="{{ctrl.createDashboardUrl()}}" ng-if="ctrl.isEditor || ctrl.canSave">
|
||||
<i class="fa fa-plus"></i>
|
||||
Dashboard
|
||||
</a>
|
||||
<a class="btn btn-success" href="dashboards/folder/new" ng-if="!ctrl.folderId">
|
||||
<a class="btn btn-success" href="dashboards/folder/new" ng-if="!ctrl.folderId && ctrl.isEditor">
|
||||
<i class="fa fa-plus"></i>
|
||||
Folder
|
||||
</a>
|
||||
@@ -95,22 +95,23 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-results-container">
|
||||
<dashboard-search-results
|
||||
<dashboard-search-results
|
||||
results="ctrl.sections"
|
||||
editable="true"
|
||||
on-selection-changed="ctrl.selectionChanged()"
|
||||
on-tag-selected="ctrl.filterByTag($tag)" />
|
||||
on-tag-selected="ctrl.filterByTag($tag)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ng-if="ctrl.folderId && !ctrl.hasFilters && ctrl.sections.length === 0">
|
||||
<div ng-if="ctrl.canSave && ctrl.folderId && !ctrl.hasFilters && ctrl.sections.length === 0">
|
||||
<empty-list-cta model="{
|
||||
title: 'This folder doesn\'t have any dashboards yet',
|
||||
buttonIcon: 'gicon gicon-dashboard-new',
|
||||
buttonLink: 'dashboard/new?folderId={{ctrl.folderId}}',
|
||||
buttonTitle: 'Create Dashboard',
|
||||
proTip: 'Add dashboards into your folder at ->',
|
||||
proTip: 'Add/move dashboards to your folder at ->',
|
||||
proTipLink: 'dashboards',
|
||||
proTipLinkTitle: 'Manage dashboards',
|
||||
proTipTarget: ''
|
||||
|
||||
@@ -3,22 +3,49 @@ import coreModule from 'app/core/core_module';
|
||||
import appEvents from 'app/core/app_events';
|
||||
import { SearchSrv } from 'app/core/services/search_srv';
|
||||
|
||||
class Query {
|
||||
query: string;
|
||||
mode: string;
|
||||
tag: any[];
|
||||
starred: boolean;
|
||||
skipRecent: boolean;
|
||||
skipStarred: boolean;
|
||||
folderIds: number[];
|
||||
}
|
||||
|
||||
export class ManageDashboardsCtrl {
|
||||
public sections: any[];
|
||||
tagFilterOptions: any[];
|
||||
selectedTagFilter: any;
|
||||
query: any;
|
||||
|
||||
query: Query;
|
||||
navModel: any;
|
||||
|
||||
selectAllChecked = false;
|
||||
|
||||
// enable/disable actions depending on the folders or dashboards selected
|
||||
canDelete = false;
|
||||
canMove = false;
|
||||
|
||||
// filter variables
|
||||
hasFilters = false;
|
||||
selectAllChecked = false;
|
||||
tagFilterOptions: any[];
|
||||
selectedTagFilter: any;
|
||||
starredFilterOptions = [{ text: 'Filter by Starred', disabled: true }, { text: 'Yes' }, { text: 'No' }];
|
||||
selectedStarredFilter: any;
|
||||
|
||||
// used when managing dashboards for a specific folder
|
||||
folderId?: number;
|
||||
folderSlug?: string;
|
||||
|
||||
// if user can add new folders and/or add new dashboards
|
||||
canSave: boolean;
|
||||
|
||||
// if user has editor role or higher
|
||||
isEditor: boolean;
|
||||
|
||||
/** @ngInject */
|
||||
constructor(private backendSrv, navModelSrv, private searchSrv: SearchSrv) {
|
||||
constructor(private backendSrv, navModelSrv, private searchSrv: SearchSrv, private contextSrv) {
|
||||
this.isEditor = this.contextSrv.isEditor;
|
||||
|
||||
this.query = {
|
||||
query: '',
|
||||
mode: 'tree',
|
||||
@@ -26,6 +53,7 @@ export class ManageDashboardsCtrl {
|
||||
starred: false,
|
||||
skipRecent: true,
|
||||
skipStarred: true,
|
||||
folderIds: [],
|
||||
};
|
||||
|
||||
if (this.folderId) {
|
||||
@@ -34,15 +62,26 @@ export class ManageDashboardsCtrl {
|
||||
|
||||
this.selectedStarredFilter = this.starredFilterOptions[0];
|
||||
|
||||
this.getDashboards().then(() => {
|
||||
this.getTags();
|
||||
this.refreshList().then(() => {
|
||||
this.initTagFilter();
|
||||
});
|
||||
}
|
||||
|
||||
getDashboards() {
|
||||
return this.searchSrv.search(this.query).then(result => {
|
||||
return this.initDashboardList(result);
|
||||
});
|
||||
refreshList() {
|
||||
return this.searchSrv
|
||||
.search(this.query)
|
||||
.then(result => {
|
||||
return this.initDashboardList(result);
|
||||
})
|
||||
.then(() => {
|
||||
if (!this.folderSlug) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.backendSrv.getDashboard('db', this.folderSlug).then(dash => {
|
||||
this.canSave = dash.meta.canSave;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
initDashboardList(result: any) {
|
||||
@@ -176,7 +215,7 @@ export class ManageDashboardsCtrl {
|
||||
appEvents.emit('alert-success', [header, msg]);
|
||||
}
|
||||
|
||||
this.getDashboards();
|
||||
this.refreshList();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -203,12 +242,12 @@ export class ManageDashboardsCtrl {
|
||||
modalClass: 'modal--narrow',
|
||||
model: {
|
||||
dashboards: selectedDashboards,
|
||||
afterSave: this.getDashboards.bind(this),
|
||||
afterSave: this.refreshList.bind(this),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getTags() {
|
||||
initTagFilter() {
|
||||
return this.searchSrv.getDashboardTags().then(results => {
|
||||
this.tagFilterOptions = [{ term: 'Filter By Tag', disabled: true }].concat(results);
|
||||
this.selectedTagFilter = this.tagFilterOptions[0];
|
||||
@@ -220,11 +259,11 @@ export class ManageDashboardsCtrl {
|
||||
this.query.tag.push(tag);
|
||||
}
|
||||
|
||||
return this.getDashboards();
|
||||
return this.refreshList();
|
||||
}
|
||||
|
||||
onQueryChange() {
|
||||
return this.getDashboards();
|
||||
return this.refreshList();
|
||||
}
|
||||
|
||||
onTagFilterChange() {
|
||||
@@ -235,7 +274,7 @@ export class ManageDashboardsCtrl {
|
||||
|
||||
removeTag(tag, evt) {
|
||||
this.query.tag = _.without(this.query.tag, tag);
|
||||
this.getDashboards();
|
||||
this.refreshList();
|
||||
if (evt) {
|
||||
evt.stopPropagation();
|
||||
evt.preventDefault();
|
||||
@@ -244,13 +283,13 @@ export class ManageDashboardsCtrl {
|
||||
|
||||
removeStarred() {
|
||||
this.query.starred = false;
|
||||
return this.getDashboards();
|
||||
return this.refreshList();
|
||||
}
|
||||
|
||||
onStarredFilterChange() {
|
||||
this.query.starred = this.selectedStarredFilter.text === 'Yes';
|
||||
this.selectedStarredFilter = this.starredFilterOptions[0];
|
||||
return this.getDashboards();
|
||||
return this.refreshList();
|
||||
}
|
||||
|
||||
onSelectAllChanged() {
|
||||
@@ -272,7 +311,7 @@ export class ManageDashboardsCtrl {
|
||||
this.query.query = '';
|
||||
this.query.tag = [];
|
||||
this.query.starred = false;
|
||||
this.getDashboards();
|
||||
this.refreshList();
|
||||
}
|
||||
|
||||
createDashboardUrl() {
|
||||
@@ -295,6 +334,7 @@ export function manageDashboardsDirective() {
|
||||
controllerAs: 'ctrl',
|
||||
scope: {
|
||||
folderId: '=',
|
||||
folderSlug: '=',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,8 +10,9 @@ export class InvitedCtrl {
|
||||
$scope.navModel = {
|
||||
main: {
|
||||
icon: 'gicon gicon-branding',
|
||||
text: 'Invite',
|
||||
subTitle: 'Register your Grafana account',
|
||||
breadcrumbs: [{ title: 'Login', url: '/login' }, { title: 'Invite' }],
|
||||
breadcrumbs: [{ title: 'Login', url: '/login' }],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -16,8 +16,9 @@ export class ResetPasswordCtrl {
|
||||
$scope.navModel = {
|
||||
main: {
|
||||
icon: 'gicon gicon-branding',
|
||||
text: 'Reset Password',
|
||||
subTitle: 'Reset your Grafana password',
|
||||
breadcrumbs: [{ title: 'Login', url: 'login' }, { title: 'Reset Password' }],
|
||||
breadcrumbs: [{ title: 'Login', url: 'login' }],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import './directives/dash_class';
|
||||
import './directives/dash_edit_link';
|
||||
import './directives/dropdown_typeahead';
|
||||
import './directives/metric_segment';
|
||||
import './directives/misc';
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
define([
|
||||
'jquery',
|
||||
'angular',
|
||||
'../core_module',
|
||||
'lodash',
|
||||
],
|
||||
function ($, angular, coreModule, _) {
|
||||
'use strict';
|
||||
|
||||
var editViewMap = {
|
||||
'settings': { src: 'public/app/features/dashboard/partials/settings.html'},
|
||||
'annotations': { src: 'public/app/features/annotations/partials/editor.html'},
|
||||
'templating': { src: 'public/app/features/templating/partials/editor.html'},
|
||||
'history': { html: '<gf-dashboard-history dashboard="dashboard"></gf-dashboard-history>'},
|
||||
'timepicker': { src: 'public/app/features/dashboard/timepicker/dropdown.html' },
|
||||
'import': { html: '<dash-import dismiss="dismiss()"></dash-import>', isModal: true },
|
||||
'permissions': { html: '<dash-acl-modal dismiss="dismiss()"></dash-acl-modal>', isModal: true },
|
||||
'new-folder': {
|
||||
isModal: true,
|
||||
html: '<folder-modal dismiss="dismiss()"></folder-modal>',
|
||||
modalClass: 'modal--narrow'
|
||||
}
|
||||
};
|
||||
|
||||
coreModule.default.directive('dashEditorView', function($compile, $location, $rootScope) {
|
||||
return {
|
||||
restrict: 'A',
|
||||
link: function(scope, elem) {
|
||||
var editorScope;
|
||||
var modalScope;
|
||||
var lastEditView;
|
||||
|
||||
function hideEditorPane(hideToShowOtherView) {
|
||||
if (editorScope) {
|
||||
editorScope.dismiss(hideToShowOtherView);
|
||||
}
|
||||
}
|
||||
|
||||
function showEditorPane(evt, options) {
|
||||
if (options.editview) {
|
||||
_.defaults(options, editViewMap[options.editview]);
|
||||
}
|
||||
|
||||
if (lastEditView && lastEditView === options.editview) {
|
||||
hideEditorPane(false);
|
||||
return;
|
||||
}
|
||||
|
||||
hideEditorPane(true);
|
||||
|
||||
lastEditView = options.editview;
|
||||
editorScope = options.scope ? options.scope.$new() : scope.$new();
|
||||
|
||||
editorScope.dismiss = function(hideToShowOtherView) {
|
||||
if (modalScope) {
|
||||
modalScope.dismiss();
|
||||
modalScope = null;
|
||||
}
|
||||
|
||||
editorScope.$destroy();
|
||||
lastEditView = null;
|
||||
editorScope = null;
|
||||
elem.removeClass('dash-edit-view--open');
|
||||
|
||||
if (!hideToShowOtherView) {
|
||||
setTimeout(function() {
|
||||
elem.empty();
|
||||
}, 250);
|
||||
}
|
||||
|
||||
if (options.editview) {
|
||||
var urlParams = $location.search();
|
||||
if (options.editview === urlParams.editview) {
|
||||
delete urlParams.editview;
|
||||
|
||||
// even though we always are in apply phase here
|
||||
// some angular bug is causing location search updates to
|
||||
// not happen always so this is a hack fix or that
|
||||
setTimeout(function() {
|
||||
$rootScope.$apply(function() {
|
||||
$location.search(urlParams);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (options.isModal) {
|
||||
modalScope = $rootScope.$new();
|
||||
modalScope.$on("$destroy", function() {
|
||||
editorScope.dismiss();
|
||||
});
|
||||
|
||||
$rootScope.appEvent('show-modal', {
|
||||
templateHtml: options.html,
|
||||
scope: modalScope,
|
||||
backdrop: 'static',
|
||||
modalClass: options.modalClass,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var view;
|
||||
if (options.src) {
|
||||
view = angular.element(document.createElement('div'));
|
||||
view.html('<div class="tabbed-view" ng-include="' + "'" + options.src + "'" + '"></div>');
|
||||
} else {
|
||||
view = angular.element(document.createElement('div'));
|
||||
view.addClass('tabbed-view');
|
||||
view.html(options.html);
|
||||
}
|
||||
|
||||
$compile(view)(editorScope);
|
||||
|
||||
setTimeout(function() {
|
||||
elem.empty();
|
||||
elem.append(view);
|
||||
setTimeout(function() {
|
||||
elem.addClass('dash-edit-view--open');
|
||||
}, 10);
|
||||
}, 10);
|
||||
}
|
||||
|
||||
scope.$watch("ctrl.dashboardViewState.state.editview", function(newValue, oldValue) {
|
||||
if (newValue) {
|
||||
showEditorPane(null, {editview: newValue});
|
||||
} else if (oldValue) {
|
||||
if (lastEditView === oldValue) {
|
||||
hideEditorPane();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
scope.$on("$destroy", hideEditorPane);
|
||||
|
||||
scope.onAppEvent('hide-dash-editor', function() {
|
||||
hideEditorPane(false);
|
||||
});
|
||||
|
||||
scope.onAppEvent('show-dash-editor', showEditorPane);
|
||||
|
||||
scope.onAppEvent('panel-fullscreen-enter', function() {
|
||||
scope.appEvent('hide-dash-editor');
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ export class BridgeSrv {
|
||||
private fullPageReloadRoutes;
|
||||
|
||||
/** @ngInject */
|
||||
constructor(private $location, private $timeout, private $window, private $rootScope) {
|
||||
constructor(private $location, private $timeout, private $window, private $rootScope, private $route) {
|
||||
this.appSubUrl = config.appSubUrl;
|
||||
this.fullPageReloadRoutes = ['/logout'];
|
||||
}
|
||||
@@ -29,14 +29,14 @@ export class BridgeSrv {
|
||||
this.$rootScope.$on('$routeUpdate', (evt, data) => {
|
||||
let angularUrl = this.$location.url();
|
||||
if (store.view.currentUrl !== angularUrl) {
|
||||
store.view.updatePathAndQuery(this.$location.path(), this.$location.search());
|
||||
store.view.updatePathAndQuery(this.$location.path(), this.$location.search(), this.$route.current.params);
|
||||
}
|
||||
});
|
||||
|
||||
this.$rootScope.$on('$routeChangeSuccess', (evt, data) => {
|
||||
let angularUrl = this.$location.url();
|
||||
if (store.view.currentUrl !== angularUrl) {
|
||||
store.view.updatePathAndQuery(this.$location.path(), this.$location.search());
|
||||
store.view.updatePathAndQuery(this.$location.path(), this.$location.search(), this.$route.current.params);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ describe('BridgeSrv', () => {
|
||||
let searchSrv;
|
||||
|
||||
beforeEach(() => {
|
||||
searchSrv = new BridgeSrv(null, null, null, null);
|
||||
searchSrv = new BridgeSrv(null, null, null, null, null);
|
||||
});
|
||||
|
||||
describe('With /subUrl as appSubUrl', () => {
|
||||
|
||||
@@ -49,7 +49,7 @@ describe('ManageDashboards', () => {
|
||||
},
|
||||
];
|
||||
ctrl = createCtrlWithStubs(response);
|
||||
return ctrl.getDashboards();
|
||||
return ctrl.refreshList();
|
||||
});
|
||||
|
||||
it('should set checked to false on all sections and children', () => {
|
||||
@@ -88,7 +88,7 @@ describe('ManageDashboards', () => {
|
||||
];
|
||||
ctrl = createCtrlWithStubs(response);
|
||||
ctrl.folderId = 410;
|
||||
return ctrl.getDashboards();
|
||||
return ctrl.refreshList();
|
||||
});
|
||||
|
||||
it('should set hide header to true on section', () => {
|
||||
@@ -137,7 +137,7 @@ describe('ManageDashboards', () => {
|
||||
ctrl.canMove = true;
|
||||
ctrl.canDelete = true;
|
||||
ctrl.selectAllChecked = true;
|
||||
return ctrl.getDashboards();
|
||||
return ctrl.refreshList();
|
||||
});
|
||||
|
||||
it('should set checked to false on all sections and children', () => {
|
||||
@@ -567,5 +567,5 @@ function createCtrlWithStubs(searchResponse: any, tags?: any) {
|
||||
},
|
||||
};
|
||||
|
||||
return new ManageDashboardsCtrl({}, { getNav: () => {} }, <SearchSrv>searchSrvStub);
|
||||
return new ManageDashboardsCtrl({}, { getNav: () => {} }, <SearchSrv>searchSrvStub, { isEditor: true });
|
||||
}
|
||||
|
||||
@@ -12,7 +12,11 @@ export function toUrlParams(a) {
|
||||
|
||||
let add = function(k, v) {
|
||||
v = typeof v === 'function' ? v() : v === null ? '' : v === undefined ? '' : v;
|
||||
s[s.length] = encodeURIComponent(k) + '=' + encodeURIComponent(v);
|
||||
if (typeof v !== 'boolean') {
|
||||
s[s.length] = encodeURIComponent(k) + '=' + encodeURIComponent(v);
|
||||
} else {
|
||||
s[s.length] = encodeURIComponent(k);
|
||||
}
|
||||
};
|
||||
|
||||
let buildParams = function(prefix, obj) {
|
||||
|
||||
@@ -54,7 +54,7 @@ export function annotationTooltipDirective($sanitize, dashboardSrv, contextSrv,
|
||||
`;
|
||||
|
||||
// Show edit icon only for users with at least Editor role
|
||||
if (event.id && contextSrv.isEditor) {
|
||||
if (event.id && dashboard.meta.canEdit) {
|
||||
header += `
|
||||
<span class="pointer graph-annotation__edit-icon" ng-click="onEdit()">
|
||||
<i class="fa fa-pencil-square"></i>
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
<div class="modal-body">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-header-title">
|
||||
<i class="fa fa-lock"></i>
|
||||
<span class="p-l-1">Permissions</span>
|
||||
</h2>
|
||||
|
||||
<a class="modal-header-close" ng-click="ctrl.dismiss();">
|
||||
<i class="fa fa-remove"></i>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="modal-content">
|
||||
<table class="filter-table gf-form-group">
|
||||
<tr ng-repeat="acl in ctrl.items" ng-class="{'gf-form-disabled': acl.inherited}">
|
||||
<td style="width: 100%;">
|
||||
<i class="{{acl.icon}}"></i>
|
||||
<span ng-bind-html="acl.nameHtml"></span>
|
||||
</td>
|
||||
<td>
|
||||
<em class="muted no-wrap" ng-show="acl.inherited">Inherited from folder</em>
|
||||
</td>
|
||||
<td class="query-keyword">Can</td>
|
||||
<td>
|
||||
<div class="gf-form-select-wrapper">
|
||||
<select class="gf-form-input gf-size-auto" ng-model="acl.permission" ng-options="p.value as p.text for p in ctrl.permissionOptions" ng-change="ctrl.permissionChanged(acl)" ng-disabled="acl.inherited"></select>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<a class="btn btn-inverse btn-small" ng-click="ctrl.removeItem($index)" ng-hide="acl.inherited">
|
||||
<i class="fa fa-remove"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr ng-show="ctrl.aclItems.length === 0">
|
||||
<td colspan="4">
|
||||
<em>No permissions. Will only be accessible by admins.</em>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div class="gf-form-inline">
|
||||
<form name="addPermission" class="gf-form-group">
|
||||
<h6 class="muted">Add Permission For</h6>
|
||||
<div class="gf-form-inline">
|
||||
<div class="gf-form">
|
||||
<div class="gf-form-select-wrapper">
|
||||
<select class="gf-form-input gf-size-auto" ng-model="ctrl.newType" ng-options="p.value as p.text for p in ctrl.aclTypes" ng-change="ctrl.typeChanged()"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gf-form" ng-show="ctrl.newType === 'User'">
|
||||
<user-picker user-picked="ctrl.userPicked($user)"></user-picker>
|
||||
</div>
|
||||
<div class="gf-form" ng-show="ctrl.newType === 'Group'">
|
||||
<team-picker team-picked="ctrl.groupPicked($group)"></team-picker>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div class="gf-form width-17">
|
||||
<span ng-if="ctrl.error" class="text-error p-l-1">
|
||||
<i class="fa fa-warning"></i>
|
||||
{{ctrl.error}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="gf-form-button-row text-center">
|
||||
<button type="button" class="btn btn-danger" ng-disabled="!ctrl.canUpdate" ng-click="ctrl.update()">
|
||||
Update Permissions
|
||||
</button>
|
||||
<a class="btn-text" ng-click="ctrl.dismiss();">Close</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- <br> -->
|
||||
<!-- <br> -->
|
||||
<!-- <br> -->
|
||||
<!-- -->
|
||||
<!-- <div class="permissionlist"> -->
|
||||
<!-- <div class="permissionlist__section"> -->
|
||||
<!-- <div class="permissionlist__section-header"> -->
|
||||
<!-- <h6>Permissions</h6> -->
|
||||
<!-- </div> -->
|
||||
<!-- <table class="filter-table form-inline"> -->
|
||||
<!-- <thead> -->
|
||||
<!-- <tr> -->
|
||||
<!-- <th style="width: 50px;"></th> -->
|
||||
<!-- <th>Name</th> -->
|
||||
<!-- <th style="width: 220px;">Permission</th> -->
|
||||
<!-- <th style="width: 120px"></th> -->
|
||||
<!-- </tr> -->
|
||||
<!-- </thead> -->
|
||||
<!-- <tbody> -->
|
||||
<!-- <tr ng-repeat="permission in ctrl.userPermissions" class="permissionlist__item"> -->
|
||||
<!-- <td><i class="fa fa-fw fa-user"></i></td> -->
|
||||
<!-- <td>{{permission.userLogin}}</td> -->
|
||||
<!-- <td class="text-right"> -->
|
||||
<!-- <a ng-click="ctrl.removePermission(permission)" class="btn btn-danger btn-small"> -->
|
||||
<!-- <i class="fa fa-remove"></i> -->
|
||||
<!-- </a> -->
|
||||
<!-- </td> -->
|
||||
<!-- </tr> -->
|
||||
<!-- <tr ng-repeat="permission in ctrl.teamPermissions" class="permissionlist__item"> -->
|
||||
<!-- <td><i class="fa fa-fw fa-users"></i></td> -->
|
||||
<!-- <td>{{permission.team}}</td> -->
|
||||
<!-- <td><select class="gf-form-input gf-size-auto" ng-model="permission.permissions" ng-options="p.value as p.text for p in ctrl.permissionTypeOptions" ng-change="ctrl.updatePermission(permission)"></select></td> -->
|
||||
<!-- <td class="text-right"> -->
|
||||
<!-- <a ng-click="ctrl.removePermission(permission)" class="btn btn-danger btn-small"> -->
|
||||
<!-- <i class="fa fa-remove"></i> -->
|
||||
<!-- </a> -->
|
||||
<!-- </td> -->
|
||||
<!-- </tr> -->
|
||||
<!-- <tr ng-repeat="role in ctrl.roles" class="permissionlist__item"> -->
|
||||
<!-- <td></td> -->
|
||||
<!-- <td>{{role.name}}</td> -->
|
||||
<!-- <td><select class="gf-form-input gf-size-auto" ng-model="role.permissions" ng-options="p.value as p.text for p in ctrl.roleOptions" ng-change="ctrl.updatePermission(role)"></select></td> -->
|
||||
<!-- <td class="text-right"> -->
|
||||
<!-- -->
|
||||
<!-- </td> -->
|
||||
<!-- </tr> -->
|
||||
<!-- </tbody> -->
|
||||
<!-- </table> -->
|
||||
<!-- </div> -->
|
||||
<!-- </div> -->
|
||||
<!-- </div> -->
|
||||
@@ -1,201 +0,0 @@
|
||||
import coreModule from 'app/core/core_module';
|
||||
import _ from 'lodash';
|
||||
|
||||
export class AclCtrl {
|
||||
dashboard: any;
|
||||
items: DashboardAcl[];
|
||||
permissionOptions = [{ value: 1, text: 'View' }, { value: 2, text: 'Edit' }, { value: 4, text: 'Admin' }];
|
||||
aclTypes = [
|
||||
{ value: 'Group', text: 'Team' },
|
||||
{ value: 'User', text: 'User' },
|
||||
{ value: 'Viewer', text: 'Everyone With Viewer Role' },
|
||||
{ value: 'Editor', text: 'Everyone With Editor Role' },
|
||||
];
|
||||
|
||||
dismiss: () => void;
|
||||
newType: string;
|
||||
canUpdate: boolean;
|
||||
error: string;
|
||||
|
||||
readonly duplicateError = 'This permission exists already.';
|
||||
|
||||
/** @ngInject */
|
||||
constructor(private backendSrv, dashboardSrv, private $sce, private $scope) {
|
||||
this.items = [];
|
||||
this.resetNewType();
|
||||
this.dashboard = dashboardSrv.getCurrent();
|
||||
this.get(this.dashboard.id);
|
||||
}
|
||||
|
||||
resetNewType() {
|
||||
this.newType = 'Group';
|
||||
}
|
||||
|
||||
get(dashboardId: number) {
|
||||
return this.backendSrv.get(`/api/dashboards/id/${dashboardId}/acl`).then(result => {
|
||||
this.items = _.map(result, this.prepareViewModel.bind(this));
|
||||
this.sortItems();
|
||||
});
|
||||
}
|
||||
|
||||
sortItems() {
|
||||
this.items = _.orderBy(this.items, ['sortRank', 'sortName'], ['desc', 'asc']);
|
||||
}
|
||||
|
||||
prepareViewModel(item: DashboardAcl): DashboardAcl {
|
||||
item.inherited = !this.dashboard.meta.isFolder && this.dashboard.id !== item.dashboardId;
|
||||
item.sortRank = 0;
|
||||
|
||||
if (item.userId > 0) {
|
||||
item.icon = 'fa fa-fw fa-user';
|
||||
item.nameHtml = this.$sce.trustAsHtml(item.userLogin);
|
||||
item.sortName = item.userLogin;
|
||||
item.sortRank = 10;
|
||||
} else if (item.teamId > 0) {
|
||||
item.icon = 'fa fa-fw fa-users';
|
||||
item.nameHtml = this.$sce.trustAsHtml(item.team);
|
||||
item.sortName = item.team;
|
||||
item.sortRank = 20;
|
||||
} else if (item.role) {
|
||||
item.icon = 'fa fa-fw fa-street-view';
|
||||
item.nameHtml = this.$sce.trustAsHtml(`Everyone with <span class="query-keyword">${item.role}</span> Role`);
|
||||
item.sortName = item.role;
|
||||
item.sortRank = 30;
|
||||
if (item.role === 'Viewer') {
|
||||
item.sortRank += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (item.inherited) {
|
||||
item.sortRank += 100;
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
update() {
|
||||
var updated = [];
|
||||
for (let item of this.items) {
|
||||
if (item.inherited) {
|
||||
continue;
|
||||
}
|
||||
updated.push({
|
||||
id: item.id,
|
||||
userId: item.userId,
|
||||
teamId: item.teamId,
|
||||
role: item.role,
|
||||
permission: item.permission,
|
||||
});
|
||||
}
|
||||
|
||||
return this.backendSrv.post(`/api/dashboards/id/${this.dashboard.id}/acl`, { items: updated }).then(() => {
|
||||
return this.dismiss();
|
||||
});
|
||||
}
|
||||
|
||||
typeChanged() {
|
||||
if (this.newType === 'Viewer' || this.newType === 'Editor') {
|
||||
this.addNewItem({ permission: 1, role: this.newType });
|
||||
this.canUpdate = true;
|
||||
this.resetNewType();
|
||||
}
|
||||
}
|
||||
|
||||
permissionChanged() {
|
||||
this.canUpdate = true;
|
||||
}
|
||||
|
||||
addNewItem(item) {
|
||||
if (!this.isValid(item)) {
|
||||
return;
|
||||
}
|
||||
this.error = '';
|
||||
|
||||
item.dashboardId = this.dashboard.id;
|
||||
|
||||
this.items.push(this.prepareViewModel(item));
|
||||
this.sortItems();
|
||||
|
||||
this.canUpdate = true;
|
||||
}
|
||||
|
||||
isValid(item) {
|
||||
const dupe = _.find(this.items, it => {
|
||||
return this.isDuplicate(it, item);
|
||||
});
|
||||
|
||||
if (dupe) {
|
||||
this.error = this.duplicateError;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
isDuplicate(origItem, newItem) {
|
||||
if (origItem.inherited) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
(origItem.role && newItem.role && origItem.role === newItem.role) ||
|
||||
(origItem.userId && newItem.userId && origItem.userId === newItem.userId) ||
|
||||
(origItem.teamId && newItem.teamId && origItem.teamId === newItem.teamId)
|
||||
);
|
||||
}
|
||||
|
||||
userPicked(user) {
|
||||
this.addNewItem({ userId: user.id, userLogin: user.login, permission: 1 });
|
||||
this.$scope.$broadcast('user-picker-reset');
|
||||
}
|
||||
|
||||
groupPicked(group) {
|
||||
this.addNewItem({ teamId: group.id, team: group.name, permission: 1 });
|
||||
this.$scope.$broadcast('team-picker-reset');
|
||||
}
|
||||
|
||||
removeItem(index) {
|
||||
this.items.splice(index, 1);
|
||||
this.canUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
export function dashAclModal() {
|
||||
return {
|
||||
restrict: 'E',
|
||||
templateUrl: 'public/app/features/dashboard/acl/acl.html',
|
||||
controller: AclCtrl,
|
||||
bindToController: true,
|
||||
controllerAs: 'ctrl',
|
||||
scope: {
|
||||
dismiss: '&',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface FormModel {
|
||||
dashboardId: number;
|
||||
userId?: number;
|
||||
teamId?: number;
|
||||
PermissionType: number;
|
||||
}
|
||||
|
||||
export interface DashboardAcl {
|
||||
id?: number;
|
||||
dashboardId?: number;
|
||||
userId?: number;
|
||||
userLogin?: string;
|
||||
userEmail?: string;
|
||||
teamId?: number;
|
||||
team?: string;
|
||||
permission?: number;
|
||||
permissionName?: string;
|
||||
role?: string;
|
||||
icon?: string;
|
||||
nameHtml?: string;
|
||||
inherited?: boolean;
|
||||
sortName?: string;
|
||||
sortRank?: number;
|
||||
}
|
||||
|
||||
coreModule.directive('dashAclModal', dashAclModal);
|
||||
@@ -1,188 +0,0 @@
|
||||
import { describe, beforeEach, it, expect, sinon, angularMocks } from 'test/lib/common';
|
||||
import { AclCtrl } from '../acl';
|
||||
|
||||
describe('AclCtrl', () => {
|
||||
const ctx: any = {};
|
||||
const backendSrv = {
|
||||
get: sinon.stub().returns(Promise.resolve([])),
|
||||
post: sinon.stub().returns(Promise.resolve([])),
|
||||
};
|
||||
|
||||
const dashboardSrv = {
|
||||
getCurrent: sinon.stub().returns({ id: 1, meta: { isFolder: false } }),
|
||||
};
|
||||
|
||||
beforeEach(angularMocks.module('grafana.core'));
|
||||
beforeEach(angularMocks.module('grafana.controllers'));
|
||||
|
||||
beforeEach(
|
||||
angularMocks.inject(($rootScope, $controller, $q, $compile) => {
|
||||
ctx.$q = $q;
|
||||
ctx.scope = $rootScope.$new();
|
||||
AclCtrl.prototype.dashboard = { dashboard: { id: 1 } };
|
||||
ctx.ctrl = $controller(
|
||||
AclCtrl,
|
||||
{
|
||||
$scope: ctx.scope,
|
||||
backendSrv: backendSrv,
|
||||
dashboardSrv: dashboardSrv,
|
||||
},
|
||||
{
|
||||
dismiss: () => {
|
||||
return;
|
||||
},
|
||||
}
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
describe('when permissions are added', () => {
|
||||
beforeEach(() => {
|
||||
backendSrv.get.reset();
|
||||
backendSrv.post.reset();
|
||||
|
||||
const userItem = {
|
||||
id: 2,
|
||||
login: 'user2',
|
||||
};
|
||||
|
||||
ctx.ctrl.userPicked(userItem);
|
||||
|
||||
const teamItem = {
|
||||
id: 2,
|
||||
name: 'ug1',
|
||||
};
|
||||
|
||||
ctx.ctrl.groupPicked(teamItem);
|
||||
|
||||
ctx.ctrl.newType = 'Editor';
|
||||
ctx.ctrl.typeChanged();
|
||||
|
||||
ctx.ctrl.newType = 'Viewer';
|
||||
ctx.ctrl.typeChanged();
|
||||
});
|
||||
|
||||
it('should sort the result by role, team and user', () => {
|
||||
expect(ctx.ctrl.items[0].role).to.eql('Viewer');
|
||||
expect(ctx.ctrl.items[1].role).to.eql('Editor');
|
||||
expect(ctx.ctrl.items[2].teamId).to.eql(2);
|
||||
expect(ctx.ctrl.items[3].userId).to.eql(2);
|
||||
});
|
||||
|
||||
it('should save permissions to db', done => {
|
||||
ctx.ctrl.update().then(() => {
|
||||
done();
|
||||
});
|
||||
|
||||
expect(backendSrv.post.getCall(0).args[0]).to.eql('/api/dashboards/id/1/acl');
|
||||
expect(backendSrv.post.getCall(0).args[1].items[0].role).to.eql('Viewer');
|
||||
expect(backendSrv.post.getCall(0).args[1].items[0].permission).to.eql(1);
|
||||
expect(backendSrv.post.getCall(0).args[1].items[1].role).to.eql('Editor');
|
||||
expect(backendSrv.post.getCall(0).args[1].items[1].permission).to.eql(1);
|
||||
expect(backendSrv.post.getCall(0).args[1].items[2].teamId).to.eql(2);
|
||||
expect(backendSrv.post.getCall(0).args[1].items[2].permission).to.eql(1);
|
||||
expect(backendSrv.post.getCall(0).args[1].items[3].userId).to.eql(2);
|
||||
expect(backendSrv.post.getCall(0).args[1].items[3].permission).to.eql(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when duplicate role permissions are added', () => {
|
||||
beforeEach(() => {
|
||||
backendSrv.get.reset();
|
||||
backendSrv.post.reset();
|
||||
ctx.ctrl.items = [];
|
||||
|
||||
ctx.ctrl.newType = 'Editor';
|
||||
ctx.ctrl.typeChanged();
|
||||
|
||||
ctx.ctrl.newType = 'Editor';
|
||||
ctx.ctrl.typeChanged();
|
||||
});
|
||||
|
||||
it('should throw a validation error', () => {
|
||||
expect(ctx.ctrl.error).to.eql(ctx.ctrl.duplicateError);
|
||||
});
|
||||
|
||||
it('should not add the duplicate permission', () => {
|
||||
expect(ctx.ctrl.items.length).to.eql(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when duplicate user permissions are added', () => {
|
||||
beforeEach(() => {
|
||||
backendSrv.get.reset();
|
||||
backendSrv.post.reset();
|
||||
ctx.ctrl.items = [];
|
||||
|
||||
const userItem = {
|
||||
id: 2,
|
||||
login: 'user2',
|
||||
};
|
||||
|
||||
ctx.ctrl.userPicked(userItem);
|
||||
ctx.ctrl.userPicked(userItem);
|
||||
});
|
||||
|
||||
it('should throw a validation error', () => {
|
||||
expect(ctx.ctrl.error).to.eql(ctx.ctrl.duplicateError);
|
||||
});
|
||||
|
||||
it('should not add the duplicate permission', () => {
|
||||
expect(ctx.ctrl.items.length).to.eql(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when duplicate team permissions are added', () => {
|
||||
beforeEach(() => {
|
||||
backendSrv.get.reset();
|
||||
backendSrv.post.reset();
|
||||
ctx.ctrl.items = [];
|
||||
|
||||
const teamItem = {
|
||||
id: 2,
|
||||
name: 'ug1',
|
||||
};
|
||||
|
||||
ctx.ctrl.groupPicked(teamItem);
|
||||
ctx.ctrl.groupPicked(teamItem);
|
||||
});
|
||||
|
||||
it('should throw a validation error', () => {
|
||||
expect(ctx.ctrl.error).to.eql(ctx.ctrl.duplicateError);
|
||||
});
|
||||
|
||||
it('should not add the duplicate permission', () => {
|
||||
expect(ctx.ctrl.items.length).to.eql(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when one inherited and one not inherited team permission are added', () => {
|
||||
beforeEach(() => {
|
||||
backendSrv.get.reset();
|
||||
backendSrv.post.reset();
|
||||
ctx.ctrl.items = [];
|
||||
|
||||
const inheritedTeamItem = {
|
||||
id: 2,
|
||||
name: 'ug1',
|
||||
dashboardId: -1,
|
||||
};
|
||||
|
||||
ctx.ctrl.items.push(inheritedTeamItem);
|
||||
|
||||
const teamItem = {
|
||||
id: 2,
|
||||
name: 'ug1',
|
||||
};
|
||||
ctx.ctrl.groupPicked(teamItem);
|
||||
});
|
||||
|
||||
it('should not throw a validation error', () => {
|
||||
expect(ctx.ctrl.error).to.eql('');
|
||||
});
|
||||
|
||||
it('should add both permissions', () => {
|
||||
expect(ctx.ctrl.items.length).to.eql(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -23,7 +23,6 @@ import './repeat_option/repeat_option';
|
||||
import './dashgrid/DashboardGridDirective';
|
||||
import './dashgrid/PanelLoader';
|
||||
import './dashgrid/RowOptions';
|
||||
import './acl/acl';
|
||||
import './folder_picker/folder_picker';
|
||||
import './move_to_folder_modal/move_to_folder';
|
||||
import './settings/settings';
|
||||
@@ -31,14 +30,12 @@ import './settings/settings';
|
||||
import coreModule from 'app/core/core_module';
|
||||
import { DashboardListCtrl } from './dashboard_list_ctrl';
|
||||
import { FolderDashboardsCtrl } from './folder_dashboards_ctrl';
|
||||
import { FolderPermissionsCtrl } from './folder_permissions_ctrl';
|
||||
import { FolderSettingsCtrl } from './folder_settings_ctrl';
|
||||
import { DashboardImportCtrl } from './dashboard_import_ctrl';
|
||||
import { CreateFolderCtrl } from './create_folder_ctrl';
|
||||
|
||||
coreModule.controller('DashboardListCtrl', DashboardListCtrl);
|
||||
coreModule.controller('FolderDashboardsCtrl', FolderDashboardsCtrl);
|
||||
coreModule.controller('FolderPermissionsCtrl', FolderPermissionsCtrl);
|
||||
coreModule.controller('FolderSettingsCtrl', FolderSettingsCtrl);
|
||||
coreModule.controller('DashboardImportCtrl', DashboardImportCtrl);
|
||||
coreModule.controller('CreateFolderCtrl', CreateFolderCtrl);
|
||||
|
||||
@@ -571,6 +571,7 @@ export class DashboardModel {
|
||||
|
||||
if (row.collapsed) {
|
||||
row.collapsed = false;
|
||||
let hasRepeat = false;
|
||||
|
||||
if (row.panels.length > 0) {
|
||||
// Use first panel to figure out if it was moved or pushed
|
||||
@@ -591,6 +592,10 @@ export class DashboardModel {
|
||||
// update insert post and y max
|
||||
insertPos += 1;
|
||||
yMax = Math.max(yMax, panel.gridPos.y + panel.gridPos.h);
|
||||
|
||||
if (panel.repeat) {
|
||||
hasRepeat = true;
|
||||
}
|
||||
}
|
||||
|
||||
const pushDownAmount = yMax - row.gridPos.y;
|
||||
@@ -601,6 +606,10 @@ export class DashboardModel {
|
||||
}
|
||||
|
||||
row.panels = [];
|
||||
|
||||
if (hasRepeat) {
|
||||
this.processRepeats();
|
||||
}
|
||||
}
|
||||
|
||||
// sort panels
|
||||
|
||||
@@ -85,7 +85,7 @@ export class DashboardSrv {
|
||||
|
||||
save(clone, options) {
|
||||
options = options || {};
|
||||
options.folderId = options.folderId || this.dash.meta.folderId;
|
||||
options.folderId = options.folderId || this.dash.meta.folderId || clone.folderId;
|
||||
|
||||
return this.backendSrv
|
||||
.saveDashboard(clone, options)
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import _ from 'lodash';
|
||||
|
||||
export class FolderPageLoader {
|
||||
constructor(private backendSrv) {}
|
||||
|
||||
@@ -11,7 +9,7 @@ export class FolderPageLoader {
|
||||
subTitle: 'Manage folder dashboards & permissions',
|
||||
url: '',
|
||||
text: '',
|
||||
breadcrumbs: [{ title: 'Dashboards', url: 'dashboards' }, { title: ' ' }],
|
||||
breadcrumbs: [{ title: 'Dashboards', url: 'dashboards' }],
|
||||
children: [
|
||||
{
|
||||
active: activeChildId === 'manage-folder-dashboards',
|
||||
@@ -41,25 +39,21 @@ export class FolderPageLoader {
|
||||
return this.backendSrv.getDashboardByUid(uid).then(result => {
|
||||
ctrl.folderId = result.dashboard.id;
|
||||
const folderTitle = result.dashboard.title;
|
||||
ctrl.navModel.main.text = '';
|
||||
ctrl.navModel.main.breadcrumbs = [{ title: 'Dashboards', url: 'dashboards' }, { title: folderTitle }];
|
||||
|
||||
const folderUrl = result.meta.url;
|
||||
ctrl.navModel.main.text = folderTitle;
|
||||
|
||||
const dashTab = _.find(ctrl.navModel.main.children, {
|
||||
id: 'manage-folder-dashboards',
|
||||
});
|
||||
const dashTab = ctrl.navModel.main.children.find(child => child.id === 'manage-folder-dashboards');
|
||||
dashTab.url = folderUrl;
|
||||
|
||||
const permTab = _.find(ctrl.navModel.main.children, {
|
||||
id: 'manage-folder-permissions',
|
||||
});
|
||||
permTab.url = folderUrl + '/permissions';
|
||||
if (result.meta.canAdmin) {
|
||||
const permTab = ctrl.navModel.main.children.find(child => child.id === 'manage-folder-permissions');
|
||||
permTab.url = folderUrl + '/permissions';
|
||||
|
||||
const settingsTab = _.find(ctrl.navModel.main.children, {
|
||||
id: 'manage-folder-settings',
|
||||
});
|
||||
settingsTab.url = folderUrl + '/settings';
|
||||
const settingsTab = ctrl.navModel.main.children.find(child => child.id === 'manage-folder-settings');
|
||||
settingsTab.url = folderUrl + '/settings';
|
||||
} else {
|
||||
ctrl.navModel.main.children = [dashTab];
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
@@ -4,6 +4,8 @@ export class FolderPermissionsCtrl {
|
||||
navModel: any;
|
||||
folderId: number;
|
||||
uid: string;
|
||||
dashboard: any;
|
||||
meta: any;
|
||||
|
||||
/** @ngInject */
|
||||
constructor(private backendSrv, navModelSrv, private $routeParams, $location) {
|
||||
@@ -14,6 +16,9 @@ export class FolderPermissionsCtrl {
|
||||
if ($location.path() !== folder.meta.url) {
|
||||
$location.path(`${folder.meta.url}/permissions`).replace();
|
||||
}
|
||||
|
||||
this.dashboard = folder.dashboard;
|
||||
this.meta = folder.meta;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,12 +30,7 @@ export class FolderPickerCtrl {
|
||||
}
|
||||
|
||||
getOptions(query) {
|
||||
var params = {
|
||||
query: query,
|
||||
type: 'dash-folder',
|
||||
};
|
||||
|
||||
return this.backendSrv.search(params).then(result => {
|
||||
return this.backendSrv.get('api/dashboards/folders', { query: query }).then(result => {
|
||||
if (
|
||||
query === '' ||
|
||||
query.toLowerCase() === 'r' ||
|
||||
@@ -120,6 +115,9 @@ export class FolderPickerCtrl {
|
||||
if (this.initialFolderId && this.initialFolderId > 0) {
|
||||
this.getOptions('').then(result => {
|
||||
this.folder = _.find(result, { value: this.initialFolderId });
|
||||
if (!this.folder) {
|
||||
this.folder = { text: this.initialTitle, value: this.initialFolderId };
|
||||
}
|
||||
this.onFolderLoad();
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<page-header ng-if="ctrl.navModel" model="ctrl.navModel"></page-header>
|
||||
|
||||
<div class="page-container page-body">
|
||||
<manage-dashboards ng-if="ctrl.folderId" folder-id="ctrl.folderId" />
|
||||
</div>
|
||||
<manage-dashboards ng-if="ctrl.folderId && ctrl.folderSlug" folder-id="ctrl.folderId" folder-slug="ctrl.folderSlug" />
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<page-header model="ctrl.navModel"></page-header>
|
||||
|
||||
<div class="page-container page-body">
|
||||
<h2 class="page-sub-heading">
|
||||
Coming soon! Permissions will be added in Grafana 5.0 beta.
|
||||
</h2>
|
||||
<dashboard-permissions ng-if="ctrl.dashboard && ctrl.meta"
|
||||
dashboardId="ctrl.dashboard.id"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -95,6 +95,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-settings__content" ng-if="ctrl.viewId === 'permissions'" >
|
||||
<dashboard-permissions ng-if="ctrl.dashboard"
|
||||
dashboardId="ctrl.dashboard.id"
|
||||
backendSrv="ctrl.backendSrv"
|
||||
folderTitle="ctrl.dashboard.meta.folderTitle"
|
||||
folderSlug="ctrl.dashboard.meta.folderSlug"
|
||||
folderId="ctrl.dashboard.meta.folderId"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-settings__content" ng-if="ctrl.viewId === '404'">
|
||||
<h3 class="dashboard-settings__header">Settings view not found</h3>
|
||||
|
||||
|
||||
@@ -70,6 +70,14 @@ export class SettingsCtrl {
|
||||
});
|
||||
}
|
||||
|
||||
if (this.dashboard.id && this.dashboard.meta.canAdmin) {
|
||||
this.sections.push({
|
||||
title: 'Permissions',
|
||||
id: 'permissions',
|
||||
icon: 'fa fa-fw fa-lock',
|
||||
});
|
||||
}
|
||||
|
||||
if (this.dashboard.meta.canMakeEditable) {
|
||||
this.sections.push({
|
||||
title: 'General',
|
||||
@@ -183,6 +191,7 @@ export class SettingsCtrl {
|
||||
onFolderChange(folder) {
|
||||
this.dashboard.meta.folderId = folder.id;
|
||||
this.dashboard.meta.folderTitle = folder.title;
|
||||
this.dashboard.meta.folderSlug = folder.slug;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,57 @@ import { expect } from 'test/lib/common';
|
||||
|
||||
jest.mock('app/core/services/context_srv', () => ({}));
|
||||
|
||||
describe('given dashboard with panel repeat', function() {
|
||||
var dashboard;
|
||||
|
||||
beforeEach(function() {
|
||||
let dashboardJSON = {
|
||||
panels: [
|
||||
{ id: 1, type: 'row', gridPos: { x: 0, y: 0, h: 1, w: 24 } },
|
||||
{ id: 2, repeat: 'apps', repeatDirection: 'h', gridPos: { x: 0, y: 1, h: 2, w: 8 } },
|
||||
],
|
||||
templating: {
|
||||
list: [
|
||||
{
|
||||
name: 'apps',
|
||||
current: {
|
||||
text: 'se1, se2, se3',
|
||||
value: ['se1', 'se2', 'se3'],
|
||||
},
|
||||
options: [
|
||||
{ text: 'se1', value: 'se1', selected: true },
|
||||
{ text: 'se2', value: 'se2', selected: true },
|
||||
{ text: 'se3', value: 'se3', selected: true },
|
||||
{ text: 'se4', value: 'se4', selected: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
dashboard = new DashboardModel(dashboardJSON);
|
||||
dashboard.processRepeats();
|
||||
});
|
||||
|
||||
it('should repeat panels when row is expanding', function() {
|
||||
expect(dashboard.panels.length).toBe(4);
|
||||
|
||||
// toggle row
|
||||
dashboard.toggleRow(dashboard.panels[0]);
|
||||
expect(dashboard.panels.length).toBe(1);
|
||||
|
||||
// change variable
|
||||
dashboard.templating.list[0].options[2].selected = false;
|
||||
dashboard.templating.list[0].current = {
|
||||
text: 'se1, se2',
|
||||
value: ['se1', 'se2'],
|
||||
};
|
||||
|
||||
// toggle row back
|
||||
dashboard.toggleRow(dashboard.panels[0]);
|
||||
expect(dashboard.panels.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('given dashboard with panel repeat in horizontal direction', function() {
|
||||
var dashboard;
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<div class="grafana-info-box">
|
||||
<h5>What are Dashboard Links?</h5>
|
||||
<p>
|
||||
Dashboad Links allow you to place links to other dashboards and web sites directly in below the dashboard header.
|
||||
Dashboard Links allow you to place links to other dashboards and web sites directly in below the dashboard header.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,11 @@
|
||||
<form name="ctrl.addMemberForm" class="gf-form-group">
|
||||
<div class="gf-form">
|
||||
<span class="gf-form-label width-10">Add member</span>
|
||||
<select-user-picker backendSrv="ctrl.backendSrv" teamId="ctrl.$routeParams.id" refreshList="ctrl.get" teamMembers="ctrl.teamMembers"></select-user-picker>
|
||||
<!--
|
||||
Old picker
|
||||
<user-picker user-picked="ctrl.userPicked($user)"></user-picker>
|
||||
-->
|
||||
<select-user-picker handlePicked="ctrl.userPicked" backendSrv="ctrl.backendSrv"></select-user-picker>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -60,3 +64,4 @@
|
||||
This team has no members yet.
|
||||
</em>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ export default class TeamDetailsCtrl {
|
||||
/** @ngInject **/
|
||||
constructor(private $scope, private backendSrv, private $routeParams, navModelSrv) {
|
||||
this.navModel = navModelSrv.getNav('cfg', 'teams', 0);
|
||||
this.userPicked = this.userPicked.bind(this);
|
||||
this.get = this.get.bind(this);
|
||||
this.get();
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export class PlaylistSearchCtrl {
|
||||
|
||||
$timeout(() => {
|
||||
this.query.query = '';
|
||||
this.query.type = 'dash-db';
|
||||
this.searchDashboards();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import './plugin_page_ctrl';
|
||||
import './plugin_list_ctrl';
|
||||
import './import_list/import_list';
|
||||
import './ds_edit_ctrl';
|
||||
import './ds_dashboards_ctrl';
|
||||
import './ds_list_ctrl';
|
||||
import './datasource_srv';
|
||||
import './plugin_component';
|
||||
|
||||
45
public/app/features/plugins/ds_dashboards_ctrl.ts
Normal file
45
public/app/features/plugins/ds_dashboards_ctrl.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { toJS } from 'mobx';
|
||||
import { coreModule } from 'app/core/core';
|
||||
import { store } from 'app/stores/store';
|
||||
|
||||
export class DataSourceDashboardsCtrl {
|
||||
datasourceMeta: any;
|
||||
navModel: any;
|
||||
current: any;
|
||||
|
||||
/** @ngInject */
|
||||
constructor(private backendSrv, private $routeParams) {
|
||||
if (store.nav.main === null) {
|
||||
store.nav.load('cfg', 'datasources');
|
||||
}
|
||||
|
||||
this.navModel = toJS(store.nav);
|
||||
|
||||
if (this.$routeParams.id) {
|
||||
this.getDatasourceById(this.$routeParams.id);
|
||||
}
|
||||
}
|
||||
|
||||
getDatasourceById(id) {
|
||||
this.backendSrv
|
||||
.get('/api/datasources/' + id)
|
||||
.then(ds => {
|
||||
this.current = ds;
|
||||
})
|
||||
.then(this.getPluginInfo.bind(this));
|
||||
}
|
||||
|
||||
updateNav() {
|
||||
store.nav.initDatasourceEditNav(this.current, this.datasourceMeta, 'datasource-dashboards');
|
||||
this.navModel = toJS(store.nav);
|
||||
}
|
||||
|
||||
getPluginInfo() {
|
||||
return this.backendSrv.get('/api/plugins/' + this.current.type + '/settings').then(pluginInfo => {
|
||||
this.datasourceMeta = pluginInfo;
|
||||
this.updateNav();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
coreModule.controller('DataSourceDashboardsCtrl', DataSourceDashboardsCtrl);
|
||||
@@ -1,7 +1,8 @@
|
||||
import _ from 'lodash';
|
||||
|
||||
import { toJS } from 'mobx';
|
||||
import config from 'app/core/config';
|
||||
import { coreModule, appEvents } from 'app/core/core';
|
||||
import { store } from 'app/stores/store';
|
||||
|
||||
var datasourceTypes = [];
|
||||
|
||||
@@ -23,24 +24,18 @@ export class DataSourceEditCtrl {
|
||||
types: any;
|
||||
testing: any;
|
||||
datasourceMeta: any;
|
||||
tabIndex: number;
|
||||
hasDashboards: boolean;
|
||||
editForm: any;
|
||||
gettingStarted: boolean;
|
||||
navModel: any;
|
||||
|
||||
/** @ngInject */
|
||||
constructor(
|
||||
private $q,
|
||||
private backendSrv,
|
||||
private $routeParams,
|
||||
private $location,
|
||||
private datasourceSrv,
|
||||
navModelSrv
|
||||
) {
|
||||
this.navModel = navModelSrv.getNav('cfg', 'datasources', 0);
|
||||
constructor(private $q, private backendSrv, private $routeParams, private $location, private datasourceSrv) {
|
||||
if (store.nav.main === null) {
|
||||
store.nav.load('cfg', 'datasources');
|
||||
}
|
||||
|
||||
this.navModel = toJS(store.nav);
|
||||
this.datasources = [];
|
||||
this.tabIndex = 0;
|
||||
|
||||
this.loadDatasourceTypes().then(() => {
|
||||
if (this.$routeParams.id) {
|
||||
@@ -55,8 +50,6 @@ export class DataSourceEditCtrl {
|
||||
this.isNew = true;
|
||||
this.current = _.cloneDeep(defaults);
|
||||
|
||||
this.navModel.breadcrumbs.push({ text: 'New' });
|
||||
|
||||
// We are coming from getting started
|
||||
if (this.$location.search().gettingstarted) {
|
||||
this.gettingStarted = true;
|
||||
@@ -82,12 +75,6 @@ export class DataSourceEditCtrl {
|
||||
this.backendSrv.get('/api/datasources/' + id).then(ds => {
|
||||
this.isNew = false;
|
||||
this.current = ds;
|
||||
this.navModel.node = {
|
||||
text: ds.name,
|
||||
icon: 'icon-gf icon-gf-fw icon-gf-datasources',
|
||||
id: 'ds-new',
|
||||
};
|
||||
this.navModel.breadcrumbs.push(this.navModel.node);
|
||||
|
||||
if (datasourceCreated) {
|
||||
datasourceCreated = false;
|
||||
@@ -112,11 +99,15 @@ export class DataSourceEditCtrl {
|
||||
this.typeChanged();
|
||||
}
|
||||
|
||||
updateNav() {
|
||||
store.nav.initDatasourceEditNav(this.current, this.datasourceMeta, 'datasource-settings');
|
||||
this.navModel = toJS(store.nav);
|
||||
}
|
||||
|
||||
typeChanged() {
|
||||
this.hasDashboards = false;
|
||||
return this.backendSrv.get('/api/plugins/' + this.current.type + '/settings').then(pluginInfo => {
|
||||
this.datasourceMeta = pluginInfo;
|
||||
this.hasDashboards = _.find(pluginInfo.includes, { type: 'dashboard' }) !== undefined;
|
||||
this.updateNav();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -171,6 +162,7 @@ export class DataSourceEditCtrl {
|
||||
if (this.current.id) {
|
||||
return this.backendSrv.put('/api/datasources/' + this.current.id, this.current).then(result => {
|
||||
this.current = result.datasource;
|
||||
this.updateNav();
|
||||
this.updateFrontendSettings().then(() => {
|
||||
this.testDatasource();
|
||||
});
|
||||
|
||||
7
public/app/features/plugins/partials/ds_dashboards.html
Normal file
7
public/app/features/plugins/partials/ds_dashboards.html
Normal file
@@ -0,0 +1,7 @@
|
||||
<page-header model="ctrl.navModel"></page-header>
|
||||
|
||||
<div class="page-container page-body" ng-if="ctrl.datasourceMeta">
|
||||
|
||||
<dashboard-import-list plugin="ctrl.datasourceMeta" datasource="ctrl.current"></dashboard-import-list>
|
||||
|
||||
</div>
|
||||
@@ -8,9 +8,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="page-sub-heading" ng-hide="ctrl.isNew">Edit Data Source</h3>
|
||||
<h3 class="page-sub-heading" ng-show="ctrl.isNew">New Data Source</h3>
|
||||
|
||||
<form name="ctrl.editForm" ng-if="ctrl.current">
|
||||
<div class="gf-form-group">
|
||||
<div class="gf-form-inline">
|
||||
|
||||
@@ -32,8 +32,8 @@ export class PluginEditCtrl {
|
||||
img: model.info.logos.large,
|
||||
subTitle: model.info.author.name,
|
||||
url: '',
|
||||
text: '',
|
||||
breadcrumbs: [{ title: 'Plugins', url: 'plugins' }, { title: model.name }],
|
||||
text: model.name,
|
||||
breadcrumbs: [{ title: 'Plugins', url: 'plugins' }],
|
||||
children: [
|
||||
{
|
||||
icon: 'fa fa-fw fa-file-text-o',
|
||||
|
||||
@@ -40,8 +40,8 @@ export class AppPageCtrl {
|
||||
img: app.info.logos.large,
|
||||
subTitle: app.name,
|
||||
url: '',
|
||||
text: '',
|
||||
breadcrumbs: [{ title: app.name, url: pluginNav.main.url }, { title: this.page.name }],
|
||||
text: this.page.name,
|
||||
breadcrumbs: [{ title: app.name, url: pluginNav.main.url }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div class="gf-form-inline">
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label query-keyword width-7">Metric</label>
|
||||
<label class="gf-form-label query-keyword width-8">Metric</label>
|
||||
|
||||
<metric-segment segment="regionSegment" get-options="getRegions()" on-change="regionChanged()"></metric-segment>
|
||||
<metric-segment segment="namespaceSegment" get-options="getNamespaces()" on-change="namespaceChanged()"></metric-segment>
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
<div class="gf-form-inline">
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label query-keyword width-7">Dimensions</label>
|
||||
<label class="gf-form-label query-keyword width-8">Dimensions</label>
|
||||
<metric-segment ng-repeat="segment in dimSegments" segment="segment" get-options="getDimSegments(segment, $index)" on-change="dimSegmentChanged(segment, $index)"></metric-segment>
|
||||
</div>
|
||||
|
||||
@@ -33,9 +33,9 @@
|
||||
|
||||
<div class="gf-form-inline">
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label query-keyword width-7">
|
||||
Period
|
||||
<info-popover mode="right-normal">Interval between points in seconds</info-popover>
|
||||
<label class="gf-form-label query-keyword width-8">
|
||||
Min period
|
||||
<info-popover mode="right-normal">Minimum interval between points in seconds</info-popover>
|
||||
</label>
|
||||
<input type="text" class="gf-form-input" ng-model="target.period" spellcheck='false' placeholder="auto" ng-model-onblur ng-change="onChange()" />
|
||||
</div>
|
||||
|
||||
@@ -654,7 +654,7 @@ function graphDirective(timeSrv, popoverSrv, contextSrv) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((ranges.ctrlKey || ranges.metaKey) && contextSrv.isEditor) {
|
||||
if ((ranges.ctrlKey || ranges.metaKey) && dashboard.meta.canEdit) {
|
||||
// Add annotation
|
||||
setTimeout(() => {
|
||||
eventManager.updateTime(ranges.xaxis);
|
||||
@@ -675,7 +675,7 @@ function graphDirective(timeSrv, popoverSrv, contextSrv) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((pos.ctrlKey || pos.metaKey) && contextSrv.isEditor) {
|
||||
if ((pos.ctrlKey || pos.metaKey) && dashboard.meta.canEdit) {
|
||||
// Skip if range selected (added in "plotselected" event handler)
|
||||
let isRangeSelection = pos.x !== pos.x1;
|
||||
if (!isRangeSelection) {
|
||||
|
||||
@@ -198,9 +198,9 @@ class SingleStatCtrl extends MetricsPanelCtrl {
|
||||
this.setValueMapping(data);
|
||||
}
|
||||
|
||||
canChangeFontSize() {
|
||||
return this.panel.gauge.show;
|
||||
}
|
||||
canChangeFontSize() {
|
||||
return this.panel.gauge.show;
|
||||
}
|
||||
|
||||
setColoring(options) {
|
||||
if (options.background) {
|
||||
|
||||
@@ -13,13 +13,15 @@ function WrapInProvider(store, Component, props) {
|
||||
}
|
||||
|
||||
/** @ngInject */
|
||||
export function reactContainer($route, $location) {
|
||||
export function reactContainer($route, $location, backendSrv) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
template: '',
|
||||
link(scope, elem) {
|
||||
let component = $route.current.locals.component;
|
||||
let props = {};
|
||||
let props = {
|
||||
backendSrv: backendSrv,
|
||||
};
|
||||
|
||||
ReactDOM.render(WrapInProvider(store, component, props), elem[0]);
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import './dashboard_loaders';
|
||||
import './ReactContainer';
|
||||
import { ServerStats } from 'app/containers/ServerStats/ServerStats';
|
||||
import { AlertRuleList } from 'app/containers/AlertRuleList/AlertRuleList';
|
||||
import { FolderSettings } from 'app/containers/ManageDashboards/FolderSettings';
|
||||
import { FolderPermissions } from 'app/containers/ManageDashboards/FolderPermissions';
|
||||
|
||||
/** @ngInject **/
|
||||
export function setupAngularRoutes($routeProvider, $locationProvider) {
|
||||
@@ -59,6 +61,11 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
|
||||
controller: 'DataSourceEditCtrl',
|
||||
controllerAs: 'ctrl',
|
||||
})
|
||||
.when('/datasources/edit/:id/dashboards', {
|
||||
templateUrl: 'public/app/features/plugins/partials/ds_dashboards.html',
|
||||
controller: 'DataSourceDashboardsCtrl',
|
||||
controllerAs: 'ctrl',
|
||||
})
|
||||
.when('/datasources/new', {
|
||||
templateUrl: 'public/app/features/plugins/partials/ds_edit.html',
|
||||
controller: 'DataSourceEditCtrl',
|
||||
@@ -75,14 +82,16 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
|
||||
controllerAs: 'ctrl',
|
||||
})
|
||||
.when('/dashboards/f/:uid/:slug/permissions', {
|
||||
templateUrl: 'public/app/features/dashboard/partials/folder_permissions.html',
|
||||
controller: 'FolderPermissionsCtrl',
|
||||
controllerAs: 'ctrl',
|
||||
template: '<react-container />',
|
||||
resolve: {
|
||||
component: () => FolderPermissions,
|
||||
},
|
||||
})
|
||||
.when('/dashboards/f/:uid/:slug/settings', {
|
||||
templateUrl: 'public/app/features/dashboard/partials/folder_settings.html',
|
||||
controller: 'FolderSettingsCtrl',
|
||||
controllerAs: 'ctrl',
|
||||
template: '<react-container />',
|
||||
resolve: {
|
||||
component: () => FolderSettings,
|
||||
},
|
||||
})
|
||||
.when('/dashboards/f/:uid/:slug', {
|
||||
templateUrl: 'public/app/features/dashboard/partials/folder_dashboards.html',
|
||||
|
||||
@@ -20,6 +20,7 @@ function getRule(name, state, info) {
|
||||
stateClass: 'asd',
|
||||
stateAge: '10m',
|
||||
info: info,
|
||||
canEdit: true,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ export const AlertRule = types
|
||||
stateAge: types.string,
|
||||
info: types.optional(types.string, ''),
|
||||
dashboardUri: types.string,
|
||||
canEdit: types.boolean,
|
||||
})
|
||||
.views(self => ({
|
||||
get isPaused() {
|
||||
|
||||
48
public/app/stores/FolderStore/FolderStore.ts
Normal file
48
public/app/stores/FolderStore/FolderStore.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { types, getEnv, flow } from 'mobx-state-tree';
|
||||
|
||||
export const Folder = types.model('Folder', {
|
||||
id: types.identifier(types.number),
|
||||
slug: types.string,
|
||||
title: types.string,
|
||||
canSave: types.boolean,
|
||||
hasChanged: types.boolean,
|
||||
});
|
||||
|
||||
export const FolderStore = types
|
||||
.model('FolderStore', {
|
||||
folder: types.maybe(Folder),
|
||||
})
|
||||
.actions(self => ({
|
||||
load: flow(function* load(slug: string) {
|
||||
const backendSrv = getEnv(self).backendSrv;
|
||||
const res = yield backendSrv.getDashboard('db', slug);
|
||||
self.folder = Folder.create({
|
||||
id: res.dashboard.id,
|
||||
title: res.dashboard.title,
|
||||
slug: res.meta.slug,
|
||||
canSave: res.meta.canSave,
|
||||
hasChanged: false,
|
||||
});
|
||||
return res;
|
||||
}),
|
||||
|
||||
setTitle: function(originalTitle: string, title: string) {
|
||||
self.folder.title = title;
|
||||
self.folder.hasChanged = originalTitle.toLowerCase() !== title.trim().toLowerCase() && title.trim().length > 0;
|
||||
},
|
||||
|
||||
saveFolder: flow(function* saveFolder(dashboard: any, options: any) {
|
||||
const backendSrv = getEnv(self).backendSrv;
|
||||
dashboard.title = self.folder.title.trim();
|
||||
|
||||
const res = yield backendSrv.saveDashboard(dashboard, options);
|
||||
self.folder.slug = res.slug;
|
||||
return `dashboards/folder/${self.folder.id}/${res.slug}/settings`;
|
||||
}),
|
||||
|
||||
deleteFolder: flow(function* deleteFolder() {
|
||||
const backendSrv = getEnv(self).backendSrv;
|
||||
|
||||
return backendSrv.deleteDashboard(self.folder.slug);
|
||||
}),
|
||||
}));
|
||||
@@ -8,5 +8,11 @@ export const NavItem = types.model('NavItem', {
|
||||
icon: types.optional(types.string, ''),
|
||||
img: types.optional(types.string, ''),
|
||||
active: types.optional(types.boolean, false),
|
||||
breadcrumbs: types.optional(types.array(types.late(() => Breadcrumb)), []),
|
||||
children: types.optional(types.array(types.late(() => NavItem)), []),
|
||||
});
|
||||
|
||||
export const Breadcrumb = types.model('Breadcrumb', {
|
||||
title: types.string,
|
||||
url: types.string,
|
||||
});
|
||||
|
||||
47
public/app/stores/NavStore/NavStore.jest.ts
Normal file
47
public/app/stores/NavStore/NavStore.jest.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { NavStore } from './NavStore';
|
||||
|
||||
describe('NavStore', () => {
|
||||
const folderId = 1;
|
||||
const folderTitle = 'Folder Name';
|
||||
const folderSlug = 'folder-name';
|
||||
const canAdmin = true;
|
||||
|
||||
const folder = {
|
||||
id: folderId,
|
||||
slug: folderSlug,
|
||||
title: folderTitle,
|
||||
canAdmin: canAdmin,
|
||||
};
|
||||
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
store = NavStore.create();
|
||||
store.initFolderNav(folder, 'manage-folder-settings');
|
||||
});
|
||||
|
||||
it('Should set text', () => {
|
||||
expect(store.main.text).toBe(folderTitle);
|
||||
});
|
||||
|
||||
it('Should load nav with tabs', () => {
|
||||
expect(store.main.children.length).toBe(3);
|
||||
expect(store.main.children[0].id).toBe('manage-folder-dashboards');
|
||||
expect(store.main.children[1].id).toBe('manage-folder-permissions');
|
||||
expect(store.main.children[2].id).toBe('manage-folder-settings');
|
||||
});
|
||||
|
||||
it('Should set correct urls for each tab', () => {
|
||||
expect(store.main.children.length).toBe(3);
|
||||
expect(store.main.children[0].url).toBe(`dashboards/folder/${folderId}/${folderSlug}`);
|
||||
expect(store.main.children[1].url).toBe(`dashboards/folder/${folderId}/${folderSlug}/permissions`);
|
||||
expect(store.main.children[2].url).toBe(`dashboards/folder/${folderId}/${folderSlug}/settings`);
|
||||
});
|
||||
|
||||
it('Should set active tab', () => {
|
||||
expect(store.main.children.length).toBe(3);
|
||||
expect(store.main.children[0].active).toBe(false);
|
||||
expect(store.main.children[1].active).toBe(false);
|
||||
expect(store.main.children[2].active).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import _ from 'lodash';
|
||||
import { types, getEnv } from 'mobx-state-tree';
|
||||
import { NavItem } from './NavItem';
|
||||
|
||||
@@ -38,4 +39,86 @@ export const NavStore = types
|
||||
self.main = NavItem.create(main);
|
||||
self.node = NavItem.create(node);
|
||||
},
|
||||
|
||||
initFolderNav(folder: any, activeChildId: string) {
|
||||
const folderUrl = createFolderUrl(folder.id, folder.slug);
|
||||
|
||||
let main = {
|
||||
icon: 'fa fa-folder-open',
|
||||
id: 'manage-folder',
|
||||
subTitle: 'Manage folder dashboards & permissions',
|
||||
url: '',
|
||||
text: folder.title,
|
||||
breadcrumbs: [{ title: 'Dashboards', url: 'dashboards' }],
|
||||
children: [
|
||||
{
|
||||
active: activeChildId === 'manage-folder-dashboards',
|
||||
icon: 'fa fa-fw fa-th-large',
|
||||
id: 'manage-folder-dashboards',
|
||||
text: 'Dashboards',
|
||||
url: folderUrl,
|
||||
},
|
||||
{
|
||||
active: activeChildId === 'manage-folder-permissions',
|
||||
icon: 'fa fa-fw fa-lock',
|
||||
id: 'manage-folder-permissions',
|
||||
text: 'Permissions',
|
||||
url: folderUrl + '/permissions',
|
||||
},
|
||||
{
|
||||
active: activeChildId === 'manage-folder-settings',
|
||||
icon: 'fa fa-fw fa-cog',
|
||||
id: 'manage-folder-settings',
|
||||
text: 'Settings',
|
||||
url: folderUrl + '/settings',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
self.main = NavItem.create(main);
|
||||
},
|
||||
|
||||
initDatasourceEditNav(ds: any, plugin: any, currentPage: string) {
|
||||
let title = 'New';
|
||||
let subTitle = `Type: ${plugin.name}`;
|
||||
|
||||
if (ds.id) {
|
||||
title = ds.name;
|
||||
}
|
||||
|
||||
let main = {
|
||||
img: plugin.info.logos.large,
|
||||
id: 'ds-edit-' + plugin.id,
|
||||
subTitle: subTitle,
|
||||
url: '',
|
||||
text: title,
|
||||
breadcrumbs: [{ title: 'Data Sources', url: 'datasources' }],
|
||||
children: [
|
||||
{
|
||||
active: currentPage === 'datasource-settings',
|
||||
icon: 'fa fa-fw fa-sliders',
|
||||
id: 'datasource-settings',
|
||||
text: 'Settings',
|
||||
url: `datasources/edit/${ds.id}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const hasDashboards = _.find(plugin.includes, { type: 'dashboard' }) !== undefined;
|
||||
if (hasDashboards && ds.id) {
|
||||
main.children.push({
|
||||
active: currentPage === 'datasource-dashboards',
|
||||
icon: 'fa fa-fw fa-th-large',
|
||||
id: 'datasource-dashboards',
|
||||
text: 'Dashboards',
|
||||
url: `datasources/edit/${ds.id}/dashboards`,
|
||||
});
|
||||
}
|
||||
|
||||
self.main = NavItem.create(main);
|
||||
},
|
||||
}));
|
||||
|
||||
function createFolderUrl(folderId: number, slug: string) {
|
||||
return `dashboards/folder/${folderId}/${slug}`;
|
||||
}
|
||||
|
||||
151
public/app/stores/PermissionsStore/PermissionsStore.jest.ts
Normal file
151
public/app/stores/PermissionsStore/PermissionsStore.jest.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { PermissionsStore } from './PermissionsStore';
|
||||
import { backendSrv } from 'test/mocks/common';
|
||||
|
||||
describe('PermissionsStore', () => {
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
backendSrv.get.mockReturnValue(
|
||||
Promise.resolve([
|
||||
{ id: 2, dashboardId: 1, role: 'Viewer', permission: 1, permissionName: 'View' },
|
||||
{ id: 3, dashboardId: 1, role: 'Editor', permission: 1, permissionName: 'Edit' },
|
||||
{
|
||||
id: 4,
|
||||
dashboardId: 10,
|
||||
permission: 1,
|
||||
permissionName: 'View',
|
||||
teamId: 1,
|
||||
teamName: 'MyTestTeam',
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
backendSrv.post = jest.fn();
|
||||
|
||||
store = PermissionsStore.create(
|
||||
{
|
||||
fetching: false,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
backendSrv: backendSrv,
|
||||
}
|
||||
);
|
||||
|
||||
return store.load(1, false, false);
|
||||
});
|
||||
|
||||
it('should save update on permission change', () => {
|
||||
expect(store.items[0].permission).toBe(1);
|
||||
expect(store.items[0].permissionName).toBe('View');
|
||||
|
||||
store.updatePermissionOnIndex(0, 2, 'Edit');
|
||||
|
||||
expect(store.items[0].permission).toBe(2);
|
||||
expect(store.items[0].permissionName).toBe('Edit');
|
||||
expect(backendSrv.post.mock.calls.length).toBe(1);
|
||||
expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl');
|
||||
});
|
||||
|
||||
it('should save newly added permissions automatically', () => {
|
||||
expect(store.items.length).toBe(3);
|
||||
|
||||
const newItem = {
|
||||
userId: 10,
|
||||
userLogin: 'tester1',
|
||||
permission: 1,
|
||||
};
|
||||
store.addStoreItem(newItem);
|
||||
|
||||
expect(store.items.length).toBe(4);
|
||||
expect(backendSrv.post.mock.calls.length).toBe(1);
|
||||
expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl');
|
||||
});
|
||||
|
||||
it('should save removed permissions automatically', () => {
|
||||
expect(store.items.length).toBe(3);
|
||||
|
||||
store.removeStoreItem(2);
|
||||
|
||||
expect(store.items.length).toBe(2);
|
||||
expect(backendSrv.post.mock.calls.length).toBe(1);
|
||||
expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl');
|
||||
});
|
||||
|
||||
describe('when duplicate user permissions are added', () => {
|
||||
beforeEach(() => {
|
||||
const newItem = {
|
||||
userId: 10,
|
||||
userLogin: 'tester1',
|
||||
permission: 1,
|
||||
dashboardId: 1,
|
||||
};
|
||||
store.addStoreItem(newItem);
|
||||
store.addStoreItem(newItem);
|
||||
});
|
||||
|
||||
it('should return a validation error', () => {
|
||||
expect(store.items.length).toBe(4);
|
||||
expect(store.error).toBe('This permission exists already.');
|
||||
expect(backendSrv.post.mock.calls.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when duplicate team permissions are added', () => {
|
||||
beforeEach(() => {
|
||||
const newItem = {
|
||||
teamId: 1,
|
||||
teamName: 'testerteam',
|
||||
permission: 1,
|
||||
dashboardId: 1,
|
||||
};
|
||||
store.addStoreItem(newItem);
|
||||
store.addStoreItem(newItem);
|
||||
});
|
||||
|
||||
it('should return a validation error', () => {
|
||||
expect(store.items.length).toBe(4);
|
||||
expect(store.error).toBe('This permission exists already.');
|
||||
expect(backendSrv.post.mock.calls.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when duplicate role permissions are added', () => {
|
||||
beforeEach(() => {
|
||||
const newItem = {
|
||||
team: 'MyTestTeam',
|
||||
teamId: 1,
|
||||
permission: 1,
|
||||
dashboardId: 1,
|
||||
};
|
||||
store.addStoreItem(newItem);
|
||||
store.addStoreItem(newItem);
|
||||
});
|
||||
|
||||
it('should return a validation error', () => {
|
||||
expect(store.items.length).toBe(4);
|
||||
expect(store.error).toBe('This permission exists already.');
|
||||
expect(backendSrv.post.mock.calls.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when one inherited and one not inherited team permission are added', () => {
|
||||
beforeEach(() => {
|
||||
const teamItem = {
|
||||
team: 'MyTestTeam',
|
||||
dashboardId: 1,
|
||||
teamId: 1,
|
||||
permission: 2,
|
||||
};
|
||||
store.addStoreItem(teamItem);
|
||||
});
|
||||
|
||||
it('should not throw a validation error', () => {
|
||||
expect(store.error).toBe(null);
|
||||
});
|
||||
|
||||
it('should add both permissions', () => {
|
||||
expect(store.items.length).toBe(4);
|
||||
});
|
||||
});
|
||||
});
|
||||
171
public/app/stores/PermissionsStore/PermissionsStore.ts
Normal file
171
public/app/stores/PermissionsStore/PermissionsStore.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { types, getEnv, flow } from 'mobx-state-tree';
|
||||
import { PermissionsStoreItem } from './PermissionsStoreItem';
|
||||
|
||||
const duplicateError = 'This permission exists already.';
|
||||
|
||||
export const permissionOptions = [
|
||||
{ value: 1, label: 'View', description: 'Can view dashboards.' },
|
||||
{ value: 2, label: 'Edit', description: 'Can add, edit and delete dashboards.' },
|
||||
{
|
||||
value: 4,
|
||||
label: 'Admin',
|
||||
description: 'Can add/remove permissions and can add, edit and delete dashboards.',
|
||||
},
|
||||
];
|
||||
|
||||
export const aclTypes = [
|
||||
{ value: 'Group', text: 'Team' },
|
||||
{ value: 'User', text: 'User' },
|
||||
{ value: 'Viewer', text: 'Everyone With Viewer Role' },
|
||||
{ value: 'Editor', text: 'Everyone With Editor Role' },
|
||||
];
|
||||
|
||||
const defaultNewType = aclTypes[0].value;
|
||||
|
||||
export const PermissionsStore = types
|
||||
.model('PermissionsStore', {
|
||||
fetching: types.boolean,
|
||||
isFolder: types.maybe(types.boolean),
|
||||
dashboardId: types.maybe(types.number),
|
||||
items: types.optional(types.array(PermissionsStoreItem), []),
|
||||
error: types.maybe(types.string),
|
||||
originalItems: types.optional(types.array(PermissionsStoreItem), []),
|
||||
newType: types.optional(types.string, defaultNewType),
|
||||
isInRoot: types.maybe(types.boolean),
|
||||
})
|
||||
.views(self => ({
|
||||
isValid: item => {
|
||||
const dupe = self.items.find(it => {
|
||||
return isDuplicate(it, item);
|
||||
});
|
||||
|
||||
if (dupe) {
|
||||
self.error = duplicateError;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
}))
|
||||
.actions(self => ({
|
||||
load: flow(function* load(dashboardId: number, isFolder: boolean, isInRoot: boolean) {
|
||||
const backendSrv = getEnv(self).backendSrv;
|
||||
self.fetching = true;
|
||||
self.isFolder = isFolder;
|
||||
self.isInRoot = isInRoot;
|
||||
self.dashboardId = dashboardId;
|
||||
const res = yield backendSrv.get(`/api/dashboards/id/${dashboardId}/acl`);
|
||||
const items = prepareServerResponse(res, dashboardId, isFolder, isInRoot);
|
||||
self.items = items;
|
||||
self.originalItems = items;
|
||||
self.fetching = false;
|
||||
self.error = null;
|
||||
}),
|
||||
addStoreItem: flow(function* addStoreItem(item) {
|
||||
self.error = null;
|
||||
if (!self.isValid(item)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
self.items.push(prepareItem(item, self.dashboardId, self.isFolder, self.isInRoot));
|
||||
return updateItems(self);
|
||||
}),
|
||||
removeStoreItem: flow(function* removeStoreItem(idx: number) {
|
||||
self.error = null;
|
||||
self.items.splice(idx, 1);
|
||||
return updateItems(self);
|
||||
}),
|
||||
updatePermissionOnIndex: flow(function* updatePermissionOnIndex(
|
||||
idx: number,
|
||||
permission: number,
|
||||
permissionName: string
|
||||
) {
|
||||
self.error = null;
|
||||
self.items[idx].updatePermission(permission, permissionName);
|
||||
return updateItems(self);
|
||||
}),
|
||||
setNewType(newType: string) {
|
||||
self.newType = newType;
|
||||
},
|
||||
resetNewType() {
|
||||
self.newType = defaultNewType;
|
||||
},
|
||||
}));
|
||||
|
||||
const updateItems = self => {
|
||||
self.error = null;
|
||||
|
||||
const backendSrv = getEnv(self).backendSrv;
|
||||
const updated = [];
|
||||
for (let item of self.items) {
|
||||
if (item.inherited) {
|
||||
continue;
|
||||
}
|
||||
updated.push({
|
||||
id: item.id,
|
||||
userId: item.userId,
|
||||
teamId: item.teamId,
|
||||
role: item.role,
|
||||
permission: item.permission,
|
||||
});
|
||||
}
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = backendSrv.post(`/api/dashboards/id/${self.dashboardId}/acl`, {
|
||||
items: updated,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
const prepareServerResponse = (response, dashboardId: number, isFolder: boolean, isInRoot: boolean) => {
|
||||
return response.map(item => {
|
||||
return prepareItem(item, dashboardId, isFolder, isInRoot);
|
||||
});
|
||||
};
|
||||
|
||||
const prepareItem = (item, dashboardId: number, isFolder: boolean, isInRoot: boolean) => {
|
||||
item.inherited = !isFolder && !isInRoot && dashboardId !== item.dashboardId;
|
||||
|
||||
item.sortRank = 0;
|
||||
if (item.userId > 0) {
|
||||
item.icon = 'fa fa-fw fa-user';
|
||||
item.nameHtml = item.userLogin;
|
||||
item.sortName = item.userLogin;
|
||||
item.sortRank = 10;
|
||||
} else if (item.teamId > 0) {
|
||||
item.icon = 'fa fa-fw fa-users';
|
||||
item.nameHtml = item.team;
|
||||
item.sortName = item.team;
|
||||
item.sortRank = 20;
|
||||
} else if (item.role) {
|
||||
item.icon = 'fa fa-fw fa-street-view';
|
||||
item.nameHtml = `Everyone with <span class="query-keyword">${item.role}</span> Role`;
|
||||
item.sortName = item.role;
|
||||
item.sortRank = 30;
|
||||
if (item.role === 'Viewer') {
|
||||
item.sortRank += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (item.inherited) {
|
||||
item.sortRank += 100;
|
||||
}
|
||||
return item;
|
||||
};
|
||||
|
||||
const isDuplicate = (origItem, newItem) => {
|
||||
if (origItem.inherited) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
(origItem.role && newItem.role && origItem.role === newItem.role) ||
|
||||
(origItem.userId && newItem.userId && origItem.userId === newItem.userId) ||
|
||||
(origItem.teamId && newItem.teamId && origItem.teamId === newItem.teamId)
|
||||
);
|
||||
};
|
||||
29
public/app/stores/PermissionsStore/PermissionsStoreItem.ts
Normal file
29
public/app/stores/PermissionsStore/PermissionsStoreItem.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { types } from 'mobx-state-tree';
|
||||
|
||||
export const PermissionsStoreItem = types
|
||||
.model('PermissionsStoreItem', {
|
||||
dashboardId: types.optional(types.number, -1),
|
||||
id: types.maybe(types.number),
|
||||
permission: types.number,
|
||||
permissionName: types.maybe(types.string),
|
||||
role: types.maybe(types.string),
|
||||
team: types.optional(types.string, ''),
|
||||
teamId: types.optional(types.number, 0),
|
||||
userEmail: types.optional(types.string, ''),
|
||||
userId: types.optional(types.number, 0),
|
||||
userLogin: types.optional(types.string, ''),
|
||||
inherited: types.maybe(types.boolean),
|
||||
sortRank: types.maybe(types.number),
|
||||
icon: types.maybe(types.string),
|
||||
nameHtml: types.maybe(types.string),
|
||||
sortName: types.maybe(types.string),
|
||||
})
|
||||
.actions(self => ({
|
||||
updateRole: role => {
|
||||
self.role = role;
|
||||
},
|
||||
updatePermission(permission: number, permissionName: string) {
|
||||
self.permission = permission;
|
||||
self.permissionName = permissionName;
|
||||
},
|
||||
}));
|
||||
@@ -4,6 +4,8 @@ import { ServerStatsStore } from './../ServerStatsStore/ServerStatsStore';
|
||||
import { NavStore } from './../NavStore/NavStore';
|
||||
import { AlertListStore } from './../AlertListStore/AlertListStore';
|
||||
import { ViewStore } from './../ViewStore/ViewStore';
|
||||
import { FolderStore } from './../FolderStore/FolderStore';
|
||||
import { PermissionsStore } from './../PermissionsStore/PermissionsStore';
|
||||
|
||||
export const RootStore = types.model({
|
||||
search: types.optional(SearchStore, {
|
||||
@@ -16,10 +18,16 @@ export const RootStore = types.model({
|
||||
alertList: types.optional(AlertListStore, {
|
||||
rules: [],
|
||||
}),
|
||||
permissions: types.optional(PermissionsStore, {
|
||||
fetching: false,
|
||||
items: [],
|
||||
}),
|
||||
view: types.optional(ViewStore, {
|
||||
path: '',
|
||||
query: {},
|
||||
routeParams: {},
|
||||
}),
|
||||
folder: types.optional(FolderStore, {}),
|
||||
});
|
||||
|
||||
type IRootStoreType = typeof RootStore.Type;
|
||||
|
||||
@@ -8,19 +8,26 @@ describe('ViewStore', () => {
|
||||
store = ViewStore.create({
|
||||
path: '',
|
||||
query: {},
|
||||
routeParams: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('Can update path and query', () => {
|
||||
store.updatePathAndQuery('/hello', { key: 1, otherParam: 'asd' });
|
||||
store.updatePathAndQuery('/hello', { key: 1, otherParam: 'asd' }, { key: 1, otherParam: 'asd' });
|
||||
expect(store.path).toBe('/hello');
|
||||
expect(store.query.get('key')).toBe(1);
|
||||
expect(store.currentUrl).toBe('/hello?key=1&otherParam=asd');
|
||||
});
|
||||
|
||||
it('Query can contain arrays', () => {
|
||||
store.updatePathAndQuery('/hello', { values: ['A', 'B'] });
|
||||
store.updatePathAndQuery('/hello', { values: ['A', 'B'] }, { key: 1, otherParam: 'asd' });
|
||||
expect(toJS(store.query.get('values'))).toMatchObject(['A', 'B']);
|
||||
expect(store.currentUrl).toBe('/hello?values=A&values=B');
|
||||
});
|
||||
|
||||
it('Query can contain boolean', () => {
|
||||
store.updatePathAndQuery('/hello', { abool: true }, { abool: true });
|
||||
expect(toJS(store.query.get('abool'))).toBe(true);
|
||||
expect(store.currentUrl).toBe('/hello?abool');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ export const ViewStore = types
|
||||
.model({
|
||||
path: types.string,
|
||||
query: types.map(QueryValueType),
|
||||
routeParams: types.map(QueryValueType),
|
||||
})
|
||||
.views(self => ({
|
||||
get currentUrl() {
|
||||
@@ -21,6 +22,7 @@ export const ViewStore = types
|
||||
},
|
||||
}))
|
||||
.actions(self => {
|
||||
// querystring only
|
||||
function updateQuery(query: any) {
|
||||
self.query.clear();
|
||||
for (let key of Object.keys(query)) {
|
||||
@@ -28,9 +30,18 @@ export const ViewStore = types
|
||||
}
|
||||
}
|
||||
|
||||
function updatePathAndQuery(path: string, query: any) {
|
||||
// needed to get route parameters like slug from the url
|
||||
function updateRouteParams(routeParams: any) {
|
||||
self.routeParams.clear();
|
||||
for (let key of Object.keys(routeParams)) {
|
||||
self.routeParams.set(key, routeParams[key]);
|
||||
}
|
||||
}
|
||||
|
||||
function updatePathAndQuery(path: string, query: any, routeParams: any) {
|
||||
self.path = path;
|
||||
updateQuery(query);
|
||||
updateRouteParams(routeParams);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -91,6 +91,7 @@
|
||||
@import 'components/popper';
|
||||
@import 'components/form_select_box';
|
||||
@import 'components/user-picker';
|
||||
@import 'components/description-picker';
|
||||
|
||||
// PAGES
|
||||
@import 'pages/login';
|
||||
|
||||
@@ -135,6 +135,7 @@ $list-item-bg: $card-background;
|
||||
$list-item-hover-bg: lighten($gray-blue, 2%);
|
||||
$list-item-link-color: $text-color;
|
||||
$list-item-shadow: $card-shadow;
|
||||
$empty-list-cta-bg: $gray-blue;
|
||||
|
||||
// Scrollbars
|
||||
$scrollbarBackground: #404357;
|
||||
|
||||
@@ -133,6 +133,7 @@ $list-item-bg: linear-gradient(135deg, $gray-5, $gray-6); //$card-background;
|
||||
$list-item-hover-bg: darken($gray-5, 5%);
|
||||
$list-item-link-color: $text-color;
|
||||
$list-item-shadow: $card-shadow;
|
||||
$empty-list-cta-bg: $gray-6;
|
||||
|
||||
// Tables
|
||||
// -------------------------
|
||||
|
||||
@@ -11,6 +11,14 @@
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.gicon--has-hover {
|
||||
opacity: 0.5;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.mini {
|
||||
width: 0.8em;
|
||||
height: 0.8em;
|
||||
@@ -190,3 +198,8 @@
|
||||
background-image: url('../img/icons_dark_theme/icon_question.svg');
|
||||
}
|
||||
}
|
||||
|
||||
.fa--permissions-list {
|
||||
min-width: 20px;
|
||||
padding-right: 5px;
|
||||
}
|
||||
|
||||
11
public/sass/components/_description-picker.scss
Normal file
11
public/sass/components/_description-picker.scss
Normal file
@@ -0,0 +1,11 @@
|
||||
.description-picker-option__button {
|
||||
position: relative;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
display: block;
|
||||
border-radius: 0;
|
||||
white-space: normal;
|
||||
i.fa-check {
|
||||
padding-left: 2px;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
.empty-list-cta {
|
||||
background-color: $search-filter-box-bg;
|
||||
background-color: $empty-list-cta-bg;
|
||||
text-align: center;
|
||||
padding: $spacer*2;
|
||||
border-radius: $border-radius;
|
||||
|
||||
@@ -9,6 +9,9 @@ $select-noresults-color: $text-color;
|
||||
$select-input-bg: $input-bg;
|
||||
$select-input-border-color: $input-border-color;
|
||||
$select-menu-box-shadow: $menu-dropdown-shadow;
|
||||
$select-text-color: $text-color;
|
||||
$select-input-bg-disabled: $input-bg-disabled;
|
||||
$select-option-selected-bg: $dropdownLinkBackgroundActive;
|
||||
|
||||
@import '../../../node_modules/react-select/scss/default.scss';
|
||||
|
||||
@@ -51,15 +54,11 @@ $select-menu-box-shadow: $menu-dropdown-shadow;
|
||||
|
||||
.Select-value {
|
||||
display: inline-block;
|
||||
padding: 2px 4px;
|
||||
font-size: $font-size-base * 0.846;
|
||||
font-weight: bold;
|
||||
line-height: 14px; // ensure proper line-height if floated
|
||||
color: $white;
|
||||
padding: $input-padding-y $input-padding-x;
|
||||
font-size: $font-size-md;
|
||||
line-height: $input-line-height;
|
||||
vertical-align: baseline;
|
||||
white-space: nowrap;
|
||||
text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25);
|
||||
background-color: $gray-1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,3 +98,9 @@ $select-menu-box-shadow: $menu-dropdown-shadow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.gf-form-input--form-dropdown-right {
|
||||
.Select-menu-outer {
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
.popper {
|
||||
position: absolute;
|
||||
z-index: $zindex-tooltip;
|
||||
background: $tooltipBackground;
|
||||
color: $tooltipColor;
|
||||
width: 150px;
|
||||
max-width: 400px;
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 0 2px rgba(0, 0, 0, 0.5);
|
||||
padding: 10px;
|
||||
|
||||
@@ -66,9 +66,7 @@
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
width: calc(
|
||||
100% - #{$page-sidebar-width + $page-sidebar-margin}
|
||||
); // sidebar width + margin
|
||||
width: calc(100% - #{$page-sidebar-width + $page-sidebar-margin}); // sidebar width + margin
|
||||
}
|
||||
|
||||
.sidebar-container {
|
||||
@@ -90,6 +88,12 @@
|
||||
margin-bottom: $spacer;
|
||||
}
|
||||
|
||||
.page-sub-heading-icon {
|
||||
margin-left: $spacer;
|
||||
vertical-align: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.page-sidebar {
|
||||
color: $text-color-weak;
|
||||
h4 {
|
||||
|
||||
@@ -74,3 +74,7 @@ button.close {
|
||||
.affix {
|
||||
position: fixed;
|
||||
}
|
||||
|
||||
.d-inline-block {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const backendSrv = {
|
||||
get: jest.fn(),
|
||||
getDashboard: jest.fn(),
|
||||
post: jest.fn(),
|
||||
};
|
||||
|
||||
@@ -11,5 +12,6 @@ export function createNavTree(...args) {
|
||||
node.push(child);
|
||||
node = child.children;
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user