diff --git a/src/components/menu-bar/menu-bar.jsx b/src/components/menu-bar/menu-bar.jsx index f046710e0f17ad4f24304e613373b6f40a4da604..b0c831a05639daf2f23b623a98a9e61afdec5c19 100644 --- a/src/components/menu-bar/menu-bar.jsx +++ b/src/components/menu-bar/menu-bar.jsx @@ -656,7 +656,6 @@ MenuBar.propTypes = { onUpdateProjectTitle: PropTypes.func, renderLogin: PropTypes.func, sessionExists: PropTypes.bool, - startSaving: PropTypes.func, username: PropTypes.string }; diff --git a/src/containers/gui.jsx b/src/containers/gui.jsx index 49b29672da7f6a9c459520bd62fecce3fe8d7d91..8a89fb1324cc5e6186cf32813a5577844c5a9f91 100644 --- a/src/containers/gui.jsx +++ b/src/containers/gui.jsx @@ -4,9 +4,13 @@ import {compose} from 'redux'; import {connect} from 'react-redux'; import ReactModal from 'react-modal'; import VM from 'scratch-vm'; +import {injectIntl, intlShape} from 'react-intl'; import ErrorBoundaryHOC from '../lib/error-boundary-hoc.jsx'; import {openExtensionLibrary} from '../reducers/modals'; +import { + getIsShowingProject +} from '../reducers/project-state'; import {setProjectTitle} from '../reducers/project-title'; import { activateTab, @@ -25,18 +29,29 @@ import ProjectFetcherHOC from '../lib/project-fetcher-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 {defaultProjectTitleMessages} from '../reducers/project-title'; import GUIComponent from '../components/gui/gui.jsx'; class GUI extends React.Component { componentDidMount () { - if (this.props.projectTitle) { - this.props.onUpdateReduxProjectTitle(this.props.projectTitle); + this.setReduxTitle(this.props.projectTitle); + } + componentDidUpdate (prevProps) { + if (this.props.projectId !== prevProps.projectId && this.props.projectId !== null) { + this.props.onUpdateProjectId(this.props.projectId); + } + if (this.props.projectTitle !== prevProps.projectTitle) { + this.setReduxTitle(this.props.projectTitle); } } - componentWillReceiveProps (nextProps) { - if (this.props.projectTitle !== nextProps.projectTitle) { - this.props.onUpdateReduxProjectTitle(nextProps.projectTitle); + setReduxTitle (newTitle) { + if (newTitle === null || typeof newTitle === 'undefined') { + this.props.onUpdateReduxProjectTitle( + this.props.intl.formatMessage(defaultProjectTitleMessages.defaultProjectTitle) + ); + } else { + this.props.onUpdateReduxProjectTitle(newTitle); } } render () { @@ -50,8 +65,11 @@ class GUI extends React.Component { errorMessage, hideIntro, loadingError, + isShowingProject, + onUpdateProjectId, onUpdateReduxProjectTitle, projectHost, + projectId, projectTitle, /* eslint-enable no-unused-vars */ children, @@ -78,40 +96,53 @@ GUI.propTypes = { fetchingProject: PropTypes.bool, hideIntro: PropTypes.bool, importInfoVisible: PropTypes.bool, + intl: intlShape, isLoading: PropTypes.bool, + isShowingProject: PropTypes.bool, loadingError: PropTypes.bool, loadingStateVisible: PropTypes.bool, onChangeProjectInfo: PropTypes.func, onSeeCommunity: PropTypes.func, + onUpdateProjectId: PropTypes.func, onUpdateProjectTitle: PropTypes.func, onUpdateReduxProjectTitle: PropTypes.func, previewInfoVisible: PropTypes.bool, projectHost: PropTypes.string, + projectId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), projectTitle: PropTypes.string, vm: PropTypes.instanceOf(VM).isRequired }; -const mapStateToProps = (state, ownProps) => ({ - activeTabIndex: state.scratchGui.editorTab.activeTabIndex, - alertsVisible: state.scratchGui.alerts.visible, - backdropLibraryVisible: state.scratchGui.modals.backdropLibrary, - blocksTabVisible: state.scratchGui.editorTab.activeTabIndex === BLOCKS_TAB_INDEX, - cardsVisible: state.scratchGui.cards.visible, - costumeLibraryVisible: state.scratchGui.modals.costumeLibrary, - costumesTabVisible: state.scratchGui.editorTab.activeTabIndex === COSTUMES_TAB_INDEX, - importInfoVisible: state.scratchGui.modals.importInfo, - isPlayerOnly: state.scratchGui.mode.isPlayerOnly, - isRtl: state.locales.isRtl, - loadingStateVisible: state.scratchGui.modals.loadingProject, - previewInfoVisible: state.scratchGui.modals.previewInfo && !ownProps.hideIntro, - targetIsStage: ( - state.scratchGui.targets.stage && - state.scratchGui.targets.stage.id === state.scratchGui.targets.editingTarget - ), - soundsTabVisible: state.scratchGui.editorTab.activeTabIndex === SOUNDS_TAB_INDEX, - tipsLibraryVisible: state.scratchGui.modals.tipsLibrary, - vm: state.scratchGui.vm -}); +GUI.defaultProps = { + onUpdateProjectId: () => {} +}; + +const mapStateToProps = (state, ownProps) => { + const loadingState = state.scratchGui.projectState.loadingState; + return { + activeTabIndex: state.scratchGui.editorTab.activeTabIndex, + alertsVisible: state.scratchGui.alerts.visible, + backdropLibraryVisible: state.scratchGui.modals.backdropLibrary, + blocksTabVisible: state.scratchGui.editorTab.activeTabIndex === BLOCKS_TAB_INDEX, + cardsVisible: state.scratchGui.cards.visible, + costumeLibraryVisible: state.scratchGui.modals.costumeLibrary, + costumesTabVisible: state.scratchGui.editorTab.activeTabIndex === COSTUMES_TAB_INDEX, + importInfoVisible: state.scratchGui.modals.importInfo, + isPlayerOnly: state.scratchGui.mode.isPlayerOnly, + isRtl: state.locales.isRtl, + isShowingProject: getIsShowingProject(loadingState), + loadingStateVisible: state.scratchGui.modals.loadingProject, + previewInfoVisible: state.scratchGui.modals.previewInfo && !ownProps.hideIntro, + projectId: state.scratchGui.projectState.projectId, + targetIsStage: ( + state.scratchGui.targets.stage && + state.scratchGui.targets.stage.id === state.scratchGui.targets.editingTarget + ), + soundsTabVisible: state.scratchGui.editorTab.activeTabIndex === SOUNDS_TAB_INDEX, + tipsLibraryVisible: state.scratchGui.modals.tipsLibrary, + vm: state.scratchGui.vm + }; +}; const mapDispatchToProps = dispatch => ({ onExtensionButtonClick: () => dispatch(openExtensionLibrary()), @@ -123,10 +154,10 @@ const mapDispatchToProps = dispatch => ({ onUpdateReduxProjectTitle: title => dispatch(setProjectTitle(title)) }); -const ConnectedGUI = connect( +const ConnectedGUI = injectIntl(connect( mapStateToProps, mapDispatchToProps, -)(GUI); +)(GUI)); // note that redux's 'compose' function is just being used as a general utility to make // the hierarchy of HOC constructor calls clearer here; it has nothing to do with redux's diff --git a/src/containers/sb-file-uploader.jsx b/src/containers/sb-file-uploader.jsx index 46be8172465a65d2542d9f2c957eccad7e10f4d7..ffa4e0122b8cd12265a573cb60050c2f8eaef973 100644 --- a/src/containers/sb-file-uploader.jsx +++ b/src/containers/sb-file-uploader.jsx @@ -112,6 +112,7 @@ class SBFileUploader extends React.Component { } SBFileUploader.propTypes = { + canSave: PropTypes.bool, // eslint-disable-line react/no-unused-prop-types children: PropTypes.func, intl: intlShape.isRequired, loadingState: PropTypes.oneOf(LoadingStates), @@ -127,9 +128,9 @@ const mapStateToProps = state => ({ vm: state.scratchGui.vm }); -const mapDispatchToProps = dispatch => ({ +const mapDispatchToProps = (dispatch, ownProps) => ({ onLoadingFinished: loadingState => { - dispatch(onLoadedProject(loadingState)); + dispatch(onLoadedProject(loadingState, ownProps.canSave)); dispatch(closeLoadingProject()); }, onLoadingStarted: () => { diff --git a/src/lib/project-fetcher-hoc.jsx b/src/lib/project-fetcher-hoc.jsx index 884a77cfc6885a6326017f28c3d63a368b64ab76..6a29cf75db29a5ecef8e1ea6ccae618f72cc2dbf 100644 --- a/src/lib/project-fetcher-hoc.jsx +++ b/src/lib/project-fetcher-hoc.jsx @@ -7,8 +7,8 @@ import {connect} from 'react-redux'; import { LoadingStates, defaultProjectId, - onFetchedProjectData, getIsFetchingWithId, + onFetchedProjectData, setProjectId } from '../reducers/project-state'; @@ -102,6 +102,7 @@ const ProjectFetcherHOC = function (WrappedComponent) { } ProjectFetcherComponent.propTypes = { assetHost: PropTypes.string, + canSave: PropTypes.bool, intl: intlShape.isRequired, isFetchingWithId: PropTypes.bool, loadingState: PropTypes.oneOf(LoadingStates), diff --git a/src/lib/project-saver-hoc.jsx b/src/lib/project-saver-hoc.jsx index 83ccd12c1526cab5f23fb5a91fb0939377d9c2e6..5bc15f1d5b6fe2b40afc9fe45e909d1471d15cd2 100644 --- a/src/lib/project-saver-hoc.jsx +++ b/src/lib/project-saver-hoc.jsx @@ -6,11 +6,15 @@ import VM from 'scratch-vm'; import storage from '../lib/storage'; import { LoadingStates, + createProject, getIsCreating, + getIsShowingProject, + getIsShowingWithoutId, getIsUpdating, onCreated, onUpdated, - onError + onError, + saveProject } from '../reducers/project-state'; /** @@ -27,7 +31,7 @@ const ProjectSaverHOC = function (WrappedComponent) { componentDidUpdate (prevProps) { if (this.props.isUpdating && !prevProps.isUpdating) { this.storeProject(this.props.reduxProjectId) - .then(() => { // eslint-disable-line no-unused-vars + .then(() => { // there is nothing we expect to find in response that we need to check here this.props.onUpdated(this.props.loadingState); }) @@ -46,6 +50,19 @@ const ProjectSaverHOC = function (WrappedComponent) { this.props.onError(`Creating a new project failed with error: ${err}`); }); } + // if this is the first time we're able to create this project on the server, create it! + const showingCreateable = this.props.canSave && this.props.isShowingWithoutId; + const prevShowingCreateable = prevProps.canSave && prevProps.isShowingWithoutId; + if (showingCreateable && !prevShowingCreateable) { + this.props.createProject(); + } else { + // 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; + if (showingSaveable && becameAbleToSave) { + this.props.saveProject(); + } + } } /** * storeProject: @@ -72,13 +89,17 @@ const ProjectSaverHOC = function (WrappedComponent) { render () { const { /* eslint-disable no-unused-vars */ + createProject: createProjectProp, onCreated: onCreatedProp, onUpdated: onUpdatedProp, onError: onErrorProp, isCreating: isCreatingProp, + isShowingWithId: isShowingWithIdProp, + isShowingWithoutId: isShowingWithoutIdProp, isUpdating: isUpdatingProp, loadingState, reduxProjectId, + saveProject: saveProjectProp, /* eslint-enable no-unused-vars */ ...componentProps } = this.props; @@ -90,19 +111,26 @@ const ProjectSaverHOC = function (WrappedComponent) { } } ProjectSaverComponent.propTypes = { + canSave: PropTypes.bool, + createProject: PropTypes.func, isCreating: PropTypes.bool, + isShowingWithId: PropTypes.bool, + isShowingWithoutId: PropTypes.bool, isUpdating: PropTypes.bool, loadingState: PropTypes.oneOf(LoadingStates), onCreated: PropTypes.func, onError: PropTypes.func, onUpdated: PropTypes.func, reduxProjectId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + saveProject: PropTypes.func, vm: PropTypes.instanceOf(VM).isRequired }; const mapStateToProps = state => { const loadingState = state.scratchGui.projectState.loadingState; return { isCreating: getIsCreating(loadingState), + isShowingWithId: getIsShowingProject(loadingState), + isShowingWithoutId: getIsShowingWithoutId(loadingState), isUpdating: getIsUpdating(loadingState), loadingState: loadingState, reduxProjectId: state.scratchGui.projectState.projectId, @@ -110,13 +138,20 @@ const ProjectSaverHOC = function (WrappedComponent) { }; }; const mapDispatchToProps = dispatch => ({ + createProject: () => dispatch(createProject()), onCreated: projectId => dispatch(onCreated(projectId)), onUpdated: (projectId, loadingState) => dispatch(onUpdated(projectId, loadingState)), - onError: errStr => dispatch(onError(errStr)) + onError: errStr => dispatch(onError(errStr)), + saveProject: () => dispatch(saveProject()) }); + // 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 + mapDispatchToProps, + mergeProps )(ProjectSaverComponent); }; diff --git a/src/lib/vm-manager-hoc.jsx b/src/lib/vm-manager-hoc.jsx index 56bd7eabf74ddee9a6f0f714b9b12a25a45ef2f0..cfc80bc97e066e0504a1fa5119bb81f8a526d355 100644 --- a/src/lib/vm-manager-hoc.jsx +++ b/src/lib/vm-manager-hoc.jsx @@ -42,13 +42,13 @@ const vmManagerHOC = function (WrappedComponent) { // and they weren't both that way until now... load project! if (this.props.isLoadingWithId && this.props.fontsLoaded && (!prevProps.isLoadingWithId || !prevProps.fontsLoaded)) { - this.loadProject(this.props.projectData, this.props.loadingState); + this.loadProject(); } } - loadProject (projectData, loadingState) { - return this.props.vm.loadProject(projectData) + loadProject () { + return this.props.vm.loadProject(this.props.projectData) .then(() => { - this.props.onLoadedProject(loadingState); + this.props.onLoadedProject(this.props.loadingState, this.props.canSave); }) .catch(e => { // Need to catch this error and update component state so that @@ -82,6 +82,7 @@ const vmManagerHOC = function (WrappedComponent) { } VMManager.propTypes = { + canSave: PropTypes.bool, fontsLoaded: PropTypes.bool, isLoadingWithId: PropTypes.bool, loadingState: PropTypes.oneOf(LoadingStates), @@ -102,7 +103,8 @@ const vmManagerHOC = function (WrappedComponent) { }; const mapDispatchToProps = dispatch => ({ - onLoadedProject: loadingState => dispatch(onLoadedProject(loadingState)) + onLoadedProject: (loadingState, canSave) => + dispatch(onLoadedProject(loadingState, canSave)) }); // Allow incoming props to override redux-provided props. Used to mock in tests. diff --git a/src/reducers/project-state.js b/src/reducers/project-state.js index 56e5bb1284dd8a030e4de74660dc5585a7c5fd0d..de171edc5df734fb35e2d35c19e5f96a9271a000 100644 --- a/src/reducers/project-state.js +++ b/src/reducers/project-state.js @@ -1,18 +1,17 @@ import keyMirror from 'keymirror'; +const START_CREATING_NEW = 'scratch-gui/project-state/START_CREATING_NEW'; const DONE_CREATING_NEW = 'scratch-gui/project-state/DONE_CREATING_NEW'; const DONE_FETCHING_WITH_ID = 'scratch-gui/project-state/DONE_FETCHING_WITH_ID'; const DONE_FETCHING_DEFAULT = 'scratch-gui/project-state/DONE_FETCHING_DEFAULT'; -const DONE_FETCHING_DEFAULT_TO_SAVE = 'scratch-gui/project-state/DONE_FETCHING_DEFAULT_TO_SAVE'; const DONE_LOADING_VM_WITH_ID = 'scratch-gui/project-state/DONE_LOADING_VM_WITH_ID'; -const DONE_LOADING_VM_NEW_DEFAULT = 'scratch-gui/project-state/DONE_LOADING_VM_NEW_DEFAULT'; -const DONE_LOADING_VM_NEW_DEFAULT_TO_SAVE = 'scratch-gui/project-state/DONE_LOADING_VM_NEW_DEFAULT_TO_SAVE'; -const DONE_LOADING_VM_FILE_UPLOAD = 'scratch-gui/project-state/DONE_LOADING_VM_FILE_UPLOAD'; +const DONE_LOADING_VM_WITHOUT_ID = 'scratch-gui/project-state/DONE_LOADING_VM_WITHOUT_ID'; +const DONE_LOADING_VM_TO_SAVE = 'scratch-gui/project-state/DONE_LOADING_VM_TO_SAVE'; const DONE_SAVING_WITH_ID = 'scratch-gui/project-state/DONE_SAVING_WITH_ID'; const DONE_SAVING_WITH_ID_BEFORE_NEW = 'scratch-gui/project-state/DONE_SAVING_WITH_ID_BEFORE_NEW'; const GO_TO_ERROR_STATE = 'scratch-gui/project-state/GO_TO_ERROR_STATE'; const SET_PROJECT_ID = 'scratch-gui/project-state/SET_PROJECT_ID'; -const START_FETCHING_NEW_WITHOUT_SAVING = 'scratch-gui/project-state/START_FETCHING_NEW_WITHOUT_SAVING'; +const START_FETCHING_NEW = 'scratch-gui/project-state/START_FETCHING_NEW'; const START_LOADING_VM_FILE_UPLOAD = 'scratch-gui/project-state/START_LOADING_FILE_UPLOAD'; const START_SAVING = 'scratch-gui/project-state/START_SAVING'; const START_SAVING_BEFORE_CREATING_NEW = 'scratch-gui/project-state/START_SAVING_BEFORE_CREATING_NEW'; @@ -24,11 +23,9 @@ const LoadingState = keyMirror({ ERROR: null, FETCHING_WITH_ID: null, FETCHING_NEW_DEFAULT: null, - FETCHING_NEW_DEFAULT_TO_SAVE: null, LOADING_VM_WITH_ID: null, LOADING_VM_FILE_UPLOAD: null, LOADING_VM_NEW_DEFAULT: null, - LOADING_VM_NEW_DEFAULT_TO_SAVE: null, SHOWING_WITH_ID: null, SHOWING_WITHOUT_ID: null, SAVING_WITH_ID: null, @@ -41,18 +38,15 @@ const LoadingStates = Object.keys(LoadingState); const getIsFetchingWithoutId = loadingState => ( // LOADING_VM_FILE_UPLOAD is an honorary fetch, since there is no fetching step for file uploads loadingState === LoadingState.LOADING_VM_FILE_UPLOAD || - loadingState === LoadingState.FETCHING_NEW_DEFAULT || - loadingState === LoadingState.FETCHING_NEW_DEFAULT_TO_SAVE + loadingState === LoadingState.FETCHING_NEW_DEFAULT ); const getIsFetchingWithId = loadingState => ( loadingState === LoadingState.FETCHING_WITH_ID || - loadingState === LoadingState.FETCHING_NEW_DEFAULT || - loadingState === LoadingState.FETCHING_NEW_DEFAULT_TO_SAVE + loadingState === LoadingState.FETCHING_NEW_DEFAULT ); const getIsLoadingWithId = loadingState => ( loadingState === LoadingState.LOADING_VM_WITH_ID || - loadingState === LoadingState.LOADING_VM_NEW_DEFAULT || - loadingState === LoadingState.LOADING_VM_NEW_DEFAULT_TO_SAVE + loadingState === LoadingState.LOADING_VM_NEW_DEFAULT ); const getIsCreating = loadingState => ( loadingState === LoadingState.CREATING_NEW @@ -65,6 +59,12 @@ const getIsShowingProject = loadingState => ( loadingState === LoadingState.SHOWING_WITH_ID || loadingState === LoadingState.SHOWING_WITHOUT_ID ); +const getIsShowingWithId = loadingState => ( + loadingState === LoadingState.SHOWING_WITH_ID +); +const getIsShowingWithoutId = loadingState => ( + loadingState === LoadingState.SHOWING_WITHOUT_ID +); const initialState = { errStr: null, @@ -103,18 +103,9 @@ const reducer = function (state, action) { }); } return state; - case DONE_FETCHING_DEFAULT_TO_SAVE: - if (state.loadingState === LoadingState.FETCHING_NEW_DEFAULT_TO_SAVE) { - return Object.assign({}, state, { - loadingState: LoadingState.LOADING_VM_NEW_DEFAULT_TO_SAVE, - projectData: action.projectData - }); - } - return state; - case DONE_LOADING_VM_FILE_UPLOAD: - // note that we don't need to explicitly set projectData, because it is loaded - // into the vm directly in file-loader-from-local - if (state.loadingState === LoadingState.LOADING_VM_FILE_UPLOAD) { + case DONE_LOADING_VM_WITHOUT_ID: + if (state.loadingState === LoadingState.LOADING_VM_FILE_UPLOAD || + state.loadingState === LoadingState.LOADING_VM_NEW_DEFAULT) { return Object.assign({}, state, { loadingState: LoadingState.SHOWING_WITHOUT_ID }); @@ -127,20 +118,10 @@ const reducer = function (state, action) { }); } return state; - case DONE_LOADING_VM_NEW_DEFAULT: - if (state.loadingState === LoadingState.LOADING_VM_NEW_DEFAULT) { - return Object.assign({}, state, { - loadingState: LoadingState.SHOWING_WITHOUT_ID - }); - } - return state; - case DONE_LOADING_VM_NEW_DEFAULT_TO_SAVE: - if (state.loadingState === LoadingState.LOADING_VM_NEW_DEFAULT_TO_SAVE) { + case DONE_LOADING_VM_TO_SAVE: + if (state.loadingState === LoadingState.LOADING_VM_FILE_UPLOAD) { return Object.assign({}, state, { - // NOTE: this is set to skip over sending a POST to create the new project - // on the server, until we can get that working on the backend. - // loadingState: LoadingState.CREATING_NEW - loadingState: LoadingState.SHOWING_WITH_ID + loadingState: LoadingState.SAVING_WITH_ID }); } return state; @@ -154,12 +135,19 @@ const reducer = function (state, action) { case DONE_SAVING_WITH_ID_BEFORE_NEW: if (state.loadingState === LoadingState.SAVING_WITH_ID_BEFORE_NEW) { return Object.assign({}, state, { - loadingState: LoadingState.FETCHING_NEW_DEFAULT_TO_SAVE, + loadingState: LoadingState.FETCHING_NEW_DEFAULT, projectId: defaultProjectId }); } return state; case SET_PROJECT_ID: + // if setting the default project id, specifically fetch that project + if (action.id === defaultProjectId) { + return Object.assign({}, state, { + loadingState: LoadingState.FETCHING_NEW_DEFAULT, + projectId: defaultProjectId + }); + } // if we were already showing a project, and a different projectId is set, only fetch that project if // projectId has changed. This prevents re-fetching projects unnecessarily. if (state.loadingState === LoadingState.SHOWING_WITH_ID) { @@ -176,7 +164,14 @@ const reducer = function (state, action) { }); } return state; - case START_FETCHING_NEW_WITHOUT_SAVING: + case START_CREATING_NEW: + if (state.loadingState === LoadingState.SHOWING_WITHOUT_ID) { + return Object.assign({}, state, { + loadingState: LoadingState.CREATING_NEW + }); + } + return state; + case START_FETCHING_NEW: if ([ LoadingState.SHOWING_WITH_ID, LoadingState.SHOWING_WITHOUT_ID @@ -216,10 +211,13 @@ const reducer = function (state, action) { case GO_TO_ERROR_STATE: // NOTE: we should introduce handling in components for showing ERROR state if ([ - LoadingState.NOT_LOADED, + LoadingState.LOADING_VM_NEW_DEFAULT, + LoadingState.LOADING_VM_WITH_ID, LoadingState.FETCHING_WITH_ID, LoadingState.FETCHING_NEW_DEFAULT, - LoadingState.FETCHING_NEW_DEFAULT_TO_SAVE + LoadingState.SAVING_WITH_ID, + LoadingState.SAVING_WITH_ID_BEFORE_NEW, + LoadingState.CREATING_NEW ].includes(state.loadingState)) { return Object.assign({}, state, { loadingState: LoadingState.ERROR, @@ -232,6 +230,10 @@ const reducer = function (state, action) { } }; +const createProject = () => ({ + type: START_CREATING_NEW +}); + const onCreated = id => ({ type: DONE_CREATING_NEW, id: id @@ -249,33 +251,29 @@ const onFetchedProjectData = (projectData, loadingState) => { type: DONE_FETCHING_DEFAULT, projectData: projectData }; - case LoadingState.FETCHING_NEW_DEFAULT_TO_SAVE: - return { - type: DONE_FETCHING_DEFAULT_TO_SAVE, - projectData: projectData - }; default: break; } }; -const onLoadedProject = loadingState => { +const onLoadedProject = (loadingState, canSave) => { switch (loadingState) { case LoadingState.LOADING_VM_WITH_ID: return { type: DONE_LOADING_VM_WITH_ID }; case LoadingState.LOADING_VM_FILE_UPLOAD: + if (canSave) { + return { + type: DONE_LOADING_VM_TO_SAVE + }; + } return { - type: DONE_LOADING_VM_FILE_UPLOAD + type: DONE_LOADING_VM_WITHOUT_ID }; case LoadingState.LOADING_VM_NEW_DEFAULT: return { - type: DONE_LOADING_VM_NEW_DEFAULT - }; - case LoadingState.LOADING_VM_NEW_DEFAULT_TO_SAVE: - return { - type: DONE_LOADING_VM_NEW_DEFAULT_TO_SAVE + type: DONE_LOADING_VM_WITHOUT_ID }; default: break; @@ -309,7 +307,7 @@ const setProjectId = id => ({ const requestNewProject = canSave => { if (canSave) return {type: START_SAVING_BEFORE_CREATING_NEW}; - return {type: START_FETCHING_NEW_WITHOUT_SAVING}; + return {type: START_FETCHING_NEW}; }; const onProjectUploadStarted = () => ({ @@ -325,6 +323,7 @@ export { initialState as projectStateInitialState, LoadingState, LoadingStates, + createProject, defaultProjectId, getIsCreating, getIsFetchingWithoutId, @@ -332,6 +331,8 @@ export { getIsLoadingWithId, getIsUpdating, getIsShowingProject, + getIsShowingWithId, + getIsShowingWithoutId, onCreated, onError, onFetchedProjectData, diff --git a/test/unit/reducers/project-state-reducer.test.js b/test/unit/reducers/project-state-reducer.test.js index 920999ffc45e4678731cdac028ebbd79ac1a7c2e..c91d1909cca70344c3e5ae6d4db3680cd8fefdcc 100644 --- a/test/unit/reducers/project-state-reducer.test.js +++ b/test/unit/reducers/project-state-reducer.test.js @@ -67,24 +67,22 @@ test('onFetchedProjectData new loads project data into vm', () => { expect(resultState.projectData).toBe('1010101'); }); -test('onFetchedProjectData new, to save loads project data into vm, prepares to save next', () => { +test('onLoadedProject upload, with canSave false, shows without id', () => { const initialState = { - projectData: null, - loadingState: LoadingState.FETCHING_NEW_DEFAULT_TO_SAVE + loadingState: LoadingState.LOADING_VM_FILE_UPLOAD }; - const action = onFetchedProjectData('1010101', initialState.loadingState); + const action = onLoadedProject(initialState.loadingState, false); const resultState = projectStateReducer(initialState, action); - expect(resultState.loadingState).toBe(LoadingState.LOADING_VM_NEW_DEFAULT_TO_SAVE); - expect(resultState.projectData).toBe('1010101'); + expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITHOUT_ID); }); -test('onLoadedProject upload shows without id', () => { +test('onLoadedProject upload, with canSave true, prepares to save', () => { const initialState = { loadingState: LoadingState.LOADING_VM_FILE_UPLOAD }; - const action = onLoadedProject(initialState.loadingState); + const action = onLoadedProject(initialState.loadingState, true); const resultState = projectStateReducer(initialState, action); - expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITHOUT_ID); + expect(resultState.loadingState).toBe(LoadingState.SAVING_WITH_ID); }); test('onLoadedProject with id shows with id', () => { @@ -105,13 +103,13 @@ test('onLoadedProject new shows without id', () => { expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITHOUT_ID); }); -test('onLoadedProject new, to save shows with id', () => { +test('onLoadedProject new, to save shows without id', () => { const initialState = { - loadingState: LoadingState.LOADING_VM_NEW_DEFAULT_TO_SAVE + loadingState: LoadingState.LOADING_VM_NEW_DEFAULT }; const action = onLoadedProject(initialState.loadingState); const resultState = projectStateReducer(initialState, action); - expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITH_ID); + expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITHOUT_ID); }); test('onUpdated with id shows with id', () => { @@ -129,7 +127,7 @@ test('onUpdated with id, before new, fetches default project', () => { }; const action = onUpdated(initialState.loadingState); const resultState = projectStateReducer(initialState, action); - expect(resultState.loadingState).toBe(LoadingState.FETCHING_NEW_DEFAULT_TO_SAVE); + expect(resultState.loadingState).toBe(LoadingState.FETCHING_NEW_DEFAULT); }); test('setProjectId, with same id as before, should show with id, not fetch', () => { @@ -219,15 +217,26 @@ test('saveProject should prepare to save', () => { expect(resultState.loadingState).toBe(LoadingState.SAVING_WITH_ID); }); -test('onError from unloaded state should show error', () => { - const initialState = { - errStr: null, - loadingState: LoadingState.NOT_LOADED - }; - const action = onError('Error string'); - const resultState = projectStateReducer(initialState, action); - expect(resultState.loadingState).toBe(LoadingState.ERROR); - expect(resultState.errStr).toBe('Error string'); +test('onError from various states should show error', () => { + const startStates = [ + LoadingState.LOADING_VM_NEW_DEFAULT, + LoadingState.LOADING_VM_WITH_ID, + LoadingState.FETCHING_WITH_ID, + LoadingState.FETCHING_NEW_DEFAULT, + LoadingState.SAVING_WITH_ID, + LoadingState.SAVING_WITH_ID_BEFORE_NEW, + LoadingState.CREATING_NEW + ]; + for (const startState of startStates) { + const initialState = { + errStr: null, + loadingState: startState + }; + const action = onError('Error string'); + const resultState = projectStateReducer(initialState, action); + expect(resultState.loadingState).toBe(LoadingState.ERROR); + expect(resultState.errStr).toBe('Error string'); + } }); test('onError from showing project should show error', () => { diff --git a/test/unit/util/project-saver-hoc.test.jsx b/test/unit/util/project-saver-hoc.test.jsx new file mode 100644 index 0000000000000000000000000000000000000000..d667db379fe35c355279403dbcbc365cda6d0bd3 --- /dev/null +++ b/test/unit/util/project-saver-hoc.test.jsx @@ -0,0 +1,216 @@ +import 'web-audio-test-api'; + +import React from 'react'; +import configureStore from 'redux-mock-store'; +import {mount} from 'enzyme'; +import {LoadingState} from '../../../src/reducers/project-state'; +import VM from 'scratch-vm'; + +import projectSaverHOC from '../../../src/lib/project-saver-hoc.jsx'; + +describe('projectSaverHOC', () => { + const mockStore = configureStore(); + let store; + let vm; + + beforeEach(() => { + store = mockStore({ + scratchGui: { + projectState: {} + } + }); + vm = new VM(); + }); + + test('if canSave becomes true when showing a project with an id, project will be saved', () => { + const mockedSaveProject = jest.fn(); + const Component = () => <div />; + const WrappedComponent = projectSaverHOC(Component); + const mounted = mount( + <WrappedComponent + isShowingWithId + canSave={false} + isCreating={false} + isShowingWithoutId={false} + isUpdating={false} + loadingState={LoadingState.SHOWING_WITH_ID} + saveProject={mockedSaveProject} + store={store} + vm={vm} + /> + ); + mounted.setProps({ + canSave: true + }); + expect(mockedSaveProject).toHaveBeenCalled(); + }); + + test('if canSave is alreatdy true and we show a project with an id, project will NOT be saved', () => { + const mockedSaveProject = jest.fn(); + const Component = () => <div />; + const WrappedComponent = projectSaverHOC(Component); + const mounted = mount( + <WrappedComponent + canSave + isCreating={false} + isShowingWithId={false} + isShowingWithoutId={false} + isUpdating={false} + loadingState={LoadingState.LOADING_VM_WITH_ID} + saveProject={mockedSaveProject} + store={store} + vm={vm} + /> + ); + mounted.setProps({ + canSave: true, + isShowingWithId: true, + loadingState: LoadingState.SHOWING_WITH_ID + }); + expect(mockedSaveProject).not.toHaveBeenCalled(); + }); + + test('if canSave is false when showing a project without an id, project will NOT be created', () => { + const mockedCreateProject = jest.fn(); + const Component = () => <div />; + const WrappedComponent = projectSaverHOC(Component); + const mounted = mount( + <WrappedComponent + isShowingWithoutId + canSave={false} + createProject={mockedCreateProject} + isCreating={false} + isShowingWithId={false} + isUpdating={false} + loadingState={LoadingState.LOADING_VM_NEW_DEFAULT} + store={store} + vm={vm} + /> + ); + mounted.setProps({ + isShowingWithoutId: true, + loadingState: LoadingState.SHOWING_WITHOUT_ID + }); + expect(mockedCreateProject).not.toHaveBeenCalled(); + }); + + test('if canSave becomes true when showing a project without an id, project will be created', () => { + const mockedCreateProject = jest.fn(); + const Component = () => <div />; + const WrappedComponent = projectSaverHOC(Component); + const mounted = mount( + <WrappedComponent + isShowingWithoutId + canSave={false} + createProject={mockedCreateProject} + isCreating={false} + isShowingWithId={false} + isUpdating={false} + loadingState={LoadingState.SHOWING_WITHOUT_ID} + store={store} + vm={vm} + /> + ); + mounted.setProps({ + canSave: true + }); + expect(mockedCreateProject).toHaveBeenCalled(); + }); + + test('if canSave is true and we transition to showing new project, project will be created', () => { + const mockedCreateProject = jest.fn(); + const Component = () => <div />; + const WrappedComponent = projectSaverHOC(Component); + const mounted = mount( + <WrappedComponent + canSave + createProject={mockedCreateProject} + isCreating={false} + isShowingWithId={false} + isShowingWithoutId={false} + isUpdating={false} + loadingState={LoadingState.LOADING_VM_NEW_DEFAULT} + store={store} + vm={vm} + /> + ); + mounted.setProps({ + isShowingWithoutId: true, + loadingState: LoadingState.SHOWING_WITHOUT_ID + }); + expect(mockedCreateProject).toHaveBeenCalled(); + }); + + test('if we enter creating state, vm project should be requested', () => { + vm.saveProjectSb3 = jest.fn(() => Promise.resolve()); + const Component = () => <div />; + const WrappedComponent = projectSaverHOC(Component); + const mounted = mount( + <WrappedComponent + canSave + isCreating={false} + isShowingWithId={false} + isShowingWithoutId={false} + isUpdating={false} + loadingState={LoadingState.LOADING_VM_NEW_DEFAULT} + reduxProjectId={'100'} + store={store} + vm={vm} + /> + ); + mounted.setProps({ + isCreating: true, + loadingState: LoadingState.CREATING_NEW + }); + expect(vm.saveProjectSb3).toHaveBeenCalled(); + }); + + test('if we enter updating/saving state, vm project shold be requested', () => { + vm.saveProjectSb3 = jest.fn(() => Promise.resolve()); + const Component = () => <div />; + const WrappedComponent = projectSaverHOC(Component); + const mounted = mount( + <WrappedComponent + canSave + isCreating={false} + isShowingWithId={false} + isShowingWithoutId={false} + isUpdating={false} + loadingState={LoadingState.LOADING_VM_WITH_ID} + reduxProjectId={'100'} + store={store} + vm={vm} + /> + ); + mounted.setProps({ + isUpdating: true, + loadingState: LoadingState.SAVING_WITH_ID + }); + expect(vm.saveProjectSb3).toHaveBeenCalled(); + }); + + test('if we are already in updating/saving state, vm project shold be NOT requested', () => { + vm.saveProjectSb3 = jest.fn(() => Promise.resolve()); + const Component = () => <div />; + const WrappedComponent = projectSaverHOC(Component); + const mounted = mount( + <WrappedComponent + canSave + isUpdating + isCreating={false} + isShowingWithId={false} + isShowingWithoutId={false} + loadingState={LoadingState.SAVING_WITH_ID} + reduxProjectId={'100'} + store={store} + vm={vm} + /> + ); + mounted.setProps({ + isUpdating: true, + loadingState: LoadingState.SAVING_WITH_ID, + reduxProjectId: '99' // random change to force a re-render and componentDidUpdate + }); + expect(vm.saveProjectSb3).not.toHaveBeenCalled(); + }); +}); diff --git a/test/unit/util/vm-manager-hoc.test.jsx b/test/unit/util/vm-manager-hoc.test.jsx index 39015a4ded01098f16cbb86d8ca80d568bf1e9b1..8c065d83d9006b4a4a4553277c45bc07e44b5ffc 100644 --- a/test/unit/util/vm-manager-hoc.test.jsx +++ b/test/unit/util/vm-manager-hoc.test.jsx @@ -68,13 +68,16 @@ describe('VMManagerHOC', () => { /> ); mounted.setProps({ + canSave: true, isLoadingWithId: true, loadingState: LoadingState.LOADING_VM_WITH_ID, projectData: '100' }); expect(vm.loadProject).toHaveBeenLastCalledWith('100'); // nextTick needed since vm.loadProject is async, and we have to wait for it :/ - process.nextTick(() => expect(mockedOnLoadedProject).toHaveBeenLastCalledWith(LoadingState.LOADING_VM_WITH_ID)); + process.nextTick(() => ( + expect(mockedOnLoadedProject).toHaveBeenLastCalledWith(LoadingState.LOADING_VM_WITH_ID, true) + )); }); test('if the fontsLoaded prop becomes true, it loads project data into the vm', () => { vm.loadProject = jest.fn(() => Promise.resolve()); @@ -90,13 +93,16 @@ describe('VMManagerHOC', () => { /> ); mounted.setProps({ + canSave: false, fontsLoaded: true, loadingState: LoadingState.LOADING_VM_WITH_ID, projectData: '100' }); expect(vm.loadProject).toHaveBeenLastCalledWith('100'); // nextTick needed since vm.loadProject is async, and we have to wait for it :/ - process.nextTick(() => expect(mockedOnLoadedProject).toHaveBeenLastCalledWith(LoadingState.LOADING_VM_WITH_ID)); + process.nextTick(() => ( + expect(mockedOnLoadedProject).toHaveBeenLastCalledWith(LoadingState.LOADING_VM_WITH_ID, false) + )); }); test('if the fontsLoaded prop is false, project data is never loaded', () => { vm.loadProject = jest.fn(() => Promise.resolve());