diff --git a/public/app/core/components/CopyToClipboard/CopyToClipboard.tsx b/public/app/core/components/CopyToClipboard/CopyToClipboard.tsx new file mode 100644 index 00000000000..ea63de58b47 --- /dev/null +++ b/public/app/core/components/CopyToClipboard/CopyToClipboard.tsx @@ -0,0 +1,67 @@ +import React, { PureComponent, ReactNode } from 'react'; +import ClipboardJS from 'clipboard'; + +interface Props { + text: () => string; + elType?: string; + onSuccess?: (evt: any) => void; + onError?: (evt: any) => void; + className?: string; + children?: ReactNode; +} + +export class CopyToClipboard extends PureComponent { + clipboardjs: any; + myRef: any; + + constructor(props) { + super(props); + this.myRef = React.createRef(); + } + + componentDidMount() { + const { text, onSuccess, onError } = this.props; + + this.clipboardjs = new ClipboardJS(this.myRef.current, { + text: text, + }); + + if (onSuccess) { + this.clipboardjs.on('success', evt => { + evt.clearSelection(); + onSuccess(evt); + }); + } + + if (onError) { + this.clipboardjs.on('error', evt => { + console.error('Action:', evt.action); + console.error('Trigger:', evt.trigger); + onError(evt); + }); + } + } + + componentWillUnmount() { + if (this.clipboardjs) { + this.clipboardjs.destroy(); + } + } + + getElementType = () => { + return this.props.elType || 'button'; + }; + + render() { + const { elType, text, children, onError, onSuccess, ...restProps } = this.props; + + return React.createElement( + this.getElementType(), + { + ref: this.myRef, + ...restProps, + }, + this.props.children + ); + } +} diff --git a/public/app/core/components/Form/Element.tsx b/public/app/core/components/Form/Element.tsx new file mode 100644 index 00000000000..997d7f0e717 --- /dev/null +++ b/public/app/core/components/Form/Element.tsx @@ -0,0 +1,43 @@ +import React, { PureComponent, ReactNode, ReactElement } from 'react'; +import { Label } from './Label'; +import { uniqueId } from 'lodash'; + +interface Props { + label?: ReactNode; + labelClassName?: string; + id?: string; + children: ReactElement; +} + +export class Element extends PureComponent { + elementId: string = this.props.id || uniqueId('form-element-'); + + get elementLabel() { + const { label, labelClassName } = this.props; + + if (label) { + return ( + + ); + } + + return null; + } + + get children() { + const { children } = this.props; + + return React.cloneElement(children, { id: this.elementId }); + } + + render() { + return ( +
+ {this.elementLabel} + {this.children} +
+ ); + } +} diff --git a/public/app/core/components/Form/Input.tsx b/public/app/core/components/Form/Input.tsx new file mode 100644 index 00000000000..315acbd645c --- /dev/null +++ b/public/app/core/components/Form/Input.tsx @@ -0,0 +1,86 @@ +import React, { PureComponent } from 'react'; +import { ValidationEvents, ValidationRule } from 'app/types'; +import { validate } 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 { + validationEvents: ValidationEvents; + hideErrorMessage?: boolean; + + // Override event props and append status as argument + onBlur?: (event: React.FocusEvent, status?: InputStatus) => void; + onFocus?: (event: React.FocusEvent, status?: InputStatus) => void; + onChange?: (event: React.FormEvent, status?: InputStatus) => void; +} + +export class Input extends PureComponent { + 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.currentTarget.value, validationRules); + this.setState(prevState => { + return { + ...prevState, + error: errors ? errors[0] : null, + }; + }); + }; + }; + + populateEventPropsWithStatus = (restProps, validationEvents: ValidationEvents) => { + const inputElementProps = { ...restProps }; + Object.keys(EventsWithValidation).forEach(eventName => { + inputElementProps[eventName] = async evt => { + if (validationEvents[eventName]) { + 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 = 'gf-form-input' + (this.isInvalid ? ' invalid' : ''); + const inputElementProps = this.populateEventPropsWithStatus(restProps, validationEvents); + + return ( +
+ + {error && !hideErrorMessage && {error}} +
+ ); + } +} diff --git a/public/app/core/components/Form/Label.tsx b/public/app/core/components/Form/Label.tsx new file mode 100644 index 00000000000..385a1b325be --- /dev/null +++ b/public/app/core/components/Form/Label.tsx @@ -0,0 +1,19 @@ +import React, { PureComponent, ReactNode } from 'react'; + +interface Props { + children: ReactNode; + htmlFor?: string; + className?: string; +} + +export class Label extends PureComponent { + render() { + const { children, htmlFor, className } = this.props; + + return ( + + ); + } +} diff --git a/public/app/core/components/Form/index.ts b/public/app/core/components/Form/index.ts new file mode 100644 index 00000000000..e4c8197aaa9 --- /dev/null +++ b/public/app/core/components/Form/index.ts @@ -0,0 +1,3 @@ +export { Element } from './Element'; +export { Input } from './Input'; +export { Label } from './Label'; diff --git a/public/app/core/components/JSONFormatter/JSONFormatter.tsx b/public/app/core/components/JSONFormatter/JSONFormatter.tsx new file mode 100644 index 00000000000..73c055de94b --- /dev/null +++ b/public/app/core/components/JSONFormatter/JSONFormatter.tsx @@ -0,0 +1,51 @@ +import React, { PureComponent, createRef } from 'react'; +// import JSONFormatterJS, { JSONFormatterConfiguration } from 'json-formatter-js'; +import { JsonExplorer } from 'app/core/core'; // We have made some monkey-patching of json-formatter-js so we can't switch right now + +interface Props { + className?: string; + json: {}; + config?: any; + open?: number; + onDidRender?: (formattedJson: any) => void; +} + +export class JSONFormatter extends PureComponent { + private wrapperRef = createRef(); + + static defaultProps = { + open: 3, + config: { + animateOpen: true, + }, + }; + + componentDidMount() { + this.renderJson(); + } + + componentDidUpdate() { + this.renderJson(); + } + + renderJson = () => { + const { json, config, open, onDidRender } = this.props; + const wrapperEl = this.wrapperRef.current; + const formatter = new JsonExplorer(json, open, config); + const hasChildren: boolean = wrapperEl.hasChildNodes(); + if (hasChildren) { + wrapperEl.replaceChild(formatter.render(), wrapperEl.lastChild); + } else { + wrapperEl.appendChild(formatter.render()); + } + + if (onDidRender) { + onDidRender(formatter.json); + } + }; + + render() { + const { className } = this.props; + return
; + } +} diff --git a/public/app/core/utils/rangeutil.ts b/public/app/core/utils/rangeutil.ts index 2079aa39006..0150e80f1ed 100644 --- a/public/app/core/utils/rangeutil.ts +++ b/public/app/core/utils/rangeutil.ts @@ -159,3 +159,12 @@ export function describeTimeRange(range: RawTimeRange): string { return range.from.toString() + ' to ' + range.to.toString(); } + +export const isValidTimeSpan = (value: string) => { + if (value.indexOf('$') === 0 || value.indexOf('+$') === 0) { + return true; + } + + const info = describeTextRange(value); + return info.invalid !== true; +}; diff --git a/public/app/core/utils/validate.ts b/public/app/core/utils/validate.ts new file mode 100644 index 00000000000..34f8125833f --- /dev/null +++ b/public/app/core/utils/validate.ts @@ -0,0 +1,11 @@ +import { ValidationRule } from 'app/types'; + +export const validate = (value: string, validationRules: ValidationRule[]) => { + const errors = validationRules.reduce((acc, currRule) => { + if (!currRule.rule(value)) { + return acc.concat(currRule.errorMessage); + } + return acc; + }, []); + return errors.length > 0 ? errors : null; +}; diff --git a/public/app/features/dashboard/dashgrid/QueriesTab.tsx b/public/app/features/dashboard/dashgrid/QueriesTab.tsx index cba631fb141..016299f574e 100644 --- a/public/app/features/dashboard/dashgrid/QueriesTab.tsx +++ b/public/app/features/dashboard/dashgrid/QueriesTab.tsx @@ -1,13 +1,18 @@ -import React, { PureComponent } from 'react'; +import React, { SFC, PureComponent } from 'react'; import DataSourceOption from './DataSourceOption'; import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader'; import { EditorTabBody } from './EditorTabBody'; import { DataSourcePicker } from './DataSourcePicker'; - import { PanelModel } from '../panel_model'; import { DashboardModel } from '../dashboard_model'; import './../../panel/metrics_tab'; import config from 'app/core/config'; +import { QueryInspector } from './QueryInspector'; +import { Switch } from 'app/core/components/Switch/Switch'; +import { Input } from 'app/core/components/Form'; +import { InputStatus, EventsWithValidation } from 'app/core/components/Form/Input'; +import { isValidTimeSpan } from 'app/core/utils/rangeutil'; +import { ValidationEvents } from 'app/types'; // Services import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; @@ -29,8 +34,29 @@ interface Help { interface State { currentDatasource: DataSourceSelectItem; help: Help; + hideTimeOverride: boolean; } +interface LoadingPlaceholderProps { + text: string; +} + +const LoadingPlaceholder: SFC = ({ text }) =>

{text}

; + +const timeRangeValidationEvents: ValidationEvents = { + [EventsWithValidation.onBlur]: [ + { + rule: value => { + if (!value) { + return true; + } + return isValidTimeSpan(value); + }, + errorMessage: 'Not a valid timespan', + }, + ], +}; + export class QueriesTab extends PureComponent { element: any; component: AngularComponent; @@ -47,6 +73,7 @@ export class QueriesTab extends PureComponent { isLoading: false, helpHtml: null, }, + hideTimeOverride: false, }; } @@ -199,9 +226,50 @@ export class QueriesTab extends PureComponent { }); }; + renderQueryInspector = () => { + const { panel } = this.props; + return ; + }; + + renderHelp = () => { + const { helpHtml, isLoading } = this.state.help; + return isLoading ? : helpHtml; + }; + + emptyToNull = (value: string) => { + return value === '' ? null : value; + }; + + onOverrideTime = (evt, status: InputStatus) => { + const { value } = evt.target; + const { panel } = this.props; + const emptyToNullValue = this.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 = this.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(); + }; + render() { const { currentDatasource } = this.state; - const { helpHtml } = this.state.help; + const hideTimeOverride = this.props.panel.hideTimeOverride; + console.log('hideTimeOverride', hideTimeOverride); const { hasQueryHelp, queryOptions } = currentDatasource.meta; const hasQueryOptions = !!queryOptions; const dsInformation = { @@ -220,7 +288,7 @@ export class QueriesTab extends PureComponent { const queryInspector = { title: 'Query Inspector', - render: () =>

hello

, + render: this.renderQueryInspector, }; const dsHelp = { @@ -228,18 +296,67 @@ export class QueriesTab extends PureComponent { icon: 'fa fa-question', disabled: !hasQueryHelp, onClick: this.loadHelp, - render: () => helpHtml, + render: this.renderHelp, }; const options = { - title: 'Options', + title: '', + icon: 'fa fa-cog', disabled: !hasQueryOptions, render: this.renderOptions, }; return ( -
(this.element = element)} style={{ width: '100%' }} /> + <> +
(this.element = element)} style={{ width: '100%' }} /> + +
Time Range
+ +
+
+ + + + + Override relative time + Last + +
+ +
+ + + + Add time shift + Amount + +
+ +
+
+ + + +
+ +
+
+ ); } diff --git a/public/app/features/dashboard/dashgrid/QueryInspector.tsx b/public/app/features/dashboard/dashgrid/QueryInspector.tsx new file mode 100644 index 00000000000..ec618404651 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/QueryInspector.tsx @@ -0,0 +1,226 @@ +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 { + 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 = ( + <> + Collapse All + + ); + const expand = ( + <> + 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 ; + } + + return ( + <> +
+ {/* + + */} + + + + Copy to Clipboard + +
+ + {!isMocking && } + {isMocking && ( +
+
+