import * as moment from 'moment'; import * as React from 'react'; import { FormEventHandler } from 'react'; import { DropdownButton, FormControl, FormGroup, HelpBlock, InputGroup, MenuItem, Sizes, } from 'react-bootstrap'; import { Icon } from 'react-fa'; import { SyntheticEvent } from 'react'; import { CommandButton } from '../CommandButton/CommandButton'; export type TimeSpanInputUnit = moment.unitOfTime.Base; export interface TimeSpanInputProps { bsClass?: string; bsSize?: Sizes; controlId?: string; duration?: moment.Duration; units?: TimeSpanInputUnit[]; initialUnit?: TimeSpanInputUnit; initialDuration?: moment.Duration; precision?: number; reparseButton?: boolean; onMomentDurationChanged?: ( duration: moment.Duration | undefined, unit: TimeSpanInputUnit, ) => void; onMomentUnitChanged?: ( unit: TimeSpanInputUnit, duration: moment.Duration | undefined, ) => void; } export interface TimeSpanInputComponentProps extends React.HTMLProps, TimeSpanInputProps {} export interface TimeSpanInputState { unit: TimeSpanInputUnit; input: string; duration?: moment.Duration; error?: string; } export class TimeSpanInput extends React.Component< TimeSpanInputComponentProps, TimeSpanInputState > { public static DefaultPrecision = 2; public static InvalidFormatError = 'Invalid Duration Format'; public static InvalidAmountError = 'Invalid Duration Amount'; public static InvalidUnitError = 'Invalid Unit'; static defaultProps: Partial = { units: ['seconds', 'minutes', 'hours', 'days'], initialUnit: 'days', precision: TimeSpanInput.DefaultPrecision, }; public static formatDuration( duration: moment.Duration | undefined, unit: TimeSpanInputUnit, precision = TimeSpanInput.DefaultPrecision, ) { if (duration == null) { return ''; } const value = duration.as(unit); const unitName = value === 1 ? unit.replace(/s$/, '') : unit; return `${Number(value.toFixed(precision))} ${unitName}`; } public static parseDuration( state: TimeSpanInputState, props: TimeSpanInputProps, ): TimeSpanInputState { if (state.input == null) { return state; } if (state.input === '') { return Object.assign({}, state, { duration: undefined, }); } const match: RegExpMatchArray = state.input.match(/^\s*([\d\.]+)(\s+(\w+))?\s*$/) || []; const [_1, value, _2, unitInput] = match; if (Number.isNumeric(value)) { // only process if it's numeric and valid const numeric = Number(value); if (isNaN(numeric) || numeric < 0) { return Object.assign({}, state, { duration: undefined, error: TimeSpanInput.InvalidAmountError, }); } if (String.isNullOrEmpty(unitInput)) { // single arg // just assume we're using the state units const duration = moment.duration(numeric, state.unit); const input = TimeSpanInput.formatDuration( duration, state.unit, props.precision, ); return Object.assign({}, state, { input, duration, error: undefined, }); } else { // two args let unit = moment.normalizeUnits( unitInput as TimeSpanInputUnit, ) as TimeSpanInputUnit; if (!String.isNullOrEmpty(unit)) { if (/s$/.test(unit) === false) { // pluralize the unit if it isn't already unit = `${unit}s` as TimeSpanInputUnit; } if (props.units!.indexOf(unit) >= 0) { const duration = moment.duration(numeric, unit); const input = TimeSpanInput.formatDuration( duration, unit, props.precision, ); return Object.assign({}, state, { unit, input, duration, error: undefined, }); } } return Object.assign({}, state, { duration: undefined, error: TimeSpanInput.InvalidUnitError, }); } } return Object.assign({}, state, { duration: undefined, error: TimeSpanInput.InvalidFormatError, }); } constructor(props: any) { super(props); this.handleReparse = this.handleReparse.bind(this); this.handleIncreaseDuration = this.handleIncreaseDuration.bind(this); this.handleDecreaseDuration = this.handleDecreaseDuration.bind(this); this.state = { unit: props.initialUnit!, input: TimeSpanInput.formatDuration( props.initialDuration, props.initialUnit!, ), duration: props.initialDuration, }; } componentDidUpdate( prevProps: Readonly, prevState: Readonly, prevContext: any, ) { if ( this.props.onMomentDurationChanged != null && prevState != null && this.state != null ) { if (prevState.duration != null) { if ( this.state.duration == null || prevState.duration !== this.state.duration ) { this.props.onMomentDurationChanged( this.state.duration, this.state.unit, ); } } else if (this.state.duration != null) { if ( prevState.duration == null || prevState.duration !== this.state.duration ) { this.props.onMomentDurationChanged( this.state.duration, this.state.unit, ); } } } if ( this.props.onMomentUnitChanged != null && prevState != null && this.state != null ) { if (prevState.unit != null) { if (this.state.unit == null || prevState.unit !== this.state.unit) { this.props.onMomentUnitChanged(this.state.unit, this.state.duration); } } else if (this.state.unit != null) { if (prevState.unit == null || prevState.unit !== this.state.unit) { this.props.onMomentUnitChanged(this.state.unit, this.state.duration); } } } if ( this.props.duration != null && prevState.duration !== this.props.duration ) { this.setState((ps, p) => { return Object.assign({}, ps, { input: this.props.duration == null ? ps.input : TimeSpanInput.formatDuration( this.props.duration, ps.unit, this.props.precision, ), duration: this.props.duration, }); }); } } render() { const { className, props, rest } = this.restProps(x => { const { bsClass, bsSize, controlId, duration, units, initialUnit, initialDuration, precision, onMomentDurationChanged, onMomentUnitChanged, reparseButton, } = x; return { bsClass, bsSize, controlId, duration, units, initialUnit, initialDuration, precision, onMomentDurationChanged, onMomentUnitChanged, reparseButton, }; }); return ( {this.renderFormInput()} {this.renderHelp()} ); } protected renderFormInput() { return ( {this.renderTextInput()} {this.renderButtons()} ); } protected renderTextInput() { if (React.Children.count(this.props.children) === 1) { const formControl = React.Children.only(this.props.children); const props = Object.assign( { value: this.state.input, onChange: this.handleInput.bind(this), onBlur: this.handleParse.bind(this), }, formControl.props, ); return React.cloneElement(formControl, props); } return ( >(this)} onBlur={this.handleParse.bind(this)} /> ); } protected renderButtons() { return ( {this.wxr.renderConditional(this.props.reparseButton, () => ( ))} {this.renderDropdown()} ); } protected renderDropdown() { return ( (this)} > {this.props.units!.map(x => ( {x} ))} ); } protected renderHelp() { return this.state.error && {this.state.error}; } protected handleChangeUnit(unit: TimeSpanInputUnit) { this.setState((prevState, props) => { const duration = this.cloneDurationFromState(prevState); const input = TimeSpanInput.formatDuration( duration, unit, this.props.precision, ); return Object.assign({ error: undefined }, prevState, { unit, input, duration, }); }); } private handleReparse() { // this is a fake handler to simulate a request to parse, but it really only forces a blur } protected handleIncreaseDuration() { this.setState((prevState, props) => { const duration = this.adjustDurationFromState(prevState, 1); const input = TimeSpanInput.formatDuration( duration, prevState.unit, this.props.precision, ); return Object.assign({}, prevState, { input, duration, error: duration.valueOf() < 0 ? TimeSpanInput.InvalidAmountError : undefined, }); }); } protected handleDecreaseDuration() { this.setState((prevState, props) => { const duration = this.adjustDurationFromState(prevState, -1); const input = TimeSpanInput.formatDuration( duration, prevState.unit, this.props.precision, ); return Object.assign({}, prevState, { input, duration, error: duration.valueOf() < 0 ? TimeSpanInput.InvalidAmountError : undefined, }); }); } protected handleInput(e: SyntheticEvent) { const input = e.currentTarget.value; this.setState((prevState, props) => { return Object.assign({}, prevState, { input, }); }); } protected handleParse() { this.setState((prevState, props) => { return Object.assign( {}, prevState, TimeSpanInput.parseDuration(prevState, this.props), ); }); } protected cloneDurationFromState(state: TimeSpanInputState) { if (state.duration == null) { return moment.duration(0, state.unit); } return state.duration.clone(); } protected adjustDurationFromState(state: TimeSpanInputState, amount: number) { return this.cloneDurationFromState(state).add(amount, state.unit); } }