Merge branch 'master' into mssql_datasource

This commit is contained in:
Marcus Efraimsson
2018-03-13 16:03:02 +01:00
2626 changed files with 339805 additions and 172731 deletions

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

View File

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

View File

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

View File

@@ -0,0 +1,53 @@
import React from 'react';
import PageHeader from './PageHeader';
import { shallow } from 'enzyme';
describe('PageHeader', () => {
let wrapper;
describe('when the nav tree has a node with a title', () => {
beforeAll(() => {
const nav = {
main: {
icon: 'fa fa-folder-open',
id: 'node',
subTitle: 'node subtitle',
url: '',
text: 'node',
},
node: {},
};
wrapper = shallow(<PageHeader model={nav as any} />);
});
it('should render the title', () => {
const title = wrapper.find('.page-header__title');
expect(title.text()).toBe('node');
});
});
describe('when the nav tree has a node with breadcrumbs and a title', () => {
beforeAll(() => {
const nav = {
main: {
icon: 'fa fa-folder-open',
id: 'child',
subTitle: 'child subtitle',
url: '',
text: 'child',
breadcrumbs: [{ title: 'Parent', url: 'parentUrl' }],
},
node: {},
};
wrapper = shallow(<PageHeader model={nav as any} />);
});
it('should render the title with breadcrumbs first and then title last', () => {
const title = wrapper.find('.page-header__title');
expect(title.text()).toBe('Parent / child');
const parentLink = wrapper.find('.page-header__title > a.text-link');
expect(parentLink.prop('href')).toBe('parentUrl');
});
});
});

View File

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

View File

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

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

View File

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

View File

@@ -0,0 +1,40 @@
import React, { Component } from 'react';
import DescriptionPicker from 'app/core/components/Picker/DescriptionPicker';
import { permissionOptions } from 'app/stores/PermissionsStore/PermissionsStore';
export interface IProps {
item: any;
}
export default class DisabledPermissionListItem extends Component<IProps, any> {
render() {
const { item } = this.props;
return (
<tr className="gf-form-disabled">
<td style={{ width: '100%' }}>
<i className={`fa--permissions-list ${item.icon}`} />
<span dangerouslySetInnerHTML={{ __html: item.nameHtml }} />
</td>
<td />
<td className="query-keyword">Can</td>
<td>
<div className="gf-form">
<DescriptionPicker
optionsWithDesc={permissionOptions}
handlePicked={() => {}}
value={item.permission}
disabled={true}
className={'gf-form-input--form-dropdown-right'}
/>
</div>
</td>
<td>
<button className="btn btn-inverse btn-small">
<i className="fa fa-lock" />
</button>
</td>
</tr>
);
}
}

View File

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

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

View File

@@ -0,0 +1,13 @@
import React from 'react';
export default () => {
return (
<div className="">
<h5>What are Permissions?</h5>
<p>
An Access Control List (ACL) model is used to limit access to Dashboard Folders. A user or a Team can be
assigned permissions for a folder or for a single dashboard.
</p>
</div>
);
};

View File

@@ -0,0 +1,64 @@
import React, { Component } from 'react';
import PermissionsListItem from './PermissionsListItem';
import DisabledPermissionsListItem from './DisabledPermissionsListItem';
import { observer } from 'mobx-react';
import { FolderInfo } from './FolderInfo';
export interface IProps {
permissions: any[];
removeItem: any;
permissionChanged: any;
fetching: boolean;
folderInfo?: FolderInfo;
}
@observer
class PermissionsList extends Component<IProps, any> {
render() {
const { permissions, removeItem, permissionChanged, fetching, folderInfo } = this.props;
return (
<table className="filter-table gf-form-group">
<tbody>
<DisabledPermissionsListItem
key={0}
item={{
nameHtml: 'Everyone with <span class="query-keyword">Admin</span> Role',
permission: 4,
icon: 'fa fa-fw fa-street-view',
}}
/>
{permissions.map((item, idx) => {
return (
<PermissionsListItem
key={idx + 1}
item={item}
itemIndex={idx}
removeItem={removeItem}
permissionChanged={permissionChanged}
folderInfo={folderInfo}
/>
);
})}
{fetching === true && permissions.length < 1 ? (
<tr>
<td colSpan={4}>
<em>Loading permissions...</em>
</td>
</tr>
) : null}
{fetching === false && permissions.length < 1 ? (
<tr>
<td colSpan={4}>
<em>No permissions are set. Will only be accessible by admins.</em>
</td>
</tr>
) : null}
</tbody>
</table>
);
}
}
export default PermissionsList;

View File

@@ -0,0 +1,65 @@
import React from 'react';
import { observer } from 'mobx-react';
import DescriptionPicker from 'app/core/components/Picker/DescriptionPicker';
import { permissionOptions } from 'app/stores/PermissionsStore/PermissionsStore';
const setClassNameHelper = inherited => {
return inherited ? 'gf-form-disabled' : '';
};
export default observer(({ item, removeItem, permissionChanged, itemIndex, folderInfo }) => {
const handleRemoveItem = evt => {
evt.preventDefault();
removeItem(itemIndex);
};
const handleChangePermission = permissionOption => {
permissionChanged(itemIndex, permissionOption.value, permissionOption.label);
};
const inheritedFromRoot = item.dashboardId === -1 && folderInfo && folderInfo.id === 0;
return (
<tr className={setClassNameHelper(item.inherited)}>
<td style={{ width: '100%' }}>
<i className={`fa--permissions-list ${item.icon}`} />
<span dangerouslySetInnerHTML={{ __html: item.nameHtml }} />
</td>
<td>
{item.inherited &&
folderInfo && (
<em className="muted no-wrap">
Inherited from folder{' '}
<a className="text-link" href={`${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>
);
});

View File

@@ -0,0 +1,56 @@
import React, { Component } from 'react';
export interface IProps {
onSelect: any;
onFocus: any;
option: any;
isFocused: any;
className: any;
}
class DescriptionOption extends Component<IProps, any> {
constructor(props) {
super(props);
this.handleMouseDown = this.handleMouseDown.bind(this);
this.handleMouseEnter = this.handleMouseEnter.bind(this);
this.handleMouseMove = this.handleMouseMove.bind(this);
}
handleMouseDown(event) {
event.preventDefault();
event.stopPropagation();
this.props.onSelect(this.props.option, event);
}
handleMouseEnter(event) {
this.props.onFocus(this.props.option, event);
}
handleMouseMove(event) {
if (this.props.isFocused) {
return;
}
this.props.onFocus(this.props.option, event);
}
render() {
const { option, children, className } = this.props;
return (
<button
onMouseDown={this.handleMouseDown}
onMouseEnter={this.handleMouseEnter}
onMouseMove={this.handleMouseMove}
title={option.title}
className={`description-picker-option__button btn btn-link ${className} width-19`}
>
<div className="gf-form">{children}</div>
<div className="gf-form">
<div className="muted width-17">{option.description}</div>
{className.indexOf('is-selected') > -1 && <i className="fa fa-check" aria-hidden="true" />}
</div>
</button>
);
}
}
export default DescriptionOption;

View File

@@ -0,0 +1,48 @@
import React, { Component } from 'react';
import Select from 'react-select';
import DescriptionOption from './DescriptionOption';
export interface IProps {
optionsWithDesc: OptionWithDescription[];
handlePicked: (permission) => void;
value: number;
disabled: boolean;
className?: string;
}
export interface OptionWithDescription {
value: any;
label: string;
description: string;
}
class DescriptionPicker extends Component<IProps, any> {
constructor(props) {
super(props);
this.state = {};
}
render() {
const { optionsWithDesc, handlePicked, value, disabled, className } = this.props;
return (
<div className="permissions-picker">
<Select
value={value}
valueKey="value"
multi={false}
clearable={false}
labelKey="label"
options={optionsWithDesc}
onChange={handlePicked}
className={`width-7 gf-form-input gf-form-input--form-dropdown ${className || ''}`}
optionComponent={DescriptionOption}
placeholder="Choose"
disabled={disabled}
/>
</div>
);
}
}
export default DescriptionPicker;

View File

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

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

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

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

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

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

View File

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

View File

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

View File

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

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

View File

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

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

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

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

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

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

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

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

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

View File

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

View File

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

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

View File

@@ -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 || '&nbsp;'">
</a>
`;
@@ -250,6 +266,9 @@ export function formDropdownDirective() {
allowCustom: '@',
labelMode: '@',
lookupText: '@',
placeholder: '@',
startOpen: '@',
debounce: '@',
},
};
}

View File

@@ -1,5 +1,3 @@
///<reference path="../../headers/common.d.ts" />
import coreModule from 'app/core/core_module';
const template = `

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,22 +3,49 @@ import coreModule from 'app/core/core_module';
import appEvents from 'app/core/app_events';
import { SearchSrv } from 'app/core/services/search_srv';
class Query {
query: string;
mode: string;
tag: any[];
starred: boolean;
skipRecent: boolean;
skipStarred: boolean;
folderIds: number[];
}
export class ManageDashboardsCtrl {
public sections: any[];
tagFilterOptions: any[];
selectedTagFilter: any;
query: any;
query: Query;
navModel: any;
selectAllChecked = false;
// enable/disable actions depending on the folders or dashboards selected
canDelete = false;
canMove = false;
// filter variables
hasFilters = false;
selectAllChecked = false;
tagFilterOptions: any[];
selectedTagFilter: any;
starredFilterOptions = [{ text: 'Filter by Starred', disabled: true }, { text: 'Yes' }, { text: 'No' }];
selectedStarredFilter: any;
// used when managing dashboards for a specific folder
folderId?: number;
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: '=',
},
};
}

View File

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

View File

@@ -1,5 +1,3 @@
///<reference path="../../../headers/common.d.ts" />
import _ from 'lodash';
export class QueryPartDef {

View File

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

View File

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

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

View File

@@ -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&#45;filter&#45;box"> -->
<!-- <div class="search&#45;filter&#45;box__header"> -->
<!-- <i class="fa fa&#45;filter"></i> -->
<!-- Filter by: -->
<!-- <a class="pointer pull&#45;right small"> -->
<!-- <i class="fa fa&#45;remove"></i> Clear -->
<!-- </a> -->
<!-- </div> -->
<!-- -->
<!-- <div class="gf&#45;form"> -->
<!-- <folder&#45;picker initial&#45;title="ctrl.initialFolderFilterTitle" -->
<!-- on&#45;change="ctrl.onFolderChange($folder)" -->
<!-- label&#45;class="width&#45;4"> -->
<!-- </folder&#45;picker> -->
<!-- </div> -->
<!-- -->
<!-- <div class="gf&#45;form"> -->
<!-- <label class="gf&#45;form&#45;label width&#45;4">Tags</label> -->
<!-- <bootstrap&#45;tagsinput ng&#45;model="ctrl.dashboard.tags" tagclass="label label&#45;tag" placeholder="add tags"> -->
<!-- </bootstrap&#45;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

View File

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

View File

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

View File

@@ -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>&nbsp;Close</span>
<span class="sidemenu__close">
<i class="fa fa-times"></i>&nbsp;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>

View File

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

View File

@@ -1,5 +1,3 @@
///<reference path="../../headers/common.d.ts" />
import coreModule from 'app/core/core_module';
var template = `