Merge branch '7883_new_url_structure' into 7883_frontend_step2

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

View File

@@ -4,8 +4,9 @@ import PageHeader from './components/PageHeader/PageHeader';
import EmptyListCTA from './components/EmptyListCTA/EmptyListCTA';
import LoginBackground from './components/Login/LoginBackground';
import { SearchResult } from './components/search/SearchResult';
import UserPicker from './components/UserPicker/UserPicker';
import { TagFilter } from './components/TagFilter/TagFilter';
import UserPicker from './components/Picker/UserPicker';
import DashboardPermissions from './components/Permissions/DashboardPermissions';
export function registerAngularDirectives() {
react2AngularDirective('passwordStrength', PasswordStrength, ['password']);
@@ -13,10 +14,17 @@ export function registerAngularDirectives() {
react2AngularDirective('emptyListCta', EmptyListCTA, ['model']);
react2AngularDirective('loginBackground', LoginBackground, []);
react2AngularDirective('searchResult', SearchResult, []);
react2AngularDirective('selectUserPicker', UserPicker, ['backendSrv', 'teamId', 'refreshList']);
react2AngularDirective('tagFilter', TagFilter, [
'tags',
['onSelect', { watchDepth: 'reference' }],
['tagOptions', { watchDepth: 'reference' }],
]);
react2AngularDirective('selectUserPicker', UserPicker, ['backendSrv', 'handlePicked']);
react2AngularDirective('dashboardPermissions', DashboardPermissions, [
'backendSrv',
'dashboardId',
'folderTitle',
'folderSlug',
'folderId',
]);
}

View File

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

View File

@@ -1,55 +1,15 @@
import React from 'react';
import { observer } from 'mobx-react';
import { NavModel, NavModelItem } from '../../nav_model_srv';
import classNames from 'classnames';
import appEvents from 'app/core/app_events';
import { toJS } from 'mobx';
export interface IProps {
model: NavModel;
}
function TabItem(tab: NavModelItem) {
if (tab.hideFromTabs) {
return null;
}
let tabClasses = classNames({
'gf-tabs-link': true,
active: tab.active,
});
return (
<li className="gf-tabs-item" key={tab.url}>
<a className={tabClasses} target={tab.target} href={tab.url}>
<i className={tab.icon} />
{tab.text}
</a>
</li>
);
}
function SelectOption(navItem: NavModelItem) {
if (navItem.hideFromTabs) {
// TODO: Rename hideFromTabs => hideFromNav
return null;
}
return (
<option key={navItem.url} value={navItem.url}>
{navItem.text}
</option>
);
}
function Navigation({ main }: { main: NavModelItem }) {
return (
<nav>
<SelectNav customCss="page-header__select-nav" main={main} />
<Tabs customCss="page-header__tabs" main={main} />
</nav>
);
}
function SelectNav({ main, customCss }: { main: NavModelItem; customCss: string }) {
const SelectNav = ({ main, customCss }: { main: NavModelItem; customCss: string }) => {
const defaultSelectedItem = main.children.find(navItem => {
return navItem.active === true;
});
@@ -66,26 +26,81 @@ function SelectNav({ main, customCss }: { main: NavModelItem; customCss: string
{/* Label to make it clickable */}
<select
className="gf-select-nav gf-form-input"
defaultValue={defaultSelectedItem.url}
value={defaultSelectedItem.url}
onChange={gotoUrl}
id="page-header-select-nav"
>
{main.children.map(SelectOption)}
{main.children.map((navItem: NavModelItem) => {
if (navItem.hideFromTabs) {
// TODO: Rename hideFromTabs => hideFromNav
return null;
}
return (
<option key={navItem.url} value={navItem.url}>
{navItem.text}
</option>
);
})}
</select>
</div>
);
}
};
function Tabs({ main, customCss }: { main: NavModelItem; customCss: string }) {
return <ul className={`gf-tabs ${customCss}`}>{main.children.map(TabItem)}</ul>;
}
const Tabs = ({ main, customCss }: { main: NavModelItem; customCss: string }) => {
return (
<ul className={`gf-tabs ${customCss}`}>
{main.children.map((tab, idx) => {
if (tab.hideFromTabs) {
return null;
}
const tabClasses = classNames({
'gf-tabs-link': true,
active: tab.active,
});
return (
<li className="gf-tabs-item" key={tab.url}>
<a className={tabClasses} target={tab.target} href={tab.url}>
<i className={tab.icon} />
{tab.text}
</a>
</li>
);
})}
</ul>
);
};
const Navigation = ({ main }: { main: NavModelItem }) => {
return (
<nav>
<SelectNav customCss="page-header__select-nav" main={main} />
<Tabs customCss="page-header__tabs" main={main} />
</nav>
);
};
@observer
export default class PageHeader extends React.Component<IProps, any> {
constructor(props) {
super(props);
}
renderBreadcrumb(breadcrumbs) {
shouldComponentUpdate() {
//Hack to re-render on changed props from angular with the @observer decorator
return true;
}
renderTitle(title: string, breadcrumbs: any[]) {
if (!title && (!breadcrumbs || breadcrumbs.length === 0)) {
return null;
}
if (!breadcrumbs || breadcrumbs.length === 0) {
return <h1 className="page-header__title">{title}</h1>;
}
const breadcrumbsResult = [];
for (let i = 0; i < breadcrumbs.length; i++) {
const bc = breadcrumbs[i];
@@ -99,7 +114,9 @@ export default class PageHeader extends React.Component<IProps, any> {
breadcrumbsResult.push(<span key={i}> / {bc.title}</span>);
}
}
return breadcrumbsResult;
breadcrumbsResult.push(<span key={breadcrumbs.length + 1}> / {title}</span>);
return <h1 className="page-header__title">{breadcrumbsResult}</h1>;
}
renderHeaderTitle(main) {
@@ -111,11 +128,7 @@ export default class PageHeader extends React.Component<IProps, any> {
</span>
<div className="page-header__info-block">
{main.text && <h1 className="page-header__title">{main.text}</h1>}
{main.breadcrumbs &&
main.breadcrumbs.length > 0 && (
<h1 className="page-header__title">{this.renderBreadcrumb(main.breadcrumbs)}</h1>
)}
{this.renderTitle(main.text, main.breadcrumbs)}
{main.subTitle && <div className="page-header__sub-title">{main.subTitle}</div>}
{main.subType && (
<div className="page-header__stamps">
@@ -135,12 +148,14 @@ export default class PageHeader extends React.Component<IProps, any> {
return null;
}
const main = toJS(model.main); // Convert to JS if its a mobx observable
return (
<div className="page-header-canvas">
<div className="page-container">
<div className="page-header">
{this.renderHeaderTitle(model.main)}
{model.main.children && <Navigation main={model.main} />}
{this.renderHeaderTitle(main)}
{main.children && <Navigation main={main} />}
</div>
</div>
</div>

View File

@@ -0,0 +1,46 @@
import React, { Component } from 'react';
import { store } from 'app/stores/store';
import Permissions from 'app/core/components/Permissions/Permissions';
import Tooltip from 'app/core/components/Tooltip/Tooltip';
import PermissionsInfo from 'app/core/components/Permissions/PermissionsInfo';
export interface IProps {
dashboardId: number;
folderId: number;
folderTitle: string;
folderSlug: string;
backendSrv: any;
}
class DashboardPermissions extends Component<IProps, any> {
permissions: any;
constructor(props) {
super(props);
this.permissions = store.permissions;
}
render() {
const { dashboardId, folderTitle, folderSlug, folderId, backendSrv } = this.props;
return (
<div>
<div className="dashboard-settings__header">
<h3 className="d-inline-block">Permissions</h3>
<Tooltip className="page-sub-heading-icon" placement="auto" content={PermissionsInfo}>
<i className="gicon gicon-question gicon--has-hover" />
</Tooltip>
</div>
<Permissions
permissions={this.permissions}
isFolder={false}
dashboardId={dashboardId}
folderInfo={{ title: folderTitle, slug: folderSlug, id: folderId }}
backendSrv={backendSrv}
/>
</div>
);
}
}
export default DashboardPermissions;

View File

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

View File

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

View File

@@ -0,0 +1,73 @@
import React from 'react';
import Permissions from './Permissions';
import { RootStore } from 'app/stores/RootStore/RootStore';
import { backendSrv } from 'test/mocks/common';
import { shallow } from 'enzyme';
describe('Permissions', () => {
let wrapper;
beforeAll(() => {
backendSrv.get.mockReturnValue(
Promise.resolve([
{ id: 2, dashboardId: 1, role: 'Viewer', permission: 1, permissionName: 'View' },
{ id: 3, dashboardId: 1, role: 'Editor', permission: 1, permissionName: 'Edit' },
{
id: 4,
dashboardId: 1,
userId: 2,
userLogin: 'danlimerick',
userEmail: 'dan.limerick@gmail.com',
permission: 4,
permissionName: 'Admin',
},
])
);
backendSrv.post = jest.fn();
const store = RootStore.create(
{},
{
backendSrv: backendSrv,
}
);
wrapper = shallow(<Permissions backendSrv={backendSrv} isFolder={true} dashboardId={1} {...store} />);
return wrapper.instance().loadStore(1, true);
});
describe('when permission for a user is added', () => {
it('should save permission to db', () => {
const userItem = {
id: 2,
login: 'user2',
};
wrapper
.instance()
.userPicked(userItem)
.then(() => {
expect(backendSrv.post.mock.calls.length).toBe(1);
expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl');
});
});
});
describe('when permission for team is added', () => {
it('should save permission to db', () => {
const teamItem = {
id: 2,
name: 'ug1',
};
wrapper
.instance()
.teamPicked(teamItem)
.then(() => {
expect(backendSrv.post.mock.calls.length).toBe(1);
expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl');
});
});
});
});

View File

@@ -0,0 +1,161 @@
import React, { Component } from 'react';
import PermissionsList from './PermissionsList';
import { observer } from 'mobx-react';
import UserPicker, { User } from 'app/core/components/Picker/UserPicker';
import TeamPicker, { Team } from 'app/core/components/Picker/TeamPicker';
import { aclTypes } from 'app/stores/PermissionsStore/PermissionsStore';
import { FolderInfo } from './FolderInfo';
export interface DashboardAcl {
id?: number;
dashboardId?: number;
userId?: number;
userLogin?: string;
userEmail?: string;
teamId?: number;
team?: string;
permission?: number;
permissionName?: string;
role?: string;
icon?: string;
nameHtml?: string;
inherited?: boolean;
sortName?: string;
sortRank?: number;
}
export interface IProps {
dashboardId: number;
folderInfo?: FolderInfo;
permissions?: any;
isFolder: boolean;
backendSrv: any;
}
@observer
class Permissions extends Component<IProps, any> {
constructor(props) {
super(props);
const { dashboardId, isFolder, folderInfo } = this.props;
this.permissionChanged = this.permissionChanged.bind(this);
this.typeChanged = this.typeChanged.bind(this);
this.removeItem = this.removeItem.bind(this);
this.userPicked = this.userPicked.bind(this);
this.teamPicked = this.teamPicked.bind(this);
this.loadStore(dashboardId, isFolder, folderInfo && folderInfo.id === 0);
}
loadStore(dashboardId, isFolder, isInRoot = false) {
return this.props.permissions.load(dashboardId, isFolder, isInRoot);
}
permissionChanged(index: number, permission: number, permissionName: string) {
const { permissions } = this.props;
permissions.updatePermissionOnIndex(index, permission, permissionName);
}
removeItem(index: number) {
const { permissions } = this.props;
permissions.removeStoreItem(index);
}
resetNewType() {
const { permissions } = this.props;
permissions.resetNewType();
}
typeChanged(evt) {
const { value } = evt.target;
const { permissions, dashboardId } = this.props;
if (value === 'Viewer' || value === 'Editor') {
permissions.addStoreItem({ permission: 1, role: value, dashboardId: dashboardId }, dashboardId);
this.resetNewType();
return;
}
permissions.setNewType(value);
}
userPicked(user: User) {
const { permissions, dashboardId } = this.props;
return permissions.addStoreItem({
userId: user.id,
userLogin: user.login,
permission: 1,
dashboardId: dashboardId,
});
}
teamPicked(team: Team) {
const { permissions, dashboardId } = this.props;
return permissions.addStoreItem({
teamId: team.id,
team: team.name,
permission: 1,
dashboardId: dashboardId,
});
}
render() {
const { permissions, folderInfo, backendSrv } = this.props;
return (
<div className="gf-form-group">
<PermissionsList
permissions={permissions.items}
removeItem={this.removeItem}
permissionChanged={this.permissionChanged}
fetching={permissions.fetching}
folderInfo={folderInfo}
/>
<div className="gf-form-inline">
<form name="addPermission" className="gf-form-group">
<h6 className="muted">Add Permission For</h6>
<div className="gf-form-inline">
<div className="gf-form">
<div className="gf-form-select-wrapper">
<select
className="gf-form-input gf-size-auto"
value={permissions.newType}
onChange={this.typeChanged}
>
{aclTypes.map((option, idx) => {
return (
<option key={idx} value={option.value}>
{option.text}
</option>
);
})}
</select>
</div>
</div>
{permissions.newType === 'User' ? (
<div className="gf-form">
<UserPicker backendSrv={backendSrv} handlePicked={this.userPicked} />
</div>
) : null}
{permissions.newType === 'Group' ? (
<div className="gf-form">
<TeamPicker backendSrv={backendSrv} handlePicked={this.teamPicked} />
</div>
) : null}
</div>
</form>
{permissions.error ? (
<div className="gf-form width-17">
<span ng-if="ctrl.error" className="text-error p-l-1">
<i className="fa fa-warning" />
{permissions.error}
</span>
</div>
) : null}
</div>
</div>
);
}
}
export default Permissions;

View File

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

View File

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

View File

@@ -0,0 +1,65 @@
import React from 'react';
import { observer } from 'mobx-react';
import DescriptionPicker from 'app/core/components/Picker/DescriptionPicker';
import { permissionOptions } from 'app/stores/PermissionsStore/PermissionsStore';
const setClassNameHelper = inherited => {
return inherited ? 'gf-form-disabled' : '';
};
export default observer(({ item, removeItem, permissionChanged, itemIndex, folderInfo }) => {
const handleRemoveItem = evt => {
evt.preventDefault();
removeItem(itemIndex);
};
const handleChangePermission = permissionOption => {
permissionChanged(itemIndex, permissionOption.value, permissionOption.label);
};
const inheritedFromRoot = item.dashboardId === -1 && folderInfo && folderInfo.id === 0;
return (
<tr className={setClassNameHelper(item.inherited)}>
<td style={{ width: '100%' }}>
<i className={`fa--permissions-list ${item.icon}`} />
<span dangerouslySetInnerHTML={{ __html: item.nameHtml }} />
</td>
<td>
{item.inherited &&
folderInfo && (
<em className="muted no-wrap">
Inherited from folder{' '}
<a className="text-link" href={`dashboards/folder/${folderInfo.id}/${folderInfo.slug}/permissions`}>
{folderInfo.title}
</a>{' '}
</em>
)}
{inheritedFromRoot && <em className="muted no-wrap">Default Permission</em>}
</td>
<td className="query-keyword">Can</td>
<td>
<div className="gf-form">
<DescriptionPicker
optionsWithDesc={permissionOptions}
handlePicked={handleChangePermission}
value={item.permission}
disabled={item.inherited}
className={'gf-form-input--form-dropdown-right'}
/>
</div>
</td>
<td>
{!item.inherited ? (
<a className="btn btn-danger btn-small" onClick={handleRemoveItem}>
<i className="fa fa-remove" />
</a>
) : (
<button className="btn btn-inverse btn-small">
<i className="fa fa-lock" />
</button>
)}
</td>
</tr>
);
});

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import React from 'react';
import renderer from 'react-test-renderer';
import UserPickerOption from './UserPickerOption';
import PickerOption from './PickerOption';
const model = {
onSelect: () => {},
@@ -14,9 +14,9 @@ const model = {
className: 'class-for-user-picker',
};
describe('UserPickerOption', () => {
describe('PickerOption', () => {
it('renders correctly', () => {
const tree = renderer.create(<UserPickerOption {...model} />).toJSON();
const tree = renderer.create(<PickerOption {...model} />).toJSON();
expect(tree).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,79 @@
import React, { Component } from 'react';
import Select from 'react-select';
import PickerOption from './PickerOption';
import withPicker from './withPicker';
import { debounce } from 'lodash';
export interface IProps {
backendSrv: any;
isLoading: boolean;
toggleLoading: any;
handlePicked: (user) => void;
}
export interface Team {
id: number;
label: string;
name: string;
avatarUrl: string;
}
class TeamPicker extends Component<IProps, any> {
debouncedSearch: any;
backendSrv: any;
constructor(props) {
super(props);
this.state = {};
this.search = this.search.bind(this);
this.debouncedSearch = debounce(this.search, 300, {
leading: true,
trailing: false,
});
}
search(query?: string) {
const { toggleLoading, backendSrv } = this.props;
toggleLoading(true);
return backendSrv.get(`/api/teams/search?perpage=10&page=1&query=${query}`).then(result => {
const teams = result.teams.map(team => {
return {
id: team.id,
label: team.name,
name: team.name,
avatarUrl: team.avatarUrl,
};
});
toggleLoading(false);
return { options: teams };
});
}
render() {
const AsyncComponent = this.state.creatable ? Select.AsyncCreatable : Select.Async;
const { isLoading, handlePicked } = this.props;
return (
<div className="user-picker">
<AsyncComponent
valueKey="id"
multi={false}
labelKey="label"
cache={false}
isLoading={isLoading}
loadOptions={this.debouncedSearch}
loadingPlaceholder="Loading..."
onChange={handlePicked}
className="width-8 gf-form-input gf-form-input--form-dropdown"
optionComponent={PickerOption}
placeholder="Choose"
/>
</div>
);
}
}
export default withPicker(TeamPicker);

View File

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

View File

@@ -0,0 +1,79 @@
import React, { Component } from 'react';
import Select from 'react-select';
import PickerOption from './PickerOption';
import withPicker from './withPicker';
import { debounce } from 'lodash';
export interface IProps {
backendSrv: any;
isLoading: boolean;
toggleLoading: any;
handlePicked: (user) => void;
}
export interface User {
id: number;
label: string;
avatarUrl: string;
login: string;
}
class UserPicker extends Component<IProps, any> {
debouncedSearch: any;
backendSrv: any;
constructor(props) {
super(props);
this.state = {};
this.search = this.search.bind(this);
this.debouncedSearch = debounce(this.search, 300, {
leading: true,
trailing: false,
});
}
search(query?: string) {
const { toggleLoading, backendSrv } = this.props;
toggleLoading(true);
return backendSrv.get(`/api/users/search?perpage=10&page=1&query=${query}`).then(result => {
const users = result.users.map(user => {
return {
id: user.id,
label: `${user.login} - ${user.email}`,
avatarUrl: user.avatarUrl,
login: user.login,
};
});
toggleLoading(false);
return { options: users };
});
}
render() {
const AsyncComponent = this.state.creatable ? Select.AsyncCreatable : Select.Async;
const { isLoading, handlePicked } = this.props;
return (
<div className="user-picker">
<AsyncComponent
valueKey="id"
multi={false}
labelKey="label"
cache={false}
isLoading={isLoading}
loadOptions={this.debouncedSearch}
loadingPlaceholder="Loading..."
noResultsText="No users found"
onChange={handlePicked}
className="width-8 gf-form-input gf-form-input--form-dropdown"
optionComponent={PickerOption}
placeholder="Choose"
/>
</div>
);
}
}
export default withPicker(UserPicker);

View File

@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`UserPickerOption renders correctly 1`] = `
exports[`PickerOption renders correctly 1`] = `
<button
className="user-picker-option__button btn btn-link class-for-user-picker"
onMouseDown={[Function]}

View File

@@ -0,0 +1,32 @@
import React, { Component } from 'react';
export interface IProps {
backendSrv: any;
handlePicked: (data) => void;
}
export default function withPicker(WrappedComponent) {
return class WithPicker extends Component<IProps, any> {
constructor(props) {
super(props);
this.toggleLoading = this.toggleLoading.bind(this);
this.state = {
isLoading: false,
};
}
toggleLoading(isLoading) {
this.setState(prevState => {
return {
...prevState,
isLoading: isLoading,
};
});
}
render() {
return <WrappedComponent toggleLoading={this.toggleLoading} isLoading={this.state.isLoading} {...this.props} />;
}
};
}

View File

@@ -6,7 +6,7 @@ describe('Popover', () => {
it('renders correctly', () => {
const tree = renderer
.create(
<Popover placement="auto" content="Popover text">
<Popover className="test-class" placement="auto" content="Popover text">
<button>Button with Popover</button>
</Popover>
)

View File

@@ -6,7 +6,7 @@ describe('Tooltip', () => {
it('renders correctly', () => {
const tree = renderer
.create(
<Tooltip placement="auto" content="Tooltip text">
<Tooltip className="test-class" placement="auto" content="Tooltip text">
<a href="http://www.grafana.com">Link with tooltip</a>
</Tooltip>
)

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ import { Manager, Popper, Arrow } from 'react-popper';
interface IwithTooltipProps {
placement?: string;
content: string | ((props: any) => JSX.Element);
className?: string;
}
export default function withTooltip(WrappedComponent) {
@@ -39,10 +40,10 @@ export default function withTooltip(WrappedComponent) {
}
render() {
const { content } = this.props;
const { content, className } = this.props;
return (
<Manager className="popper__manager">
<Manager className={`popper__manager ${className || ''}`}>
<WrappedComponent {...this.props} tooltipSetState={this.setState} />
{this.state.show ? (
<Popper placement={this.state.placement} className="popper">

View File

@@ -1,108 +0,0 @@
import React, { Component } from 'react';
import { debounce } from 'lodash';
import Select from 'react-select';
import UserPickerOption from './UserPickerOption';
export interface IProps {
backendSrv: any;
teamId: string;
refreshList: any;
}
export interface User {
id: number;
name: string;
login: string;
email: string;
}
class UserPicker extends Component<IProps, any> {
debouncedSearchUsers: any;
backendSrv: any;
teamId: string;
refreshList: any;
constructor(props) {
super(props);
this.backendSrv = this.props.backendSrv;
this.teamId = this.props.teamId;
this.refreshList = this.props.refreshList;
this.searchUsers = this.searchUsers.bind(this);
this.handleChange = this.handleChange.bind(this);
this.addUser = this.addUser.bind(this);
this.toggleLoading = this.toggleLoading.bind(this);
this.debouncedSearchUsers = debounce(this.searchUsers, 300, {
leading: true,
trailing: false,
});
this.state = {
multi: false,
isLoading: false,
};
}
handleChange(user) {
this.addUser(user.id);
}
toggleLoading(isLoading) {
this.setState(prevState => {
return {
...prevState,
isLoading: isLoading,
};
});
}
addUser(userId) {
this.toggleLoading(true);
this.backendSrv.post(`/api/teams/${this.teamId}/members`, { userId: userId }).then(() => {
this.refreshList();
this.toggleLoading(false);
});
}
searchUsers(query) {
this.toggleLoading(true);
return this.backendSrv.get(`/api/users/search?perpage=10&page=1&query=${query}`).then(result => {
const users = result.users.map(user => {
return {
id: user.id,
label: `${user.login} - ${user.email}`,
avatarUrl: user.avatarUrl,
};
});
this.toggleLoading(false);
return { options: users };
});
}
render() {
const AsyncComponent = this.state.creatable ? Select.AsyncCreatable : Select.Async;
return (
<div className="user-picker">
<AsyncComponent
valueKey="id"
multi={this.state.multi}
labelKey="label"
cache={false}
isLoading={this.state.isLoading}
loadOptions={this.debouncedSearchUsers}
loadingPlaceholder="Loading..."
noResultsText="No users found"
onChange={this.handleChange}
className="width-8 gf-form-input gf-form-input--form-dropdown"
optionComponent={UserPickerOption}
placeholder="Choose"
/>
</div>
);
}
}
export default UserPicker;

View File

@@ -5,11 +5,11 @@
<i class="gf-form-input-icon fa fa-search"></i>
</label>
<div class="page-action-bar__spacer"></div>
<a class="btn btn-success" ng-href="{{ctrl.createDashboardUrl()}}">
<a class="btn btn-success" ng-href="{{ctrl.createDashboardUrl()}}" ng-if="ctrl.isEditor || ctrl.canSave">
<i class="fa fa-plus"></i>
Dashboard
</a>
<a class="btn btn-success" href="dashboards/folder/new" ng-if="!ctrl.folderId">
<a class="btn btn-success" href="dashboards/folder/new" ng-if="!ctrl.folderId && ctrl.isEditor">
<i class="fa fa-plus"></i>
Folder
</a>
@@ -95,22 +95,23 @@
</div>
</div>
<div class="search-results-container">
<dashboard-search-results
<dashboard-search-results
results="ctrl.sections"
editable="true"
on-selection-changed="ctrl.selectionChanged()"
on-tag-selected="ctrl.filterByTag($tag)" />
on-tag-selected="ctrl.filterByTag($tag)"
/>
</div>
</div>
</div>
<div ng-if="ctrl.folderId && !ctrl.hasFilters && ctrl.sections.length === 0">
<div ng-if="ctrl.canSave && ctrl.folderId && !ctrl.hasFilters && ctrl.sections.length === 0">
<empty-list-cta model="{
title: 'This folder doesn\'t have any dashboards yet',
buttonIcon: 'gicon gicon-dashboard-new',
buttonLink: 'dashboard/new?folderId={{ctrl.folderId}}',
buttonTitle: 'Create Dashboard',
proTip: 'Add dashboards into your folder at ->',
proTip: 'Add/move dashboards to your folder at ->',
proTipLink: 'dashboards',
proTipLinkTitle: 'Manage dashboards',
proTipTarget: ''

View File

@@ -3,22 +3,49 @@ import coreModule from 'app/core/core_module';
import appEvents from 'app/core/app_events';
import { SearchSrv } from 'app/core/services/search_srv';
class Query {
query: string;
mode: string;
tag: any[];
starred: boolean;
skipRecent: boolean;
skipStarred: boolean;
folderIds: number[];
}
export class ManageDashboardsCtrl {
public sections: any[];
tagFilterOptions: any[];
selectedTagFilter: any;
query: any;
query: Query;
navModel: any;
selectAllChecked = false;
// enable/disable actions depending on the folders or dashboards selected
canDelete = false;
canMove = false;
// filter variables
hasFilters = false;
selectAllChecked = false;
tagFilterOptions: any[];
selectedTagFilter: any;
starredFilterOptions = [{ text: 'Filter by Starred', disabled: true }, { text: 'Yes' }, { text: 'No' }];
selectedStarredFilter: any;
// used when managing dashboards for a specific folder
folderId?: number;
folderSlug?: string;
// if user can add new folders and/or add new dashboards
canSave: boolean;
// if user has editor role or higher
isEditor: boolean;
/** @ngInject */
constructor(private backendSrv, navModelSrv, private searchSrv: SearchSrv) {
constructor(private backendSrv, navModelSrv, private searchSrv: SearchSrv, private contextSrv) {
this.isEditor = this.contextSrv.isEditor;
this.query = {
query: '',
mode: 'tree',
@@ -26,6 +53,7 @@ export class ManageDashboardsCtrl {
starred: false,
skipRecent: true,
skipStarred: true,
folderIds: [],
};
if (this.folderId) {
@@ -34,15 +62,26 @@ export class ManageDashboardsCtrl {
this.selectedStarredFilter = this.starredFilterOptions[0];
this.getDashboards().then(() => {
this.getTags();
this.refreshList().then(() => {
this.initTagFilter();
});
}
getDashboards() {
return this.searchSrv.search(this.query).then(result => {
return this.initDashboardList(result);
});
refreshList() {
return this.searchSrv
.search(this.query)
.then(result => {
return this.initDashboardList(result);
})
.then(() => {
if (!this.folderSlug) {
return;
}
return this.backendSrv.getDashboard('db', this.folderSlug).then(dash => {
this.canSave = dash.meta.canSave;
});
});
}
initDashboardList(result: any) {
@@ -176,7 +215,7 @@ export class ManageDashboardsCtrl {
appEvents.emit('alert-success', [header, msg]);
}
this.getDashboards();
this.refreshList();
});
}
@@ -203,12 +242,12 @@ export class ManageDashboardsCtrl {
modalClass: 'modal--narrow',
model: {
dashboards: selectedDashboards,
afterSave: this.getDashboards.bind(this),
afterSave: this.refreshList.bind(this),
},
});
}
getTags() {
initTagFilter() {
return this.searchSrv.getDashboardTags().then(results => {
this.tagFilterOptions = [{ term: 'Filter By Tag', disabled: true }].concat(results);
this.selectedTagFilter = this.tagFilterOptions[0];
@@ -220,11 +259,11 @@ export class ManageDashboardsCtrl {
this.query.tag.push(tag);
}
return this.getDashboards();
return this.refreshList();
}
onQueryChange() {
return this.getDashboards();
return this.refreshList();
}
onTagFilterChange() {
@@ -235,7 +274,7 @@ export class ManageDashboardsCtrl {
removeTag(tag, evt) {
this.query.tag = _.without(this.query.tag, tag);
this.getDashboards();
this.refreshList();
if (evt) {
evt.stopPropagation();
evt.preventDefault();
@@ -244,13 +283,13 @@ export class ManageDashboardsCtrl {
removeStarred() {
this.query.starred = false;
return this.getDashboards();
return this.refreshList();
}
onStarredFilterChange() {
this.query.starred = this.selectedStarredFilter.text === 'Yes';
this.selectedStarredFilter = this.starredFilterOptions[0];
return this.getDashboards();
return this.refreshList();
}
onSelectAllChanged() {
@@ -272,7 +311,7 @@ export class ManageDashboardsCtrl {
this.query.query = '';
this.query.tag = [];
this.query.starred = false;
this.getDashboards();
this.refreshList();
}
createDashboardUrl() {
@@ -295,6 +334,7 @@ export function manageDashboardsDirective() {
controllerAs: 'ctrl',
scope: {
folderId: '=',
folderSlug: '=',
},
};
}

View File

@@ -10,8 +10,9 @@ export class InvitedCtrl {
$scope.navModel = {
main: {
icon: 'gicon gicon-branding',
text: 'Invite',
subTitle: 'Register your Grafana account',
breadcrumbs: [{ title: 'Login', url: '/login' }, { title: 'Invite' }],
breadcrumbs: [{ title: 'Login', url: '/login' }],
},
};

View File

@@ -16,8 +16,9 @@ export class ResetPasswordCtrl {
$scope.navModel = {
main: {
icon: 'gicon gicon-branding',
text: 'Reset Password',
subTitle: 'Reset your Grafana password',
breadcrumbs: [{ title: 'Login', url: 'login' }, { title: 'Reset Password' }],
breadcrumbs: [{ title: 'Login', url: 'login' }],
},
};

View File

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

View File

@@ -1,150 +0,0 @@
define([
'jquery',
'angular',
'../core_module',
'lodash',
],
function ($, angular, coreModule, _) {
'use strict';
var editViewMap = {
'settings': { src: 'public/app/features/dashboard/partials/settings.html'},
'annotations': { src: 'public/app/features/annotations/partials/editor.html'},
'templating': { src: 'public/app/features/templating/partials/editor.html'},
'history': { html: '<gf-dashboard-history dashboard="dashboard"></gf-dashboard-history>'},
'timepicker': { src: 'public/app/features/dashboard/timepicker/dropdown.html' },
'import': { html: '<dash-import dismiss="dismiss()"></dash-import>', isModal: true },
'permissions': { html: '<dash-acl-modal dismiss="dismiss()"></dash-acl-modal>', isModal: true },
'new-folder': {
isModal: true,
html: '<folder-modal dismiss="dismiss()"></folder-modal>',
modalClass: 'modal--narrow'
}
};
coreModule.default.directive('dashEditorView', function($compile, $location, $rootScope) {
return {
restrict: 'A',
link: function(scope, elem) {
var editorScope;
var modalScope;
var lastEditView;
function hideEditorPane(hideToShowOtherView) {
if (editorScope) {
editorScope.dismiss(hideToShowOtherView);
}
}
function showEditorPane(evt, options) {
if (options.editview) {
_.defaults(options, editViewMap[options.editview]);
}
if (lastEditView && lastEditView === options.editview) {
hideEditorPane(false);
return;
}
hideEditorPane(true);
lastEditView = options.editview;
editorScope = options.scope ? options.scope.$new() : scope.$new();
editorScope.dismiss = function(hideToShowOtherView) {
if (modalScope) {
modalScope.dismiss();
modalScope = null;
}
editorScope.$destroy();
lastEditView = null;
editorScope = null;
elem.removeClass('dash-edit-view--open');
if (!hideToShowOtherView) {
setTimeout(function() {
elem.empty();
}, 250);
}
if (options.editview) {
var urlParams = $location.search();
if (options.editview === urlParams.editview) {
delete urlParams.editview;
// even though we always are in apply phase here
// some angular bug is causing location search updates to
// not happen always so this is a hack fix or that
setTimeout(function() {
$rootScope.$apply(function() {
$location.search(urlParams);
});
});
}
}
};
if (options.isModal) {
modalScope = $rootScope.$new();
modalScope.$on("$destroy", function() {
editorScope.dismiss();
});
$rootScope.appEvent('show-modal', {
templateHtml: options.html,
scope: modalScope,
backdrop: 'static',
modalClass: options.modalClass,
});
return;
}
var view;
if (options.src) {
view = angular.element(document.createElement('div'));
view.html('<div class="tabbed-view" ng-include="' + "'" + options.src + "'" + '"></div>');
} else {
view = angular.element(document.createElement('div'));
view.addClass('tabbed-view');
view.html(options.html);
}
$compile(view)(editorScope);
setTimeout(function() {
elem.empty();
elem.append(view);
setTimeout(function() {
elem.addClass('dash-edit-view--open');
}, 10);
}, 10);
}
scope.$watch("ctrl.dashboardViewState.state.editview", function(newValue, oldValue) {
if (newValue) {
showEditorPane(null, {editview: newValue});
} else if (oldValue) {
if (lastEditView === oldValue) {
hideEditorPane();
}
}
});
scope.$on("$destroy", hideEditorPane);
scope.onAppEvent('hide-dash-editor', function() {
hideEditorPane(false);
});
scope.onAppEvent('show-dash-editor', showEditorPane);
scope.onAppEvent('panel-fullscreen-enter', function() {
scope.appEvent('hide-dash-editor');
});
}
};
});
});

View File

@@ -10,7 +10,7 @@ export class BridgeSrv {
private fullPageReloadRoutes;
/** @ngInject */
constructor(private $location, private $timeout, private $window, private $rootScope) {
constructor(private $location, private $timeout, private $window, private $rootScope, private $route) {
this.appSubUrl = config.appSubUrl;
this.fullPageReloadRoutes = ['/logout'];
}
@@ -29,14 +29,14 @@ export class BridgeSrv {
this.$rootScope.$on('$routeUpdate', (evt, data) => {
let angularUrl = this.$location.url();
if (store.view.currentUrl !== angularUrl) {
store.view.updatePathAndQuery(this.$location.path(), this.$location.search());
store.view.updatePathAndQuery(this.$location.path(), this.$location.search(), this.$route.current.params);
}
});
this.$rootScope.$on('$routeChangeSuccess', (evt, data) => {
let angularUrl = this.$location.url();
if (store.view.currentUrl !== angularUrl) {
store.view.updatePathAndQuery(this.$location.path(), this.$location.search());
store.view.updatePathAndQuery(this.$location.path(), this.$location.search(), this.$route.current.params);
}
});

View File

@@ -10,7 +10,7 @@ describe('BridgeSrv', () => {
let searchSrv;
beforeEach(() => {
searchSrv = new BridgeSrv(null, null, null, null);
searchSrv = new BridgeSrv(null, null, null, null, null);
});
describe('With /subUrl as appSubUrl', () => {

View File

@@ -49,7 +49,7 @@ describe('ManageDashboards', () => {
},
];
ctrl = createCtrlWithStubs(response);
return ctrl.getDashboards();
return ctrl.refreshList();
});
it('should set checked to false on all sections and children', () => {
@@ -88,7 +88,7 @@ describe('ManageDashboards', () => {
];
ctrl = createCtrlWithStubs(response);
ctrl.folderId = 410;
return ctrl.getDashboards();
return ctrl.refreshList();
});
it('should set hide header to true on section', () => {
@@ -137,7 +137,7 @@ describe('ManageDashboards', () => {
ctrl.canMove = true;
ctrl.canDelete = true;
ctrl.selectAllChecked = true;
return ctrl.getDashboards();
return ctrl.refreshList();
});
it('should set checked to false on all sections and children', () => {
@@ -567,5 +567,5 @@ function createCtrlWithStubs(searchResponse: any, tags?: any) {
},
};
return new ManageDashboardsCtrl({}, { getNav: () => {} }, <SearchSrv>searchSrvStub);
return new ManageDashboardsCtrl({}, { getNav: () => {} }, <SearchSrv>searchSrvStub, { isEditor: true });
}

View File

@@ -12,7 +12,11 @@ export function toUrlParams(a) {
let add = function(k, v) {
v = typeof v === 'function' ? v() : v === null ? '' : v === undefined ? '' : v;
s[s.length] = encodeURIComponent(k) + '=' + encodeURIComponent(v);
if (typeof v !== 'boolean') {
s[s.length] = encodeURIComponent(k) + '=' + encodeURIComponent(v);
} else {
s[s.length] = encodeURIComponent(k);
}
};
let buildParams = function(prefix, obj) {