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..a261203b3f3 --- /dev/null +++ b/public/app/core/components/Form/Input.tsx @@ -0,0 +1,96 @@ +import React, { PureComponent } from 'react'; +import { ValidationRule } from 'app/types'; + +export enum InputStatus { + Default = 'default', + Loading = 'loading', + Invalid = 'invalid', + Valid = 'valid', +} + +export enum InputTypes { + Text = 'text', + Number = 'number', + Password = 'password', + Email = 'email', +} + +interface Props { + status?: InputStatus; + validationRules: ValidationRule[]; + hideErrorMessage?: boolean; + onBlurWithStatus?: (evt, status: InputStatus) => void; + emptyToNull?: boolean; +} + +const validator = (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; +}; + +export class Input extends PureComponent> { + state = { + error: null, + }; + + get status() { + const { error } = this.state; + if (error) { + return InputStatus.Invalid; + } + return InputStatus.Valid; + } + + onBlurWithValidation = evt => { + const { validationRules, onBlurWithStatus, onBlur } = this.props; + + let errors = null; + if (validationRules) { + errors = validator(evt.currentTarget.value, validationRules); + this.setState(prevState => { + return { + ...prevState, + error: errors ? errors[0] : null, + }; + }); + } + + if (onBlurWithStatus) { + onBlurWithStatus(evt, errors ? InputStatus.Invalid : InputStatus.Valid); + } + + if (onBlur) { + onBlur(evt); + } + }; + + render() { + const { + status, + validationRules, + onBlurWithStatus, + onBlur, + className, + hideErrorMessage, + emptyToNull, + ...restProps + } = this.props; + + const { error } = this.state; + + let inputClassName = 'gf-form-input'; + inputClassName = this.status === InputStatus.Invalid ? inputClassName + ' invalid' : inputClassName; + + 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/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/features/dashboard/dashgrid/QueriesTab.tsx b/public/app/features/dashboard/dashgrid/QueriesTab.tsx index 9a679832048..3c40c8a3568 100644 --- a/public/app/features/dashboard/dashgrid/QueriesTab.tsx +++ b/public/app/features/dashboard/dashgrid/QueriesTab.tsx @@ -8,6 +8,11 @@ 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 } from 'app/core/components/Form/Input'; +import { isValidTimeSpan } from 'app/core/utils/rangeutil'; +import { ValidationRule } from 'app/types'; // Services import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; @@ -29,6 +34,7 @@ interface Help { interface State { currentDatasource: DataSourceSelectItem; help: Help; + hideTimeOverride: boolean; } interface LoadingPlaceholderProps { @@ -36,6 +42,17 @@ interface LoadingPlaceholderProps { } const LoadingPlaceholder: SFC = ({ text }) =>

{text}

; +const validationRules: ValidationRule[] = [ + { + rule: value => { + if (!value) { + return true; + } + return isValidTimeSpan(value); + }, + errorMessage: 'Not a valid timespan', + }, +]; export class QueriesTab extends PureComponent { element: any; @@ -53,6 +70,7 @@ export class QueriesTab extends PureComponent { isLoading: false, helpHtml: null, }, + hideTimeOverride: false, }; } @@ -215,9 +233,40 @@ export class QueriesTab extends PureComponent { 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 hideTimeOverride = this.props.panel.hideTimeOverride; + console.log('hideTimeOverride', hideTimeOverride); const { hasQueryHelp, queryOptions } = currentDatasource.meta; const hasQueryOptions = !!queryOptions; const dsInformation = { @@ -256,7 +305,55 @@ export class QueriesTab extends PureComponent { 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/types/form.ts b/public/app/types/form.ts new file mode 100644 index 00000000000..180b41d8730 --- /dev/null +++ b/public/app/types/form.ts @@ -0,0 +1,4 @@ +export interface ValidationRule { + rule: (value: string) => boolean; + errorMessage: string; +} diff --git a/public/app/types/index.ts b/public/app/types/index.ts index bf19e52468b..e60dcb0993d 100644 --- a/public/app/types/index.ts +++ b/public/app/types/index.ts @@ -30,7 +30,7 @@ import { AppNotificationTimeout, } from './appNotifications'; import { DashboardSearchHit } from './search'; - +import { ValidationRule } from './form'; export { Team, TeamsState, @@ -89,6 +89,7 @@ export { AppNotificationTimeout, DashboardSearchHit, UserState, + ValidationRule, }; export interface StoreState { diff --git a/public/sass/utils/_validation.scss b/public/sass/utils/_validation.scss index 86b7c008bfd..657d1f0414b 100644 --- a/public/sass/utils/_validation.scss +++ b/public/sass/utils/_validation.scss @@ -1,7 +1,11 @@ -input[type="text"].ng-dirty.ng-invalid { +input[type='text'].ng-dirty.ng-invalid { } input.validation-error, input.ng-dirty.ng-invalid { box-shadow: inset 0 0px 5px $red; } + +input.invalid { + box-shadow: inset 0 0px 5px $red; +}