mirror of https://github.com/grafana/grafana
Merge pull request #14274 from grafana/develop
Develop (New Panel Edit UX & Explore All Datasources suppport) -> Masterpull/14531/head
commit
3efaf52049
@ -0,0 +1,38 @@ |
||||
import React, { SFC } from 'react'; |
||||
import Transition from 'react-transition-group/Transition'; |
||||
|
||||
interface Props { |
||||
duration: number; |
||||
children: JSX.Element; |
||||
in: boolean; |
||||
unmountOnExit?: boolean; |
||||
} |
||||
|
||||
export const FadeIn: SFC<Props> = props => { |
||||
const defaultStyle = { |
||||
transition: `opacity ${props.duration}ms linear`, |
||||
opacity: 0, |
||||
}; |
||||
|
||||
const transitionStyles = { |
||||
exited: { opacity: 0, display: 'none' }, |
||||
entering: { opacity: 0 }, |
||||
entered: { opacity: 1 }, |
||||
exiting: { opacity: 0 }, |
||||
}; |
||||
|
||||
return ( |
||||
<Transition in={props.in} timeout={props.duration} unmountOnExit={props.unmountOnExit || false}> |
||||
{state => ( |
||||
<div |
||||
style={{ |
||||
...defaultStyle, |
||||
...transitionStyles[state], |
||||
}} |
||||
> |
||||
{props.children} |
||||
</div> |
||||
)} |
||||
</Transition> |
||||
); |
||||
}; |
@ -0,0 +1,36 @@ |
||||
import { PureComponent } from 'react'; |
||||
import ReactDOM from 'react-dom'; |
||||
|
||||
export interface Props { |
||||
onClick: () => void; |
||||
} |
||||
|
||||
interface State { |
||||
hasEventListener: boolean; |
||||
} |
||||
|
||||
export class ClickOutsideWrapper extends PureComponent<Props, State> { |
||||
state = { |
||||
hasEventListener: false, |
||||
}; |
||||
|
||||
componentDidMount() { |
||||
window.addEventListener('click', this.onOutsideClick, false); |
||||
} |
||||
|
||||
componentWillUnmount() { |
||||
window.removeEventListener('click', this.onOutsideClick, false); |
||||
} |
||||
|
||||
onOutsideClick = event => { |
||||
const domNode = ReactDOM.findDOMNode(this) as Element; |
||||
|
||||
if (!domNode || !domNode.contains(event.target)) { |
||||
this.props.onClick(); |
||||
} |
||||
}; |
||||
|
||||
render() { |
||||
return this.props.children; |
||||
} |
||||
} |
@ -0,0 +1,94 @@ |
||||
import React, { PureComponent } from 'react'; |
||||
import classNames from 'classnames'; |
||||
import { ValidationEvents, ValidationRule } from 'app/types'; |
||||
import { validate, hasValidationEvent } from 'app/core/utils/validate'; |
||||
|
||||
export enum InputStatus { |
||||
Invalid = 'invalid', |
||||
Valid = 'valid', |
||||
} |
||||
|
||||
export enum InputTypes { |
||||
Text = 'text', |
||||
Number = 'number', |
||||
Password = 'password', |
||||
Email = 'email', |
||||
} |
||||
|
||||
export enum EventsWithValidation { |
||||
onBlur = 'onBlur', |
||||
onFocus = 'onFocus', |
||||
onChange = 'onChange', |
||||
} |
||||
|
||||
interface Props extends React.HTMLProps<HTMLInputElement> { |
||||
validationEvents?: ValidationEvents; |
||||
hideErrorMessage?: boolean; |
||||
|
||||
// Override event props and append status as argument
|
||||
onBlur?: (event: React.FocusEvent<HTMLInputElement>, status?: InputStatus) => void; |
||||
onFocus?: (event: React.FocusEvent<HTMLInputElement>, status?: InputStatus) => void; |
||||
onChange?: (event: React.FormEvent<HTMLInputElement>, status?: InputStatus) => void; |
||||
} |
||||
|
||||
export class Input extends PureComponent<Props> { |
||||
static defaultProps = { |
||||
className: '', |
||||
}; |
||||
|
||||
state = { |
||||
error: null, |
||||
}; |
||||
|
||||
get status() { |
||||
return this.state.error ? InputStatus.Invalid : InputStatus.Valid; |
||||
} |
||||
|
||||
get isInvalid() { |
||||
return this.status === InputStatus.Invalid; |
||||
} |
||||
|
||||
validatorAsync = (validationRules: ValidationRule[]) => { |
||||
return evt => { |
||||
const errors = validate(evt.target.value, validationRules); |
||||
this.setState(prevState => { |
||||
return { |
||||
...prevState, |
||||
error: errors ? errors[0] : null, |
||||
}; |
||||
}); |
||||
}; |
||||
}; |
||||
|
||||
populateEventPropsWithStatus = (restProps, validationEvents: ValidationEvents) => { |
||||
const inputElementProps = { ...restProps }; |
||||
Object.keys(EventsWithValidation).forEach((eventName: EventsWithValidation) => { |
||||
if (hasValidationEvent(eventName, validationEvents) || restProps[eventName]) { |
||||
inputElementProps[eventName] = async evt => { |
||||
evt.persist(); // Needed for async. https://reactjs.org/docs/events.html#event-pooling
|
||||
if (hasValidationEvent(eventName, validationEvents)) { |
||||
await this.validatorAsync(validationEvents[eventName]).apply(this, [evt]); |
||||
} |
||||
if (restProps[eventName]) { |
||||
restProps[eventName].apply(null, [evt, this.status]); |
||||
} |
||||
}; |
||||
} |
||||
}); |
||||
return inputElementProps; |
||||
}; |
||||
|
||||
render() { |
||||
const { validationEvents, className, hideErrorMessage, ...restProps } = this.props; |
||||
const { error } = this.state; |
||||
const inputClassName = classNames('gf-form-input', { invalid: this.isInvalid }, className); |
||||
const inputElementProps = this.populateEventPropsWithStatus(restProps, validationEvents); |
||||
|
||||
return ( |
||||
<div className="our-custom-wrapper-class"> |
||||
<input {...inputElementProps} className={inputClassName} /> |
||||
{error && !hideErrorMessage && <span>{error}</span>} |
||||
</div> |
||||
); |
||||
} |
||||
} |
@ -0,0 +1,11 @@ |
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP |
||||
|
||||
exports[`Input renders correctly 1`] = ` |
||||
<div |
||||
className="our-custom-wrapper-class" |
||||
> |
||||
<input |
||||
className="gf-form-input" |
||||
/> |
||||
</div> |
||||
`; |
@ -1,25 +0,0 @@ |
||||
import React from 'react'; |
||||
import { components } from 'react-select'; |
||||
import { OptionProps } from 'react-select/lib/components/Option'; |
||||
|
||||
// https://github.com/JedWatson/react-select/issues/3038
|
||||
interface ExtendedOptionProps extends OptionProps<any> { |
||||
data: any; |
||||
} |
||||
|
||||
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">{data.description}</div> |
||||
</div> |
||||
</div> |
||||
</components.Option> |
||||
); |
||||
}; |
||||
|
||||
export default Option; |
@ -1,52 +0,0 @@ |
||||
import React, { Component } from 'react'; |
||||
import Select from 'react-select'; |
||||
import DescriptionOption from './DescriptionOption'; |
||||
import IndicatorsContainer from './IndicatorsContainer'; |
||||
import ResetStyles from './ResetStyles'; |
||||
import NoOptionsMessage from './NoOptionsMessage'; |
||||
|
||||
export interface OptionWithDescription { |
||||
value: any; |
||||
label: string; |
||||
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, disabled, className, value } = this.props; |
||||
const selectedOption = getSelectedOption(optionsWithDesc, value); |
||||
return ( |
||||
<div className="permissions-picker"> |
||||
<Select |
||||
placeholder="Choose" |
||||
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> |
||||
); |
||||
} |
||||
} |
||||
|
||||
export default DescriptionPicker; |
@ -1,22 +0,0 @@ |
||||
import React from 'react'; |
||||
import { components } from 'react-select'; |
||||
import { OptionProps } from 'react-select/lib/components/Option'; |
||||
|
||||
// https://github.com/JedWatson/react-select/issues/3038
|
||||
interface ExtendedOptionProps extends OptionProps<any> { |
||||
data: any; |
||||
} |
||||
|
||||
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} |
||||
</div> |
||||
</components.Option> |
||||
); |
||||
}; |
||||
|
||||
export default PickerOption; |
@ -1,49 +0,0 @@ |
||||
import React, { SFC } from 'react'; |
||||
import Select from 'react-select'; |
||||
import DescriptionOption from './DescriptionOption'; |
||||
import ResetStyles from './ResetStyles'; |
||||
|
||||
interface Props { |
||||
className?: string; |
||||
defaultValue?: any; |
||||
getOptionLabel: (item: any) => string; |
||||
getOptionValue: (item: any) => string; |
||||
onSelected: (item: any) => {} | void; |
||||
options: any[]; |
||||
placeholder?: string; |
||||
width: number; |
||||
value: any; |
||||
} |
||||
|
||||
const SimplePicker: SFC<Props> = ({ |
||||
className, |
||||
defaultValue, |
||||
getOptionLabel, |
||||
getOptionValue, |
||||
onSelected, |
||||
options, |
||||
placeholder, |
||||
width, |
||||
value, |
||||
}) => { |
||||
return ( |
||||
<Select |
||||
classNamePrefix={`gf-form-select-box`} |
||||
className={`width-${width} gf-form-input gf-form-input--form-dropdown ${className || ''}`} |
||||
components={{ |
||||
Option: DescriptionOption, |
||||
}} |
||||
defaultValue={defaultValue} |
||||
value={value} |
||||
getOptionLabel={getOptionLabel} |
||||
getOptionValue={getOptionValue} |
||||
isSearchable={false} |
||||
onChange={onSelected} |
||||
options={options} |
||||
placeholder={placeholder || 'Choose'} |
||||
styles={ResetStyles} |
||||
/> |
||||
); |
||||
}; |
||||
|
||||
export default SimplePicker; |
@ -1,17 +0,0 @@ |
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP |
||||
|
||||
exports[`PickerOption renders correctly 1`] = ` |
||||
<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> |
||||
`; |
||||
|
@ -0,0 +1,72 @@ |
||||
// Libraries
|
||||
import React, { PureComponent } from 'react'; |
||||
import _ from 'lodash'; |
||||
|
||||
// Components
|
||||
import Select from './Select'; |
||||
|
||||
// Types
|
||||
import { DataSourceSelectItem } from 'app/types'; |
||||
|
||||
export interface Props { |
||||
onChange: (ds: DataSourceSelectItem) => void; |
||||
datasources: DataSourceSelectItem[]; |
||||
current: DataSourceSelectItem; |
||||
onBlur?: () => void; |
||||
autoFocus?: boolean; |
||||
} |
||||
|
||||
export class DataSourcePicker extends PureComponent<Props> { |
||||
static defaultProps = { |
||||
autoFocus: false, |
||||
}; |
||||
|
||||
searchInput: HTMLElement; |
||||
|
||||
constructor(props) { |
||||
super(props); |
||||
} |
||||
|
||||
onChange = item => { |
||||
const ds = this.props.datasources.find(ds => ds.name === item.value); |
||||
this.props.onChange(ds); |
||||
}; |
||||
|
||||
render() { |
||||
const { datasources, current, autoFocus, onBlur } = this.props; |
||||
|
||||
const options = datasources.map(ds => ({ |
||||
value: ds.name, |
||||
label: ds.name, |
||||
imgUrl: ds.meta.info.logos.small, |
||||
})); |
||||
|
||||
const value = current && { |
||||
label: current.name, |
||||
value: current.name, |
||||
imgUrl: current.meta.info.logos.small, |
||||
}; |
||||
|
||||
return ( |
||||
<div className="gf-form-inline"> |
||||
<Select |
||||
className="ds-picker" |
||||
isMulti={false} |
||||
isClearable={false} |
||||
backspaceRemovesValue={false} |
||||
onChange={this.onChange} |
||||
options={options} |
||||
autoFocus={autoFocus} |
||||
onBlur={onBlur} |
||||
openMenuOnFocus={true} |
||||
maxMenuHeight={500} |
||||
placeholder="Select datasource" |
||||
noOptionsMessage={() => 'No datasources found'} |
||||
value={value} |
||||
/> |
||||
</div> |
||||
); |
||||
} |
||||
} |
||||
|
||||
export default DataSourcePicker; |
@ -0,0 +1,20 @@ |
||||
import React from 'react'; |
||||
import { components } from '@torkelo/react-select'; |
||||
import { OptionProps } from '@torkelo/react-select/lib/components/Option'; |
||||
|
||||
export interface Props { |
||||
children: Element; |
||||
} |
||||
|
||||
export const NoOptionsMessage = (props: OptionProps<any>) => { |
||||
const { children } = props; |
||||
return ( |
||||
<components.Option {...props}> |
||||
<div className="gf-form-select-box__desc-option"> |
||||
<div className="gf-form-select-box__desc-option__body">{children}</div> |
||||
</div> |
||||
</components.Option> |
||||
); |
||||
}; |
||||
|
||||
export default NoOptionsMessage; |
@ -0,0 +1,53 @@ |
||||
import React, { PureComponent } from 'react'; |
||||
import { GroupProps } from 'react-select/lib/components/Group'; |
||||
|
||||
interface ExtendedGroupProps extends GroupProps<any> { |
||||
data: any; |
||||
} |
||||
|
||||
interface State { |
||||
expanded: boolean; |
||||
} |
||||
|
||||
export default class OptionGroup extends PureComponent<ExtendedGroupProps, State> { |
||||
state = { |
||||
expanded: false, |
||||
}; |
||||
|
||||
componentDidMount() { |
||||
if (this.props.selectProps) { |
||||
const value = this.props.selectProps.value[this.props.selectProps.value.length - 1]; |
||||
|
||||
if (value && this.props.options.some(option => option.value === value)) { |
||||
this.setState({ expanded: true }); |
||||
} |
||||
} |
||||
} |
||||
|
||||
componentDidUpdate(nextProps) { |
||||
if (nextProps.selectProps.inputValue !== '') { |
||||
this.setState({ expanded: true }); |
||||
} |
||||
} |
||||
|
||||
onToggleChildren = () => { |
||||
this.setState(prevState => ({ |
||||
expanded: !prevState.expanded, |
||||
})); |
||||
}; |
||||
|
||||
render() { |
||||
const { children, label } = this.props; |
||||
const { expanded } = this.state; |
||||
|
||||
return ( |
||||
<div className="gf-form-select-box__option-group"> |
||||
<div className="gf-form-select-box__option-group__header" onClick={this.onToggleChildren}> |
||||
<span className="flex-grow">{label}</span> |
||||
<i className={`fa ${expanded ? 'fa-caret-left' : 'fa-caret-down'}`} />{' '} |
||||
</div> |
||||
{expanded && children} |
||||
</div> |
||||
); |
||||
} |
||||
} |
@ -0,0 +1,44 @@ |
||||
import React from 'react'; |
||||
import { components } from '@torkelo/react-select'; |
||||
import { OptionProps } from 'react-select/lib/components/Option'; |
||||
|
||||
// https://github.com/JedWatson/react-select/issues/3038
|
||||
interface ExtendedOptionProps extends OptionProps<any> { |
||||
data: { |
||||
description?: string; |
||||
imgUrl?: string; |
||||
}; |
||||
} |
||||
|
||||
export const Option = (props: ExtendedOptionProps) => { |
||||
const { children, isSelected, data } = props; |
||||
|
||||
return ( |
||||
<components.Option {...props}> |
||||
<div className="gf-form-select-box__desc-option"> |
||||
{data.imgUrl && <img className="gf-form-select-box__desc-option__img" src={data.imgUrl} />} |
||||
<div className="gf-form-select-box__desc-option__body"> |
||||
<div>{children}</div> |
||||
{data.description && <div className="gf-form-select-box__desc-option__desc">{data.description}</div>} |
||||
</div> |
||||
{isSelected && <i className="fa fa-check" aria-hidden="true" />} |
||||
</div> |
||||
</components.Option> |
||||
); |
||||
}; |
||||
|
||||
// was not able to type this without typescript error
|
||||
export const SingleValue = props => { |
||||
const { children, data } = props; |
||||
|
||||
return ( |
||||
<components.SingleValue {...props}> |
||||
<div className="gf-form-select-box__img-value"> |
||||
{data.imgUrl && <img className="gf-form-select-box__desc-option__img" src={data.imgUrl} />} |
||||
{children} |
||||
</div> |
||||
</components.SingleValue> |
||||
); |
||||
}; |
||||
|
||||
export default Option; |
@ -0,0 +1,232 @@ |
||||
// Libraries
|
||||
import classNames from 'classnames'; |
||||
import React, { PureComponent } from 'react'; |
||||
import { default as ReactSelect } from '@torkelo/react-select'; |
||||
import { default as ReactAsyncSelect } from '@torkelo/react-select/lib/Async'; |
||||
import { components } from '@torkelo/react-select'; |
||||
|
||||
// Components
|
||||
import { Option, SingleValue } from './PickerOption'; |
||||
import OptionGroup from './OptionGroup'; |
||||
import IndicatorsContainer from './IndicatorsContainer'; |
||||
import NoOptionsMessage from './NoOptionsMessage'; |
||||
import ResetStyles from './ResetStyles'; |
||||
import CustomScrollbar from '../CustomScrollbar/CustomScrollbar'; |
||||
|
||||
export interface SelectOptionItem { |
||||
label?: string; |
||||
value?: any; |
||||
imgUrl?: string; |
||||
description?: string; |
||||
[key: string]: any; |
||||
} |
||||
|
||||
interface CommonProps { |
||||
defaultValue?: any; |
||||
getOptionLabel?: (item: SelectOptionItem) => string; |
||||
getOptionValue?: (item: SelectOptionItem) => string; |
||||
onChange: (item: SelectOptionItem) => {} | void; |
||||
placeholder?: string; |
||||
width?: number; |
||||
value?: SelectOptionItem; |
||||
className?: string; |
||||
isDisabled?: boolean; |
||||
isSearchable?: boolean; |
||||
isClearable?: boolean; |
||||
autoFocus?: boolean; |
||||
openMenuOnFocus?: boolean; |
||||
onBlur?: () => void; |
||||
maxMenuHeight?: number; |
||||
isLoading: boolean; |
||||
noOptionsMessage?: () => string; |
||||
isMulti?: boolean; |
||||
backspaceRemovesValue: boolean; |
||||
} |
||||
|
||||
interface SelectProps { |
||||
options: SelectOptionItem[]; |
||||
} |
||||
|
||||
interface AsyncProps { |
||||
defaultOptions: boolean; |
||||
loadOptions: (query: string) => Promise<SelectOptionItem[]>; |
||||
loadingMessage?: () => string; |
||||
} |
||||
|
||||
export const MenuList = props => { |
||||
return ( |
||||
<components.MenuList {...props}> |
||||
<CustomScrollbar autoHide={false}>{props.children}</CustomScrollbar> |
||||
</components.MenuList> |
||||
); |
||||
}; |
||||
|
||||
export class Select extends PureComponent<CommonProps & SelectProps> { |
||||
static defaultProps = { |
||||
width: null, |
||||
className: '', |
||||
isDisabled: false, |
||||
isSearchable: true, |
||||
isClearable: false, |
||||
isMulti: false, |
||||
openMenuOnFocus: false, |
||||
autoFocus: false, |
||||
isLoading: false, |
||||
backspaceRemovesValue: true, |
||||
maxMenuHeight: 300, |
||||
}; |
||||
|
||||
render() { |
||||
const { |
||||
defaultValue, |
||||
getOptionLabel, |
||||
getOptionValue, |
||||
onChange, |
||||
options, |
||||
placeholder, |
||||
width, |
||||
value, |
||||
className, |
||||
isDisabled, |
||||
isLoading, |
||||
isSearchable, |
||||
isClearable, |
||||
backspaceRemovesValue, |
||||
isMulti, |
||||
autoFocus, |
||||
openMenuOnFocus, |
||||
onBlur, |
||||
maxMenuHeight, |
||||
noOptionsMessage, |
||||
} = this.props; |
||||
|
||||
let widthClass = ''; |
||||
if (width) { |
||||
widthClass = 'width-' + width; |
||||
} |
||||
|
||||
const selectClassNames = classNames('gf-form-input', 'gf-form-input--form-dropdown', widthClass, className); |
||||
|
||||
return ( |
||||
<ReactSelect |
||||
classNamePrefix="gf-form-select-box" |
||||
className={selectClassNames} |
||||
components={{ |
||||
Option, |
||||
SingleValue, |
||||
IndicatorsContainer, |
||||
MenuList, |
||||
Group: OptionGroup, |
||||
}} |
||||
defaultValue={defaultValue} |
||||
value={value} |
||||
getOptionLabel={getOptionLabel} |
||||
getOptionValue={getOptionValue} |
||||
menuShouldScrollIntoView={false} |
||||
isSearchable={isSearchable} |
||||
onChange={onChange} |
||||
options={options} |
||||
placeholder={placeholder || 'Choose'} |
||||
styles={ResetStyles} |
||||
isDisabled={isDisabled} |
||||
isLoading={isLoading} |
||||
isClearable={isClearable} |
||||
autoFocus={autoFocus} |
||||
onBlur={onBlur} |
||||
openMenuOnFocus={openMenuOnFocus} |
||||
maxMenuHeight={maxMenuHeight} |
||||
noOptionsMessage={noOptionsMessage} |
||||
isMulti={isMulti} |
||||
backspaceRemovesValue={backspaceRemovesValue} |
||||
/> |
||||
); |
||||
} |
||||
} |
||||
|
||||
export class AsyncSelect extends PureComponent<CommonProps & AsyncProps> { |
||||
static defaultProps = { |
||||
width: null, |
||||
className: '', |
||||
components: {}, |
||||
loadingMessage: () => 'Loading...', |
||||
isDisabled: false, |
||||
isClearable: false, |
||||
isMulti: false, |
||||
isSearchable: true, |
||||
backspaceRemovesValue: true, |
||||
autoFocus: false, |
||||
openMenuOnFocus: false, |
||||
maxMenuHeight: 300, |
||||
}; |
||||
|
||||
render() { |
||||
const { |
||||
defaultValue, |
||||
getOptionLabel, |
||||
getOptionValue, |
||||
onChange, |
||||
placeholder, |
||||
width, |
||||
value, |
||||
className, |
||||
loadOptions, |
||||
defaultOptions, |
||||
isLoading, |
||||
loadingMessage, |
||||
noOptionsMessage, |
||||
isDisabled, |
||||
isSearchable, |
||||
isClearable, |
||||
backspaceRemovesValue, |
||||
autoFocus, |
||||
onBlur, |
||||
openMenuOnFocus, |
||||
maxMenuHeight, |
||||
isMulti, |
||||
} = this.props; |
||||
|
||||
let widthClass = ''; |
||||
if (width) { |
||||
widthClass = 'width-' + width; |
||||
} |
||||
|
||||
const selectClassNames = classNames('gf-form-input', 'gf-form-input--form-dropdown', widthClass, className); |
||||
|
||||
return ( |
||||
<ReactAsyncSelect |
||||
classNamePrefix="gf-form-select-box" |
||||
className={selectClassNames} |
||||
components={{ |
||||
Option, |
||||
SingleValue, |
||||
IndicatorsContainer, |
||||
NoOptionsMessage, |
||||
}} |
||||
defaultValue={defaultValue} |
||||
value={value} |
||||
getOptionLabel={getOptionLabel} |
||||
getOptionValue={getOptionValue} |
||||
menuShouldScrollIntoView={false} |
||||
onChange={onChange} |
||||
loadOptions={loadOptions} |
||||
isLoading={isLoading} |
||||
defaultOptions={defaultOptions} |
||||
placeholder={placeholder || 'Choose'} |
||||
styles={ResetStyles} |
||||
loadingMessage={loadingMessage} |
||||
noOptionsMessage={noOptionsMessage} |
||||
isDisabled={isDisabled} |
||||
isSearchable={isSearchable} |
||||
isClearable={isClearable} |
||||
autoFocus={autoFocus} |
||||
onBlur={onBlur} |
||||
openMenuOnFocus={openMenuOnFocus} |
||||
maxMenuHeight={maxMenuHeight} |
||||
isMulti={isMulti} |
||||
backspaceRemovesValue={backspaceRemovesValue} |
||||
/> |
||||
); |
||||
} |
||||
} |
||||
|
||||
export default Select; |
@ -0,0 +1,51 @@ |
||||
import React, { PureComponent } from 'react'; |
||||
import Select from './Select'; |
||||
import kbn from 'app/core/utils/kbn'; |
||||
|
||||
interface Props { |
||||
onChange: (item: any) => {} | void; |
||||
defaultValue?: string; |
||||
width?: number; |
||||
} |
||||
|
||||
export default class UnitPicker extends PureComponent<Props> { |
||||
static defaultProps = { |
||||
width: 12, |
||||
}; |
||||
|
||||
render() { |
||||
const { defaultValue, onChange, width } = this.props; |
||||
|
||||
const unitGroups = kbn.getUnitFormats(); |
||||
|
||||
// Need to transform the data structure to work well with Select
|
||||
const groupOptions = unitGroups.map(group => { |
||||
const options = group.submenu.map(unit => { |
||||
return { |
||||
label: unit.text, |
||||
value: unit.value, |
||||
}; |
||||
}); |
||||
|
||||
return { |
||||
label: group.text, |
||||
options, |
||||
}; |
||||
}); |
||||
|
||||
const value = groupOptions.map(group => { |
||||
return group.options.find(option => option.value === defaultValue); |
||||
}); |
||||
|
||||
return ( |
||||
<Select |
||||
width={width} |
||||
defaultValue={value} |
||||
isSearchable={true} |
||||
options={groupOptions} |
||||
placeholder="Choose" |
||||
onChange={onChange} |
||||
/> |
||||
); |
||||
} |
||||
} |
@ -0,0 +1,21 @@ |
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP |
||||
|
||||
exports[`PickerOption renders correctly 1`] = ` |
||||
<div> |
||||
<div |
||||
className="gf-form-select-box__desc-option" |
||||
> |
||||
<img |
||||
className="gf-form-select-box__desc-option__img" |
||||
src="url/to/avatar" |
||||
/> |
||||
<div |
||||
className="gf-form-select-box__desc-option__body" |
||||
> |
||||
<div> |
||||
Model title |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
`; |
@ -0,0 +1,88 @@ |
||||
import React from 'react'; |
||||
|
||||
export interface UsingPopperProps { |
||||
showPopper: (prevState: object) => void; |
||||
hidePopper: (prevState: object) => void; |
||||
renderContent: (content: any) => any; |
||||
show: boolean; |
||||
placement?: string; |
||||
content: string | ((props: any) => JSX.Element); |
||||
className?: string; |
||||
refClassName?: string; |
||||
} |
||||
|
||||
interface Props { |
||||
placement?: string; |
||||
className?: string; |
||||
refClassName?: string; |
||||
content: string | ((props: any) => JSX.Element); |
||||
} |
||||
|
||||
interface State { |
||||
placement: string; |
||||
show: boolean; |
||||
} |
||||
|
||||
export default function withPopper(WrappedComponent) { |
||||
return class extends React.Component<Props, State> { |
||||
constructor(props) { |
||||
super(props); |
||||
this.setState = this.setState.bind(this); |
||||
this.state = { |
||||
placement: this.props.placement || 'auto', |
||||
show: false, |
||||
}; |
||||
} |
||||
|
||||
componentWillReceiveProps(nextProps) { |
||||
if (nextProps.placement && nextProps.placement !== this.state.placement) { |
||||
this.setState(prevState => { |
||||
return { |
||||
...prevState, |
||||
placement: nextProps.placement, |
||||
}; |
||||
}); |
||||
} |
||||
} |
||||
|
||||
showPopper = () => { |
||||
this.setState(prevState => ({ |
||||
...prevState, |
||||
show: true, |
||||
})); |
||||
}; |
||||
|
||||
hidePopper = () => { |
||||
this.setState(prevState => ({ |
||||
...prevState, |
||||
show: false, |
||||
})); |
||||
}; |
||||
|
||||
renderContent(content) { |
||||
if (typeof content === 'function') { |
||||
// If it's a function we assume it's a React component
|
||||
const ReactComponent = content; |
||||
return <ReactComponent />; |
||||
} |
||||
return content; |
||||
} |
||||
|
||||
render() { |
||||
const { show, placement } = this.state; |
||||
const className = this.props.className || ''; |
||||
|
||||
return ( |
||||
<WrappedComponent |
||||
{...this.props} |
||||
showPopper={this.showPopper} |
||||
hidePopper={this.hidePopper} |
||||
renderContent={this.renderContent} |
||||
show={show} |
||||
placement={placement} |
||||
className={className} |
||||
/> |
||||
); |
||||
} |
||||
}; |
||||
} |
@ -1,187 +1,191 @@ |
||||
<div class="edit-tab-with-sidemenu" ng-if="ctrl.alert"> |
||||
<aside class="edit-sidemenu-aside"> |
||||
<ul class="edit-sidemenu"> |
||||
<li ng-class="{active: ctrl.subTabIndex === 0}"> |
||||
<a ng-click="ctrl.changeTabIndex(0)">Alert Config</a> |
||||
</li> |
||||
<li ng-class="{active: ctrl.subTabIndex === 1}"> |
||||
<a ng-click="ctrl.changeTabIndex(1)"> |
||||
Notifications <span class="muted">({{ctrl.alertNotifications.length}})</span> |
||||
</a> |
||||
</li> |
||||
<li ng-class="{active: ctrl.subTabIndex === 2}"> |
||||
<a ng-click="ctrl.changeTabIndex(2)">State history</a> |
||||
</li> |
||||
<li> |
||||
<a ng-click="ctrl.delete()">Delete</a> |
||||
</li> |
||||
</ul> |
||||
</aside> |
||||
<div class="panel-option-section__body" ng-if="ctrl.alert"> |
||||
<div class="edit-tab-with-sidemenu"> |
||||
<aside class="edit-sidemenu-aside"> |
||||
<ul class="edit-sidemenu"> |
||||
<li ng-class="{active: ctrl.subTabIndex === 0}"> |
||||
<a ng-click="ctrl.changeTabIndex(0)">Alert Config</a> |
||||
</li> |
||||
<li ng-class="{active: ctrl.subTabIndex === 1}"> |
||||
<a ng-click="ctrl.changeTabIndex(1)"> |
||||
Notifications <span class="muted">({{ctrl.alertNotifications.length}})</span> |
||||
</a> |
||||
</li> |
||||
<li ng-class="{active: ctrl.subTabIndex === 2}"> |
||||
<a ng-click="ctrl.changeTabIndex(2)">State history</a> |
||||
</li> |
||||
<li> |
||||
<a ng-click="ctrl.delete()">Delete</a> |
||||
</li> |
||||
</ul> |
||||
</aside> |
||||
|
||||
<div class="edit-tab-content"> |
||||
<div ng-if="ctrl.subTabIndex === 0"> |
||||
<div class="alert alert-error m-b-2" ng-show="ctrl.error"> |
||||
<i class="fa fa-warning"></i> {{ctrl.error}} |
||||
</div> |
||||
<div class="edit-tab-content"> |
||||
<div ng-if="ctrl.subTabIndex === 0"> |
||||
<div class="alert alert-error m-b-2" ng-show="ctrl.error"> |
||||
<i class="fa fa-warning"></i> {{ctrl.error}} |
||||
</div> |
||||
|
||||
<div class="gf-form-group"> |
||||
<h5 class="section-heading">Alert Config</h5> |
||||
<div class="gf-form"> |
||||
<span class="gf-form-label width-6">Name</span> |
||||
<input type="text" class="gf-form-input width-20" ng-model="ctrl.alert.name"> |
||||
</div> |
||||
<div class="gf-form-inline"> |
||||
<div class="gf-form"> |
||||
<span class="gf-form-label width-9">Evaluate every</span> |
||||
<input class="gf-form-input max-width-6" type="text" ng-model="ctrl.alert.frequency"> |
||||
</div> |
||||
<div class="gf-form max-width-11"> |
||||
<label class="gf-form-label width-5">For</label> |
||||
<input type="text" class="gf-form-input max-width-6" ng-model="ctrl.alert.for" spellcheck='false' placeholder="5m"> |
||||
<info-popover mode="right-absolute"> |
||||
If an alert rule has a configured For and the query violates the configured threshold it will first go from OK to Pending. |
||||
Going from OK to Pending Grafana will not send any notifications. Once the alert rule has been firing for more than For duration, it will change to Alerting and send alert notifications. |
||||
</info-popover> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
<div class="gf-form-group"> |
||||
<h5 class="section-heading">Alert Config</h5> |
||||
<div class="gf-form"> |
||||
<span class="gf-form-label width-6">Name</span> |
||||
<input type="text" class="gf-form-input width-20" ng-model="ctrl.alert.name"> |
||||
</div> |
||||
<div class="gf-form-inline"> |
||||
<div class="gf-form"> |
||||
<span class="gf-form-label width-9">Evaluate every</span> |
||||
<input class="gf-form-input max-width-6" type="text" ng-model="ctrl.alert.frequency"> |
||||
</div> |
||||
<div class="gf-form max-width-11"> |
||||
<label class="gf-form-label width-5">For</label> |
||||
<input type="text" class="gf-form-input max-width-6" ng-model="ctrl.alert.for" spellcheck='false' placeholder="5m"> |
||||
<info-popover mode="right-absolute"> |
||||
If an alert rule has a configured For and the query violates the configured threshold it will first go from OK to Pending. |
||||
Going from OK to Pending Grafana will not send any notifications. Once the alert rule has been firing for more than For duration, it will change to Alerting and send alert notifications. |
||||
</info-popover> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="gf-form-group"> |
||||
<h5 class="section-heading">Conditions</h5> |
||||
<div class="gf-form-inline" ng-repeat="conditionModel in ctrl.conditionModels"> |
||||
<div class="gf-form"> |
||||
<metric-segment-model css-class="query-keyword width-5" ng-if="$index" property="conditionModel.operator.type" options="ctrl.evalOperators" custom="false"></metric-segment-model> |
||||
<span class="gf-form-label query-keyword width-5" ng-if="$index===0">WHEN</span> |
||||
</div> |
||||
<div class="gf-form"> |
||||
<query-part-editor class="gf-form-label query-part width-9" part="conditionModel.reducerPart" handle-event="ctrl.handleReducerPartEvent(conditionModel, $event)"> |
||||
</query-part-editor> |
||||
<span class="gf-form-label query-keyword">OF</span> |
||||
</div> |
||||
<div class="gf-form"> |
||||
<query-part-editor class="gf-form-label query-part" part="conditionModel.queryPart" handle-event="ctrl.handleQueryPartEvent(conditionModel, $event)"> |
||||
</query-part-editor> |
||||
</div> |
||||
<div class="gf-form"> |
||||
<metric-segment-model property="conditionModel.evaluator.type" options="ctrl.evalFunctions" custom="false" css-class="query-keyword" on-change="ctrl.evaluatorTypeChanged(conditionModel.evaluator)"></metric-segment-model> |
||||
<input class="gf-form-input max-width-9" type="number" step="any" ng-hide="conditionModel.evaluator.params.length === 0" ng-model="conditionModel.evaluator.params[0]" ng-change="ctrl.evaluatorParamsChanged()"> |
||||
<label class="gf-form-label query-keyword" ng-show="conditionModel.evaluator.params.length === 2">TO</label> |
||||
<input class="gf-form-input max-width-9" type="number" step="any" ng-if="conditionModel.evaluator.params.length === 2" ng-model="conditionModel.evaluator.params[1]" ng-change="ctrl.evaluatorParamsChanged()"> |
||||
</div> |
||||
<div class="gf-form"> |
||||
<label class="gf-form-label"> |
||||
<a class="pointer" tabindex="1" ng-click="ctrl.removeCondition($index)"> |
||||
<i class="fa fa-trash"></i> |
||||
</a> |
||||
</label> |
||||
</div> |
||||
</div> |
||||
<div class="gf-form-group"> |
||||
<h5 class="section-heading">Conditions</h5> |
||||
<div class="gf-form-inline" ng-repeat="conditionModel in ctrl.conditionModels"> |
||||
<div class="gf-form"> |
||||
<metric-segment-model css-class="query-keyword width-5" ng-if="$index" property="conditionModel.operator.type" options="ctrl.evalOperators" custom="false"></metric-segment-model> |
||||
<span class="gf-form-label query-keyword width-5" ng-if="$index===0">WHEN</span> |
||||
</div> |
||||
<div class="gf-form"> |
||||
<query-part-editor class="gf-form-label query-part width-9" part="conditionModel.reducerPart" handle-event="ctrl.handleReducerPartEvent(conditionModel, $event)"> |
||||
</query-part-editor> |
||||
<span class="gf-form-label query-keyword">OF</span> |
||||
</div> |
||||
<div class="gf-form"> |
||||
<query-part-editor class="gf-form-label query-part" part="conditionModel.queryPart" handle-event="ctrl.handleQueryPartEvent(conditionModel, $event)"> |
||||
</query-part-editor> |
||||
</div> |
||||
<div class="gf-form"> |
||||
<metric-segment-model property="conditionModel.evaluator.type" options="ctrl.evalFunctions" custom="false" css-class="query-keyword" on-change="ctrl.evaluatorTypeChanged(conditionModel.evaluator)"></metric-segment-model> |
||||
<input class="gf-form-input max-width-9" type="number" step="any" ng-hide="conditionModel.evaluator.params.length === 0" ng-model="conditionModel.evaluator.params[0]" ng-change="ctrl.evaluatorParamsChanged()"> |
||||
<label class="gf-form-label query-keyword" ng-show="conditionModel.evaluator.params.length === 2">TO</label> |
||||
<input class="gf-form-input max-width-9" type="number" step="any" ng-if="conditionModel.evaluator.params.length === 2" ng-model="conditionModel.evaluator.params[1]" ng-change="ctrl.evaluatorParamsChanged()"> |
||||
</div> |
||||
<div class="gf-form"> |
||||
<label class="gf-form-label"> |
||||
<a class="pointer" tabindex="1" ng-click="ctrl.removeCondition($index)"> |
||||
<i class="fa fa-trash"></i> |
||||
</a> |
||||
</label> |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="gf-form"> |
||||
<label class="gf-form-label dropdown"> |
||||
<a class="pointer dropdown-toggle" data-toggle="dropdown"> |
||||
<i class="fa fa-plus"></i> |
||||
</a> |
||||
<ul class="dropdown-menu" role="menu"> |
||||
<li ng-repeat="ct in ctrl.conditionTypes" role="menuitem"> |
||||
<a ng-click="ctrl.addCondition(ct.value);">{{ct.text}}</a> |
||||
</li> |
||||
</ul> |
||||
</label> |
||||
</div> |
||||
</div> |
||||
<div class="gf-form"> |
||||
<label class="gf-form-label dropdown"> |
||||
<a class="pointer dropdown-toggle" data-toggle="dropdown"> |
||||
<i class="fa fa-plus"></i> |
||||
</a> |
||||
<ul class="dropdown-menu" role="menu"> |
||||
<li ng-repeat="ct in ctrl.conditionTypes" role="menuitem"> |
||||
<a ng-click="ctrl.addCondition(ct.value);">{{ct.text}}</a> |
||||
</li> |
||||
</ul> |
||||
</label> |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="gf-form-group"> |
||||
<div class="gf-form"> |
||||
<span class="gf-form-label width-18">If no data or all values are null</span> |
||||
<span class="gf-form-label query-keyword">SET STATE TO</span> |
||||
<div class="gf-form-select-wrapper"> |
||||
<select class="gf-form-input" ng-model="ctrl.alert.noDataState" ng-options="f.value as f.text for f in ctrl.noDataModes"> |
||||
</select> |
||||
</div> |
||||
</div> |
||||
<div class="gf-form-group"> |
||||
<div class="gf-form"> |
||||
<span class="gf-form-label width-18">If no data or all values are null</span> |
||||
<span class="gf-form-label query-keyword">SET STATE TO</span> |
||||
<div class="gf-form-select-wrapper"> |
||||
<select class="gf-form-input" ng-model="ctrl.alert.noDataState" ng-options="f.value as f.text for f in ctrl.noDataModes"> |
||||
</select> |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="gf-form"> |
||||
<span class="gf-form-label width-18">If execution error or timeout</span> |
||||
<span class="gf-form-label query-keyword">SET STATE TO</span> |
||||
<div class="gf-form-select-wrapper"> |
||||
<select class="gf-form-input" ng-model="ctrl.alert.executionErrorState" ng-options="f.value as f.text for f in ctrl.executionErrorModes"> |
||||
</select> |
||||
</div> |
||||
</div> |
||||
<div class="gf-form"> |
||||
<span class="gf-form-label width-18">If execution error or timeout</span> |
||||
<span class="gf-form-label query-keyword">SET STATE TO</span> |
||||
<div class="gf-form-select-wrapper"> |
||||
<select class="gf-form-input" ng-model="ctrl.alert.executionErrorState" ng-options="f.value as f.text for f in ctrl.executionErrorModes"> |
||||
</select> |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="gf-form-button-row"> |
||||
<button class="btn btn-inverse" ng-click="ctrl.test()"> |
||||
Test Rule |
||||
</button> |
||||
</div> |
||||
</div> |
||||
<div class="gf-form-button-row"> |
||||
<button class="btn btn-inverse" ng-click="ctrl.test()"> |
||||
Test Rule |
||||
</button> |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="gf-form-group" ng-if="ctrl.testing"> |
||||
Evaluating rule <i class="fa fa-spinner fa-spin"></i> |
||||
</div> |
||||
<div class="gf-form-group" ng-if="ctrl.testing"> |
||||
Evaluating rule <i class="fa fa-spinner fa-spin"></i> |
||||
</div> |
||||
|
||||
<div class="gf-form-group" ng-if="ctrl.testResult"> |
||||
<json-tree root-name="result" object="ctrl.testResult" start-expanded="true"></json-tree> |
||||
</div> |
||||
</div> |
||||
<div class="gf-form-group" ng-if="ctrl.testResult"> |
||||
<json-tree root-name="result" object="ctrl.testResult" start-expanded="true"></json-tree> |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="gf-form-group" ng-if="ctrl.subTabIndex === 1"> |
||||
<h5 class="section-heading">Notifications</h5> |
||||
<div class="gf-form-inline"> |
||||
<div class="gf-form max-width-30"> |
||||
<span class="gf-form-label width-8">Send to</span> |
||||
<span class="gf-form-label" ng-repeat="nc in ctrl.alertNotifications" ng-style="{'background-color': nc.bgColor }"> |
||||
<i class="{{nc.iconClass}}"></i> {{nc.name}} |
||||
<i class="fa fa-remove pointer muted" ng-click="ctrl.removeNotification($index)" ng-if="nc.isDefault === false"></i> |
||||
</span> |
||||
<metric-segment segment="ctrl.addNotificationSegment" get-options="ctrl.getNotifications()" on-change="ctrl.notificationAdded()"></metric-segment> |
||||
</div> |
||||
</div> |
||||
<div class="gf-form gf-form--v-stretch"> |
||||
<span class="gf-form-label width-8">Message</span> |
||||
<textarea class="gf-form-input" rows="10" ng-model="ctrl.alert.message" placeholder="Notification message details..."></textarea> |
||||
</div> |
||||
</div> |
||||
<div class="gf-form-group" ng-if="ctrl.subTabIndex === 1"> |
||||
<h5 class="section-heading">Notifications</h5> |
||||
<div class="gf-form-inline"> |
||||
<div class="gf-form max-width-30"> |
||||
<span class="gf-form-label width-8">Send to</span> |
||||
<span class="gf-form-label" ng-repeat="nc in ctrl.alertNotifications" ng-style="{'background-color': nc.bgColor }"> |
||||
<i class="{{nc.iconClass}}"></i> {{nc.name}} |
||||
<i class="fa fa-remove pointer muted" ng-click="ctrl.removeNotification($index)" ng-if="nc.isDefault === false"></i> |
||||
</span> |
||||
<metric-segment segment="ctrl.addNotificationSegment" get-options="ctrl.getNotifications()" on-change="ctrl.notificationAdded()"></metric-segment> |
||||
</div> |
||||
</div> |
||||
<div class="gf-form gf-form--v-stretch"> |
||||
<span class="gf-form-label width-8">Message</span> |
||||
<textarea class="gf-form-input" rows="10" ng-model="ctrl.alert.message" placeholder="Notification message details..."></textarea> |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="gf-form-group" style="max-width: 720px;" ng-if="ctrl.subTabIndex === 2"> |
||||
<button class="btn btn-mini btn-danger pull-right" ng-click="ctrl.clearHistory()"><i class="fa fa-trash"></i> Clear history</button> |
||||
<h5 class="section-heading" style="whitespace: nowrap"> |
||||
State history <span class="muted small">(last 50 state changes)</span> |
||||
</h5> |
||||
<div class="gf-form-group" style="max-width: 720px;" ng-if="ctrl.subTabIndex === 2"> |
||||
<button class="btn btn-mini btn-danger pull-right" ng-click="ctrl.clearHistory()"><i class="fa fa-trash"></i> Clear history</button> |
||||
<h5 class="section-heading" style="whitespace: nowrap"> |
||||
State history <span class="muted small">(last 50 state changes)</span> |
||||
</h5> |
||||
|
||||
<div ng-show="ctrl.alertHistory.length === 0"> |
||||
<br> |
||||
<i>No state changes recorded</i> |
||||
</div> |
||||
<div ng-show="ctrl.alertHistory.length === 0"> |
||||
<br> |
||||
<i>No state changes recorded</i> |
||||
</div> |
||||
|
||||
<ol class="alert-rule-list" > |
||||
<li class="alert-rule-item" ng-repeat="al in ctrl.alertHistory"> |
||||
<div class="alert-rule-item__icon {{al.stateModel.stateClass}}"> |
||||
<i class="{{al.stateModel.iconClass}}"></i> |
||||
</div> |
||||
<div class="alert-rule-item__body"> |
||||
<div class="alert-rule-item__header"> |
||||
<div class="alert-rule-item__text"> |
||||
<span class="{{al.stateModel.stateClass}}">{{al.stateModel.text}}</span> |
||||
</div> |
||||
</div> |
||||
<span class="alert-list-info">{{al.info}}</span> |
||||
</div> |
||||
<div class="alert-rule-item__time"> |
||||
<span>{{al.time}}</span> |
||||
</div> |
||||
</li> |
||||
</ol> |
||||
</div> |
||||
</div> |
||||
<ol class="alert-rule-list" > |
||||
<li class="alert-rule-item" ng-repeat="al in ctrl.alertHistory"> |
||||
<div class="alert-rule-item__icon {{al.stateModel.stateClass}}"> |
||||
<i class="{{al.stateModel.iconClass}}"></i> |
||||
</div> |
||||
<div class="alert-rule-item__body"> |
||||
<div class="alert-rule-item__header"> |
||||
<div class="alert-rule-item__text"> |
||||
<span class="{{al.stateModel.stateClass}}">{{al.stateModel.text}}</span> |
||||
</div> |
||||
</div> |
||||
<span class="alert-list-info">{{al.info}}</span> |
||||
</div> |
||||
<div class="alert-rule-item__time"> |
||||
<span>{{al.time}}</span> |
||||
</div> |
||||
</li> |
||||
</ol> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="gf-form-group" ng-if="!ctrl.alert"> |
||||
<div class="gf-form-button-row"> |
||||
<button class="btn btn-inverse" ng-click="ctrl.enable()"> |
||||
<i class="icon-gf icon-gf-alert"></i> |
||||
Create Alert |
||||
</button> |
||||
</div> |
||||
<div class="gf-form-group p-t-4 p-b-4" ng-if="!ctrl.alert"> |
||||
<div class="empty-list-cta"> |
||||
<div class="empty-list-cta__title">Panel has no alert rule defined</div> |
||||
<button class="empty-list-cta__button btn btn-xlarge btn-success" ng-click="ctrl.enable()"> |
||||
<i class="icon-gf icon-gf-alert"></i> |
||||
Create Alert |
||||
</button> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
|
@ -0,0 +1,72 @@ |
||||
import React, { PureComponent } from 'react'; |
||||
|
||||
import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader'; |
||||
import { EditorTabBody } from './EditorTabBody'; |
||||
import 'app/features/alerting/AlertTabCtrl'; |
||||
|
||||
interface Props { |
||||
angularPanel?: AngularComponent; |
||||
} |
||||
|
||||
export class AlertTab extends PureComponent<Props> { |
||||
element: any; |
||||
component: AngularComponent; |
||||
|
||||
constructor(props) { |
||||
super(props); |
||||
} |
||||
|
||||
componentDidMount() { |
||||
if (this.shouldLoadAlertTab()) { |
||||
this.loadAlertTab(); |
||||
} |
||||
} |
||||
|
||||
componentDidUpdate(prevProps: Props) { |
||||
if (this.shouldLoadAlertTab()) { |
||||
this.loadAlertTab(); |
||||
} |
||||
} |
||||
|
||||
shouldLoadAlertTab() { |
||||
return this.props.angularPanel && this.element; |
||||
} |
||||
|
||||
componentWillUnmount() { |
||||
if (this.component) { |
||||
this.component.destroy(); |
||||
} |
||||
} |
||||
|
||||
loadAlertTab() { |
||||
const { angularPanel } = this.props; |
||||
|
||||
const scope = angularPanel.getScope(); |
||||
|
||||
// When full page reloading in edit mode the angular panel has on fully compiled & instantiated yet
|
||||
if (!scope.$$childHead) { |
||||
setTimeout(() => { |
||||
this.forceUpdate(); |
||||
}); |
||||
return; |
||||
} |
||||
|
||||
const panelCtrl = scope.$$childHead.ctrl; |
||||
const loader = getAngularLoader(); |
||||
const template = '<alert-tab />'; |
||||
|
||||
const scopeProps = { |
||||
ctrl: panelCtrl, |
||||
}; |
||||
|
||||
this.component = loader.load(this.element, scopeProps, template); |
||||
} |
||||
|
||||
render() { |
||||
return ( |
||||
<EditorTabBody heading="Alert" toolbarItems={[]}> |
||||
<div ref={element => (this.element = element)} /> |
||||
</EditorTabBody> |
||||
); |
||||
} |
||||
} |
@ -1,4 +1,4 @@ |
||||
import { react2AngularDirective } from 'app/core/utils/react2angular'; |
||||
import { DashboardGrid } from './DashboardGrid'; |
||||
import DashboardGrid from './DashboardGrid'; |
||||
|
||||
react2AngularDirective('dashboardGrid', DashboardGrid, [['dashboard', { watchDepth: 'reference' }]]); |
||||
|
@ -0,0 +1,133 @@ |
||||
// Libraries
|
||||
import React, { PureComponent } from 'react'; |
||||
|
||||
// Components
|
||||
import CustomScrollbar from 'app/core/components/CustomScrollbar/CustomScrollbar'; |
||||
import { FadeIn } from 'app/core/components/Animations/FadeIn'; |
||||
import { PanelOptionSection } from './PanelOptionSection'; |
||||
|
||||
interface Props { |
||||
children: JSX.Element; |
||||
heading: string; |
||||
renderToolbar?: () => JSX.Element; |
||||
toolbarItems?: EditorToolBarView[]; |
||||
} |
||||
|
||||
export interface EditorToolBarView { |
||||
title?: string; |
||||
heading?: string; |
||||
imgSrc?: string; |
||||
icon?: string; |
||||
disabled?: boolean; |
||||
onClick?: () => void; |
||||
render: (closeFunction?: any) => JSX.Element | JSX.Element[]; |
||||
} |
||||
|
||||
interface State { |
||||
openView?: EditorToolBarView; |
||||
isOpen: boolean; |
||||
fadeIn: boolean; |
||||
} |
||||
|
||||
export class EditorTabBody extends PureComponent<Props, State> { |
||||
static defaultProps = { |
||||
toolbarItems: [], |
||||
}; |
||||
|
||||
constructor(props) { |
||||
super(props); |
||||
|
||||
this.state = { |
||||
openView: null, |
||||
fadeIn: false, |
||||
isOpen: false, |
||||
}; |
||||
} |
||||
|
||||
componentDidMount() { |
||||
this.setState({ fadeIn: true }); |
||||
} |
||||
|
||||
onToggleToolBarView = (item: EditorToolBarView) => { |
||||
this.setState({ |
||||
openView: item, |
||||
isOpen: !this.state.isOpen, |
||||
}); |
||||
}; |
||||
|
||||
onCloseOpenView = () => { |
||||
this.setState({ isOpen: false }); |
||||
}; |
||||
|
||||
static getDerivedStateFromProps(props, state) { |
||||
if (state.openView) { |
||||
const activeToolbarItem = props.toolbarItems.find( |
||||
item => item.title === state.openView.title && item.icon === state.openView.icon |
||||
); |
||||
if (activeToolbarItem) { |
||||
return { |
||||
...state, |
||||
openView: activeToolbarItem, |
||||
}; |
||||
} |
||||
} |
||||
return state; |
||||
} |
||||
|
||||
renderButton(view: EditorToolBarView) { |
||||
const onClick = () => { |
||||
if (view.onClick) { |
||||
view.onClick(); |
||||
} |
||||
this.onToggleToolBarView(view); |
||||
}; |
||||
|
||||
return ( |
||||
<div className="nav-buttons" key={view.title + view.icon}> |
||||
<button className="btn navbar-button" onClick={onClick} disabled={view.disabled}> |
||||
{view.icon && <i className={view.icon} />} {view.title} |
||||
</button> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
renderOpenView(view: EditorToolBarView) { |
||||
return ( |
||||
<PanelOptionSection title={view.title || view.heading} onClose={this.onCloseOpenView}> |
||||
{view.render()} |
||||
</PanelOptionSection> |
||||
); |
||||
} |
||||
|
||||
render() { |
||||
const { children, renderToolbar, heading, toolbarItems } = this.props; |
||||
const { openView, fadeIn, isOpen } = this.state; |
||||
|
||||
return ( |
||||
<> |
||||
<div className="toolbar"> |
||||
<div className="toolbar__heading">{heading}</div> |
||||
{renderToolbar && renderToolbar()} |
||||
{toolbarItems.length > 0 && ( |
||||
<> |
||||
<div className="gf-form--grow" /> |
||||
{toolbarItems.map(item => this.renderButton(item))} |
||||
</> |
||||
)} |
||||
</div> |
||||
<div className="panel-editor__scroll"> |
||||
<CustomScrollbar autoHide={false}> |
||||
<div className="panel-editor__content"> |
||||
<FadeIn in={isOpen} duration={200} unmountOnExit={true}> |
||||
{openView && this.renderOpenView(openView)} |
||||
</FadeIn> |
||||
<FadeIn in={fadeIn} duration={50}> |
||||
{children} |
||||
</FadeIn> |
||||
</div> |
||||
</CustomScrollbar> |
||||
</div> |
||||
</> |
||||
); |
||||
} |
||||
} |
@ -0,0 +1,52 @@ |
||||
import React, { PureComponent } from 'react'; |
||||
|
||||
import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader'; |
||||
import { EditorTabBody } from './EditorTabBody'; |
||||
|
||||
import { PanelModel } from '../panel_model'; |
||||
import './../../panel/GeneralTabCtrl'; |
||||
|
||||
interface Props { |
||||
panel: PanelModel; |
||||
} |
||||
|
||||
export class GeneralTab extends PureComponent<Props> { |
||||
element: any; |
||||
component: AngularComponent; |
||||
|
||||
constructor(props) { |
||||
super(props); |
||||
} |
||||
|
||||
componentDidMount() { |
||||
if (!this.element) { |
||||
return; |
||||
} |
||||
|
||||
const { panel } = this.props; |
||||
|
||||
const loader = getAngularLoader(); |
||||
const template = '<panel-general-tab />'; |
||||
const scopeProps = { |
||||
ctrl: { |
||||
panel: panel, |
||||
}, |
||||
}; |
||||
|
||||
this.component = loader.load(this.element, scopeProps, template); |
||||
} |
||||
|
||||
componentWillUnmount() { |
||||
if (this.component) { |
||||
this.component.destroy(); |
||||
} |
||||
} |
||||
|
||||
render() { |
||||
return ( |
||||
<EditorTabBody heading="Panel Options" toolbarItems={[]}> |
||||
<div ref={element => (this.element = element)} /> |
||||
</EditorTabBody> |
||||
); |
||||
} |
||||
} |
@ -1,51 +1,88 @@ |
||||
import React, { PureComponent } from 'react'; |
||||
import React, { Component } from 'react'; |
||||
import classNames from 'classnames'; |
||||
|
||||
import PanelHeaderCorner from './PanelHeaderCorner'; |
||||
import { PanelHeaderMenu } from './PanelHeaderMenu'; |
||||
|
||||
import { DashboardModel } from 'app/features/dashboard/dashboard_model'; |
||||
import { PanelModel } from 'app/features/dashboard/panel_model'; |
||||
import { ClickOutsideWrapper } from 'app/core/components/ClickOutsideWrapper/ClickOutsideWrapper'; |
||||
|
||||
export interface Props { |
||||
panel: PanelModel; |
||||
dashboard: DashboardModel; |
||||
timeInfo: string; |
||||
title?: string; |
||||
description?: string; |
||||
scopedVars?: string; |
||||
links?: []; |
||||
} |
||||
|
||||
export class PanelHeader extends PureComponent<Props> { |
||||
interface State { |
||||
panelMenuOpen: boolean; |
||||
} |
||||
|
||||
export class PanelHeader extends Component<Props, State> { |
||||
state = { |
||||
panelMenuOpen: false, |
||||
}; |
||||
|
||||
onMenuToggle = event => { |
||||
event.stopPropagation(); |
||||
|
||||
this.setState(prevState => ({ |
||||
panelMenuOpen: !prevState.panelMenuOpen, |
||||
})); |
||||
}; |
||||
|
||||
closeMenu = () => { |
||||
this.setState({ |
||||
panelMenuOpen: false, |
||||
}); |
||||
}; |
||||
|
||||
render() { |
||||
const isFullscreen = false; |
||||
const isLoading = false; |
||||
const panelHeaderClass = classNames({ 'panel-header': true, 'grid-drag-handle': !isFullscreen }); |
||||
const { panel, dashboard } = this.props; |
||||
|
||||
const { panel, dashboard, timeInfo } = this.props; |
||||
return ( |
||||
<div className={panelHeaderClass}> |
||||
<span className="panel-info-corner"> |
||||
<i className="fa" /> |
||||
<span className="panel-info-corner-inner" /> |
||||
</span> |
||||
|
||||
{isLoading && ( |
||||
<span className="panel-loading"> |
||||
<i className="fa fa-spinner fa-spin" /> |
||||
</span> |
||||
)} |
||||
|
||||
<div className="panel-title-container"> |
||||
<div className="panel-title"> |
||||
<span className="icon-gf panel-alert-icon" /> |
||||
<span className="panel-title-text" data-toggle="dropdown"> |
||||
{panel.title} <span className="fa fa-caret-down panel-menu-toggle" /> |
||||
<> |
||||
<PanelHeaderCorner |
||||
panel={panel} |
||||
title={panel.title} |
||||
description={panel.description} |
||||
scopedVars={panel.scopedVars} |
||||
links={panel.links} |
||||
/> |
||||
<div className={panelHeaderClass}> |
||||
{isLoading && ( |
||||
<span className="panel-loading"> |
||||
<i className="fa fa-spinner fa-spin" /> |
||||
</span> |
||||
)} |
||||
<div className="panel-title-container" onClick={this.onMenuToggle}> |
||||
<div className="panel-title"> |
||||
<span className="icon-gf panel-alert-icon" /> |
||||
<span className="panel-title-text"> |
||||
{panel.title} <span className="fa fa-caret-down panel-menu-toggle" /> |
||||
</span> |
||||
|
||||
<PanelHeaderMenu panel={panel} dashboard={dashboard} /> |
||||
{this.state.panelMenuOpen && ( |
||||
<ClickOutsideWrapper onClick={this.closeMenu}> |
||||
<PanelHeaderMenu panel={panel} dashboard={dashboard} /> |
||||
</ClickOutsideWrapper> |
||||
)} |
||||
|
||||
<span className="panel-time-info"> |
||||
<i className="fa fa-clock-o" /> 4m |
||||
</span> |
||||
{timeInfo && ( |
||||
<span className="panel-time-info"> |
||||
<i className="fa fa-clock-o" /> {timeInfo} |
||||
</span> |
||||
)} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</> |
||||
); |
||||
} |
||||
} |
||||
|
@ -0,0 +1,94 @@ |
||||
import React, { Component } from 'react'; |
||||
import { PanelModel } from 'app/features/dashboard/panel_model'; |
||||
import Tooltip from 'app/core/components/Tooltip/Tooltip'; |
||||
import templateSrv from 'app/features/templating/template_srv'; |
||||
import { LinkSrv } from 'app/features/dashboard/panellinks/link_srv'; |
||||
import { getTimeSrv, TimeSrv } from 'app/features/dashboard/time_srv'; |
||||
import Remarkable from 'remarkable'; |
||||
|
||||
enum InfoModes { |
||||
Error = 'Error', |
||||
Info = 'Info', |
||||
Links = 'Links', |
||||
} |
||||
|
||||
interface Props { |
||||
panel: PanelModel; |
||||
title?: string; |
||||
description?: string; |
||||
scopedVars?: string; |
||||
links?: []; |
||||
} |
||||
|
||||
export class PanelHeaderCorner extends Component<Props> { |
||||
timeSrv: TimeSrv = getTimeSrv(); |
||||
|
||||
getInfoMode = () => { |
||||
const { panel } = this.props; |
||||
if (!!panel.description) { |
||||
return InfoModes.Info; |
||||
} |
||||
if (panel.links && panel.links.length) { |
||||
return InfoModes.Links; |
||||
} |
||||
|
||||
return undefined; |
||||
}; |
||||
|
||||
getInfoContent = (): JSX.Element => { |
||||
const { panel } = this.props; |
||||
const markdown = panel.description; |
||||
const linkSrv = new LinkSrv(templateSrv, this.timeSrv); |
||||
const interpolatedMarkdown = templateSrv.replace(markdown, panel.scopedVars); |
||||
const remarkableInterpolatedMarkdown = new Remarkable().render(interpolatedMarkdown); |
||||
|
||||
const html = ( |
||||
<div className="markdown-html"> |
||||
<div dangerouslySetInnerHTML={{ __html: remarkableInterpolatedMarkdown }} /> |
||||
{panel.links && |
||||
panel.links.length > 0 && ( |
||||
<ul className="text-left"> |
||||
{panel.links.map((link, idx) => { |
||||
const info = linkSrv.getPanelLinkAnchorInfo(link, panel.scopedVars); |
||||
return ( |
||||
<li key={idx}> |
||||
<a className="panel-menu-link" href={info.href} target={info.target}> |
||||
{info.title} |
||||
</a> |
||||
</li> |
||||
); |
||||
})} |
||||
</ul> |
||||
)} |
||||
</div> |
||||
); |
||||
|
||||
return html; |
||||
}; |
||||
|
||||
render() { |
||||
const infoMode: InfoModes | undefined = this.getInfoMode(); |
||||
|
||||
if (!infoMode) { |
||||
return null; |
||||
} |
||||
|
||||
return ( |
||||
<> |
||||
{infoMode === InfoModes.Info || infoMode === InfoModes.Links ? ( |
||||
<Tooltip |
||||
content={this.getInfoContent} |
||||
className="popper__manager--block" |
||||
refClassName={`panel-info-corner panel-info-corner--${infoMode.toLowerCase()}`} |
||||
placement="bottom-start" |
||||
> |
||||
<i className="fa" /> |
||||
<span className="panel-info-corner-inner" /> |
||||
</Tooltip> |
||||
) : null} |
||||
</> |
||||
); |
||||
} |
||||
} |
||||
|
||||
export default PanelHeaderCorner; |
@ -0,0 +1,26 @@ |
||||
// Libraries
|
||||
import React, { SFC } from 'react'; |
||||
|
||||
interface Props { |
||||
title?: string; |
||||
onClose?: () => void; |
||||
children: JSX.Element | JSX.Element[]; |
||||
} |
||||
|
||||
export const PanelOptionSection: SFC<Props> = props => { |
||||
return ( |
||||
<div className="panel-option-section"> |
||||
{props.title && ( |
||||
<div className="panel-option-section__header"> |
||||
{props.title} |
||||
{props.onClose && ( |
||||
<button className="btn btn-link" onClick={props.onClose}> |
||||
<i className="fa fa-remove" /> |
||||
</button> |
||||
)} |
||||
</div> |
||||
)} |
||||
<div className="panel-option-section__body">{props.children}</div> |
||||
</div> |
||||
); |
||||
}; |
@ -0,0 +1,64 @@ |
||||
import _ from 'lodash'; |
||||
import React, { PureComponent } from 'react'; |
||||
import { PanelPlugin, PanelProps } from 'app/types'; |
||||
|
||||
interface Props { |
||||
pluginId: string; |
||||
} |
||||
|
||||
class PanelPluginNotFound extends PureComponent<Props> { |
||||
constructor(props) { |
||||
super(props); |
||||
} |
||||
|
||||
render() { |
||||
const style = { |
||||
display: 'flex', |
||||
alignItems: 'center', |
||||
textAlign: 'center' as 'center', |
||||
height: '100%', |
||||
}; |
||||
|
||||
return ( |
||||
<div style={style}> |
||||
<div className="alert alert-error" style={{ margin: '0 auto' }}> |
||||
Panel plugin with id {this.props.pluginId} could not be found |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
} |
||||
|
||||
export function getPanelPluginNotFound(id: string): PanelPlugin { |
||||
const NotFound = class NotFound extends PureComponent<PanelProps> { |
||||
render() { |
||||
return <PanelPluginNotFound pluginId={id} />; |
||||
} |
||||
}; |
||||
|
||||
return { |
||||
id: id, |
||||
name: id, |
||||
sort: 100, |
||||
module: '', |
||||
baseUrl: '', |
||||
info: { |
||||
author: { |
||||
name: '', |
||||
}, |
||||
description: '', |
||||
links: [], |
||||
logos: { |
||||
large: '', |
||||
small: '', |
||||
}, |
||||
screenshots: [], |
||||
updated: '', |
||||
version: '', |
||||
}, |
||||
|
||||
exports: { |
||||
Panel: NotFound, |
||||
}, |
||||
}; |
||||
} |
@ -0,0 +1,220 @@ |
||||
import React, { PureComponent } from 'react'; |
||||
import { JSONFormatter } from 'app/core/components/JSONFormatter/JSONFormatter'; |
||||
import appEvents from 'app/core/app_events'; |
||||
import { CopyToClipboard } from 'app/core/components/CopyToClipboard/CopyToClipboard'; |
||||
|
||||
interface DsQuery { |
||||
isLoading: boolean; |
||||
response: {}; |
||||
} |
||||
|
||||
interface Props { |
||||
panel: any; |
||||
LoadingPlaceholder: any; |
||||
} |
||||
|
||||
interface State { |
||||
allNodesExpanded: boolean; |
||||
isMocking: boolean; |
||||
mockedResponse: string; |
||||
dsQuery: DsQuery; |
||||
} |
||||
|
||||
export class QueryInspector extends PureComponent<Props, State> { |
||||
formattedJson: any; |
||||
clipboard: any; |
||||
|
||||
constructor(props) { |
||||
super(props); |
||||
this.state = { |
||||
allNodesExpanded: null, |
||||
isMocking: false, |
||||
mockedResponse: '', |
||||
dsQuery: { |
||||
isLoading: false, |
||||
response: {}, |
||||
}, |
||||
}; |
||||
} |
||||
|
||||
componentDidMount() { |
||||
const { panel } = this.props; |
||||
panel.events.on('refresh', this.onPanelRefresh); |
||||
appEvents.on('ds-request-response', this.onDataSourceResponse); |
||||
panel.refresh(); |
||||
} |
||||
|
||||
componentWillUnmount() { |
||||
const { panel } = this.props; |
||||
appEvents.off('ds-request-response', this.onDataSourceResponse); |
||||
panel.events.off('refresh', this.onPanelRefresh); |
||||
} |
||||
|
||||
handleMocking(response) { |
||||
const { mockedResponse } = this.state; |
||||
let mockedData; |
||||
try { |
||||
mockedData = JSON.parse(mockedResponse); |
||||
} catch (err) { |
||||
appEvents.emit('alert-error', ['R: Failed to parse mocked response']); |
||||
return; |
||||
} |
||||
|
||||
response.data = mockedData; |
||||
} |
||||
|
||||
onPanelRefresh = () => { |
||||
this.setState(prevState => ({ |
||||
...prevState, |
||||
dsQuery: { |
||||
isLoading: true, |
||||
response: {}, |
||||
}, |
||||
})); |
||||
}; |
||||
|
||||
onDataSourceResponse = (response: any = {}) => { |
||||
if (this.state.isMocking) { |
||||
this.handleMocking(response); |
||||
return; |
||||
} |
||||
|
||||
response = { ...response }; // clone - dont modify the response
|
||||
|
||||
if (response.headers) { |
||||
delete response.headers; |
||||
} |
||||
|
||||
if (response.config) { |
||||
response.request = response.config; |
||||
delete response.config; |
||||
delete response.request.transformRequest; |
||||
delete response.request.transformResponse; |
||||
delete response.request.paramSerializer; |
||||
delete response.request.jsonpCallbackParam; |
||||
delete response.request.headers; |
||||
delete response.request.requestId; |
||||
delete response.request.inspect; |
||||
delete response.request.retry; |
||||
delete response.request.timeout; |
||||
} |
||||
|
||||
if (response.data) { |
||||
response.response = response.data; |
||||
|
||||
delete response.data; |
||||
delete response.status; |
||||
delete response.statusText; |
||||
delete response.$$config; |
||||
} |
||||
this.setState(prevState => ({ |
||||
...prevState, |
||||
dsQuery: { |
||||
isLoading: false, |
||||
response: response, |
||||
}, |
||||
})); |
||||
}; |
||||
|
||||
setFormattedJson = formattedJson => { |
||||
this.formattedJson = formattedJson; |
||||
}; |
||||
|
||||
getTextForClipboard = () => { |
||||
return JSON.stringify(this.formattedJson, null, 2); |
||||
}; |
||||
|
||||
onClipboardSuccess = () => { |
||||
appEvents.emit('alert-success', ['Content copied to clipboard']); |
||||
}; |
||||
|
||||
onToggleExpand = () => { |
||||
this.setState(prevState => ({ |
||||
...prevState, |
||||
allNodesExpanded: !this.state.allNodesExpanded, |
||||
})); |
||||
}; |
||||
|
||||
onToggleMocking = () => { |
||||
this.setState(prevState => ({ |
||||
...prevState, |
||||
isMocking: !this.state.isMocking, |
||||
})); |
||||
}; |
||||
|
||||
getNrOfOpenNodes = () => { |
||||
if (this.state.allNodesExpanded === null) { |
||||
return 3; // 3 is default, ie when state is null
|
||||
} else if (this.state.allNodesExpanded) { |
||||
return 20; |
||||
} |
||||
return 1; |
||||
}; |
||||
|
||||
setMockedResponse = evt => { |
||||
const mockedResponse = evt.target.value; |
||||
this.setState(prevState => ({ |
||||
...prevState, |
||||
mockedResponse, |
||||
})); |
||||
}; |
||||
|
||||
renderExpandCollapse = () => { |
||||
const { allNodesExpanded } = this.state; |
||||
|
||||
const collapse = ( |
||||
<> |
||||
<i className="fa fa-minus-square-o" /> Collapse All |
||||
</> |
||||
); |
||||
const expand = ( |
||||
<> |
||||
<i className="fa fa-plus-square-o" /> Expand All |
||||
</> |
||||
); |
||||
return allNodesExpanded ? collapse : expand; |
||||
}; |
||||
|
||||
render() { |
||||
const { response, isLoading } = this.state.dsQuery; |
||||
const { LoadingPlaceholder } = this.props; |
||||
const { isMocking } = this.state; |
||||
const openNodes = this.getNrOfOpenNodes(); |
||||
|
||||
if (isLoading) { |
||||
return <LoadingPlaceholder text="Loading query inspector..." />; |
||||
} |
||||
|
||||
return ( |
||||
<> |
||||
<div className="pull-right"> |
||||
<button className="btn btn-transparent btn-p-x-0 m-r-1" onClick={this.onToggleExpand}> |
||||
{this.renderExpandCollapse()} |
||||
</button> |
||||
<CopyToClipboard |
||||
className="btn btn-transparent btn-p-x-0" |
||||
text={this.getTextForClipboard} |
||||
onSuccess={this.onClipboardSuccess} |
||||
> |
||||
<i className="fa fa-clipboard" /> Copy to Clipboard |
||||
</CopyToClipboard> |
||||
</div> |
||||
|
||||
{!isMocking && <JSONFormatter json={response} open={openNodes} onDidRender={this.setFormattedJson} />} |
||||
{isMocking && ( |
||||
<div className="query-troubleshooter__body"> |
||||
<div className="gf-form p-l-1 gf-form--v-stretch"> |
||||
<textarea |
||||
className="gf-form-input" |
||||
style={{ width: '95%' }} |
||||
rows={10} |
||||
onInput={this.setMockedResponse} |
||||
placeholder="JSON" |
||||
/> |
||||
</div> |
||||
</div> |
||||
)} |
||||
</> |
||||
); |
||||
} |
||||
} |
@ -0,0 +1,167 @@ |
||||
// Libraries
|
||||
import React, { PureComponent } from 'react'; |
||||
|
||||
// Utils
|
||||
import { isValidTimeSpan } from 'app/core/utils/rangeutil'; |
||||
|
||||
// Components
|
||||
import { Switch } from 'app/core/components/Switch/Switch'; |
||||
import { Input } from 'app/core/components/Form'; |
||||
import { EventsWithValidation } from 'app/core/components/Form/Input'; |
||||
import { InputStatus } from 'app/core/components/Form/Input'; |
||||
import DataSourceOption from './DataSourceOption'; |
||||
|
||||
// Types
|
||||
import { PanelModel } from '../panel_model'; |
||||
import { ValidationEvents, DataSourceSelectItem } from 'app/types'; |
||||
|
||||
const timeRangeValidationEvents: ValidationEvents = { |
||||
[EventsWithValidation.onBlur]: [ |
||||
{ |
||||
rule: value => { |
||||
if (!value) { |
||||
return true; |
||||
} |
||||
return isValidTimeSpan(value); |
||||
}, |
||||
errorMessage: 'Not a valid timespan', |
||||
}, |
||||
], |
||||
}; |
||||
|
||||
const emptyToNull = (value: string) => { |
||||
return value === '' ? null : value; |
||||
}; |
||||
|
||||
interface Props { |
||||
panel: PanelModel; |
||||
datasource: DataSourceSelectItem; |
||||
} |
||||
|
||||
export class QueryOptions extends PureComponent<Props> { |
||||
onOverrideTime = (evt, status: InputStatus) => { |
||||
const { value } = evt.target; |
||||
const { panel } = this.props; |
||||
const emptyToNullValue = emptyToNull(value); |
||||
if (status === InputStatus.Valid && panel.timeFrom !== emptyToNullValue) { |
||||
panel.timeFrom = emptyToNullValue; |
||||
panel.refresh(); |
||||
} |
||||
}; |
||||
|
||||
onTimeShift = (evt, status: InputStatus) => { |
||||
const { value } = evt.target; |
||||
const { panel } = this.props; |
||||
const emptyToNullValue = emptyToNull(value); |
||||
if (status === InputStatus.Valid && panel.timeShift !== emptyToNullValue) { |
||||
panel.timeShift = emptyToNullValue; |
||||
panel.refresh(); |
||||
} |
||||
}; |
||||
|
||||
onToggleTimeOverride = () => { |
||||
const { panel } = this.props; |
||||
panel.hideTimeOverride = !panel.hideTimeOverride; |
||||
panel.refresh(); |
||||
}; |
||||
|
||||
renderOptions() { |
||||
const { datasource, panel } = this.props; |
||||
const { queryOptions } = datasource.meta; |
||||
|
||||
if (!queryOptions) { |
||||
return null; |
||||
} |
||||
|
||||
const onChangeFn = (panelKey: string) => { |
||||
return (value: string | number) => { |
||||
panel[panelKey] = value; |
||||
panel.refresh(); |
||||
}; |
||||
}; |
||||
|
||||
const allOptions = { |
||||
cacheTimeout: { |
||||
label: 'Cache timeout', |
||||
placeholder: '60', |
||||
name: 'cacheTimeout', |
||||
value: panel.cacheTimeout, |
||||
tooltipInfo: ( |
||||
<> |
||||
If your time series store has a query cache this option can override the default cache timeout. Specify a |
||||
numeric value in seconds. |
||||
</> |
||||
), |
||||
}, |
||||
maxDataPoints: { |
||||
label: 'Max data points', |
||||
placeholder: 'auto', |
||||
name: 'maxDataPoints', |
||||
value: panel.maxDataPoints, |
||||
tooltipInfo: ( |
||||
<> |
||||
The maximum data points the query should return. For graphs this is automatically set to one data point per |
||||
pixel. |
||||
</> |
||||
), |
||||
}, |
||||
minInterval: { |
||||
label: 'Min time interval', |
||||
placeholder: '0', |
||||
name: 'minInterval', |
||||
value: panel.interval, |
||||
panelKey: 'interval', |
||||
tooltipInfo: ( |
||||
<> |
||||
A lower limit for the auto group by time interval. Recommended to be set to write frequency, for example{' '} |
||||
<code>1m</code> if your data is written every minute. Access auto interval via variable{' '} |
||||
<code>$__interval</code> for time range string and <code>$__interval_ms</code> for numeric variable that can |
||||
be used in math expressions. |
||||
</> |
||||
), |
||||
}, |
||||
}; |
||||
|
||||
return Object.keys(queryOptions).map(key => { |
||||
const options = allOptions[key]; |
||||
return <DataSourceOption key={key} {...options} onChange={onChangeFn(allOptions[key].panelKey || key)} />; |
||||
}); |
||||
} |
||||
|
||||
render = () => { |
||||
const hideTimeOverride = this.props.panel.hideTimeOverride; |
||||
return ( |
||||
<div className="gf-form-inline"> |
||||
{this.renderOptions()} |
||||
|
||||
<div className="gf-form"> |
||||
<span className="gf-form-label">Relative time</span> |
||||
<Input |
||||
type="text" |
||||
className="width-6" |
||||
placeholder="1h" |
||||
onBlur={this.onOverrideTime} |
||||
validationEvents={timeRangeValidationEvents} |
||||
hideErrorMessage={true} |
||||
/> |
||||
</div> |
||||
|
||||
<div className="gf-form"> |
||||
<span className="gf-form-label">Time shift</span> |
||||
<Input |
||||
type="text" |
||||
className="width-6" |
||||
placeholder="1h" |
||||
onBlur={this.onTimeShift} |
||||
validationEvents={timeRangeValidationEvents} |
||||
hideErrorMessage={true} |
||||
/> |
||||
</div> |
||||
|
||||
<div className="gf-form-inline"> |
||||
<Switch label="Hide time info" checked={hideTimeOverride} onChange={this.onToggleTimeOverride} /> |
||||
</div> |
||||
</div> |
||||
); |
||||
}; |
||||
} |
@ -0,0 +1,221 @@ |
||||
// Libraries
|
||||
import React, { PureComponent } from 'react'; |
||||
|
||||
// Utils & Services
|
||||
import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader'; |
||||
|
||||
// Components
|
||||
import { EditorTabBody } from './EditorTabBody'; |
||||
import { VizTypePicker } from './VizTypePicker'; |
||||
import { FadeIn } from 'app/core/components/Animations/FadeIn'; |
||||
import { PanelOptionSection } from './PanelOptionSection'; |
||||
|
||||
// Types
|
||||
import { PanelModel } from '../panel_model'; |
||||
import { DashboardModel } from '../dashboard_model'; |
||||
import { PanelPlugin } from 'app/types/plugins'; |
||||
|
||||
interface Props { |
||||
panel: PanelModel; |
||||
dashboard: DashboardModel; |
||||
plugin: PanelPlugin; |
||||
angularPanel?: AngularComponent; |
||||
onTypeChanged: (newType: PanelPlugin) => void; |
||||
} |
||||
|
||||
interface State { |
||||
isVizPickerOpen: boolean; |
||||
searchQuery: string; |
||||
} |
||||
|
||||
export class VisualizationTab extends PureComponent<Props, State> { |
||||
element: HTMLElement; |
||||
angularOptions: AngularComponent; |
||||
searchInput: HTMLElement; |
||||
|
||||
constructor(props) { |
||||
super(props); |
||||
|
||||
this.state = { |
||||
isVizPickerOpen: false, |
||||
searchQuery: '', |
||||
}; |
||||
} |
||||
|
||||
getPanelDefaultOptions = () => { |
||||
const { panel, plugin } = this.props; |
||||
|
||||
if (plugin.exports.PanelDefaults) { |
||||
return panel.getOptions(plugin.exports.PanelDefaults.options); |
||||
} |
||||
|
||||
return panel.getOptions(plugin.exports.PanelDefaults); |
||||
}; |
||||
|
||||
renderPanelOptions() { |
||||
const { plugin, angularPanel } = this.props; |
||||
const { PanelOptions } = plugin.exports; |
||||
|
||||
if (angularPanel) { |
||||
return <div ref={element => (this.element = element)} />; |
||||
} |
||||
|
||||
return ( |
||||
<PanelOptionSection> |
||||
{PanelOptions ? ( |
||||
<PanelOptions options={this.getPanelDefaultOptions()} onChange={this.onPanelOptionsChanged} /> |
||||
) : ( |
||||
<p>Visualization has no options</p> |
||||
)} |
||||
</PanelOptionSection> |
||||
); |
||||
} |
||||
|
||||
componentDidMount() { |
||||
if (this.shouldLoadAngularOptions()) { |
||||
this.loadAngularOptions(); |
||||
} |
||||
} |
||||
|
||||
componentDidUpdate(prevProps: Props) { |
||||
if (this.props.plugin !== prevProps.plugin) { |
||||
this.cleanUpAngularOptions(); |
||||
} |
||||
|
||||
if (this.shouldLoadAngularOptions()) { |
||||
this.loadAngularOptions(); |
||||
} |
||||
} |
||||
|
||||
shouldLoadAngularOptions() { |
||||
return this.props.angularPanel && this.element && !this.angularOptions; |
||||
} |
||||
|
||||
loadAngularOptions() { |
||||
const { angularPanel } = this.props; |
||||
|
||||
const scope = angularPanel.getScope(); |
||||
|
||||
// When full page reloading in edit mode the angular panel has on fully compiled & instantiated yet
|
||||
if (!scope.$$childHead) { |
||||
setTimeout(() => { |
||||
this.forceUpdate(); |
||||
}); |
||||
return; |
||||
} |
||||
|
||||
const panelCtrl = scope.$$childHead.ctrl; |
||||
|
||||
let template = ''; |
||||
for (let i = 0; i < panelCtrl.editorTabs.length; i++) { |
||||
template += |
||||
` |
||||
<div class="panel-option-section" ng-cloak>` +
|
||||
(i > 0 ? `<div class="panel-option-section__header">{{ctrl.editorTabs[${i}].title}}</div>` : '') + |
||||
`<div class="panel-option-section__body">
|
||||
<panel-editor-tab editor-tab="ctrl.editorTabs[${i}]" ctrl="ctrl"></panel-editor-tab> |
||||
</div> |
||||
</div> |
||||
`;
|
||||
} |
||||
|
||||
const loader = getAngularLoader(); |
||||
const scopeProps = { ctrl: panelCtrl }; |
||||
|
||||
this.angularOptions = loader.load(this.element, scopeProps, template); |
||||
} |
||||
|
||||
componentWillUnmount() { |
||||
this.cleanUpAngularOptions(); |
||||
} |
||||
|
||||
cleanUpAngularOptions() { |
||||
if (this.angularOptions) { |
||||
this.angularOptions.destroy(); |
||||
this.angularOptions = null; |
||||
} |
||||
} |
||||
|
||||
onPanelOptionsChanged = (options: any) => { |
||||
this.props.panel.updateOptions(options); |
||||
this.forceUpdate(); |
||||
}; |
||||
|
||||
onOpenVizPicker = () => { |
||||
this.setState({ isVizPickerOpen: true }); |
||||
}; |
||||
|
||||
onCloseVizPicker = () => { |
||||
this.setState({ isVizPickerOpen: false }); |
||||
}; |
||||
|
||||
onSearchQueryChange = evt => { |
||||
const value = evt.target.value; |
||||
this.setState({ |
||||
searchQuery: value, |
||||
}); |
||||
}; |
||||
|
||||
renderToolbar = (): JSX.Element => { |
||||
const { plugin } = this.props; |
||||
const { searchQuery } = this.state; |
||||
|
||||
if (this.state.isVizPickerOpen) { |
||||
return ( |
||||
<> |
||||
<label className="gf-form--has-input-icon"> |
||||
<input |
||||
type="text" |
||||
className="gf-form-input width-13" |
||||
placeholder="" |
||||
onChange={this.onSearchQueryChange} |
||||
value={searchQuery} |
||||
ref={elem => elem && elem.focus()} |
||||
/> |
||||
<i className="gf-form-input-icon fa fa-search" /> |
||||
</label> |
||||
<button className="btn btn-link toolbar__close" onClick={this.onCloseVizPicker}> |
||||
<i className="fa fa-chevron-up" /> |
||||
</button> |
||||
</> |
||||
); |
||||
} else { |
||||
return ( |
||||
<div className="toolbar__main" onClick={this.onOpenVizPicker}> |
||||
<img className="toolbar__main-image" src={plugin.info.logos.small} /> |
||||
<div className="toolbar__main-name">{plugin.name}</div> |
||||
<i className="fa fa-caret-down" /> |
||||
</div> |
||||
); |
||||
} |
||||
}; |
||||
|
||||
onTypeChanged = (plugin: PanelPlugin) => { |
||||
if (plugin.id === this.props.plugin.id) { |
||||
this.setState({ isVizPickerOpen: false }); |
||||
} else { |
||||
this.props.onTypeChanged(plugin); |
||||
} |
||||
}; |
||||
|
||||
render() { |
||||
const { plugin } = this.props; |
||||
const { isVizPickerOpen, searchQuery } = this.state; |
||||
|
||||
return ( |
||||
<EditorTabBody heading="Visualization" renderToolbar={this.renderToolbar}> |
||||
<> |
||||
<FadeIn in={isVizPickerOpen} duration={200} unmountOnExit={true}> |
||||
<VizTypePicker |
||||
current={plugin} |
||||
onTypeChanged={this.onTypeChanged} |
||||
searchQuery={searchQuery} |
||||
onClose={this.onCloseVizPicker} |
||||
/> |
||||
</FadeIn> |
||||
{this.renderPanelOptions()} |
||||
</> |
||||
</EditorTabBody> |
||||
); |
||||
} |
||||
} |
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue