diff --git a/src/lib/project-saver-hoc.jsx b/src/lib/project-saver-hoc.jsx index 2487531bb65275fa62146e5dd0664e2a02f3e15a..bbd78f4463919a920cb9cdd822b3b8ad8a9a87c5 100644 --- a/src/lib/project-saver-hoc.jsx +++ b/src/lib/project-saver-hoc.jsx @@ -1,3 +1,4 @@ +import bindAll from 'lodash.bindall'; import React from 'react'; import PropTypes from 'prop-types'; import {connect} from 'react-redux'; @@ -9,6 +10,7 @@ import { showAlertWithTimeout, showStandardAlert } from '../reducers/alerts'; +import {setAutoSaveTimeoutId} from '../reducers/timeout'; import {setProjectUnchanged} from '../reducers/project-changed'; import { LoadingStates, @@ -37,7 +39,16 @@ import { */ const ProjectSaverHOC = function (WrappedComponent) { class ProjectSaverComponent extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'tryToAutoSave' + ]); + } componentDidUpdate (prevProps) { + if (this.props.projectChanged && !prevProps.projectChanged) { + this.scheduleAutoSave(); + } if (this.props.isUpdating && !prevProps.isUpdating) { this.updateProjectToStorage(); } @@ -65,10 +76,30 @@ const ProjectSaverHOC = function (WrappedComponent) { // don't try to save immediately after trying to save if (prevProps.isUpdating) return; // if we're newly able to save this project, save it! - const showingSaveable = this.props.canSave && this.props.isShowingWithId; const becameAbleToSave = this.props.canSave && !prevProps.canSave; const becameShared = this.props.isShared && !prevProps.isShared; - if (showingSaveable && (becameAbleToSave || becameShared)) { + if (this.props.isShowingSaveable && (becameAbleToSave || becameShared)) { + this.props.onAutoUpdateProject(); + } + } + componentWillUnmount () { + this.clearAutoSaveTimeout(); + } + clearAutoSaveTimeout () { + if (this.props.autoSaveTimeoutId !== null) { + clearTimeout(this.props.autoSaveTimeoutId); + this.props.setAutoSaveTimeoutId(null); + } + } + scheduleAutoSave () { + if (this.props.isShowingSaveable && this.props.autoSaveTimeoutId === null) { + const timeoutId = setTimeout(this.tryToAutoSave, + this.props.autosaveIntervalSecs * 1000); + this.props.setAutoSaveTimeoutId(timeoutId); + } + } + tryToAutoSave () { + if (this.props.projectChanged && this.props.isShowingSaveable) { this.props.onAutoUpdateProject(); } } @@ -143,6 +174,7 @@ const ProjectSaverHOC = function (WrappedComponent) { */ storeProject (projectId, requestParams) { requestParams = requestParams || {}; + this.clearAutoSaveTimeout(); return Promise.all(this.props.vm.assets .filter(asset => !asset.clean) .map( @@ -180,10 +212,13 @@ const ProjectSaverHOC = function (WrappedComponent) { render () { const { /* eslint-disable no-unused-vars */ + autosaveIntervalSecs, isCreatingCopy, isCreatingNew, + projectChanged, isManualUpdating, isRemixing, + isShowingSaveable, isShowingWithId, isShowingWithoutId, isUpdating, @@ -200,6 +235,7 @@ const ProjectSaverHOC = function (WrappedComponent) { onUpdatedProject, reduxProjectId, reduxProjectTitle, + setAutoSaveTimeoutId: setAutoSaveTimeoutIdProp, /* eslint-enable no-unused-vars */ ...componentProps } = this.props; @@ -212,6 +248,7 @@ const ProjectSaverHOC = function (WrappedComponent) { } ProjectSaverComponent.propTypes = { + autoSaveTimeoutId: PropTypes.number, canCreateNew: PropTypes.bool, canSave: PropTypes.bool, isCreatingCopy: PropTypes.bool, @@ -219,6 +256,7 @@ const ProjectSaverHOC = function (WrappedComponent) { isManualUpdating: PropTypes.bool, isRemixing: PropTypes.bool, isShared: PropTypes.bool, + isShowingSaveable: PropTypes.bool, isShowingWithId: PropTypes.bool, isShowingWithoutId: PropTypes.bool, isUpdating: PropTypes.bool, @@ -233,21 +271,29 @@ const ProjectSaverHOC = function (WrappedComponent) { onShowSaveSuccessAlert: PropTypes.func, onShowSavingAlert: PropTypes.func, onUpdatedProject: PropTypes.func, + projectChanged: PropTypes.bool, reduxProjectId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), reduxProjectTitle: PropTypes.string, vm: PropTypes.instanceOf(VM).isRequired }; - const mapStateToProps = state => { + ProjectSaverComponent.defaultProps = { + autosaveIntervalSecs: 120 + }; + const mapStateToProps = (state, ownProps) => { const loadingState = state.scratchGui.projectState.loadingState; + const isShowingWithId = getIsShowingWithId(loadingState); return { + autoSaveTimeoutId: state.scratchGui.timeout.autoSaveTimeoutId, isCreatingCopy: getIsCreatingCopy(loadingState), isCreatingNew: getIsCreatingNew(loadingState), isRemixing: getIsRemixing(loadingState), - isShowingWithId: getIsShowingWithId(loadingState), + isShowingSaveable: ownProps.canSave && isShowingWithId, + isShowingWithId: isShowingWithId, isShowingWithoutId: getIsShowingWithoutId(loadingState), isUpdating: getIsUpdating(loadingState), isManualUpdating: getIsManualUpdating(loadingState), loadingState: loadingState, + projectChanged: state.scratchGui.projectChanged, reduxProjectId: state.scratchGui.projectState.projectId, reduxProjectTitle: state.scratchGui.projectTitle, vm: state.scratchGui.vm @@ -264,7 +310,8 @@ const ProjectSaverHOC = function (WrappedComponent) { onShowCreatingAlert: () => showAlertWithTimeout(dispatch, 'creating'), onShowSaveSuccessAlert: () => showAlertWithTimeout(dispatch, 'saveSuccess'), onShowSavingAlert: () => showAlertWithTimeout(dispatch, 'saving'), - onUpdatedProject: (projectId, loadingState) => dispatch(doneUpdatingProject(projectId, loadingState)) + onUpdatedProject: (projectId, loadingState) => dispatch(doneUpdatingProject(projectId, loadingState)), + setAutoSaveTimeoutId: id => dispatch(setAutoSaveTimeoutId(id)) }); // Allow incoming props to override redux-provided props. Used to mock in tests. const mergeProps = (stateProps, dispatchProps, ownProps) => Object.assign( diff --git a/src/reducers/gui.js b/src/reducers/gui.js index 8fb65a2112e9fac70f4be41fe7297903421e4088..41b5fbce0719880568a8b31dc304f978896b0993 100644 --- a/src/reducers/gui.js +++ b/src/reducers/gui.js @@ -20,6 +20,7 @@ import projectTitleReducer, {projectTitleInitialState} from './project-title'; import restoreDeletionReducer, {restoreDeletionInitialState} from './restore-deletion'; import stageSizeReducer, {stageSizeInitialState} from './stage-size'; import targetReducer, {targetsInitialState} from './targets'; +import timeoutReducer, {timeoutInitialState} from './timeout'; import toolboxReducer, {toolboxInitialState} from './toolbox'; import vmReducer, {vmInitialState} from './vm'; import vmStatusReducer, {vmStatusInitialState} from './vm-status'; @@ -51,6 +52,7 @@ const guiInitialState = { projectTitle: projectTitleInitialState, restoreDeletion: restoreDeletionInitialState, targets: targetsInitialState, + timeout: timeoutInitialState, toolbox: toolboxInitialState, vm: vmInitialState, vmStatus: vmStatusInitialState @@ -133,6 +135,7 @@ const guiReducer = combineReducers({ projectTitle: projectTitleReducer, restoreDeletion: restoreDeletionReducer, targets: targetReducer, + timeout: timeoutReducer, toolbox: toolboxReducer, vm: vmReducer, vmStatus: vmStatusReducer diff --git a/src/reducers/timeout.js b/src/reducers/timeout.js new file mode 100644 index 0000000000000000000000000000000000000000..5ed5b7be3c16845abee19fe52f13d3b516acfd88 --- /dev/null +++ b/src/reducers/timeout.js @@ -0,0 +1,27 @@ +const SET_AUTOSAVE_TIMEOUT_ID = 'timeout/SET_AUTOSAVE_TIMEOUT_ID'; + +const initialState = { + autoSaveTimeoutId: null +}; + +const reducer = function (state, action) { + if (typeof state === 'undefined') state = initialState; + switch (action.type) { + case SET_AUTOSAVE_TIMEOUT_ID: + return Object.assign({}, state, { + autoSaveTimeoutId: action.id + }); + default: + return state; + } +}; +const setAutoSaveTimeoutId = id => ({ + type: SET_AUTOSAVE_TIMEOUT_ID, + id +}); + +export { + reducer as default, + initialState as timeoutInitialState, + setAutoSaveTimeoutId +}; diff --git a/test/unit/util/project-saver-hoc.test.jsx b/test/unit/util/project-saver-hoc.test.jsx index 4af09047c3bebdd8ae61374fec4dfdcd7d1154a6..90dc63367cffbfe72be92e2371c1ad11a76d8ee7 100644 --- a/test/unit/util/project-saver-hoc.test.jsx +++ b/test/unit/util/project-saver-hoc.test.jsx @@ -16,10 +16,16 @@ describe('projectSaverHOC', () => { beforeEach(() => { store = mockStore({ scratchGui: { - projectState: {} + projectChanged: false, + projectState: {}, + projectTitle: 'Scratch Project', + timeout: { + autoSaveTimeoutId: null + } } }); vm = new VM(); + jest.useFakeTimers(); }); test('if canSave becomes true when showing a project with an id, project will be saved', () => { @@ -31,6 +37,7 @@ describe('projectSaverHOC', () => { isShowingWithId canSave={false} isCreatingNew={false} + isShowingSaveable={false} // set explicitly because it relies on ownProps.canSave isShowingWithoutId={false} isUpdating={false} loadingState={LoadingState.SHOWING_WITH_ID} @@ -40,7 +47,8 @@ describe('projectSaverHOC', () => { /> ); mounted.setProps({ - canSave: true + canSave: true, + isShowingSaveable: true }); expect(mockedUpdateProject).toHaveBeenCalled(); }); @@ -313,4 +321,82 @@ describe('projectSaverHOC', () => { }); expect(mockedShowSavingAlert).toHaveBeenCalled(); }); + + test('if project is changed, it should autosave after interval', () => { + const Component = () => <div />; + const WrappedComponent = projectSaverHOC(Component); + const mockedAutoUpdate = jest.fn(() => Promise.resolve()); + const mounted = mount( + <WrappedComponent + canSave + isShowingSaveable + isShowingWithId + loadingState={LoadingState.SHOWING_WITH_ID} + store={store} + vm={vm} + onAutoUpdateProject={mockedAutoUpdate} + /> + ); + mounted.setProps({ + projectChanged: true + }); + // Fast-forward until all timers have been executed + jest.runAllTimers(); + expect(mockedAutoUpdate).toHaveBeenCalled(); + }); + + test('if project is changed several times in a row, it should only autosave once', () => { + const Component = () => <div />; + const WrappedComponent = projectSaverHOC(Component); + const mockedAutoUpdate = jest.fn(() => Promise.resolve()); + const mounted = mount( + <WrappedComponent + canSave + isShowingSaveable + isShowingWithId + loadingState={LoadingState.SHOWING_WITH_ID} + store={store} + vm={vm} + onAutoUpdateProject={mockedAutoUpdate} + /> + ); + mounted.setProps({ + projectChanged: true, + reduxProjectTitle: 'a' + }); + mounted.setProps({ + projectChanged: true, + reduxProjectTitle: 'b' + }); + mounted.setProps({ + projectChanged: true, + reduxProjectTitle: 'c' + }); + // Fast-forward until all timers have been executed + jest.runAllTimers(); + expect(mockedAutoUpdate).toHaveBeenCalledTimes(1); + }); + + test('if project is not changed, it should not autosave after interval', () => { + const Component = () => <div />; + const WrappedComponent = projectSaverHOC(Component); + const mockedAutoUpdate = jest.fn(() => Promise.resolve()); + const mounted = mount( + <WrappedComponent + canSave + isShowingSaveable + isShowingWithId + loadingState={LoadingState.SHOWING_WITH_ID} + store={store} + vm={vm} + onAutoUpdateProject={mockedAutoUpdate} + /> + ); + mounted.setProps({ + projectChanged: false + }); + // Fast-forward until all timers have been executed + jest.runAllTimers(); + expect(mockedAutoUpdate).not.toHaveBeenCalled(); + }); });