From f2291d963cfe82b9d714d99bc75703f419a10f96 Mon Sep 17 00:00:00 2001 From: Ben Wheeler <wheeler.benjamin@gmail.com> Date: Tue, 30 Apr 2019 09:38:39 -0400 Subject: [PATCH] continuing sb file uploader refactor into hoc draft of refactor to make custom confirm modal work for upload; need to further edit sb-file-uploader Add support for custom confirm modal to sb-file-uploader Show custom confirm modal when confirming project upload got custom upload refactor of file upload working, and tested failure case cleaned up debug code, make project file upload work simplified logic around canceling file upload removed old sb-file-uploader.jsx; fixed linter errors reverted two components to use old strings, not shared (can do that refactor another time) removed unnecessary confirm container removed stray comment moved, updated sb file uploader tests, related project state tests removed custom modal, to reduce scope of changes sb file uploader test uses intl --- src/components/gui/gui.jsx | 3 + src/components/menu-bar/menu-bar.jsx | 21 +- src/containers/gui.jsx | 2 + src/containers/sb-file-uploader.jsx | 226 ---------------- src/lib/sb-file-uploader-hoc.jsx | 241 ++++++++++++++++++ src/reducers/project-state.js | 19 +- .../unit/containers/sb-file-uploader.test.jsx | 84 ------ .../reducers/project-state-reducer.test.js | 84 ++++-- test/unit/util/project-saver-hoc.test.jsx | 2 +- test/unit/util/sb-file-uploader-hoc.test.jsx | 108 ++++++++ 10 files changed, 440 insertions(+), 350 deletions(-) delete mode 100644 src/containers/sb-file-uploader.jsx create mode 100644 src/lib/sb-file-uploader-hoc.jsx delete mode 100644 test/unit/containers/sb-file-uploader.test.jsx create mode 100644 test/unit/util/sb-file-uploader-hoc.test.jsx diff --git a/src/components/gui/gui.jsx b/src/components/gui/gui.jsx index 9846eed75..8524b2ba3 100644 --- a/src/components/gui/gui.jsx +++ b/src/components/gui/gui.jsx @@ -105,6 +105,7 @@ const GUIComponent = props => { onRequestCloseTelemetryModal, onSeeCommunity, onShare, + onStartSelectingFileUpload, onTelemetryModalCancel, onTelemetryModalOptIn, onTelemetryModalOptOut, @@ -226,6 +227,7 @@ const GUIComponent = props => { onProjectTelemetryEvent={onProjectTelemetryEvent} onSeeCommunity={onSeeCommunity} onShare={onShare} + onStartSelectingFileUpload={onStartSelectingFileUpload} onToggleLoginOpen={onToggleLoginOpen} /> <Box className={styles.bodyWrapper}> @@ -399,6 +401,7 @@ GUIComponent.propTypes = { onRequestCloseTelemetryModal: PropTypes.func, onSeeCommunity: PropTypes.func, onShare: PropTypes.func, + onStartSelectingFileUpload: PropTypes.func, onTabSelect: PropTypes.func, onTelemetryModalCancel: PropTypes.func, onTelemetryModalOptIn: PropTypes.func, diff --git a/src/components/menu-bar/menu-bar.jsx b/src/components/menu-bar/menu-bar.jsx index 98bc128a9..05a5294bc 100644 --- a/src/components/menu-bar/menu-bar.jsx +++ b/src/components/menu-bar/menu-bar.jsx @@ -17,7 +17,6 @@ import {ComingSoonTooltip} from '../coming-soon/coming-soon.jsx'; import Divider from '../divider/divider.jsx'; import LanguageSelector from '../../containers/language-selector.jsx'; import SaveStatus from './save-status.jsx'; -import SBFileUploader from '../../containers/sb-file-uploader.jsx'; import ProjectWatcher from '../../containers/project-watcher.jsx'; import MenuBarMenu from './menu-bar-menu.jsx'; import {MenuItem, MenuSection} from '../menu/menu.jsx'; @@ -392,22 +391,11 @@ class MenuBar extends React.Component { </MenuSection> )} <MenuSection> - <SBFileUploader - canSave={this.props.canSave} - userOwnsProject={this.props.userOwnsProject} + <MenuItem + onClick={this.props.onStartSelectingFileUpload} > - {(className, renderFileInput, handleLoadProject) => ( - <MenuItem - className={className} - onClick={handleLoadProject} - > - {/* eslint-disable max-len */} - {this.props.intl.formatMessage(sharedMessages.loadFromComputerTitle)} - {/* eslint-enable max-len */} - {renderFileInput()} - </MenuItem> - )} - </SBFileUploader> + {this.props.intl.formatMessage(sharedMessages.loadFromComputerTitle)} + </MenuItem> <SB3Downloader>{(className, downloadProjectCallback) => ( <MenuItem className={className} @@ -743,6 +731,7 @@ MenuBar.propTypes = { onRequestCloseLogin: PropTypes.func, onSeeCommunity: PropTypes.func, onShare: PropTypes.func, + onStartSelectingFileUpload: PropTypes.func, onToggleLoginOpen: PropTypes.func, projectTitle: PropTypes.string, renderLogin: PropTypes.func, diff --git a/src/containers/gui.jsx b/src/containers/gui.jsx index 314c38893..c10df0fb3 100644 --- a/src/containers/gui.jsx +++ b/src/containers/gui.jsx @@ -27,6 +27,7 @@ import { import FontLoaderHOC from '../lib/font-loader-hoc.jsx'; import LocalizationHOC from '../lib/localization-hoc.jsx'; +import SBFileUploaderHOC from '../lib/sb-file-uploader-hoc.jsx'; import ProjectFetcherHOC from '../lib/project-fetcher-hoc.jsx'; import TitledHOC from '../lib/titled-hoc.jsx'; import ProjectSaverHOC from '../lib/project-saver-hoc.jsx'; @@ -181,6 +182,7 @@ const WrappedGui = compose( ProjectSaverHOC, vmListenerHOC, vmManagerHOC, + SBFileUploaderHOC, cloudManagerHOC )(ConnectedGUI); diff --git a/src/containers/sb-file-uploader.jsx b/src/containers/sb-file-uploader.jsx deleted file mode 100644 index 2019f719e..000000000 --- a/src/containers/sb-file-uploader.jsx +++ /dev/null @@ -1,226 +0,0 @@ -import bindAll from 'lodash.bindall'; -import PropTypes from 'prop-types'; -import React from 'react'; -import {connect} from 'react-redux'; -import {defineMessages, injectIntl, intlShape} from 'react-intl'; -import {setProjectTitle} from '../reducers/project-title'; - -import log from '../lib/log'; -import sharedMessages from '../lib/shared-messages'; - -import { - LoadingStates, - getIsLoadingUpload, - getIsShowingWithoutId, - onLoadedProject, - requestProjectUpload -} from '../reducers/project-state'; - -import { - openLoadingProject, - closeLoadingProject -} from '../reducers/modals'; -import { - closeFileMenu -} from '../reducers/menus'; - -/** - * SBFileUploader component passes a file input, load handler and props to its child. - * It expects this child to be a function with the signature - * function (renderFileInput, handleLoadProject) {} - * The component can then be used to attach project loading functionality - * to any other component: - * - * <SBFileUploader>{(className, renderFileInput, handleLoadProject) => ( - * <MyCoolComponent - * className={className} - * onClick={handleLoadProject} - * > - * {renderFileInput()} - * </MyCoolComponent> - * )}</SBFileUploader> - */ - -const messages = defineMessages({ - loadError: { - id: 'gui.projectLoader.loadError', - defaultMessage: 'The project file that was selected failed to load.', - description: 'An error that displays when a local project file fails to load.' - } -}); - -class SBFileUploader extends React.Component { - constructor (props) { - super(props); - bindAll(this, [ - 'getProjectTitleFromFilename', - 'renderFileInput', - 'setFileInput', - 'handleChange', - 'handleClick', - 'onload', - 'resetFileInput' - ]); - } - componentWillMount () { - this.reader = new FileReader(); - this.reader.onload = this.onload; - this.resetFileInput(); - } - componentDidUpdate (prevProps) { - if (this.props.isLoadingUpload && !prevProps.isLoadingUpload && this.fileToUpload && this.reader) { - this.reader.readAsArrayBuffer(this.fileToUpload); - } - } - componentWillUnmount () { - this.reader = null; - this.resetFileInput(); - } - resetFileInput () { - this.fileToUpload = null; - if (this.fileInput) { - this.fileInput.value = null; - } - } - getProjectTitleFromFilename (fileInputFilename) { - if (!fileInputFilename) return ''; - // only parse title with valid scratch project extensions - // (.sb, .sb2, and .sb3) - const matches = fileInputFilename.match(/^(.*)\.sb[23]?$/); - if (!matches) return ''; - return matches[1].substring(0, 100); // truncate project title to max 100 chars - } - // called when user has finished selecting a file to upload - handleChange (e) { - const { - intl, - isShowingWithoutId, - loadingState, - projectChanged, - userOwnsProject - } = this.props; - - const thisFileInput = e.target; - if (thisFileInput.files) { // Don't attempt to load if no file was selected - this.fileToUpload = thisFileInput.files[0]; - - // If user owns the project, or user has changed the project, - // we must confirm with the user that they really intend to replace it. - // (If they don't own the project and haven't changed it, no need to confirm.) - let uploadAllowed = true; - if (userOwnsProject || (projectChanged && isShowingWithoutId)) { - uploadAllowed = confirm( // eslint-disable-line no-alert - intl.formatMessage(sharedMessages.replaceProjectWarning) - ); - } - if (uploadAllowed) { - this.props.requestProjectUpload(loadingState); - } else { - this.props.closeFileMenu(); - } - } - } - // called when file upload raw data is available in the reader - onload () { - if (this.reader) { - this.props.onLoadingStarted(); - const filename = this.fileToUpload && this.fileToUpload.name; - this.props.vm.loadProject(this.reader.result) - .then(() => { - this.props.onLoadingFinished(this.props.loadingState, true); - // Reset the file input after project is loaded - // This is necessary in case the user wants to reload a project - if (filename) { - const uploadedProjectTitle = this.getProjectTitleFromFilename(filename); - this.props.onReceivedProjectTitle(uploadedProjectTitle); - } - this.resetFileInput(); - }) - .catch(error => { - log.warn(error); - alert(this.props.intl.formatMessage(messages.loadError)); // eslint-disable-line no-alert - this.props.onLoadingFinished(this.props.loadingState, false); - // Reset the file input after project is loaded - // This is necessary in case the user wants to reload a project - this.resetFileInput(); - }); - } - } - handleClick () { - // open filesystem browsing window - this.fileInput.click(); - } - setFileInput (input) { - this.fileInput = input; - } - renderFileInput () { - return ( - <input - accept=".sb,.sb2,.sb3" - ref={this.setFileInput} - style={{display: 'none'}} - type="file" - onChange={this.handleChange} - /> - ); - } - render () { - return this.props.children(this.props.className, this.renderFileInput, this.handleClick); - } -} - -SBFileUploader.propTypes = { - canSave: PropTypes.bool, // eslint-disable-line react/no-unused-prop-types - children: PropTypes.func, - className: PropTypes.string, - closeFileMenu: PropTypes.func, - intl: intlShape.isRequired, - isLoadingUpload: PropTypes.bool, - isShowingWithoutId: PropTypes.bool, - loadingState: PropTypes.oneOf(LoadingStates), - onLoadingFinished: PropTypes.func, - onLoadingStarted: PropTypes.func, - projectChanged: PropTypes.bool, - requestProjectUpload: PropTypes.func, - onReceivedProjectTitle: PropTypes.func, - userOwnsProject: PropTypes.bool, - vm: PropTypes.shape({ - loadProject: PropTypes.func - }) -}; -SBFileUploader.defaultProps = { - className: '' -}; -const mapStateToProps = state => { - const loadingState = state.scratchGui.projectState.loadingState; - return { - isLoadingUpload: getIsLoadingUpload(loadingState), - isShowingWithoutId: getIsShowingWithoutId(loadingState), - loadingState: loadingState, - projectChanged: state.scratchGui.projectChanged, - vm: state.scratchGui.vm - }; -}; - -const mapDispatchToProps = (dispatch, ownProps) => ({ - closeFileMenu: () => dispatch(closeFileMenu()), - onLoadingFinished: (loadingState, success) => { - dispatch(onLoadedProject(loadingState, ownProps.canSave, success)); - dispatch(closeLoadingProject()); - dispatch(closeFileMenu()); - }, - requestProjectUpload: loadingState => dispatch(requestProjectUpload(loadingState)), - onLoadingStarted: () => dispatch(openLoadingProject()), - onReceivedProjectTitle: title => dispatch(setProjectTitle(title)) -}); - -// Allow incoming props to override redux-provided props. Used to mock in tests. -const mergeProps = (stateProps, dispatchProps, ownProps) => Object.assign( - {}, stateProps, dispatchProps, ownProps -); - -export default connect( - mapStateToProps, - mapDispatchToProps, - mergeProps -)(injectIntl(SBFileUploader)); diff --git a/src/lib/sb-file-uploader-hoc.jsx b/src/lib/sb-file-uploader-hoc.jsx new file mode 100644 index 000000000..90dd1ff53 --- /dev/null +++ b/src/lib/sb-file-uploader-hoc.jsx @@ -0,0 +1,241 @@ +import bindAll from 'lodash.bindall'; +import React from 'react'; +import PropTypes from 'prop-types'; +import {defineMessages, intlShape, injectIntl} from 'react-intl'; +import {connect} from 'react-redux'; +import log from '../lib/log'; +import sharedMessages from './shared-messages'; + +import { + LoadingStates, + getIsLoadingUpload, + getIsShowingWithoutId, + onLoadedProject, + requestProjectUpload +} from '../reducers/project-state'; +import { + openLoadingProject, + closeLoadingProject +} from '../reducers/modals'; +import { + closeFileMenu +} from '../reducers/menus'; + +const messages = defineMessages({ + loadError: { + id: 'gui.projectLoader.loadError', + defaultMessage: 'The project file that was selected failed to load.', + description: 'An error that displays when a local project file fails to load.' + } +}); + +/** + * Higher Order Component to provide behavior for loading local project files into editor. + * @param {React.Component} WrappedComponent the component to add project file loading functionality to + * @returns {React.Component} WrappedComponent with project file loading functionality added + * + * <SBFileUploaderHOC> + * <WrappedComponent /> + * </SBFileUploaderHOC> + */ +const SBFileUploaderHOC = function (WrappedComponent) { + class SBFileUploaderComponent extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'getProjectTitleFromFilename', + 'handleStartSelectingFileUpload', + 'setFileInput', + 'handleChange', + 'onload', + 'resetFileInput' + ]); + } + componentWillMount () { + this.reader = new FileReader(); + this.reader.onload = this.onload; + this.resetFileInput(); + } + componentDidUpdate (prevProps) { + if (this.props.isLoadingUpload && !prevProps.isLoadingUpload) { + if (this.fileToUpload && this.reader) { + this.reader.readAsArrayBuffer(this.fileToUpload); + } else { + this.props.cancelFileUpload(this.props.loadingState); + } + } + } + componentWillUnmount () { + this.reader = null; + this.resetFileInput(); + } + resetFileInput () { + this.fileToUpload = null; + if (this.fileInput) { + this.fileInput.value = null; + } + } + getProjectTitleFromFilename (fileInputFilename) { + if (!fileInputFilename) return ''; + // only parse title with valid scratch project extensions + // (.sb, .sb2, and .sb3) + const matches = fileInputFilename.match(/^(.*)\.sb[23]?$/); + if (!matches) return ''; + return matches[1].substring(0, 100); // truncate project title to max 100 chars + } + // called when user has finished selecting a file to upload + handleChange (e) { + const { + intl, + isShowingWithoutId, + loadingState, + projectChanged, + userOwnsProject + } = this.props; + + const thisFileInput = e.target; + if (thisFileInput.files) { // Don't attempt to load if no file was selected + this.fileToUpload = thisFileInput.files[0]; + + // If user owns the project, or user has changed the project, + // we must confirm with the user that they really intend to replace it. + // (If they don't own the project and haven't changed it, no need to confirm.) + let uploadAllowed = true; + if (userOwnsProject || (projectChanged && isShowingWithoutId)) { + uploadAllowed = confirm( // eslint-disable-line no-alert + intl.formatMessage(sharedMessages.replaceProjectWarning) + ); + } + if (uploadAllowed) { + this.props.requestProjectUpload(loadingState); + } else { + this.resetFileInput(); + } + this.props.closeFileMenu(); + } + } + // called when file upload raw data is available in the reader + onload () { + if (this.reader) { + this.props.onLoadingStarted(); + const filename = this.fileToUpload && this.fileToUpload.name; + this.props.vm.loadProject(this.reader.result) + .then(() => { + this.props.onLoadingFinished(this.props.loadingState, true); + // Reset the file input after project is loaded + // This is necessary in case the user wants to reload a project + if (filename) { + const uploadedProjectTitle = this.getProjectTitleFromFilename(filename); + this.props.onUpdateProjectTitle(uploadedProjectTitle); + } + this.resetFileInput(); + }) + .catch(error => { + log.warn(error); + this.props.intl.formatMessage(messages.loadError); // eslint-disable-line no-alert + this.props.onLoadingFinished(this.props.loadingState, false); + // Reset the file input after project is loaded + // This is necessary in case the user wants to reload a project + this.resetFileInput(); + }); + } + } + handleStartSelectingFileUpload () { + // open filesystem browsing window + this.fileInput.click(); + } + setFileInput (input) { + this.fileInput = input; + } + render () { + const fileInput = ( + <input + accept=".sb,.sb2,.sb3" + ref={this.setFileInput} + style={{display: 'none'}} + type="file" + onChange={this.handleChange} + /> + ); + const { + /* eslint-disable no-unused-vars */ + closeFileMenu: closeFileMenuProp, + isLoadingUpload, + isShowingWithoutId, + loadingState, + onLoadingFinished, + onLoadingStarted, + projectChanged, + requestProjectUpload: requestProjectUploadProp, + userOwnsProject, + /* eslint-enable no-unused-vars */ + ...componentProps + } = this.props; + return ( + <React.Fragment> + <WrappedComponent + onStartSelectingFileUpload={this.handleStartSelectingFileUpload} + {...componentProps} + /> + {fileInput} + </React.Fragment> + ); + } + } + + SBFileUploaderComponent.propTypes = { + canSave: PropTypes.bool, + cancelFileUpload: PropTypes.func, + closeFileMenu: PropTypes.func, + intl: intlShape.isRequired, + isLoadingUpload: PropTypes.bool, + isShowingWithoutId: PropTypes.bool, + loadingState: PropTypes.oneOf(LoadingStates), + onLoadingFinished: PropTypes.func, + onLoadingStarted: PropTypes.func, + onUpdateProjectTitle: PropTypes.func, + projectChanged: PropTypes.bool, + requestProjectUpload: PropTypes.func, + userOwnsProject: PropTypes.bool, + vm: PropTypes.shape({ + loadProject: PropTypes.func + }) + }; + const mapStateToProps = (state, ownProps) => { + const loadingState = state.scratchGui.projectState.loadingState; + const user = state.session && state.session.session && state.session.session.user; + return { + isLoadingUpload: getIsLoadingUpload(loadingState), + isShowingWithoutId: getIsShowingWithoutId(loadingState), + loadingState: loadingState, + projectChanged: state.scratchGui.projectChanged, + userOwnsProject: ownProps.authorUsername && user && + (ownProps.authorUsername === user.username) + // vm: state.scratchGui.vm + }; + }; + const mapDispatchToProps = (dispatch, ownProps) => ({ + cancelFileUpload: loadingState => dispatch(onLoadedProject(loadingState, false, false)), + closeFileMenu: () => dispatch(closeFileMenu()), + onLoadingFinished: (loadingState, success) => { + dispatch(onLoadedProject(loadingState, ownProps.canSave, success)); + dispatch(closeLoadingProject()); + dispatch(closeFileMenu()); + }, + onLoadingStarted: () => dispatch(openLoadingProject()), + requestProjectUpload: loadingState => dispatch(requestProjectUpload(loadingState)) + }); + // Allow incoming props to override redux-provided props. Used to mock in tests. + const mergeProps = (stateProps, dispatchProps, ownProps) => Object.assign( + {}, stateProps, dispatchProps, ownProps + ); + return injectIntl(connect( + mapStateToProps, + mapDispatchToProps, + mergeProps + )(SBFileUploaderComponent)); +}; + +export { + SBFileUploaderHOC as default +}; diff --git a/src/reducers/project-state.js b/src/reducers/project-state.js index 91e51bbe4..a26d7c971 100644 --- a/src/reducers/project-state.js +++ b/src/reducers/project-state.js @@ -17,7 +17,7 @@ const START_AUTO_UPDATING = 'scratch-gui/project-state/START_AUTO_UPDATING'; const START_CREATING_NEW = 'scratch-gui/project-state/START_CREATING_NEW'; const START_ERROR = 'scratch-gui/project-state/START_ERROR'; 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_LOADING_VM_FILE_UPLOAD = 'scratch-gui/project-state/START_LOADING_VM_FILE_UPLOAD'; const START_MANUAL_UPDATING = 'scratch-gui/project-state/START_MANUAL_UPDATING'; const START_REMIXING = 'scratch-gui/project-state/START_REMIXING'; const START_UPDATING_BEFORE_CREATING_COPY = 'scratch-gui/project-state/START_UPDATING_BEFORE_CREATING_COPY'; @@ -435,10 +435,21 @@ const onLoadedProject = (loadingState, canSave, success) => { default: return; } + } else { + switch (loadingState) { + case LoadingState.LOADING_VM_WITH_ID: + case LoadingState.LOADING_VM_FILE_UPLOAD: + return { + type: RETURN_TO_SHOWING + }; + case LoadingState.LOADING_VM_NEW_DEFAULT: + return { + type: START_ERROR + }; + default: + return; + } } - return { - type: RETURN_TO_SHOWING - }; }; const doneUpdatingProject = loadingState => { diff --git a/test/unit/containers/sb-file-uploader.test.jsx b/test/unit/containers/sb-file-uploader.test.jsx deleted file mode 100644 index c757114b4..000000000 --- a/test/unit/containers/sb-file-uploader.test.jsx +++ /dev/null @@ -1,84 +0,0 @@ -import React from 'react'; - -import {shallowWithIntl} from '../../helpers/intl-helpers.jsx'; -import configureStore from 'redux-mock-store'; -import SBFileUploader from '../../../src/containers/sb-file-uploader'; -import {LoadingState} from '../../../src/reducers/project-state'; - -jest.mock('react-ga'); // must mock this entire library, or lib/analytics causes error - -describe('SBFileUploader Container', () => { - const mockStore = configureStore(); - let onLoadingFinished; - let onLoadingStarted; - let store; - - // Wrap this in a function so it gets test specific states and can be reused. - const getContainer = function () { - return ( - <SBFileUploader - onLoadingFinished={onLoadingFinished} - onLoadingStarted={onLoadingStarted} - > - {(renderFileInput, loadProject) => ( - <div - onClick={loadProject} - /> - )} - </SBFileUploader> - ); - }; - - beforeEach(() => { - store = mockStore({ - scratchGui: { - projectState: { - loadingState: LoadingState.SHOWING_WITH_ID - }, - vm: {} - } - }); - onLoadingFinished = jest.fn(); - onLoadingStarted = jest.fn(); - }); - - test('correctly sets title with .sb3 filename', () => { - const wrapper = shallowWithIntl(getContainer(), {context: {store}}); - const instance = wrapper - .dive() // unwrap redux Connect(InjectIntl(SBFileUploader)) - .dive() // unwrap InjectIntl(SBFileUploader) - .instance(); // SBFileUploader - const projectName = instance.getProjectTitleFromFilename('my project is great.sb3'); - expect(projectName).toBe('my project is great'); - }); - - test('correctly sets title with .sb2 filename', () => { - const wrapper = shallowWithIntl(getContainer(), {context: {store}}); - const instance = wrapper - .dive() // unwrap redux Connect(InjectIntl(SBFileUploader)) - .dive() // unwrap InjectIntl(SBFileUploader) - .instance(); // SBFileUploader - const projectName = instance.getProjectTitleFromFilename('my project is great.sb2'); - expect(projectName).toBe('my project is great'); - }); - - test('correctly sets title with .sb filename', () => { - const wrapper = shallowWithIntl(getContainer(), {context: {store}}); - const instance = wrapper - .dive() // unwrap redux Connect(InjectIntl(SBFileUploader)) - .dive() // unwrap InjectIntl(SBFileUploader) - .instance(); // SBFileUploader - const projectName = instance.getProjectTitleFromFilename('my project is great.sb'); - expect(projectName).toBe('my project is great'); - }); - - test('sets blank title with filename with no extension', () => { - const wrapper = shallowWithIntl(getContainer(), {context: {store}}); - const instance = wrapper - .dive() // unwrap redux Connect(InjectIntl(SBFileUploader)) - .dive() // unwrap InjectIntl(SBFileUploader) - .instance(); // SBFileUploader - const projectName = instance.getProjectTitleFromFilename('my project is great'); - expect(projectName).toBe(''); - }); -}); diff --git a/test/unit/reducers/project-state-reducer.test.js b/test/unit/reducers/project-state-reducer.test.js index 2683c5bee..6938085a3 100644 --- a/test/unit/reducers/project-state-reducer.test.js +++ b/test/unit/reducers/project-state-reducer.test.js @@ -92,16 +92,49 @@ test('onFetchedProjectData new loads project data into vm', () => { expect(resultState.projectData).toBe('1010101'); }); -test('onLoadedProject upload, with canSave false, shows without id', () => { +// onLoadedProject: LOADING_VM_WITH_ID + +test('onLoadedProject (LOADING_VM_WITH_ID, true, true) shows with id', () => { const initialState = { - loadingState: LoadingState.LOADING_VM_FILE_UPLOAD + loadingState: LoadingState.LOADING_VM_WITH_ID }; - const action = onLoadedProject(initialState.loadingState, false, true); + const action = onLoadedProject(initialState.loadingState, true, true); + const resultState = projectStateReducer(initialState, action); + expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITH_ID); +}); + +test('onLoadedProject (LOADING_VM_WITH_ID, false, true) shows with id', () => { + const initialState = { + loadingState: LoadingState.LOADING_VM_WITH_ID + }; + const action = onLoadedProject(initialState.loadingState, true, true); + const resultState = projectStateReducer(initialState, action); + expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITH_ID); +}); + +test('onLoadedProject (LOADING_VM_WITH_ID, false, false), with project id, shows with id', () => { + const initialState = { + loadingState: LoadingState.LOADING_VM_WITH_ID, + projectId: '12345' + }; + const action = onLoadedProject(initialState.loadingState, false, false); + const resultState = projectStateReducer(initialState, action); + expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITH_ID); +}); + +test('onLoadedProject (LOADING_VM_WITH_ID, false, false), with no project id, shows without id', () => { + const initialState = { + loadingState: LoadingState.LOADING_VM_WITH_ID, + projectId: null + }; + const action = onLoadedProject(initialState.loadingState, false, false); const resultState = projectStateReducer(initialState, action); expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITHOUT_ID); }); -test('onLoadedProject upload, with canSave true, prepares to save', () => { +// onLoadedProject: LOADING_VM_FILE_UPLOAD + +test('onLoadedProject(LOADING_VM_FILE_UPLOAD, true, true) prepares to save', () => { const initialState = { loadingState: LoadingState.LOADING_VM_FILE_UPLOAD }; @@ -110,25 +143,38 @@ test('onLoadedProject upload, with canSave true, prepares to save', () => { expect(resultState.loadingState).toBe(LoadingState.AUTO_UPDATING); }); -test('onLoadedProject with id shows with id', () => { +test('onLoadedProject (LOADING_VM_FILE_UPLOAD, false, true) shows without id', () => { const initialState = { - loadingState: LoadingState.LOADING_VM_WITH_ID + loadingState: LoadingState.LOADING_VM_FILE_UPLOAD }; - const action = onLoadedProject(initialState.loadingState, true, true); + const action = onLoadedProject(initialState.loadingState, false, true); + const resultState = projectStateReducer(initialState, action); + expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITHOUT_ID); +}); + +test('onLoadedProject (LOADING_VM_FILE_UPLOAD, false, false), with project id, shows with id', () => { + const initialState = { + loadingState: LoadingState.LOADING_VM_FILE_UPLOAD, + projectId: '12345' + }; + const action = onLoadedProject(initialState.loadingState, false, false); const resultState = projectStateReducer(initialState, action); expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITH_ID); }); -test('onLoadedProject new shows without id', () => { +test('onLoadedProject (LOADING_VM_FILE_UPLOAD, false, false), with no project id, shows without id', () => { const initialState = { - loadingState: LoadingState.LOADING_VM_NEW_DEFAULT + loadingState: LoadingState.LOADING_VM_FILE_UPLOAD, + projectId: null }; - const action = onLoadedProject(initialState.loadingState, false, true); + const action = onLoadedProject(initialState.loadingState, false, false); const resultState = projectStateReducer(initialState, action); expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITHOUT_ID); }); -test('onLoadedProject new, to save shows without id', () => { +// onLoadedProject: LOADING_VM_NEW_DEFAULT + +test('onLoadedProject (LOADING_VM_NEW_DEFAULT, true, true) shows without id', () => { const initialState = { loadingState: LoadingState.LOADING_VM_NEW_DEFAULT }; @@ -137,26 +183,26 @@ test('onLoadedProject new, to save shows without id', () => { expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITHOUT_ID); }); -test('onLoadedProject with success false, no project id, shows without id', () => { +test('onLoadedProject (LOADING_VM_NEW_DEFAULT, false, true) shows without id', () => { const initialState = { - loadingState: LoadingState.LOADING_VM_WITH_ID, - projectId: null + loadingState: LoadingState.LOADING_VM_NEW_DEFAULT }; - const action = onLoadedProject(initialState.loadingState, false, false); + const action = onLoadedProject(initialState.loadingState, false, true); const resultState = projectStateReducer(initialState, action); expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITHOUT_ID); }); -test('onLoadedProject with success false, valid project id, shows with id', () => { +test('onLoadedProject (LOADING_VM_NEW_DEFAULT, false, false) shows error', () => { const initialState = { - loadingState: LoadingState.LOADING_VM_WITH_ID, - projectId: '12345' + loadingState: LoadingState.LOADING_VM_NEW_DEFAULT }; const action = onLoadedProject(initialState.loadingState, false, false); const resultState = projectStateReducer(initialState, action); - expect(resultState.loadingState).toBe(LoadingState.SHOWING_WITH_ID); + expect(resultState.loadingState).toBe(LoadingState.ERROR); }); +// doneUpdatingProject + test('doneUpdatingProject with id shows with id', () => { const initialState = { loadingState: LoadingState.MANUAL_UPDATING diff --git a/test/unit/util/project-saver-hoc.test.jsx b/test/unit/util/project-saver-hoc.test.jsx index 0ec3190bf..f5ac7b680 100644 --- a/test/unit/util/project-saver-hoc.test.jsx +++ b/test/unit/util/project-saver-hoc.test.jsx @@ -56,7 +56,7 @@ describe('projectSaverHOC', () => { expect(mockedUpdateProject).toHaveBeenCalled(); }); - test('if canSave is alreatdy true and we show a project with an id, project will NOT be saved', () => { + test('if canSave is already 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); diff --git a/test/unit/util/sb-file-uploader-hoc.test.jsx b/test/unit/util/sb-file-uploader-hoc.test.jsx new file mode 100644 index 000000000..c200157c9 --- /dev/null +++ b/test/unit/util/sb-file-uploader-hoc.test.jsx @@ -0,0 +1,108 @@ +import 'web-audio-test-api'; + +import React from 'react'; +import configureStore from 'redux-mock-store'; +import {mountWithIntl, shallowWithIntl} from '../../helpers/intl-helpers.jsx'; +import {LoadingState} from '../../../src/reducers/project-state'; +import VM from 'scratch-vm'; + +import SBFileUploaderHOC from '../../../src/lib/sb-file-uploader-hoc.jsx'; + +describe('SBFileUploaderHOC', () => { + const mockStore = configureStore(); + let store; + let vm; + + // Wrap this in a function so it gets test specific states and can be reused. + const getContainer = function () { + const Component = () => <div />; + return SBFileUploaderHOC(Component); + }; + + const shallowMountWithContext = component => ( + shallowWithIntl(component, {context: {store}}) + ); + + const unwrappedInstance = () => { + const WrappedComponent = getContainer(); + // default starting state: looking at a project you created, not logged in + const wrapper = shallowMountWithContext( + <WrappedComponent + projectChanged + canSave={false} + cancelFileUpload={jest.fn()} + closeFileMenu={jest.fn()} + requestProjectUpload={jest.fn()} + userOwnsProject={false} + vm={vm} + onLoadingFinished={jest.fn()} + onLoadingStarted={jest.fn()} + onUpdateProjectTitle={jest.fn()} + /> + ); + return wrapper + .dive() // unwrap intl + .dive() // unwrap redux Connect(SBFileUploaderComponent) + .instance(); // SBFileUploaderComponent + }; + + beforeEach(() => { + vm = new VM(); + store = mockStore({ + scratchGui: { + projectState: { + loadingState: LoadingState.SHOWING_WITHOUT_ID + }, + vm: {} + }, + locales: { + locale: 'en' + } + }); + }); + + test('correctly sets title with .sb3 filename', () => { + const projectName = unwrappedInstance().getProjectTitleFromFilename('my project is great.sb3'); + expect(projectName).toBe('my project is great'); + }); + + test('correctly sets title with .sb2 filename', () => { + const projectName = unwrappedInstance().getProjectTitleFromFilename('my project is great.sb2'); + expect(projectName).toBe('my project is great'); + }); + + test('correctly sets title with .sb filename', () => { + const projectName = unwrappedInstance().getProjectTitleFromFilename('my project is great.sb'); + expect(projectName).toBe('my project is great'); + }); + + test('sets blank title with filename with no extension', () => { + const projectName = unwrappedInstance().getProjectTitleFromFilename('my project is great'); + expect(projectName).toBe(''); + }); + + test('if isLoadingUpload becomes true, without fileToUpload set, will call cancelFileUpload', () => { + const mockedCancelFileUpload = jest.fn(); + const WrappedComponent = getContainer(); + const mounted = mountWithIntl( + <WrappedComponent + projectChanged + canSave={false} + cancelFileUpload={mockedCancelFileUpload} + closeFileMenu={jest.fn()} + isLoadingUpload={false} + requestProjectUpload={jest.fn()} + store={store} + userOwnsProject={false} + vm={vm} + onLoadingFinished={jest.fn()} + onLoadingStarted={jest.fn()} + onUpdateProjectTitle={jest.fn()} + /> + ); + mounted.setProps({ + isLoadingUpload: true + }); + expect(mockedCancelFileUpload).toHaveBeenCalled(); + }); +}); -- GitLab