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

Merge pull request #149 from smiclea/CORWEB-144

Improve scheduler's UX based on Sketch mockup CORWEB-144
Dorin Paslaru 8 лет назад
Родитель
Сommit
c885e44c50

+ 13 - 7
src/actions/ScheduleActions.js

@@ -86,13 +86,15 @@ class ScheduleActions {
     return response || true
   }
 
-  updateSchedule(replicaId, scheduleId, data, oldData) {
-    ScheduleSource.updateSchedule(replicaId, scheduleId, data, oldData).then(
-      schedule => { this.updateScheduleSuccess(schedule) },
-      response => { this.updateScheduleFailed(response) },
-    )
-
-    return { replicaId, scheduleId, data }
+  updateSchedule(replicaId, scheduleId, data, oldData, unsavedData, forceSave) {
+    if (forceSave) {
+      ScheduleSource.updateSchedule(replicaId, scheduleId, data, oldData, unsavedData).then(
+        schedule => { this.updateScheduleSuccess(schedule) },
+        response => { this.updateScheduleFailed(response) },
+      )
+    }
+
+    return { replicaId, scheduleId, data, forceSave }
   }
 
   updateScheduleSuccess(schedule) {
@@ -102,6 +104,10 @@ class ScheduleActions {
   updateScheduleFailed(response) {
     return response || null
   }
+
+  clearUnsavedSchedules() {
+    return true
+  }
 }
 
 export default alt.createActions(ScheduleActions)

+ 1 - 1
src/components/atoms/Button/index.jsx

@@ -93,7 +93,7 @@ const StyledButton = styled.button`
   ${props => StyleProps.exactWidth(getWidth(props))}
   cursor: pointer;
   font-size: inherit;
-  transition: all ${StyleProps.animations.swift};
+  transition: background-color ${StyleProps.animations.swift}, opacity ${StyleProps.animations.swift};
   &:disabled {
     opacity: 0.7;
     cursor: not-allowed;

+ 1 - 0
src/components/atoms/DropdownButton/index.jsx

@@ -40,6 +40,7 @@ const Label = styled.div`
   text-overflow: ellipsis;
   white-space: nowrap;
   flex-grow: 1;
+  ${props => props.useBold ? `font-weight: ${StyleProps.fontWeights.medium};` : ''}
   ${props => props.centered ? 'text-align: center;' : ''}
 `
 

+ 3 - 1
src/components/molecules/DatetimePicker/index.jsx

@@ -53,7 +53,8 @@ type Props = {
   value: ?Date,
   onChange: (date: Date) => void,
   isValidDate: (currentDate: Date, selectedDate: Date) => boolean,
-  timezone: 'utc' | 'local'
+  timezone: 'utc' | 'local',
+  useBold?: boolean,
 }
 type State = {
   showPicker: boolean,
@@ -133,6 +134,7 @@ class DatetimePicker extends React.Component<Props, State> {
           width={176}
           value={(timezoneDate && moment(timezoneDate).format('DD/MM/YYYY hh:mm A')) || '-'}
           centered
+          useBold={this.props.useBold}
           onClick={() => { this.handleDropdownClick() }}
           onMouseDown={() => { this.itemMouseDown = true }}
           onMouseUp={() => { this.itemMouseDown = false }}

+ 4 - 0
src/components/organisms/ReplicaDetailsContent/index.jsx

@@ -25,6 +25,7 @@ import Schedule from '../../organisms/Schedule'
 import type { MainItem } from '../../../types/MainItem'
 import type { Endpoint } from '../../../types/Endpoint'
 import type { Execution } from '../../../types/Execution'
+import type { Schedule as ScheduleType } from '../../../types/Schedule'
 
 const Wrapper = styled.div`
   display: flex;
@@ -80,6 +81,7 @@ type Props = {
   onAddScheduleClick: () => void,
   onScheduleChange: () => void,
   onScheduleRemove: () => void,
+  onScheduleSave: (schedule: ScheduleType) => void,
 }
 type State = {
   timezone: TimezoneValue,
@@ -180,11 +182,13 @@ class ReplicaDetailsContent extends React.Component<Props, State> {
     return (
       <Schedule
         schedules={this.props.scheduleStore.schedules}
+        unsavedSchedules={this.props.scheduleStore.unsavedSchedules}
         adding={this.props.scheduleStore.adding}
         loading={this.props.scheduleStore.loading}
         onAddScheduleClick={this.props.onAddScheduleClick}
         onChange={this.props.onScheduleChange}
         onRemove={this.props.onScheduleRemove}
+        onSaveSchedule={this.props.onScheduleSave}
         timezone={this.state.timezone}
         onTimezoneChange={timezone => { this.handleTimezoneChange(timezone) }}
       />

+ 20 - 0
src/components/organisms/Schedule/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/organisms/Schedule/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>

+ 88 - 16
src/components/organisms/Schedule/index.jsx

@@ -15,7 +15,7 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 // @flow
 
 import React from 'react'
-import styled from 'styled-components'
+import styled, { css } from 'styled-components'
 import moment from 'moment'
 
 import Button from '../../atoms/Button'
@@ -34,9 +34,12 @@ import NotificationActions from '../../../actions/NotificationActions'
 import DateUtils from '../../../utils/DateUtils'
 import type { Schedule as ScheduleType, ScheduleInfo as ScheduleInfoType } 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`
@@ -74,20 +77,31 @@ const Row = styled.div`
     border-bottom: 1px solid ${Palette.grayscale[1]};
   }
 `
-const DeleteButton = styled.div`
+const ItemButton = props => css`
   width: 16px;
   height: 16px;
-  background: url('${deleteImage}') center no-repeat;
   position: absolute;
   cursor: pointer;
-  right: -32px;
   top: 24px;
-  ${props => props.hidden ? 'display: none;' : ''}
-
+  ${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};
 `
@@ -131,7 +145,7 @@ const Label = styled.div`
 const Footer = styled.div`
   margin-top: 16px;
   display: flex;
-  align-items: center;
+  align-items: flex-start;
   justify-content: space-between;
 `
 const Timezone = styled.div`
@@ -141,16 +155,28 @@ const Timezone = styled.div`
 const TimezoneLabel = styled.div`
   margin-right: 4px;
 `
+const Buttons = styled.div`
+  display: flex;
+  flex-direction: column;
+  button {
+    margin-bottom: 16px;
+    &:last-child {
+      margin-bottom: 0;
+    }
+  }
+`
 
 type TimeZoneValue = 'local' | 'utc'
 type DictItem = { label: string, value: any }
 type Props = {
   schedules: ScheduleType[],
+  unsavedSchedules: ScheduleType[],
   timezone: TimeZoneValue,
   onTimezoneChange: (timezone: TimeZoneValue) => void,
   onAddScheduleClick: (schedule: ScheduleType) => void,
-  onChange: (scheduleId: ?string, schedule: ScheduleType) => void,
+  onChange: (scheduleId: ?string, schedule: ScheduleType, forceSave?: boolean) => void,
   onRemove: (scheduleId: ?string) => void,
+  onSaveSchedule: (schedule: ScheduleType) => void,
   adding?: boolean,
   loading?: boolean,
   secondaryEmpty?: boolean,
@@ -165,6 +191,10 @@ 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: [],
+  }
+
   constructor() {
     super()
 
@@ -228,7 +258,7 @@ class Schedule extends React.Component<Props, State> {
       options[f.name] = f.value || false
     })
 
-    this.props.onChange(this.state.selectedSchedule ? this.state.selectedSchedule.id : null, options)
+    this.props.onChange(this.state.selectedSchedule ? this.state.selectedSchedule.id : null, options, true)
   }
 
   handleExecutionOptionsChange(fieldName: string, value: string) {
@@ -281,6 +311,18 @@ class Schedule extends React.Component<Props, State> {
     this.props.onAddScheduleClick({ schedule: { hour, minute: 0 } })
   }
 
+  areExecutionOptionsChanged(schedule: ScheduleType) {
+    let isChanged = false
+    executionOptions.forEach(o => {
+      let scheduleValue = schedule[o.name]
+      let optionValue = o.value !== undefined ? o.value : false
+      if (scheduleValue !== undefined && scheduleValue !== null && scheduleValue !== optionValue) {
+        isChanged = true
+      }
+    })
+    return isChanged
+  }
+
   padNumber(number: number) {
     if (number < 10) {
       return `0${number}`
@@ -289,6 +331,18 @@ class Schedule extends React.Component<Props, State> {
     return number.toString()
   }
 
+  shouldUseBold(scheduleId: ?string, fieldName: string, isRootField?: boolean) {
+    const unsavedSchedule = this.props.unsavedSchedules.find(s => s.id === scheduleId)
+    if (!unsavedSchedule) {
+      return false
+    }
+    let data = isRootField ? unsavedSchedule : unsavedSchedule.schedule
+    if (data && data[fieldName] !== undefined && data[fieldName] !== null) {
+      return true
+    }
+    return false
+  }
+
   renderLoading() {
     if (!this.props.loading) {
       return null
@@ -334,6 +388,7 @@ class Schedule extends React.Component<Props, State> {
         centered
         width={136}
         items={items}
+        useBold={this.shouldUseBold(s.id, 'month')}
         selectedItem={this.getFieldValue(s.schedule, items, 'month')}
         onChange={item => { this.handleMonthChange(s, item) }}
       />
@@ -356,6 +411,7 @@ class Schedule extends React.Component<Props, State> {
         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 } }) }}
       />
@@ -379,6 +435,7 @@ class Schedule extends React.Component<Props, State> {
         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 } }) }}
       />
@@ -400,6 +457,7 @@ class Schedule extends React.Component<Props, State> {
         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) }}
       />
@@ -421,6 +479,7 @@ class Schedule extends React.Component<Props, State> {
         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 } }) }}
       />
@@ -442,6 +501,7 @@ class Schedule extends React.Component<Props, State> {
       <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())}
       />
@@ -459,7 +519,7 @@ class Schedule extends React.Component<Props, State> {
                   noLabel
                   height={16}
                   checked={s.enabled !== null && s.enabled !== undefined ? s.enabled : false}
-                  onChange={enabled => { this.props.onChange(s.id, { enabled }) }}
+                  onChange={enabled => { this.props.onChange(s.id, { enabled }, true) }}
                 />
               </RowData>
               <RowData width={colWidths[1]}>
@@ -484,12 +544,22 @@ class Schedule extends React.Component<Props, State> {
                 <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 !== null && s.enabled !== undefined ? s.enabled : false}
+                hidden={s.enabled}
+              />
+              <SaveButton
+                onClick={() => { this.props.onSaveSchedule(s) }}
+                hidden={s.enabled || !this.props.unsavedSchedules.find(us => us.id === s.id)}
               />
             </Row>
           )
@@ -539,11 +609,13 @@ class Schedule extends React.Component<Props, State> {
 
     return (
       <Footer>
-        <Button
-          disabled={this.props.adding}
-          secondary
-          onClick={() => { this.handleAddScheduleClick() }}
-        >Add Schedule</Button>
+        <Buttons>
+          <Button
+            disabled={this.props.adding}
+            secondary
+            onClick={() => { this.handleAddScheduleClick() }}
+          >Add Schedule</Button>
+        </Buttons>
         <Timezone>
           <TimezoneLabel>Show all times in</TimezoneLabel>
           <DropdownLink

+ 10 - 3
src/components/pages/ReplicaDetailsPage/index.jsx

@@ -101,6 +101,7 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
 
   componentWillUnmount() {
     ReplicaActions.clearDetails()
+    ScheduleActions.clearUnsavedSchedules()
     clearTimeout(this.pollTimeout)
   }
 
@@ -200,9 +201,14 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
     ScheduleActions.addSchedule(this.props.match.params.id, schedule)
   }
 
-  handleScheduleChange(scheduleId, data) {
+  handleScheduleChange(scheduleId, data, forceSave) {
     let oldData = this.props.scheduleStore.schedules.find(s => s.id === scheduleId)
-    ScheduleActions.updateSchedule(this.props.match.params.id, scheduleId, data, oldData)
+    let unsavedData = this.props.scheduleStore.unsavedSchedules.find(s => s.id === scheduleId)
+    ScheduleActions.updateSchedule(this.props.match.params.id, scheduleId, data, oldData, unsavedData, forceSave)
+  }
+
+  handleScheduleSave(schedule) {
+    ScheduleActions.updateSchedule(this.props.match.params.id, schedule.id, schedule, schedule, schedule, true)
   }
 
   handleScheduleRemove(scheduleId) {
@@ -273,8 +279,9 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
             onDeleteReplicaClick={() => { this.handleDeleteReplicaClick() }}
             onDeleteReplicaDisksClick={() => { this.handleDeleteReplicaDisksClick() }}
             onAddScheduleClick={schedule => { this.handleAddScheduleClick(schedule) }}
-            onScheduleChange={(scheduleId, data) => { this.handleScheduleChange(scheduleId, data) }}
+            onScheduleChange={(scheduleId, data, forceSave) => { this.handleScheduleChange(scheduleId, data, forceSave) }}
             onScheduleRemove={scheduleId => { this.handleScheduleRemove(scheduleId) }}
+            onScheduleSave={s => { this.handleScheduleSave(s) }}
           />}
         />
         <Modal

+ 8 - 8
src/sources/ScheduleSource.js

@@ -127,24 +127,24 @@ class ScheduleSource {
     })
   }
 
-  static updateSchedule(replicaId, scheduleId, scheduleData, scheduleOldData) {
+  static updateSchedule(replicaId, scheduleId, scheduleData, scheduleOldData, unsavedData) {
     return new Promise((resolve, reject) => {
       let projectId = cookie.get('projectId')
       let payload = {}
-      if (scheduleData.expiration_date) {
-        payload.expiration_date = moment(scheduleData.expiration_date).toISOString()
-      }
       if (scheduleData.enabled !== null && scheduleData.enabled !== undefined) {
         payload.enabled = scheduleData.enabled
       }
       if (scheduleData.shutdown_instances !== null && scheduleData.shutdown_instances !== undefined) {
         payload.shutdown_instance = scheduleData.shutdown_instances
       }
-      if (scheduleData.schedule !== null && scheduleData.schedule !== undefined && Object.keys(scheduleData.schedule).length) {
+      if (unsavedData && unsavedData.expiration_date) {
+        payload.expiration_date = moment(unsavedData.expiration_date).toISOString()
+      }
+      if (unsavedData && unsavedData.schedule !== null && unsavedData.schedule !== undefined && Object.keys(unsavedData.schedule).length) {
         payload.schedule = { ...scheduleOldData.schedule }
-        Object.keys(scheduleData.schedule).forEach(prop => {
-          if (scheduleData.schedule[prop] !== null && scheduleData.schedule[prop] !== undefined) {
-            payload.schedule[prop] = scheduleData.schedule[prop]
+        Object.keys(unsavedData.schedule).forEach(prop => {
+          if (unsavedData.schedule[prop] !== null && unsavedData.schedule[prop] !== undefined) {
+            payload.schedule[prop] = unsavedData.schedule[prop]
           } else {
             delete payload.schedule[prop]
           }

+ 33 - 11
src/stores/ScheduleStore.js

@@ -15,10 +15,25 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 import alt from '../alt'
 import ScheduleActions from '../actions/ScheduleActions'
 
+const updateSchedule = (schedules, id, data) => {
+  return schedules.map(schedule => {
+    if (schedule.id === id) {
+      let newSchedule = { ...schedule, ...data }
+      if (data.schedule !== null && data.schedule !== undefined && Object.keys(data.schedule).length) {
+        newSchedule.schedule = { ...schedule.schedule, ...data.schedule || {} }
+      }
+      return newSchedule
+    }
+
+    return { ...schedule }
+  })
+}
+
 class ScheduleStore {
   constructor() {
     this.loading = false
     this.schedules = []
+    this.unsavedSchedules = []
     this.scheduling = false
     this.adding = false
 
@@ -35,6 +50,7 @@ class ScheduleStore {
       handleRemoveSchedule: ScheduleActions.REMOVE_SCHEDULE,
       handleUpdateSchedule: ScheduleActions.UPDATE_SCHEDULE,
       handleUpdateScheduleSuccess: ScheduleActions.UPDATE_SCHEDULE_SUCCESS,
+      handleClearUnsavedSchedules: ScheduleActions.CLEAR_UNSAVED_SCHEDULES,
     })
   }
 
@@ -78,20 +94,20 @@ class ScheduleStore {
 
   handleRemoveSchedule({ scheduleId }) {
     this.schedules = this.schedules.filter(s => s.id !== scheduleId)
+    this.unsavedSchedules = this.unsavedSchedules.filter(s => s.id !== scheduleId)
   }
 
-  handleUpdateSchedule({ scheduleId, data }) {
-    this.schedules = this.schedules.map(schedule => {
-      if (schedule.id === scheduleId) {
-        let newSchedule = { ...schedule }
-        if (data.schedule !== null && data.schedule !== undefined && Object.keys(data.schedule).length) {
-          newSchedule.schedule = { ...schedule.schedule, ...data.schedule || {} }
-        }
-        return newSchedule
-      }
+  handleUpdateSchedule({ scheduleId, data, forceSave }) {
+    this.schedules = updateSchedule(this.schedules, scheduleId, data)
 
-      return { ...schedule }
-    })
+    if (!forceSave) {
+      const unsavedSchedule = this.unsavedSchedules.find(s => s.id === scheduleId)
+      if (unsavedSchedule) {
+        this.unsavedSchedules = updateSchedule(this.unsavedSchedules, scheduleId, data)
+      } else {
+        this.unsavedSchedules.push({ id: scheduleId, ...data })
+      }
+    }
   }
 
   handleUpdateScheduleSuccess(schedule) {
@@ -102,6 +118,12 @@ class ScheduleStore {
 
       return { ...s }
     })
+    this.unsavedSchedules = this.unsavedSchedules.filter(s => s.id !== schedule.id)
+  }
+
+  handleClearUnsavedSchedules() {
+    this.unsavedSchedules = []
+    this.saving = false
   }
 }