Repository: buildo/react-components (ts)

Issue:
TimePicker
## Requirements
- Name: `TimePicker`
- There should be an input form
  - it can be manually filled
    - input will be validated
    - should support 1min interval
  - can be filled selecting from a dropdown list
    - each item of the list should have 30min interval
- The value can support both 24h and 12h time formats
- There should be a placeholder on empty values
- Values can be typed in in multiple ways

## Specs
- `TimePicker` is a self closing JSX tag, doesn’t accept any child
- It will wrap a  `Dropdown` from `b-r-c`
- `value` is an Object composed by `hours` and `minutes`
- When the user start typing the `Dropdown` will be filtered
- The dropdown list will include only times between [min..max]
- The interval (aka step value) is fixed to 30 min, because smaller intervals would lead to a poor UX
- Seconds are not handled
- Choose one of the item from the list will override user typed one
- The manually typed input have to be validated (if is not part of the option list already or it can be)
  - This is a validation for 12h
    - `^([1-9]|1[0-2])[:,\.\|\/\\]?([0-5]\d)?\s*?(AM|PM)$`
  - This is a validation for 24h
    - `^([01]\d|2[0-3])[:,\.\|\/\\]?([0-5]\d)?$`
- If the input validation failed, the input will become red ( or something)
- the placeholder will be a string that not have to be validated

## API [Draft]
```javascript
<TimePicker 
  value={Object}
  min={Object}
  max={Object}
  format={Enum}
  placeholder={String}
/>
```
## Defaults
**value**: `undefined`
**min** : `undefined`
**max** : `undefined`
**format** : `24h`
**placeholder** : ``

Pull request description:
#277: TimePicker (closes #277)
Issue #277

Patch:
diff --git a/examples/components/examples.json b/examples/components/examples.json
index 30c2c946..a58d60f2 100644
--- a/examples/components/examples.json
+++ b/examples/components/examples.json
@@ -299,6 +299,18 @@
       "examples": [
         "react-components/__TAG__/examples/components/browser-detector/default.example"
       ]
+    },
+    {
+      "id": "time-picker",
+      "title": "TimePicker",
+      "desc": "TimePicker",
+      "repo": "react-components",
+      "url": "https://github.com/buildo/react-components/tree/master/src/time-picker",
+      "component": "react-components/__TAG__/src/time-picker/TimePicker.js",
+      "examples": [
+        "react-components/__TAG__/examples/components/time-picker/TimePicker24.example",
+        "react-components/__TAG__/examples/components/time-picker/TimePicker12.example"
+      ]
     }
   ]
 }
diff --git a/examples/components/time-picker/TimePicker12.example b/examples/components/time-picker/TimePicker12.example
new file mode 100644
index 00000000..5a9b8e6f
--- /dev/null
+++ b/examples/components/time-picker/TimePicker12.example
@@ -0,0 +1,16 @@
+class Example extends React.Component {
+
+  constructor() {
+    super();
+    this.state = {};
+  }
+
+  render = () => {
+    return (
+      <div>
+        <h1>12 Hour Time Picker</h1>
+        <TimePicker value={this.state.value} onChange={(value) => this.setState({ value })} timeFormat="12h" />
+      </div>
+    );
+  }
+}
diff --git a/examples/components/time-picker/TimePicker24.example b/examples/components/time-picker/TimePicker24.example
new file mode 100644
index 00000000..60e49732
--- /dev/null
+++ b/examples/components/time-picker/TimePicker24.example
@@ -0,0 +1,18 @@
+class Example extends React.Component {
+
+  constructor() {
+    super();
+    this.state = {};
+  }
+
+  render = () => {
+    const minTime = { hours : 8, minutes: 30 };
+    const maxTime = { hours : 18, minutes: 30 };
+    return (
+      <div>
+        <h1>24 Hour Time Picker</h1>
+        <TimePicker value={this.state.value} minTime={minTime} maxTime={maxTime} onChange={(value) => this.setState({ value })} timeFormat="24h" />
+      </div>
+    );
+  }
+}
diff --git a/src/index.js b/src/index.js
index 1ba80e1c..dc6b88ae 100644
--- a/src/index.js
+++ b/src/index.js
@@ -25,3 +25,4 @@ export Table, { Column } from './table';
 export Meter from './meter';
 export CollapsableSection from './collapsable-section';
 export BrowserDetector from './browser-detector';
+export TimePicker from './time-picker';
diff --git a/src/time-picker/README.md b/src/time-picker/README.md
new file mode 100644
index 00000000..742e3d9f
--- /dev/null
+++ b/src/time-picker/README.md
@@ -0,0 +1,2 @@
+`TimePicker` (component)
+=====================
diff --git a/src/time-picker/TimePicker.js b/src/time-picker/TimePicker.js
new file mode 100644
index 00000000..c32b6690
--- /dev/null
+++ b/src/time-picker/TimePicker.js
@@ -0,0 +1,228 @@
+import React from 'react';
+import { props, t, skinnable } from '../utils';
+import cx from 'classnames';
+import Dropdown from '../dropdown/Dropdown';
+import range from 'lodash/utility/range';
+import flatten from 'lodash/array/flatten';
+
+const options = [];
+
+export const H24 = '24h';
+export const H12 = '12h';
+export const inputError = {};
+const symbolRegex = /[\s,\-\+\.\|\/\\]/;
+const numberRegex = /^\d+$/;
+const separator = ':';
+const interval = 30;
+
+const pad = (num) => num <= 9 ? `0${num}` : num;
+const normalizeHoursToH24 = hour => hour + 12 !== 24 ? hour + 12 : 0;
+const lteTime = (minTime, maxTime) => (
+  maxTime.hours > minTime.hours || (maxTime.hours === minTime.hours && maxTime.minutes >= minTime.minutes)
+);
+
+const Integer = t.refinement(t.Number, n => n % 1 === 0, 'Integer');
+const Hour = t.refinement(Integer, n => n >= 0 && n <= 23, 'Hour');
+const Hour12 = t.refinement(Integer, n => n >= 1 && n <= 12, 'Hour12');
+const Minute = t.refinement(Integer, n => n >= 0 && n <= 59, 'Minute');
+const TimeFormat = t.enums.of([H12, H24], 'TimeFormat');
+const Time = t.struct({
+  hours: Hour,
+  minutes: Minute
+}, 'Time');
+
+const isValidHoursInTimeFormat = (hours, timeFormat) => timeFormat === H24 ? Hour.is(hours) : Hour12.is(hours);
+
+const Props = t.refinement(t.struct({
+  /**
+  * onChange handler it will return an object
+  */
+  onChange: t.Function,
+  /**
+   * Value provided as input. Have to be passed in 24h format.
+   * Es: { hours: 10, minutes: 30 }
+   */
+  value: t.maybe(Time),
+  /**
+   * Minimum value. Have to be passed in 24h format. Default [00:00]
+   */
+  minTime: t.maybe(Time),
+  /**
+   * Maximum value. Have to be passed in 24h format. Default [23:59]
+   */
+  maxTime: t.maybe(Time),
+  /**
+   * Field placeholder, displayed when there's no value. Default[--:--]
+   */
+  placeholder: t.maybe(t.String),
+  /**
+   * format in which options are displayed. Default [24H].
+   */
+  timeFormat: t.maybe(TimeFormat),
+  id: t.maybe(t.String),
+  className: t.maybe(t.String),
+  style: t.maybe(t.Object)
+}), ({ value, minTime, maxTime }) => (
+  lteTime(minTime, maxTime) && (!value || (lteTime(value, maxTime) && lteTime(minTime, value)))
+), 'Props');
+
+const formatTime24 = ({ hours, minutes }) => `${pad(hours)}:${pad(minutes)}`;
+const formatTime12 = ({ hours, minutes }) => {
+  if (hours === 0 || (hours > 12 && hours % 12 === 0)) {
+    return `12${separator}${pad(minutes)} am`;
+  } else if (hours === 12) {
+    return `12${separator}${pad(minutes)} pm`;
+  } else if (hours >= 13) {
+    return `${pad(hours % 12)}${separator}${pad(minutes)} pm`;
+  } else {
+    return `${pad(hours)}${separator}${pad(minutes)} am`;
+  }
+};
+const formatTime = ({ hours, minutes, timeFormat }) => (
+  timeFormat === H12 ? formatTime12({ hours, minutes }) : formatTime24({ hours, minutes })
+);
+
+export const toOption = time => ({
+  value: formatTime24(time),
+  label: formatTime(time)
+});
+
+const cleanSeparator = str => str.replace(symbolRegex, separator);
+const maybeAddTrailingZero = str => {
+  if (str.length === 4) {
+    return `${str}0`;
+  } else {
+    return str;
+  }
+};
+const maybeInsertSeparator = str => {
+  if (numberRegex.test(str)) {
+    if (str.length === 3) {
+      const position = str.length - 1;
+      const strWithColon = `${str.substr(0, position)}${separator}${str.substr(position)}`;
+      return strWithColon;
+    } else if (str.length === 4) {
+      const position = str.length - 2;
+      const strWithColon = `${str.substr(0, position)}${separator}${str.substr(position)}`;
+      return strWithColon;
+    }
+  }
+  return str;
+};
+
+export const parseInTimeFormat = (inputStr, timeFormat) => {
+  if (inputStr === '') {
+    return { originalInput: inputStr };
+  }
+  const cleanedInput = maybeAddTrailingZero(maybeInsertSeparator(cleanSeparator(inputStr)));
+  const inputArray = cleanedInput.split(separator);
+  const [_hours, _minutes] = inputArray;
+  const hours = numberRegex.test(_hours) ? parseInt(_hours, 10) : '';
+  const minutes = !_minutes || numberRegex.test(_minutes) ? parseInt(_minutes, 10) : '';
+  const validHours = isValidHoursInTimeFormat(hours, timeFormat);
+  const validMinutes = Minute.is(minutes);
+
+  if (validHours && validMinutes) {
+    return { hours, minutes, originalInput: cleanedInput };
+  } else if (validHours) {
+    return { originalInput: cleanedInput };
+  } else {
+    return inputError;
+  }
+};
+
+export const createTimeList = ({ hours, minutes }, timeFormat) => {
+  if (!isValidHoursInTimeFormat(hours, timeFormat) || !Minute.is(minutes)) {
+    const hoursList = range(0, 24);
+    const minutesList = range(0, 60, interval);
+    return flatten(hoursList.map(hours => minutesList.map(minutes => ({ hours, minutes, timeFormat }))));
+  } else {
+    return timeFormat !== H24 ? [
+      { hours, minutes, timeFormat },
+      { hours: normalizeHoursToH24(hours), minutes, timeFormat }
+    ] : [
+      { hours, minutes, timeFormat }
+    ];
+  }
+};
+
+const startsWith = (time, originalInput) => {
+  const timeStr = formatTime(time);
+  return timeStr.substr(0, originalInput.length) === originalInput;
+};
+
+export const filterTime = ({ originalInput, minTime, maxTime }) => time => {
+  return lteTime(minTime, time) && lteTime(time, maxTime) && startsWith(time, originalInput);
+};
+
+// we are not "filtering" options (options array is always empty and discarded by this function)
+// our `filterOptions` actually generates options based on current input string and props.
+// We use this as a workaround because there's not any other easy way of updating options
+// without breaking input in react-select v0.6.x
+// NOTE: can be fixed updating to v1.0.0
+export const makeFilterOptions = ({ minTime, maxTime, timeFormat }) => (_, inputStr) => {
+  const time = parseInTimeFormat(inputStr, timeFormat);
+  const timeList = time === inputError ? [] : createTimeList(time, timeFormat);
+  const filteredTimeList = timeList.filter(filterTime({
+    originalInput: time.originalInput, minTime, maxTime
+  }));
+  return filteredTimeList.map(toOption);
+};
+
+@skinnable()
+@props(Props)
+export default class TimePicker extends React.Component {
+
+  static defaultProps = {
+    placeholder: `--${separator}--`,
+    timeFormat: H24,
+    minTime: { hours: 0, minutes: 0 },
+    maxTime: { hours: 23, minutes: 59 }
+  }
+
+  _onChange = (value) => {
+    if (value) {
+      // interface with component user is always in H24
+      const time = parseInTimeFormat(value, H24);
+      this.props.onChange(time);
+    } else {
+      this.props.onChange();
+    }
+  };
+
+  getLocals() {
+    const {
+      timeFormat,
+      className,
+      value: userValue,
+      ...props
+    } = this.props;
+
+    const value = userValue ? toOption({
+      ...userValue, timeFormat
+    }) : undefined;
+
+    return {
+      ...props,
+      value,
+      options,
+      className: cx('time-picker', className),
+      onChange: this._onChange,
+      filterOptions: makeFilterOptions(this.props)
+    };
+  }
+
+  template({ id, className, style, value, placeholder, options, onChange, filterOptions }) {
+    return (
+      <Dropdown
+        {...{ id, className, style }}
+        value={value}
+        onChange={onChange}
+        options={options}
+        placeholder={placeholder}
+        filterOptions={filterOptions}
+        onBlur={() => this.forceUpdate()}
+      />
+    );
+  }
+}
diff --git a/src/time-picker/index.js b/src/time-picker/index.js
new file mode 100644
index 00000000..57f9fc91
--- /dev/null
+++ b/src/time-picker/index.js
@@ -0,0 +1,1 @@
+export default from './TimePicker';


Test patch:
diff --git a/test/tests/components/TimePicker.js b/test/tests/components/TimePicker.js
new file mode 100644
index 00000000..65d5c8b0
--- /dev/null
+++ b/test/tests/components/TimePicker.js
@@ -0,0 +1,277 @@
+import expect from 'expect';
+import { newComponent } from '../helpers';
+import TimePicker, {
+  parseInTimeFormat, H24, H12, toOption, filterTime, createTimeList, makeFilterOptions, inputError
+} from '../../../src/time-picker/TimePicker';
+
+describe('TimePicker', () => {
+
+  describe('locals', () => {
+
+    const exampleProps = {
+      id: '12345',
+      className: 'fancy-class-name',
+      placeholder: '--:--',
+      timeFormat: '24h',
+      style: { margin: 10, position: 'relative' },
+      value: { hours: 6, minutes: 30 },
+      minTime: { hours: 5, minutes: 10 },
+      maxTime: { hours: 8, minutes: 20 }
+    };
+
+    const componentTimePicker = new TimePicker(exampleProps);
+
+    it('should pass props', ()  => {
+      const locals = componentTimePicker.getLocals();
+      expect(locals.id).toBe(exampleProps.id);
+      expect(locals.style).toBe(exampleProps.style);
+      expect(locals.placeholder).toBe(exampleProps.placeholder);
+      expect(locals.minTime).toBe(exampleProps.minTime);
+      expect(locals.maxTime).toBe(exampleProps.maxTime);
+    });
+
+    it('should compute className', () => {
+      const { className } = componentTimePicker.getLocals();
+      expect(className).toInclude('time-picker');
+      expect(className).toInclude('fancy-class-name');
+    });
+
+    it('should pass value properly formatted when H24', () => {
+      const timePicker = newComponent(TimePicker, {
+        value: { hours: 15, minutes: 33 }
+      });
+      const { value } = timePicker.getLocals();
+      expect(value).toEqual({ value: '15:33', label: '15:33' });
+    });
+
+    it('should pass value properly formatted when H12', () => {
+      const timePicker = newComponent(TimePicker, {
+        value: { hours: 15, minutes: 33 },
+        timeFormat: H12
+      });
+      const { value } = timePicker.getLocals();
+      expect(value).toEqual({ value: '15:33', label: '03:33 pm' });
+    });
+
+    it('should pass an undefined value when value prop is undefined', () => {
+      const timePicker = newComponent(TimePicker, {});
+      const { value } = timePicker.getLocals();
+      expect(value).toBe(undefined);
+    });
+
+  });
+
+  describe('parseInTimeFormat', () => {
+
+    it('should parse input and return a valid 24h time', () => {
+      const input = '13:59';
+      const parsedInput = parseInTimeFormat(input, H24);
+      expect(parsedInput).toBeA(Object);
+      expect(parsedInput.originalInput).toBe(input);
+      expect(parsedInput.hours).toBe(13);
+      expect(parsedInput.minutes).toBe(59);
+    });
+
+    it('should parse input and return a valid 12h time', () => {
+      const input = '06:30';
+      const parsedInput = parseInTimeFormat(input, H12);
+      expect(parsedInput).toBeA(Object);
+      expect(parsedInput.originalInput).toBe(input);
+      expect(parsedInput.hours).toBe(6);
+      expect(parsedInput.minutes).toBe(30);
+    });
+
+    it('should parse input and return an error for invalid 24h time', () => {
+      const input = '36:30';
+      const parsedInput = parseInTimeFormat(input, H24);
+      expect(parsedInput).toBe(inputError);
+    });
+
+    it('should parse input and return an error for invalid 12h time', () => {
+      const input = '18:30';
+      const parsedInput = parseInTimeFormat(input, H12);
+      expect(parsedInput).toBe(inputError);
+    });
+
+    it('should parse empty input', () => {
+      const input = '';
+      const parsedInput = parseInTimeFormat(input, H24);
+      expect(parsedInput).toBeA(Object);
+      expect(parsedInput.originalInput).toBe(input);
+      expect(parsedInput.hours).toNotExist();
+      expect(parsedInput.minutes).toNotExist();
+    });
+
+    it('should parse input when minutes are not set', () => {
+      const input = '10:';
+      const parsedInput = parseInTimeFormat(input, H24);
+      expect(parsedInput).toBeA(Object);
+      expect(parsedInput.originalInput).toBe(input);
+      expect(parsedInput.hours).toNotExist();
+      expect(parsedInput.minutes).toNotExist();
+    });
+
+    it('should parse input without separator and return a valid time', () => {
+      const input = '1231';
+      const parsedInput = parseInTimeFormat(input, H24);
+      expect(parsedInput).toBeA(Object);
+      expect(parsedInput.hours).toBe(12);
+      expect(parsedInput.minutes).toBe(31);
+      expect(parsedInput.originalInput).toBe('12:31');
+    });
+
+    it('should parse input and return error if string is not well formed', () => {
+      const input = '1a:aa';
+      const parsedInput = parseInTimeFormat(input, H24);
+      expect(parsedInput).toBe(inputError);
+    });
+
+  });
+
+  describe('createTimeList', () => {
+
+    it('should return all the available times', () => {
+      const time = { };
+      const timeList24 = createTimeList(time, H24);
+      expect(timeList24).toBeA(Array);
+      expect(timeList24.length).toBe(48);
+      expect(timeList24[0].hours).toBe(0);
+      expect(timeList24[0].minutes).toBe(0);
+      expect(timeList24[0].timeFormat).toBe('24h');
+      expect(timeList24[26].hours).toBe(13);
+      expect(timeList24[26].minutes).toBe(0);
+      expect(timeList24[27].hours).toBe(13);
+      expect(timeList24[27].minutes).toBe(30);
+      const timeList12 = createTimeList(time, H12);
+      expect(timeList24).toNotEqual(timeList12);
+      expect(timeList12[0].timeFormat).toBe('12h');
+    });
+
+    it('should return an array of a single element for 24h', () => {
+      const time = { hours: 10, minutes: 31 };
+      const timeList = createTimeList(time, H24);
+      expect(timeList).toBeA(Array);
+      expect(timeList.length).toBe(1);
+      expect(timeList[0]).toEqual({ hours: 10, minutes: 31, timeFormat: H24 });
+
+    });
+
+    it('should return an array of 2 elements for 12h', () => {
+      const time = { hours: 10, minutes: 31 };
+      const timeList = createTimeList(time, H12);
+      expect(timeList).toBeA(Array);
+      expect(timeList.length).toBe(2);
+      expect(timeList[0]).toEqual({ hours: 10, minutes: 31, timeFormat: H12 });
+      expect(timeList[1]).toEqual({ hours: 22, minutes: 31, timeFormat: H12 });
+
+    });
+
+  });
+
+  describe('filterTime', () => {
+
+    it('should filter based on minTime/maxTime ', () => {
+      const minTime = { hours: 8, minutes: 0 };
+      const maxTime = { hours: 9, minutes: 0 };
+      const timeList = [
+        { hours: 7, minutes: 22 },
+        { hours: 8, minutes: 33 },
+        { hours: 9, minutes: 44 }
+      ];
+      const timeListFilterd = timeList.filter(filterTime({ originalInput: '', minTime, maxTime }));
+      expect(timeListFilterd).toBeA(Array);
+      expect(timeListFilterd.length).toBe(1);
+      expect(timeListFilterd[0]).toEqual({ hours: 8, minutes: 33 });
+    });
+
+    it('should filter based on original input', () => {
+      const timeList = [
+        { hours: 7, minutes: 22 },
+        { hours: 8, minutes: 33 },
+        { hours: 9, minutes: 44 }
+      ];
+      const timeListFilterd = timeList.filter(filterTime({
+        originalInput: '08', minTime: TimePicker.defaultProps.minTime, maxTime: TimePicker.defaultProps.maxTime
+      }));
+      expect(timeListFilterd).toBeA(Array);
+      expect(timeListFilterd.length).toBe(1);
+      expect(timeListFilterd[0]).toEqual({ hours: 8, minutes: 33 });
+    });
+
+  });
+
+  describe('toOption', () => {
+
+    it('should format into a valid option for 24H time', () => {
+      const time = { hours: 10, minutes: 20 };
+      const formattedTime = toOption(time, H24);
+      expect(formattedTime).toBeA(Object);
+      expect(formattedTime.label).toBe('10:20');
+    });
+
+    describe('12H time', () => {
+
+      it('should work 1', () => {
+        const time = { hours: 13, minutes: 20, timeFormat: H12 };
+        const formattedTime = toOption(time);
+        expect(formattedTime).toBeA(Object);
+        expect(formattedTime.label).toBe('01:20 pm');
+      });
+
+      it('should work 2', () => {
+        const time = { hours: 10, minutes: 20, timeFormat: H12 };
+        const formattedTime = toOption(time);
+        expect(formattedTime).toBeA(Object);
+        expect(formattedTime.label).toBe('10:20 am');
+      });
+
+      it('should work 3', () => {
+        const time = { hours: 12, minutes: 34, timeFormat: H12 };
+        const formattedTime = toOption(time);
+        expect(formattedTime).toBeA(Object);
+        expect(formattedTime.label).toBe('12:34 pm');
+      });
+
+      it('should work 4', () => {
+        const time = { hours: 0, minutes: 34, timeFormat: H12 };
+        const formattedTime = toOption(time);
+        expect(formattedTime).toBeA(Object);
+        expect(formattedTime.label).toBe('12:34 am');
+      });
+
+      it('should work 5', () => {
+        const time = { hours: 24, minutes: 34, timeFormat: H12 };
+        const formattedTime = toOption(time);
+        expect(formattedTime).toBeA(Object);
+        expect(formattedTime.label).toBe('12:34 am');
+      });
+
+    });
+
+  });
+
+  describe('makeFilterOptions', () => {
+
+    it('should compute options based on min/max for 24H time', () => {
+      const filterOptions = makeFilterOptions({ timeFormat: H24, minTime: { hours: 5, minutes: 10 }, maxTime: { hours: 7, minutes: 20 } });
+      const inputStr = '';
+      const results = filterOptions([], inputStr);
+      expect(results).toBeA(Array);
+      expect(results.length).toBe(4);
+      expect(results[0]).toEqual({ label: '05:30', value: '05:30' });
+      expect(results[3]).toEqual({ label: '07:00', value: '07:00' });
+    });
+
+    it('should compute options based on min/max for 12H time', () => {
+      const filterOptions = makeFilterOptions({ timeFormat: H12, minTime: { hours: 5, minutes: 10 }, maxTime: { hours: 7, minutes: 20 } });
+      const inputStr = '';
+      const results = filterOptions([], inputStr);
+      expect(results).toBeA(Array);
+      expect(results.length).toBe(4);
+      expect(results[0]).toEqual({ label: '05:30 am', value: '05:30' });
+      expect(results[3]).toEqual({ label: '07:00 am', value: '07:00' });
+    });
+
+  });
+
+});