mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Merge branch '7883_new_url_structure' into 7883_frontend_step2
This commit is contained in:
@@ -4,8 +4,9 @@ import PageHeader from './components/PageHeader/PageHeader';
|
||||
import EmptyListCTA from './components/EmptyListCTA/EmptyListCTA';
|
||||
import LoginBackground from './components/Login/LoginBackground';
|
||||
import { SearchResult } from './components/search/SearchResult';
|
||||
import UserPicker from './components/UserPicker/UserPicker';
|
||||
import { TagFilter } from './components/TagFilter/TagFilter';
|
||||
import UserPicker from './components/Picker/UserPicker';
|
||||
import DashboardPermissions from './components/Permissions/DashboardPermissions';
|
||||
|
||||
export function registerAngularDirectives() {
|
||||
react2AngularDirective('passwordStrength', PasswordStrength, ['password']);
|
||||
@@ -13,10 +14,17 @@ export function registerAngularDirectives() {
|
||||
react2AngularDirective('emptyListCta', EmptyListCTA, ['model']);
|
||||
react2AngularDirective('loginBackground', LoginBackground, []);
|
||||
react2AngularDirective('searchResult', SearchResult, []);
|
||||
react2AngularDirective('selectUserPicker', UserPicker, ['backendSrv', 'teamId', 'refreshList']);
|
||||
react2AngularDirective('tagFilter', TagFilter, [
|
||||
'tags',
|
||||
['onSelect', { watchDepth: 'reference' }],
|
||||
['tagOptions', { watchDepth: 'reference' }],
|
||||
]);
|
||||
react2AngularDirective('selectUserPicker', UserPicker, ['backendSrv', 'handlePicked']);
|
||||
react2AngularDirective('dashboardPermissions', DashboardPermissions, [
|
||||
'backendSrv',
|
||||
'dashboardId',
|
||||
'folderTitle',
|
||||
'folderSlug',
|
||||
'folderId',
|
||||
]);
|
||||
}
|
||||
|
||||
53
public/app/core/components/PageHeader/PageHeader.jest.tsx
Normal file
53
public/app/core/components/PageHeader/PageHeader.jest.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import PageHeader from './PageHeader';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
describe('PageHeader', () => {
|
||||
let wrapper;
|
||||
|
||||
describe('when the nav tree has a node with a title', () => {
|
||||
beforeAll(() => {
|
||||
const nav = {
|
||||
main: {
|
||||
icon: 'fa fa-folder-open',
|
||||
id: 'node',
|
||||
subTitle: 'node subtitle',
|
||||
url: '',
|
||||
text: 'node',
|
||||
},
|
||||
node: {},
|
||||
};
|
||||
wrapper = shallow(<PageHeader model={nav as any} />);
|
||||
});
|
||||
|
||||
it('should render the title', () => {
|
||||
const title = wrapper.find('.page-header__title');
|
||||
expect(title.text()).toBe('node');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the nav tree has a node with breadcrumbs and a title', () => {
|
||||
beforeAll(() => {
|
||||
const nav = {
|
||||
main: {
|
||||
icon: 'fa fa-folder-open',
|
||||
id: 'child',
|
||||
subTitle: 'child subtitle',
|
||||
url: '',
|
||||
text: 'child',
|
||||
breadcrumbs: [{ title: 'Parent', url: 'parentUrl' }],
|
||||
},
|
||||
node: {},
|
||||
};
|
||||
wrapper = shallow(<PageHeader model={nav as any} />);
|
||||
});
|
||||
|
||||
it('should render the title with breadcrumbs first and then title last', () => {
|
||||
const title = wrapper.find('.page-header__title');
|
||||
expect(title.text()).toBe('Parent / child');
|
||||
|
||||
const parentLink = wrapper.find('.page-header__title > a.text-link');
|
||||
expect(parentLink.prop('href')).toBe('parentUrl');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,55 +1,15 @@
|
||||
import React from 'react';
|
||||
import { observer } from 'mobx-react';
|
||||
import { NavModel, NavModelItem } from '../../nav_model_srv';
|
||||
import classNames from 'classnames';
|
||||
import appEvents from 'app/core/app_events';
|
||||
import { toJS } from 'mobx';
|
||||
|
||||
export interface IProps {
|
||||
model: NavModel;
|
||||
}
|
||||
|
||||
function TabItem(tab: NavModelItem) {
|
||||
if (tab.hideFromTabs) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let tabClasses = classNames({
|
||||
'gf-tabs-link': true,
|
||||
active: tab.active,
|
||||
});
|
||||
|
||||
return (
|
||||
<li className="gf-tabs-item" key={tab.url}>
|
||||
<a className={tabClasses} target={tab.target} href={tab.url}>
|
||||
<i className={tab.icon} />
|
||||
{tab.text}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectOption(navItem: NavModelItem) {
|
||||
if (navItem.hideFromTabs) {
|
||||
// TODO: Rename hideFromTabs => hideFromNav
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<option key={navItem.url} value={navItem.url}>
|
||||
{navItem.text}
|
||||
</option>
|
||||
);
|
||||
}
|
||||
|
||||
function Navigation({ main }: { main: NavModelItem }) {
|
||||
return (
|
||||
<nav>
|
||||
<SelectNav customCss="page-header__select-nav" main={main} />
|
||||
<Tabs customCss="page-header__tabs" main={main} />
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectNav({ main, customCss }: { main: NavModelItem; customCss: string }) {
|
||||
const SelectNav = ({ main, customCss }: { main: NavModelItem; customCss: string }) => {
|
||||
const defaultSelectedItem = main.children.find(navItem => {
|
||||
return navItem.active === true;
|
||||
});
|
||||
@@ -66,26 +26,81 @@ function SelectNav({ main, customCss }: { main: NavModelItem; customCss: string
|
||||
{/* Label to make it clickable */}
|
||||
<select
|
||||
className="gf-select-nav gf-form-input"
|
||||
defaultValue={defaultSelectedItem.url}
|
||||
value={defaultSelectedItem.url}
|
||||
onChange={gotoUrl}
|
||||
id="page-header-select-nav"
|
||||
>
|
||||
{main.children.map(SelectOption)}
|
||||
{main.children.map((navItem: NavModelItem) => {
|
||||
if (navItem.hideFromTabs) {
|
||||
// TODO: Rename hideFromTabs => hideFromNav
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<option key={navItem.url} value={navItem.url}>
|
||||
{navItem.text}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
function Tabs({ main, customCss }: { main: NavModelItem; customCss: string }) {
|
||||
return <ul className={`gf-tabs ${customCss}`}>{main.children.map(TabItem)}</ul>;
|
||||
}
|
||||
const Tabs = ({ main, customCss }: { main: NavModelItem; customCss: string }) => {
|
||||
return (
|
||||
<ul className={`gf-tabs ${customCss}`}>
|
||||
{main.children.map((tab, idx) => {
|
||||
if (tab.hideFromTabs) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tabClasses = classNames({
|
||||
'gf-tabs-link': true,
|
||||
active: tab.active,
|
||||
});
|
||||
|
||||
return (
|
||||
<li className="gf-tabs-item" key={tab.url}>
|
||||
<a className={tabClasses} target={tab.target} href={tab.url}>
|
||||
<i className={tab.icon} />
|
||||
{tab.text}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
const Navigation = ({ main }: { main: NavModelItem }) => {
|
||||
return (
|
||||
<nav>
|
||||
<SelectNav customCss="page-header__select-nav" main={main} />
|
||||
<Tabs customCss="page-header__tabs" main={main} />
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
@observer
|
||||
export default class PageHeader extends React.Component<IProps, any> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
renderBreadcrumb(breadcrumbs) {
|
||||
shouldComponentUpdate() {
|
||||
//Hack to re-render on changed props from angular with the @observer decorator
|
||||
return true;
|
||||
}
|
||||
|
||||
renderTitle(title: string, breadcrumbs: any[]) {
|
||||
if (!title && (!breadcrumbs || breadcrumbs.length === 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!breadcrumbs || breadcrumbs.length === 0) {
|
||||
return <h1 className="page-header__title">{title}</h1>;
|
||||
}
|
||||
|
||||
const breadcrumbsResult = [];
|
||||
for (let i = 0; i < breadcrumbs.length; i++) {
|
||||
const bc = breadcrumbs[i];
|
||||
@@ -99,7 +114,9 @@ export default class PageHeader extends React.Component<IProps, any> {
|
||||
breadcrumbsResult.push(<span key={i}> / {bc.title}</span>);
|
||||
}
|
||||
}
|
||||
return breadcrumbsResult;
|
||||
breadcrumbsResult.push(<span key={breadcrumbs.length + 1}> / {title}</span>);
|
||||
|
||||
return <h1 className="page-header__title">{breadcrumbsResult}</h1>;
|
||||
}
|
||||
|
||||
renderHeaderTitle(main) {
|
||||
@@ -111,11 +128,7 @@ export default class PageHeader extends React.Component<IProps, any> {
|
||||
</span>
|
||||
|
||||
<div className="page-header__info-block">
|
||||
{main.text && <h1 className="page-header__title">{main.text}</h1>}
|
||||
{main.breadcrumbs &&
|
||||
main.breadcrumbs.length > 0 && (
|
||||
<h1 className="page-header__title">{this.renderBreadcrumb(main.breadcrumbs)}</h1>
|
||||
)}
|
||||
{this.renderTitle(main.text, main.breadcrumbs)}
|
||||
{main.subTitle && <div className="page-header__sub-title">{main.subTitle}</div>}
|
||||
{main.subType && (
|
||||
<div className="page-header__stamps">
|
||||
@@ -135,12 +148,14 @@ export default class PageHeader extends React.Component<IProps, any> {
|
||||
return null;
|
||||
}
|
||||
|
||||
const main = toJS(model.main); // Convert to JS if its a mobx observable
|
||||
|
||||
return (
|
||||
<div className="page-header-canvas">
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
{this.renderHeaderTitle(model.main)}
|
||||
{model.main.children && <Navigation main={model.main} />}
|
||||
{this.renderHeaderTitle(main)}
|
||||
{main.children && <Navigation main={main} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import React, { Component } from 'react';
|
||||
import { store } from 'app/stores/store';
|
||||
import Permissions from 'app/core/components/Permissions/Permissions';
|
||||
import Tooltip from 'app/core/components/Tooltip/Tooltip';
|
||||
import PermissionsInfo from 'app/core/components/Permissions/PermissionsInfo';
|
||||
|
||||
export interface IProps {
|
||||
dashboardId: number;
|
||||
folderId: number;
|
||||
folderTitle: string;
|
||||
folderSlug: string;
|
||||
backendSrv: any;
|
||||
}
|
||||
|
||||
class DashboardPermissions extends Component<IProps, any> {
|
||||
permissions: any;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.permissions = store.permissions;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { dashboardId, folderTitle, folderSlug, folderId, backendSrv } = this.props;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="dashboard-settings__header">
|
||||
<h3 className="d-inline-block">Permissions</h3>
|
||||
<Tooltip className="page-sub-heading-icon" placement="auto" content={PermissionsInfo}>
|
||||
<i className="gicon gicon-question gicon--has-hover" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Permissions
|
||||
permissions={this.permissions}
|
||||
isFolder={false}
|
||||
dashboardId={dashboardId}
|
||||
folderInfo={{ title: folderTitle, slug: folderSlug, id: folderId }}
|
||||
backendSrv={backendSrv}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default DashboardPermissions;
|
||||
@@ -0,0 +1,40 @@
|
||||
import React, { Component } from 'react';
|
||||
import DescriptionPicker from 'app/core/components/Picker/DescriptionPicker';
|
||||
import { permissionOptions } from 'app/stores/PermissionsStore/PermissionsStore';
|
||||
|
||||
export interface IProps {
|
||||
item: any;
|
||||
}
|
||||
|
||||
export default class DisabledPermissionListItem extends Component<IProps, any> {
|
||||
render() {
|
||||
const { item } = this.props;
|
||||
|
||||
return (
|
||||
<tr className="gf-form-disabled">
|
||||
<td style={{ width: '100%' }}>
|
||||
<i className={`fa--permissions-list ${item.icon}`} />
|
||||
<span dangerouslySetInnerHTML={{ __html: item.nameHtml }} />
|
||||
</td>
|
||||
<td />
|
||||
<td className="query-keyword">Can</td>
|
||||
<td>
|
||||
<div className="gf-form">
|
||||
<DescriptionPicker
|
||||
optionsWithDesc={permissionOptions}
|
||||
handlePicked={() => {}}
|
||||
value={item.permission}
|
||||
disabled={true}
|
||||
className={'gf-form-input--form-dropdown-right'}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<button className="btn btn-inverse btn-small">
|
||||
<i className="fa fa-lock" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
}
|
||||
5
public/app/core/components/Permissions/FolderInfo.ts
Normal file
5
public/app/core/components/Permissions/FolderInfo.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface FolderInfo {
|
||||
title: string;
|
||||
id: number;
|
||||
slug: string;
|
||||
}
|
||||
73
public/app/core/components/Permissions/Permissions.jest.tsx
Normal file
73
public/app/core/components/Permissions/Permissions.jest.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React from 'react';
|
||||
import Permissions from './Permissions';
|
||||
import { RootStore } from 'app/stores/RootStore/RootStore';
|
||||
import { backendSrv } from 'test/mocks/common';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
describe('Permissions', () => {
|
||||
let wrapper;
|
||||
|
||||
beforeAll(() => {
|
||||
backendSrv.get.mockReturnValue(
|
||||
Promise.resolve([
|
||||
{ id: 2, dashboardId: 1, role: 'Viewer', permission: 1, permissionName: 'View' },
|
||||
{ id: 3, dashboardId: 1, role: 'Editor', permission: 1, permissionName: 'Edit' },
|
||||
{
|
||||
id: 4,
|
||||
dashboardId: 1,
|
||||
userId: 2,
|
||||
userLogin: 'danlimerick',
|
||||
userEmail: 'dan.limerick@gmail.com',
|
||||
permission: 4,
|
||||
permissionName: 'Admin',
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
backendSrv.post = jest.fn();
|
||||
|
||||
const store = RootStore.create(
|
||||
{},
|
||||
{
|
||||
backendSrv: backendSrv,
|
||||
}
|
||||
);
|
||||
|
||||
wrapper = shallow(<Permissions backendSrv={backendSrv} isFolder={true} dashboardId={1} {...store} />);
|
||||
return wrapper.instance().loadStore(1, true);
|
||||
});
|
||||
|
||||
describe('when permission for a user is added', () => {
|
||||
it('should save permission to db', () => {
|
||||
const userItem = {
|
||||
id: 2,
|
||||
login: 'user2',
|
||||
};
|
||||
|
||||
wrapper
|
||||
.instance()
|
||||
.userPicked(userItem)
|
||||
.then(() => {
|
||||
expect(backendSrv.post.mock.calls.length).toBe(1);
|
||||
expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when permission for team is added', () => {
|
||||
it('should save permission to db', () => {
|
||||
const teamItem = {
|
||||
id: 2,
|
||||
name: 'ug1',
|
||||
};
|
||||
|
||||
wrapper
|
||||
.instance()
|
||||
.teamPicked(teamItem)
|
||||
.then(() => {
|
||||
expect(backendSrv.post.mock.calls.length).toBe(1);
|
||||
expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
161
public/app/core/components/Permissions/Permissions.tsx
Normal file
161
public/app/core/components/Permissions/Permissions.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import React, { Component } from 'react';
|
||||
import PermissionsList from './PermissionsList';
|
||||
import { observer } from 'mobx-react';
|
||||
import UserPicker, { User } from 'app/core/components/Picker/UserPicker';
|
||||
import TeamPicker, { Team } from 'app/core/components/Picker/TeamPicker';
|
||||
import { aclTypes } from 'app/stores/PermissionsStore/PermissionsStore';
|
||||
import { FolderInfo } from './FolderInfo';
|
||||
|
||||
export interface DashboardAcl {
|
||||
id?: number;
|
||||
dashboardId?: number;
|
||||
userId?: number;
|
||||
userLogin?: string;
|
||||
userEmail?: string;
|
||||
teamId?: number;
|
||||
team?: string;
|
||||
permission?: number;
|
||||
permissionName?: string;
|
||||
role?: string;
|
||||
icon?: string;
|
||||
nameHtml?: string;
|
||||
inherited?: boolean;
|
||||
sortName?: string;
|
||||
sortRank?: number;
|
||||
}
|
||||
|
||||
export interface IProps {
|
||||
dashboardId: number;
|
||||
folderInfo?: FolderInfo;
|
||||
permissions?: any;
|
||||
isFolder: boolean;
|
||||
backendSrv: any;
|
||||
}
|
||||
|
||||
@observer
|
||||
class Permissions extends Component<IProps, any> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const { dashboardId, isFolder, folderInfo } = this.props;
|
||||
this.permissionChanged = this.permissionChanged.bind(this);
|
||||
this.typeChanged = this.typeChanged.bind(this);
|
||||
this.removeItem = this.removeItem.bind(this);
|
||||
this.userPicked = this.userPicked.bind(this);
|
||||
this.teamPicked = this.teamPicked.bind(this);
|
||||
this.loadStore(dashboardId, isFolder, folderInfo && folderInfo.id === 0);
|
||||
}
|
||||
|
||||
loadStore(dashboardId, isFolder, isInRoot = false) {
|
||||
return this.props.permissions.load(dashboardId, isFolder, isInRoot);
|
||||
}
|
||||
|
||||
permissionChanged(index: number, permission: number, permissionName: string) {
|
||||
const { permissions } = this.props;
|
||||
permissions.updatePermissionOnIndex(index, permission, permissionName);
|
||||
}
|
||||
|
||||
removeItem(index: number) {
|
||||
const { permissions } = this.props;
|
||||
permissions.removeStoreItem(index);
|
||||
}
|
||||
|
||||
resetNewType() {
|
||||
const { permissions } = this.props;
|
||||
permissions.resetNewType();
|
||||
}
|
||||
|
||||
typeChanged(evt) {
|
||||
const { value } = evt.target;
|
||||
const { permissions, dashboardId } = this.props;
|
||||
|
||||
if (value === 'Viewer' || value === 'Editor') {
|
||||
permissions.addStoreItem({ permission: 1, role: value, dashboardId: dashboardId }, dashboardId);
|
||||
this.resetNewType();
|
||||
return;
|
||||
}
|
||||
|
||||
permissions.setNewType(value);
|
||||
}
|
||||
|
||||
userPicked(user: User) {
|
||||
const { permissions, dashboardId } = this.props;
|
||||
return permissions.addStoreItem({
|
||||
userId: user.id,
|
||||
userLogin: user.login,
|
||||
permission: 1,
|
||||
dashboardId: dashboardId,
|
||||
});
|
||||
}
|
||||
|
||||
teamPicked(team: Team) {
|
||||
const { permissions, dashboardId } = this.props;
|
||||
return permissions.addStoreItem({
|
||||
teamId: team.id,
|
||||
team: team.name,
|
||||
permission: 1,
|
||||
dashboardId: dashboardId,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { permissions, folderInfo, backendSrv } = this.props;
|
||||
|
||||
return (
|
||||
<div className="gf-form-group">
|
||||
<PermissionsList
|
||||
permissions={permissions.items}
|
||||
removeItem={this.removeItem}
|
||||
permissionChanged={this.permissionChanged}
|
||||
fetching={permissions.fetching}
|
||||
folderInfo={folderInfo}
|
||||
/>
|
||||
<div className="gf-form-inline">
|
||||
<form name="addPermission" className="gf-form-group">
|
||||
<h6 className="muted">Add Permission For</h6>
|
||||
<div className="gf-form-inline">
|
||||
<div className="gf-form">
|
||||
<div className="gf-form-select-wrapper">
|
||||
<select
|
||||
className="gf-form-input gf-size-auto"
|
||||
value={permissions.newType}
|
||||
onChange={this.typeChanged}
|
||||
>
|
||||
{aclTypes.map((option, idx) => {
|
||||
return (
|
||||
<option key={idx} value={option.value}>
|
||||
{option.text}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{permissions.newType === 'User' ? (
|
||||
<div className="gf-form">
|
||||
<UserPicker backendSrv={backendSrv} handlePicked={this.userPicked} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{permissions.newType === 'Group' ? (
|
||||
<div className="gf-form">
|
||||
<TeamPicker backendSrv={backendSrv} handlePicked={this.teamPicked} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
{permissions.error ? (
|
||||
<div className="gf-form width-17">
|
||||
<span ng-if="ctrl.error" className="text-error p-l-1">
|
||||
<i className="fa fa-warning" />
|
||||
{permissions.error}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Permissions;
|
||||
13
public/app/core/components/Permissions/PermissionsInfo.tsx
Normal file
13
public/app/core/components/Permissions/PermissionsInfo.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
|
||||
export default () => {
|
||||
return (
|
||||
<div className="">
|
||||
<h5>What are Permissions?</h5>
|
||||
<p>
|
||||
An Access Control List (ACL) model is used to limit access to Dashboard Folders. A user or a Team can be
|
||||
assigned permissions for a folder or for a single dashboard.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
64
public/app/core/components/Permissions/PermissionsList.tsx
Normal file
64
public/app/core/components/Permissions/PermissionsList.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import React, { Component } from 'react';
|
||||
import PermissionsListItem from './PermissionsListItem';
|
||||
import DisabledPermissionsListItem from './DisabledPermissionsListItem';
|
||||
import { observer } from 'mobx-react';
|
||||
import { FolderInfo } from './FolderInfo';
|
||||
|
||||
export interface IProps {
|
||||
permissions: any[];
|
||||
removeItem: any;
|
||||
permissionChanged: any;
|
||||
fetching: boolean;
|
||||
folderInfo?: FolderInfo;
|
||||
}
|
||||
|
||||
@observer
|
||||
class PermissionsList extends Component<IProps, any> {
|
||||
render() {
|
||||
const { permissions, removeItem, permissionChanged, fetching, folderInfo } = this.props;
|
||||
|
||||
return (
|
||||
<table className="filter-table gf-form-group">
|
||||
<tbody>
|
||||
<DisabledPermissionsListItem
|
||||
key={0}
|
||||
item={{
|
||||
nameHtml: 'Everyone with <span class="query-keyword">Admin</span> Role',
|
||||
permission: 4,
|
||||
icon: 'fa fa-fw fa-street-view',
|
||||
}}
|
||||
/>
|
||||
{permissions.map((item, idx) => {
|
||||
return (
|
||||
<PermissionsListItem
|
||||
key={idx + 1}
|
||||
item={item}
|
||||
itemIndex={idx}
|
||||
removeItem={removeItem}
|
||||
permissionChanged={permissionChanged}
|
||||
folderInfo={folderInfo}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{fetching === true && permissions.length < 1 ? (
|
||||
<tr>
|
||||
<td colSpan={4}>
|
||||
<em>Loading permissions...</em>
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
|
||||
{fetching === false && permissions.length < 1 ? (
|
||||
<tr>
|
||||
<td colSpan={4}>
|
||||
<em>No permissions are set. Will only be accessible by admins.</em>
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default PermissionsList;
|
||||
@@ -0,0 +1,65 @@
|
||||
import React from 'react';
|
||||
import { observer } from 'mobx-react';
|
||||
import DescriptionPicker from 'app/core/components/Picker/DescriptionPicker';
|
||||
import { permissionOptions } from 'app/stores/PermissionsStore/PermissionsStore';
|
||||
|
||||
const setClassNameHelper = inherited => {
|
||||
return inherited ? 'gf-form-disabled' : '';
|
||||
};
|
||||
|
||||
export default observer(({ item, removeItem, permissionChanged, itemIndex, folderInfo }) => {
|
||||
const handleRemoveItem = evt => {
|
||||
evt.preventDefault();
|
||||
removeItem(itemIndex);
|
||||
};
|
||||
|
||||
const handleChangePermission = permissionOption => {
|
||||
permissionChanged(itemIndex, permissionOption.value, permissionOption.label);
|
||||
};
|
||||
|
||||
const inheritedFromRoot = item.dashboardId === -1 && folderInfo && folderInfo.id === 0;
|
||||
|
||||
return (
|
||||
<tr className={setClassNameHelper(item.inherited)}>
|
||||
<td style={{ width: '100%' }}>
|
||||
<i className={`fa--permissions-list ${item.icon}`} />
|
||||
<span dangerouslySetInnerHTML={{ __html: item.nameHtml }} />
|
||||
</td>
|
||||
<td>
|
||||
{item.inherited &&
|
||||
folderInfo && (
|
||||
<em className="muted no-wrap">
|
||||
Inherited from folder{' '}
|
||||
<a className="text-link" href={`dashboards/folder/${folderInfo.id}/${folderInfo.slug}/permissions`}>
|
||||
{folderInfo.title}
|
||||
</a>{' '}
|
||||
</em>
|
||||
)}
|
||||
{inheritedFromRoot && <em className="muted no-wrap">Default Permission</em>}
|
||||
</td>
|
||||
<td className="query-keyword">Can</td>
|
||||
<td>
|
||||
<div className="gf-form">
|
||||
<DescriptionPicker
|
||||
optionsWithDesc={permissionOptions}
|
||||
handlePicked={handleChangePermission}
|
||||
value={item.permission}
|
||||
disabled={item.inherited}
|
||||
className={'gf-form-input--form-dropdown-right'}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{!item.inherited ? (
|
||||
<a className="btn btn-danger btn-small" onClick={handleRemoveItem}>
|
||||
<i className="fa fa-remove" />
|
||||
</a>
|
||||
) : (
|
||||
<button className="btn btn-inverse btn-small">
|
||||
<i className="fa fa-lock" />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
56
public/app/core/components/Picker/DescriptionOption.tsx
Normal file
56
public/app/core/components/Picker/DescriptionOption.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import React, { Component } from 'react';
|
||||
|
||||
export interface IProps {
|
||||
onSelect: any;
|
||||
onFocus: any;
|
||||
option: any;
|
||||
isFocused: any;
|
||||
className: any;
|
||||
}
|
||||
|
||||
class DescriptionOption extends Component<IProps, any> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.handleMouseDown = this.handleMouseDown.bind(this);
|
||||
this.handleMouseEnter = this.handleMouseEnter.bind(this);
|
||||
this.handleMouseMove = this.handleMouseMove.bind(this);
|
||||
}
|
||||
|
||||
handleMouseDown(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.props.onSelect(this.props.option, event);
|
||||
}
|
||||
|
||||
handleMouseEnter(event) {
|
||||
this.props.onFocus(this.props.option, event);
|
||||
}
|
||||
|
||||
handleMouseMove(event) {
|
||||
if (this.props.isFocused) {
|
||||
return;
|
||||
}
|
||||
this.props.onFocus(this.props.option, event);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { option, children, className } = this.props;
|
||||
return (
|
||||
<button
|
||||
onMouseDown={this.handleMouseDown}
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
onMouseMove={this.handleMouseMove}
|
||||
title={option.title}
|
||||
className={`description-picker-option__button btn btn-link ${className} width-19`}
|
||||
>
|
||||
<div className="gf-form">{children}</div>
|
||||
<div className="gf-form">
|
||||
<div className="muted width-17">{option.description}</div>
|
||||
{className.indexOf('is-selected') > -1 && <i className="fa fa-check" aria-hidden="true" />}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default DescriptionOption;
|
||||
48
public/app/core/components/Picker/DescriptionPicker.tsx
Normal file
48
public/app/core/components/Picker/DescriptionPicker.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import React, { Component } from 'react';
|
||||
import Select from 'react-select';
|
||||
import DescriptionOption from './DescriptionOption';
|
||||
|
||||
export interface IProps {
|
||||
optionsWithDesc: OptionWithDescription[];
|
||||
handlePicked: (permission) => void;
|
||||
value: number;
|
||||
disabled: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface OptionWithDescription {
|
||||
value: any;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
class DescriptionPicker extends Component<IProps, any> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {};
|
||||
}
|
||||
|
||||
render() {
|
||||
const { optionsWithDesc, handlePicked, value, disabled, className } = this.props;
|
||||
|
||||
return (
|
||||
<div className="permissions-picker">
|
||||
<Select
|
||||
value={value}
|
||||
valueKey="value"
|
||||
multi={false}
|
||||
clearable={false}
|
||||
labelKey="label"
|
||||
options={optionsWithDesc}
|
||||
onChange={handlePicked}
|
||||
className={`width-7 gf-form-input gf-form-input--form-dropdown ${className || ''}`}
|
||||
optionComponent={DescriptionOption}
|
||||
placeholder="Choose"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default DescriptionPicker;
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import renderer from 'react-test-renderer';
|
||||
import UserPickerOption from './UserPickerOption';
|
||||
import PickerOption from './PickerOption';
|
||||
|
||||
const model = {
|
||||
onSelect: () => {},
|
||||
@@ -14,9 +14,9 @@ const model = {
|
||||
className: 'class-for-user-picker',
|
||||
};
|
||||
|
||||
describe('UserPickerOption', () => {
|
||||
describe('PickerOption', () => {
|
||||
it('renders correctly', () => {
|
||||
const tree = renderer.create(<UserPickerOption {...model} />).toJSON();
|
||||
const tree = renderer.create(<PickerOption {...model} />).toJSON();
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
79
public/app/core/components/Picker/TeamPicker.tsx
Normal file
79
public/app/core/components/Picker/TeamPicker.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import React, { Component } from 'react';
|
||||
import Select from 'react-select';
|
||||
import PickerOption from './PickerOption';
|
||||
import withPicker from './withPicker';
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
export interface IProps {
|
||||
backendSrv: any;
|
||||
isLoading: boolean;
|
||||
toggleLoading: any;
|
||||
handlePicked: (user) => void;
|
||||
}
|
||||
|
||||
export interface Team {
|
||||
id: number;
|
||||
label: string;
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
}
|
||||
|
||||
class TeamPicker extends Component<IProps, any> {
|
||||
debouncedSearch: any;
|
||||
backendSrv: any;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {};
|
||||
this.search = this.search.bind(this);
|
||||
|
||||
this.debouncedSearch = debounce(this.search, 300, {
|
||||
leading: true,
|
||||
trailing: false,
|
||||
});
|
||||
}
|
||||
|
||||
search(query?: string) {
|
||||
const { toggleLoading, backendSrv } = this.props;
|
||||
|
||||
toggleLoading(true);
|
||||
return backendSrv.get(`/api/teams/search?perpage=10&page=1&query=${query}`).then(result => {
|
||||
const teams = result.teams.map(team => {
|
||||
return {
|
||||
id: team.id,
|
||||
label: team.name,
|
||||
name: team.name,
|
||||
avatarUrl: team.avatarUrl,
|
||||
};
|
||||
});
|
||||
|
||||
toggleLoading(false);
|
||||
return { options: teams };
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const AsyncComponent = this.state.creatable ? Select.AsyncCreatable : Select.Async;
|
||||
const { isLoading, handlePicked } = this.props;
|
||||
|
||||
return (
|
||||
<div className="user-picker">
|
||||
<AsyncComponent
|
||||
valueKey="id"
|
||||
multi={false}
|
||||
labelKey="label"
|
||||
cache={false}
|
||||
isLoading={isLoading}
|
||||
loadOptions={this.debouncedSearch}
|
||||
loadingPlaceholder="Loading..."
|
||||
onChange={handlePicked}
|
||||
className="width-8 gf-form-input gf-form-input--form-dropdown"
|
||||
optionComponent={PickerOption}
|
||||
placeholder="Choose"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withPicker(TeamPicker);
|
||||
@@ -8,8 +8,7 @@ const model = {
|
||||
return new Promise((resolve, reject) => {});
|
||||
},
|
||||
},
|
||||
refreshList: () => {},
|
||||
teamId: '1',
|
||||
handlePicked: () => {},
|
||||
};
|
||||
|
||||
describe('UserPicker', () => {
|
||||
79
public/app/core/components/Picker/UserPicker.tsx
Normal file
79
public/app/core/components/Picker/UserPicker.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import React, { Component } from 'react';
|
||||
import Select from 'react-select';
|
||||
import PickerOption from './PickerOption';
|
||||
import withPicker from './withPicker';
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
export interface IProps {
|
||||
backendSrv: any;
|
||||
isLoading: boolean;
|
||||
toggleLoading: any;
|
||||
handlePicked: (user) => void;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
label: string;
|
||||
avatarUrl: string;
|
||||
login: string;
|
||||
}
|
||||
|
||||
class UserPicker extends Component<IProps, any> {
|
||||
debouncedSearch: any;
|
||||
backendSrv: any;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {};
|
||||
this.search = this.search.bind(this);
|
||||
|
||||
this.debouncedSearch = debounce(this.search, 300, {
|
||||
leading: true,
|
||||
trailing: false,
|
||||
});
|
||||
}
|
||||
|
||||
search(query?: string) {
|
||||
const { toggleLoading, backendSrv } = this.props;
|
||||
|
||||
toggleLoading(true);
|
||||
return backendSrv.get(`/api/users/search?perpage=10&page=1&query=${query}`).then(result => {
|
||||
const users = result.users.map(user => {
|
||||
return {
|
||||
id: user.id,
|
||||
label: `${user.login} - ${user.email}`,
|
||||
avatarUrl: user.avatarUrl,
|
||||
login: user.login,
|
||||
};
|
||||
});
|
||||
toggleLoading(false);
|
||||
return { options: users };
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const AsyncComponent = this.state.creatable ? Select.AsyncCreatable : Select.Async;
|
||||
const { isLoading, handlePicked } = this.props;
|
||||
|
||||
return (
|
||||
<div className="user-picker">
|
||||
<AsyncComponent
|
||||
valueKey="id"
|
||||
multi={false}
|
||||
labelKey="label"
|
||||
cache={false}
|
||||
isLoading={isLoading}
|
||||
loadOptions={this.debouncedSearch}
|
||||
loadingPlaceholder="Loading..."
|
||||
noResultsText="No users found"
|
||||
onChange={handlePicked}
|
||||
className="width-8 gf-form-input gf-form-input--form-dropdown"
|
||||
optionComponent={PickerOption}
|
||||
placeholder="Choose"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withPicker(UserPicker);
|
||||
@@ -1,6 +1,6 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`UserPickerOption renders correctly 1`] = `
|
||||
exports[`PickerOption renders correctly 1`] = `
|
||||
<button
|
||||
className="user-picker-option__button btn btn-link class-for-user-picker"
|
||||
onMouseDown={[Function]}
|
||||
32
public/app/core/components/Picker/withPicker.tsx
Normal file
32
public/app/core/components/Picker/withPicker.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React, { Component } from 'react';
|
||||
|
||||
export interface IProps {
|
||||
backendSrv: any;
|
||||
handlePicked: (data) => void;
|
||||
}
|
||||
|
||||
export default function withPicker(WrappedComponent) {
|
||||
return class WithPicker extends Component<IProps, any> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.toggleLoading = this.toggleLoading.bind(this);
|
||||
|
||||
this.state = {
|
||||
isLoading: false,
|
||||
};
|
||||
}
|
||||
|
||||
toggleLoading(isLoading) {
|
||||
this.setState(prevState => {
|
||||
return {
|
||||
...prevState,
|
||||
isLoading: isLoading,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return <WrappedComponent toggleLoading={this.toggleLoading} isLoading={this.state.isLoading} {...this.props} />;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -6,7 +6,7 @@ describe('Popover', () => {
|
||||
it('renders correctly', () => {
|
||||
const tree = renderer
|
||||
.create(
|
||||
<Popover placement="auto" content="Popover text">
|
||||
<Popover className="test-class" placement="auto" content="Popover text">
|
||||
<button>Button with Popover</button>
|
||||
</Popover>
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ describe('Tooltip', () => {
|
||||
it('renders correctly', () => {
|
||||
const tree = renderer
|
||||
.create(
|
||||
<Tooltip placement="auto" content="Tooltip text">
|
||||
<Tooltip className="test-class" placement="auto" content="Tooltip text">
|
||||
<a href="http://www.grafana.com">Link with tooltip</a>
|
||||
</Tooltip>
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
exports[`Popover renders correctly 1`] = `
|
||||
<div
|
||||
className="popper__manager"
|
||||
className="popper__manager test-class"
|
||||
>
|
||||
<div
|
||||
className="popper__target"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
exports[`Tooltip renders correctly 1`] = `
|
||||
<div
|
||||
className="popper__manager"
|
||||
className="popper__manager test-class"
|
||||
>
|
||||
<div
|
||||
className="popper__target"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Manager, Popper, Arrow } from 'react-popper';
|
||||
interface IwithTooltipProps {
|
||||
placement?: string;
|
||||
content: string | ((props: any) => JSX.Element);
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function withTooltip(WrappedComponent) {
|
||||
@@ -39,10 +40,10 @@ export default function withTooltip(WrappedComponent) {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { content } = this.props;
|
||||
const { content, className } = this.props;
|
||||
|
||||
return (
|
||||
<Manager className="popper__manager">
|
||||
<Manager className={`popper__manager ${className || ''}`}>
|
||||
<WrappedComponent {...this.props} tooltipSetState={this.setState} />
|
||||
{this.state.show ? (
|
||||
<Popper placement={this.state.placement} className="popper">
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import { debounce } from 'lodash';
|
||||
import Select from 'react-select';
|
||||
import UserPickerOption from './UserPickerOption';
|
||||
|
||||
export interface IProps {
|
||||
backendSrv: any;
|
||||
teamId: string;
|
||||
refreshList: any;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
name: string;
|
||||
login: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
class UserPicker extends Component<IProps, any> {
|
||||
debouncedSearchUsers: any;
|
||||
backendSrv: any;
|
||||
teamId: string;
|
||||
refreshList: any;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.backendSrv = this.props.backendSrv;
|
||||
this.teamId = this.props.teamId;
|
||||
this.refreshList = this.props.refreshList;
|
||||
|
||||
this.searchUsers = this.searchUsers.bind(this);
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
this.addUser = this.addUser.bind(this);
|
||||
this.toggleLoading = this.toggleLoading.bind(this);
|
||||
|
||||
this.debouncedSearchUsers = debounce(this.searchUsers, 300, {
|
||||
leading: true,
|
||||
trailing: false,
|
||||
});
|
||||
|
||||
this.state = {
|
||||
multi: false,
|
||||
isLoading: false,
|
||||
};
|
||||
}
|
||||
|
||||
handleChange(user) {
|
||||
this.addUser(user.id);
|
||||
}
|
||||
|
||||
toggleLoading(isLoading) {
|
||||
this.setState(prevState => {
|
||||
return {
|
||||
...prevState,
|
||||
isLoading: isLoading,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
addUser(userId) {
|
||||
this.toggleLoading(true);
|
||||
this.backendSrv.post(`/api/teams/${this.teamId}/members`, { userId: userId }).then(() => {
|
||||
this.refreshList();
|
||||
this.toggleLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
searchUsers(query) {
|
||||
this.toggleLoading(true);
|
||||
|
||||
return this.backendSrv.get(`/api/users/search?perpage=10&page=1&query=${query}`).then(result => {
|
||||
const users = result.users.map(user => {
|
||||
return {
|
||||
id: user.id,
|
||||
label: `${user.login} - ${user.email}`,
|
||||
avatarUrl: user.avatarUrl,
|
||||
};
|
||||
});
|
||||
this.toggleLoading(false);
|
||||
return { options: users };
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const AsyncComponent = this.state.creatable ? Select.AsyncCreatable : Select.Async;
|
||||
|
||||
return (
|
||||
<div className="user-picker">
|
||||
<AsyncComponent
|
||||
valueKey="id"
|
||||
multi={this.state.multi}
|
||||
labelKey="label"
|
||||
cache={false}
|
||||
isLoading={this.state.isLoading}
|
||||
loadOptions={this.debouncedSearchUsers}
|
||||
loadingPlaceholder="Loading..."
|
||||
noResultsText="No users found"
|
||||
onChange={this.handleChange}
|
||||
className="width-8 gf-form-input gf-form-input--form-dropdown"
|
||||
optionComponent={UserPickerOption}
|
||||
placeholder="Choose"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default UserPicker;
|
||||
@@ -5,11 +5,11 @@
|
||||
<i class="gf-form-input-icon fa fa-search"></i>
|
||||
</label>
|
||||
<div class="page-action-bar__spacer"></div>
|
||||
<a class="btn btn-success" ng-href="{{ctrl.createDashboardUrl()}}">
|
||||
<a class="btn btn-success" ng-href="{{ctrl.createDashboardUrl()}}" ng-if="ctrl.isEditor || ctrl.canSave">
|
||||
<i class="fa fa-plus"></i>
|
||||
Dashboard
|
||||
</a>
|
||||
<a class="btn btn-success" href="dashboards/folder/new" ng-if="!ctrl.folderId">
|
||||
<a class="btn btn-success" href="dashboards/folder/new" ng-if="!ctrl.folderId && ctrl.isEditor">
|
||||
<i class="fa fa-plus"></i>
|
||||
Folder
|
||||
</a>
|
||||
@@ -95,22 +95,23 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-results-container">
|
||||
<dashboard-search-results
|
||||
<dashboard-search-results
|
||||
results="ctrl.sections"
|
||||
editable="true"
|
||||
on-selection-changed="ctrl.selectionChanged()"
|
||||
on-tag-selected="ctrl.filterByTag($tag)" />
|
||||
on-tag-selected="ctrl.filterByTag($tag)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ng-if="ctrl.folderId && !ctrl.hasFilters && ctrl.sections.length === 0">
|
||||
<div ng-if="ctrl.canSave && ctrl.folderId && !ctrl.hasFilters && ctrl.sections.length === 0">
|
||||
<empty-list-cta model="{
|
||||
title: 'This folder doesn\'t have any dashboards yet',
|
||||
buttonIcon: 'gicon gicon-dashboard-new',
|
||||
buttonLink: 'dashboard/new?folderId={{ctrl.folderId}}',
|
||||
buttonTitle: 'Create Dashboard',
|
||||
proTip: 'Add dashboards into your folder at ->',
|
||||
proTip: 'Add/move dashboards to your folder at ->',
|
||||
proTipLink: 'dashboards',
|
||||
proTipLinkTitle: 'Manage dashboards',
|
||||
proTipTarget: ''
|
||||
|
||||
@@ -3,22 +3,49 @@ import coreModule from 'app/core/core_module';
|
||||
import appEvents from 'app/core/app_events';
|
||||
import { SearchSrv } from 'app/core/services/search_srv';
|
||||
|
||||
class Query {
|
||||
query: string;
|
||||
mode: string;
|
||||
tag: any[];
|
||||
starred: boolean;
|
||||
skipRecent: boolean;
|
||||
skipStarred: boolean;
|
||||
folderIds: number[];
|
||||
}
|
||||
|
||||
export class ManageDashboardsCtrl {
|
||||
public sections: any[];
|
||||
tagFilterOptions: any[];
|
||||
selectedTagFilter: any;
|
||||
query: any;
|
||||
|
||||
query: Query;
|
||||
navModel: any;
|
||||
|
||||
selectAllChecked = false;
|
||||
|
||||
// enable/disable actions depending on the folders or dashboards selected
|
||||
canDelete = false;
|
||||
canMove = false;
|
||||
|
||||
// filter variables
|
||||
hasFilters = false;
|
||||
selectAllChecked = false;
|
||||
tagFilterOptions: any[];
|
||||
selectedTagFilter: any;
|
||||
starredFilterOptions = [{ text: 'Filter by Starred', disabled: true }, { text: 'Yes' }, { text: 'No' }];
|
||||
selectedStarredFilter: any;
|
||||
|
||||
// used when managing dashboards for a specific folder
|
||||
folderId?: number;
|
||||
folderSlug?: string;
|
||||
|
||||
// if user can add new folders and/or add new dashboards
|
||||
canSave: boolean;
|
||||
|
||||
// if user has editor role or higher
|
||||
isEditor: boolean;
|
||||
|
||||
/** @ngInject */
|
||||
constructor(private backendSrv, navModelSrv, private searchSrv: SearchSrv) {
|
||||
constructor(private backendSrv, navModelSrv, private searchSrv: SearchSrv, private contextSrv) {
|
||||
this.isEditor = this.contextSrv.isEditor;
|
||||
|
||||
this.query = {
|
||||
query: '',
|
||||
mode: 'tree',
|
||||
@@ -26,6 +53,7 @@ export class ManageDashboardsCtrl {
|
||||
starred: false,
|
||||
skipRecent: true,
|
||||
skipStarred: true,
|
||||
folderIds: [],
|
||||
};
|
||||
|
||||
if (this.folderId) {
|
||||
@@ -34,15 +62,26 @@ export class ManageDashboardsCtrl {
|
||||
|
||||
this.selectedStarredFilter = this.starredFilterOptions[0];
|
||||
|
||||
this.getDashboards().then(() => {
|
||||
this.getTags();
|
||||
this.refreshList().then(() => {
|
||||
this.initTagFilter();
|
||||
});
|
||||
}
|
||||
|
||||
getDashboards() {
|
||||
return this.searchSrv.search(this.query).then(result => {
|
||||
return this.initDashboardList(result);
|
||||
});
|
||||
refreshList() {
|
||||
return this.searchSrv
|
||||
.search(this.query)
|
||||
.then(result => {
|
||||
return this.initDashboardList(result);
|
||||
})
|
||||
.then(() => {
|
||||
if (!this.folderSlug) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.backendSrv.getDashboard('db', this.folderSlug).then(dash => {
|
||||
this.canSave = dash.meta.canSave;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
initDashboardList(result: any) {
|
||||
@@ -176,7 +215,7 @@ export class ManageDashboardsCtrl {
|
||||
appEvents.emit('alert-success', [header, msg]);
|
||||
}
|
||||
|
||||
this.getDashboards();
|
||||
this.refreshList();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -203,12 +242,12 @@ export class ManageDashboardsCtrl {
|
||||
modalClass: 'modal--narrow',
|
||||
model: {
|
||||
dashboards: selectedDashboards,
|
||||
afterSave: this.getDashboards.bind(this),
|
||||
afterSave: this.refreshList.bind(this),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getTags() {
|
||||
initTagFilter() {
|
||||
return this.searchSrv.getDashboardTags().then(results => {
|
||||
this.tagFilterOptions = [{ term: 'Filter By Tag', disabled: true }].concat(results);
|
||||
this.selectedTagFilter = this.tagFilterOptions[0];
|
||||
@@ -220,11 +259,11 @@ export class ManageDashboardsCtrl {
|
||||
this.query.tag.push(tag);
|
||||
}
|
||||
|
||||
return this.getDashboards();
|
||||
return this.refreshList();
|
||||
}
|
||||
|
||||
onQueryChange() {
|
||||
return this.getDashboards();
|
||||
return this.refreshList();
|
||||
}
|
||||
|
||||
onTagFilterChange() {
|
||||
@@ -235,7 +274,7 @@ export class ManageDashboardsCtrl {
|
||||
|
||||
removeTag(tag, evt) {
|
||||
this.query.tag = _.without(this.query.tag, tag);
|
||||
this.getDashboards();
|
||||
this.refreshList();
|
||||
if (evt) {
|
||||
evt.stopPropagation();
|
||||
evt.preventDefault();
|
||||
@@ -244,13 +283,13 @@ export class ManageDashboardsCtrl {
|
||||
|
||||
removeStarred() {
|
||||
this.query.starred = false;
|
||||
return this.getDashboards();
|
||||
return this.refreshList();
|
||||
}
|
||||
|
||||
onStarredFilterChange() {
|
||||
this.query.starred = this.selectedStarredFilter.text === 'Yes';
|
||||
this.selectedStarredFilter = this.starredFilterOptions[0];
|
||||
return this.getDashboards();
|
||||
return this.refreshList();
|
||||
}
|
||||
|
||||
onSelectAllChanged() {
|
||||
@@ -272,7 +311,7 @@ export class ManageDashboardsCtrl {
|
||||
this.query.query = '';
|
||||
this.query.tag = [];
|
||||
this.query.starred = false;
|
||||
this.getDashboards();
|
||||
this.refreshList();
|
||||
}
|
||||
|
||||
createDashboardUrl() {
|
||||
@@ -295,6 +334,7 @@ export function manageDashboardsDirective() {
|
||||
controllerAs: 'ctrl',
|
||||
scope: {
|
||||
folderId: '=',
|
||||
folderSlug: '=',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,8 +10,9 @@ export class InvitedCtrl {
|
||||
$scope.navModel = {
|
||||
main: {
|
||||
icon: 'gicon gicon-branding',
|
||||
text: 'Invite',
|
||||
subTitle: 'Register your Grafana account',
|
||||
breadcrumbs: [{ title: 'Login', url: '/login' }, { title: 'Invite' }],
|
||||
breadcrumbs: [{ title: 'Login', url: '/login' }],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -16,8 +16,9 @@ export class ResetPasswordCtrl {
|
||||
$scope.navModel = {
|
||||
main: {
|
||||
icon: 'gicon gicon-branding',
|
||||
text: 'Reset Password',
|
||||
subTitle: 'Reset your Grafana password',
|
||||
breadcrumbs: [{ title: 'Login', url: 'login' }, { title: 'Reset Password' }],
|
||||
breadcrumbs: [{ title: 'Login', url: 'login' }],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import './directives/dash_class';
|
||||
import './directives/dash_edit_link';
|
||||
import './directives/dropdown_typeahead';
|
||||
import './directives/metric_segment';
|
||||
import './directives/misc';
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
define([
|
||||
'jquery',
|
||||
'angular',
|
||||
'../core_module',
|
||||
'lodash',
|
||||
],
|
||||
function ($, angular, coreModule, _) {
|
||||
'use strict';
|
||||
|
||||
var editViewMap = {
|
||||
'settings': { src: 'public/app/features/dashboard/partials/settings.html'},
|
||||
'annotations': { src: 'public/app/features/annotations/partials/editor.html'},
|
||||
'templating': { src: 'public/app/features/templating/partials/editor.html'},
|
||||
'history': { html: '<gf-dashboard-history dashboard="dashboard"></gf-dashboard-history>'},
|
||||
'timepicker': { src: 'public/app/features/dashboard/timepicker/dropdown.html' },
|
||||
'import': { html: '<dash-import dismiss="dismiss()"></dash-import>', isModal: true },
|
||||
'permissions': { html: '<dash-acl-modal dismiss="dismiss()"></dash-acl-modal>', isModal: true },
|
||||
'new-folder': {
|
||||
isModal: true,
|
||||
html: '<folder-modal dismiss="dismiss()"></folder-modal>',
|
||||
modalClass: 'modal--narrow'
|
||||
}
|
||||
};
|
||||
|
||||
coreModule.default.directive('dashEditorView', function($compile, $location, $rootScope) {
|
||||
return {
|
||||
restrict: 'A',
|
||||
link: function(scope, elem) {
|
||||
var editorScope;
|
||||
var modalScope;
|
||||
var lastEditView;
|
||||
|
||||
function hideEditorPane(hideToShowOtherView) {
|
||||
if (editorScope) {
|
||||
editorScope.dismiss(hideToShowOtherView);
|
||||
}
|
||||
}
|
||||
|
||||
function showEditorPane(evt, options) {
|
||||
if (options.editview) {
|
||||
_.defaults(options, editViewMap[options.editview]);
|
||||
}
|
||||
|
||||
if (lastEditView && lastEditView === options.editview) {
|
||||
hideEditorPane(false);
|
||||
return;
|
||||
}
|
||||
|
||||
hideEditorPane(true);
|
||||
|
||||
lastEditView = options.editview;
|
||||
editorScope = options.scope ? options.scope.$new() : scope.$new();
|
||||
|
||||
editorScope.dismiss = function(hideToShowOtherView) {
|
||||
if (modalScope) {
|
||||
modalScope.dismiss();
|
||||
modalScope = null;
|
||||
}
|
||||
|
||||
editorScope.$destroy();
|
||||
lastEditView = null;
|
||||
editorScope = null;
|
||||
elem.removeClass('dash-edit-view--open');
|
||||
|
||||
if (!hideToShowOtherView) {
|
||||
setTimeout(function() {
|
||||
elem.empty();
|
||||
}, 250);
|
||||
}
|
||||
|
||||
if (options.editview) {
|
||||
var urlParams = $location.search();
|
||||
if (options.editview === urlParams.editview) {
|
||||
delete urlParams.editview;
|
||||
|
||||
// even though we always are in apply phase here
|
||||
// some angular bug is causing location search updates to
|
||||
// not happen always so this is a hack fix or that
|
||||
setTimeout(function() {
|
||||
$rootScope.$apply(function() {
|
||||
$location.search(urlParams);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (options.isModal) {
|
||||
modalScope = $rootScope.$new();
|
||||
modalScope.$on("$destroy", function() {
|
||||
editorScope.dismiss();
|
||||
});
|
||||
|
||||
$rootScope.appEvent('show-modal', {
|
||||
templateHtml: options.html,
|
||||
scope: modalScope,
|
||||
backdrop: 'static',
|
||||
modalClass: options.modalClass,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var view;
|
||||
if (options.src) {
|
||||
view = angular.element(document.createElement('div'));
|
||||
view.html('<div class="tabbed-view" ng-include="' + "'" + options.src + "'" + '"></div>');
|
||||
} else {
|
||||
view = angular.element(document.createElement('div'));
|
||||
view.addClass('tabbed-view');
|
||||
view.html(options.html);
|
||||
}
|
||||
|
||||
$compile(view)(editorScope);
|
||||
|
||||
setTimeout(function() {
|
||||
elem.empty();
|
||||
elem.append(view);
|
||||
setTimeout(function() {
|
||||
elem.addClass('dash-edit-view--open');
|
||||
}, 10);
|
||||
}, 10);
|
||||
}
|
||||
|
||||
scope.$watch("ctrl.dashboardViewState.state.editview", function(newValue, oldValue) {
|
||||
if (newValue) {
|
||||
showEditorPane(null, {editview: newValue});
|
||||
} else if (oldValue) {
|
||||
if (lastEditView === oldValue) {
|
||||
hideEditorPane();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
scope.$on("$destroy", hideEditorPane);
|
||||
|
||||
scope.onAppEvent('hide-dash-editor', function() {
|
||||
hideEditorPane(false);
|
||||
});
|
||||
|
||||
scope.onAppEvent('show-dash-editor', showEditorPane);
|
||||
|
||||
scope.onAppEvent('panel-fullscreen-enter', function() {
|
||||
scope.appEvent('hide-dash-editor');
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ export class BridgeSrv {
|
||||
private fullPageReloadRoutes;
|
||||
|
||||
/** @ngInject */
|
||||
constructor(private $location, private $timeout, private $window, private $rootScope) {
|
||||
constructor(private $location, private $timeout, private $window, private $rootScope, private $route) {
|
||||
this.appSubUrl = config.appSubUrl;
|
||||
this.fullPageReloadRoutes = ['/logout'];
|
||||
}
|
||||
@@ -29,14 +29,14 @@ export class BridgeSrv {
|
||||
this.$rootScope.$on('$routeUpdate', (evt, data) => {
|
||||
let angularUrl = this.$location.url();
|
||||
if (store.view.currentUrl !== angularUrl) {
|
||||
store.view.updatePathAndQuery(this.$location.path(), this.$location.search());
|
||||
store.view.updatePathAndQuery(this.$location.path(), this.$location.search(), this.$route.current.params);
|
||||
}
|
||||
});
|
||||
|
||||
this.$rootScope.$on('$routeChangeSuccess', (evt, data) => {
|
||||
let angularUrl = this.$location.url();
|
||||
if (store.view.currentUrl !== angularUrl) {
|
||||
store.view.updatePathAndQuery(this.$location.path(), this.$location.search());
|
||||
store.view.updatePathAndQuery(this.$location.path(), this.$location.search(), this.$route.current.params);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ describe('BridgeSrv', () => {
|
||||
let searchSrv;
|
||||
|
||||
beforeEach(() => {
|
||||
searchSrv = new BridgeSrv(null, null, null, null);
|
||||
searchSrv = new BridgeSrv(null, null, null, null, null);
|
||||
});
|
||||
|
||||
describe('With /subUrl as appSubUrl', () => {
|
||||
|
||||
@@ -49,7 +49,7 @@ describe('ManageDashboards', () => {
|
||||
},
|
||||
];
|
||||
ctrl = createCtrlWithStubs(response);
|
||||
return ctrl.getDashboards();
|
||||
return ctrl.refreshList();
|
||||
});
|
||||
|
||||
it('should set checked to false on all sections and children', () => {
|
||||
@@ -88,7 +88,7 @@ describe('ManageDashboards', () => {
|
||||
];
|
||||
ctrl = createCtrlWithStubs(response);
|
||||
ctrl.folderId = 410;
|
||||
return ctrl.getDashboards();
|
||||
return ctrl.refreshList();
|
||||
});
|
||||
|
||||
it('should set hide header to true on section', () => {
|
||||
@@ -137,7 +137,7 @@ describe('ManageDashboards', () => {
|
||||
ctrl.canMove = true;
|
||||
ctrl.canDelete = true;
|
||||
ctrl.selectAllChecked = true;
|
||||
return ctrl.getDashboards();
|
||||
return ctrl.refreshList();
|
||||
});
|
||||
|
||||
it('should set checked to false on all sections and children', () => {
|
||||
@@ -567,5 +567,5 @@ function createCtrlWithStubs(searchResponse: any, tags?: any) {
|
||||
},
|
||||
};
|
||||
|
||||
return new ManageDashboardsCtrl({}, { getNav: () => {} }, <SearchSrv>searchSrvStub);
|
||||
return new ManageDashboardsCtrl({}, { getNav: () => {} }, <SearchSrv>searchSrvStub, { isEditor: true });
|
||||
}
|
||||
|
||||
@@ -12,7 +12,11 @@ export function toUrlParams(a) {
|
||||
|
||||
let add = function(k, v) {
|
||||
v = typeof v === 'function' ? v() : v === null ? '' : v === undefined ? '' : v;
|
||||
s[s.length] = encodeURIComponent(k) + '=' + encodeURIComponent(v);
|
||||
if (typeof v !== 'boolean') {
|
||||
s[s.length] = encodeURIComponent(k) + '=' + encodeURIComponent(v);
|
||||
} else {
|
||||
s[s.length] = encodeURIComponent(k);
|
||||
}
|
||||
};
|
||||
|
||||
let buildParams = function(prefix, obj) {
|
||||
|
||||
Reference in New Issue
Block a user