mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Merge branch 'master' into mssql_datasource
This commit is contained in:
37
public/app/core/components/Animations/SlideDown.tsx
Normal file
37
public/app/core/components/Animations/SlideDown.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import Transition from 'react-transition-group/Transition';
|
||||
|
||||
const defaultMaxHeight = '200px'; // When animating using max-height we need to use a static value.
|
||||
// If this is not enough, pass in <SlideDown maxHeight="....
|
||||
const defaultDuration = 200;
|
||||
const defaultStyle = {
|
||||
transition: `max-height ${defaultDuration}ms ease-in-out`,
|
||||
overflow: 'hidden',
|
||||
};
|
||||
|
||||
export default ({ children, in: inProp, maxHeight = defaultMaxHeight }) => {
|
||||
// There are 4 main states a Transition can be in:
|
||||
// ENTERING, ENTERED, EXITING, EXITED
|
||||
// https://reactcommunity.org/react-transition-group/
|
||||
const transitionStyles = {
|
||||
exited: { maxHeight: 0 },
|
||||
entering: { maxHeight: maxHeight },
|
||||
entered: { maxHeight: maxHeight, overflow: 'visible' },
|
||||
exiting: { maxHeight: 0 },
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition in={inProp} timeout={defaultDuration}>
|
||||
{state => (
|
||||
<div
|
||||
style={{
|
||||
...defaultStyle,
|
||||
...transitionStyles[state],
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</Transition>
|
||||
);
|
||||
};
|
||||
@@ -3,19 +3,18 @@ import renderer from 'react-test-renderer';
|
||||
import EmptyListCTA from './EmptyListCTA';
|
||||
|
||||
const model = {
|
||||
title: 'Title',
|
||||
buttonIcon: 'ga css class',
|
||||
buttonLink: 'http://url/to/destination',
|
||||
buttonTitle: 'Click me',
|
||||
proTip: 'This is a tip',
|
||||
proTipLink: 'http://url/to/tip/destination',
|
||||
proTipLinkTitle: 'Learn more',
|
||||
proTipTarget: '_blank'
|
||||
title: 'Title',
|
||||
buttonIcon: 'ga css class',
|
||||
buttonLink: 'http://url/to/destination',
|
||||
buttonTitle: 'Click me',
|
||||
proTip: 'This is a tip',
|
||||
proTipLink: 'http://url/to/tip/destination',
|
||||
proTipLinkTitle: 'Learn more',
|
||||
proTipTarget: '_blank',
|
||||
};
|
||||
|
||||
describe('CollorPalette', () => {
|
||||
|
||||
it('renders correctly', () => {
|
||||
describe('EmptyListCTA', () => {
|
||||
it('renders correctly', () => {
|
||||
const tree = renderer.create(<EmptyListCTA model={model} />).toJSON();
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`CollorPalette renders correctly 1`] = `
|
||||
exports[`EmptyListCTA renders correctly 1`] = `
|
||||
<div
|
||||
className="empty-list-cta"
|
||||
>
|
||||
|
||||
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,61 +1,15 @@
|
||||
import React from "react";
|
||||
import { NavModel, NavModelItem } from "../../nav_model_srv";
|
||||
import classNames from "classnames";
|
||||
import appEvents from "app/core/app_events";
|
||||
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;
|
||||
});
|
||||
@@ -63,40 +17,90 @@ function SelectNav({
|
||||
const gotoUrl = evt => {
|
||||
var element = evt.target;
|
||||
var url = element.options[element.selectedIndex].value;
|
||||
appEvents.emit("location-change", { href: url });
|
||||
appEvents.emit('location-change', { href: url });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`gf-form-select-wrapper width-20 ${customCss}`}>
|
||||
<label
|
||||
className={`gf-form-select-icon ${defaultSelectedItem.icon}`}
|
||||
htmlFor="page-header-select-nav"
|
||||
/>
|
||||
<label className={`gf-form-select-icon ${defaultSelectedItem.icon}`} htmlFor="page-header-select-nav" />
|
||||
{/* 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 }) {
|
||||
const Tabs = ({ main, customCss }: { main: NavModelItem; customCss: string }) => {
|
||||
return (
|
||||
<ul className={`gf-tabs ${customCss}`}>{main.children.map(TabItem)}</ul>
|
||||
);
|
||||
}
|
||||
<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];
|
||||
@@ -110,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) {
|
||||
@@ -122,16 +128,8 @@ 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>
|
||||
)}
|
||||
{main.subTitle && (
|
||||
<div className="page-header__sub-title">{main.subTitle}</div>
|
||||
)}
|
||||
{this.renderTitle(main.text, main.breadcrumbs)}
|
||||
{main.subTitle && <div className="page-header__sub-title">{main.subTitle}</div>}
|
||||
{main.subType && (
|
||||
<div className="page-header__stamps">
|
||||
<i className={main.subType.icon} />
|
||||
@@ -150,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,90 @@
|
||||
import React from 'react';
|
||||
import AddPermissions from './AddPermissions';
|
||||
import { RootStore } from 'app/stores/RootStore/RootStore';
|
||||
import { backendSrv } from 'test/mocks/common';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
describe('AddPermissions', () => {
|
||||
let wrapper;
|
||||
let store;
|
||||
let instance;
|
||||
|
||||
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' },
|
||||
])
|
||||
);
|
||||
|
||||
backendSrv.post = jest.fn(() => Promise.resolve({}));
|
||||
|
||||
store = RootStore.create(
|
||||
{},
|
||||
{
|
||||
backendSrv: backendSrv,
|
||||
}
|
||||
);
|
||||
|
||||
wrapper = shallow(<AddPermissions permissions={store.permissions} backendSrv={backendSrv} />);
|
||||
instance = wrapper.instance();
|
||||
return store.permissions.load(1, true, false);
|
||||
});
|
||||
|
||||
describe('when permission for a user is added', () => {
|
||||
it('should save permission to db', () => {
|
||||
const evt = {
|
||||
target: {
|
||||
value: 'User',
|
||||
},
|
||||
};
|
||||
const userItem = {
|
||||
id: 2,
|
||||
login: 'user2',
|
||||
};
|
||||
|
||||
instance.typeChanged(evt);
|
||||
instance.userPicked(userItem);
|
||||
|
||||
wrapper.update();
|
||||
|
||||
expect(wrapper.find('[data-save-permission]').prop('disabled')).toBe(false);
|
||||
|
||||
wrapper.find('form').simulate('submit', { preventDefault() {} });
|
||||
|
||||
expect(backendSrv.post.mock.calls.length).toBe(1);
|
||||
expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/permissions');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when permission for team is added', () => {
|
||||
it('should save permission to db', () => {
|
||||
const evt = {
|
||||
target: {
|
||||
value: 'Group',
|
||||
},
|
||||
};
|
||||
|
||||
const teamItem = {
|
||||
id: 2,
|
||||
name: 'ug1',
|
||||
};
|
||||
|
||||
instance.typeChanged(evt);
|
||||
instance.teamPicked(teamItem);
|
||||
|
||||
wrapper.update();
|
||||
|
||||
expect(wrapper.find('[data-save-permission]').prop('disabled')).toBe(false);
|
||||
|
||||
wrapper.find('form').simulate('submit', { preventDefault() {} });
|
||||
|
||||
expect(backendSrv.post.mock.calls.length).toBe(1);
|
||||
expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/permissions');
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
backendSrv.post.mockClear();
|
||||
});
|
||||
});
|
||||
143
public/app/core/components/Permissions/AddPermissions.tsx
Normal file
143
public/app/core/components/Permissions/AddPermissions.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import React, { Component } from 'react';
|
||||
import { observer } from 'mobx-react';
|
||||
import { aclTypes } from 'app/stores/PermissionsStore/PermissionsStore';
|
||||
import UserPicker, { User } from 'app/core/components/Picker/UserPicker';
|
||||
import TeamPicker, { Team } from 'app/core/components/Picker/TeamPicker';
|
||||
import DescriptionPicker, { OptionWithDescription } from 'app/core/components/Picker/DescriptionPicker';
|
||||
import { permissionOptions } from 'app/stores/PermissionsStore/PermissionsStore';
|
||||
|
||||
export interface IProps {
|
||||
permissions: any;
|
||||
backendSrv: any;
|
||||
}
|
||||
@observer
|
||||
class AddPermissions extends Component<IProps, any> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.userPicked = this.userPicked.bind(this);
|
||||
this.teamPicked = this.teamPicked.bind(this);
|
||||
this.permissionPicked = this.permissionPicked.bind(this);
|
||||
this.typeChanged = this.typeChanged.bind(this);
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
const { permissions } = this.props;
|
||||
permissions.resetNewType();
|
||||
}
|
||||
|
||||
typeChanged(evt) {
|
||||
const { value } = evt.target;
|
||||
const { permissions } = this.props;
|
||||
|
||||
permissions.setNewType(value);
|
||||
}
|
||||
|
||||
userPicked(user: User) {
|
||||
const { permissions } = this.props;
|
||||
if (!user) {
|
||||
permissions.newItem.setUser(null, null);
|
||||
return;
|
||||
}
|
||||
return permissions.newItem.setUser(user.id, user.login);
|
||||
}
|
||||
|
||||
teamPicked(team: Team) {
|
||||
const { permissions } = this.props;
|
||||
if (!team) {
|
||||
permissions.newItem.setTeam(null, null);
|
||||
return;
|
||||
}
|
||||
return permissions.newItem.setTeam(team.id, team.name);
|
||||
}
|
||||
|
||||
permissionPicked(permission: OptionWithDescription) {
|
||||
const { permissions } = this.props;
|
||||
return permissions.newItem.setPermission(permission.value);
|
||||
}
|
||||
|
||||
resetNewType() {
|
||||
const { permissions } = this.props;
|
||||
return permissions.resetNewType();
|
||||
}
|
||||
|
||||
handleSubmit(evt) {
|
||||
evt.preventDefault();
|
||||
const { permissions } = this.props;
|
||||
permissions.addStoreItem();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { permissions, backendSrv } = this.props;
|
||||
const newItem = permissions.newItem;
|
||||
const pickerClassName = 'width-20';
|
||||
|
||||
const isValid = newItem.isValid();
|
||||
|
||||
return (
|
||||
<div className="gf-form-inline cta-form">
|
||||
<button className="cta-form__close btn btn-transparent" onClick={permissions.hideAddPermissions}>
|
||||
<i className="fa fa-close" />
|
||||
</button>
|
||||
<form name="addPermission" onSubmit={this.handleSubmit}>
|
||||
<h6>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={newItem.type} onChange={this.typeChanged}>
|
||||
{aclTypes.map((option, idx) => {
|
||||
return (
|
||||
<option key={idx} value={option.value}>
|
||||
{option.text}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{newItem.type === 'User' ? (
|
||||
<div className="gf-form">
|
||||
<UserPicker
|
||||
backendSrv={backendSrv}
|
||||
handlePicked={this.userPicked}
|
||||
value={newItem.userId}
|
||||
className={pickerClassName}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{newItem.type === 'Group' ? (
|
||||
<div className="gf-form">
|
||||
<TeamPicker
|
||||
backendSrv={backendSrv}
|
||||
handlePicked={this.teamPicked}
|
||||
value={newItem.teamId}
|
||||
className={pickerClassName}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="gf-form">
|
||||
<DescriptionPicker
|
||||
optionsWithDesc={permissionOptions}
|
||||
handlePicked={this.permissionPicked}
|
||||
value={newItem.permission}
|
||||
disabled={false}
|
||||
className={'gf-form-input--form-dropdown-right'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="gf-form">
|
||||
<button data-save-permission className="btn btn-success" type="submit" disabled={!isValid}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default AddPermissions;
|
||||
@@ -0,0 +1,70 @@
|
||||
import React, { Component } from 'react';
|
||||
import { observer } from 'mobx-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';
|
||||
import AddPermissions from 'app/core/components/Permissions/AddPermissions';
|
||||
import SlideDown from 'app/core/components/Animations/SlideDown';
|
||||
import { FolderInfo } from './FolderInfo';
|
||||
|
||||
export interface IProps {
|
||||
dashboardId: number;
|
||||
folder?: FolderInfo;
|
||||
backendSrv: any;
|
||||
}
|
||||
@observer
|
||||
class DashboardPermissions extends Component<IProps, any> {
|
||||
permissions: any;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.handleAddPermission = this.handleAddPermission.bind(this);
|
||||
this.permissions = store.permissions;
|
||||
}
|
||||
|
||||
handleAddPermission() {
|
||||
this.permissions.toggleAddPermissions();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.permissions.hideAddPermissions();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { dashboardId, folder, backendSrv } = this.props;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="dashboard-settings__header">
|
||||
<div className="page-action-bar">
|
||||
<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 className="page-action-bar__spacer" />
|
||||
<button
|
||||
className="btn btn-success pull-right"
|
||||
onClick={this.handleAddPermission}
|
||||
disabled={this.permissions.isAddPermissionsVisible}
|
||||
>
|
||||
<i className="fa fa-plus" /> Add Permission
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<SlideDown in={this.permissions.isAddPermissionsVisible}>
|
||||
<AddPermissions permissions={this.permissions} backendSrv={backendSrv} />
|
||||
</SlideDown>
|
||||
<Permissions
|
||||
permissions={this.permissions}
|
||||
isFolder={false}
|
||||
dashboardId={dashboardId}
|
||||
folderInfo={folder}
|
||||
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 {
|
||||
id: number;
|
||||
title: string;
|
||||
url: string;
|
||||
}
|
||||
92
public/app/core/components/Permissions/Permissions.tsx
Normal file
92
public/app/core/components/Permissions/Permissions.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import React, { Component } from 'react';
|
||||
import PermissionsList from './PermissionsList';
|
||||
import { observer } from 'mobx-react';
|
||||
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.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);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { permissions, folderInfo } = this.props;
|
||||
|
||||
return (
|
||||
<div className="gf-form-group">
|
||||
<PermissionsList
|
||||
permissions={permissions.items}
|
||||
removeItem={this.removeItem}
|
||||
permissionChanged={this.permissionChanged}
|
||||
fetching={permissions.fetching}
|
||||
folderInfo={folderInfo}
|
||||
/>
|
||||
</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={`${folderInfo.url}/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;
|
||||
22
public/app/core/components/Picker/PickerOption.jest.tsx
Normal file
22
public/app/core/components/Picker/PickerOption.jest.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import renderer from 'react-test-renderer';
|
||||
import PickerOption from './PickerOption';
|
||||
|
||||
const model = {
|
||||
onSelect: () => {},
|
||||
onFocus: () => {},
|
||||
isFocused: () => {},
|
||||
option: {
|
||||
title: 'Model title',
|
||||
avatarUrl: 'url/to/avatar',
|
||||
label: 'User picker label',
|
||||
},
|
||||
className: 'class-for-user-picker',
|
||||
};
|
||||
|
||||
describe('PickerOption', () => {
|
||||
it('renders correctly', () => {
|
||||
const tree = renderer.create(<PickerOption {...model} />).toJSON();
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
54
public/app/core/components/Picker/PickerOption.tsx
Normal file
54
public/app/core/components/Picker/PickerOption.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React, { Component } from 'react';
|
||||
|
||||
export interface IProps {
|
||||
onSelect: any;
|
||||
onFocus: any;
|
||||
option: any;
|
||||
isFocused: any;
|
||||
className: any;
|
||||
}
|
||||
|
||||
class UserPickerOption 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={`user-picker-option__button btn btn-link ${className}`}
|
||||
>
|
||||
<img src={option.avatarUrl} alt={option.label} className="user-picker-option__avatar" />
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default UserPickerOption;
|
||||
19
public/app/core/components/Picker/TeamPicker.jest.tsx
Normal file
19
public/app/core/components/Picker/TeamPicker.jest.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import renderer from 'react-test-renderer';
|
||||
import TeamPicker from './TeamPicker';
|
||||
|
||||
const model = {
|
||||
backendSrv: {
|
||||
get: () => {
|
||||
return new Promise((resolve, reject) => {});
|
||||
},
|
||||
},
|
||||
handlePicked: () => {},
|
||||
};
|
||||
|
||||
describe('TeamPicker', () => {
|
||||
it('renders correctly', () => {
|
||||
const tree = renderer.create(<TeamPicker {...model} />).toJSON();
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
84
public/app/core/components/Picker/TeamPicker.tsx
Normal file
84
public/app/core/components/Picker/TeamPicker.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
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;
|
||||
value?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
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, value, className } = 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 teams found"
|
||||
onChange={handlePicked}
|
||||
className={`gf-form-input gf-form-input--form-dropdown ${className || ''}`}
|
||||
optionComponent={PickerOption}
|
||||
placeholder="Choose"
|
||||
value={value}
|
||||
autosize={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withPicker(TeamPicker);
|
||||
19
public/app/core/components/Picker/UserPicker.jest.tsx
Normal file
19
public/app/core/components/Picker/UserPicker.jest.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import renderer from 'react-test-renderer';
|
||||
import UserPicker from './UserPicker';
|
||||
|
||||
const model = {
|
||||
backendSrv: {
|
||||
get: () => {
|
||||
return new Promise((resolve, reject) => {});
|
||||
},
|
||||
},
|
||||
handlePicked: () => {},
|
||||
};
|
||||
|
||||
describe('UserPicker', () => {
|
||||
it('renders correctly', () => {
|
||||
const tree = renderer.create(<UserPicker {...model} />).toJSON();
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
82
public/app/core/components/Picker/UserPicker.tsx
Normal file
82
public/app/core/components/Picker/UserPicker.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
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;
|
||||
value?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
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: true,
|
||||
});
|
||||
}
|
||||
|
||||
search(query?: string) {
|
||||
const { toggleLoading, backendSrv } = this.props;
|
||||
|
||||
toggleLoading(true);
|
||||
return backendSrv.get(`/api/org/users?query=${query}&limit=10`).then(result => {
|
||||
const users = result.map(user => {
|
||||
return {
|
||||
id: user.userId,
|
||||
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, value, className } = 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={`gf-form-input gf-form-input--form-dropdown ${className || ''}`}
|
||||
optionComponent={PickerOption}
|
||||
placeholder="Choose"
|
||||
value={value}
|
||||
autosize={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withPicker(UserPicker);
|
||||
@@ -0,0 +1,17 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`PickerOption renders correctly 1`] = `
|
||||
<button
|
||||
className="user-picker-option__button btn btn-link class-for-user-picker"
|
||||
onMouseDown={[Function]}
|
||||
onMouseEnter={[Function]}
|
||||
onMouseMove={[Function]}
|
||||
title="Model title"
|
||||
>
|
||||
<img
|
||||
alt="User picker label"
|
||||
className="user-picker-option__avatar"
|
||||
src="url/to/avatar"
|
||||
/>
|
||||
</button>
|
||||
`;
|
||||
@@ -0,0 +1,98 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`TeamPicker renders correctly 1`] = `
|
||||
<div
|
||||
className="user-picker"
|
||||
>
|
||||
<div
|
||||
className="Select gf-form-input gf-form-input--form-dropdown is-clearable is-loading is-searchable Select--single"
|
||||
style={undefined}
|
||||
>
|
||||
<div
|
||||
className="Select-control"
|
||||
onKeyDown={[Function]}
|
||||
onMouseDown={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
onTouchMove={[Function]}
|
||||
onTouchStart={[Function]}
|
||||
style={undefined}
|
||||
>
|
||||
<span
|
||||
className="Select-multi-value-wrapper"
|
||||
id="react-select-2--value"
|
||||
>
|
||||
<div
|
||||
className="Select-placeholder"
|
||||
>
|
||||
Loading...
|
||||
</div>
|
||||
<div
|
||||
className="Select-input"
|
||||
style={
|
||||
Object {
|
||||
"display": "inline-block",
|
||||
}
|
||||
}
|
||||
>
|
||||
<input
|
||||
aria-activedescendant="react-select-2--value"
|
||||
aria-describedby={undefined}
|
||||
aria-expanded="false"
|
||||
aria-haspopup="false"
|
||||
aria-label={undefined}
|
||||
aria-labelledby={undefined}
|
||||
aria-owns=""
|
||||
className={undefined}
|
||||
id={undefined}
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onFocus={[Function]}
|
||||
required={false}
|
||||
role="combobox"
|
||||
style={
|
||||
Object {
|
||||
"boxSizing": "content-box",
|
||||
"width": "5px",
|
||||
}
|
||||
}
|
||||
tabIndex={undefined}
|
||||
value=""
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"height": 0,
|
||||
"left": 0,
|
||||
"overflow": "scroll",
|
||||
"position": "absolute",
|
||||
"top": 0,
|
||||
"visibility": "hidden",
|
||||
"whiteSpace": "pre",
|
||||
}
|
||||
}
|
||||
>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Select-loading-zone"
|
||||
>
|
||||
<span
|
||||
className="Select-loading"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className="Select-arrow-zone"
|
||||
onMouseDown={[Function]}
|
||||
>
|
||||
<span
|
||||
className="Select-arrow"
|
||||
onMouseDown={[Function]}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,98 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`UserPicker renders correctly 1`] = `
|
||||
<div
|
||||
className="user-picker"
|
||||
>
|
||||
<div
|
||||
className="Select gf-form-input gf-form-input--form-dropdown is-clearable is-loading is-searchable Select--single"
|
||||
style={undefined}
|
||||
>
|
||||
<div
|
||||
className="Select-control"
|
||||
onKeyDown={[Function]}
|
||||
onMouseDown={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
onTouchMove={[Function]}
|
||||
onTouchStart={[Function]}
|
||||
style={undefined}
|
||||
>
|
||||
<span
|
||||
className="Select-multi-value-wrapper"
|
||||
id="react-select-2--value"
|
||||
>
|
||||
<div
|
||||
className="Select-placeholder"
|
||||
>
|
||||
Loading...
|
||||
</div>
|
||||
<div
|
||||
className="Select-input"
|
||||
style={
|
||||
Object {
|
||||
"display": "inline-block",
|
||||
}
|
||||
}
|
||||
>
|
||||
<input
|
||||
aria-activedescendant="react-select-2--value"
|
||||
aria-describedby={undefined}
|
||||
aria-expanded="false"
|
||||
aria-haspopup="false"
|
||||
aria-label={undefined}
|
||||
aria-labelledby={undefined}
|
||||
aria-owns=""
|
||||
className={undefined}
|
||||
id={undefined}
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onFocus={[Function]}
|
||||
required={false}
|
||||
role="combobox"
|
||||
style={
|
||||
Object {
|
||||
"boxSizing": "content-box",
|
||||
"width": "5px",
|
||||
}
|
||||
}
|
||||
tabIndex={undefined}
|
||||
value=""
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"height": 0,
|
||||
"left": 0,
|
||||
"overflow": "scroll",
|
||||
"position": "absolute",
|
||||
"top": 0,
|
||||
"visibility": "hidden",
|
||||
"whiteSpace": "pre",
|
||||
}
|
||||
}
|
||||
>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Select-loading-zone"
|
||||
>
|
||||
<span
|
||||
className="Select-loading"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className="Select-arrow-zone"
|
||||
onMouseDown={[Function]}
|
||||
>
|
||||
<span
|
||||
className="Select-arrow"
|
||||
onMouseDown={[Function]}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
34
public/app/core/components/Picker/withPicker.tsx
Normal file
34
public/app/core/components/Picker/withPicker.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React, { Component } from 'react';
|
||||
|
||||
export interface IProps {
|
||||
backendSrv: any;
|
||||
handlePicked: (data) => void;
|
||||
value?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
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} />;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -7,7 +7,6 @@ export interface Props {
|
||||
}
|
||||
|
||||
export default class ScrollBar extends React.Component<Props, any> {
|
||||
|
||||
private container: any;
|
||||
private ps: PerfectScrollbar;
|
||||
|
||||
@@ -16,7 +15,9 @@ export default class ScrollBar extends React.Component<Props, any> {
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.ps = new PerfectScrollbar(this.container);
|
||||
this.ps = new PerfectScrollbar(this.container, {
|
||||
wheelPropagation: true,
|
||||
});
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
|
||||
37
public/app/core/components/TagFilter/TagBadge.tsx
Normal file
37
public/app/core/components/TagFilter/TagBadge.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import tags from 'app/core/utils/tags';
|
||||
|
||||
export interface IProps {
|
||||
label: string;
|
||||
removeIcon: boolean;
|
||||
count: number;
|
||||
onClick: any;
|
||||
}
|
||||
|
||||
export class TagBadge extends React.Component<IProps, any> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.onClick = this.onClick.bind(this);
|
||||
}
|
||||
|
||||
onClick(event) {
|
||||
this.props.onClick(event);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { label, removeIcon, count } = this.props;
|
||||
const { color, borderColor } = tags.getTagColorsFromName(label);
|
||||
const tagStyle = {
|
||||
backgroundColor: color,
|
||||
borderColor: borderColor,
|
||||
};
|
||||
const countLabel = count !== 0 && <span className="tag-count-label">{`(${count})`}</span>;
|
||||
|
||||
return (
|
||||
<span className={`label label-tag`} onClick={this.onClick} style={tagStyle}>
|
||||
{removeIcon && <i className="fa fa-remove" />}
|
||||
{label} {countLabel}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
69
public/app/core/components/TagFilter/TagFilter.tsx
Normal file
69
public/app/core/components/TagFilter/TagFilter.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import _ from 'lodash';
|
||||
import React from 'react';
|
||||
import { Async } from 'react-select';
|
||||
import { TagValue } from './TagValue';
|
||||
import { TagOption } from './TagOption';
|
||||
|
||||
export interface IProps {
|
||||
tags: string[];
|
||||
tagOptions: () => any;
|
||||
onSelect: (tag: string) => void;
|
||||
}
|
||||
|
||||
export class TagFilter extends React.Component<IProps, any> {
|
||||
inlineTags: boolean;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.searchTags = this.searchTags.bind(this);
|
||||
this.onChange = this.onChange.bind(this);
|
||||
this.onTagRemove = this.onTagRemove.bind(this);
|
||||
}
|
||||
|
||||
searchTags(query) {
|
||||
return this.props.tagOptions().then(options => {
|
||||
const tags = _.map(options, tagOption => {
|
||||
return { value: tagOption.term, label: tagOption.term, count: tagOption.count };
|
||||
});
|
||||
return { options: tags };
|
||||
});
|
||||
}
|
||||
|
||||
onChange(newTags) {
|
||||
this.props.onSelect(newTags);
|
||||
}
|
||||
|
||||
onTagRemove(tag) {
|
||||
let newTags = _.without(this.props.tags, tag.label);
|
||||
newTags = _.map(newTags, tag => {
|
||||
return { value: tag };
|
||||
});
|
||||
this.props.onSelect(newTags);
|
||||
}
|
||||
|
||||
render() {
|
||||
let selectOptions = {
|
||||
loadOptions: this.searchTags,
|
||||
onChange: this.onChange,
|
||||
value: this.props.tags,
|
||||
multi: true,
|
||||
className: 'gf-form-input gf-form-input--form-dropdown',
|
||||
placeholder: 'Tags',
|
||||
loadingPlaceholder: 'Loading...',
|
||||
noResultsText: 'No tags found',
|
||||
optionComponent: TagOption,
|
||||
};
|
||||
|
||||
selectOptions['valueComponent'] = TagValue;
|
||||
|
||||
return (
|
||||
<div className="gf-form gf-form--has-input-icon gf-form--grow">
|
||||
<div className="tag-filter">
|
||||
<Async {...selectOptions} />
|
||||
</div>
|
||||
<i className="gf-form-input-icon fa fa-tag" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
52
public/app/core/components/TagFilter/TagOption.tsx
Normal file
52
public/app/core/components/TagFilter/TagOption.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import { TagBadge } from './TagBadge';
|
||||
|
||||
export interface IProps {
|
||||
onSelect: any;
|
||||
onFocus: any;
|
||||
option: any;
|
||||
isFocused: any;
|
||||
className: any;
|
||||
}
|
||||
|
||||
export class TagOption extends React.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, className } = this.props;
|
||||
|
||||
return (
|
||||
<button
|
||||
onMouseDown={this.handleMouseDown}
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
onMouseMove={this.handleMouseMove}
|
||||
title={option.title}
|
||||
className={`tag-filter-option btn btn-link ${className || ''}`}
|
||||
>
|
||||
<TagBadge label={option.label} removeIcon={false} count={option.count} onClick={this.handleMouseDown} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
26
public/app/core/components/TagFilter/TagValue.tsx
Normal file
26
public/app/core/components/TagFilter/TagValue.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import { TagBadge } from './TagBadge';
|
||||
|
||||
export interface IProps {
|
||||
value: any;
|
||||
className: any;
|
||||
onClick: any;
|
||||
onRemove: any;
|
||||
}
|
||||
|
||||
export class TagValue extends React.Component<IProps, any> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.onClick = this.onClick.bind(this);
|
||||
}
|
||||
|
||||
onClick(event) {
|
||||
this.props.onRemove(this.props.value, event);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { value } = this.props;
|
||||
|
||||
return <TagBadge label={value.label} removeIcon={true} count={0} onClick={this.onClick} />;
|
||||
}
|
||||
}
|
||||
16
public/app/core/components/Tooltip/Popover.jest.tsx
Normal file
16
public/app/core/components/Tooltip/Popover.jest.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import renderer from 'react-test-renderer';
|
||||
import Popover from './Popover';
|
||||
|
||||
describe('Popover', () => {
|
||||
it('renders correctly', () => {
|
||||
const tree = renderer
|
||||
.create(
|
||||
<Popover className="test-class" placement="auto" content="Popover text">
|
||||
<button>Button with Popover</button>
|
||||
</Popover>
|
||||
)
|
||||
.toJSON();
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
34
public/app/core/components/Tooltip/Popover.tsx
Normal file
34
public/app/core/components/Tooltip/Popover.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import withTooltip from './withTooltip';
|
||||
import { Target } from 'react-popper';
|
||||
|
||||
interface IPopoverProps {
|
||||
tooltipSetState: (prevState: object) => void;
|
||||
}
|
||||
|
||||
class Popover extends React.Component<IPopoverProps, any> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.toggleTooltip = this.toggleTooltip.bind(this);
|
||||
}
|
||||
|
||||
toggleTooltip() {
|
||||
const { tooltipSetState } = this.props;
|
||||
tooltipSetState(prevState => {
|
||||
return {
|
||||
...prevState,
|
||||
show: !prevState.show,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Target className="popper__target" onClick={this.toggleTooltip}>
|
||||
{this.props.children}
|
||||
</Target>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withTooltip(Popover);
|
||||
16
public/app/core/components/Tooltip/Tooltip.jest.tsx
Normal file
16
public/app/core/components/Tooltip/Tooltip.jest.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import renderer from 'react-test-renderer';
|
||||
import Tooltip from './Tooltip';
|
||||
|
||||
describe('Tooltip', () => {
|
||||
it('renders correctly', () => {
|
||||
const tree = renderer
|
||||
.create(
|
||||
<Tooltip className="test-class" placement="auto" content="Tooltip text">
|
||||
<a href="http://www.grafana.com">Link with tooltip</a>
|
||||
</Tooltip>
|
||||
)
|
||||
.toJSON();
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
45
public/app/core/components/Tooltip/Tooltip.tsx
Normal file
45
public/app/core/components/Tooltip/Tooltip.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import withTooltip from './withTooltip';
|
||||
import { Target } from 'react-popper';
|
||||
|
||||
interface ITooltipProps {
|
||||
tooltipSetState: (prevState: object) => void;
|
||||
}
|
||||
|
||||
class Tooltip extends React.Component<ITooltipProps, any> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.showTooltip = this.showTooltip.bind(this);
|
||||
this.hideTooltip = this.hideTooltip.bind(this);
|
||||
}
|
||||
|
||||
showTooltip() {
|
||||
const { tooltipSetState } = this.props;
|
||||
tooltipSetState(prevState => {
|
||||
return {
|
||||
...prevState,
|
||||
show: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
hideTooltip() {
|
||||
const { tooltipSetState } = this.props;
|
||||
tooltipSetState(prevState => {
|
||||
return {
|
||||
...prevState,
|
||||
show: false,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Target className="popper__target" onMouseOver={this.showTooltip} onMouseOut={this.hideTooltip}>
|
||||
{this.props.children}
|
||||
</Target>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withTooltip(Tooltip);
|
||||
@@ -0,0 +1,16 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Popover renders correctly 1`] = `
|
||||
<div
|
||||
className="popper__manager test-class"
|
||||
>
|
||||
<div
|
||||
className="popper__target"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<button>
|
||||
Button with Popover
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,19 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Tooltip renders correctly 1`] = `
|
||||
<div
|
||||
className="popper__manager test-class"
|
||||
>
|
||||
<div
|
||||
className="popper__target"
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
>
|
||||
<a
|
||||
href="http://www.grafana.com"
|
||||
>
|
||||
Link with tooltip
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
58
public/app/core/components/Tooltip/withTooltip.tsx
Normal file
58
public/app/core/components/Tooltip/withTooltip.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import { Manager, Popper, Arrow } from 'react-popper';
|
||||
|
||||
interface IwithTooltipProps {
|
||||
placement?: string;
|
||||
content: string | ((props: any) => JSX.Element);
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function withTooltip(WrappedComponent) {
|
||||
return class extends React.Component<IwithTooltipProps, any> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.setState = this.setState.bind(this);
|
||||
this.state = {
|
||||
placement: this.props.placement || 'auto',
|
||||
show: false,
|
||||
};
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (nextProps.placement && nextProps.placement !== this.state.placement) {
|
||||
this.setState(prevState => {
|
||||
return {
|
||||
...prevState,
|
||||
placement: nextProps.placement,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
renderContent(content) {
|
||||
if (typeof content === 'function') {
|
||||
// If it's a function we assume it's a React component
|
||||
const ReactComponent = content;
|
||||
return <ReactComponent />;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { content, className } = this.props;
|
||||
|
||||
return (
|
||||
<Manager className={`popper__manager ${className || ''}`}>
|
||||
<WrappedComponent {...this.props} tooltipSetState={this.setState} />
|
||||
{this.state.show ? (
|
||||
<Popper placement={this.state.placement} className="popper">
|
||||
{this.renderContent(content)}
|
||||
<Arrow className="popper__arrow" />
|
||||
</Popper>
|
||||
) : null}
|
||||
</Manager>
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import _ from 'lodash';
|
||||
import $ from 'jquery';
|
||||
import coreModule from '../../core_module';
|
||||
|
||||
function typeaheadMatcher(item) {
|
||||
var str = this.query;
|
||||
if (str === '') {
|
||||
return true;
|
||||
}
|
||||
if (str[0] === '/') {
|
||||
str = str.substring(1);
|
||||
}
|
||||
@@ -30,6 +32,9 @@ export class FormDropdownCtrl {
|
||||
getOptions: any;
|
||||
optionCache: any;
|
||||
lookupText: boolean;
|
||||
placeholder: any;
|
||||
startOpen: any;
|
||||
debounce: number;
|
||||
|
||||
/** @ngInject **/
|
||||
constructor(private $scope, $element, private $sce, private templateSrv, private $q) {
|
||||
@@ -47,6 +52,10 @@ export class FormDropdownCtrl {
|
||||
this.cssClasses = 'gf-form-input gf-form-input--dropdown ' + this.cssClass;
|
||||
}
|
||||
|
||||
if (this.placeholder) {
|
||||
this.inputElement.attr('placeholder', this.placeholder);
|
||||
}
|
||||
|
||||
this.inputElement.attr('data-provide', 'typeahead');
|
||||
this.inputElement.typeahead({
|
||||
source: this.typeaheadSource.bind(this),
|
||||
@@ -61,10 +70,13 @@ export class FormDropdownCtrl {
|
||||
var typeahead = this.inputElement.data('typeahead');
|
||||
typeahead.lookup = function() {
|
||||
this.query = this.$element.val() || '';
|
||||
var items = this.source(this.query, $.proxy(this.process, this));
|
||||
return items ? this.process(items) : items;
|
||||
this.source(this.query, this.process.bind(this));
|
||||
};
|
||||
|
||||
if (this.debounce) {
|
||||
typeahead.lookup = _.debounce(typeahead.lookup, 500, { leading: true });
|
||||
}
|
||||
|
||||
this.linkElement.keydown(evt => {
|
||||
// trigger typeahead on down arrow or enter key
|
||||
if (evt.keyCode === 40 || evt.keyCode === 13) {
|
||||
@@ -81,6 +93,10 @@ export class FormDropdownCtrl {
|
||||
});
|
||||
|
||||
this.inputElement.blur(this.inputBlur.bind(this));
|
||||
|
||||
if (this.startOpen) {
|
||||
setTimeout(this.open.bind(this), 0);
|
||||
}
|
||||
}
|
||||
|
||||
getOptionsInternal(query) {
|
||||
@@ -121,9 +137,9 @@ export class FormDropdownCtrl {
|
||||
});
|
||||
|
||||
// add custom values
|
||||
if (this.allowCustom) {
|
||||
if (this.allowCustom && this.text !== '') {
|
||||
if (_.indexOf(optionTexts, this.text) === -1) {
|
||||
options.unshift(this.text);
|
||||
optionTexts.unshift(this.text);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,10 +244,10 @@ const template = `
|
||||
style="display:none">
|
||||
</input>
|
||||
<a ng-class="ctrl.cssClasses"
|
||||
tabindex="1"
|
||||
ng-click="ctrl.open()"
|
||||
give-focus="ctrl.focus"
|
||||
ng-bind-html="ctrl.display">
|
||||
tabindex="1"
|
||||
ng-click="ctrl.open()"
|
||||
give-focus="ctrl.focus"
|
||||
ng-bind-html="ctrl.display || ' '">
|
||||
</a>
|
||||
`;
|
||||
|
||||
@@ -250,6 +266,9 @@ export function formDropdownDirective() {
|
||||
allowCustom: '@',
|
||||
labelMode: '@',
|
||||
lookupText: '@',
|
||||
placeholder: '@',
|
||||
startOpen: '@',
|
||||
debounce: '@',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
///<reference path="../../headers/common.d.ts" />
|
||||
|
||||
import coreModule from 'app/core/core_module';
|
||||
|
||||
const template = `
|
||||
|
||||
@@ -6,24 +6,29 @@ import coreModule from 'app/core/core_module';
|
||||
import { profiler } from 'app/core/profiler';
|
||||
import appEvents from 'app/core/app_events';
|
||||
import Drop from 'tether-drop';
|
||||
import { createStore } from 'app/stores/store';
|
||||
import colors from 'app/core/utils/colors';
|
||||
|
||||
export class GrafanaCtrl {
|
||||
/** @ngInject */
|
||||
constructor($scope, alertSrv, utilSrv, $rootScope, $controller, contextSrv, globalEventSrv) {
|
||||
constructor($scope, alertSrv, utilSrv, $rootScope, $controller, contextSrv, bridgeSrv, backendSrv) {
|
||||
createStore(backendSrv);
|
||||
|
||||
$scope.init = function() {
|
||||
$scope.contextSrv = contextSrv;
|
||||
|
||||
$rootScope.appSubUrl = config.appSubUrl;
|
||||
$scope.appSubUrl = config.appSubUrl;
|
||||
$scope._ = _;
|
||||
|
||||
profiler.init(config, $rootScope);
|
||||
alertSrv.init();
|
||||
utilSrv.init();
|
||||
globalEventSrv.init();
|
||||
bridgeSrv.init();
|
||||
|
||||
$scope.dashAlerts = alertSrv;
|
||||
};
|
||||
|
||||
$rootScope.colors = colors;
|
||||
|
||||
$scope.initDashboard = function(dashboardData, viewScope) {
|
||||
$scope.appEvent('dashboard-fetch-end', dashboardData);
|
||||
$controller('DashboardCtrl', { $scope: viewScope }).init(dashboardData);
|
||||
@@ -46,71 +51,12 @@ export class GrafanaCtrl {
|
||||
appEvents.emit(name, payload);
|
||||
};
|
||||
|
||||
$rootScope.colors = [
|
||||
'#7EB26D',
|
||||
'#EAB839',
|
||||
'#6ED0E0',
|
||||
'#EF843C',
|
||||
'#E24D42',
|
||||
'#1F78C1',
|
||||
'#BA43A9',
|
||||
'#705DA0',
|
||||
'#508642',
|
||||
'#CCA300',
|
||||
'#447EBC',
|
||||
'#C15C17',
|
||||
'#890F02',
|
||||
'#0A437C',
|
||||
'#6D1F62',
|
||||
'#584477',
|
||||
'#B7DBAB',
|
||||
'#F4D598',
|
||||
'#70DBED',
|
||||
'#F9BA8F',
|
||||
'#F29191',
|
||||
'#82B5D8',
|
||||
'#E5A8E2',
|
||||
'#AEA2E0',
|
||||
'#629E51',
|
||||
'#E5AC0E',
|
||||
'#64B0C8',
|
||||
'#E0752D',
|
||||
'#BF1B00',
|
||||
'#0A50A1',
|
||||
'#962D82',
|
||||
'#614D93',
|
||||
'#9AC48A',
|
||||
'#F2C96D',
|
||||
'#65C5DB',
|
||||
'#F9934E',
|
||||
'#EA6460',
|
||||
'#5195CE',
|
||||
'#D683CE',
|
||||
'#806EB7',
|
||||
'#3F6833',
|
||||
'#967302',
|
||||
'#2F575E',
|
||||
'#99440A',
|
||||
'#58140C',
|
||||
'#052B51',
|
||||
'#511749',
|
||||
'#3F2B5B',
|
||||
'#E0F9D7',
|
||||
'#FCEACA',
|
||||
'#CFFAFF',
|
||||
'#F9E2D2',
|
||||
'#FCE2DE',
|
||||
'#BADFF4',
|
||||
'#F9D9F9',
|
||||
'#DEDAF7',
|
||||
];
|
||||
|
||||
$scope.init();
|
||||
}
|
||||
}
|
||||
|
||||
/** @ngInject */
|
||||
export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScope) {
|
||||
export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScope, $location) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
controller: GrafanaCtrl,
|
||||
@@ -125,6 +71,7 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop
|
||||
body.toggleClass('sidemenu-open', sidemenuOpen);
|
||||
|
||||
appEvents.on('toggle-sidemenu', () => {
|
||||
sidemenuOpen = scope.contextSrv.sidemenu;
|
||||
body.toggleClass('sidemenu-open');
|
||||
});
|
||||
|
||||
@@ -136,6 +83,15 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop
|
||||
body.toggleClass('sidemenu-hidden');
|
||||
});
|
||||
|
||||
scope.$watch(() => playlistSrv.isPlaying, function(newValue) {
|
||||
elem.toggleClass('playlist-active', newValue === true);
|
||||
});
|
||||
|
||||
// check if we are in server side render
|
||||
if (document.cookie.indexOf('renderKey') !== -1) {
|
||||
body.addClass('body--phantomjs');
|
||||
}
|
||||
|
||||
// tooltip removal fix
|
||||
// manage page classes
|
||||
var pageClass;
|
||||
@@ -221,6 +177,8 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop
|
||||
// mouse and keyboard is user activity
|
||||
body.mousemove(userActivityDetected);
|
||||
body.keydown(userActivityDetected);
|
||||
// set useCapture = true to catch event here
|
||||
document.addEventListener('wheel', userActivityDetected, true);
|
||||
// treat tab change as activity
|
||||
document.addEventListener('visibilitychange', userActivityDetected);
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
///<reference path="../../../headers/common.d.ts" />
|
||||
|
||||
import coreModule from '../../core_module';
|
||||
import appEvents from 'app/core/app_events';
|
||||
|
||||
@@ -21,7 +19,6 @@ export class HelpCtrl {
|
||||
],
|
||||
Dashboard: [
|
||||
{ keys: ['mod+s'], description: 'Save dashboard' },
|
||||
{ keys: ['mod+h'], description: 'Hide row controls' },
|
||||
{ keys: ['d', 'r'], description: 'Refresh all panels' },
|
||||
{ keys: ['d', 's'], description: 'Dashboard settings' },
|
||||
{ keys: ['d', 'v'], description: 'Toggle in-active / view mode' },
|
||||
@@ -36,10 +33,6 @@ export class HelpCtrl {
|
||||
{ keys: ['p', 's'], description: 'Open Panel Share Modal' },
|
||||
{ keys: ['p', 'r'], description: 'Remove Panel' },
|
||||
],
|
||||
'Focused Row': [
|
||||
{ keys: ['r', 'c'], description: 'Collapse Row' },
|
||||
{ keys: ['r', 'r'], description: 'Remove Row' },
|
||||
],
|
||||
'Time Range': [
|
||||
{ keys: ['t', 'z'], description: 'Zoom out time range' },
|
||||
{
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
///<reference path="../../headers/common.d.ts" />
|
||||
|
||||
import _ from 'lodash';
|
||||
import coreModule from 'app/core/core_module';
|
||||
import Drop from 'tether-drop';
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<div class="dashboard-list">
|
||||
<div class="page-action-bar page-action-bar--narrow" ng-hide="!ctrl.hasFilters && ctrl.sections.length === 0">
|
||||
<div class="page-action-bar page-action-bar--narrow" ng-hide="ctrl.folderId && !ctrl.hasFilters && ctrl.sections.length === 0">
|
||||
<label class="gf-form gf-form--grow gf-form--has-input-icon">
|
||||
<input type="text" class="gf-form-input max-width-30" placeholder="Find Dashboard by name" tabindex="1" give-focus="true" ng-model="ctrl.query.query" ng-model-options="{ debounce: 500 }" spellcheck='false' ng-change="ctrl.onQueryChange()" />
|
||||
<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>
|
||||
@@ -52,6 +52,12 @@
|
||||
</em>
|
||||
</div>
|
||||
|
||||
<div class="search-results" ng-show="!ctrl.folderId && !ctrl.hasFilters && ctrl.sections.length === 0">
|
||||
<em class="muted">
|
||||
No dashboards found.
|
||||
</em>
|
||||
</div>
|
||||
|
||||
<div class="search-results" ng-show="ctrl.sections.length > 0">
|
||||
<div class="search-results-filter-row">
|
||||
<gf-form-switch
|
||||
@@ -95,22 +101,24 @@
|
||||
</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;
|
||||
folderUid?: string;
|
||||
|
||||
// if user can add new folders and/or add new dashboards
|
||||
canSave = false;
|
||||
|
||||
// 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.folderUid) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.backendSrv.getFolderByUid(this.folderUid).then(folder => {
|
||||
this.canSave = folder.canSave;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
initDashboardList(result: any) {
|
||||
@@ -91,10 +130,10 @@ export class ManageDashboardsCtrl {
|
||||
|
||||
for (const section of this.sections) {
|
||||
if (section.checked && section.id !== 0) {
|
||||
selectedDashboards.folders.push(section.slug);
|
||||
selectedDashboards.folders.push(section.uid);
|
||||
} else {
|
||||
const selected = _.filter(section.items, { checked: true });
|
||||
selectedDashboards.dashboards.push(..._.map(selected, 'slug'));
|
||||
selectedDashboards.dashboards.push(..._.map(selected, 'uid'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,49 +173,14 @@ export class ManageDashboardsCtrl {
|
||||
icon: 'fa-trash',
|
||||
yesText: 'Delete',
|
||||
onConfirm: () => {
|
||||
const foldersAndDashboards = data.folders.concat(data.dashboards);
|
||||
this.deleteFoldersAndDashboards(foldersAndDashboards);
|
||||
this.deleteFoldersAndDashboards(data.folders, data.dashboards);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private deleteFoldersAndDashboards(slugs) {
|
||||
this.backendSrv.deleteDashboards(slugs).then(result => {
|
||||
const folders = _.filter(result, dash => dash.meta.isFolder);
|
||||
const folderCount = folders.length;
|
||||
const dashboards = _.filter(result, dash => !dash.meta.isFolder);
|
||||
const dashCount = dashboards.length;
|
||||
|
||||
if (result.length > 0) {
|
||||
let header;
|
||||
let msg;
|
||||
|
||||
if (folderCount > 0 && dashCount > 0) {
|
||||
header = `Folder${folderCount === 1 ? '' : 's'} And Dashboard${dashCount === 1 ? '' : 's'} Deleted`;
|
||||
msg = `${folderCount} folder${folderCount === 1 ? '' : 's'} `;
|
||||
msg += `and ${dashCount} dashboard${dashCount === 1 ? '' : 's'} has been deleted`;
|
||||
} else if (folderCount > 0) {
|
||||
header = `Folder${folderCount === 1 ? '' : 's'} Deleted`;
|
||||
|
||||
if (folderCount === 1) {
|
||||
msg = `${folders[0].dashboard.title} has been deleted`;
|
||||
} else {
|
||||
msg = `${folderCount} folder${folderCount === 1 ? '' : 's'} has been deleted`;
|
||||
}
|
||||
} else if (dashCount > 0) {
|
||||
header = `Dashboard${dashCount === 1 ? '' : 's'} Deleted`;
|
||||
|
||||
if (dashCount === 1) {
|
||||
msg = `${dashboards[0].dashboard.title} has been deleted`;
|
||||
} else {
|
||||
msg = `${dashCount} dashboard${dashCount === 1 ? '' : 's'} has been deleted`;
|
||||
}
|
||||
}
|
||||
|
||||
appEvents.emit('alert-success', [header, msg]);
|
||||
}
|
||||
|
||||
this.getDashboards();
|
||||
private deleteFoldersAndDashboards(folderUids, dashboardUids) {
|
||||
this.backendSrv.deleteFoldersAndDashboards(folderUids, dashboardUids).then(() => {
|
||||
this.refreshList();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -185,7 +189,7 @@ export class ManageDashboardsCtrl {
|
||||
|
||||
for (const section of this.sections) {
|
||||
const selected = _.filter(section.items, { checked: true });
|
||||
selectedDashboards.push(..._.map(selected, 'slug'));
|
||||
selectedDashboards.push(..._.map(selected, 'uid'));
|
||||
}
|
||||
|
||||
return selectedDashboards;
|
||||
@@ -203,12 +207,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 +224,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 +239,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 +248,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 +276,7 @@ export class ManageDashboardsCtrl {
|
||||
this.query.query = '';
|
||||
this.query.tag = [];
|
||||
this.query.starred = false;
|
||||
this.getDashboards();
|
||||
this.refreshList();
|
||||
}
|
||||
|
||||
createDashboardUrl() {
|
||||
@@ -295,6 +299,7 @@ export function manageDashboardsDirective() {
|
||||
controllerAs: 'ctrl',
|
||||
scope: {
|
||||
folderId: '=',
|
||||
folderUid: '=',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
///<reference path="../../headers/common.d.ts" />
|
||||
|
||||
import coreModule from 'app/core/core_module';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import config from 'app/core/config';
|
||||
|
||||
const template = `
|
||||
<div class="modal-body">
|
||||
@@ -16,8 +15,7 @@ const template = `
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="modal-content">
|
||||
<div class="gf-form-group">
|
||||
<div class="modal-content modal-content--has-scroll" grafana-scrollbar>
|
||||
<table class="filter-table form-inline">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -62,16 +60,11 @@ export class OrgSwitchCtrl {
|
||||
|
||||
setUsingOrg(org) {
|
||||
return this.backendSrv.post('/api/user/using/' + org.orgId).then(() => {
|
||||
const re = /orgId=\d+/gi;
|
||||
this.setWindowLocationHref(this.getWindowLocationHref().replace(re, 'orgId=' + org.orgId));
|
||||
this.setWindowLocation(config.appSubUrl + (config.appSubUrl.endsWith('/') ? '' : '/') + '?orgId=' + org.orgId);
|
||||
});
|
||||
}
|
||||
|
||||
getWindowLocationHref() {
|
||||
return window.location.href;
|
||||
}
|
||||
|
||||
setWindowLocationHref(href: string) {
|
||||
setWindowLocation(href: string) {
|
||||
window.location.href = href;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
///<reference path="../../../headers/common.d.ts" />
|
||||
|
||||
import _ from 'lodash';
|
||||
|
||||
export class QueryPartDef {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
///<reference path="../../../headers/common.d.ts" />
|
||||
|
||||
import _ from 'lodash';
|
||||
import $ from 'jquery';
|
||||
import coreModule from 'app/core/core_module';
|
||||
@@ -25,11 +23,13 @@ export function queryPartEditorDirective($compile, templateSrv) {
|
||||
scope: {
|
||||
part: '=',
|
||||
handleEvent: '&',
|
||||
debounce: '@',
|
||||
},
|
||||
link: function postLink($scope, elem) {
|
||||
var part = $scope.part;
|
||||
var partDef = part.def;
|
||||
var $paramsContainer = elem.find('.query-part-parameters');
|
||||
var debounceLookup = $scope.debounce;
|
||||
|
||||
$scope.partActions = [];
|
||||
|
||||
@@ -130,6 +130,10 @@ export function queryPartEditorDirective($compile, templateSrv) {
|
||||
var items = this.source(this.query, $.proxy(this.process, this));
|
||||
return items ? this.process(items) : items;
|
||||
};
|
||||
|
||||
if (debounceLookup) {
|
||||
typeahead.lookup = _.debounce(typeahead.lookup, 500, { leading: true });
|
||||
}
|
||||
}
|
||||
|
||||
$scope.showActionsMenu = function() {
|
||||
|
||||
@@ -1,17 +1,38 @@
|
||||
import PerfectScrollbar from 'perfect-scrollbar';
|
||||
import coreModule from 'app/core/core_module';
|
||||
import appEvents from 'app/core/app_events';
|
||||
|
||||
export function geminiScrollbar() {
|
||||
return {
|
||||
restrict: 'A',
|
||||
link: function(scope, elem, attrs) {
|
||||
let scrollbar = new PerfectScrollbar(elem[0]);
|
||||
let scrollbar = new PerfectScrollbar(elem[0], {
|
||||
wheelPropagation: true,
|
||||
wheelSpeed: 3,
|
||||
});
|
||||
let lastPos = 0;
|
||||
|
||||
appEvents.on(
|
||||
'dash-scroll',
|
||||
evt => {
|
||||
if (evt.restore) {
|
||||
elem[0].scrollTop = lastPos;
|
||||
return;
|
||||
}
|
||||
|
||||
lastPos = elem[0].scrollTop;
|
||||
|
||||
if (evt.animate) {
|
||||
elem.animate({ scrollTop: evt.pos }, 500);
|
||||
} else {
|
||||
elem[0].scrollTop = evt.pos;
|
||||
}
|
||||
},
|
||||
scope
|
||||
);
|
||||
|
||||
scope.$on('$routeChangeSuccess', () => {
|
||||
elem[0].scrollTop = 0;
|
||||
});
|
||||
|
||||
scope.$on('$routeUpdate', () => {
|
||||
lastPos = 0;
|
||||
elem[0].scrollTop = 0;
|
||||
});
|
||||
|
||||
|
||||
77
public/app/core/components/search/SearchResult.tsx
Normal file
77
public/app/core/components/search/SearchResult.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { observer } from 'mobx-react';
|
||||
import { store } from 'app/stores/store';
|
||||
|
||||
export interface SearchResultProps {
|
||||
search: any;
|
||||
}
|
||||
|
||||
@observer
|
||||
export class SearchResult extends React.Component<SearchResultProps, any> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
search: store.search,
|
||||
};
|
||||
|
||||
store.search.query();
|
||||
}
|
||||
|
||||
render() {
|
||||
return this.state.search.sections.map(section => {
|
||||
return <SearchResultSection section={section} key={section.id} />;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export interface SectionProps {
|
||||
section: any;
|
||||
}
|
||||
|
||||
@observer
|
||||
export class SearchResultSection extends React.Component<SectionProps, any> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
renderItem(item) {
|
||||
return (
|
||||
<a className="search-item" href={item.url} key={item.id}>
|
||||
<span className="search-item__icon">
|
||||
<i className="fa fa-th-large" />
|
||||
</span>
|
||||
<span className="search-item__body">
|
||||
<div className="search-item__body-title">{item.title}</div>
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
toggleSection = () => {
|
||||
this.props.section.toggle();
|
||||
};
|
||||
|
||||
render() {
|
||||
let collapseClassNames = classNames({
|
||||
fa: true,
|
||||
'fa-plus': !this.props.section.expanded,
|
||||
'fa-minus': this.props.section.expanded,
|
||||
'search-section__header__toggle': true,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="search-section" key={this.props.section.id}>
|
||||
<div className="search-section__header">
|
||||
<i className={classNames('search-section__header__icon', this.props.section.icon)} />
|
||||
<span className="search-section__header__text">{this.props.section.title}</span>
|
||||
<i className={collapseClassNames} onClick={this.toggleSection} />
|
||||
</div>
|
||||
{this.props.section.expanded && (
|
||||
<div className="search-section__items">{this.props.section.items.map(this.renderItem)}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,7 @@
|
||||
ng-model-options="{ debounce: 500 }"
|
||||
spellcheck='false'
|
||||
ng-change="ctrl.search()"
|
||||
ng-blur="ctrl.searchInputBlur()"
|
||||
/>
|
||||
/>
|
||||
|
||||
<div class="search-field-spacer"></div>
|
||||
</div>
|
||||
@@ -31,37 +30,28 @@
|
||||
</div>
|
||||
|
||||
<div class="search-dropdown__col_2">
|
||||
<!-- <div class="search-filter-box"> -->
|
||||
<!-- <div class="search-filter-box__header"> -->
|
||||
<!-- <i class="fa fa-filter"></i> -->
|
||||
<!-- Filter by: -->
|
||||
<!-- <a class="pointer pull-right small"> -->
|
||||
<!-- <i class="fa fa-remove"></i> Clear -->
|
||||
<!-- </a> -->
|
||||
<!-- </div> -->
|
||||
<!-- -->
|
||||
<!-- <div class="gf-form"> -->
|
||||
<!-- <folder-picker initial-title="ctrl.initialFolderFilterTitle" -->
|
||||
<!-- on-change="ctrl.onFolderChange($folder)" -->
|
||||
<!-- label-class="width-4"> -->
|
||||
<!-- </folder-picker> -->
|
||||
<!-- </div> -->
|
||||
<!-- -->
|
||||
<!-- <div class="gf-form"> -->
|
||||
<!-- <label class="gf-form-label width-4">Tags</label> -->
|
||||
<!-- <bootstrap-tagsinput ng-model="ctrl.dashboard.tags" tagclass="label label-tag" placeholder="add tags"> -->
|
||||
<!-- </bootstrap-tagsinput> -->
|
||||
<!-- </div> -->
|
||||
<!-- </div> -->
|
||||
<div class="search-filter-box" ng-click="ctrl.onFilterboxClick()">
|
||||
<div class="search-filter-box__header">
|
||||
<i class="fa fa-filter"></i>
|
||||
Filter by:
|
||||
<a class="pointer pull-right small" ng-click="ctrl.clearSearchFilter()">
|
||||
<i class="fa fa-remove"></i> Clear
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="search-filter-box">
|
||||
<tag-filter tags="ctrl.query.tag" tagOptions="ctrl.getTags" onSelect="ctrl.onTagSelect">
|
||||
</tag-filter>
|
||||
</div>
|
||||
|
||||
<div class="search-filter-box" ng-if="ctrl.isEditor">
|
||||
<a href="dashboard/new" class="search-filter-box-link">
|
||||
<i class="gicon gicon-dashboard-new"></i>
|
||||
New dashboard
|
||||
<i class="gicon gicon-dashboard-new"></i> New dashboard
|
||||
</a>
|
||||
<a href="dashboards/folder/new" class="search-filter-box-link">
|
||||
<i class="gicon gicon-folder-new"></i>
|
||||
New folder
|
||||
<i class="gicon gicon-folder-new"></i> New folder
|
||||
</a>
|
||||
<a href="dashboard/import" class="search-filter-box-link">
|
||||
<i class="gicon gicon-dashboard-import"></i> Import dashboard
|
||||
</a>
|
||||
<a class="search-filter-box-link" target="_blank" href="https://grafana.com/dashboards?utm_source=grafana_search">
|
||||
<img src="public/img/icn-dashboard-tiny.svg" width="20" /> Find dashboards on Grafana.com
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import _ from 'lodash';
|
||||
import coreModule from '../../core_module';
|
||||
import { SearchSrv } from 'app/core/services/search_srv';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import appEvents from 'app/core/app_events';
|
||||
|
||||
export class SearchCtrl {
|
||||
@@ -15,6 +16,7 @@ export class SearchCtrl {
|
||||
ignoreClose: any;
|
||||
isLoading: boolean;
|
||||
initialFolderFilterTitle: string;
|
||||
isEditor: string;
|
||||
|
||||
/** @ngInject */
|
||||
constructor($scope, private $location, private $timeout, private searchSrv: SearchSrv) {
|
||||
@@ -22,6 +24,9 @@ export class SearchCtrl {
|
||||
appEvents.on('hide-dash-search', this.closeSearch.bind(this), $scope);
|
||||
|
||||
this.initialFolderFilterTitle = 'All';
|
||||
this.getTags = this.getTags.bind(this);
|
||||
this.onTagSelect = this.onTagSelect.bind(this);
|
||||
this.isEditor = contextSrv.isEditor;
|
||||
}
|
||||
|
||||
closeSearch() {
|
||||
@@ -88,6 +93,19 @@ export class SearchCtrl {
|
||||
}
|
||||
}
|
||||
|
||||
onFilterboxClick() {
|
||||
this.giveSearchFocus = 0;
|
||||
this.preventClose();
|
||||
}
|
||||
|
||||
preventClose() {
|
||||
this.ignoreClose = true;
|
||||
|
||||
this.$timeout(() => {
|
||||
this.ignoreClose = false;
|
||||
}, 100);
|
||||
}
|
||||
|
||||
moveSelection(direction) {
|
||||
if (this.results.length === 0) {
|
||||
return;
|
||||
@@ -160,7 +178,6 @@ export class SearchCtrl {
|
||||
if (_.indexOf(this.query.tag, tag) === -1) {
|
||||
this.query.tag.push(tag);
|
||||
this.search();
|
||||
this.giveSearchFocus = this.giveSearchFocus + 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,10 +190,17 @@ export class SearchCtrl {
|
||||
}
|
||||
|
||||
getTags() {
|
||||
return this.searchSrv.getDashboardTags().then(results => {
|
||||
this.results = results;
|
||||
this.giveSearchFocus = this.giveSearchFocus + 1;
|
||||
});
|
||||
return this.searchSrv.getDashboardTags();
|
||||
}
|
||||
|
||||
onTagSelect(newTags) {
|
||||
this.query.tag = _.map(newTags, tag => tag.value);
|
||||
this.search();
|
||||
}
|
||||
|
||||
clearSearchFilter() {
|
||||
this.query.tag = [];
|
||||
this.search();
|
||||
}
|
||||
|
||||
showStarred() {
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<div class="search-section__header" ng-show="section.hideHeader"></div>
|
||||
|
||||
<div ng-if="section.expanded">
|
||||
<a ng-repeat="item in section.items" class="search-item" ng-class="{'selected': item.selected}" ng-href="{{::item.url}}">
|
||||
<a ng-repeat="item in section.items" class="search-item" ng-class="{'selected': item.selected}" ng-href="{{::item.url}}" >
|
||||
<div ng-click="ctrl.toggleSelection(item, $event)">
|
||||
<gf-form-switch
|
||||
ng-show="ctrl.editable"
|
||||
|
||||
@@ -1,73 +1,78 @@
|
||||
<a class="sidemenu__logo" ng-click="ctrl.toggleSideMenu()">
|
||||
<img src="public/img/grafana_icon.svg"></img>
|
||||
<img src="public/img/grafana_icon.svg"></img>
|
||||
</a>
|
||||
|
||||
<a class="sidemenu__logo_small_breakpoint" ng-click="ctrl.toggleSideMenuSmallBreakpoint()">
|
||||
<i class="fa fa-bars"></i>
|
||||
<span class="sidemenu__close"><i class="fa fa-times"></i> Close</span>
|
||||
<span class="sidemenu__close">
|
||||
<i class="fa fa-times"></i> Close</span>
|
||||
</a>
|
||||
|
||||
<div class="sidemenu__top">
|
||||
<div ng-repeat="item in ::ctrl.mainLinks" class="sidemenu-item dropdown">
|
||||
<a href="{{::item.url}}" class="sidemenu-link" target="{{::item.target}}">
|
||||
<span class="icon-circle sidemenu-icon">
|
||||
<i class="{{::item.icon}}" ng-show="::item.icon"></i>
|
||||
<img ng-src="{{::item.img}}" ng-show="::item.img">
|
||||
</span>
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu--sidemenu" role="menu" ng-if="::item.children">
|
||||
<li class="side-menu-header">
|
||||
<span class="sidemenu-item-text">{{::item.text}}</span>
|
||||
</li>
|
||||
<li ng-repeat="child in ::item.children" ng-class="{divider: child.divider}">
|
||||
<a href="{{::child.url}}">
|
||||
<i class="{{::child.icon}}" ng-show="::child.icon"></i>
|
||||
{{::child.text}}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div ng-repeat="item in ::ctrl.mainLinks" class="sidemenu-item dropdown">
|
||||
<a href="{{::item.url}}" class="sidemenu-link" target="{{::item.target}}">
|
||||
<span class="icon-circle sidemenu-icon">
|
||||
<i class="{{::item.icon}}" ng-show="::item.icon"></i>
|
||||
<img ng-src="{{::item.img}}" ng-show="::item.img">
|
||||
</span>
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu--sidemenu" role="menu" ng-if="::item.children">
|
||||
<li class="side-menu-header">
|
||||
<span class="sidemenu-item-text">{{::item.text}}</span>
|
||||
</li>
|
||||
<li ng-repeat="child in ::item.children" ng-class="{divider: child.divider}">
|
||||
<a href="{{::child.url}}">
|
||||
<i class="{{::child.icon}}" ng-show="::child.icon"></i>
|
||||
{{::child.text}}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidemenu__bottom">
|
||||
<div ng-show="::!ctrl.isSignedIn" class="sidemenu-item">
|
||||
<a href="{{ctrl.loginUrl}}" class="sidemenu-link" target="_self">
|
||||
<span class="icon-circle sidemenu-icon"><i class="fa fa-fw fa-sign-in"></i></span>
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu--sidemenu" role="menu">
|
||||
<li class="side-menu-header">
|
||||
<span class="sidemenu-item-text">Sign In</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div ng-show="::!ctrl.isSignedIn" class="sidemenu-item">
|
||||
<a href="{{ctrl.loginUrl}}" class="sidemenu-link" target="_self">
|
||||
<span class="icon-circle sidemenu-icon">
|
||||
<i class="fa fa-fw fa-sign-in"></i>
|
||||
</span>
|
||||
</a>
|
||||
<a href="{{ctrl.loginUrl}}">
|
||||
<ul class="dropdown-menu dropdown-menu--sidemenu" role="menu">
|
||||
<li class="side-menu-header">
|
||||
<span class="sidemenu-item-text">Sign In</span>
|
||||
</li>
|
||||
</ul>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div ng-repeat="item in ::ctrl.bottomNav" class="sidemenu-item dropdown dropup">
|
||||
<a href="{{::item.url}}" class="sidemenu-link" target="{{::item.target}}">
|
||||
<span class="icon-circle sidemenu-icon">
|
||||
<i class="{{::item.icon}}" ng-show="::item.icon"></i>
|
||||
<img ng-src="{{::item.img}}" ng-show="::item.img">
|
||||
</span>
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu--sidemenu" role="menu">
|
||||
<li ng-if="item.showOrgSwitcher" class="sidemenu-org-switcher">
|
||||
<a ng-click="ctrl.switchOrg()">
|
||||
<div>
|
||||
<div class="sidemenu-org-switcher__org-name">{{ctrl.contextSrv.user.orgName}}</div>
|
||||
<div class="sidemenu-org-switcher__org-current">Current Org:</div>
|
||||
</div>
|
||||
<div class="sidemenu-org-switcher__switch"><i class="fa fa-fw fa-random"></i>Switch</div>
|
||||
</a>
|
||||
</li>
|
||||
<li ng-repeat="child in ::item.children" ng-class="{divider: child.divider}" ng-hide="::child.hideFromMenu">
|
||||
<a href="{{::child.url}}" target="{{::child.target}}" ng-click="ctrl.itemClicked(child, $event)">
|
||||
<i class="{{::child.icon}}" ng-show="::child.icon"></i>
|
||||
{{::child.text}}
|
||||
</a>
|
||||
</li>
|
||||
<li class="side-menu-header">
|
||||
<span class="sidemenu-item-text">{{::item.text}}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div ng-repeat="item in ::ctrl.bottomNav" class="sidemenu-item dropdown dropup">
|
||||
<a href="{{::item.url}}" class="sidemenu-link" target="{{::item.target}}">
|
||||
<span class="icon-circle sidemenu-icon">
|
||||
<i class="{{::item.icon}}" ng-show="::item.icon"></i>
|
||||
<img ng-src="{{::item.img}}" ng-show="::item.img">
|
||||
</span>
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu--sidemenu" role="menu">
|
||||
<li ng-if="item.showOrgSwitcher" class="sidemenu-org-switcher">
|
||||
<a ng-click="ctrl.switchOrg()">
|
||||
<div>
|
||||
<div class="sidemenu-org-switcher__org-name">{{ctrl.contextSrv.user.orgName}}</div>
|
||||
<div class="sidemenu-org-switcher__org-current">Current Org:</div>
|
||||
</div>
|
||||
<div class="sidemenu-org-switcher__switch">
|
||||
<i class="fa fa-fw fa-random"></i>Switch</div>
|
||||
</a>
|
||||
</li>
|
||||
<li ng-repeat="child in ::item.children" ng-class="{divider: child.divider}" ng-hide="::child.hideFromMenu">
|
||||
<a href="{{::child.url}}" target="{{::child.target}}" ng-click="ctrl.itemClicked(child, $event)">
|
||||
<i class="{{::child.icon}}" ng-show="::child.icon"></i>
|
||||
{{::child.text}}
|
||||
</a>
|
||||
</li>
|
||||
<li class="side-menu-header">
|
||||
<span class="sidemenu-item-text">{{::item.text}}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -16,8 +16,10 @@ export class SideMenuCtrl {
|
||||
constructor(private $scope, private $rootScope, private $location, private contextSrv, private $timeout) {
|
||||
this.isSignedIn = contextSrv.isSignedIn;
|
||||
this.user = contextSrv.user;
|
||||
this.mainLinks = _.filter(config.bootData.navTree, item => !item.hideFromMenu);
|
||||
this.bottomNav = _.filter(config.bootData.navTree, item => item.hideFromMenu);
|
||||
|
||||
let navTree = _.cloneDeep(config.bootData.navTree);
|
||||
this.mainLinks = _.filter(navTree, item => !item.hideFromMenu);
|
||||
this.bottomNav = _.filter(navTree, item => item.hideFromMenu);
|
||||
this.loginUrl = 'login?redirect=' + encodeURIComponent(this.$location.path());
|
||||
|
||||
if (contextSrv.user.orgCount > 1) {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
///<reference path="../../headers/common.d.ts" />
|
||||
|
||||
import coreModule from 'app/core/core_module';
|
||||
|
||||
var template = `
|
||||
|
||||
Reference in New Issue
Block a user