diff --git a/packages/grafana-ui/src/components/Input/Input.tsx b/packages/grafana-ui/src/components/Input/Input.tsx new file mode 100644 index 00000000000..57d6a753b17 --- /dev/null +++ b/packages/grafana-ui/src/components/Input/Input.tsx @@ -0,0 +1,93 @@ +import React, { PureComponent } from 'react'; +import classNames from 'classnames'; +import { ValidationEvents, ValidationRule } from '../../types/forms'; + +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 { + 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 ( +
+ + {error && !hideErrorMessage && {error}} +
+ ); + } +} diff --git a/packages/grafana-ui/src/types/forms.ts b/packages/grafana-ui/src/types/forms.ts new file mode 100644 index 00000000000..602ee434ee5 --- /dev/null +++ b/packages/grafana-ui/src/types/forms.ts @@ -0,0 +1,26 @@ +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', +} + +export interface ValidationRule { + rule: (valueToValidate: string) => boolean; + errorMessage: string; +} + +export interface ValidationEvents { + [eventName: string]: ValidationRule[]; +} diff --git a/packages/grafana-ui/src/types/index.ts b/packages/grafana-ui/src/types/index.ts index b09d88bab4d..390f8a4db29 100644 --- a/packages/grafana-ui/src/types/index.ts +++ b/packages/grafana-ui/src/types/index.ts @@ -5,3 +5,4 @@ export * from './plugin'; export * from './datasource'; export * from './theme'; export * from './threshold'; +export * from './forms'; diff --git a/packages/grafana-ui/src/utils/index.ts b/packages/grafana-ui/src/utils/index.ts index a08b9ce1a89..00a6c20d4d1 100644 --- a/packages/grafana-ui/src/utils/index.ts +++ b/packages/grafana-ui/src/utils/index.ts @@ -7,3 +7,4 @@ export * from './thresholds'; export * from './string'; export * from './deprecationWarning'; export { getMappedValue } from './valueMappings'; +export * from './validate'; diff --git a/packages/grafana-ui/src/utils/validate.ts b/packages/grafana-ui/src/utils/validate.ts new file mode 100644 index 00000000000..20979ae33ff --- /dev/null +++ b/packages/grafana-ui/src/utils/validate.ts @@ -0,0 +1,15 @@ +import { EventsWithValidation, ValidationEvents, ValidationRule } from '../types'; + +export const validate = (value: string, validationRules: ValidationRule[]) => { + const errors = validationRules.reduce((acc, currentRule) => { + if (!currentRule.rule(value)) { + return acc.concat(currentRule.errorMessage); + } + return acc; + }, []); + return errors.length > 0 ? errors : null; +}; + +export const hasValidationEvent = (event: EventsWithValidation, validationEvents?: ValidationEvents) => { + return validationEvents && validationEvents[event]; +};