mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Merge branch 'master' into data-source-instance-to-react
This commit is contained in:
17
public/app/core/components/PageLoader/PageLoader.tsx
Normal file
17
public/app/core/components/PageLoader/PageLoader.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React, { SFC } from 'react';
|
||||
|
||||
interface Props {
|
||||
pageName: string;
|
||||
}
|
||||
|
||||
const PageLoader: SFC<Props> = ({ pageName }) => {
|
||||
const loadingText = `Loading ${pageName}...`;
|
||||
return (
|
||||
<div className="page-loader-wrapper">
|
||||
<i className="page-loader-wrapper__spinner fa fa-spinner fa-spin" />
|
||||
<div className="page-loader-wrapper__text">{loadingText}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageLoader;
|
||||
@@ -54,11 +54,11 @@ class AddPermissions extends Component<Props, NewDashboardAclItem> {
|
||||
};
|
||||
|
||||
onUserSelected = (user: User) => {
|
||||
this.setState({ userId: user ? user.id : 0 });
|
||||
this.setState({ userId: user && !Array.isArray(user) ? user.id : 0 });
|
||||
};
|
||||
|
||||
onTeamSelected = (team: Team) => {
|
||||
this.setState({ teamId: team ? team.id : 0 });
|
||||
this.setState({ teamId: team && !Array.isArray(team) ? team.id : 0 });
|
||||
};
|
||||
|
||||
onPermissionChanged = (permission: OptionWithDescription) => {
|
||||
@@ -86,7 +86,6 @@ class AddPermissions extends Component<Props, NewDashboardAclItem> {
|
||||
const newItem = this.state;
|
||||
const pickerClassName = 'width-20';
|
||||
const isValid = this.isValid();
|
||||
|
||||
return (
|
||||
<div className="gf-form-inline cta-form">
|
||||
<button className="cta-form__close btn btn-transparent" onClick={onCancel}>
|
||||
@@ -111,21 +110,13 @@ class AddPermissions extends Component<Props, NewDashboardAclItem> {
|
||||
|
||||
{newItem.type === AclTarget.User ? (
|
||||
<div className="gf-form">
|
||||
<UserPicker
|
||||
onSelected={this.onUserSelected}
|
||||
value={newItem.userId.toString()}
|
||||
className={pickerClassName}
|
||||
/>
|
||||
<UserPicker onSelected={this.onUserSelected} className={pickerClassName} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{newItem.type === AclTarget.Team ? (
|
||||
<div className="gf-form">
|
||||
<TeamPicker
|
||||
onSelected={this.onTeamSelected}
|
||||
value={newItem.teamId.toString()}
|
||||
className={pickerClassName}
|
||||
/>
|
||||
<TeamPicker onSelected={this.onTeamSelected} className={pickerClassName} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -133,9 +124,8 @@ class AddPermissions extends Component<Props, NewDashboardAclItem> {
|
||||
<DescriptionPicker
|
||||
optionsWithDesc={dashboardPermissionLevels}
|
||||
onSelected={this.onPermissionChanged}
|
||||
value={newItem.permission}
|
||||
disabled={false}
|
||||
className={'gf-form-input--form-dropdown-right'}
|
||||
className={'gf-form-select-box__control--menu-right'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -26,9 +26,9 @@ export default class DisabledPermissionListItem extends Component<Props, any> {
|
||||
<DescriptionPicker
|
||||
optionsWithDesc={dashboardPermissionLevels}
|
||||
onSelected={() => {}}
|
||||
value={item.permission}
|
||||
disabled={true}
|
||||
className={'gf-form-input--form-dropdown-right'}
|
||||
className={'gf-form-select-box__control--menu-right'}
|
||||
value={item.permission}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -77,9 +77,9 @@ export default class PermissionsListItem extends PureComponent<Props> {
|
||||
<DescriptionPicker
|
||||
optionsWithDesc={dashboardPermissionLevels}
|
||||
onSelected={this.onPermissionChanged}
|
||||
value={item.permission}
|
||||
disabled={item.inherited}
|
||||
className={'gf-form-input--form-dropdown-right'}
|
||||
className={'gf-form-select-box__control--menu-right'}
|
||||
value={item.permission}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -1,56 +1,25 @@
|
||||
import React, { Component } from 'react';
|
||||
import React from 'react';
|
||||
import { components } from 'react-select';
|
||||
import { OptionProps } from 'react-select/lib/components/Option';
|
||||
|
||||
export interface Props {
|
||||
onSelect: any;
|
||||
onFocus: any;
|
||||
option: any;
|
||||
isFocused: any;
|
||||
className: any;
|
||||
// https://github.com/JedWatson/react-select/issues/3038
|
||||
interface ExtendedOptionProps extends OptionProps<any> {
|
||||
data: any;
|
||||
}
|
||||
|
||||
class DescriptionOption extends Component<Props, 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`}
|
||||
>
|
||||
export const Option = (props: ExtendedOptionProps) => {
|
||||
const { children, isSelected, data, className } = props;
|
||||
return (
|
||||
<components.Option {...props}>
|
||||
<div className={`description-picker-option__button btn btn-link ${className}`}>
|
||||
{isSelected && <i className="fa fa-check pull-right" aria-hidden="true" />}
|
||||
<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 className="muted width-17">{data.description}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</components.Option>
|
||||
);
|
||||
};
|
||||
|
||||
export default DescriptionOption;
|
||||
export default Option;
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import React, { Component } from 'react';
|
||||
import Select from 'react-select';
|
||||
import DescriptionOption from './DescriptionOption';
|
||||
|
||||
export interface Props {
|
||||
optionsWithDesc: OptionWithDescription[];
|
||||
onSelected: (permission) => void;
|
||||
value: number;
|
||||
disabled: boolean;
|
||||
className?: string;
|
||||
}
|
||||
import IndicatorsContainer from './IndicatorsContainer';
|
||||
import ResetStyles from './ResetStyles';
|
||||
import NoOptionsMessage from './NoOptionsMessage';
|
||||
|
||||
export interface OptionWithDescription {
|
||||
value: any;
|
||||
@@ -16,24 +11,38 @@ export interface OptionWithDescription {
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface Props {
|
||||
optionsWithDesc: OptionWithDescription[];
|
||||
onSelected: (permission) => void;
|
||||
disabled: boolean;
|
||||
className?: string;
|
||||
value?: any;
|
||||
}
|
||||
|
||||
const getSelectedOption = (optionsWithDesc, value) => optionsWithDesc.find(option => option.value === value);
|
||||
|
||||
class DescriptionPicker extends Component<Props, any> {
|
||||
render() {
|
||||
const { optionsWithDesc, onSelected, value, disabled, className } = this.props;
|
||||
|
||||
const { optionsWithDesc, onSelected, disabled, className, value } = this.props;
|
||||
const selectedOption = getSelectedOption(optionsWithDesc, value);
|
||||
return (
|
||||
<div className="permissions-picker">
|
||||
<Select
|
||||
value={value}
|
||||
valueKey="value"
|
||||
multi={false}
|
||||
clearable={false}
|
||||
labelKey="label"
|
||||
options={optionsWithDesc}
|
||||
onChange={onSelected}
|
||||
className={`width-7 gf-form-input gf-form-input--form-dropdown ${className || ''}`}
|
||||
optionComponent={DescriptionOption}
|
||||
placeholder="Choose"
|
||||
disabled={disabled}
|
||||
classNamePrefix={`gf-form-select-box`}
|
||||
className={`width-7 gf-form-input gf-form-input--form-dropdown ${className || ''}`}
|
||||
options={optionsWithDesc}
|
||||
components={{
|
||||
Option: DescriptionOption,
|
||||
IndicatorsContainer,
|
||||
NoOptionsMessage,
|
||||
}}
|
||||
styles={ResetStyles}
|
||||
isDisabled={disabled}
|
||||
onChange={onSelected}
|
||||
getOptionValue={i => i.value}
|
||||
getOptionLabel={i => i.label}
|
||||
value={selectedOption}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
15
public/app/core/components/Picker/IndicatorsContainer.tsx
Normal file
15
public/app/core/components/Picker/IndicatorsContainer.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import { components } from 'react-select';
|
||||
|
||||
export const IndicatorsContainer = props => {
|
||||
const isOpen = props.selectProps.menuIsOpen;
|
||||
return (
|
||||
<components.IndicatorsContainer {...props}>
|
||||
<span
|
||||
className={`gf-form-select-box__select-arrow ${isOpen ? `gf-form-select-box__select-arrow--reversed` : ''}`}
|
||||
/>
|
||||
</components.IndicatorsContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default IndicatorsContainer;
|
||||
18
public/app/core/components/Picker/NoOptionsMessage.tsx
Normal file
18
public/app/core/components/Picker/NoOptionsMessage.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
import { components } from 'react-select';
|
||||
import { OptionProps } from 'react-select/lib/components/Option';
|
||||
|
||||
export interface Props {
|
||||
children: Element;
|
||||
}
|
||||
|
||||
export const PickerOption = (props: OptionProps<any>) => {
|
||||
const { children, className } = props;
|
||||
return (
|
||||
<components.Option {...props}>
|
||||
<div className={`description-picker-option__button btn btn-link ${className}`}>{children}</div>
|
||||
</components.Option>
|
||||
);
|
||||
};
|
||||
|
||||
export default PickerOption;
|
||||
@@ -3,10 +3,26 @@ import renderer from 'react-test-renderer';
|
||||
import PickerOption from './PickerOption';
|
||||
|
||||
const model = {
|
||||
onSelect: () => {},
|
||||
onFocus: () => {},
|
||||
isFocused: () => {},
|
||||
option: {
|
||||
cx: jest.fn(),
|
||||
clearValue: jest.fn(),
|
||||
onSelect: jest.fn(),
|
||||
getStyles: jest.fn(),
|
||||
getValue: jest.fn(),
|
||||
hasValue: true,
|
||||
isMulti: false,
|
||||
options: [],
|
||||
selectOption: jest.fn(),
|
||||
selectProps: {},
|
||||
setValue: jest.fn(),
|
||||
isDisabled: false,
|
||||
isFocused: false,
|
||||
isSelected: false,
|
||||
innerRef: null,
|
||||
innerProps: null,
|
||||
label: 'Option label',
|
||||
type: null,
|
||||
children: 'Model title',
|
||||
data: {
|
||||
title: 'Model title',
|
||||
avatarUrl: 'url/to/avatar',
|
||||
label: 'User picker label',
|
||||
|
||||
@@ -1,54 +1,22 @@
|
||||
import React, { Component } from 'react';
|
||||
import React from 'react';
|
||||
import { components } from 'react-select';
|
||||
import { OptionProps } from 'react-select/lib/components/Option';
|
||||
|
||||
export interface Props {
|
||||
onSelect: any;
|
||||
onFocus: any;
|
||||
option: any;
|
||||
isFocused: any;
|
||||
className: any;
|
||||
// https://github.com/JedWatson/react-select/issues/3038
|
||||
interface ExtendedOptionProps extends OptionProps<any> {
|
||||
data: any;
|
||||
}
|
||||
|
||||
class UserPickerOption extends Component<Props, 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" />
|
||||
export const PickerOption = (props: ExtendedOptionProps) => {
|
||||
const { children, data, className } = props;
|
||||
return (
|
||||
<components.Option {...props}>
|
||||
<div className={`description-picker-option__button btn btn-link ${className}`}>
|
||||
{data.avatarUrl && <img src={data.avatarUrl} alt={data.label} className="user-picker-option__avatar" />}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</components.Option>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserPickerOption;
|
||||
export default PickerOption;
|
||||
|
||||
23
public/app/core/components/Picker/ResetStyles.tsx
Normal file
23
public/app/core/components/Picker/ResetStyles.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
export default {
|
||||
clearIndicator: () => ({}),
|
||||
container: () => ({}),
|
||||
control: () => ({}),
|
||||
dropdownIndicator: () => ({}),
|
||||
group: () => ({}),
|
||||
groupHeading: () => ({}),
|
||||
indicatorsContainer: () => ({}),
|
||||
indicatorSeparator: () => ({}),
|
||||
input: () => ({}),
|
||||
loadingIndicator: () => ({}),
|
||||
loadingMessage: () => ({}),
|
||||
menu: () => ({}),
|
||||
menuList: () => ({}),
|
||||
multiValue: () => ({}),
|
||||
multiValueLabel: () => ({}),
|
||||
multiValueRemove: () => ({}),
|
||||
noOptionsMessage: () => ({}),
|
||||
option: () => ({}),
|
||||
placeholder: () => ({}),
|
||||
singleValue: () => ({}),
|
||||
valueContainer: () => ({}),
|
||||
};
|
||||
@@ -1,18 +1,11 @@
|
||||
import React, { Component } from 'react';
|
||||
import Select from 'react-select';
|
||||
import AsyncSelect from 'react-select/lib/Async';
|
||||
import PickerOption from './PickerOption';
|
||||
import { debounce } from 'lodash';
|
||||
import { getBackendSrv } from 'app/core/services/backend_srv';
|
||||
|
||||
export interface Props {
|
||||
onSelected: (team: Team) => void;
|
||||
value?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface State {
|
||||
isLoading;
|
||||
}
|
||||
import ResetStyles from './ResetStyles';
|
||||
import IndicatorsContainer from './IndicatorsContainer';
|
||||
import NoOptionsMessage from './NoOptionsMessage';
|
||||
|
||||
export interface Team {
|
||||
id: number;
|
||||
@@ -21,6 +14,15 @@ export interface Team {
|
||||
avatarUrl: string;
|
||||
}
|
||||
|
||||
export interface Props {
|
||||
onSelected: (team: Team) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface State {
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export class TeamPicker extends Component<Props, State> {
|
||||
debouncedSearch: any;
|
||||
|
||||
@@ -31,7 +33,7 @@ export class TeamPicker extends Component<Props, State> {
|
||||
|
||||
this.debouncedSearch = debounce(this.search, 300, {
|
||||
leading: true,
|
||||
trailing: false,
|
||||
trailing: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -39,7 +41,7 @@ export class TeamPicker extends Component<Props, State> {
|
||||
const backendSrv = getBackendSrv();
|
||||
this.setState({ isLoading: true });
|
||||
|
||||
return backendSrv.get(`/api/teams/search?perpage=50&page=1&query=${query}`).then(result => {
|
||||
return backendSrv.get(`/api/teams/search?perpage=10&page=1&query=${query}`).then(result => {
|
||||
const teams = result.teams.map(team => {
|
||||
return {
|
||||
id: team.id,
|
||||
@@ -50,31 +52,34 @@ export class TeamPicker extends Component<Props, State> {
|
||||
});
|
||||
|
||||
this.setState({ isLoading: false });
|
||||
return { options: teams };
|
||||
return teams;
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { onSelected, value, className } = this.props;
|
||||
const { onSelected, className } = this.props;
|
||||
const { isLoading } = this.state;
|
||||
|
||||
return (
|
||||
<div className="user-picker">
|
||||
<Select.Async
|
||||
valueKey="id"
|
||||
multi={false}
|
||||
labelKey="label"
|
||||
cache={false}
|
||||
<AsyncSelect
|
||||
classNamePrefix={`gf-form-select-box`}
|
||||
isMulti={false}
|
||||
isLoading={isLoading}
|
||||
defaultOptions={true}
|
||||
loadOptions={this.debouncedSearch}
|
||||
loadingPlaceholder="Loading..."
|
||||
noResultsText="No teams found"
|
||||
onChange={onSelected}
|
||||
className={`gf-form-input gf-form-input--form-dropdown ${className || ''}`}
|
||||
optionComponent={PickerOption}
|
||||
styles={ResetStyles}
|
||||
components={{
|
||||
Option: PickerOption,
|
||||
IndicatorsContainer,
|
||||
NoOptionsMessage,
|
||||
}}
|
||||
placeholder="Select a team"
|
||||
value={value}
|
||||
autosize={true}
|
||||
loadingMessage={() => 'Loading...'}
|
||||
noOptionsMessage={() => 'No teams found'}
|
||||
getOptionValue={i => i.id}
|
||||
getOptionLabel={i => i.label}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import React, { Component } from 'react';
|
||||
import Select from 'react-select';
|
||||
import AsyncSelect from 'react-select/lib/Async';
|
||||
import PickerOption from './PickerOption';
|
||||
import { debounce } from 'lodash';
|
||||
import { getBackendSrv } from 'app/core/services/backend_srv';
|
||||
import { User } from 'app/types';
|
||||
import ResetStyles from './ResetStyles';
|
||||
import IndicatorsContainer from './IndicatorsContainer';
|
||||
import NoOptionsMessage from './NoOptionsMessage';
|
||||
|
||||
export interface Props {
|
||||
onSelected: (user: User) => void;
|
||||
value?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -31,20 +33,17 @@ export class UserPicker extends Component<Props, State> {
|
||||
|
||||
search(query?: string) {
|
||||
const backendSrv = getBackendSrv();
|
||||
|
||||
this.setState({ isLoading: true });
|
||||
|
||||
return backendSrv
|
||||
.get(`/api/org/users?query=${query}&limit=10`)
|
||||
.then(result => {
|
||||
return {
|
||||
options: result.map(user => ({
|
||||
id: user.userId,
|
||||
label: `${user.login} - ${user.email}`,
|
||||
avatarUrl: user.avatarUrl,
|
||||
login: user.login,
|
||||
})),
|
||||
};
|
||||
return result.map(user => ({
|
||||
id: user.userId,
|
||||
label: `${user.login} - ${user.email}`,
|
||||
avatarUrl: user.avatarUrl,
|
||||
login: user.login,
|
||||
}));
|
||||
})
|
||||
.finally(() => {
|
||||
this.setState({ isLoading: false });
|
||||
@@ -52,26 +51,30 @@ export class UserPicker extends Component<Props, State> {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { value, className } = this.props;
|
||||
const { className, onSelected } = this.props;
|
||||
const { isLoading } = this.state;
|
||||
|
||||
return (
|
||||
<div className="user-picker">
|
||||
<Select.Async
|
||||
valueKey="id"
|
||||
multi={false}
|
||||
labelKey="label"
|
||||
cache={false}
|
||||
<AsyncSelect
|
||||
classNamePrefix={`gf-form-select-box`}
|
||||
isMulti={false}
|
||||
isLoading={isLoading}
|
||||
defaultOptions={true}
|
||||
loadOptions={this.debouncedSearch}
|
||||
loadingPlaceholder="Loading..."
|
||||
noResultsText="No users found"
|
||||
onChange={this.props.onSelected}
|
||||
onChange={onSelected}
|
||||
className={`gf-form-input gf-form-input--form-dropdown ${className || ''}`}
|
||||
optionComponent={PickerOption}
|
||||
styles={ResetStyles}
|
||||
components={{
|
||||
Option: PickerOption,
|
||||
IndicatorsContainer,
|
||||
NoOptionsMessage,
|
||||
}}
|
||||
placeholder="Select user"
|
||||
value={value}
|
||||
autosize={true}
|
||||
loadingMessage={() => 'Loading...'}
|
||||
noOptionsMessage={() => 'No users found'}
|
||||
getOptionValue={i => i.id}
|
||||
getOptionLabel={i => i.label}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
// 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>
|
||||
<div>
|
||||
<div
|
||||
className="description-picker-option__button btn btn-link class-for-user-picker"
|
||||
>
|
||||
<img
|
||||
alt="User picker label"
|
||||
className="user-picker-option__avatar"
|
||||
src="url/to/avatar"
|
||||
/>
|
||||
Model title
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -5,85 +5,115 @@ exports[`TeamPicker renders correctly 1`] = `
|
||||
className="user-picker"
|
||||
>
|
||||
<div
|
||||
className="Select gf-form-input gf-form-input--form-dropdown is-clearable is-loading is-searchable Select--single"
|
||||
className="css-0 gf-form-input gf-form-input--form-dropdown"
|
||||
onKeyDown={[Function]}
|
||||
>
|
||||
<div
|
||||
className="Select-control"
|
||||
onKeyDown={[Function]}
|
||||
className="css-0 gf-form-select-box__control"
|
||||
onMouseDown={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
onTouchMove={[Function]}
|
||||
onTouchStart={[Function]}
|
||||
>
|
||||
<div
|
||||
className="Select-multi-value-wrapper"
|
||||
id="react-select-2--value"
|
||||
className="css-0 gf-form-select-box__value-container"
|
||||
>
|
||||
<div
|
||||
className="Select-placeholder"
|
||||
className="css-0 gf-form-select-box__placeholder"
|
||||
>
|
||||
Loading...
|
||||
Select a team
|
||||
</div>
|
||||
<div
|
||||
className="Select-input"
|
||||
style={
|
||||
Object {
|
||||
"display": "inline-block",
|
||||
}
|
||||
}
|
||||
className="css-0"
|
||||
>
|
||||
<input
|
||||
aria-activedescendant="react-select-2--value"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="false"
|
||||
aria-owns=""
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onFocus={[Function]}
|
||||
required={false}
|
||||
role="combobox"
|
||||
style={
|
||||
Object {
|
||||
"boxSizing": "content-box",
|
||||
"width": "5px",
|
||||
}
|
||||
}
|
||||
value=""
|
||||
/>
|
||||
<div
|
||||
className="gf-form-select-box__input"
|
||||
style={
|
||||
Object {
|
||||
"height": 0,
|
||||
"left": 0,
|
||||
"overflow": "scroll",
|
||||
"position": "absolute",
|
||||
"top": 0,
|
||||
"visibility": "hidden",
|
||||
"whiteSpace": "pre",
|
||||
"display": "inline-block",
|
||||
}
|
||||
}
|
||||
>
|
||||
|
||||
<input
|
||||
aria-autocomplete="list"
|
||||
autoCapitalize="none"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
disabled={false}
|
||||
id="react-select-2-input"
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onFocus={[Function]}
|
||||
spellCheck="false"
|
||||
style={
|
||||
Object {
|
||||
"background": 0,
|
||||
"border": 0,
|
||||
"boxSizing": "content-box",
|
||||
"color": "inherit",
|
||||
"fontSize": "inherit",
|
||||
"opacity": 1,
|
||||
"outline": 0,
|
||||
"padding": 0,
|
||||
"width": "1px",
|
||||
}
|
||||
}
|
||||
tabIndex="0"
|
||||
theme={
|
||||
Object {
|
||||
"borderRadius": 4,
|
||||
"colors": Object {
|
||||
"danger": "#DE350B",
|
||||
"dangerLight": "#FFBDAD",
|
||||
"neutral0": "hsl(0, 0%, 100%)",
|
||||
"neutral10": "hsl(0, 0%, 90%)",
|
||||
"neutral20": "hsl(0, 0%, 80%)",
|
||||
"neutral30": "hsl(0, 0%, 70%)",
|
||||
"neutral40": "hsl(0, 0%, 60%)",
|
||||
"neutral5": "hsl(0, 0%, 95%)",
|
||||
"neutral50": "hsl(0, 0%, 50%)",
|
||||
"neutral60": "hsl(0, 0%, 40%)",
|
||||
"neutral70": "hsl(0, 0%, 30%)",
|
||||
"neutral80": "hsl(0, 0%, 20%)",
|
||||
"neutral90": "hsl(0, 0%, 10%)",
|
||||
"primary": "#2684FF",
|
||||
"primary25": "#DEEBFF",
|
||||
"primary50": "#B2D4FF",
|
||||
"primary75": "#4C9AFF",
|
||||
},
|
||||
"spacing": Object {
|
||||
"baseUnit": 4,
|
||||
"controlHeight": 38,
|
||||
"menuGutter": 8,
|
||||
},
|
||||
}
|
||||
}
|
||||
type="text"
|
||||
value=""
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"height": 0,
|
||||
"left": 0,
|
||||
"overflow": "scroll",
|
||||
"position": "absolute",
|
||||
"top": 0,
|
||||
"visibility": "hidden",
|
||||
"whiteSpace": "pre",
|
||||
}
|
||||
}
|
||||
>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Select-loading-zone"
|
||||
<div
|
||||
className="css-0 gf-form-select-box__indicators"
|
||||
>
|
||||
<span
|
||||
className="Select-loading"
|
||||
className="gf-form-select-box__select-arrow "
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className="Select-arrow-zone"
|
||||
onMouseDown={[Function]}
|
||||
>
|
||||
<span
|
||||
className="Select-arrow"
|
||||
onMouseDown={[Function]}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,85 +5,115 @@ exports[`UserPicker renders correctly 1`] = `
|
||||
className="user-picker"
|
||||
>
|
||||
<div
|
||||
className="Select gf-form-input gf-form-input--form-dropdown is-clearable is-loading is-searchable Select--single"
|
||||
className="css-0 gf-form-input gf-form-input--form-dropdown"
|
||||
onKeyDown={[Function]}
|
||||
>
|
||||
<div
|
||||
className="Select-control"
|
||||
onKeyDown={[Function]}
|
||||
className="css-0 gf-form-select-box__control"
|
||||
onMouseDown={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
onTouchMove={[Function]}
|
||||
onTouchStart={[Function]}
|
||||
>
|
||||
<div
|
||||
className="Select-multi-value-wrapper"
|
||||
id="react-select-2--value"
|
||||
className="css-0 gf-form-select-box__value-container"
|
||||
>
|
||||
<div
|
||||
className="Select-placeholder"
|
||||
className="css-0 gf-form-select-box__placeholder"
|
||||
>
|
||||
Loading...
|
||||
Select user
|
||||
</div>
|
||||
<div
|
||||
className="Select-input"
|
||||
style={
|
||||
Object {
|
||||
"display": "inline-block",
|
||||
}
|
||||
}
|
||||
className="css-0"
|
||||
>
|
||||
<input
|
||||
aria-activedescendant="react-select-2--value"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="false"
|
||||
aria-owns=""
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onFocus={[Function]}
|
||||
required={false}
|
||||
role="combobox"
|
||||
style={
|
||||
Object {
|
||||
"boxSizing": "content-box",
|
||||
"width": "5px",
|
||||
}
|
||||
}
|
||||
value=""
|
||||
/>
|
||||
<div
|
||||
className="gf-form-select-box__input"
|
||||
style={
|
||||
Object {
|
||||
"height": 0,
|
||||
"left": 0,
|
||||
"overflow": "scroll",
|
||||
"position": "absolute",
|
||||
"top": 0,
|
||||
"visibility": "hidden",
|
||||
"whiteSpace": "pre",
|
||||
"display": "inline-block",
|
||||
}
|
||||
}
|
||||
>
|
||||
|
||||
<input
|
||||
aria-autocomplete="list"
|
||||
autoCapitalize="none"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
disabled={false}
|
||||
id="react-select-2-input"
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onFocus={[Function]}
|
||||
spellCheck="false"
|
||||
style={
|
||||
Object {
|
||||
"background": 0,
|
||||
"border": 0,
|
||||
"boxSizing": "content-box",
|
||||
"color": "inherit",
|
||||
"fontSize": "inherit",
|
||||
"opacity": 1,
|
||||
"outline": 0,
|
||||
"padding": 0,
|
||||
"width": "1px",
|
||||
}
|
||||
}
|
||||
tabIndex="0"
|
||||
theme={
|
||||
Object {
|
||||
"borderRadius": 4,
|
||||
"colors": Object {
|
||||
"danger": "#DE350B",
|
||||
"dangerLight": "#FFBDAD",
|
||||
"neutral0": "hsl(0, 0%, 100%)",
|
||||
"neutral10": "hsl(0, 0%, 90%)",
|
||||
"neutral20": "hsl(0, 0%, 80%)",
|
||||
"neutral30": "hsl(0, 0%, 70%)",
|
||||
"neutral40": "hsl(0, 0%, 60%)",
|
||||
"neutral5": "hsl(0, 0%, 95%)",
|
||||
"neutral50": "hsl(0, 0%, 50%)",
|
||||
"neutral60": "hsl(0, 0%, 40%)",
|
||||
"neutral70": "hsl(0, 0%, 30%)",
|
||||
"neutral80": "hsl(0, 0%, 20%)",
|
||||
"neutral90": "hsl(0, 0%, 10%)",
|
||||
"primary": "#2684FF",
|
||||
"primary25": "#DEEBFF",
|
||||
"primary50": "#B2D4FF",
|
||||
"primary75": "#4C9AFF",
|
||||
},
|
||||
"spacing": Object {
|
||||
"baseUnit": 4,
|
||||
"controlHeight": 38,
|
||||
"menuGutter": 8,
|
||||
},
|
||||
}
|
||||
}
|
||||
type="text"
|
||||
value=""
|
||||
/>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"height": 0,
|
||||
"left": 0,
|
||||
"overflow": "scroll",
|
||||
"position": "absolute",
|
||||
"top": 0,
|
||||
"visibility": "hidden",
|
||||
"whiteSpace": "pre",
|
||||
}
|
||||
}
|
||||
>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Select-loading-zone"
|
||||
<div
|
||||
className="css-0 gf-form-select-box__indicators"
|
||||
>
|
||||
<span
|
||||
className="Select-loading"
|
||||
className="gf-form-select-box__select-arrow "
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className="Select-arrow-zone"
|
||||
onMouseDown={[Function]}
|
||||
>
|
||||
<span
|
||||
className="Select-arrow"
|
||||
onMouseDown={[Function]}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,17 +5,12 @@ export interface Props {
|
||||
label: string;
|
||||
removeIcon: boolean;
|
||||
count: number;
|
||||
onClick: any;
|
||||
onClick?: any;
|
||||
}
|
||||
|
||||
export class TagBadge extends React.Component<Props, any> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.onClick = this.onClick.bind(this);
|
||||
}
|
||||
|
||||
onClick(event) {
|
||||
this.props.onClick(event);
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -28,7 +23,7 @@ export class TagBadge extends React.Component<Props, any> {
|
||||
const countLabel = count !== 0 && <span className="tag-count-label">{`(${count})`}</span>;
|
||||
|
||||
return (
|
||||
<span className={`label label-tag`} onClick={this.onClick} style={tagStyle}>
|
||||
<span className={`label label-tag`} style={tagStyle}>
|
||||
{removeIcon && <i className="fa fa-remove" />}
|
||||
{label} {countLabel}
|
||||
</span>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import _ from 'lodash';
|
||||
import React from 'react';
|
||||
import { Async } from 'react-select';
|
||||
import { TagValue } from './TagValue';
|
||||
import AsyncSelect from 'react-select/lib/Async';
|
||||
import { TagOption } from './TagOption';
|
||||
import { TagBadge } from './TagBadge';
|
||||
import IndicatorsContainer from 'app/core/components/Picker/IndicatorsContainer';
|
||||
import NoOptionsMessage from 'app/core/components/Picker/NoOptionsMessage';
|
||||
import { components } from 'react-select';
|
||||
import ResetStyles from 'app/core/components/Picker/ResetStyles';
|
||||
|
||||
export interface Props {
|
||||
tags: string[];
|
||||
@@ -18,15 +21,15 @@ export class TagFilter extends React.Component<Props, any> {
|
||||
|
||||
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 };
|
||||
return options.map(option => ({
|
||||
value: option.term,
|
||||
label: option.term,
|
||||
count: option.count,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -34,33 +37,44 @@ export class TagFilter extends React.Component<Props, any> {
|
||||
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() {
|
||||
const selectOptions = {
|
||||
classNamePrefix: 'gf-form-select-box',
|
||||
isMulti: true,
|
||||
defaultOptions: true,
|
||||
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,
|
||||
};
|
||||
loadingMessage: () => 'Loading...',
|
||||
noOptionsMessage: () => 'No tags found',
|
||||
getOptionValue: i => i.value,
|
||||
getOptionLabel: i => i.label,
|
||||
value: this.props.tags,
|
||||
styles: ResetStyles,
|
||||
components: {
|
||||
Option: TagOption,
|
||||
IndicatorsContainer,
|
||||
NoOptionsMessage,
|
||||
MultiValueLabel: () => {
|
||||
return null; // We want the whole tag to be clickable so we use MultiValueRemove instead
|
||||
},
|
||||
MultiValueRemove: props => {
|
||||
const { data } = props;
|
||||
|
||||
selectOptions['valueComponent'] = TagValue;
|
||||
return (
|
||||
<components.MultiValueRemove {...props}>
|
||||
<TagBadge key={data.label} label={data.label} removeIcon={true} count={data.count} />
|
||||
</components.MultiValueRemove>
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="gf-form gf-form--has-input-icon gf-form--grow">
|
||||
<div className="tag-filter">
|
||||
<Async {...selectOptions} />
|
||||
<AsyncSelect {...selectOptions} />
|
||||
</div>
|
||||
<i className="gf-form-input-icon fa fa-tag" />
|
||||
</div>
|
||||
|
||||
@@ -1,52 +1,22 @@
|
||||
import React from 'react';
|
||||
import { components } from 'react-select';
|
||||
import { OptionProps } from 'react-select/lib/components/Option';
|
||||
import { TagBadge } from './TagBadge';
|
||||
|
||||
export interface Props {
|
||||
onSelect: any;
|
||||
onFocus: any;
|
||||
option: any;
|
||||
isFocused: any;
|
||||
className: any;
|
||||
// https://github.com/JedWatson/react-select/issues/3038
|
||||
interface ExtendedOptionProps extends OptionProps<any> {
|
||||
data: any;
|
||||
}
|
||||
|
||||
export class TagOption extends React.Component<Props, any> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.handleMouseDown = this.handleMouseDown.bind(this);
|
||||
this.handleMouseEnter = this.handleMouseEnter.bind(this);
|
||||
this.handleMouseMove = this.handleMouseMove.bind(this);
|
||||
}
|
||||
export const TagOption = (props: ExtendedOptionProps) => {
|
||||
const { data, className, label } = props;
|
||||
return (
|
||||
<components.Option {...props}>
|
||||
<div className={`tag-filter-option btn btn-link ${className || ''}`}>
|
||||
<TagBadge label={label} removeIcon={false} count={data.count} />
|
||||
</div>
|
||||
</components.Option>
|
||||
);
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
export default TagOption;
|
||||
|
||||
@@ -21,6 +21,6 @@ export class TagValue extends React.Component<Props, any> {
|
||||
render() {
|
||||
const { value } = this.props;
|
||||
|
||||
return <TagBadge label={value.label} removeIcon={true} count={0} onClick={this.onClick} />;
|
||||
return <TagBadge label={value.label} removeIcon={false} count={0} onClick={this.onClick} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,7 +207,7 @@ export class ManageDashboardsCtrl {
|
||||
const template =
|
||||
'<move-to-folder-modal dismiss="dismiss()" ' +
|
||||
'dashboards="model.dashboards" after-save="model.afterSave()">' +
|
||||
'</move-to-folder-modal>`';
|
||||
'</move-to-folder-modal>';
|
||||
appEvents.emit('show-modal', {
|
||||
templateHtml: template,
|
||||
modalClass: 'modal--narrow',
|
||||
|
||||
@@ -160,8 +160,12 @@ export class SearchCtrl {
|
||||
searchDashboards() {
|
||||
this.currentSearchId = this.currentSearchId + 1;
|
||||
const localSearchId = this.currentSearchId;
|
||||
const query = {
|
||||
...this.query,
|
||||
tag: this.query.tag.map(i => i.value),
|
||||
};
|
||||
|
||||
return this.searchSrv.search(this.query).then(results => {
|
||||
return this.searchSrv.search(query).then(results => {
|
||||
if (localSearchId < this.currentSearchId) {
|
||||
return;
|
||||
}
|
||||
@@ -196,7 +200,7 @@ export class SearchCtrl {
|
||||
}
|
||||
|
||||
onTagSelect(newTags) {
|
||||
this.query.tag = _.map(newTags, tag => tag.value);
|
||||
this.query.tag = newTags;
|
||||
this.search();
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ export class SideMenu extends PureComponent {
|
||||
render() {
|
||||
return [
|
||||
<div className="sidemenu__logo" onClick={this.toggleSideMenu} key="logo">
|
||||
<img src="public/img/grafana_icon.svg" alt="graphana_logo" />
|
||||
<img src="public/img/grafana_icon.svg" alt="Grafana" />
|
||||
</div>,
|
||||
<div className="sidemenu__logo_small_breakpoint" onClick={this.toggleSideMenuSmallBreakpoint} key="hamburger">
|
||||
<i className="fa fa-bars" />
|
||||
|
||||
@@ -8,7 +8,7 @@ Array [
|
||||
onClick={[Function]}
|
||||
>
|
||||
<img
|
||||
alt="graphana_logo"
|
||||
alt="Grafana"
|
||||
src="public/img/grafana_icon.svg"
|
||||
/>
|
||||
</div>,
|
||||
|
||||
@@ -11,7 +11,7 @@ export enum LogLevel {
|
||||
export interface LogSearchMatch {
|
||||
start: number;
|
||||
length: number;
|
||||
text?: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface LogRow {
|
||||
@@ -21,7 +21,7 @@ export interface LogRow {
|
||||
timestamp: string;
|
||||
timeFromNow: string;
|
||||
timeLocal: string;
|
||||
searchMatches?: LogSearchMatch[];
|
||||
searchWords?: string[];
|
||||
}
|
||||
|
||||
export interface LogsModel {
|
||||
|
||||
@@ -399,6 +399,77 @@ describe('duration', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('clock', () => {
|
||||
it('null', () => {
|
||||
const str = kbn.toClock(null, 0);
|
||||
expect(str).toBe('');
|
||||
});
|
||||
it('size less than 1 second', () => {
|
||||
const str = kbn.toClock(999, 0);
|
||||
expect(str).toBe('999ms');
|
||||
});
|
||||
describe('size less than 1 minute', () => {
|
||||
it('default', () => {
|
||||
const str = kbn.toClock(59999);
|
||||
expect(str).toBe('59s:999ms');
|
||||
});
|
||||
it('decimals equals 0', () => {
|
||||
const str = kbn.toClock(59999, 0);
|
||||
expect(str).toBe('59s');
|
||||
});
|
||||
});
|
||||
describe('size less than 1 hour', () => {
|
||||
it('default', () => {
|
||||
const str = kbn.toClock(3599999);
|
||||
expect(str).toBe('59m:59s:999ms');
|
||||
});
|
||||
it('decimals equals 0', () => {
|
||||
const str = kbn.toClock(3599999, 0);
|
||||
expect(str).toBe('59m');
|
||||
});
|
||||
it('decimals equals 1', () => {
|
||||
const str = kbn.toClock(3599999, 1);
|
||||
expect(str).toBe('59m:59s');
|
||||
});
|
||||
});
|
||||
describe('size greater than or equal 1 hour', () => {
|
||||
it('default', () => {
|
||||
const str = kbn.toClock(7199999);
|
||||
expect(str).toBe('01h:59m:59s:999ms');
|
||||
});
|
||||
it('decimals equals 0', () => {
|
||||
const str = kbn.toClock(7199999, 0);
|
||||
expect(str).toBe('01h');
|
||||
});
|
||||
it('decimals equals 1', () => {
|
||||
const str = kbn.toClock(7199999, 1);
|
||||
expect(str).toBe('01h:59m');
|
||||
});
|
||||
it('decimals equals 2', () => {
|
||||
const str = kbn.toClock(7199999, 2);
|
||||
expect(str).toBe('01h:59m:59s');
|
||||
});
|
||||
});
|
||||
describe('size greater than or equal 1 day', () => {
|
||||
it('default', () => {
|
||||
const str = kbn.toClock(89999999);
|
||||
expect(str).toBe('24h:59m:59s:999ms');
|
||||
});
|
||||
it('decimals equals 0', () => {
|
||||
const str = kbn.toClock(89999999, 0);
|
||||
expect(str).toBe('24h');
|
||||
});
|
||||
it('decimals equals 1', () => {
|
||||
const str = kbn.toClock(89999999, 1);
|
||||
expect(str).toBe('24h:59m');
|
||||
});
|
||||
it('decimals equals 2', () => {
|
||||
const str = kbn.toClock(89999999, 2);
|
||||
expect(str).toBe('24h:59m:59s');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('volume', () => {
|
||||
it('1000m3', () => {
|
||||
const str = kbn.valueFormats['m3'](1000, 1, null);
|
||||
|
||||
@@ -104,5 +104,17 @@ describe('Directed acyclic graph', () => {
|
||||
const actual = nodeH.getOptimizedInputEdges();
|
||||
expect(actual).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('when linking non-existing input node with existing output node should throw error', () => {
|
||||
expect(() => {
|
||||
dag.link('non-existing', 'A');
|
||||
}).toThrowError("cannot link input node named non-existing since it doesn't exist in graph");
|
||||
});
|
||||
|
||||
it('when linking existing input node with non-existing output node should throw error', () => {
|
||||
expect(() => {
|
||||
dag.link('A', 'non-existing');
|
||||
}).toThrowError("cannot link output node named non-existing since it doesn't exist in graph");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,14 @@ export class Edge {
|
||||
}
|
||||
|
||||
link(inputNode: Node, outputNode: Node) {
|
||||
if (!inputNode) {
|
||||
throw Error('inputNode is required');
|
||||
}
|
||||
|
||||
if (!outputNode) {
|
||||
throw Error('outputNode is required');
|
||||
}
|
||||
|
||||
this.unlink();
|
||||
this.inputNode = inputNode;
|
||||
this.outputNode = outputNode;
|
||||
@@ -152,7 +160,11 @@ export class Graph {
|
||||
for (let n = 0; n < inputArr.length; n++) {
|
||||
const i = inputArr[n];
|
||||
if (typeof i === 'string') {
|
||||
inputNodes.push(this.getNode(i));
|
||||
const n = this.getNode(i);
|
||||
if (!n) {
|
||||
throw Error(`cannot link input node named ${i} since it doesn't exist in graph`);
|
||||
}
|
||||
inputNodes.push(n);
|
||||
} else {
|
||||
inputNodes.push(i);
|
||||
}
|
||||
@@ -161,7 +173,11 @@ export class Graph {
|
||||
for (let n = 0; n < outputArr.length; n++) {
|
||||
const i = outputArr[n];
|
||||
if (typeof i === 'string') {
|
||||
outputNodes.push(this.getNode(i));
|
||||
const n = this.getNode(i);
|
||||
if (!n) {
|
||||
throw Error(`cannot link output node named ${i} since it doesn't exist in graph`);
|
||||
}
|
||||
outputNodes.push(n);
|
||||
} else {
|
||||
outputNodes.push(i);
|
||||
}
|
||||
|
||||
@@ -36,14 +36,40 @@ describe('state functions', () => {
|
||||
range: DEFAULT_RANGE,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a valid Explore state from URL parameter', () => {
|
||||
const paramValue =
|
||||
'%7B"datasource":"Local","queries":%5B%7B"query":"metric"%7D%5D,"range":%7B"from":"now-1h","to":"now"%7D%7D';
|
||||
expect(parseUrlState(paramValue)).toMatchObject({
|
||||
datasource: 'Local',
|
||||
queries: [{ query: 'metric' }],
|
||||
range: {
|
||||
from: 'now-1h',
|
||||
to: 'now',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a valid Explore state from a compact URL parameter', () => {
|
||||
const paramValue = '%5B"now-1h","now","Local","metric"%5D';
|
||||
expect(parseUrlState(paramValue)).toMatchObject({
|
||||
datasource: 'Local',
|
||||
queries: [{ query: 'metric' }],
|
||||
range: {
|
||||
from: 'now-1h',
|
||||
to: 'now',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('serializeStateToUrlParam', () => {
|
||||
it('returns url parameter value for a state object', () => {
|
||||
const state = {
|
||||
...DEFAULT_EXPLORE_STATE,
|
||||
datasourceName: 'foo',
|
||||
range: {
|
||||
from: 'now - 5h',
|
||||
from: 'now-5h',
|
||||
to: 'now',
|
||||
},
|
||||
queries: [
|
||||
@@ -57,10 +83,33 @@ describe('state functions', () => {
|
||||
};
|
||||
expect(serializeStateToUrlParam(state)).toBe(
|
||||
'{"datasource":"foo","queries":[{"query":"metric{test=\\"a/b\\"}"},' +
|
||||
'{"query":"super{foo=\\"x/z\\"}"}],"range":{"from":"now - 5h","to":"now"}}'
|
||||
'{"query":"super{foo=\\"x/z\\"}"}],"range":{"from":"now-5h","to":"now"}}'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns url parameter value for a state object', () => {
|
||||
const state = {
|
||||
...DEFAULT_EXPLORE_STATE,
|
||||
datasourceName: 'foo',
|
||||
range: {
|
||||
from: 'now-5h',
|
||||
to: 'now',
|
||||
},
|
||||
queries: [
|
||||
{
|
||||
query: 'metric{test="a/b"}',
|
||||
},
|
||||
{
|
||||
query: 'super{foo="x/z"}',
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(serializeStateToUrlParam(state, true)).toBe(
|
||||
'["now-5h","now","foo","metric{test=\\"a/b\\"}","super{foo=\\"x/z\\"}"]'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('interplay', () => {
|
||||
it('can parse the serialized state into the original state', () => {
|
||||
const state = {
|
||||
|
||||
@@ -60,7 +60,20 @@ export async function getExploreUrl(
|
||||
export function parseUrlState(initial: string | undefined): ExploreUrlState {
|
||||
if (initial) {
|
||||
try {
|
||||
return JSON.parse(decodeURI(initial));
|
||||
const parsed = JSON.parse(decodeURI(initial));
|
||||
if (Array.isArray(parsed)) {
|
||||
if (parsed.length <= 3) {
|
||||
throw new Error('Error parsing compact URL state for Explore.');
|
||||
}
|
||||
const range = {
|
||||
from: parsed[0],
|
||||
to: parsed[1],
|
||||
};
|
||||
const datasource = parsed[2];
|
||||
const queries = parsed.slice(3).map(query => ({ query }));
|
||||
return { datasource, queries, range };
|
||||
}
|
||||
return parsed;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
@@ -68,11 +81,19 @@ export function parseUrlState(initial: string | undefined): ExploreUrlState {
|
||||
return { datasource: null, queries: [], range: DEFAULT_RANGE };
|
||||
}
|
||||
|
||||
export function serializeStateToUrlParam(state: ExploreState): string {
|
||||
export function serializeStateToUrlParam(state: ExploreState, compact?: boolean): string {
|
||||
const urlState: ExploreUrlState = {
|
||||
datasource: state.datasourceName,
|
||||
queries: state.queries.map(q => ({ query: q.query })),
|
||||
range: state.range,
|
||||
};
|
||||
if (compact) {
|
||||
return JSON.stringify([
|
||||
urlState.range.from,
|
||||
urlState.range.to,
|
||||
urlState.datasource,
|
||||
...urlState.queries.map(q => q.query),
|
||||
]);
|
||||
}
|
||||
return JSON.stringify(urlState);
|
||||
}
|
||||
|
||||
@@ -808,6 +808,51 @@ kbn.toDuration = (size, decimals, timeScale) => {
|
||||
return strings.join(', ');
|
||||
};
|
||||
|
||||
kbn.toClock = (size, decimals) => {
|
||||
if (size === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// < 1 second
|
||||
if (size < 1000) {
|
||||
return moment.utc(size).format('SSS\\m\\s');
|
||||
}
|
||||
|
||||
// < 1 minute
|
||||
if (size < 60000) {
|
||||
let format = 'ss\\s:SSS\\m\\s';
|
||||
if (decimals === 0) {
|
||||
format = 'ss\\s';
|
||||
}
|
||||
return moment.utc(size).format(format);
|
||||
}
|
||||
|
||||
// < 1 hour
|
||||
if (size < 3600000) {
|
||||
let format = 'mm\\m:ss\\s:SSS\\m\\s';
|
||||
if (decimals === 0) {
|
||||
format = 'mm\\m';
|
||||
} else if (decimals === 1) {
|
||||
format = 'mm\\m:ss\\s';
|
||||
}
|
||||
return moment.utc(size).format(format);
|
||||
}
|
||||
|
||||
let format = 'mm\\m:ss\\s:SSS\\m\\s';
|
||||
|
||||
const hours = `${('0' + Math.floor(moment.duration(size, 'milliseconds').asHours())).slice(-2)}h`;
|
||||
|
||||
if (decimals === 0) {
|
||||
format = '';
|
||||
} else if (decimals === 1) {
|
||||
format = 'mm\\m';
|
||||
} else if (decimals === 2) {
|
||||
format = 'mm\\m:ss\\s';
|
||||
}
|
||||
|
||||
return format ? `${hours}:${moment.utc(size).format(format)}` : hours;
|
||||
};
|
||||
|
||||
kbn.valueFormats.dtdurationms = (size, decimals) => {
|
||||
return kbn.toDuration(size, decimals, 'millisecond');
|
||||
};
|
||||
@@ -824,6 +869,14 @@ kbn.valueFormats.timeticks = (size, decimals, scaledDecimals) => {
|
||||
return kbn.valueFormats.s(size / 100, decimals, scaledDecimals);
|
||||
};
|
||||
|
||||
kbn.valueFormats.clockms = (size, decimals) => {
|
||||
return kbn.toClock(size, decimals);
|
||||
};
|
||||
|
||||
kbn.valueFormats.clocks = (size, decimals) => {
|
||||
return kbn.toClock(size * 1000, decimals);
|
||||
};
|
||||
|
||||
kbn.valueFormats.dateTimeAsIso = (epoch, isUtc) => {
|
||||
const time = isUtc ? moment.utc(epoch) : moment(epoch);
|
||||
|
||||
@@ -901,6 +954,8 @@ kbn.getUnitFormats = () => {
|
||||
{ text: 'duration (s)', value: 'dtdurations' },
|
||||
{ text: 'duration (hh:mm:ss)', value: 'dthms' },
|
||||
{ text: 'Timeticks (s/100)', value: 'timeticks' },
|
||||
{ text: 'clock (ms)', value: 'clockms' },
|
||||
{ text: 'clock (s)', value: 'clocks' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
24
public/app/core/utils/text.test.ts
Normal file
24
public/app/core/utils/text.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { findMatchesInText } from './text';
|
||||
|
||||
describe('findMatchesInText()', () => {
|
||||
it('gets no matches for when search and or line are empty', () => {
|
||||
expect(findMatchesInText('', '')).toEqual([]);
|
||||
expect(findMatchesInText('foo', '')).toEqual([]);
|
||||
expect(findMatchesInText('', 'foo')).toEqual([]);
|
||||
});
|
||||
|
||||
it('gets no matches for unmatched search string', () => {
|
||||
expect(findMatchesInText('foo', 'bar')).toEqual([]);
|
||||
});
|
||||
|
||||
it('gets matches for matched search string', () => {
|
||||
expect(findMatchesInText('foo', 'foo')).toEqual([{ length: 3, start: 0, text: 'foo', end: 3 }]);
|
||||
expect(findMatchesInText(' foo ', 'foo')).toEqual([{ length: 3, start: 1, text: 'foo', end: 4 }]);
|
||||
});
|
||||
|
||||
expect(findMatchesInText(' foo foo bar ', 'foo|bar')).toEqual([
|
||||
{ length: 3, start: 1, text: 'foo', end: 4 },
|
||||
{ length: 3, start: 5, text: 'foo', end: 8 },
|
||||
{ length: 3, start: 9, text: 'bar', end: 12 },
|
||||
]);
|
||||
});
|
||||
32
public/app/core/utils/text.ts
Normal file
32
public/app/core/utils/text.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { TextMatch } from 'app/types/explore';
|
||||
|
||||
/**
|
||||
* Adapt findMatchesInText for react-highlight-words findChunks handler.
|
||||
* See https://github.com/bvaughn/react-highlight-words#props
|
||||
*/
|
||||
export function findHighlightChunksInText({ searchWords, textToHighlight }) {
|
||||
return findMatchesInText(textToHighlight, searchWords.join(' '));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of substring regexp matches.
|
||||
*/
|
||||
export function findMatchesInText(haystack: string, needle: string): TextMatch[] {
|
||||
// Empty search can send re.exec() into infinite loop, exit early
|
||||
if (!haystack || !needle) {
|
||||
return [];
|
||||
}
|
||||
const regexp = new RegExp(`(?:${needle})`, 'g');
|
||||
const matches = [];
|
||||
let match = regexp.exec(haystack);
|
||||
while (match) {
|
||||
matches.push({
|
||||
text: match[0],
|
||||
start: match.index,
|
||||
length: match[0].length,
|
||||
end: match.index + match[0].length,
|
||||
});
|
||||
match = regexp.exec(haystack);
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
Reference in New Issue
Block a user