Merge branch '7883_new_url_structure' into 7883_frontend_step2

This commit is contained in:
Marcus Efraimsson
2018-02-01 11:08:39 +01:00
137 changed files with 3945 additions and 1596 deletions

View File

@@ -24,6 +24,7 @@ describe('AlertRuleList', () => {
evalData: {},
executionError: '',
dashboardUri: 'db/mygool',
canEdit: true,
},
])
);

View File

@@ -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>
);

View File

@@ -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"

View File

@@ -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;

View 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>
);
}
}

View File

@@ -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);
});
});

View 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>
);
}
}

View File

@@ -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();

View File

@@ -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"

View File

@@ -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',
]);
}

View 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');
});
});
});

View File

@@ -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>

View File

@@ -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;

View File

@@ -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>
);
}
}

View File

@@ -0,0 +1,5 @@
export interface FolderInfo {
title: string;
id: number;
slug: string;
}

View 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');
});
});
});
});

View 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;

View 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>
);
};

View 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;

View File

@@ -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>
);
});

View 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;

View 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;

View File

@@ -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();
});
});

View 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);

View File

@@ -8,8 +8,7 @@ const model = {
return new Promise((resolve, reject) => {});
},
},
refreshList: () => {},
teamId: '1',
handlePicked: () => {},
};
describe('UserPicker', () => {

View 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);

View File

@@ -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]}

View 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} />;
}
};
}

View File

@@ -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>
)

View File

@@ -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>
)

View File

@@ -2,7 +2,7 @@
exports[`Popover renders correctly 1`] = `
<div
className="popper__manager"
className="popper__manager test-class"
>
<div
className="popper__target"

View File

@@ -2,7 +2,7 @@
exports[`Tooltip renders correctly 1`] = `
<div
className="popper__manager"
className="popper__manager test-class"
>
<div
className="popper__target"

View File

@@ -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">

View File

@@ -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;

View File

@@ -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: ''

View File

@@ -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: '=',
},
};
}

View File

@@ -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' }],
},
};

View File

@@ -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' }],
},
};

View File

@@ -1,5 +1,4 @@
import './directives/dash_class';
import './directives/dash_edit_link';
import './directives/dropdown_typeahead';
import './directives/metric_segment';
import './directives/misc';

View File

@@ -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');
});
}
};
});
});

View File

@@ -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);
}
});

View File

@@ -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', () => {

View File

@@ -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 });
}

View File

@@ -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) {

View File

@@ -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>

View File

@@ -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&#45;header"> -->
<!-- <h6>Permissions</h6> -->
<!-- </div> -->
<!-- <table class="filter&#45;table form&#45;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&#45;repeat="permission in ctrl.userPermissions" class="permissionlist__item"> -->
<!-- <td><i class="fa fa&#45;fw fa&#45;user"></i></td> -->
<!-- <td>{{permission.userLogin}}</td> -->
<!-- <td class="text&#45;right"> -->
<!-- <a ng&#45;click="ctrl.removePermission(permission)" class="btn btn&#45;danger btn&#45;small"> -->
<!-- <i class="fa fa&#45;remove"></i> -->
<!-- </a> -->
<!-- </td> -->
<!-- </tr> -->
<!-- <tr ng&#45;repeat="permission in ctrl.teamPermissions" class="permissionlist__item"> -->
<!-- <td><i class="fa fa&#45;fw fa&#45;users"></i></td> -->
<!-- <td>{{permission.team}}</td> -->
<!-- <td><select class="gf&#45;form&#45;input gf&#45;size&#45;auto" ng&#45;model="permission.permissions" ng&#45;options="p.value as p.text for p in ctrl.permissionTypeOptions" ng&#45;change="ctrl.updatePermission(permission)"></select></td> -->
<!-- <td class="text&#45;right"> -->
<!-- <a ng&#45;click="ctrl.removePermission(permission)" class="btn btn&#45;danger btn&#45;small"> -->
<!-- <i class="fa fa&#45;remove"></i> -->
<!-- </a> -->
<!-- </td> -->
<!-- </tr> -->
<!-- <tr ng&#45;repeat="role in ctrl.roles" class="permissionlist__item"> -->
<!-- <td></td> -->
<!-- <td>{{role.name}}</td> -->
<!-- <td><select class="gf&#45;form&#45;input gf&#45;size&#45;auto" ng&#45;model="role.permissions" ng&#45;options="p.value as p.text for p in ctrl.roleOptions" ng&#45;change="ctrl.updatePermission(role)"></select></td> -->
<!-- <td class="text&#45;right"> -->
<!-- -->
<!-- </td> -->
<!-- </tr> -->
<!-- </tbody> -->
<!-- </table> -->
<!-- </div> -->
<!-- </div> -->
<!-- </div> -->

View File

@@ -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);

View File

@@ -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);
});
});
});

View File

@@ -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);

View File

@@ -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

View File

@@ -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)

View File

@@ -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;
});

View File

@@ -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;
});
}
}

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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();
}

View File

@@ -12,6 +12,7 @@ export class PlaylistSearchCtrl {
$timeout(() => {
this.query.query = '';
this.query.type = 'dash-db';
this.searchDashboards();
}, 100);
}

View File

@@ -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';

View 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);

View File

@@ -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();
});

View 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>

View File

@@ -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">

View File

@@ -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',

View File

@@ -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 }],
},
};
}

View File

@@ -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>

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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]);

View File

@@ -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',

View File

@@ -20,6 +20,7 @@ function getRule(name, state, info) {
stateClass: 'asd',
stateAge: '10m',
info: info,
canEdit: true,
};
}

View File

@@ -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() {

View 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);
}),
}));

View File

@@ -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,
});

View 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);
});
});

View File

@@ -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}`;
}

View 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);
});
});
});

View 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)
);
};

View 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;
},
}));

View File

@@ -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;

View File

@@ -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');
});
});

View File

@@ -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 {

View File

@@ -91,6 +91,7 @@
@import 'components/popper';
@import 'components/form_select_box';
@import 'components/user-picker';
@import 'components/description-picker';
// PAGES
@import 'pages/login';

View File

@@ -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;

View File

@@ -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
// -------------------------

View File

@@ -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;
}

View 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;
}
}

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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 {

View File

@@ -74,3 +74,7 @@ button.close {
.affix {
position: fixed;
}
.d-inline-block {
display: inline-block;
}

View File

@@ -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;
}