diff --git a/src/components/gui/gui.jsx b/src/components/gui/gui.jsx index 9846eed7566356ed1e5395def9b59388bb870958..8524b2ba36cdf19ebe818ece46fa79021042dc64 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 98bc128a92f11fe272d674513930e53f897d58bb..05a5294bc30eadfe41a723f7712bc4b3fcb0e837 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 314c38893518b0a8e5a8bce6a847dc008a6ac5b6..c10df0fb3e9bf73c3ca60d342f8d4f155a6e61c0 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 2019f719e978a9c485b540488f873cb8046644ee..0000000000000000000000000000000000000000 --- 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 0000000000000000000000000000000000000000..9e34b57684ef9390d6d9517d47e1ea3c1c8863ee --- /dev/null +++ b/src/lib/sb-file-uploader-hoc.jsx @@ -0,0 +1,269 @@ +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, [ + 'createFileObjects', + 'getProjectTitleFromFilename', + 'handleFinishedLoadingUpload', + 'handleStartSelectingFileUpload', + 'handleChange', + 'onload', + 'removeFileObjects' + ]); + } + componentDidUpdate (prevProps) { + if (this.props.isLoadingUpload && !prevProps.isLoadingUpload) { + this.handleFinishedLoadingUpload(); // cue step 5 below + } + } + componentWillUnmount () { + this.removeFileObjects(); + } + // step 1: this is where the upload process begins + handleStartSelectingFileUpload () { + this.createFileObjects(); // go to step 2 + } + // step 2: create a FileReader and an <input> element, and issue a + // pseudo-click to it. That will open the file chooser dialog. + createFileObjects () { + // redo step 7, in case it got skipped last time and its objects are + // still in memory + this.removeFileObjects(); + // create fileReader + this.fileReader = new FileReader(); + this.fileReader.onload = this.onload; + // create <input> element and add it to DOM + this.inputElement = document.createElement('input'); + this.inputElement.accept = '.sb,.sb2,.sb3'; + this.inputElement.style = 'display: none;'; + this.inputElement.type = 'file'; + this.inputElement.onchange = this.handleChange; // connects to step 3 + document.body.appendChild(this.inputElement); + // simulate a click to open file chooser dialog + this.inputElement.click(); + } + // step 3: user has picked a file using the file chooser dialog. + // We don't actually load the file here, we only decide whether to do so. + 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) { + // cues step 4 + this.props.requestProjectUpload(loadingState); + } else { + // skips ahead to step 7 + this.removeFileObjects(); + } + this.props.closeFileMenu(); + } + } + // step 4 is below, in mapDispatchToProps + + // step 5: called from componentDidUpdate when project state shows + // that project data has finished "uploading" into the browser + handleFinishedLoadingUpload () { + if (this.fileToUpload && this.fileReader) { + // begin to read data from the file. When finished, + // cues step 6 using the reader's onload callback + this.fileReader.readAsArrayBuffer(this.fileToUpload); + } else { + this.props.cancelFileUpload(this.props.loadingState); + // skip ahead to step 7 + this.removeFileObjects(); + } + } + // used in step 6 below + 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 + } + // step 6: attached as a handler on our FileReader object; called when + // file upload raw data is available in the reader + onload () { + if (this.fileReader) { + this.props.onLoadingStarted(); + const filename = this.fileToUpload && this.fileToUpload.name; + this.props.vm.loadProject(this.fileReader.result) + .then(() => { + this.props.onLoadingFinished(this.props.loadingState, true); + if (filename) { + const uploadedProjectTitle = this.getProjectTitleFromFilename(filename); + this.props.onUpdateProjectTitle(uploadedProjectTitle); + } + }) + .catch(error => { + log.warn(error); + this.props.intl.formatMessage(messages.loadError); + this.props.onLoadingFinished(this.props.loadingState, false); + }) + .then(() => { + // go back to step 7: whether project loading succeeded + // or failed, reset file objects + this.removeFileObjects(); + }); + } + } + // step 7: remove the <input> element from the DOM and clear reader and + // fileToUpload reference, so those objects can be garbage collected + removeFileObjects () { + if (this.inputElement) { + this.inputElement.value = null; + document.body.removeChild(this.inputElement); + } + this.inputElement = null; + this.fileReader = null; + this.fileToUpload = null; + } + render () { + const { + /* eslint-disable no-unused-vars */ + cancelFileUpload, + 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} + /> + </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()), + // transition project state from loading to regular, and close + // loading screen and file menu + onLoadingFinished: (loadingState, success) => { + dispatch(onLoadedProject(loadingState, ownProps.canSave, success)); + dispatch(closeLoadingProject()); + dispatch(closeFileMenu()); + }, + // show project loading screen + onLoadingStarted: () => dispatch(openLoadingProject()), + // step 4: transition the project state so we're ready to handle the new + // project data. When this is done, the project state transition will be + // noticed by componentDidUpdate() + 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/test/integration/menu-bar.test.js b/test/integration/menu-bar.test.js index 3f6457e5208ae171685e62f70327a3f3463a5f4c..c83f1022d214a69f4bfa7cf019971334f4c4e153 100644 --- a/test/integration/menu-bar.test.js +++ b/test/integration/menu-bar.test.js @@ -75,6 +75,7 @@ describe('Menu bar settings', () => { test('User is not warned before uploading project file over a fresh project', async () => { await loadUri(uri); await clickText('File'); + await clickText('Load from your computer'); const input = await findByXpath('//input[@accept=".sb,.sb2,.sb3"]'); await input.sendKeys(path.resolve(__dirname, '../fixtures/project1.sb3')); // No replace alert since no changes were made @@ -89,6 +90,7 @@ describe('Menu bar settings', () => { await clickText('delete', scope.spriteTile); await clickText('File'); + await clickText('Load from your computer'); const input = await findByXpath('//input[@accept=".sb,.sb2,.sb3"]'); await input.sendKeys(path.resolve(__dirname, '../fixtures/project1.sb3')); await driver.switchTo().alert() 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 c757114b4ca7e387c78e7477931cf31a04c91a9a..0000000000000000000000000000000000000000 --- 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/util/sb-file-uploader-hoc.test.jsx b/test/unit/util/sb-file-uploader-hoc.test.jsx new file mode 100644 index 0000000000000000000000000000000000000000..c200157c984d5afaf1ff384da292a0b417985210 --- /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(); + }); +});