Spaces:
Running
Running
import bindAll from 'lodash.bindall'; | |
import React from 'react'; | |
import PropTypes from 'prop-types'; | |
import {connect} from 'react-redux'; | |
import VM from 'scratch-vm'; | |
import collectMetadata from '../lib/collect-metadata'; | |
import log from '../lib/log'; | |
import storage from '../lib/storage'; | |
import dataURItoBlob from '../lib/data-uri-to-blob'; | |
import saveProjectToServer from '../lib/save-project-to-server'; | |
import { | |
showAlertWithTimeout, | |
showStandardAlert | |
} from '../reducers/alerts'; | |
import {setAutoSaveTimeoutId} from '../reducers/timeout'; | |
import {setProjectUnchanged} from '../reducers/project-changed'; | |
import { | |
LoadingStates, | |
autoUpdateProject, | |
createProject, | |
doneCreatingProject, | |
doneUpdatingProject, | |
getIsAnyCreatingNewState, | |
getIsCreatingCopy, | |
getIsCreatingNew, | |
getIsLoading, | |
getIsManualUpdating, | |
getIsRemixing, | |
getIsShowingWithId, | |
getIsShowingWithoutId, | |
getIsUpdating, | |
projectError | |
} from '../reducers/project-state'; | |
/** | |
* Higher Order Component to provide behavior for saving projects. | |
* @param {React.Component} WrappedComponent the component to add project saving functionality to | |
* @returns {React.Component} WrappedComponent with project saving functionality added | |
* | |
* <ProjectSaverHOC> | |
* <WrappedComponent /> | |
* </ProjectSaverHOC> | |
*/ | |
const ProjectSaverHOC = function (WrappedComponent) { | |
class ProjectSaverComponent extends React.Component { | |
constructor (props) { | |
super(props); | |
bindAll(this, [ | |
'getProjectThumbnail', | |
'leavePageConfirm', | |
'tryToAutoSave' | |
]); | |
} | |
componentWillMount () { | |
if (typeof window === 'object') { | |
// Note: it might be better to use a listener instead of assigning onbeforeunload; | |
// but then it'd be hard to turn this listening off in our tests | |
window.onbeforeunload = e => this.leavePageConfirm(e); | |
} | |
// Allow the GUI consumer to pass in a function to receive a trigger | |
// for triggering thumbnail or whole project saves. | |
// These functions are called with null on unmount to prevent stale references. | |
this.props.onSetProjectThumbnailer(this.getProjectThumbnail); | |
this.props.onSetProjectSaver(this.tryToAutoSave); | |
} | |
componentDidUpdate (prevProps) { | |
if (!this.props.isAnyCreatingNewState && prevProps.isAnyCreatingNewState) { | |
this.reportTelemetryEvent('projectWasCreated'); | |
} | |
if (!this.props.isLoading && prevProps.isLoading) { | |
this.reportTelemetryEvent('projectDidLoad'); | |
} | |
if (this.props.projectChanged && !prevProps.projectChanged) { | |
this.scheduleAutoSave(); | |
} | |
if (this.props.isUpdating && !prevProps.isUpdating) { | |
this.updateProjectToStorage(); | |
} | |
if (this.props.isCreatingNew && !prevProps.isCreatingNew) { | |
this.createNewProjectToStorage(); | |
} | |
if (this.props.isCreatingCopy && !prevProps.isCreatingCopy) { | |
this.createCopyToStorage(); | |
} | |
if (this.props.isRemixing && !prevProps.isRemixing) { | |
this.props.onRemixing(true); | |
this.createRemixToStorage(); | |
} else if (!this.props.isRemixing && prevProps.isRemixing) { | |
this.props.onRemixing(false); | |
} | |
// see if we should "create" the current project on the server | |
// | |
// don't try to create or save immediately after trying to create | |
if (prevProps.isCreatingNew) return; | |
// if we're newly able to create this project, create it! | |
if (this.isShowingCreatable(this.props) && !this.isShowingCreatable(prevProps)) { | |
this.props.onCreateProject(); | |
} | |
// see if we should save/update the current project on the server | |
// | |
// 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 becameAbleToSave = this.props.canSave && !prevProps.canSave; | |
const becameShared = this.props.isShared && !prevProps.isShared; | |
if (this.props.isShowingSaveable && (becameAbleToSave || becameShared)) { | |
this.props.onAutoUpdateProject(); | |
} | |
} | |
componentWillUnmount () { | |
this.clearAutoSaveTimeout(); | |
// Cant unset the beforeunload because it might no longer belong to this component | |
// i.e. if another of this component has been mounted before this one gets unmounted | |
// which happens when going from project to editor view. | |
// window.onbeforeunload = undefined; // eslint-disable-line no-undefined | |
// Remove project thumbnailer function since the components are unmounting | |
this.props.onSetProjectThumbnailer(null); | |
this.props.onSetProjectSaver(null); | |
} | |
leavePageConfirm (e) { | |
if (this.props.projectChanged) { | |
// both methods of returning a value may be necessary for browser compatibility | |
(e || window.event).returnValue = true; | |
return true; | |
} | |
return; // Returning undefined prevents the prompt from coming up | |
} | |
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(); | |
} | |
} | |
isShowingCreatable (props) { | |
return props.canCreateNew && props.isShowingWithoutId; | |
} | |
updateProjectToStorage () { | |
this.props.onShowSavingAlert(); | |
return this.storeProject(this.props.reduxProjectId) | |
.then(() => { | |
// there's an http response object available here, but we don't need to examine | |
// it, because there are no values contained in it that we care about | |
this.props.onUpdatedProject(this.props.loadingState); | |
this.props.onShowSaveSuccessAlert(); | |
}) | |
.catch(err => { | |
// Always show the savingError alert because it gives the | |
// user the chance to download or retry the save manually. | |
this.props.onShowAlert('savingError'); | |
this.props.onProjectError(err); | |
}); | |
} | |
createNewProjectToStorage () { | |
return this.storeProject(null) | |
.then(response => { | |
this.props.onCreatedProject(response.id.toString(), this.props.loadingState); | |
}) | |
.catch(err => { | |
this.props.onShowAlert('creatingError'); | |
this.props.onProjectError(err); | |
}); | |
} | |
createCopyToStorage () { | |
this.props.onShowCreatingCopyAlert(); | |
return this.storeProject(null, { | |
originalId: this.props.reduxProjectId, | |
isCopy: 1, | |
title: this.props.reduxProjectTitle | |
}) | |
.then(response => { | |
this.props.onCreatedProject(response.id.toString(), this.props.loadingState); | |
this.props.onShowCopySuccessAlert(); | |
}) | |
.catch(err => { | |
this.props.onShowAlert('creatingError'); | |
this.props.onProjectError(err); | |
}); | |
} | |
createRemixToStorage () { | |
this.props.onShowCreatingRemixAlert(); | |
return this.storeProject(null, { | |
originalId: this.props.reduxProjectId, | |
isRemix: 1, | |
title: this.props.reduxProjectTitle | |
}) | |
.then(response => { | |
this.props.onCreatedProject(response.id.toString(), this.props.loadingState); | |
this.props.onShowRemixSuccessAlert(); | |
}) | |
.catch(err => { | |
this.props.onShowAlert('creatingError'); | |
this.props.onProjectError(err); | |
}); | |
} | |
/** | |
* storeProject: | |
* @param {number|string|undefined} projectId - defined value will PUT/update; undefined/null will POST/create | |
* @return {Promise} - resolves with json object containing project's existing or new id | |
* @param {?object} requestParams - object of params to add to request body | |
*/ | |
storeProject (projectId, requestParams) { | |
requestParams = requestParams || {}; | |
this.clearAutoSaveTimeout(); | |
// Serialize VM state now before embarking on | |
// the asynchronous journey of storing assets to | |
// the server. This ensures that assets don't update | |
// while in the process of saving a project (e.g. the | |
// serialized project refers to a newer asset than what | |
// we just finished saving). | |
const savedVMState = this.props.vm.toJSON(); | |
return Promise.all(this.props.vm.assets | |
.filter(asset => !asset.clean) | |
.map( | |
asset => storage.store( | |
asset.assetType, | |
asset.dataFormat, | |
asset.data, | |
asset.assetId | |
).then(response => { | |
// Asset servers respond with {status: ok} for successful POSTs | |
if (response.status !== 'ok') { | |
// Errors include a `code` property, e.g. "Forbidden" | |
return Promise.reject(response.code); | |
} | |
asset.clean = true; | |
}) | |
) | |
) | |
.then(() => this.props.onUpdateProjectData(projectId, savedVMState, requestParams)) | |
.then(response => { | |
this.props.onSetProjectUnchanged(); | |
const id = response.id.toString(); | |
if (id && this.props.onUpdateProjectThumbnail) { | |
this.storeProjectThumbnail(id); | |
} | |
this.reportTelemetryEvent('projectDidSave'); | |
return response; | |
}) | |
.catch(err => { | |
log.error(err); | |
throw err; // pass the error up the chain | |
}); | |
} | |
/** | |
* Store a snapshot of the project once it has been saved/created. | |
* Needs to happen _after_ save because the project must have an ID. | |
* @param {!string} projectId - id of the project, must be defined. | |
*/ | |
storeProjectThumbnail (projectId) { | |
try { | |
this.getProjectThumbnail(dataURI => { | |
this.props.onUpdateProjectThumbnail(projectId, dataURItoBlob(dataURI)); | |
}); | |
} catch (e) { | |
log.error('Project thumbnail save error', e); | |
// This is intentionally fire/forget because a failure | |
// to save the thumbnail is not vitally important to the user. | |
} | |
} | |
getProjectThumbnail (callback) { | |
this.props.vm.postIOData('video', {forceTransparentPreview: true}); | |
this.props.vm.renderer.requestSnapshot(dataURI => { | |
this.props.vm.postIOData('video', {forceTransparentPreview: false}); | |
callback(dataURI); | |
}); | |
this.props.vm.renderer.draw(); | |
} | |
/** | |
* Report a telemetry event. | |
* @param {string} event - one of `projectWasCreated`, `projectDidLoad`, `projectDidSave`, `projectWasUploaded` | |
*/ | |
// TODO make a telemetry HOC and move this stuff there | |
reportTelemetryEvent (event) { | |
try { | |
if (this.props.onProjectTelemetryEvent) { | |
const metadata = collectMetadata(this.props.vm, this.props.reduxProjectTitle, this.props.locale); | |
this.props.onProjectTelemetryEvent(event, metadata); | |
} | |
} catch (e) { | |
log.error('Telemetry error', event, e); | |
// This is intentionally fire/forget because a failure | |
// to report telemetry should not block saving | |
} | |
} | |
render () { | |
const { | |
/* eslint-disable no-unused-vars */ | |
autoSaveTimeoutId, | |
autoSaveIntervalSecs, | |
isCreatingCopy, | |
isCreatingNew, | |
projectChanged, | |
isAnyCreatingNewState, | |
isLoading, | |
isManualUpdating, | |
isRemixing, | |
isShowingSaveable, | |
isShowingWithId, | |
isShowingWithoutId, | |
isUpdating, | |
loadingState, | |
onAutoUpdateProject, | |
onCreatedProject, | |
onCreateProject, | |
onProjectError, | |
onRemixing, | |
onSetProjectUnchanged, | |
onSetProjectThumbnailer, | |
onSetProjectSaver, | |
onShowAlert, | |
onShowCopySuccessAlert, | |
onShowRemixSuccessAlert, | |
onShowCreatingCopyAlert, | |
onShowCreatingRemixAlert, | |
onShowSaveSuccessAlert, | |
onShowSavingAlert, | |
onUpdatedProject, | |
onUpdateProjectData, | |
onUpdateProjectThumbnail, | |
reduxProjectId, | |
reduxProjectTitle, | |
setAutoSaveTimeoutId: setAutoSaveTimeoutIdProp, | |
/* eslint-enable no-unused-vars */ | |
...componentProps | |
} = this.props; | |
return ( | |
<WrappedComponent | |
isCreating={isAnyCreatingNewState} | |
{...componentProps} | |
/> | |
); | |
} | |
} | |
ProjectSaverComponent.propTypes = { | |
autoSaveIntervalSecs: PropTypes.number.isRequired, | |
autoSaveTimeoutId: PropTypes.number, | |
canCreateNew: PropTypes.bool, | |
canSave: PropTypes.bool, | |
isAnyCreatingNewState: PropTypes.bool, | |
isCreatingCopy: PropTypes.bool, | |
isCreatingNew: PropTypes.bool, | |
isLoading: PropTypes.bool, | |
isManualUpdating: PropTypes.bool, | |
isRemixing: PropTypes.bool, | |
isShared: PropTypes.bool, | |
isShowingSaveable: PropTypes.bool, | |
isShowingWithId: PropTypes.bool, | |
isShowingWithoutId: PropTypes.bool, | |
isUpdating: PropTypes.bool, | |
loadingState: PropTypes.oneOf(LoadingStates), | |
locale: PropTypes.string.isRequired, | |
onAutoUpdateProject: PropTypes.func, | |
onCreateProject: PropTypes.func, | |
onCreatedProject: PropTypes.func, | |
onProjectError: PropTypes.func, | |
onProjectTelemetryEvent: PropTypes.func, | |
onRemixing: PropTypes.func, | |
onSetProjectSaver: PropTypes.func.isRequired, | |
onSetProjectThumbnailer: PropTypes.func.isRequired, | |
onSetProjectUnchanged: PropTypes.func.isRequired, | |
onShowAlert: PropTypes.func, | |
onShowCopySuccessAlert: PropTypes.func, | |
onShowCreatingCopyAlert: PropTypes.func, | |
onShowCreatingRemixAlert: PropTypes.func, | |
onShowRemixSuccessAlert: PropTypes.func, | |
onShowSaveSuccessAlert: PropTypes.func, | |
onShowSavingAlert: PropTypes.func, | |
onUpdateProjectData: PropTypes.func.isRequired, | |
onUpdateProjectThumbnail: PropTypes.func, | |
onUpdatedProject: PropTypes.func, | |
projectChanged: PropTypes.bool, | |
reduxProjectId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), | |
reduxProjectTitle: PropTypes.string, | |
setAutoSaveTimeoutId: PropTypes.func.isRequired, | |
vm: PropTypes.instanceOf(VM).isRequired | |
}; | |
ProjectSaverComponent.defaultProps = { | |
autoSaveIntervalSecs: 120, | |
onRemixing: () => {}, | |
onSetProjectThumbnailer: () => {}, | |
onSetProjectSaver: () => {}, | |
onUpdateProjectData: saveProjectToServer | |
}; | |
const mapStateToProps = (state, ownProps) => { | |
const loadingState = state.scratchGui.projectState.loadingState; | |
const isShowingWithId = getIsShowingWithId(loadingState); | |
return { | |
autoSaveTimeoutId: state.scratchGui.timeout.autoSaveTimeoutId, | |
isAnyCreatingNewState: getIsAnyCreatingNewState(loadingState), | |
isLoading: getIsLoading(loadingState), | |
isCreatingCopy: getIsCreatingCopy(loadingState), | |
isCreatingNew: getIsCreatingNew(loadingState), | |
isRemixing: getIsRemixing(loadingState), | |
isShowingSaveable: ownProps.canSave && isShowingWithId, | |
isShowingWithId: isShowingWithId, | |
isShowingWithoutId: getIsShowingWithoutId(loadingState), | |
isUpdating: getIsUpdating(loadingState), | |
isManualUpdating: getIsManualUpdating(loadingState), | |
loadingState: loadingState, | |
locale: state.locales.locale, | |
projectChanged: state.scratchGui.projectChanged, | |
reduxProjectId: state.scratchGui.projectState.projectId, | |
reduxProjectTitle: state.scratchGui.projectTitle, | |
vm: state.scratchGui.vm | |
}; | |
}; | |
const mapDispatchToProps = dispatch => ({ | |
onAutoUpdateProject: () => dispatch(autoUpdateProject()), | |
onCreatedProject: (projectId, loadingState) => dispatch(doneCreatingProject(projectId, loadingState)), | |
onCreateProject: () => dispatch(createProject()), | |
onProjectError: error => dispatch(projectError(error)), | |
onSetProjectUnchanged: () => dispatch(setProjectUnchanged()), | |
onShowAlert: alertType => dispatch(showStandardAlert(alertType)), | |
onShowCopySuccessAlert: () => showAlertWithTimeout(dispatch, 'createCopySuccess'), | |
onShowRemixSuccessAlert: () => showAlertWithTimeout(dispatch, 'createRemixSuccess'), | |
onShowCreatingCopyAlert: () => showAlertWithTimeout(dispatch, 'creatingCopy'), | |
onShowCreatingRemixAlert: () => showAlertWithTimeout(dispatch, 'creatingRemix'), | |
onShowSaveSuccessAlert: () => showAlertWithTimeout(dispatch, 'saveSuccess'), | |
onShowSavingAlert: () => showAlertWithTimeout(dispatch, 'saving'), | |
onUpdatedProject: loadingState => dispatch(doneUpdatingProject(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( | |
{}, stateProps, dispatchProps, ownProps | |
); | |
return connect( | |
mapStateToProps, | |
mapDispatchToProps, | |
mergeProps | |
)(ProjectSaverComponent); | |
}; | |
export { | |
ProjectSaverHOC as default | |
}; | |