fix(scheduling): improve TimePicker UI (#1898)

Fixes #1893
This commit is contained in:
Pierre Donias
2017-01-25 15:59:03 +01:00
committed by Julien Fontanet
parent f396d61633
commit 5b4f98b03b
3 changed files with 271 additions and 225 deletions

View File

@@ -5,12 +5,15 @@ import map from 'lodash/map'
import randomPassword from 'random-password'
import React from 'react'
import round from 'lodash/round'
import SingleLineRow from 'single-line-row'
import { Container, Col } from 'grid'
import {
DropdownButton,
MenuItem
} from 'react-bootstrap-4/lib'
import Component from '../base-component'
import getEventValue from '../get-event-value'
import propTypes from '../prop-types'
import {
firstDefined,
@@ -89,68 +92,45 @@ export class Password extends Component {
// ===================================================================
@propTypes({
defaultValue: propTypes.number,
max: propTypes.number.isRequired,
min: propTypes.number.isRequired,
onChange: propTypes.func,
step: propTypes.number,
onChange: propTypes.func
value: propTypes.number
})
export class Range extends Component {
constructor (props) {
super()
this.state = {
value: props.defaultValue || props.min
componentDidMount () {
const { min, onChange, value } = this.props
if (!value) {
onChange && onChange(min)
}
}
get value () {
return this.state.value
}
set value (value) {
this.setState({
value: +value
})
}
_handleChange = event => {
const { onChange } = this.props
const { value } = event.target
if (value === this.state.value) {
return
}
this.setState({
value
}, onChange && (() => onChange(value)))
}
_onChange = value =>
this.props.onChange(getEventValue(value))
render () {
const {
props
} = this
const step = props.step || 1
const { value } = this.state
const { max, min, step, value } = this.props
return (
<div className='form-group row'>
<label className='col-sm-2 control-label'>
{value}
</label>
<div className='col-sm-10'>
return <Container>
<SingleLineRow>
<Col size={2}>
<span className='pull-right'>{value}</span>
</Col>
<Col size={10}>
<input
className='form-control'
type='range'
min={props.min}
max={props.max}
max={max}
min={min}
onChange={this._onChange}
step={step}
type='range'
value={value}
onChange={this._handleChange}
/>
</div>
</div>
)
</Col>
</SingleLineRow>
</Container>
}
}

View File

@@ -185,17 +185,23 @@ var messages = {
// --- Dates/Scheduler ---
schedulingMonth: 'Month',
schedulingEveryNMonth: 'Every N month',
schedulingEachSelectedMonth: 'Each selected month',
schedulingMonthDay: 'Day of the month',
schedulingEachSelectedMonthDay: 'Each selected day',
schedulingWeekDay: 'Day of the week',
schedulingEachSelectedWeekDay: 'Each selected day',
schedulingDay: 'Day',
schedulingEveryNDay: 'Every N day',
schedulingEachSelectedDay: 'Each selected day',
schedulingSetWeekDayMode: 'Switch to week days',
schedulingSetMonthDayMode: 'Switch to month days',
schedulingHour: 'Hour',
schedulingEveryNHour: 'Every N hour',
schedulingEachSelectedHour: 'Each selected hour',
schedulingEveryNHour: 'Every N hour',
schedulingMinute: 'Minute',
schedulingEveryNMinute: 'Every N minute',
schedulingEachSelectedMinute: 'Each selected minute',
schedulingEveryNMinute: 'Every N minute',
selectTableAllMonth: 'Every month',
selectTableAllDay: 'Every day',
selectTableAllHour: 'Every hour',
selectTableAllMinute: 'Every minute',
schedulingReset: 'Reset',
unknownSchedule: 'Unknown',
timezonePickerUseLocalTime: 'Web browser timezone',

View File

@@ -1,13 +1,17 @@
import includes from 'lodash/includes'
import classNames from 'classnames'
import Icon from 'icon'
import later from 'later'
import map from 'lodash/map'
import React from 'react'
import sortedIndex from 'lodash/sortedIndex'
import Tooltip from 'tooltip'
import { Toggle } from 'form'
import { FormattedDate, FormattedTime } from 'react-intl'
import {
Tab,
Tabs
} from 'react-bootstrap-4/lib'
forEach,
includes,
isArray,
map,
sortedIndex
} from 'lodash'
import _ from './intl'
import Component from './base-component'
@@ -19,13 +23,22 @@ import { Range } from './form'
// ===================================================================
// By default later use UTC but we use this line for futures versions.
// By default, later uses UTC but we use this line for future versions.
later.date.UTC()
// ===================================================================
const NAV_EACH_SELECTED = 1
const NAV_EVERY_N = 2
const CLICKABLE = { cursor: 'pointer' }
const PREVIEW_SLIDER_STYLE = { width: '400px' }
// ===================================================================
const UNITS = [ 'minute', 'hour', 'monthDay', 'month', 'weekDay' ]
const MINUTES_RANGE = [2, 30]
const HOURS_RANGE = [2, 12]
const MONTH_DAYS_RANGE = [2, 15]
const MONTHS_RANGE = [2, 6]
const MIN_PREVIEWS = 5
const MAX_PREVIEWS = 20
@@ -129,24 +142,20 @@ const getDayName = (dayNum) =>
cronPattern: propTypes.string.isRequired
})
export class SchedulePreview extends Component {
_handleChange = value => {
this.setState({
value
})
}
render () {
const { cronPattern } = this.props
const { value } = this.state
const cronSched = later.parse.cron(cronPattern)
const dates = later.schedule(cronSched).next(this.state.value || MIN_PREVIEWS)
const dates = later.schedule(cronSched).next(value)
return (
<div>
<div className='alert alert-info' role='alert'>
{_('cronPattern')} <strong>{cronPattern}</strong>
</div>
<div className='form-inline pb-1'>
<Range min={MIN_PREVIEWS} max={MAX_PREVIEWS} onChange={this._handleChange} />
<div className='mb-1' style={PREVIEW_SLIDER_STYLE}>
<Range min={MIN_PREVIEWS} max={MAX_PREVIEWS} onChange={this.linkState('value')} value={+value} />
</div>
<ul className='list-group'>
{map(dates, (date, id) => (
@@ -178,7 +187,7 @@ class ToggleTd extends Component {
render () {
const { props } = this
return (
<td style={{ cursor: 'pointer' }} className={props.value ? 'table-success' : ''} onClick={this._onClick}>
<td style={CLICKABLE} className={props.value ? 'table-success' : ''} onClick={this._onClick}>
{props.children}
</td>
)
@@ -188,14 +197,15 @@ class ToggleTd extends Component {
// ===================================================================
@propTypes({
labelId: propTypes.string.isRequired,
options: propTypes.array.isRequired,
optionsRenderer: propTypes.func,
optionRenderer: propTypes.func,
onChange: propTypes.func.isRequired,
value: propTypes.array.isRequired
})
class TableSelect extends Component {
static defaultProps = {
optionsRenderer: value => value
optionRenderer: value => value
}
_reset = () => {
@@ -225,190 +235,247 @@ class TableSelect extends Component {
render () {
const {
labelId,
options,
optionsRenderer,
optionRenderer,
value
} = this.props
return (
<div>
<table className='table table-bordered table-sm'>
<tbody>
{map(options, (line, i) => (
<tr key={i}>
{map(line, tdOption => {
return (
<ToggleTd
children={optionsRenderer(tdOption)}
tdId={tdOption}
key={tdOption}
onChange={this._handleChange}
value={includes(value, tdOption)}
/>
)
})}
</tr>
))}
</tbody>
</table>
<button className='btn btn-secondary pull-right' onClick={this._reset}>
{_('selectTableReset')}
</button>
</div>
)
return <div>
<table className='table table-bordered table-sm'>
<tbody>
{map(options, (line, i) => (
<tr key={i}>
{map(line, tdOption => (
<ToggleTd
children={optionRenderer(tdOption)}
tdId={tdOption}
key={tdOption}
onChange={this._handleChange}
value={includes(value, tdOption)}
/>
))}
</tr>
))}
</tbody>
</table>
<button className='btn btn-secondary pull-right' onClick={this._reset}>
{_(`selectTableAll${labelId}`)} {value && !value.length && <Icon icon='success' />}
</button>
</div>
}
}
// ===================================================================
// "2,7" => [2,7] "*/2" => 2 "*" => []
const cronToValue = (cron, range) => {
if (cron.indexOf('/') === 1) {
return +cron.split('/')[1]
}
if (cron === '*') {
return []
}
return map(cron.split(','), Number)
}
// [2,7] => "2,7" 2 => "*/2" [] => "*"
const valueToCron = value => {
if (!isArray(value)) {
return `*/${value}`
}
if (!value.length) {
return '*'
}
return value.join(',')
}
@propTypes({
optionsRenderer: propTypes.func,
headerAddon: propTypes.node,
optionRenderer: propTypes.func,
onChange: propTypes.func.isRequired,
range: propTypes.array,
labelId: propTypes.string.isRequired,
value: propTypes.any.isRequired
})
class TimePicker extends Component {
constructor () {
super()
this.state = {
activeKey: NAV_EACH_SELECTED,
tableValue: []
}
}
_update = cron => {
const { tableValue, rangeValue } = this.state
_update (props) {
const { value } = props
const newValue = cronToValue(cron)
const periodic = !isArray(newValue)
if (value.indexOf('/') === 1) {
this.setState({
activeKey: NAV_EVERY_N
}, () => { this.refs.range.value = value.split('/')[1] })
} else {
this.setState({
activeKey: NAV_EACH_SELECTED,
tableValue: value === '*'
? []
: map(value.split(','), Number)
})
}
}
componentWillMount () {
this._update(this.props)
}
componentWillReceiveProps (props) {
this._update(props)
}
_selectTab = activeKey => {
this.setState({
activeKey
}, () => {
const { activeKey, tableValue } = this.state
const { onChange } = this.props
const { refs } = this
if (activeKey === NAV_EACH_SELECTED) {
onChange(tableValue)
} else {
onChange(refs.range.value)
}
periodic,
tableValue: periodic ? tableValue : newValue,
rangeValue: periodic ? newValue : rangeValue
})
}
_handleTableValue = tableValue => {
this.setState({
tableValue
}, () => this.props.onChange(tableValue))
componentWillReceiveProps (props) {
if (props.value !== this.props.value) {
this._update(props.value)
}
}
componentDidMount () {
this._update(this.props.value)
}
_onChange = value => {
this.props.onChange(valueToCron(value))
}
_tableTab = () => this._onChange(this.state.tableValue || [])
_periodicTab = () => this._onChange(this.state.rangeValue || this.props.range[0])
render () {
const {
onChange,
headerAddon,
labelId,
options,
optionsRenderer,
range,
labelId
optionRenderer,
range
} = this.props
const { tableValue } = this.state
const tableSelect = (
<TableSelect
onChange={this._handleTableValue}
options={options}
optionsRenderer={optionsRenderer}
value={tableValue}
/>
const {
periodic,
tableValue,
rangeValue
} = this.state
return <Card>
<CardHeader>
{_(`scheduling${labelId}`)}
{headerAddon}
</CardHeader>
<CardBlock>
{range && <ul className='nav nav-tabs mb-1'>
<li className='nav-item'>
<a onClick={this._tableTab} className={classNames('nav-link', !periodic && 'active')} style={CLICKABLE}>
{_(`schedulingEachSelected${labelId}`)}
</a>
</li>
<li className='nav-item'>
<a onClick={this._periodicTab} className={classNames('nav-link', periodic && 'active')} style={CLICKABLE}>
{_(`schedulingEveryN${labelId}`)}
</a>
</li>
</ul>}
{periodic
? <Range ref='range' min={range[0]} max={range[1]} onChange={this._onChange} value={rangeValue} />
: <TableSelect
labelId={labelId}
onChange={this._onChange}
options={options}
optionRenderer={optionRenderer}
value={tableValue || []}
/>
}
</CardBlock>
</Card>
}
}
const isWeekDayMode = ({ monthDayPattern, weekDayPattern }) => {
if (monthDayPattern === '*' && weekDayPattern === '*') {
return
}
return weekDayPattern !== '*'
}
@propTypes({
monthDayPattern: propTypes.string.isRequired,
weekDayPattern: propTypes.string.isRequired
})
class DayPicker extends Component {
state = {
weekDayMode: isWeekDayMode(this.props)
}
componentWillReceiveProps (props) {
const weekDayMode = isWeekDayMode(props)
if (weekDayMode !== undefined) {
this.setState({ weekDayMode })
}
}
_setWeekDayMode = weekDayMode => {
this.props.onChange([ '*', '*' ])
this.setState({ weekDayMode })
}
_onChange = cron => {
const isMonthDayPattern = !this.state.weekDayMode || includes(cron, '/')
this.props.onChange([
isMonthDayPattern ? cron : '*',
isMonthDayPattern ? '*' : cron
])
}
render () {
const { monthDayPattern, weekDayPattern } = this.props
const { weekDayMode } = this.state
const dayModeToggle = (
<Tooltip content={_(weekDayMode ? 'schedulingSetMonthDayMode' : 'schedulingSetWeekDayMode')}>
<span className='pull-right'><Toggle onChange={this._setWeekDayMode} iconSize={1} value={weekDayMode} /></span>
</Tooltip>
)
return (
<Card>
<CardHeader>
{_(`scheduling${labelId}`)}
</CardHeader>
<CardBlock>
{range
? (
<Tabs bsStyle='tabs' activeKey={this.state.activeKey} onSelect={this._selectTab}>
<Tab tabClassName='nav-item' eventKey={NAV_EACH_SELECTED} title={_(`schedulingEachSelected${labelId}`)}>
{tableSelect}
</Tab>
<Tab tabClassName='nav-item' eventKey={NAV_EVERY_N} title={_(`schedulingEveryN${labelId}`)}>
<Range ref='range' min={range[0]} max={range[1]} onChange={onChange} />
</Tab>
</Tabs>
) : tableSelect
}
</CardBlock>
</Card>
)
return <TimePicker
headerAddon={dayModeToggle}
key={weekDayMode ? 'week' : 'month'}
labelId='Day'
optionRenderer={weekDayMode ? getDayName : undefined}
options={weekDayMode ? WEEK_DAYS : DAYS}
onChange={this._onChange}
range={MONTH_DAYS_RANGE}
setWeekDayMode={this._setWeekDayMode}
value={weekDayMode ? weekDayPattern : monthDayPattern}
/>
}
}
// ===================================================================
const HOURS_RANGE = [2, 12]
const MINUTES_RANGE = [2, 30]
@propTypes({
cronPattern: propTypes.string.isRequired,
onChange: propTypes.func,
timezone: propTypes.string
})
export default class Scheduler extends Component {
_update (type, value) {
if (Array.isArray(value)) {
if (!value.length) {
value = '*'
} else {
value = value.join(',')
}
} else {
value = `*/${value}`
constructor (props) {
super(props)
this._onCronChange = newCrons => {
const cronPattern = this.props.cronPattern.split(' ')
forEach(newCrons, (cron, unit) => {
cronPattern[PICKTIME_TO_ID[unit]] = cron
})
this.props.onChange({
cronPattern: cronPattern.join(' '),
timezone: this.props.timezone
})
}
const { props } = this
const cronPattern = props.cronPattern.split(' ')
cronPattern[PICKTIME_TO_ID[type]] = value
this.props.onChange({
cronPattern: cronPattern.join(' '),
timezone: props.timezone
forEach(UNITS, unit => {
this[`_${unit}Change`] = cron => this._onCronChange({ [unit]: cron })
})
this._dayChange = ([ monthDay, weekDay ]) => this._onCronChange({ monthDay, weekDay })
}
_onHourChange = value => this._update('hour', value)
_onMinuteChange = value => this._update('minute', value)
_onMonthChange = value => this._update('month', value)
_onMonthDayChange = value => this._update('monthDay', value)
_onWeekDayChange = value => this._update('weekDay', value)
_onTimezoneChange = timezone => {
const { props } = this
props.onChange({
cronPattern: props.cronPattern,
this.props.onChange({
cronPattern: this.props.cronPattern,
timezone
})
}
@@ -426,23 +493,16 @@ export default class Scheduler extends Component {
<Col mediumSize={6}>
<TimePicker
labelId='Month'
optionsRenderer={getMonthName}
optionRenderer={getMonthName}
options={MONTHS}
onChange={this._onMonthChange}
onChange={this._monthChange}
range={MONTHS_RANGE}
value={cronPatternArr[PICKTIME_TO_ID['month']]}
/>
<TimePicker
labelId='MonthDay'
options={DAYS}
onChange={this._onMonthDayChange}
value={cronPatternArr[PICKTIME_TO_ID['monthDay']]}
/>
<TimePicker
labelId='WeekDay'
optionsRenderer={getDayName}
options={WEEK_DAYS}
onChange={this._onWeekDayChange}
value={cronPatternArr[PICKTIME_TO_ID['weekDay']]}
<DayPicker
onChange={this._dayChange}
monthDayPattern={cronPatternArr[PICKTIME_TO_ID['monthDay']]}
weekDayPattern={cronPatternArr[PICKTIME_TO_ID['weekDay']]}
/>
</Col>
<Col mediumSize={6}>
@@ -450,14 +510,14 @@ export default class Scheduler extends Component {
labelId='Hour'
options={HOURS}
range={HOURS_RANGE}
onChange={this._onHourChange}
onChange={this._hourChange}
value={cronPatternArr[PICKTIME_TO_ID['hour']]}
/>
<TimePicker
labelId='Minute'
options={MINS}
range={MINUTES_RANGE}
onChange={this._onMinuteChange}
onChange={this._minuteChange}
value={cronPatternArr[PICKTIME_TO_ID['minute']]}
/>
</Col>