Просмотр исходного кода

Refactor `Schedule` component

The class became too large so it is now split into two separate classes.
The new `ScheduleItem` class holds one schedule row, while the
`Schedule` class holds the entire row collection.
Sergiu Miclea 8 лет назад
Родитель
Сommit
96fc41759b

+ 0 - 0
src/components/organisms/Schedule/images/delete-hover.svg → src/components/molecules/ScheduleItem/images/delete-hover.svg


+ 0 - 0
src/components/organisms/Schedule/images/delete.svg → src/components/molecules/ScheduleItem/images/delete.svg


+ 20 - 0
src/components/molecules/ScheduleItem/images/save-hover.svg

@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <!-- Generator: Sketch 48.2 (47327) - http://www.bohemiancoding.com/sketch -->
+    <title>Delete Copy</title>
+    <desc>Created with Sketch.</desc>
+    <defs>
+        <path d="M1.96153846,0 C0.884615385,0 0,0.884615385 0,1.96153846 L0,14.0384615 C0,15.1153846 0.884615385,16 1.96153846,16 L14.0384615,16 C15.1153846,16 16,15.1153846 16,14.0384615 L16,3.74519262 L12.2548074,0 L1.96153846,0 Z" id="path-1"></path>
+    </defs>
+    <g id="Elements/Schedule-Line" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" transform="translate(-968.000000, -24.000000)">
+        <g id="Icon/Save/Fill" transform="translate(968.000000, 24.000000)">
+            <g id="Path">
+                <use fill="#0044CA" fill-rule="evenodd" xlink:href="#path-1"></use>
+                <path stroke="#0044CA" stroke-width="1.5" d="M15.25,4.05585279 L11.9441472,0.75 L1.96153846,0.75 C1.29882895,0.75 0.75,1.29882895 0.75,1.96153846 L0.75,14.0384615 C0.75,14.7011711 1.29882895,15.25 1.96153846,15.25 L14.0384615,15.25 C14.7011711,15.25 15.25,14.7011711 15.25,14.0384615 L15.25,4.05585279 Z"></path>
+            </g>
+            <path d="M3,6 L3,2 C3,1.44771525 3.44771525,1 4,1 L11,1 C11.5522847,1 12,1.44771525 12,2 L12,6 L3,6 Z" id="Rectangle-8-Copy-2" fill="#FFFFFF" fill-rule="evenodd" transform="translate(7.500000, 3.500000) scale(1, -1) translate(-7.500000, -3.500000) "></path>
+            <rect id="Rectangle-12" fill="#0044CA" fill-rule="evenodd" x="8" y="0" width="2" height="4"></rect>
+            <path d="M4,15 L4,11 C4,10.4477153 4.44771525,10 5,10 L11,10 C11.5522847,10 12,10.4477153 12,11 L12,15 L4,15 Z" id="Rectangle-8-Copy-3" fill="#FFFFFF" fill-rule="evenodd"></path>
+        </g>
+    </g>
+</svg>

+ 15 - 0
src/components/molecules/ScheduleItem/images/save.svg

@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <!-- Generator: Sketch 48.2 (47327) - http://www.bohemiancoding.com/sketch -->
+    <title>Delete Copy</title>
+    <desc>Created with Sketch.</desc>
+    <defs></defs>
+    <g id="Elements/Schedule-Line" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" transform="translate(-968.000000, -24.000000)">
+        <g id="Icon/Save/Outline" transform="translate(968.000000, 24.000000)">
+            <path d="M15.25,4.05585279 L11.9441472,0.75 L1.96153846,0.75 C1.29882895,0.75 0.75,1.29882895 0.75,1.96153846 L0.75,14.0384615 C0.75,14.7011711 1.29882895,15.25 1.96153846,15.25 L14.0384615,15.25 C14.7011711,15.25 15.25,14.7011711 15.25,14.0384615 L15.25,4.05585279 Z" id="Path" stroke="#0044CA" stroke-width="1.5"></path>
+            <rect id="Rectangle-8" stroke="#0044CA" stroke-width="1.5" stroke-linejoin="round" x="4.5" y="10.5" width="7" height="4.5"></rect>
+            <rect id="Rectangle-8-Copy-2" stroke="#0044CA" stroke-width="1.5" stroke-linejoin="round" transform="translate(7.500000, 3.000000) scale(1, -1) translate(-7.500000, -3.000000) " x="3.5" y="0.5" width="8" height="5"></rect>
+            <rect id="Rectangle-12" fill="#0044CA" fill-rule="evenodd" x="8" y="0" width="2" height="4"></rect>
+        </g>
+    </g>
+</svg>

+ 383 - 0
src/components/molecules/ScheduleItem/index.jsx

@@ -0,0 +1,383 @@
+/*
+Copyright (C) 2017  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+// @flow
+
+import React from 'react'
+import styled, { css } from 'styled-components'
+import moment from 'moment'
+
+import Switch from '../../atoms/Switch'
+import Dropdown from '../../molecules/Dropdown'
+import DatetimePicker from '../../molecules/DatetimePicker'
+import Button from '../../atoms/Button'
+import type { Schedule } from '../../../types/Schedule'
+
+import { executionOptions } from '../../../config'
+import Palette from '../../styleUtils/Palette'
+import StyleProps from '../../styleUtils/StyleProps'
+import DateUtils from '../../../utils/DateUtils'
+import NotificationActions from '../../../actions/NotificationActions'
+import deleteImage from './images/delete.svg'
+import deleteHoverImage from './images/delete-hover.svg'
+import saveImage from './images/save.svg'
+import saveHoverImage from './images/save-hover.svg'
+
+const Wrapper = styled.div`
+  display: flex;
+  border-top: 1px solid ${Palette.grayscale[1]};
+  padding: 16px 0;
+  position: relative;
+  &:last-child {
+    border-bottom: 1px solid ${Palette.grayscale[1]};
+  }
+`
+const Data = styled.div`
+  width: ${props => props.width};
+`
+const Label = styled.div`
+  background: ${Palette.grayscale[7]};
+  height: 100%;
+  font-size: 12px;
+  margin-right: 8px;
+  border-radius: ${StyleProps.borderRadius};
+  padding: 0 8px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  text-align: center;
+  line-height: 35px;
+  margin-bottom: -8px;
+`
+const DropdownStyled = styled(Dropdown)`
+  font-size: 12px;
+`
+const ItemButton = props => css`
+  width: 16px;
+  height: 16px;
+  position: absolute;
+  cursor: pointer;
+  top: 24px;
+  ${props.hidden ? 'display: none;' : ''}
+`
+const DeleteButton = styled.div`
+  ${props => ItemButton(props)}
+  background: url('${deleteImage}') center no-repeat;
+  right: -32px;
+  
+  &:hover {
+    background: url('${deleteHoverImage}') center no-repeat;
+  }
+`
+const SaveButton = styled.div`
+  ${props => ItemButton(props)}
+  background: url('${saveImage}') center no-repeat;
+  right: -64px;
+  &:hover {
+    background: url('${saveHoverImage}') center no-repeat;
+  }
+`
+const padNumber = number => {
+  if (number < 10) return `0${number}`
+  return number.toString()
+}
+
+type Field = { label: string, value?: any }
+type TimezoneValue = 'utc' | 'local'
+type Props = {
+  colWidths: string[],
+  item: Schedule,
+  onChange: (schedule: Schedule, forced?: boolean) => void,
+  onSaveSchedule: () => void,
+  onShowOptionsClick: () => void,
+  onDeleteClick: () => void,
+  unsavedSchedules: Schedule[],
+  timezone: TimezoneValue,
+}
+class ScheduleItem extends React.Component<Props> {
+  getFieldValue(items: Field[], fieldName: string, zeroBasedIndex?: boolean, defaultSelectedIndex?: number) {
+    if (this.props.item.schedule === null || this.props.item.schedule === undefined) {
+      return defaultSelectedIndex !== undefined ? items[defaultSelectedIndex] : items[0]
+    }
+
+    if (this.props.item.schedule[fieldName] === null || this.props.item.schedule[fieldName] === undefined) {
+      return items[0]
+    }
+
+    if (zeroBasedIndex) {
+      let value = this.props.item.schedule[fieldName]
+
+      if (fieldName === 'hour') {
+        if (this.props.timezone === 'local') {
+          value = DateUtils.getLocalHour(value)
+        }
+      }
+
+      return items[value + 1]
+    }
+
+    return items[this.props.item.schedule[fieldName]]
+  }
+
+  handleMonthChange(item: Field) {
+    let month = item.value || 1
+    let maxNumDays = moment().month(month - 1).daysInMonth()
+    let change: Schedule = { schedule: { month: item.value } }
+    if (this.props.item.schedule && this.props.item.schedule.dom && this.props.item.schedule.dom > maxNumDays) {
+      // $FlowIssue
+      change.schedule.dom = maxNumDays
+    }
+
+    this.props.onChange(change)
+  }
+
+  handleExpirationDateChange(date: Date) {
+    let newDate = moment(date)
+    if (newDate.diff(new Date(), 'minutes') < 60) {
+      NotificationActions.notify('Please select a further expiration date.', 'error')
+      return
+    }
+
+    this.props.onChange({ expiration_date: newDate.toDate() })
+  }
+
+  handleHourChange(hour: number) {
+    if (this.props.timezone === 'local' && hour !== null && hour !== undefined) {
+      hour = DateUtils.getUtcHour(hour)
+    }
+
+    this.props.onChange({ schedule: { hour } })
+  }
+
+  shouldUseBold(fieldName: string, isRootField?: boolean) {
+    const unsavedSchedule = this.props.unsavedSchedules.find(s => s.id === this.props.item.id)
+    if (!unsavedSchedule) {
+      return false
+    }
+    let data = isRootField ? unsavedSchedule : unsavedSchedule.schedule
+    if (data && data[fieldName] !== undefined && data[fieldName] !== null) {
+      return true
+    }
+    return false
+  }
+
+  areExecutionOptionsChanged() {
+    let isChanged = false
+    executionOptions.forEach(o => {
+      let scheduleValue = this.props.item[o.name]
+      let optionValue = o.value !== undefined ? o.value : false
+      if (scheduleValue !== undefined && scheduleValue !== null && scheduleValue !== optionValue) {
+        isChanged = true
+      }
+    })
+    return isChanged
+  }
+
+  renderLabel(value: Field) {
+    return <Label>{value.label}</Label>
+  }
+
+  renderMonthValue() {
+    let items = [{ label: 'Any', value: null }]
+    let months = moment.months()
+    months.forEach((label, value) => {
+      items.push({ label, value: value + 1 })
+    })
+
+    if (this.props.item.enabled) {
+      return this.renderLabel(this.getFieldValue(items, 'month'))
+    }
+
+    return (
+      <DropdownStyled
+        centered
+        width={136}
+        items={items}
+        useBold={this.shouldUseBold('month')}
+        selectedItem={this.getFieldValue(items, 'month')}
+        onChange={item => { this.handleMonthChange(item) }}
+      />
+    )
+  }
+
+  renderDayOfMonthValue() {
+    let month = this.props.item.schedule && this.props.item.schedule.month ? this.props.item.schedule.month : 1
+    let items = [{ label: 'Any', value: null }]
+    for (let i = 1; i <= moment().month(month - 1).daysInMonth(); i += 1) {
+      items.push({ label: i.toString(), value: i })
+    }
+
+    if (this.props.item.enabled) {
+      return this.renderLabel(this.getFieldValue(items, 'dom'))
+    }
+
+    return (
+      <DropdownStyled
+        centered
+        width={72}
+        items={items}
+        useBold={this.shouldUseBold('dom')}
+        selectedItem={this.getFieldValue(items, 'dom')}
+        onChange={item => { this.props.onChange({ schedule: { dom: item.value } }) }}
+      />
+    )
+  }
+
+  renderDayOfWeekValue() {
+    let items = [{ label: 'Any', value: null }]
+    // $FlowIssue
+    let days = moment.weekdays(true)
+    days.forEach((label, value) => {
+      items.push({ label, value })
+    })
+
+    if (this.props.item.enabled) {
+      return this.renderLabel(this.getFieldValue(items, 'dow', true))
+    }
+
+    return (
+      <DropdownStyled
+        centered
+        width={136}
+        items={items}
+        useBold={this.shouldUseBold('dow')}
+        selectedItem={this.getFieldValue(items, 'dow', true)}
+        onChange={item => { this.props.onChange({ schedule: { dow: item.value } }) }}
+      />
+    )
+  }
+
+  renderHourValue() {
+    let items = [{ label: 'Any', value: null }]
+    for (let i = 0; i <= 23; i += 1) {
+      items.push({ label: padNumber(i), value: i })
+    }
+
+    if (this.props.item.enabled) {
+      return this.renderLabel(this.getFieldValue(items, 'hour', true, 1))
+    }
+
+    return (
+      <DropdownStyled
+        centered
+        width={72}
+        items={items}
+        useBold={this.shouldUseBold('hour')}
+        selectedItem={this.getFieldValue(items, 'hour', true, 1)}
+        onChange={item => { this.handleHourChange(item.value) }}
+      />
+    )
+  }
+
+  renderMinuteValue() {
+    let items = [{ label: 'Any', value: null }]
+    for (let i = 0; i <= 59; i += 1) {
+      items.push({ label: padNumber(i), value: i })
+    }
+
+    if (this.props.item.enabled) {
+      return this.renderLabel(this.getFieldValue(items, 'minute', true, 1))
+    }
+
+    return (
+      <DropdownStyled
+        centered
+        width={72}
+        items={items}
+        useBold={this.shouldUseBold('minute')}
+        selectedItem={this.getFieldValue(items, 'minute', true, 1)}
+        onChange={item => { this.props.onChange({ schedule: { minute: item.value } }) }}
+      />
+    )
+  }
+
+  renderExpirationValue() {
+    let date = this.props.item.expiration_date && moment(this.props.item.expiration_date)
+    let labelDate = date
+    if (this.props.timezone === 'utc' && date) {
+      labelDate = DateUtils.getUtcTime(date)
+    }
+
+    if (this.props.item.enabled) {
+      return this.renderLabel({ label: (labelDate && labelDate.format('DD/MM/YYYY hh:mm A')) || '-' })
+    }
+
+    return (
+      <DatetimePicker
+        value={date ? date.toDate() : null}
+        timezone={this.props.timezone}
+        useBold={this.shouldUseBold('expiration_date', true)}
+        onChange={date => { this.handleExpirationDateChange(date) }}
+        isValidDate={date => moment(date).isAfter(moment())}
+      />
+    )
+  }
+
+  render() {
+    let enabled = typeof this.props.item.enabled !== 'undefined' && this.props.item.enabled !== null ? this.props.item.enabled : false
+    return (
+      <Wrapper>
+        <Data width={this.props.colWidths[0]}>
+          <Switch
+            noLabel
+            height={16}
+            checked={enabled}
+            onChange={enabled => { this.props.onChange({ enabled }, true) }}
+          />
+        </Data>
+        <Data width={this.props.colWidths[1]}>
+          {this.renderMonthValue()}
+        </Data>
+        <Data width={this.props.colWidths[2]}>
+          {this.renderDayOfMonthValue()}
+        </Data>
+        <Data width={this.props.colWidths[3]}>
+          {this.renderDayOfWeekValue()}
+        </Data>
+        <Data width={this.props.colWidths[4]}>
+          {this.renderHourValue()}
+        </Data>
+        <Data width={this.props.colWidths[5]}>
+          {this.renderMinuteValue()}
+        </Data>
+        <Data width={this.props.colWidths[6]}>
+          {this.renderExpirationValue()}
+        </Data>
+        <Data width={this.props.colWidths[7]}>
+          <Button
+            onClick={this.props.onShowOptionsClick}
+            secondary
+            hollow={!this.areExecutionOptionsChanged()}
+            width="40px"
+            style={{
+              fontSize: '9px',
+              letterSpacing: '1px',
+              padding: '0 0 1px 3px',
+            }}
+          >•••</Button>
+        </Data>
+        <DeleteButton
+          onClick={this.props.onDeleteClick}
+          hidden={this.props.item.enabled}
+        />
+        <SaveButton
+          onClick={this.props.onSaveSchedule}
+          hidden={this.props.item.enabled || !this.props.unsavedSchedules.find(us => us.id === this.props.item.id)}
+        />
+      </Wrapper>
+    )
+  }
+}
+
+export default ScheduleItem

+ 21 - 314
src/components/organisms/Schedule/index.jsx

@@ -15,31 +15,23 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 // @flow
 
 import React from 'react'
-import styled, { css } from 'styled-components'
-import moment from 'moment'
+import styled from 'styled-components'
 
 import Button from '../../atoms/Button'
 import StatusImage from '../../atoms/StatusImage'
-import Switch from '../../atoms/Switch'
-import Dropdown from '../../molecules/Dropdown'
 import Modal from '../../molecules/Modal'
 import DropdownLink from '../../molecules/DropdownLink'
-import DatetimePicker from '../../molecules/DatetimePicker'
 import AlertModal from '../../organisms/AlertModal'
 import ReplicaExecutionOptions from '../../organisms/ReplicaExecutionOptions'
+import ScheduleItem from '../../molecules/ScheduleItem'
 
 import StyleProps from '../../styleUtils/StyleProps'
 import Palette from '../../styleUtils/Palette'
-import NotificationActions from '../../../actions/NotificationActions'
 import DateUtils from '../../../utils/DateUtils'
-import type { Schedule as ScheduleType, ScheduleInfo as ScheduleInfoType } from '../../../types/Schedule'
+import type { Schedule as ScheduleType } from '../../../types/Schedule'
 import type { Field } from '../../../types/Field'
 import { executionOptions } from '../../../config'
 
-import deleteImage from './images/delete.svg'
-import deleteHoverImage from './images/delete-hover.svg'
-import saveImage from './images/save.svg'
-import saveHoverImage from './images/save-hover.svg'
 import scheduleImage from './images/schedule.svg'
 
 const Wrapper = styled.div`
@@ -68,43 +60,6 @@ const HeaderData = styled.div`
   text-transform: uppercase;
 `
 const Body = styled.div``
-const Row = styled.div`
-  display: flex;
-  border-top: 1px solid ${Palette.grayscale[1]};
-  padding: 16px 0;
-  position: relative;
-  &:last-child {
-    border-bottom: 1px solid ${Palette.grayscale[1]};
-  }
-`
-const ItemButton = props => css`
-  width: 16px;
-  height: 16px;
-  position: absolute;
-  cursor: pointer;
-  top: 24px;
-  ${props.hidden ? 'display: none;' : ''}
-`
-const DeleteButton = styled.div`
-  ${props => ItemButton(props)}
-  background: url('${deleteImage}') center no-repeat;
-  right: -32px;
-  
-  &:hover {
-    background: url('${deleteHoverImage}') center no-repeat;
-  }
-`
-const SaveButton = styled.div`
-  ${props => ItemButton(props)}
-  background: url('${saveImage}') center no-repeat;
-  right: -64px;
-  &:hover {
-    background: url('${saveHoverImage}') center no-repeat;
-  }
-`
-const RowData = styled.div`
-  width: ${props => props.width};
-`
 const NoSchedules = styled.div`
   display: flex;
   flex-direction: column;
@@ -125,23 +80,6 @@ const ScheduleImage = styled.div`
   background: url('${scheduleImage}') no-repeat center;
   margin-bottom: 46px;
 `
-const DropdownStyled = styled(Dropdown)`
-  font-size: 12px;
-`
-const Label = styled.div`
-  background: ${Palette.grayscale[7]};
-  height: 100%;
-  font-size: 12px;
-  margin-right: 8px;
-  border-radius: ${StyleProps.borderRadius};
-  padding: 0 8px;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  text-align: center;
-  line-height: 35px;
-  margin-bottom: -8px;
-`
 const Footer = styled.div`
   margin-top: 16px;
   display: flex;
@@ -167,7 +105,6 @@ const Buttons = styled.div`
 `
 
 type TimeZoneValue = 'local' | 'utc'
-type DictItem = { label: string, value: any }
 type Props = {
   schedules: ScheduleType[],
   unsavedSchedules: ScheduleType[],
@@ -176,7 +113,7 @@ type Props = {
   onAddScheduleClick: (schedule: ScheduleType) => void,
   onChange: (scheduleId: ?string, schedule: ScheduleType, forceSave?: boolean) => void,
   onRemove: (scheduleId: ?string) => void,
-  onSaveSchedule: (schedule: ScheduleType) => void,
+  onSaveSchedule?: (schedule: ScheduleType) => void,
   adding?: boolean,
   loading?: boolean,
   secondaryEmpty?: boolean,
@@ -189,12 +126,15 @@ type State = {
 }
 
 const colWidths = ['6%', '18%', '10%', '18%', '10%', '10%', '23%', '5%']
-const daysInMonths = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
 class Schedule extends React.Component<Props, State> {
   static defaultProps: $Shape<Props> = {
     unsavedSchedules: [],
   }
 
+  static defaultProps = {
+    unsavedSchedules: [],
+  }
+
   constructor() {
     super()
 
@@ -206,30 +146,6 @@ class Schedule extends React.Component<Props, State> {
     }
   }
 
-  getFieldValue(schedule: ?ScheduleInfoType, items: DictItem[], fieldName: string, zeroBasedIndex?: boolean, defaultSelectedIndex?: number) {
-    if (schedule === null || schedule === undefined) {
-      return defaultSelectedIndex !== undefined ? items[defaultSelectedIndex] : items[0]
-    }
-
-    if (schedule[fieldName] === null || schedule[fieldName] === undefined) {
-      return items[0]
-    }
-
-    if (zeroBasedIndex) {
-      let value = schedule[fieldName]
-
-      if (fieldName === 'hour') {
-        if (this.props.timezone === 'local') {
-          value = DateUtils.getLocalHour(value)
-        }
-      }
-
-      return items[value + 1]
-    }
-
-    return items[schedule[fieldName]]
-  }
-
   handleDeleteClick(selectedSchedule: ScheduleType) {
     this.setState({ showDeleteConfirmation: true, selectedSchedule })
   }
@@ -274,35 +190,6 @@ class Schedule extends React.Component<Props, State> {
     this.setState({ executionOptions: options })
   }
 
-  handleMonthChange(s: ScheduleType, item: DictItem) {
-    let month = item.value || 1
-    let maxNumDays = daysInMonths[month - 1]
-    let change: ScheduleType = { schedule: { month: item.value } }
-    if (s.schedule && s.schedule.dom && s.schedule.dom > maxNumDays) {
-      if (change.schedule) change.schedule.dom = maxNumDays
-    }
-
-    this.props.onChange(s.id, change)
-  }
-
-  handleExpirationDateChange(s: ScheduleType, date: Date) {
-    let newDate = moment(date)
-    if (newDate.diff(new Date(), 'minutes') < 60) {
-      NotificationActions.notify('Please select a further expiration date.', 'error')
-      return
-    }
-
-    this.props.onChange(s.id, { expiration_date: newDate.toDate() })
-  }
-
-  handleHourChange(s: ScheduleType, hour: number) {
-    if (this.props.timezone === 'local' && hour !== null && hour !== undefined) {
-      hour = DateUtils.getUtcHour(hour)
-    }
-
-    this.props.onChange(s.id, { schedule: { hour } })
-  }
-
   handleAddScheduleClick() {
     let hour = 0
     if (this.props.timezone === 'local') {
@@ -368,202 +255,22 @@ class Schedule extends React.Component<Props, State> {
     )
   }
 
-  renderLabel(value: DictItem) {
-    return <Label>{value.label}</Label>
-  }
-
-  renderMonthValue(s: ScheduleType) {
-    let items = [{ label: 'Any', value: null }]
-    let months = moment.months()
-    months.forEach((label, value) => {
-      items.push({ label, value: value + 1 })
-    })
-
-    if (s.enabled) {
-      return this.renderLabel(this.getFieldValue(s.schedule, items, 'month'))
-    }
-
-    return (
-      <DropdownStyled
-        centered
-        width={136}
-        items={items}
-        useBold={this.shouldUseBold(s.id, 'month')}
-        selectedItem={this.getFieldValue(s.schedule, items, 'month')}
-        onChange={item => { this.handleMonthChange(s, item) }}
-      />
-    )
-  }
-
-  renderDayOfMonthValue(s: ScheduleType) {
-    let month = s.schedule ? s.schedule.month || 1 : 1
-    let items = [{ label: 'Any', value: null }]
-    for (let i = 1; i <= daysInMonths[month - 1]; i += 1) {
-      items.push({ label: i.toString(), value: i })
-    }
-
-    if (s.enabled) {
-      return this.renderLabel(this.getFieldValue(s.schedule, items, 'dom'))
-    }
-
-    return (
-      <DropdownStyled
-        centered
-        width={72}
-        items={items}
-        useBold={this.shouldUseBold(s.id, 'dom')}
-        selectedItem={this.getFieldValue(s.schedule, items, 'dom')}
-        onChange={item => { this.props.onChange(s.id, { schedule: { dom: item.value } }) }}
-      />
-    )
-  }
-
-  renderDayOfWeekValue(s: ScheduleType) {
-    let items = [{ label: 'Any', value: null }]
-    // $FlowIssue
-    let days = moment.weekdays(true)
-    days.forEach((label, value) => {
-      items.push({ label, value })
-    })
-
-    if (s.enabled) {
-      return this.renderLabel(this.getFieldValue(s.schedule, items, 'dow', true))
-    }
-
-    return (
-      <DropdownStyled
-        centered
-        width={136}
-        items={items}
-        useBold={this.shouldUseBold(s.id, 'dow')}
-        selectedItem={this.getFieldValue(s.schedule, items, 'dow', true)}
-        onChange={item => { this.props.onChange(s.id, { schedule: { dow: item.value } }) }}
-      />
-    )
-  }
-
-  renderHourValue(s: ScheduleType) {
-    let items = [{ label: 'Any', value: null }]
-    for (let i = 0; i <= 23; i += 1) {
-      items.push({ label: this.padNumber(i), value: i })
-    }
-
-    if (s.enabled) {
-      return this.renderLabel(this.getFieldValue(s.schedule, items, 'hour', true, 1))
-    }
-
-    return (
-      <DropdownStyled
-        centered
-        width={72}
-        items={items}
-        useBold={this.shouldUseBold(s.id, 'hour')}
-        selectedItem={this.getFieldValue(s.schedule, items, 'hour', true, 1)}
-        onChange={item => { this.handleHourChange(s, item.value) }}
-      />
-    )
-  }
-
-  renderMinuteValue(s: ScheduleType) {
-    let items = [{ label: 'Any', value: null }]
-    for (let i = 0; i <= 59; i += 1) {
-      items.push({ label: this.padNumber(i), value: i })
-    }
-
-    if (s.enabled) {
-      return this.renderLabel(this.getFieldValue(s.schedule, items, 'minute', true, 1))
-    }
-
-    return (
-      <DropdownStyled
-        centered
-        width={72}
-        items={items}
-        useBold={this.shouldUseBold(s.id, 'minute')}
-        selectedItem={this.getFieldValue(s.schedule, items, 'minute', true, 1)}
-        onChange={item => { this.props.onChange(s.id, { schedule: { minute: item.value } }) }}
-      />
-    )
-  }
-
-  renderExpirationValue(s: ScheduleType) {
-    let date = s.expiration_date ? moment(s.expiration_date) : null
-    let labelDate = date
-    if (this.props.timezone === 'utc' && date) {
-      labelDate = DateUtils.getUtcTime(date)
-    }
-
-    if (s.enabled) {
-      return this.renderLabel({ label: labelDate ? labelDate.format('DD/MM/YYYY hh:mm A') : '-', value: '' })
-    }
-
-    return (
-      <DatetimePicker
-        value={date ? date.toDate() : null}
-        timezone={this.props.timezone}
-        useBold={this.shouldUseBold(s.id, 'expiration_date', true)}
-        onChange={date => { this.handleExpirationDateChange(s, date) }}
-        isValidDate={date => moment(date).isAfter(moment())}
-      />
-    )
-  }
-
   renderBody() {
     return (
       <Body>
-        {this.props.schedules.map((s, i) => {
-          return (
-            <Row key={i}>
-              <RowData width={colWidths[0]}>
-                <Switch
-                  noLabel
-                  height={16}
-                  checked={s.enabled !== null && s.enabled !== undefined ? s.enabled : false}
-                  onChange={enabled => { this.props.onChange(s.id, { enabled }, true) }}
-                />
-              </RowData>
-              <RowData width={colWidths[1]}>
-                {this.renderMonthValue(s)}
-              </RowData>
-              <RowData width={colWidths[2]}>
-                {this.renderDayOfMonthValue(s)}
-              </RowData>
-              <RowData width={colWidths[3]}>
-                {this.renderDayOfWeekValue(s)}
-              </RowData>
-              <RowData width={colWidths[4]}>
-                {this.renderHourValue(s)}
-              </RowData>
-              <RowData width={colWidths[5]}>
-                {this.renderMinuteValue(s)}
-              </RowData>
-              <RowData width={colWidths[6]}>
-                {this.renderExpirationValue(s)}
-              </RowData>
-              <RowData width={colWidths[7]}>
-                <Button
-                  onClick={() => { this.handleShowOptions(s) }}
-                  secondary
-                  hollow={!this.areExecutionOptionsChanged(s)}
-                  width="40px"
-                  style={{
-                    fontSize: '9px',
-                    letterSpacing: '1px',
-                    padding: '0 0 1px 3px',
-                  }}
-                >•••</Button>
-              </RowData>
-              <DeleteButton
-                onClick={() => { this.handleDeleteClick(s) }}
-                hidden={s.enabled}
-              />
-              <SaveButton
-                onClick={() => { this.props.onSaveSchedule(s) }}
-                hidden={s.enabled || !this.props.unsavedSchedules.find(us => us.id === s.id)}
-              />
-            </Row>
-          )
-        })}
+        {this.props.schedules.map(schedule => (
+          <ScheduleItem
+            key={schedule.id}
+            colWidths={colWidths}
+            item={schedule}
+            unsavedSchedules={this.props.unsavedSchedules}
+            timezone={this.props.timezone}
+            onChange={(data, forceSave) => { this.props.onChange(schedule.id, data, forceSave) }}
+            onSaveSchedule={() => { if (this.props.onSaveSchedule) this.props.onSaveSchedule(schedule) }}
+            onShowOptionsClick={() => { this.handleShowOptions(schedule) }}
+            onDeleteClick={() => { this.handleDeleteClick(schedule) }}
+          />
+        ))}
       </Body>
     )
   }