diff --git a/src/components/menu-bar/menu-bar.jsx b/src/components/menu-bar/menu-bar.jsx index 3702f5996..dda83cd37 100644 --- a/src/components/menu-bar/menu-bar.jsx +++ b/src/components/menu-bar/menu-bar.jsx @@ -21,6 +21,7 @@ import DeletionRestorer from '../../containers/deletion-restorer.jsx'; import TurboMode from '../../containers/turbo-mode.jsx'; import {openTipsLibrary} from '../../reducers/modals'; +import {ProjectState, stepTowardsNewProject} from '../../reducers/project-id'; import {setPlayer} from '../../reducers/mode'; import { openAccountMenu, @@ -131,6 +132,21 @@ class MenuBar extends React.Component { ]); this.state = {projectSaveInProgress: false}; } + componentWillReceiveProps (newProps) { + // if we're no longer showing the project (loading, or whatever), close menus + if (!newProps.isShowingProject && this.props.isShowingProject) { + this.props.onRequestCloseFile(); + this.props.onRequestCloseEdit(); + } + // if we're now saving, show save in progress + if (newProps.isSavingWithId && !this.props.isSavingWithId) { + this.setState({projectSaveInProgress: true}); + } + // if we're no longer saving, don't show save in progress + if (!newProps.isSavingWithId && this.props.isSavingWithId) { + this.setState({projectSaveInProgress: false}); + } + } handleLanguageMouseUp (e) { if (!this.props.languageMenuOpen) { this.props.onClickLanguage(e); @@ -142,23 +158,27 @@ class MenuBar extends React.Component { this.props.onRequestCloseEdit(); }; } + // NOTE: not using updateFun currently + // NOTE: need some sort of reducer or some place to do saves, besides a component. + // then it can listen for state changes into saving, and do them. handleNewProject (updateFun) { return () => { - this.props.onRequestCloseFile(); - if (this.props.userOwnsProject) { - this.setState({projectSaveInProgress: true}, - () => { - updateFun().then(() => { - this.props.onRequestNewProject(() => { - this.setState({projectSaveInProgress: false}); - }); - }); - } - ); - } else { - this.props.onRequestNewProject(); - } + this.props.stepTowardsNewProject(); }; + // if (this.props.userOwnsProject) { + // this.setState({projectSaveInProgress: true}, + // () => { + // updateFun().then(() => { + // this.props.onRequestNewProject(() => { + // this.setState({projectSaveInProgress: false}); + // }); + // }); + // } + // ); + // } else { + // this.props.onRequestNewProject(); + // } + // }; } handleUpdateProject (updateFun) { return () => { @@ -284,11 +304,12 @@ class MenuBar extends React.Component { place={this.props.isRtl ? 'left' : 'right'} onRequestClose={this.props.onRequestCloseFile} > - - {(saveProject, updateProject, createProject) => ( + {/* + {(downloadProject, updateProject, createProject) => ( */} - )} - + {/* )} + */} - {(saveProject, updateProject) => ( + {(downloadProject, updateProject) => ( this.props.canUpdateProject && this.props.userOwnsProject ? ( {saveNowMessage} @@ -345,9 +366,9 @@ class MenuBar extends React.Component { )} - {saveProject => ( + {downloadProject => ( ({ - canUpdateProject: typeof (state.session && state.session.session && state.session.session.user) !== 'undefined', - accountMenuOpen: accountMenuOpen(state), - fileMenuOpen: fileMenuOpen(state), - editMenuOpen: editMenuOpen(state), - isRtl: state.locales.isRtl, - languageMenuOpen: languageMenuOpen(state), - loginMenuOpen: loginMenuOpen(state), - sessionExists: state.session && typeof state.session.session !== 'undefined', - username: state.session && state.session.session && state.session.session.user ? - state.session.session.user.username : null -}); +const mapStateToProps = state => { + const projectState = state.scratchGui.projectId.projectState; + const user = state.session && state.session.session && state.session.session.user; + return { + canUpdateProject: typeof user !== 'undefined', // NOTE: this is wrong, correct?? + accountMenuOpen: accountMenuOpen(state), + fileMenuOpen: fileMenuOpen(state), + editMenuOpen: editMenuOpen(state), + isRtl: state.locales.isRtl, + isSavingWithId: projectState === ProjectState.SAVING_WITH_ID || + projectState === ProjectState.SAVING_WITH_ID_BEFORE_NEW, + isShowingProject: projectState === ProjectState.SHOWING_WITH_ID || + projectState === ProjectState.SHOWING_FILE_UPLOAD || + projectState === ProjectState.SHOWING_NEW_DEFAULT, + languageMenuOpen: languageMenuOpen(state), + loginMenuOpen: loginMenuOpen(state), + sessionExists: state.session && typeof state.session.session !== 'undefined', + username: user ? user.username : null + }; +}; const mapDispatchToProps = dispatch => ({ onOpenTipLibrary: () => dispatch(openTipsLibrary()), @@ -672,7 +704,8 @@ const mapDispatchToProps = dispatch => ({ onRequestCloseLanguage: () => dispatch(closeLanguageMenu()), onClickLogin: () => dispatch(openLoginMenu()), onRequestCloseLogin: () => dispatch(closeLoginMenu()), - onSeeCommunity: () => dispatch(setPlayer(true)) + onSeeCommunity: () => dispatch(setPlayer(true)), + stepTowardsNewProject: () => dispatch(stepTowardsNewProject()) }); export default injectIntl(connect( diff --git a/src/containers/gui.jsx b/src/containers/gui.jsx index 0d2bf5d2b..b13ae7d0b 100644 --- a/src/containers/gui.jsx +++ b/src/containers/gui.jsx @@ -20,15 +20,16 @@ import { } from '../reducers/modals'; import ProjectLoaderHOC from '../lib/project-loader-hoc.jsx'; +import ProjectSaverHOC from '../lib/project-saver-hoc.jsx'; import vmListenerHOC from '../lib/vm-listener-hoc.jsx'; import vmManagerHOC from '../lib/vm-manager-hoc.jsx'; import GUIComponent from '../components/gui/gui.jsx'; class GUI extends React.Component { - constructor (props) { - super(props); - } + // constructor (props) { + // super(props); + // } componentDidMount () { if (this.props.projectTitle) { this.props.onUpdateReduxProjectTitle(this.props.projectTitle); @@ -86,6 +87,7 @@ GUI.propTypes = { onUpdateProjectTitle: PropTypes.func, onUpdateReduxProjectTitle: PropTypes.func, previewInfoVisible: PropTypes.bool, + projectData: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), projectHost: PropTypes.string, projectTitle: PropTypes.string }; @@ -129,6 +131,7 @@ const ConnectedGUI = connect( const WrappedGui = compose( ErrorBoundaryHOC('Top Level App'), ProjectLoaderHOC, + ProjectSaverHOC, vmListenerHOC, vmManagerHOC )(ConnectedGUI); diff --git a/src/containers/project-loader.jsx b/src/containers/project-loader.jsx index 485db8dc1..ff8377030 100644 --- a/src/containers/project-loader.jsx +++ b/src/containers/project-loader.jsx @@ -1,3 +1,5 @@ +// NOTE: rename this or project-loader-hoc to make clear they describe mutually exclusive functionality and states? + import bindAll from 'lodash.bindall'; import PropTypes from 'prop-types'; import React from 'react'; @@ -7,6 +9,7 @@ import {defineMessages, injectIntl, intlShape} from 'react-intl'; import analytics from '../lib/analytics'; import log from '../lib/log'; import {setProjectTitle} from '../reducers/project-title'; +import {startLoadingFileUpload, doneLoadingFileUpload} from '../reducers/project-id'; import { openLoadingProject, @@ -134,9 +137,15 @@ const mapStateToProps = state => ({ }); const mapDispatchToProps = dispatch => ({ - closeLoadingState: () => dispatch(closeLoadingProject()), + closeLoadingState: () => { + dispatch(doneLoadingFileUpload()); + dispatch(closeLoadingProject()); + }, onSetReduxProjectTitle: title => dispatch(setProjectTitle(title)), - openLoadingState: () => dispatch(openLoadingProject()) + openLoadingState: () => { + dispatch(openLoadingProject()); + dispatch(startLoadingFileUpload()); + } }); export default connect( diff --git a/src/containers/project-saver.jsx b/src/containers/project-saver.jsx index 53e854b1e..f49647383 100644 --- a/src/containers/project-saver.jsx +++ b/src/containers/project-saver.jsx @@ -2,20 +2,22 @@ import bindAll from 'lodash.bindall'; import PropTypes from 'prop-types'; import React from 'react'; import {connect} from 'react-redux'; -import storage from '../lib/storage'; +// import storage from '../lib/storage'; import {projectTitleInitialState} from '../reducers/project-title'; +import {ProjectState} from '../reducers/project-id'; +import {doStoreProject} from '../reducers/vm'; /** - * Project saver component passes a saveProject function to its child. + * Project saver component passes a downloadProject function to its child. * It expects this child to be a function with the signature - * function (saveProject, props) {} + * function (downloadProject, props) {} * The component can then be used to attach project saving functionality * to any other component: * - * {(saveProject, props) => ( + * {(downloadProject, props) => ( * * )} @@ -26,13 +28,12 @@ class ProjectSaver extends React.Component { bindAll(this, [ 'createProject', 'updateProject', - 'saveProject', - 'doStoreProject' + 'downloadProject' ]); } - saveProject () { - const saveLink = document.createElement('a'); - document.body.appendChild(saveLink); + downloadProject () { + const downloadLink = document.createElement('a'); + document.body.appendChild(downloadLink); this.props.saveProjectSb3().then(content => { if (this.props.onSaveFinished) { @@ -45,43 +46,49 @@ class ProjectSaver extends React.Component { } const url = window.URL.createObjectURL(content); - saveLink.href = url; - saveLink.download = this.props.projectFilename; - saveLink.click(); + downloadLink.href = url; + downloadLink.download = this.props.projectFilename; + downloadLink.click(); window.URL.revokeObjectURL(url); - document.body.removeChild(saveLink); + document.body.removeChild(downloadLink); }); } - doStoreProject (id) { - return this.props.saveProjectSb3() - .then(content => { - if (this.props.onSaveFinished) { - this.props.onSaveFinished(); - } - const assetType = storage.AssetType.Project; - const dataFormat = storage.DataFormat.SB3; - const body = new FormData(); - body.append('sb3_file', content, 'sb3_file'); - return storage.store( - assetType, - dataFormat, - body, - id - ); - }); - } + // doStoreProject (id) { + // return this.props.saveProjectSb3() + // .then(content => { + // if (this.props.onSaveFinished) { + // this.props.onSaveFinished(); + // } + // const assetType = storage.AssetType.Project; + // const dataFormat = storage.DataFormat.SB3; + // const body = new FormData(); + // body.append('sb3_file', content, 'sb3_file'); + // return storage.store( + // assetType, + // dataFormat, + // body, + // id + // ); + // }); + // } createProject () { - return this.doStoreProject(); + if (this.props.isShowingWithId) { + return doStoreProject(null, this.props.onSaveFinished); + } + return Promise.reject(); } updateProject () { - return this.doStoreProject(this.props.projectId); + if (this.props.isShowingWithId) { + return doStoreProject(this.props.projectId, this.props.onSaveFinished); + } + return Promise.reject(); } render () { const { children } = this.props; return children( - this.saveProject, + this.downloadProject, this.updateProject, this.createProject ); @@ -98,17 +105,22 @@ const getProjectFilename = (curTitle, defaultTitle) => { ProjectSaver.propTypes = { children: PropTypes.func, + isShowingWithId: PropTypes.bool, onSaveFinished: PropTypes.func, projectFilename: PropTypes.string, projectId: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), saveProjectSb3: PropTypes.func }; -const mapStateToProps = state => ({ - saveProjectSb3: state.scratchGui.vm.saveProjectSb3.bind(state.scratchGui.vm), - projectFilename: getProjectFilename(state.scratchGui.projectTitle, projectTitleInitialState), - projectId: state.scratchGui.projectId -}); +const mapStateToProps = state => { + const projectState = state.scratchGui.projectId.projectState; + return { + isShowingWithId: projectState === ProjectState.SHOWING_WITH_ID, + saveProjectSb3: state.scratchGui.vm.saveProjectSb3.bind(state.scratchGui.vm), + projectFilename: getProjectFilename(state.scratchGui.projectTitle, projectTitleInitialState), + projectId: state.scratchGui.projectId.projectId + }; +}; export default connect( mapStateToProps, diff --git a/src/lib/hash-parser-hoc.jsx b/src/lib/hash-parser-hoc.jsx index 3b9a84054..7768e2d81 100644 --- a/src/lib/hash-parser-hoc.jsx +++ b/src/lib/hash-parser-hoc.jsx @@ -1,7 +1,9 @@ -import React from 'react'; import bindAll from 'lodash.bindall'; +import React from 'react'; +import PropTypes from 'prop-types'; +import {connect} from 'react-redux'; -import {defaultProjectId} from '../reducers/project-id'; +import {ProjectState, defaultProjectId, setHashProjectId} from '../reducers/project-id'; /* Higher Order Component to get the project id from location.hash * @param {React.Component} WrappedComponent component to receive projectData prop @@ -15,46 +17,69 @@ const HashParserHOC = function (WrappedComponent) { 'handleHashChange', 'handleRequestNewProject' ]); - this.state = { - projectId: null - }; } componentDidMount () { window.addEventListener('hashchange', this.handleHashChange); this.handleHashChange(); } + componentWillReceiveProps (nextProps) { + // if we are newly loading a non-hash project... + if (nextProps.isLoadingNonHashProject && !this.props.isLoadingNonHashProject) { + // ...clear the hash from the url + history.pushState('new-project', 'new-project', + window.location.pathname + window.location.search); + } + } componentWillUnmount () { window.removeEventListener('hashchange', this.handleHashChange); } handleHashChange () { const hashMatch = window.location.hash.match(/#(\d+)/); - const projectId = hashMatch === null ? defaultProjectId : hashMatch[1]; - if (projectId !== this.state.projectId) { - this.setState({projectId: projectId}); + const hashProjectId = hashMatch === null ? defaultProjectId : hashMatch[1]; + if (hashProjectId !== this.props.reduxProjectId) { + this.props.setHashProjectId(hashProjectId); } } - handleRequestNewProject (callback) { - // unset the projectId - this.setState({projectId: defaultProjectId}, () => { - // strip the hash tag from the url - history.pushState('new-project', 'new-project', - window.location.pathname + window.location.search); - if (callback) callback(defaultProjectId); - }); - } + // handleRequestNewProject (callback) { + // // unset the projectId + // this.setState({projectId: defaultProjectId}, () => { + // // strip the hash tag from the url + // // history.pushState('new-project', 'new-project', + // // window.location.pathname + window.location.search); + // if (callback) callback(defaultProjectId); + // }); + // } render () { return ( ); } } - - return HashParserComponent; + HashParserComponent.propTypes = { + isLoadingNonHashProject: PropTypes.bool, + reduxProjectId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + setHashProjectId: PropTypes.func + }; + const mapStateToProps = state => { + const projectState = state.scratchGui.projectId.projectState; + return { + isLoadingNonHashProject: projectState === ProjectState.LOADING_FILE_UPLOAD || + projectState === ProjectState.LOADING_NEW_DEFAULT, + reduxProjectId: state.scratchGui.projectId.projectId + }; + }; + const mapDispatchToProps = dispatch => ({ + setHashProjectId: projectId => dispatch(setHashProjectId(projectId)) + }); + return connect( + mapStateToProps, + mapDispatchToProps + )(HashParserComponent); }; export { diff --git a/src/lib/project-loader-hoc.jsx b/src/lib/project-loader-hoc.jsx index 237c014be..f1350ad07 100644 --- a/src/lib/project-loader-hoc.jsx +++ b/src/lib/project-loader-hoc.jsx @@ -2,8 +2,9 @@ import React from 'react'; import PropTypes from 'prop-types'; import {intlShape, injectIntl} from 'react-intl'; import bindAll from 'lodash.bindall'; +import {connect} from 'react-redux'; -import {defaultProjectId} from '../reducers/project-id'; +import {ProjectState, defaultProjectId, setInitialProjectId} from '../reducers/project-id'; import analytics from './analytics'; import log from './log'; @@ -39,9 +40,12 @@ const ProjectLoaderHOC = function (WrappedComponent) { props.projectId !== null && typeof props.projectId !== 'undefined' ) { - this.updateProject(props.projectId); + this.props.setInitialProjectId(props.projectId); + // NOTE: may need to reenable this + // this.updateProject(props.projectId); } } + // NOTE: try componentWillReceiveProps instead? componentWillUpdate (nextProps) { if (this.props.projectHost !== nextProps.projectHost) { storage.setProjectHost(nextProps.projectHost); @@ -49,23 +53,26 @@ const ProjectLoaderHOC = function (WrappedComponent) { if (this.props.assetHost !== nextProps.assetHost) { storage.setAssetHost(nextProps.assetHost); } - if (this.props.projectId !== nextProps.projectId) { - this.setState({fetchingProject: true}, () => { - this.updateProject(nextProps.projectId); - }); + if (nextProps.isLoadingProjectWithId && !this.props.isLoadingProjectWithId) { + this.updateProject(nextProps.reduxProjectId); } + // if (this.props.projectId !== nextProps.projectId) { + // this.setState({fetchingProject: true}, () => { + // this.updateProject(nextProps.projectId); + // }); + // } } - handleRequestNewProject (callback) { - // pass the request up the chain - this.props.onRequestNewProject(newProjectId => { - // now that parents have had chance to act and change the projectId, - // update the metadata using the projectId -- even if it is the - // same projectId as before. - this.updateProject(newProjectId).then(() => { - if (callback) callback(newProjectId); // pass the callback down the chain - }); - }); - } + // handleRequestNewProject (callback) { + // // pass the request up the chain + // this.props.onRequestNewProject(newProjectId => { + // // now that parents have had chance to act and change the projectId, + // // update the metadata using the projectId -- even if it is the + // // same projectId as before. + // // this.updateProject(newProjectId).then(() => { + // if (callback) callback(newProjectId); // pass the callback down the chain + // // }); + // }); + // } // NOTE: should we instesad have updateProject return a promise, and resolve those // in the functions that call updatePromise to improve the execution sequence? updateProject (projectId) { @@ -116,17 +123,36 @@ const ProjectLoaderHOC = function (WrappedComponent) { ProjectLoaderComponent.propTypes = { assetHost: PropTypes.string, intl: intlShape.isRequired, + isLoadingProjectWithId: PropTypes.bool, onRequestNewProject: PropTypes.func, projectHost: PropTypes.string, - projectId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]) + projectId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + reduxProjectId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + setInitialProjectId: PropTypes.func }; ProjectLoaderComponent.defaultProps = { + // NOTE: shouldn't these settings be moved into webpack, like API_HOST? assetHost: 'https://assets.scratch.mit.edu', - projectHost: 'https://projects.scratch.mit.edu', - projectId: defaultProjectId + projectHost: 'https://projects.scratch.mit.edu' + // NOTE: disabled because either embed or hash parser should provide val + // projectId: defaultProjectId }; - return injectIntl(ProjectLoaderComponent); + const mapStateToProps = state => { + const projectState = state.scratchGui.projectId.projectState; + return { + isLoadingProjectWithId: projectState === ProjectState.LOADING_WITH_ID || + projectState === ProjectState.LOADING_NEW_DEFAULT, + reduxProjectId: state.scratchGui.projectId.projectId + }; + }; + const mapDispatchToProps = dispatch => ({ + setInitialProjectId: projectId => dispatch(setInitialProjectId(projectId)) + }); + return injectIntl(connect( + mapStateToProps, + mapDispatchToProps + )(ProjectLoaderComponent)); }; export { diff --git a/src/lib/project-metadata-hoc.jsx b/src/lib/project-metadata-hoc.jsx index e06d2d8b6..4216eb53b 100644 --- a/src/lib/project-metadata-hoc.jsx +++ b/src/lib/project-metadata-hoc.jsx @@ -2,11 +2,11 @@ import React from 'react'; import defaultsdeep from 'lodash.defaultsdeep'; import bindAll from 'lodash.bindall'; import PropTypes from 'prop-types'; -// import {connect} from 'react-redux'; +import {connect} from 'react-redux'; import {intlShape, injectIntl} from 'react-intl'; import xhr from 'xhr'; -import {defaultProjectId} from '../reducers/project-id'; +import {ProjectState, defaultProjectId} from '../reducers/project-id'; import {defaultProjectTitleMessages} from '../reducers/project-title'; const api = (options, token, callback) => { @@ -50,29 +50,32 @@ const ProjectMetaDataHOC = function (WrappedComponent) { ]); this.updateProjectMetaData(props.projectId); } - componentWillUpdate (nextProps) { - // if projectId has changed, and not just from one default value to - // another equivalent value, look up project metadata - if (this.props.projectId !== nextProps.projectId && - ((this.props.projectId !== defaultProjectId && - this.props.projectId !== null && - typeof this.props.projectId !== 'undefined') || - (nextProps.projectId !== defaultProjectId && - nextProps.projectId !== null && - typeof nextProps.projectId !== 'undefined'))) { + componentWillReceiveProps (nextProps) { + if (nextProps.isLoadingProjectWithId && !this.props.isLoadingProjectWithId) { this.updateProjectMetaData(nextProps.projectId); } + // // if projectId has changed, and not just from one default value to + // // another equivalent value, look up project metadata + // if (this.props.projectId !== nextProps.projectId && + // ((this.props.projectId !== defaultProjectId && + // this.props.projectId !== null && + // typeof this.props.projectId !== 'undefined') || + // (nextProps.projectId !== defaultProjectId && + // nextProps.projectId !== null && + // typeof nextProps.projectId !== 'undefined'))) { + // this.updateProjectMetaData(nextProps.projectId); + // } } - handleRequestNewProject (callback) { - // pass the request up the chain - this.props.onRequestNewProject(newProjectId => { - // now that parents have had chance to act and change the projectId, - // update the metadata using the projectId -- even if it is the - // same projectId as before. - this.updateProjectMetaData(newProjectId); - if (callback) callback(newProjectId); // pass the callback down the chain - }); - } + // handleRequestNewProject (callback) { + // // pass the request up the chain + // this.props.onRequestNewProject(newProjectId => { + // // now that parents have had chance to act and change the projectId, + // // update the metadata using the projectId -- even if it is the + // // same projectId as before. + // // this.updateProjectMetaData(newProjectId); + // if (callback) callback(newProjectId); // pass the callback down the chain + // }); + // } updateProjectMetaData (projectId) { if (projectId === defaultProjectId || projectId === null || typeof projectId === 'undefined') { // if project is default, use static data @@ -108,20 +111,26 @@ const ProjectMetaDataHOC = function (WrappedComponent) { ProjectMetaDataComponent.propTypes = { intl: intlShape.isRequired, + isLoadingProjectWithId: PropTypes.bool, onRequestNewProject: PropTypes.func, onUpdateProjectTitle: PropTypes.func, - projectId: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), - token: PropTypes.string + projectId: PropTypes.oneOfType([PropTypes.number, PropTypes.string]) + // token: PropTypes.string }; - // const mapStateToProps = state => ({ - // token: state.session.session && state.session.session.user && state.session.session.user.token - // }); - // - // const mapDispatchToProps = () => ({}); - // - // return injectIntl(connect(mapStateToProps, mapDispatchToProps)(ProjectMetaDataComponent)); - return injectIntl(ProjectMetaDataComponent); + const mapStateToProps = state => { + const projectState = state.scratchGui.projectId.projectState; + return { + isLoadingProjectWithId: projectState === ProjectState.LOADING_WITH_ID || + projectState === ProjectState.LOADING_NEW_DEFAULT, + projectId: state.scratchGui.projectId.projectId + // token: state.session.session && state.session.session.user && state.session.session.user.token + }; + }; + + const mapDispatchToProps = () => ({}); + + return injectIntl(connect(mapStateToProps, mapDispatchToProps)(ProjectMetaDataComponent)); }; export { diff --git a/src/lib/project-saver-hoc.jsx b/src/lib/project-saver-hoc.jsx new file mode 100644 index 000000000..50f9707d9 --- /dev/null +++ b/src/lib/project-saver-hoc.jsx @@ -0,0 +1,70 @@ +// NOTE: maybe this whole HOC can be moved inside project-id reducer? + +import React from 'react'; +import PropTypes from 'prop-types'; +import bindAll from 'lodash.bindall'; +import {connect} from 'react-redux'; + +import {ProjectState, doneSavingWithId} from '../reducers/project-id'; +import {doStoreProject} from '../reducers/vm'; +// import storage from './storage'; + +/* Higher Order Component to provide behavior for saving projects. + */ +const ProjectSaverHOC = function (WrappedComponent) { + class ProjectSaverComponent extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + ]); + this.state = { + }; + } + componentWillReceiveProps (nextProps) { + if (nextProps.isSavingWithId && !this.props.isSavingWithId) { + this.doStoreProject(nextProps.reduxProjectId).then(() => { + this.doneSavingWithId(); + }); + } + } + render () { + const { + /* eslint-disable no-unused-vars */ + /* eslint-enable no-unused-vars */ + ...componentProps + } = this.props; + return ( + + ); + } + } + ProjectSaverComponent.propTypes = { + doStoreProject: PropTypes.func, + doneSavingWithId: PropTypes.func, + isSavingWithId: PropTypes.bool, + reduxProjectId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]) + }; + + const mapStateToProps = state => { + const projectState = state.scratchGui.projectId.projectState; + return { + isSavingWithId: projectState === ProjectState.SAVING_WITH_ID || + projectState === ProjectState.SAVING_WITH_ID_BEFORE_NEW, + reduxProjectId: state.scratchGui.projectId.projectId + }; + }; + const mapDispatchToProps = dispatch => ({ + doStoreProject: projectId => dispatch(doStoreProject(projectId)), + doneSavingWithId: projectId => dispatch(doneSavingWithId(projectId)) + }); + return connect( + mapStateToProps, + mapDispatchToProps + )(ProjectSaverComponent); +}; + +export { + ProjectSaverHOC as default +}; diff --git a/src/lib/vm-manager-hoc.jsx b/src/lib/vm-manager-hoc.jsx index 2e0b69dda..197badcec 100644 --- a/src/lib/vm-manager-hoc.jsx +++ b/src/lib/vm-manager-hoc.jsx @@ -1,10 +1,12 @@ import bindAll from 'lodash.bindall'; import PropTypes from 'prop-types'; import React from 'react'; +import {connect} from 'react-redux'; + import VM from 'scratch-vm'; import AudioEngine from 'scratch-audio'; -import {connect} from 'react-redux'; +import {ProjectState} from '../reducers/project-id'; /* * Higher Order Component to manage events emitted by the VM @@ -34,11 +36,14 @@ const vmManagerHOC = function (WrappedComponent) { }); } componentWillReceiveProps (nextProps) { - if (this.props.projectData !== nextProps.projectData) { - this.setState({isLoading: true}, () => { - this.loadProject(false); - }); + if (nextProps.isLoadingProjectWithId && !this.props.isLoadingProjectWithId) { + this.loadProject(false); } + // if (this.props.projectData !== nextProps.projectData) { + // this.setState({isLoading: true}, () => { + // this.loadProject(false); + // }); + // } } // NOTE: keep working here. problem is that we ONLY want to load the project // here when the old project id was also default... otherwise, we load twice!!! @@ -58,17 +63,17 @@ const vmManagerHOC = function (WrappedComponent) { this.setState({loadingError: true, errorMessage: e}); }); } - handleRequestNewProject (callback) { - // pass the request up the chain - this.props.onRequestNewProject(newProjectId => { - // now that parents have had chance to act and change the projectId, - this.setState({isLoading: true}, () => { - this.loadProject(false).then(() => { - if (callback) callback(newProjectId); // pass the callback down the chain - }); - }); - }); - } + // handleRequestNewProject (callback) { + // // pass the request up the chain + // this.props.onRequestNewProject(newProjectId => { + // // now that parents have had chance to act and change the projectId, + // this.setState({isLoading: true}, () => { + // // this.loadProject(false).then(() => { + // if (callback) callback(newProjectId); // pass the callback down the chain + // // }); + // }); + // }); + // } render () { const { /* eslint-disable no-unused-vars */ @@ -92,17 +97,24 @@ const vmManagerHOC = function (WrappedComponent) { } VMManager.propTypes = { + isLoadingProjectWithId: PropTypes.bool, onRequestNewProject: PropTypes.func, projectData: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), + projectId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), vm: PropTypes.instanceOf(VM).isRequired }; - const mapStateToProps = state => ({ - vm: state.scratchGui.vm - }); + const mapStateToProps = state => { + const projectState = state.scratchGui.projectId.projectState; + return { + isLoadingProjectWithId: projectState === ProjectState.LOADING_WITH_ID || + projectState === ProjectState.LOADING_NEW_DEFAULT, + projectId: state.scratchGui.projectId.projectId, + vm: state.scratchGui.vm + }; + }; - const mapDispatchToProps = dispatch => ({ - }); + const mapDispatchToProps = () => ({}); return connect( mapStateToProps, diff --git a/src/reducers/project-id.js b/src/reducers/project-id.js index 7330f761c..7aaab7761 100644 --- a/src/reducers/project-id.js +++ b/src/reducers/project-id.js @@ -1,16 +1,67 @@ -const SET_PROJECT_ID = 'scratch-gui/project-id/SET_PROJECT_ID'; +import keyMirror from 'keymirror'; -const initialState = null; +const SET_PROJECT_ID = 'scratch-gui/project-id/SET_PROJECT_ID'; +const TRANSITION_STATE = 'scratch-gui/project-id/TRANSITION_STATE'; const defaultProjectId = 0; // hardcoded id of default project +const ProjectState = keyMirror({ + NOT_LOADED: null, + ERROR: null, + LOADING_WITH_ID: null, + LOADING_FILE_UPLOAD: null, + LOADING_NEW_DEFAULT: null, + SHOWING_WITH_ID: null, + SHOWING_FILE_UPLOAD: null, + SHOWING_NEW_DEFAULT: null, + SAVING_WITH_ID: null, + SAVING_WITH_ID_BEFORE_NEW: null +}); + +const initialState = { + projectId: null, + projectState: ProjectState.NOT_LOADED +}; + const reducer = function (state, action) { if (typeof state === 'undefined') state = initialState; switch (action.type) { + // NOTE: delete this action entirely? case SET_PROJECT_ID: - // debugger; - return action.id; + return Object.assign({}, state, {projectId: action.id}); + case TRANSITION_STATE: + // "from" state must be either an array that includes current project state, + // or null/undefined + if (state.projectState in action.transitions) { + const resultState = action.transitions[state.projectState]; + switch (resultState) { + case ProjectState.LOADING_WITH_ID: + return Object.assign({}, state, { + projectId: action.id, + projectState: resultState + }); + case ProjectState.LOADING_FILE_UPLOAD: + return Object.assign({}, state, { + projectId: null, + projectState: resultState + }); + case ProjectState.LOADING_NEW_DEFAULT: + return Object.assign({}, state, { + projectId: defaultProjectId, + projectState: resultState + }); + default: + return Object.assign({}, state, {projectState: resultState}); + } + } + // default to requiring transitions to successfully match current state + if (action.require || typeof action.require === 'undefined') { + // NOTE: how to throw error right here? + console.log(`Error: tried to transition from state ${state.projectState} to states...`); + console.log(action.transitions); + } + break; default: return state; } @@ -23,9 +74,80 @@ const setProjectId = function (id) { }; }; +// "initial" here refers to being invoked, usually embedded in another app, with a projectId property +const setInitialProjectId = id => ({ + type: TRANSITION_STATE, + transitions: { + [ProjectState.NOT_LOADED]: ProjectState.LOADING_WITH_ID + }, + id: id +}); + +const setHashProjectId = id => { + if (id === defaultProjectId || id === null || typeof id === 'undefined') { + // it's ok if nothing matches, we may have just stripped the hash. + // only transition based on seeing no hash if this is the initial load. + return { + type: TRANSITION_STATE, + transitions: { + [ProjectState.NOT_LOADED]: ProjectState.LOADING_NEW_DEFAULT + }, + require: false + }; + } + return { + type: TRANSITION_STATE, + transitions: { + [ProjectState.NOT_LOADED]: ProjectState.LOADING_WITH_ID + }, + id: id + }; +}; + +const startLoadingFileUpload = () => ({ + type: TRANSITION_STATE, + transitions: { + [ProjectState.NOT_LOADED]: ProjectState.LOADING_FILE_UPLOAD, + [ProjectState.SHOWING_WITH_ID]: ProjectState.LOADING_FILE_UPLOAD, + [ProjectState.SHOWING_FILE_UPLOAD]: ProjectState.LOADING_FILE_UPLOAD, + [ProjectState.SHOWING_NEW_DEFAULT]: ProjectState.LOADING_FILE_UPLOAD + } +}); + +const doneLoadingFileUpload = () => ({ + type: TRANSITION_STATE, + transitions: { + [ProjectState.LOADING_FILE_UPLOAD]: ProjectState.SHOWING_FILE_UPLOAD + } +}); + +const stepTowardsNewProject = () => ({ + type: TRANSITION_STATE, + transitions: { + [ProjectState.SHOWING_WITH_ID]: ProjectState.SAVING_WITH_ID_BEFORE_NEW, + [ProjectState.SHOWING_FILE_UPLOAD]: ProjectState.LOADING_NEW_DEFAULT, + [ProjectState.SHOWING_NEW_DEFAULT]: ProjectState.LOADING_NEW_DEFAULT + } +}); + +const doneSavingWithId = () => ({ + type: TRANSITION_STATE, + transitions: { + [ProjectState.SAVING_WITH_ID]: ProjectState.SHOWING_WITH_ID, + [ProjectState.SAVING_WITH_ID_BEFORE_NEW]: ProjectState.LOADING_NEW_DEFAULT + } +}); + export { reducer as default, initialState as projectIdInitialState, + ProjectState, defaultProjectId, - setProjectId + doneLoadingFileUpload, + doneSavingWithId, + setHashProjectId, + setInitialProjectId, + setProjectId, + startLoadingFileUpload, + stepTowardsNewProject }; diff --git a/src/reducers/vm.js b/src/reducers/vm.js index da2155cf8..6132f0e0d 100644 --- a/src/reducers/vm.js +++ b/src/reducers/vm.js @@ -21,8 +21,30 @@ const setVM = function (vm) { vm: vm }; }; + +const doStoreProject = (id, onSaveFinished) => ( + reducer.saveProjectSb3() + .then(content => { + if (onSaveFinished) { + onSaveFinished(); + } + const assetType = storage.AssetType.Project; + const dataFormat = storage.DataFormat.SB3; + const body = new FormData(); + body.append('sb3_file', content, 'sb3_file'); + return storage.store( + assetType, + dataFormat, + body, + id + ); + }) +); + + export { reducer as default, initialState as vmInitialState, + doStoreProject, setVM };