diff --git a/README.md b/README.md index 663780144020b403e34a5f5ab71f9de7e0a07573..d5f75a62d2caa98e27e121c428e6f51de21898f3 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,11 @@ npm start Then go to [http://localhost:8601/](http://localhost:8601/) - the playground outputs the default GUI component ## Developing alongside other Scratch repositories + +### Linking this code to another project's `node_modules/scratch-gui` + +#### Configuration + If you wish to develop scratch-gui alongside other scratch repositories that depend on it, you may wish to have the other repositories use your local scratch-gui build instead of fetching the current production version of the scratch-gui that is found by default using `npm install`. @@ -43,7 +48,7 @@ To do this: Instead of `BUILD_MODE=dist npm run build` you can also use `BUILD_MODE=dist npm run watch`, however this may be unreliable. -### Oh no! It didn't work! +#### Oh no! It didn't work! * Follow the recipe above step by step and don't change the order. It is especially important to run npm first because installing after the linking will reset the linking. * Make sure the repositories are siblings on your machine's file tree. * If you have multiple Terminal tabs or windows open for the different Scratch repositories, make sure to use the same node version in all of them. diff --git a/package.json b/package.json index 233c56ebed9254ca3ad2adc334e00d84988fa21e..a50e24b003e456f18deabebadc406cbc33ec942d 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "babel-preset-react": "^6.22.0", "base64-loader": "1.0.0", "bowser": "1.9.4", - "chromedriver": "2.42.0", + "chromedriver": "2.42.1", "classnames": "2.2.6", "copy-webpack-plugin": "^4.5.1", "core-js": "2.5.7", @@ -74,6 +74,7 @@ "postcss-loader": "^3.0.0", "postcss-simple-vars": "^5.0.1", "prop-types": "^15.5.10", + "query-string": "^5.1.1", "raf": "^3.4.0", "raw-loader": "^0.5.1", "react": "16.2.0", @@ -96,13 +97,13 @@ "redux-throttle": "0.1.1", "rimraf": "^2.6.1", "scratch-audio": "0.1.0-prerelease.20180625202813", - "scratch-blocks": "0.1.0-prerelease.1539092006", - "scratch-l10n": "3.0.20181004141631", + "scratch-blocks": "0.1.0-prerelease.1539267627", + "scratch-l10n": "3.0.20181010220115", "scratch-paint": "0.2.0-prerelease.20181010194950", "scratch-render": "0.1.0-prerelease.20181002192350", "scratch-storage": "1.0.4", "scratch-svg-renderer": "0.2.0-prerelease.20180926143036", - "scratch-vm": "0.2.0-prerelease.20181010193639", + "scratch-vm": "0.2.0-prerelease.20181011142759", "selenium-webdriver": "3.6.0", "startaudiocontext": "1.2.1", "style-loader": "^0.23.0", diff --git a/src/components/cards/cards.jsx b/src/components/cards/cards.jsx index 24e3d6edd94624f64fb6d52b2dc5f6e4635d6801..335849e9e4ee72b357c3253d19b2eb8dfc33ccba 100644 --- a/src/components/cards/cards.jsx +++ b/src/components/cards/cards.jsx @@ -64,12 +64,18 @@ const VideoStep = ({video, dragging}) => ( ) : null} <iframe allowFullScreen - allow="autoplay; encrypted-media" + allowTransparency="true" frameBorder="0" - height="337" - src={`${video}?rel=0&showinfo=0`} + height="338" + scrolling="no" + src={`https://fast.wistia.net/embed/iframe/${video}?seo=false&videoFoam=true`} + title="📹" width="600" /> + <script + async + src="https://fast.wistia.net/assets/external/E-v1.js" + /> </div> ); diff --git a/src/components/gui/gui.css b/src/components/gui/gui.css index 16f79ea4d684308cf8a6095bb4246b7966eb87af..a10b08b60a4d34e30ae137cfa4f274ab559fa6ff 100644 --- a/src/components/gui/gui.css +++ b/src/components/gui/gui.css @@ -279,6 +279,12 @@ $fade-out-distance: 15px; margin-top: 0; } +/* Menu */ + +.menu-bar-position { + position: relative; + z-index: $z-index-menu-bar; +} /* Alerts */ .alerts-container { diff --git a/src/components/gui/gui.jsx b/src/components/gui/gui.jsx index 9cdafaa8cfb31143e74cf879ffcfaef8a0d7e6ca..d39e2283320384f35a81e0185c6d7d01f59fbe92 100644 --- a/src/components/gui/gui.jsx +++ b/src/components/gui/gui.jsx @@ -164,6 +164,7 @@ const GUIComponent = props => { ) : null} <MenuBar accountNavOpen={accountNavOpen} + className={styles.menuBarPosition} enableCommunity={enableCommunity} renderLogin={renderLogin} onClickAccountNav={onClickAccountNav} diff --git a/src/components/language-selector/language-selector.css b/src/components/language-selector/language-selector.css index 25b9951608e138250d158906fcd56833a3790348..553183f77d031ec89d30c7673027e174792d351f 100644 --- a/src/components/language-selector/language-selector.css +++ b/src/components/language-selector/language-selector.css @@ -1,10 +1,6 @@ @import "../../css/colors.css"; @import "../../css/units.css"; -.language-icon { - height: 1.5rem; -} - /* Position the language select over the language icon, and make it transparent */ .language-select { position: absolute; diff --git a/src/components/menu-bar/menu-bar.css b/src/components/menu-bar/menu-bar.css index dcc230672499634335229e1e3295bd626319970a..0e01803b57ab6f388d1e349197da6d3c7e2995d7 100644 --- a/src/components/menu-bar/menu-bar.css +++ b/src/components/menu-bar/menu-bar.css @@ -47,10 +47,11 @@ .language-icon { height: 1.5rem; + vertical-align: middle; } .language-caret { - margin-bottom: .625rem; + margin: 0 .125rem; } .language-menu { @@ -100,6 +101,10 @@ padding: 0 0.75rem; } +.menu-bar-item.language-menu { + padding: 0 0.5rem; +} + .menu-bar-menu { margin-top: $menu-bar-height; z-index: $z-index-menu-bar; diff --git a/src/components/menu-bar/menu-bar.jsx b/src/components/menu-bar/menu-bar.jsx index 7d321ede9ecb4c068e1fc2c8a0de36af2ff6cc9a..81715e607114e540fce495bbbd7e3f06288930f9 100644 --- a/src/components/menu-bar/menu-bar.jsx +++ b/src/components/menu-bar/menu-bar.jsx @@ -10,18 +10,24 @@ import Button from '../button/button.jsx'; import {ComingSoonTooltip} from '../coming-soon/coming-soon.jsx'; import Divider from '../divider/divider.jsx'; import LanguageSelector from '../../containers/language-selector.jsx'; -import ProjectLoader from '../../containers/project-loader.jsx'; +import SBFileUploader from '../../containers/sb-file-uploader.jsx'; import MenuBarMenu from './menu-bar-menu.jsx'; import {MenuItem, MenuSection} from '../menu/menu.jsx'; import ProjectTitleInput from './project-title-input.jsx'; import AccountNav from '../../containers/account-nav.jsx'; import LoginDropdown from './login-dropdown.jsx'; -import ProjectSaver from '../../containers/project-saver.jsx'; +import SB3Downloader from '../../containers/sb3-downloader.jsx'; import DeletionRestorer from '../../containers/deletion-restorer.jsx'; import TurboMode from '../../containers/turbo-mode.jsx'; import {openTipsLibrary} from '../../reducers/modals'; import {setPlayer} from '../../reducers/mode'; +import { + getIsUpdating, + getIsShowingProject, + requestNewProject, + saveProject +} from '../../reducers/project-state'; import { openAccountMenu, closeAccountMenu, @@ -123,42 +129,50 @@ class MenuBar extends React.Component { constructor (props) { super(props); bindAll(this, [ + 'handleClickNew', + 'handleClickSave', + 'handleCloseFileMenuAndThen', 'handleLanguageMouseUp', 'handleRestoreOption', - 'handleCloseFileMenuAndThen', 'restoreOptionMessage' ]); - this.state = {projectSaveInProgress: false}; } - handleLanguageMouseUp (e) { - if (!this.props.languageMenuOpen) { - this.props.onClickLanguage(e); + componentDidUpdate (prevProps) { + // if we're no longer showing the project (loading, or whatever), close menus + if (this.props.isShowingProject && !prevProps.isShowingProject) { + this.props.onRequestCloseFile(); + this.props.onRequestCloseEdit(); + } + } + handleClickNew () { + const canSave = this.props.canUpdateProject; // logged in + // if canSave===true, it's safe to replace current project, since we will auto-save first + const readyToReplaceProject = + canSave || confirm('Replace contents of the current project?'); // eslint-disable-line no-alert + if (readyToReplaceProject) { + this.props.onClickNew(canSave); } } + handleClickSave () { + this.props.onClickSave(); + } handleRestoreOption (restoreFun) { return () => { restoreFun(); this.props.onRequestCloseEdit(); }; } - handleUpdateProject (updateFun) { - return () => { - this.props.onRequestCloseFile(); - this.setState({projectSaveInProgress: true}, - () => { - updateFun().then(() => { - this.setState({projectSaveInProgress: false}); - }); - } - ); - }; - } handleCloseFileMenuAndThen (fn) { return () => { this.props.onRequestCloseFile(); fn(); }; } + handleLanguageMouseUp (e) { + if (!this.props.languageMenuOpen) { + this.props.onClickLanguage(e); + } + } restoreOptionMessage (deletedItem) { switch (deletedItem) { case 'Sprite': @@ -196,6 +210,13 @@ class MenuBar extends React.Component { id="gui.menuBar.saveNow" /> ); + const newProjectMessage = ( + <FormattedMessage + defaultMessage="New" + description="Menu bar item for creating a new project" + id="gui.menuBar.new" + /> + ); const shareButton = ( <Button className={classNames(styles.shareButton)} @@ -210,9 +231,11 @@ class MenuBar extends React.Component { ); return ( <Box - className={classNames(styles.menuBar, { - [styles.saveInProgress]: this.state.projectSaveInProgress - })} + className={classNames( + this.props.className, + styles.menuBar, + {[styles.saveInProgress]: this.props.isUpdating} + )} > <div className={styles.mainMenu}> <div className={styles.fileGroup}> @@ -262,33 +285,35 @@ class MenuBar extends React.Component { place={this.props.isRtl ? 'left' : 'right'} onRequestClose={this.props.onRequestCloseFile} > - <MenuItemTooltip - id="new" - isRtl={this.props.isRtl} - > - <MenuItem> - <FormattedMessage - defaultMessage="New" - description="Menu bar item for creating a new project" - id="gui.menuBar.new" - /> + {/* for now, only enable New when there is no session */} + {this.props.sessionExists ? ( + <MenuItemTooltip + id="new" + isRtl={this.props.isRtl} + > + <MenuItem>{newProjectMessage}</MenuItem> + </MenuItemTooltip> + ) : ( + <MenuItem + isRtl={this.props.isRtl} + onClick={this.handleClickNew} + > + {newProjectMessage} </MenuItem> - </MenuItemTooltip> + )} <MenuSection> - <ProjectSaver>{(saveProject, updateProject) => ( - this.props.canUpdateProject ? ( - <MenuItem onClick={this.handleUpdateProject(updateProject)}> - {saveNowMessage} - </MenuItem> - ) : ( - <MenuItemTooltip - id="save" - isRtl={this.props.isRtl} - > - <MenuItem>{saveNowMessage}</MenuItem> - </MenuItemTooltip> - ) - )}</ProjectSaver> + {this.props.canUpdateProject ? ( + <MenuItem onClick={this.handleClickSave}> + {saveNowMessage} + </MenuItem> + ) : ( + <MenuItemTooltip + id="save" + isRtl={this.props.isRtl} + > + <MenuItem>{saveNowMessage}</MenuItem> + </MenuItemTooltip> + )} <MenuItemTooltip id="copy" isRtl={this.props.isRtl} @@ -303,22 +328,25 @@ class MenuBar extends React.Component { </MenuItemTooltip> </MenuSection> <MenuSection> - <ProjectLoader>{(renderFileInput, loadProject, loadProps) => ( - <MenuItem - onClick={loadProject} - {...loadProps} - > - <FormattedMessage - defaultMessage="Load from your computer" - description="Menu bar item for uploading a project from your computer" - id="gui.menuBar.uploadFromComputer" - /> - {renderFileInput()} - </MenuItem> - )}</ProjectLoader> - <ProjectSaver>{saveProject => ( + <SBFileUploader> + {(renderFileInput, loadProject) => ( + <MenuItem + onClick={loadProject} + > + <FormattedMessage + defaultMessage="Load from your computer" + description={ + 'Menu bar item for uploading a project from your computer' + } + id="gui.menuBar.uploadFromComputer" + /> + {renderFileInput()} + </MenuItem> + )} + </SBFileUploader> + <SB3Downloader>{downloadProject => ( <MenuItem - onClick={this.handleCloseFileMenuAndThen(saveProject)} + onClick={this.handleCloseFileMenuAndThen(downloadProject)} > <FormattedMessage defaultMessage="Save to your computer" @@ -326,7 +354,7 @@ class MenuBar extends React.Component { id="gui.menuBar.downloadToComputer" /> </MenuItem> - )}</ProjectSaver> + )}</SB3Downloader> </MenuSection> </MenuBarMenu> </div> @@ -588,11 +616,14 @@ class MenuBar extends React.Component { MenuBar.propTypes = { accountMenuOpen: PropTypes.bool, canUpdateProject: PropTypes.bool, + className: PropTypes.string, editMenuOpen: PropTypes.bool, enableCommunity: PropTypes.bool, fileMenuOpen: PropTypes.bool, intl: intlShape, isRtl: PropTypes.bool, + isShowingProject: PropTypes.bool, + isUpdating: PropTypes.bool, languageMenuOpen: PropTypes.bool, loginMenuOpen: PropTypes.bool, onClickAccount: PropTypes.func, @@ -600,6 +631,8 @@ MenuBar.propTypes = { onClickFile: PropTypes.func, onClickLanguage: PropTypes.func, onClickLogin: PropTypes.func, + onClickNew: PropTypes.func, + onClickSave: PropTypes.func, onLogOut: PropTypes.func, onOpenRegistration: PropTypes.func, onOpenTipLibrary: PropTypes.func, @@ -609,25 +642,32 @@ MenuBar.propTypes = { onRequestCloseLanguage: PropTypes.func, onRequestCloseLogin: PropTypes.func, onSeeCommunity: PropTypes.func, + onShare: PropTypes.func, onToggleLoginOpen: PropTypes.func, onUpdateProjectTitle: PropTypes.func, renderLogin: PropTypes.func, sessionExists: PropTypes.bool, + startSaving: PropTypes.func, username: PropTypes.string }; -const mapStateToProps = state => ({ - canUpdateProject: typeof (state.session && state.session.session && state.session.session.user) !== 'undefined', - accountMenuOpen: accountMenuOpen(state), - fileMenuOpen: fileMenuOpen(state), - editMenuOpen: editMenuOpen(state), - isRtl: state.locales.isRtl, - languageMenuOpen: languageMenuOpen(state), - loginMenuOpen: loginMenuOpen(state), - sessionExists: state.session && typeof state.session.session !== 'undefined', - username: state.session && state.session.session && state.session.session.user ? - state.session.session.user.username : null -}); +const mapStateToProps = state => { + const loadingState = state.scratchGui.projectState.loadingState; + const user = state.session && state.session.session && state.session.session.user; + return { + accountMenuOpen: accountMenuOpen(state), + canUpdateProject: typeof user !== 'undefined', + fileMenuOpen: fileMenuOpen(state), + editMenuOpen: editMenuOpen(state), + isRtl: state.locales.isRtl, + isUpdating: getIsUpdating(loadingState), + isShowingProject: getIsShowingProject(loadingState), + languageMenuOpen: languageMenuOpen(state), + loginMenuOpen: loginMenuOpen(state), + sessionExists: state.session && typeof state.session.session !== 'undefined', + username: user ? user.username : null + }; +}; const mapDispatchToProps = dispatch => ({ onOpenTipLibrary: () => dispatch(openTipsLibrary()), @@ -641,6 +681,8 @@ const mapDispatchToProps = dispatch => ({ onRequestCloseLanguage: () => dispatch(closeLanguageMenu()), onClickLogin: () => dispatch(openLoginMenu()), onRequestCloseLogin: () => dispatch(closeLoginMenu()), + onClickNew: canSave => dispatch(requestNewProject(canSave)), + onClickSave: () => dispatch(saveProject()), onSeeCommunity: () => dispatch(setPlayer(true)) }); diff --git a/src/components/sprite-selector/sprite-selector.jsx b/src/components/sprite-selector/sprite-selector.jsx index ff594d0f1184cd461d044351025b31b83a7c29eb..d8a719cb5f7af3481916e05fa83830b6b2dcce9c 100644 --- a/src/components/sprite-selector/sprite-selector.jsx +++ b/src/components/sprite-selector/sprite-selector.jsx @@ -7,7 +7,7 @@ import SpriteInfo from '../../containers/sprite-info.jsx'; import SpriteList from './sprite-list.jsx'; import ActionMenu from '../action-menu/action-menu.jsx'; import {STAGE_DISPLAY_SIZES} from '../../lib/layout-constants'; -import {rtlLocales} from '../../lib/locale-utils'; +import {isRtl} from 'scratch-l10n'; import styles from './sprite-selector.css'; @@ -140,7 +140,7 @@ const SpriteSelectorComponent = function (props) { } ]} title={intl.formatMessage(messages.addSpriteFromLibrary)} - tooltipPlace={rtlLocales.indexOf(intl.locale) === -1 ? 'left' : 'right'} + tooltipPlace={isRtl(intl.locale) ? 'left' : 'right'} onClick={onNewSpriteClick} /> </Box> diff --git a/src/containers/blocks.jsx b/src/containers/blocks.jsx index bf759bb1b89eade4eb1f4b01446faeecfe075c81..57c91e3c665b342ae5b951136fe459044a2c6a30 100644 --- a/src/containers/blocks.jsx +++ b/src/containers/blocks.jsx @@ -21,9 +21,14 @@ import {STAGE_DISPLAY_SIZES} from '../lib/layout-constants'; import {connect} from 'react-redux'; import {updateToolbox} from '../reducers/toolbox'; import {activateColorPicker} from '../reducers/color-picker'; -import {closeExtensionLibrary} from '../reducers/modals'; +import {closeExtensionLibrary, openSoundRecorder} from '../reducers/modals'; import {activateCustomProcedures, deactivateCustomProcedures} from '../reducers/custom-procedures'; +import { + activateTab, + SOUNDS_TAB_INDEX +} from '../reducers/editor-tab'; + const addFunctionListener = (object, property, callback) => { const oldFn = object[property]; object[property] = function () { @@ -44,6 +49,7 @@ class Blocks extends React.Component { 'handleConnectionModalStart', 'handleConnectionModalClose', 'handleStatusButtonUpdate', + 'handleOpenSoundRecorder', 'handlePromptStart', 'handlePromptCallback', 'handlePromptClose', @@ -63,6 +69,8 @@ class Blocks extends React.Component { ]); this.ScratchBlocks.prompt = this.handlePromptStart; this.ScratchBlocks.statusButtonCallback = this.handleConnectionModalStart; + this.ScratchBlocks.recordSoundCallback = this.handleOpenSoundRecorder; + this.state = { workspaceMetrics: {}, prompt: null, @@ -395,6 +403,9 @@ class Blocks extends React.Component { handleStatusButtonUpdate () { this.ScratchBlocks.refreshStatusButtons(this.workspace); } + handleOpenSoundRecorder () { + this.props.onOpenSoundRecorder(); + } handlePromptCallback (input, optionSelection) { this.state.prompt.callback( input, @@ -423,6 +434,7 @@ class Blocks extends React.Component { isRtl, isVisible, onActivateColorPicker, + onOpenSoundRecorder, updateToolboxState, onActivateCustomProcedures, onRequestCloseExtensionLibrary, @@ -486,6 +498,7 @@ Blocks.propTypes = { messages: PropTypes.objectOf(PropTypes.string), onActivateColorPicker: PropTypes.func, onActivateCustomProcedures: PropTypes.func, + onOpenSoundRecorder: PropTypes.func, onRequestCloseCustomProcedures: PropTypes.func, onRequestCloseExtensionLibrary: PropTypes.func, options: PropTypes.shape({ @@ -565,6 +578,10 @@ const mapStateToProps = state => ({ const mapDispatchToProps = dispatch => ({ onActivateColorPicker: callback => dispatch(activateColorPicker(callback)), onActivateCustomProcedures: (data, callback) => dispatch(activateCustomProcedures(data, callback)), + onOpenSoundRecorder: () => { + dispatch(activateTab(SOUNDS_TAB_INDEX)); + dispatch(openSoundRecorder()); + }, onRequestCloseExtensionLibrary: () => { dispatch(closeExtensionLibrary()); }, diff --git a/src/containers/gui.jsx b/src/containers/gui.jsx index 2c4c55be25a66bd4db6f011d6f908dc79c24a042..49b29672da7f6a9c459520bd62fecce3fe8d7d91 100644 --- a/src/containers/gui.jsx +++ b/src/containers/gui.jsx @@ -1,10 +1,9 @@ -import AudioEngine from 'scratch-audio'; import PropTypes from 'prop-types'; import React from 'react'; -import VM from 'scratch-vm'; +import {compose} from 'redux'; import {connect} from 'react-redux'; import ReactModal from 'react-modal'; -import bindAll from 'lodash.bindall'; +import VM from 'scratch-vm'; import ErrorBoundaryHOC from '../lib/error-boundary-hoc.jsx'; import {openExtensionLibrary} from '../reducers/modals'; @@ -21,117 +20,49 @@ import { closeBackdropLibrary } from '../reducers/modals'; -import ProjectLoaderHOC from '../lib/project-loader-hoc.jsx'; +import FontLoaderHOC from '../lib/font-loader-hoc.jsx'; +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 GUIComponent from '../components/gui/gui.jsx'; class GUI extends React.Component { - constructor (props) { - super(props); - bindAll(this, [ - 'loadProject' - ]); - this.state = { - loading: !props.vm.initialized, - loadingError: false, - errorMessage: '' - }; - } componentDidMount () { if (this.props.projectTitle) { this.props.onUpdateReduxProjectTitle(this.props.projectTitle); } - - if (this.props.vm.initialized) return; - this.audioEngine = new AudioEngine(); - this.props.vm.attachAudioEngine(this.audioEngine); - this.props.vm.initialized = true; - - const getFontPromises = () => { - const fontPromises = []; - // Browsers that support the font loader interface have an iterable document.fonts.values() - // Firefox has a mocked out object that doesn't actually implement iterable, which is why - // the deep safety check is necessary. - if (document.fonts && - typeof document.fonts.values === 'function' && - typeof document.fonts.values()[Symbol.iterator] === 'function') { - for (const fontFace of document.fonts.values()) { - fontPromises.push(fontFace.loaded); - fontFace.load(); - } - } - return fontPromises; - }; - - // Font promises must be gathered after the document is loaded, because on Mac Chrome, the promise - // objects get replaced and the old ones never resolve. - if (document.readyState === 'complete') { - Promise.all(getFontPromises()).then(this.loadProject); - } else { - document.onreadystatechange = () => { - if (document.readyState !== 'complete') return; - document.onreadystatechange = null; - Promise.all(getFontPromises()).then(this.loadProject); - }; - } } componentWillReceiveProps (nextProps) { - if (this.props.projectData !== nextProps.projectData) { - this.setState({loading: true}, () => { - this.props.vm.loadProject(nextProps.projectData) - .then(() => { - this.setState({loading: false}); - }) - .catch(e => { - // Need to catch this error and update component state so that - // error page gets rendered if project failed to load - this.setState({loadingError: true, errorMessage: e}); - }); - }); - } if (this.props.projectTitle !== nextProps.projectTitle) { this.props.onUpdateReduxProjectTitle(nextProps.projectTitle); } } - loadProject () { - return this.props.vm.loadProject(this.props.projectData) - .then(() => { - this.setState({loading: false}, () => { - this.props.vm.setCompatibilityMode(true); - this.props.vm.start(); - }); - }) - .catch(e => { - // Need to catch this error and update component state so that - // error page gets rendered if project failed to load - this.setState({loadingError: true, errorMessage: e}); - }); - } render () { - if (this.state.loadingError) { + if (this.props.loadingError) { throw new Error( - `Failed to load project from server [id=${window.location.hash}]: ${this.state.errorMessage}`); + `Failed to load project from server [id=${window.location.hash}]: ${this.props.errorMessage}`); } const { /* eslint-disable no-unused-vars */ assetHost, + errorMessage, hideIntro, + loadingError, onUpdateReduxProjectTitle, - projectData, projectHost, projectTitle, /* eslint-enable no-unused-vars */ children, fetchingProject, + isLoading, loadingStateVisible, - vm, ...componentProps } = this.props; return ( <GUIComponent - loading={fetchingProject || this.state.loading || loadingStateVisible} - vm={vm} + loading={fetchingProject || isLoading || loadingStateVisible} {...componentProps} > {children} @@ -143,18 +74,21 @@ class GUI extends React.Component { GUI.propTypes = { assetHost: PropTypes.string, children: PropTypes.node, + errorMessage: PropTypes.string, fetchingProject: PropTypes.bool, hideIntro: PropTypes.bool, importInfoVisible: PropTypes.bool, + isLoading: PropTypes.bool, + loadingError: PropTypes.bool, loadingStateVisible: PropTypes.bool, + onChangeProjectInfo: PropTypes.func, onSeeCommunity: PropTypes.func, onUpdateProjectTitle: PropTypes.func, onUpdateReduxProjectTitle: PropTypes.func, previewInfoVisible: PropTypes.bool, - projectData: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), projectHost: PropTypes.string, projectTitle: PropTypes.string, - vm: PropTypes.instanceOf(VM) + vm: PropTypes.instanceOf(VM).isRequired }; const mapStateToProps = (state, ownProps) => ({ @@ -175,7 +109,8 @@ const mapStateToProps = (state, ownProps) => ({ state.scratchGui.targets.stage.id === state.scratchGui.targets.editingTarget ), soundsTabVisible: state.scratchGui.editorTab.activeTabIndex === SOUNDS_TAB_INDEX, - tipsLibraryVisible: state.scratchGui.modals.tipsLibrary + tipsLibraryVisible: state.scratchGui.modals.tipsLibrary, + vm: state.scratchGui.vm }); const mapDispatchToProps = dispatch => ({ @@ -193,9 +128,17 @@ const ConnectedGUI = connect( mapDispatchToProps, )(GUI); -const WrappedGui = ErrorBoundaryHOC('Top Level App')( - ProjectLoaderHOC(vmListenerHOC(ConnectedGUI)) -); +// 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 +// ability to compose reducers. +const WrappedGui = compose( + ErrorBoundaryHOC('Top Level App'), + FontLoaderHOC, + ProjectFetcherHOC, + ProjectSaverHOC, + vmListenerHOC, + vmManagerHOC +)(ConnectedGUI); WrappedGui.setAppElement = ReactModal.setAppElement; export default WrappedGui; diff --git a/src/containers/project-loader.jsx b/src/containers/sb-file-uploader.jsx similarity index 72% rename from src/containers/project-loader.jsx rename to src/containers/sb-file-uploader.jsx index 66081b7a906871cef4a5230d9264cc434c2d3dcd..a5d8c99a70078a94be55d7570087a73c80bfe636 100644 --- a/src/containers/project-loader.jsx +++ b/src/containers/sb-file-uploader.jsx @@ -7,6 +7,7 @@ import {defineMessages, injectIntl, intlShape} from 'react-intl'; import analytics from '../lib/analytics'; import log from '../lib/log'; import {setProjectTitle} from '../reducers/project-title'; +import {LoadingStates, onLoadedProject, onProjectUploadStarted} from '../reducers/project-state'; import { openLoadingProject, @@ -14,20 +15,19 @@ import { } from '../reducers/modals'; /** - * Project loader component passes a file input, load handler and props to its child. + * 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, loadProject, props) {} + * function (renderFileInput, loadProject) {} * The component can then be used to attach project loading functionality * to any other component: * - * <ProjectLoader>{(renderFileInput, loadProject, props) => ( + * <SBFileUploader>{(renderFileInput, loadProject) => ( * <MyCoolComponent * onClick={loadProject} - * {...props} * > * {renderFileInput()} * </MyCoolComponent> - * )}</ProjectLoader> + * )}</SBFileUploader> */ const messages = defineMessages({ @@ -38,7 +38,7 @@ const messages = defineMessages({ } }); -class ProjectLoader extends React.Component { +class SBFileUploader extends React.Component { constructor (props) { super(props); bindAll(this, [ @@ -48,6 +48,7 @@ class ProjectLoader extends React.Component { 'handleClick' ]); } + // called when user has finished selecting a file to upload handleChange (e) { // Remove the hash if any (without triggering a hash change event or a reload) history.replaceState({}, document.title, '.'); @@ -60,7 +61,7 @@ class ProjectLoader extends React.Component { action: 'Import Project File', nonInteraction: true }); - this.props.closeLoadingState(); + this.props.onLoadingFinished(this.props.loadingState); // Reset the file input after project is loaded // This is necessary in case the user wants to reload a project thisFileInput.value = null; @@ -68,23 +69,26 @@ class ProjectLoader extends React.Component { .catch(error => { log.warn(error); alert(this.props.intl.formatMessage(messages.loadError)); // eslint-disable-line no-alert - this.props.closeLoadingState(); + this.props.onLoadingFinished(this.props.loadingState); // Reset the file input after project is loaded // This is necessary in case the user wants to reload a project thisFileInput.value = null; }); if (thisFileInput.files) { // Don't attempt to load if no file was selected - this.props.openLoadingState(); + this.props.onLoadingStarted(); reader.readAsArrayBuffer(thisFileInput.files[0]); + // extract the title from the file and set it as current project title if (thisFileInput.files[0].name) { const matches = thisFileInput.files[0].name.match(/^(.*)\.sb3$/); if (matches) { - this.props.onSetProjectTitle(matches[1].substring(0, 100)); + const truncatedProjectTitle = matches[1].substring(0, 100); + this.props.onSetProjectTitle(truncatedProjectTitle); } } } } handleClick () { + // open filesystem browsing window this.fileInput.click(); } setFileInput (input) { @@ -102,41 +106,39 @@ class ProjectLoader extends React.Component { ); } render () { - const { - /* eslint-disable no-unused-vars */ - children, - closeLoadingState, - openLoadingState, - vm, - /* eslint-enable no-unused-vars */ - ...props - } = this.props; - return this.props.children(this.renderFileInput, this.handleClick, props); + return this.props.children(this.renderFileInput, this.handleClick); } } -ProjectLoader.propTypes = { +SBFileUploader.propTypes = { children: PropTypes.func, - closeLoadingState: PropTypes.func, intl: intlShape.isRequired, + loadingState: PropTypes.oneOf(LoadingStates), + onLoadingFinished: PropTypes.func, + onLoadingStarted: PropTypes.func, onSetProjectTitle: PropTypes.func, - openLoadingState: PropTypes.func, vm: PropTypes.shape({ loadProject: PropTypes.func }) }; - const mapStateToProps = state => ({ + loadingState: state.scratchGui.projectState.loadingState, vm: state.scratchGui.vm }); const mapDispatchToProps = dispatch => ({ - closeLoadingState: () => dispatch(closeLoadingProject()), + onLoadingFinished: loadingState => { + dispatch(onLoadedProject(loadingState)); + dispatch(closeLoadingProject()); + }, onSetProjectTitle: title => dispatch(setProjectTitle(title)), - openLoadingState: () => dispatch(openLoadingProject()) + onLoadingStarted: () => { + dispatch(openLoadingProject()); + dispatch(onProjectUploadStarted()); + } }); export default connect( mapStateToProps, mapDispatchToProps -)(injectIntl(ProjectLoader)); +)(injectIntl(SBFileUploader)); diff --git a/src/containers/project-saver.jsx b/src/containers/sb3-downloader.jsx similarity index 50% rename from src/containers/project-saver.jsx rename to src/containers/sb3-downloader.jsx index c32d4b1bf0a045b6c20f9d9593cf5548aebf922b..703674c62398a0a71a2424118d9155e556583c01 100644 --- a/src/containers/project-saver.jsx +++ b/src/containers/sb3-downloader.jsx @@ -2,39 +2,37 @@ import bindAll from 'lodash.bindall'; import PropTypes from 'prop-types'; import React from 'react'; import {connect} from 'react-redux'; -import storage from '../lib/storage'; import {projectTitleInitialState} from '../reducers/project-title'; - /** - * Project saver component passes a saveProject function to its child. + * Project saver component passes a downloadProject function to its child. * It expects this child to be a function with the signature - * function (saveProject, props) {} + * function (downloadProject, props) {} * The component can then be used to attach project saving functionality * to any other component: * - * <ProjectSaver>{(saveProject, props) => ( + * <SB3Downloader>{(downloadProject, props) => ( * <MyCoolComponent - * onClick={saveProject} + * onClick={downloadProject} * {...props} * /> - * )}</ProjectSaver> + * )}</SB3Downloader> */ -class ProjectSaver extends React.Component { +class SB3Downloader extends React.Component { constructor (props) { super(props); bindAll(this, [ - 'createProject', - 'updateProject', - 'saveProject', - 'doStoreProject' + 'downloadProject' ]); } - saveProject () { - const saveLink = document.createElement('a'); - document.body.appendChild(saveLink); + downloadProject () { + const downloadLink = document.createElement('a'); + document.body.appendChild(downloadLink); this.props.saveProjectSb3().then(content => { + if (this.props.onSaveFinished) { + this.props.onSaveFinished(); + } // Use special ms version if available to get it working on Edge. if (navigator.msSaveOrOpenBlob) { navigator.msSaveOrOpenBlob(content, this.props.projectFilename); @@ -42,42 +40,19 @@ class ProjectSaver extends React.Component { } const url = window.URL.createObjectURL(content); - saveLink.href = url; - saveLink.download = this.props.projectFilename; - saveLink.click(); + downloadLink.href = url; + downloadLink.download = this.props.projectFilename; + downloadLink.click(); window.URL.revokeObjectURL(url); - document.body.removeChild(saveLink); + document.body.removeChild(downloadLink); }); } - doStoreProject (id) { - return this.props.saveProjectSb3() - .then(content => { - const assetType = storage.AssetType.Project; - const dataFormat = storage.DataFormat.SB3; - const body = new FormData(); - body.append('sb3_file', content, 'sb3_file'); - return storage.store( - assetType, - dataFormat, - body, - id - ); - }); - } - createProject () { - return this.doStoreProject(); - } - updateProject () { - return this.doStoreProject(this.props.projectId); - } render () { const { children } = this.props; return children( - this.saveProject, - this.updateProject, - this.createProject + this.downloadProject ); } } @@ -90,20 +65,19 @@ const getProjectFilename = (curTitle, defaultTitle) => { return `${filenameTitle.substring(0, 100)}.sb3`; }; -ProjectSaver.propTypes = { +SB3Downloader.propTypes = { children: PropTypes.func, + onSaveFinished: PropTypes.func, projectFilename: PropTypes.string, - projectId: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), saveProjectSb3: PropTypes.func }; const mapStateToProps = state => ({ saveProjectSb3: state.scratchGui.vm.saveProjectSb3.bind(state.scratchGui.vm), - projectFilename: getProjectFilename(state.scratchGui.projectTitle, projectTitleInitialState), - projectId: state.scratchGui.projectId + projectFilename: getProjectFilename(state.scratchGui.projectTitle, projectTitleInitialState) }); export default connect( mapStateToProps, () => ({}) // omit dispatch prop -)(ProjectSaver); +)(SB3Downloader); diff --git a/src/lib/blocks.js b/src/lib/blocks.js index f6b78b68ee4a325c5d03701e8d484466ba856f0c..57283adbbc7033a20ab286719ce5b0b230df51a4 100644 --- a/src/lib/blocks.js +++ b/src/lib/blocks.js @@ -48,10 +48,15 @@ export default function (vm) { }; const soundsMenu = function () { + let menu = [['', '']]; if (vm.editingTarget && vm.editingTarget.sprite.sounds.length > 0) { - return vm.editingTarget.sprite.sounds.map(sound => [sound.name, sound.name]); + menu = vm.editingTarget.sprite.sounds.map(sound => [sound.name, sound.name]); } - return [['', '']]; + menu.push([ + ScratchBlocks.ScratchMsgs.translate('SOUND_RECORD', 'record...'), + ScratchBlocks.recordSoundCallback + ]); + return menu; }; const costumesMenu = function () { diff --git a/src/lib/detect-locale.js b/src/lib/detect-locale.js index c9eaf5feccd6fb362f12288c1ac712029563140b..ad2001b548a786156c838456edface5f8e03fde7 100644 --- a/src/lib/detect-locale.js +++ b/src/lib/detect-locale.js @@ -3,6 +3,8 @@ * Utility function to detect locale from the browser setting or paramenter on the URL. */ +import queryString from 'query-string'; + /** * look for language setting in the browser. Check against supported locales. * If there's a parameter in the URL, override the browser setting @@ -23,13 +25,18 @@ const detectLocale = supportedLocales => { } } - if (window.location.search.indexOf('locale=') !== -1 || - window.location.search.indexOf('lang=') !== -1) { - const urlLocale = window.location.search.match(/(?:locale|lang)=([\w-]+)/)[1].toLowerCase(); - if (supportedLocales.includes(urlLocale)) { - locale = urlLocale; - } + const queryParams = queryString.parse(location.search); + // Flatten potential arrays and remove falsy values + const potentialLocales = [].concat(queryParams.locale, queryParams.lang).filter(l => l); + if (!potentialLocales.length) { + return locale; } + + const urlLocale = potentialLocales[0].toLowerCase(); + if (supportedLocales.includes(urlLocale)) { + return urlLocale; + } + return locale; }; diff --git a/src/lib/font-loader-hoc.jsx b/src/lib/font-loader-hoc.jsx new file mode 100644 index 0000000000000000000000000000000000000000..cbb7856c31fb3c95c3e8990d41e2cbd21922330f --- /dev/null +++ b/src/lib/font-loader-hoc.jsx @@ -0,0 +1,61 @@ +import React from 'react'; + +/* Higher Order Component to provide behavior for loading fonts. + * @param {React.Component} WrappedComponent component to receive fontsLoaded prop + * @returns {React.Component} component with font loading behavior + */ +const FontLoaderHOC = function (WrappedComponent) { + class FontLoaderComponent extends React.Component { + constructor (props) { + super(props); + this.state = { + fontsLoaded: false + }; + } + componentDidMount () { + const getFontPromises = () => { + const fontPromises = []; + // Browsers that support the font loader interface have an iterable document.fonts.values() + // Firefox has a mocked out object that doesn't actually implement iterable, which is why + // the deep safety check is necessary. + if (document.fonts && + typeof document.fonts.values === 'function' && + typeof document.fonts.values()[Symbol.iterator] === 'function') { + for (const fontFace of document.fonts.values()) { + fontPromises.push(fontFace.loaded); + fontFace.load(); + } + } + return fontPromises; + }; + // Font promises must be gathered after the document is loaded, because on Mac Chrome, the promise + // objects get replaced and the old ones never resolve. + if (document.readyState === 'complete') { + Promise.all(getFontPromises()).then(() => { + this.setState({fontsLoaded: true}); + }); + } else { + document.onreadystatechange = () => { + if (document.readyState !== 'complete') return; + document.onreadystatechange = null; + Promise.all(getFontPromises()).then(() => { + this.setState({fontsLoaded: true}); + }); + }; + } + } + render () { + return ( + <WrappedComponent + fontsLoaded={this.state.fontsLoaded} + {...this.props} + /> + ); + } + } + return FontLoaderComponent; +}; + +export { + FontLoaderHOC as default +}; diff --git a/src/lib/hash-parser-hoc.jsx b/src/lib/hash-parser-hoc.jsx index b5dedeeac0eac27964ab955b29883e6e7e8f2755..2cb90a526c5e73dfc422e813a5742584a40b1bbb 100644 --- a/src/lib/hash-parser-hoc.jsx +++ b/src/lib/hash-parser-hoc.jsx @@ -1,9 +1,17 @@ -import React from 'react'; import bindAll from 'lodash.bindall'; +import React from 'react'; +import PropTypes from 'prop-types'; +import {connect} from 'react-redux'; + +import { + defaultProjectId, + getIsFetchingWithoutId, + setProjectId +} from '../reducers/project-state'; /* Higher Order Component to get the project id from location.hash - * @param {React.Component} WrappedComponent component to receive projectData prop - * @returns {React.Component} component with project loading behavior + * @param {React.Component} WrappedComponent: component to render + * @returns {React.Component} component with hash parsing behavior */ const HashParserHOC = function (WrappedComponent) { class HashParserComponent extends React.Component { @@ -13,35 +21,73 @@ const HashParserHOC = function (WrappedComponent) { 'handleHashChange' ]); this.state = { - projectId: null + hideIntro: false }; } componentDidMount () { window.addEventListener('hashchange', this.handleHashChange); this.handleHashChange(); } + componentDidUpdate (prevProps) { + // if we are newly fetching a non-hash project... + if (this.props.isFetchingWithoutId && !prevProps.isFetchingWithoutId) { + // ...clear the hash from the url + history.pushState('new-project', 'new-project', + window.location.pathname + window.location.search); + } + } componentWillUnmount () { window.removeEventListener('hashchange', this.handleHashChange); } handleHashChange () { const hashMatch = window.location.hash.match(/#(\d+)/); - const projectId = hashMatch === null ? 0 : hashMatch[1]; - if (projectId !== this.state.projectId) { - this.setState({projectId: projectId}); + const hashProjectId = hashMatch === null ? defaultProjectId : hashMatch[1]; + this.props.setProjectId(hashProjectId); + if (hashProjectId !== defaultProjectId) { + this.setState({hideIntro: true}); } } render () { + const { + /* eslint-disable no-unused-vars */ + isFetchingWithoutId: isFetchingWithoutIdProp, + reduxProjectId, + setProjectId: setProjectIdProp, + /* eslint-enable no-unused-vars */ + ...componentProps + } = this.props; return ( <WrappedComponent - hideIntro={this.state.projectId && this.state.projectId !== 0} - projectId={this.state.projectId} - {...this.props} + hideIntro={this.state.hideIntro} + {...componentProps} /> ); } } - - return HashParserComponent; + HashParserComponent.propTypes = { + isFetchingWithoutId: PropTypes.bool, + reduxProjectId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + setProjectId: PropTypes.func + }; + const mapStateToProps = state => { + const loadingState = state.scratchGui.projectState.loadingState; + return { + isFetchingWithoutId: getIsFetchingWithoutId(loadingState), + reduxProjectId: state.scratchGui.projectState.projectId + }; + }; + const mapDispatchToProps = dispatch => ({ + setProjectId: projectId => dispatch(setProjectId(projectId)) + }); + // Allow incoming props to override redux-provided props. Used to mock in tests. + const mergeProps = (stateProps, dispatchProps, ownProps) => Object.assign( + {}, stateProps, dispatchProps, ownProps + ); + return connect( + mapStateToProps, + mapDispatchToProps, + mergeProps + )(HashParserComponent); }; export { diff --git a/src/lib/libraries/decks/index.jsx b/src/lib/libraries/decks/index.jsx index f8324f3ae73068221fe0b86bc68b5eb0757a9db5..bf714af9363415696ad2f0012cde29c16f4796e8 100644 --- a/src/lib/libraries/decks/index.jsx +++ b/src/lib/libraries/decks/index.jsx @@ -74,7 +74,7 @@ export default { img: libraryIntro, steps: [{ - video: 'https://www.youtube.com/embed/h9x8IPGN3SI' + video: 'rpjvs3v9gj' }, { title: ( <FormattedMessage @@ -112,7 +112,7 @@ export default { ), img: libraryAnimate, steps: [{ - video: 'https://www.youtube.com/embed/RUih6RnEdPg' + video: 'pyur30ho05' }, { title: ( <FormattedMessage @@ -186,7 +186,7 @@ export default { ), img: libraryMakeMusic, steps: [{ - video: 'https://www.youtube.com/embed/UQHHAQGuhl8' + video: 'ir0j8ljsgm' }, { title: ( @@ -254,7 +254,7 @@ export default { ), img: libraryMakeAGame, steps: [{ - video: 'https://www.youtube.com/embed/3G2miGV4TbQ' + video: '5rp47ys13g' }, { title: ( @@ -340,7 +340,7 @@ export default { ), img: libraryChaseGame, steps: [{ - video: 'https://www.youtube.com/embed/IRf9-P8PiZo' + video: 'kusyx9thl5' }, { title: ( @@ -471,7 +471,7 @@ export default { ), img: addBackdropThumb, steps: [{ - video: 'https://www.youtube.com/embed/Xv3Z80yy2l0' + video: 'nict6zdzlx' }, { deckIds: [ 'change-size', @@ -490,7 +490,7 @@ export default { ), img: changeSizeThumb, steps: [{ - video: 'https://www.youtube.com/embed/PJijGbhcT3E' + video: 'p8va85hh61' }, { deckIds: [ 'glide-around', @@ -509,7 +509,7 @@ export default { ), img: glideAroundThumb, steps: [{ - video: 'https://www.youtube.com/embed/KYmbgLX1xDs' + video: 'sh9j978rg8' }, { deckIds: [ 'add-a-backdrop', @@ -529,7 +529,7 @@ export default { ), img: recordASound, steps: [{ - video: 'https://www.youtube.com/embed/1WaU6e70Zig' + video: 'ulzl1fbzny' }, { deckIds: [ 'Make-Music', @@ -548,7 +548,7 @@ export default { ), img: spinThumb, steps: [{ - video: 'https://www.youtube.com/embed/C76V5cuI9XM' + video: '07fed5hhpv' }, { deckIds: [ 'add-a-backdrop', @@ -567,7 +567,7 @@ export default { ), img: hideAndShowThumb, steps: [{ - video: 'https://www.youtube.com/embed/6yWUvRU19ms' + video: 'g479ahobo9' }, { deckIds: [ 'add-a-backdrop', @@ -587,7 +587,7 @@ export default { ), img: switchCostumeThumb, steps: [{ - video: 'https://www.youtube.com/embed/vppgw1Xiegw' + video: '1ocp6a1ejn' }, { deckIds: [ 'add-a-backdrop', @@ -607,7 +607,7 @@ export default { ), img: moveArrowKeysThumb, steps: [{ - video: 'https://www.youtube.com/embed/uf6agkKnXJw' + video: 'yetrmk4iuu' }, { deckIds: [ 'add-a-backdrop', @@ -626,7 +626,7 @@ export default { ), img: addEffectsThumb, steps: [{ - video: 'https://www.youtube.com/embed/w3kGWEzRtxY' + video: '3jvl8zgjo2' }, { deckIds: [ 'add-a-backdrop', diff --git a/src/lib/locale-utils.js b/src/lib/locale-utils.js index 571186b6c0e2622f11a0269728bae585c07849d3..49ce25643c0a1467ad79c32013a88a59d07ca4ad 100644 --- a/src/lib/locale-utils.js +++ b/src/lib/locale-utils.js @@ -1,6 +1,7 @@ -// TODO: this probably should be coming from scratch-l10n -// Tracking in https://github.com/LLK/scratch-l10n/issues/32 -const rtlLocales = ['he']; +/** + * @fileoverview + * Utility functions related to localization specific to the GUI + */ const wideLocales = [ 'ab', @@ -16,12 +17,17 @@ const wideLocales = [ 'vi' ]; +/** + * Identify the languages where translations are too long to fit in fixed width parts of the gui. + * @param {string} locale The current locale. + * @return {bool} true if translations in this language are too long + */ + const isWideLocale = locale => ( wideLocales.indexOf(locale) !== -1 ); export { - rtlLocales, wideLocales, isWideLocale }; diff --git a/src/lib/project-fetcher-hoc.jsx b/src/lib/project-fetcher-hoc.jsx new file mode 100644 index 0000000000000000000000000000000000000000..4d850b68109fbff61b4842f2bae3ca396718e7f1 --- /dev/null +++ b/src/lib/project-fetcher-hoc.jsx @@ -0,0 +1,142 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import {intlShape, injectIntl} from 'react-intl'; +import bindAll from 'lodash.bindall'; +import {connect} from 'react-redux'; + +import { + LoadingStates, + defaultProjectId, + onFetchedProjectData, + getIsFetchingWithId, + setProjectId +} from '../reducers/project-state'; + +import analytics from './analytics'; +import log from './log'; +import storage from './storage'; + +/* Higher Order Component to provide behavior for loading projects by id. If + * there's no id, the default project is loaded. + * @param {React.Component} WrappedComponent component to receive projectData prop + * @returns {React.Component} component with project loading behavior + */ +const ProjectFetcherHOC = function (WrappedComponent) { + class ProjectFetcherComponent extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'fetchProject' + ]); + storage.setProjectHost(props.projectHost); + storage.setAssetHost(props.assetHost); + storage.setTranslatorFunction(props.intl.formatMessage); + // props.projectId might be unset, in which case we use our default; + // or it may be set by an even higher HOC, and passed to us. + // Either way, we now know what the initial projectId should be, so + // set it in the redux store. + if ( + props.projectId !== '' && + props.projectId !== null && + typeof props.projectId !== 'undefined' + ) { + this.props.setProjectId(props.projectId); + } + } + componentDidUpdate (prevProps) { + if (prevProps.projectHost !== this.props.projectHost) { + storage.setProjectHost(this.props.projectHost); + } + if (prevProps.assetHost !== this.props.assetHost) { + storage.setAssetHost(this.props.assetHost); + } + if (this.props.isFetchingWithId && !prevProps.isFetchingWithId) { + this.fetchProject(this.props.reduxProjectId, this.props.loadingState); + } + } + fetchProject (projectId, loadingState) { + return storage + .load(storage.AssetType.Project, projectId, storage.DataFormat.JSON) + .then(projectAsset => { + if (projectAsset) { + this.props.onFetchedProjectData(projectAsset.data, loadingState); + } + }) + .then(() => { + if (projectId !== defaultProjectId) { + // if not default project, register a project load event + analytics.event({ + category: 'project', + action: 'Load Project', + label: projectId, + nonInteraction: true + }); + } + }) + .catch(err => { + log.error(err); + }); + } + render () { + const { + /* eslint-disable no-unused-vars */ + assetHost, + onFetchedProjectData: onFetchedProjectDataProp, + intl, + projectHost, + projectId, + loadingState, + reduxProjectId, + setProjectId: setProjectIdProp, + /* eslint-enable no-unused-vars */ + isFetchingWithId: isFetchingWithIdProp, + ...componentProps + } = this.props; + return ( + <WrappedComponent + fetchingProject={isFetchingWithIdProp} + {...componentProps} + /> + ); + } + } + ProjectFetcherComponent.propTypes = { + assetHost: PropTypes.string, + intl: intlShape.isRequired, + isFetchingWithId: PropTypes.bool, + loadingState: PropTypes.oneOf(LoadingStates), + onFetchedProjectData: PropTypes.func, + projectHost: PropTypes.string, + projectId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + reduxProjectId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + setProjectId: PropTypes.func + }; + ProjectFetcherComponent.defaultProps = { + assetHost: 'https://assets.scratch.mit.edu', + projectHost: 'https://projects.scratch.mit.edu' + }; + + const mapStateToProps = state => ({ + isFetchingWithId: getIsFetchingWithId(state.scratchGui.projectState.loadingState), + loadingState: state.scratchGui.projectState.loadingState, + reduxProjectId: state.scratchGui.projectState.projectId + }); + const mapDispatchToProps = dispatch => ({ + onFetchedProjectData: (projectData, loadingState) => + dispatch(onFetchedProjectData(projectData, loadingState)), + setProjectId: projectId => dispatch(setProjectId(projectId)) + }); + // 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 + )(ProjectFetcherComponent)); +}; + +export { + ProjectFetcherHOC as default +}; diff --git a/src/lib/project-loader-hoc.jsx b/src/lib/project-loader-hoc.jsx deleted file mode 100644 index c045dc0eebe090cab651ffa903d806ebe9989a9c..0000000000000000000000000000000000000000 --- a/src/lib/project-loader-hoc.jsx +++ /dev/null @@ -1,115 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import {connect} from 'react-redux'; -import {injectIntl, intlShape} from 'react-intl'; - -import {setProjectId} from '../reducers/project-id'; - -import analytics from './analytics'; -import log from './log'; -import storage from './storage'; - -/* Higher Order Component to provide behavior for loading projects by id. If - * there's no id, the default project is loaded. - * @param {React.Component} WrappedComponent component to receive projectData prop - * @returns {React.Component} component with project loading behavior - */ -const ProjectLoaderHOC = function (WrappedComponent) { - class ProjectLoaderComponent extends React.Component { - constructor (props) { - super(props); - this.updateProject = this.updateProject.bind(this); - this.state = { - projectData: null, - fetchingProject: false - }; - storage.setProjectHost(props.projectHost); - storage.setAssetHost(props.assetHost); - storage.setTranslatorFunction(props.intl.formatMessage); - props.setProjectId(props.projectId); - if ( - props.projectId !== '' && - props.projectId !== null && - typeof props.projectId !== 'undefined' - ) { - this.updateProject(props.projectId); - } - } - componentWillUpdate (nextProps) { - if (this.props.projectHost !== nextProps.projectHost) { - storage.setProjectHost(nextProps.projectHost); - } - if (this.props.assetHost !== nextProps.assetHost) { - storage.setAssetHost(nextProps.assetHost); - } - if (this.props.projectId !== nextProps.projectId) { - this.props.setProjectId(nextProps.projectId); - this.setState({fetchingProject: true}, () => { - this.updateProject(nextProps.projectId); - }); - } - } - updateProject (projectId) { - storage - .load(storage.AssetType.Project, projectId, storage.DataFormat.JSON) - .then(projectAsset => projectAsset && this.setState({ - projectData: projectAsset.data, - fetchingProject: false - })) - .then(() => { - if (projectId !== 0) { - analytics.event({ - category: 'project', - action: 'Load Project', - label: projectId, - nonInteraction: true - }); - } - }) - .catch(err => log.error(err)); - } - render () { - const { - /* eslint-disable no-unused-vars */ - assetHost, - projectHost, - projectId, - setProjectId: setProjectIdProp, - /* eslint-enable no-unused-vars */ - ...componentProps - } = this.props; - if (!this.state.projectData) return null; - return ( - <WrappedComponent - fetchingProject={this.state.fetchingProject} - projectData={this.state.projectData} - {...componentProps} - /> - ); - } - } - ProjectLoaderComponent.propTypes = { - assetHost: PropTypes.string, - intl: intlShape.isRequired, - projectHost: PropTypes.string, - projectId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - setProjectId: PropTypes.func - }; - ProjectLoaderComponent.defaultProps = { - assetHost: 'https://assets.scratch.mit.edu', - projectHost: 'https://projects.scratch.mit.edu', - projectId: 0 - }; - - const mapStateToProps = () => ({}); - - const mapDispatchToProps = dispatch => ({ - setProjectId: id => dispatch(setProjectId(id)) - }); - - return injectIntl(connect(mapStateToProps, mapDispatchToProps)(ProjectLoaderComponent)); -}; - -export { - ProjectLoaderHOC as default -}; diff --git a/src/lib/project-saver-hoc.jsx b/src/lib/project-saver-hoc.jsx new file mode 100644 index 0000000000000000000000000000000000000000..065a0602abbe568af67d32f775db195683860b09 --- /dev/null +++ b/src/lib/project-saver-hoc.jsx @@ -0,0 +1,125 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import {connect} from 'react-redux'; +import VM from 'scratch-vm'; + +import storage from '../lib/storage'; +import { + LoadingStates, + getIsCreating, + getIsUpdating, + onCreated, + onUpdated, + onError +} from '../reducers/project-state'; + +/** + * Higher Order Component to provide behavior for saving projects. + * @param {React.Component} WrappedComponent the component to add project saving functionality to + * @returns {React.Component} WrappedComponent with project saving functionality added + * + * <ProjectSaverHOC> + * <WrappedComponent /> + * </ProjectSaverHOC> + */ +const ProjectSaverHOC = function (WrappedComponent) { + class ProjectSaverComponent extends React.Component { + componentDidUpdate (prevProps) { + if (this.props.isUpdating && !prevProps.isUpdating) { + this.storeProject(this.props.reduxProjectId) + .then(() => { // eslint-disable-line no-unused-vars + // there is nothing we expect to find in response that we need to check here + this.props.onUpdated(this.props.loadingState); + }) + .catch(err => { + // NOTE: should throw up a notice for user + this.props.onError(`Saving the project failed with error: ${err}`); + }); + } + if (this.props.isCreating && !prevProps.isCreating) { + this.storeProject() + .then(response => { + this.props.onCreated(response.id); + }) + .catch(err => { + // NOTE: should throw up a notice for user + this.props.onError(`Creating a new project failed with error: ${err}`); + }); + } + } + /** + * storeProject: + * @param {number|string|undefined} projectId defined value causes PUT/update; undefined causes POST/create + * @return {Promise} resolves with json object containing project's existing or new id + */ + storeProject (projectId) { + return this.props.vm.saveProjectSb3() + .then(content => { + const assetType = storage.AssetType.Project; + const dataFormat = storage.DataFormat.SB3; + const body = new FormData(); + body.append('sb3_file', content, 'sb3_file'); + // when id is undefined or null, storage.store as we have + // configured it will create a new project with id + return storage.store( + assetType, + dataFormat, + body, + projectId + ); + }); + } + render () { + const { + /* eslint-disable no-unused-vars */ + onCreated: onCreatedProp, + onUpdated: onUpdatedProp, + onError: onErrorProp, + isCreating: isCreatingProp, + isUpdating: isUpdatingProp, + loadingState, + reduxProjectId, + /* eslint-enable no-unused-vars */ + ...componentProps + } = this.props; + return ( + <WrappedComponent + {...componentProps} + /> + ); + } + } + ProjectSaverComponent.propTypes = { + isCreating: 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]), + vm: PropTypes.instanceOf(VM).isRequired + }; + const mapStateToProps = state => { + const loadingState = state.scratchGui.projectState.loadingState; + return { + isCreating: getIsCreating(loadingState), + isUpdating: getIsUpdating(loadingState), + loadingState: loadingState, + reduxProjectId: state.scratchGui.projectState.projectId, + vm: state.scratchGui.vm + }; + }; + const mapDispatchToProps = dispatch => ({ + onCreated: projectId => dispatch(onCreated(projectId)), + onUpdated: (projectId, loadingState) => dispatch(onUpdated(projectId, loadingState)), + onError: errStr => dispatch(onError(errStr)) + }); + return connect( + mapStateToProps, + mapDispatchToProps + )(ProjectSaverComponent); +}; + +export { + ProjectSaverHOC as default +}; diff --git a/src/lib/titled-hoc.jsx b/src/lib/titled-hoc.jsx index 2534d96bd61ba9f4cb4db89a51e33c031f1be6c4..2cc3e59209ead4289927f52d591096a615baf7bf 100644 --- a/src/lib/titled-hoc.jsx +++ b/src/lib/titled-hoc.jsx @@ -1,14 +1,7 @@ import React from 'react'; import bindAll from 'lodash.bindall'; -import {defineMessages, intlShape, injectIntl} from 'react-intl'; - -const messages = defineMessages({ - defaultProjectTitle: { - id: 'gui.gui.defaultProjectTitle', - description: 'Default title for project', - defaultMessage: 'Scratch Project' - } -}); +import {intlShape, injectIntl} from 'react-intl'; +import {defaultProjectTitleMessages} from '../reducers/project-title'; /* Higher Order Component to get and set the project title * @param {React.Component} WrappedComponent component to receive project title related props @@ -22,18 +15,24 @@ const TitledHOC = function (WrappedComponent) { 'handleUpdateProjectTitle' ]); this.state = { - projectTitle: this.props.intl.formatMessage(messages.defaultProjectTitle) + projectTitle: this.props.intl.formatMessage(defaultProjectTitleMessages.defaultProjectTitle) }; } handleUpdateProjectTitle (newTitle) { this.setState({projectTitle: newTitle}); } render () { + const { + /* eslint-disable no-unused-vars */ + intl, + /* eslint-enable no-unused-vars */ + ...componentProps + } = this.props; return ( <WrappedComponent projectTitle={this.state.projectTitle} onUpdateProjectTitle={this.handleUpdateProjectTitle} - {...this.props} + {...componentProps} /> ); } @@ -43,10 +42,7 @@ const TitledHOC = function (WrappedComponent) { intl: intlShape.isRequired }; - // return TitledComponent; - const IntlTitledComponent = injectIntl(TitledComponent); - return IntlTitledComponent; - + return injectIntl(TitledComponent); }; export { diff --git a/src/lib/tutorial-from-url.js b/src/lib/tutorial-from-url.js index c627b8db7abf1a709719c12add3b6eec64f13257..3dab2f3d39595471c8df45c844cc142651f41726 100644 --- a/src/lib/tutorial-from-url.js +++ b/src/lib/tutorial-from-url.js @@ -5,6 +5,7 @@ import tutorials from './libraries/decks/index.jsx'; import analytics from './analytics'; +import queryString from 'query-string'; /** * Get the tutorial id from the given numerical id (representing the @@ -34,11 +35,14 @@ const getDeckIdFromUrlId = urlId => { * requested or found. */ const detectTutorialId = () => { - if (window.location.search.indexOf('tutorial=') !== -1) { - const urlTutorialId = window.location.search.match(/(?:tutorial)=(\d+)/)[1]; - if (urlTutorialId) { - return getDeckIdFromUrlId(Number(urlTutorialId)); - } + const queryParams = queryString.parse(location.search); + const tutorialID = Number( + Array.isArray(queryParams.tutorial) ? + queryParams.tutorial[0] : + queryParams.tutorial + ); + if (!isNaN(tutorialID)) { + return getDeckIdFromUrlId(tutorialID); } return null; }; diff --git a/src/lib/vm-manager-hoc.jsx b/src/lib/vm-manager-hoc.jsx new file mode 100644 index 0000000000000000000000000000000000000000..56bd7eabf74ddee9a6f0f714b9b12a25a45ef2f0 --- /dev/null +++ b/src/lib/vm-manager-hoc.jsx @@ -0,0 +1,120 @@ +import bindAll from 'lodash.bindall'; +import PropTypes from 'prop-types'; +import React from 'react'; +import {connect} from 'react-redux'; + +import VM from 'scratch-vm'; +import AudioEngine from 'scratch-audio'; + +import { + LoadingStates, + onLoadedProject, + getIsLoadingWithId +} from '../reducers/project-state'; + +/* + * Higher Order Component to manage events emitted by the VM + * @param {React.Component} WrappedComponent component to manage VM events for + * @returns {React.Component} connected component with vm events bound to redux + */ +const vmManagerHOC = function (WrappedComponent) { + class VMManager extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'loadProject' + ]); + this.state = { + loadingError: false, + errorMessage: '' + }; + } + componentDidMount () { + if (this.props.vm.initialized) return; + this.audioEngine = new AudioEngine(); + this.props.vm.attachAudioEngine(this.audioEngine); + this.props.vm.setCompatibilityMode(true); + this.props.vm.start(); + this.props.vm.initialized = true; + } + componentDidUpdate (prevProps) { + // if project is in loading state, AND fonts are loaded, + // 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); + } + } + loadProject (projectData, loadingState) { + return this.props.vm.loadProject(projectData) + .then(() => { + this.props.onLoadedProject(loadingState); + }) + .catch(e => { + // Need to catch this error and update component state so that + // error page gets rendered if project failed to load + this.setState({loadingError: true, errorMessage: e}); + }); + } + render () { + const { + /* eslint-disable no-unused-vars */ + fontsLoaded, + onLoadedProject: onLoadedProjectProp, + projectData, + projectId, + loadingState, + /* eslint-enable no-unused-vars */ + isLoadingWithId: isLoadingWithIdProp, + vm, + ...componentProps + } = this.props; + return ( + <WrappedComponent + errorMessage={this.state.errorMessage} + isLoading={isLoadingWithIdProp} + loadingError={this.state.loadingError} + vm={vm} + {...componentProps} + /> + ); + } + } + + VMManager.propTypes = { + fontsLoaded: PropTypes.bool, + isLoadingWithId: PropTypes.bool, + loadingState: PropTypes.oneOf(LoadingStates), + onLoadedProject: PropTypes.func, + projectData: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), + projectId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + vm: PropTypes.instanceOf(VM).isRequired + }; + + const mapStateToProps = state => { + const loadingState = state.scratchGui.projectState.loadingState; + return { + isLoadingWithId: getIsLoadingWithId(loadingState), + projectData: state.scratchGui.projectState.projectData, + projectId: state.scratchGui.projectState.projectId, + loadingState: loadingState + }; + }; + + const mapDispatchToProps = dispatch => ({ + onLoadedProject: loadingState => dispatch(onLoadedProject(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 connect( + mapStateToProps, + mapDispatchToProps, + mergeProps + )(VMManager); +}; + +export default vmManagerHOC; diff --git a/src/playground/blocks-only.jsx b/src/playground/blocks-only.jsx index 1a72e267b8776201ecf8cf9bda733142636087ce..d08e733378bcab352cd9b448237d50069ec32009 100644 --- a/src/playground/blocks-only.jsx +++ b/src/playground/blocks-only.jsx @@ -27,7 +27,7 @@ const BlocksOnly = props => ( </GUI> ); -const App = HashParserHOC(AppStateHOC(BlocksOnly)); +const App = AppStateHOC(HashParserHOC(BlocksOnly)); const appTarget = document.createElement('div'); document.body.appendChild(appTarget); diff --git a/src/playground/compatibility-testing.jsx b/src/playground/compatibility-testing.jsx index 3209131c3f7a943b65757979b49f2b096011947a..e5977a8d2dfc1c2a196886c4ffaa9084fd1a54e6 100644 --- a/src/playground/compatibility-testing.jsx +++ b/src/playground/compatibility-testing.jsx @@ -4,7 +4,7 @@ import ReactDOM from 'react-dom'; import GUI from '../containers/gui.jsx'; import HashParserHOC from '../lib/hash-parser-hoc.jsx'; import AppStateHOC from '../lib/app-state-hoc.jsx'; -const WrappedGui = HashParserHOC(AppStateHOC(GUI)); +const WrappedGui = AppStateHOC(HashParserHOC(GUI)); const DEFAULT_PROJECT_ID = '10015059'; diff --git a/src/playground/player.jsx b/src/playground/player.jsx index e236de667eb4b12919548908daad377dfa827fe9..2235bbac703d01ebfe4003976a3ce65919b3eb95 100644 --- a/src/playground/player.jsx +++ b/src/playground/player.jsx @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import React from 'react'; import ReactDOM from 'react-dom'; import {connect} from 'react-redux'; +import {compose} from 'redux'; import Box from '../components/box/box.jsx'; import GUI from '../containers/gui.jsx'; @@ -48,8 +49,19 @@ const mapDispatchToProps = dispatch => ({ onSeeInside: () => dispatch(setPlayer(false)) }); -const ConnectedPlayer = connect(mapStateToProps, mapDispatchToProps)(Player); -const WrappedPlayer = HashParserHOC(AppStateHOC(TitledHOC(ConnectedPlayer))); +const ConnectedPlayer = connect( + mapStateToProps, + mapDispatchToProps +)(Player); + +// 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 +// ability to compose reducers. +const WrappedPlayer = compose( + AppStateHOC, + HashParserHOC, + TitledHOC +)(ConnectedPlayer); const appTarget = document.createElement('div'); document.body.appendChild(appTarget); diff --git a/src/playground/render-gui.jsx b/src/playground/render-gui.jsx index 562e57eac9c4c60ad4aea322adeec8b907067761..c5be243d113a20c53e99d0cd3861fd6ebe640911 100644 --- a/src/playground/render-gui.jsx +++ b/src/playground/render-gui.jsx @@ -1,5 +1,6 @@ import React from 'react'; import ReactDOM from 'react-dom'; +import {compose} from 'redux'; import AppStateHOC from '../lib/app-state-hoc.jsx'; import GUI from '../containers/gui.jsx'; @@ -13,7 +14,15 @@ import TitledHOC from '../lib/titled-hoc.jsx'; */ export default appTarget => { GUI.setAppElement(appTarget); - const WrappedGui = HashParserHOC(AppStateHOC(TitledHOC(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 + // ability to compose reducers. + const WrappedGui = compose( + AppStateHOC, + HashParserHOC, + TitledHOC + )(GUI); // TODO a hack for testing the backpack, allow backpack host to be set by url param const backpackHostMatches = window.location.href.match(/[?&]backpack_host=([^&]*)&?/); diff --git a/src/reducers/gui.js b/src/reducers/gui.js index bce112a1c327c6e6a2761cbbeb6e63f29dbf87da..1eb254b47718b4a600d4bac4b8163e67af25b2ff 100644 --- a/src/reducers/gui.js +++ b/src/reducers/gui.js @@ -13,7 +13,7 @@ import modalReducer, {modalsInitialState} from './modals'; import modeReducer, {modeInitialState} from './mode'; import monitorReducer, {monitorsInitialState} from './monitors'; import monitorLayoutReducer, {monitorLayoutInitialState} from './monitor-layout'; -import projectIdReducer, {projectIdInitialState} from './project-id'; +import projectStateReducer, {projectStateInitialState} from './project-state'; import projectTitleReducer, {projectTitleInitialState} from './project-title'; import restoreDeletionReducer, {restoreDeletionInitialState} from './restore-deletion'; import stageSizeReducer, {stageSizeInitialState} from './stage-size'; @@ -43,7 +43,7 @@ const guiInitialState = { modals: modalsInitialState, monitors: monitorsInitialState, monitorLayout: monitorLayoutInitialState, - projectId: projectIdInitialState, + projectState: projectStateInitialState, projectTitle: projectTitleInitialState, restoreDeletion: restoreDeletionInitialState, targets: targetsInitialState, @@ -110,7 +110,7 @@ const guiReducer = combineReducers({ modals: modalReducer, monitors: monitorReducer, monitorLayout: monitorLayoutReducer, - projectId: projectIdReducer, + projectState: projectStateReducer, projectTitle: projectTitleReducer, restoreDeletion: restoreDeletionReducer, targets: targetReducer, diff --git a/src/reducers/locales.js b/src/reducers/locales.js index e2b7fed6ece50df70689f08fbb871139aed325c9..34a42163eda0a869133e76bd8f3fa37d6e1de140 100644 --- a/src/reducers/locales.js +++ b/src/reducers/locales.js @@ -2,7 +2,7 @@ import {addLocaleData} from 'react-intl'; import {localeData} from 'scratch-l10n'; import editorMessages from 'scratch-l10n/locales/editor-msgs'; -import {rtlLocales} from '../lib/locale-utils'; +import {isRtl} from 'scratch-l10n'; addLocaleData(localeData); @@ -21,7 +21,7 @@ const reducer = function (state, action) { switch (action.type) { case SELECT_LOCALE: return Object.assign({}, state, { - isRtl: rtlLocales.indexOf(action.locale) !== -1, + isRtl: isRtl(action.locale), locale: action.locale, messagesByLocale: state.messagesByLocale, messages: state.messagesByLocale[action.locale] @@ -57,7 +57,7 @@ const initLocale = function (currentState, locale) { {}, currentState, { - isRtl: rtlLocales.indexOf(locale) !== -1, + isRtl: isRtl(locale), locale: locale, messagesByLocale: currentState.messagesByLocale, messages: currentState.messagesByLocale[locale] diff --git a/src/reducers/project-id.js b/src/reducers/project-id.js deleted file mode 100644 index 9045873c74e67c04b06281f133ab215f1a8f4c27..0000000000000000000000000000000000000000 --- a/src/reducers/project-id.js +++ /dev/null @@ -1,27 +0,0 @@ -const SET_PROJECT_ID = 'scratch-gui/project-id/SET_PROJECT_ID'; - -const initialState = null; - -const reducer = function (state, action) { - if (typeof state === 'undefined') state = initialState; - - switch (action.type) { - case SET_PROJECT_ID: - return action.id; - default: - return state; - } -}; - -const setProjectId = function (id) { - return { - type: SET_PROJECT_ID, - id: id - }; -}; - -export { - reducer as default, - initialState as projectIdInitialState, - setProjectId -}; diff --git a/src/reducers/project-state.js b/src/reducers/project-state.js new file mode 100644 index 0000000000000000000000000000000000000000..b69cfc5244d93c4fa59e493dc5d47ed61417cfba --- /dev/null +++ b/src/reducers/project-state.js @@ -0,0 +1,344 @@ +import keyMirror from 'keymirror'; + +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_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_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'; + +const defaultProjectId = 0; // hardcoded id of default project + +const LoadingState = keyMirror({ + NOT_LOADED: null, + 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, + SAVING_WITH_ID_BEFORE_NEW: null, + CREATING_NEW: null +}); + +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 +); +const getIsFetchingWithId = loadingState => ( + loadingState === LoadingState.FETCHING_WITH_ID || + loadingState === LoadingState.FETCHING_NEW_DEFAULT || + loadingState === LoadingState.FETCHING_NEW_DEFAULT_TO_SAVE +); +const getIsLoadingWithId = loadingState => ( + loadingState === LoadingState.LOADING_VM_WITH_ID || + loadingState === LoadingState.LOADING_VM_NEW_DEFAULT || + loadingState === LoadingState.LOADING_VM_NEW_DEFAULT_TO_SAVE +); +const getIsCreating = loadingState => ( + loadingState === LoadingState.CREATING_NEW +); +const getIsUpdating = loadingState => ( + loadingState === LoadingState.SAVING_WITH_ID || + loadingState === LoadingState.SAVING_WITH_ID_BEFORE_NEW +); +const getIsShowingProject = loadingState => ( + loadingState === LoadingState.SHOWING_WITH_ID || + loadingState === LoadingState.SHOWING_WITHOUT_ID +); + +const initialState = { + errStr: null, + projectData: null, + projectId: null, + loadingState: LoadingState.NOT_LOADED +}; + +const reducer = function (state, action) { + if (typeof state === 'undefined') state = initialState; + + switch (action.type) { + case DONE_CREATING_NEW: + // We need to set project id since we just created new project on the server. + // No need to load, we should have data already in vm. + if (state.loadingState === LoadingState.CREATING_NEW) { + return Object.assign({}, state, { + loadingState: LoadingState.SHOWING_WITH_ID, + id: action.id + }); + } + return state; + case DONE_FETCHING_WITH_ID: + if (state.loadingState === LoadingState.FETCHING_WITH_ID) { + return Object.assign({}, state, { + loadingState: LoadingState.LOADING_VM_WITH_ID, + projectData: action.projectData + }); + } + return state; + case DONE_FETCHING_DEFAULT: + if (state.loadingState === LoadingState.FETCHING_NEW_DEFAULT) { + return Object.assign({}, state, { + loadingState: LoadingState.LOADING_VM_NEW_DEFAULT, + projectData: action.projectData + }); + } + 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) { + return Object.assign({}, state, { + loadingState: LoadingState.SHOWING_WITHOUT_ID + }); + } + return state; + case DONE_LOADING_VM_WITH_ID: + if (state.loadingState === LoadingState.LOADING_VM_WITH_ID) { + return Object.assign({}, state, { + loadingState: LoadingState.SHOWING_WITH_ID + }); + } + 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) { + 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 + }); + } + return state; + case DONE_SAVING_WITH_ID: + if (state.loadingState === LoadingState.SAVING_WITH_ID) { + return Object.assign({}, state, { + loadingState: LoadingState.SHOWING_WITH_ID + }); + } + return state; + 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, + projectId: defaultProjectId + }); + } + return state; + case SET_PROJECT_ID: + // if we were already showing something, only fetch project if the + // project id has changed. This prevents re-fetching projects unnecessarily. + if (state.loadingState === LoadingState.SHOWING_WITH_ID) { + if (state.projectId !== action.id) { + return Object.assign({}, state, { + loadingState: LoadingState.FETCHING_WITH_ID, + projectId: action.id + }); + } + } else { // allow any other states to transition to fetching project + return Object.assign({}, state, { + loadingState: LoadingState.FETCHING_WITH_ID, + projectId: action.id + }); + } + return state; + case START_FETCHING_NEW_WITHOUT_SAVING: + if ([ + LoadingState.SHOWING_WITH_ID, + LoadingState.SHOWING_WITHOUT_ID + ].includes(state.loadingState)) { + return Object.assign({}, state, { + loadingState: LoadingState.FETCHING_NEW_DEFAULT, + projectId: defaultProjectId + }); + } + return state; + case START_LOADING_VM_FILE_UPLOAD: + if ([ + LoadingState.NOT_LOADED, + LoadingState.SHOWING_WITH_ID, + LoadingState.SHOWING_WITHOUT_ID + ].includes(state.loadingState)) { + return Object.assign({}, state, { + loadingState: LoadingState.LOADING_VM_FILE_UPLOAD, + projectId: null // clear any current projectId + }); + } + return state; + case START_SAVING: + if (state.loadingState === LoadingState.SHOWING_WITH_ID) { + return Object.assign({}, state, { + loadingState: LoadingState.SAVING_WITH_ID + }); + } + return state; + case START_SAVING_BEFORE_CREATING_NEW: + if (state.loadingState === LoadingState.SHOWING_WITH_ID) { + return Object.assign({}, state, { + loadingState: LoadingState.SAVING_WITH_ID_BEFORE_NEW + }); + } + return state; + case GO_TO_ERROR_STATE: + // NOTE: we should introduce handling in components for showing ERROR state + if ([ + LoadingState.NOT_LOADED, + LoadingState.FETCHING_WITH_ID, + LoadingState.FETCHING_NEW_DEFAULT, + LoadingState.FETCHING_NEW_DEFAULT_TO_SAVE + ].includes(state.loadingState)) { + return Object.assign({}, state, { + loadingState: LoadingState.ERROR, + errStr: action.errStr + }); + } + return state; + default: + return state; + } +}; + +const onCreated = id => ({ + type: DONE_CREATING_NEW, + id: id +}); + +const onFetchedProjectData = (projectData, loadingState) => { + switch (loadingState) { + case LoadingState.FETCHING_WITH_ID: + return { + type: DONE_FETCHING_WITH_ID, + projectData: projectData + }; + case LoadingState.FETCHING_NEW_DEFAULT: + return { + 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 => { + switch (loadingState) { + case LoadingState.LOADING_VM_WITH_ID: + return { + type: DONE_LOADING_VM_WITH_ID + }; + case LoadingState.LOADING_VM_FILE_UPLOAD: + return { + type: DONE_LOADING_VM_FILE_UPLOAD + }; + 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 + }; + default: + break; + } +}; + +const onUpdated = loadingState => { + switch (loadingState) { + case LoadingState.SAVING_WITH_ID: + return { + type: DONE_SAVING_WITH_ID + }; + case LoadingState.SAVING_WITH_ID_BEFORE_NEW: + return { + type: DONE_SAVING_WITH_ID_BEFORE_NEW + }; + default: + break; + } +}; + +const onError = errStr => ({ + type: GO_TO_ERROR_STATE, + errStr: errStr +}); + +const setProjectId = id => ({ + type: SET_PROJECT_ID, + id: id +}); + +const requestNewProject = canSave => { + if (canSave) return {type: START_SAVING_BEFORE_CREATING_NEW}; + return {type: START_FETCHING_NEW_WITHOUT_SAVING}; +}; + +const onProjectUploadStarted = () => ({ + type: START_LOADING_VM_FILE_UPLOAD +}); + +const saveProject = () => ({ + type: START_SAVING +}); + +export { + reducer as default, + initialState as projectStateInitialState, + LoadingState, + LoadingStates, + defaultProjectId, + getIsCreating, + getIsFetchingWithoutId, + getIsFetchingWithId, + getIsLoadingWithId, + getIsUpdating, + getIsShowingProject, + onCreated, + onError, + onFetchedProjectData, + onLoadedProject, + onProjectUploadStarted, + onUpdated, + requestNewProject, + saveProject, + setProjectId +}; diff --git a/src/reducers/project-title.js b/src/reducers/project-title.js index 09bf6c4eab417e626b8a719326ac685c25d46cdd..389687ceb10b23c8f59964abdf8b78bb48c2fb6f 100644 --- a/src/reducers/project-title.js +++ b/src/reducers/project-title.js @@ -1,9 +1,19 @@ +import {defineMessages} from 'react-intl'; + const SET_PROJECT_TITLE = 'projectTitle/SET_PROJECT_TITLE'; // we are initializing to a blank string instead of an actual title, // because it would be hard to localize here const initialState = ''; +const defaultProjectTitleMessages = defineMessages({ + defaultProjectTitle: { + id: 'gui.gui.defaultProjectTitle', + description: 'Default title for project', + defaultMessage: 'Scratch Project' + } +}); + const reducer = function (state, action) { if (typeof state === 'undefined') state = initialState; switch (action.type) { @@ -21,5 +31,6 @@ const setProjectTitle = title => ({ export { reducer as default, initialState as projectTitleInitialState, + defaultProjectTitleMessages, setProjectTitle }; diff --git a/src/reducers/vm.js b/src/reducers/vm.js index da2155cf8cd55dc52cb9f9b2d4a63e42c3ca2ff4..0ed08544463059d2adfbb18f07e1ebabaa0855ae 100644 --- a/src/reducers/vm.js +++ b/src/reducers/vm.js @@ -21,6 +21,7 @@ const setVM = function (vm) { vm: vm }; }; + export { reducer as default, initialState as vmInitialState, diff --git a/test/integration/blocks.test.js b/test/integration/blocks.test.js index b3ec62e2f6042c69ccb5f7ea985d8267e73da93e..8c4e1a6037bb57d08947c0a45e1bb407a28b4ea9 100644 --- a/test/integration/blocks.test.js +++ b/test/integration/blocks.test.js @@ -157,4 +157,17 @@ describe('Working with the blocks', () => { const logs = await getLogs(); await expect(logs).toEqual([]); }); + + test('Record option from sound block menu opens sound recorder', async () => { + await loadUri(uri); + await clickXpath('//button[@title="Try It"]'); + await clickText('Code'); + await clickText('Sound', scope.blocksTab); + await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for scroll animation + await clickText('Meow', scope.blocksTab); // Click "play sound <Meow> until done" block + await clickText('record'); // Click "record..." option in the block's sound menu + await findByText('Record Sound'); // Sound recorder is open + const logs = await getLogs(); + await expect(logs).toEqual([]); + }); }); diff --git a/test/unit/util/detect-locale.test.js b/test/unit/util/detect-locale.test.js index a7a699c414900b4a42c9b0cf452ac95f06bc65e0..f9b1f63fceaa6970c8f93bbf7f44dce489a6d732 100644 --- a/test/unit/util/detect-locale.test.js +++ b/test/unit/util/detect-locale.test.js @@ -67,4 +67,20 @@ describe('detectLocale', () => { ); expect(detectLocale(supportedLocales)).toEqual('en'); }); + + test('works with an empty locale', () => { + Object.defineProperty(window.location, + 'search', + {value: '?locale='} + ); + expect(detectLocale(supportedLocales)).toEqual('en'); + }); + + test('if multiple, uses the first locale', () => { + Object.defineProperty(window.location, + 'search', + {value: '?locale=de&locale=en'} + ); + expect(detectLocale(supportedLocales)).toEqual('de'); + }); }); diff --git a/test/unit/util/hash-project-loader-hoc.test.jsx b/test/unit/util/hash-project-loader-hoc.test.jsx index 1242385d62241588f60b89ea9be57f169f1516fd..a78f43475f0d416ecfd2f48efbb5108b4872c3b4 100644 --- a/test/unit/util/hash-project-loader-hoc.test.jsx +++ b/test/unit/util/hash-project-loader-hoc.test.jsx @@ -1,42 +1,81 @@ import React from 'react'; -import HashParserHOC from '../../../src/lib/hash-parser-hoc.jsx'; +import configureStore from 'redux-mock-store'; import {mount} from 'enzyme'; +import HashParserHOC from '../../../src/lib/hash-parser-hoc.jsx'; + jest.mock('react-ga'); describe('HashParserHOC', () => { + const mockStore = configureStore(); + let store; + + beforeEach(() => { + store = mockStore({ + scratchGui: { + projectState: {} + } + }); + }); + test('when there is a hash, it passes the hash as projectId', () => { const Component = ({projectId}) => <div>{projectId}</div>; const WrappedComponent = HashParserHOC(Component); window.location.hash = '#1234567'; - const mounted = mount(<WrappedComponent />); - expect(mounted.state().projectId).toEqual('1234567'); + const mockSetProjectIdFunc = jest.fn(); + mount( + <WrappedComponent + setProjectId={mockSetProjectIdFunc} + store={store} + /> + ); + expect(mockSetProjectIdFunc.mock.calls[0][0]).toBe('1234567'); }); test('when there is no hash, it passes 0 as the projectId', () => { const Component = ({projectId}) => <div>{projectId}</div>; const WrappedComponent = HashParserHOC(Component); window.location.hash = ''; - const mounted = mount(<WrappedComponent />); - expect(mounted.state().projectId).toEqual(0); + const mockSetProjectIdFunc = jest.fn(); + mount( + <WrappedComponent + setProjectId={mockSetProjectIdFunc} + store={store} + /> + ); + expect(mockSetProjectIdFunc.mock.calls[0][0]).toBe(0); }); test('when the hash is not a number, it passes 0 as projectId', () => { const Component = ({projectId}) => <div>{projectId}</div>; const WrappedComponent = HashParserHOC(Component); window.location.hash = '#winning'; - const mounted = mount(<WrappedComponent />); - expect(mounted.state().projectId).toEqual(0); + const mockSetProjectIdFunc = jest.fn(); + mount( + <WrappedComponent + setProjectId={mockSetProjectIdFunc} + store={store} + /> + ); + expect(mockSetProjectIdFunc.mock.calls[0][0]).toBe(0); }); test('when hash change happens, the projectId state is changed', () => { const Component = ({projectId}) => <div>{projectId}</div>; const WrappedComponent = HashParserHOC(Component); window.location.hash = ''; - const mounted = mount(<WrappedComponent />); - expect(mounted.state().projectId).toEqual(0); + const mockSetProjectIdFunc = jest.fn(); + const mounted = mount( + <WrappedComponent + setProjectId={mockSetProjectIdFunc} + store={store} + /> + ); window.location.hash = '#1234567'; - mounted.instance().handleHashChange(); - expect(mounted.state().projectId).toEqual('1234567'); + mounted + .childAt(0) + .instance() + .handleHashChange(); + expect(mockSetProjectIdFunc.mock.calls.length).toBe(2); }); }); diff --git a/test/unit/util/project-fetcher-hoc.test.jsx b/test/unit/util/project-fetcher-hoc.test.jsx new file mode 100644 index 0000000000000000000000000000000000000000..31593acc01d059b850f1194f502cabddf522e2b7 --- /dev/null +++ b/test/unit/util/project-fetcher-hoc.test.jsx @@ -0,0 +1,64 @@ +import React from 'react'; +import configureStore from 'redux-mock-store'; + +import {mountWithIntl} from '../../helpers/intl-helpers.jsx'; + +import ProjectFetcherHOC from '../../../src/lib/project-fetcher-hoc.jsx'; +import storage from '../../../src/lib/storage'; +import {LoadingState} from '../../../src/reducers/project-state'; + +jest.mock('react-ga'); + +describe('ProjectFetcherHOC', () => { + const mockStore = configureStore(); + let store; + + beforeEach(() => { + store = mockStore({ + scratchGui: { + projectState: {} + } + }); + }); + + test('when there is an id, it tries to update the store with that id', () => { + const Component = ({projectId}) => <div>{projectId}</div>; + const WrappedComponent = ProjectFetcherHOC(Component); + const mockSetProjectIdFunc = jest.fn(); + mountWithIntl( + <WrappedComponent + projectId="100" + setProjectId={mockSetProjectIdFunc} + store={store} + /> + ); + expect(mockSetProjectIdFunc.mock.calls[0][0]).toBe('100'); + }); + test('when there is a reduxProjectId and isFetchingWithProjectId is true, it loads the project', () => { + const mockedOnFetchedProject = jest.fn(); + const originalLoad = storage.load; + storage.load = jest.fn((type, id) => Promise.resolve({data: id})); + const Component = ({projectId}) => <div>{projectId}</div>; + const WrappedComponent = ProjectFetcherHOC(Component); + const mounted = mountWithIntl( + <WrappedComponent + store={store} + onFetchedProjectData={mockedOnFetchedProject} + /> + ); + mounted.setProps({ + reduxProjectId: '100', + isFetchingWithId: true, + loadingState: LoadingState.FETCHING_WITH_ID + }); + expect(storage.load).toHaveBeenLastCalledWith( + storage.AssetType.Project, '100', storage.DataFormat.JSON + ); + storage.load = originalLoad; + // nextTick needed since storage.load is async, and onFetchedProject is called in its then() + process.nextTick( + () => expect(mockedOnFetchedProject) + .toHaveBeenLastCalledWith('100', LoadingState.FETCHING_WITH_ID) + ); + }); +}); diff --git a/test/unit/util/project-loader-hoc.test.jsx b/test/unit/util/project-loader-hoc.test.jsx deleted file mode 100644 index a0a51cc66101947abdfe5751ff33441c48de0579..0000000000000000000000000000000000000000 --- a/test/unit/util/project-loader-hoc.test.jsx +++ /dev/null @@ -1,46 +0,0 @@ -import React from 'react'; -import configureStore from 'redux-mock-store'; -import ProjectLoaderHOC from '../../../src/lib/project-loader-hoc.jsx'; -import storage from '../../../src/lib/storage'; -import {mountWithIntl} from '../../helpers/intl-helpers.jsx'; - -jest.mock('react-ga'); - -describe('ProjectLoaderHOC', () => { - const mockStore = configureStore(); - let store; - - beforeEach(() => { - store = mockStore({scratchGui: {}}); - }); - - test('when there is an id, it tries to load that project', () => { - const Component = ({projectData}) => <div>{projectData}</div>; - const WrappedComponent = ProjectLoaderHOC(Component); - const originalLoad = storage.load; - storage.load = jest.fn((type, id) => Promise.resolve({data: id})); - const mounted = mountWithIntl( - <WrappedComponent - projectId="100" - store={store} - /> - ); - expect(mounted.props().projectId).toEqual('100'); - expect(storage.load).toHaveBeenLastCalledWith( - storage.AssetType.Project, '100', storage.DataFormat.JSON - ); - storage.load = originalLoad; - }); - - test('when there is no project data, it renders null', () => { - const Component = ({projectData}) => <div>{projectData}</div>; - const WrappedComponent = ProjectLoaderHOC(Component); - const originalLoad = storage.load; - storage.load = jest.fn(() => Promise.resolve(null)); - const mounted = mountWithIntl(<WrappedComponent store={store} />); - storage.load = originalLoad; - const mountedDiv = mounted.find('div'); - expect(mountedDiv.exists()).toEqual(false); - }); - -}); diff --git a/test/unit/util/tutorial-from-url.test.js b/test/unit/util/tutorial-from-url.test.js new file mode 100644 index 0000000000000000000000000000000000000000..f29cbd8cc379436cd8d2eac9b01e44413a1ec680 --- /dev/null +++ b/test/unit/util/tutorial-from-url.test.js @@ -0,0 +1,40 @@ +jest.mock('../../../src/lib/analytics.js', () => ({ + event: () => {} +})); + +jest.mock('../../../src/lib/libraries/decks/index.jsx', () => ({ + foo: {urlId: 1} +})); + +import {detectTutorialId} from '../../../src/lib/tutorial-from-url.js'; + +Object.defineProperty( + window.location, + 'search', + {value: '', writable: true} +); + +test('returns the tutorial ID if the urlId matches', () => { + window.location.search = '?tutorial=1'; + expect(detectTutorialId()).toBe('foo'); +}); + +test('returns null if no matching urlId', () => { + window.location.search = '?tutorial=10'; + expect(detectTutorialId()).toBe(null); +}); + +test('returns null if empty template', () => { + window.location.search = '?tutorial='; + expect(detectTutorialId()).toBe(null); +}); + +test('returns null if non-numeric template', () => { + window.location.search = '?tutorial=asdf'; + expect(detectTutorialId()).toBe(null); +}); + +test('takes the first of multiple', () => { + window.location.search = '?tutorial=1&tutorial=2'; + expect(detectTutorialId()).toBe('foo'); +}); diff --git a/test/unit/util/vm-manager-hoc.test.jsx b/test/unit/util/vm-manager-hoc.test.jsx new file mode 100644 index 0000000000000000000000000000000000000000..0bcb8f4b8ea8a1d224af62494a95dd3f9f4720b0 --- /dev/null +++ b/test/unit/util/vm-manager-hoc.test.jsx @@ -0,0 +1,101 @@ +import 'web-audio-test-api'; + +import React from 'react'; +import configureStore from 'redux-mock-store'; +import {mount} from 'enzyme'; +import VM from 'scratch-vm'; +import {LoadingState} from '../../../src/reducers/project-state'; + +import vmManagerHOC from '../../../src/lib/vm-manager-hoc.jsx'; + +describe('VMManagerHOC', () => { + const mockStore = configureStore(); + let store; + let vm; + + beforeEach(() => { + store = mockStore({ + scratchGui: { + projectState: {} + } + }); + vm = new VM(); + vm.attachAudioEngine = jest.fn(); + vm.setCompatibilityMode = jest.fn(); + vm.start = jest.fn(); + }); + test('when it mounts, the vm is initialized', () => { + const Component = () => (<div />); + const WrappedComponent = vmManagerHOC(Component); + mount( + <WrappedComponent + store={store} + vm={vm} + /> + ); + expect(vm.attachAudioEngine.mock.calls.length).toBe(1); + expect(vm.setCompatibilityMode.mock.calls.length).toBe(1); + expect(vm.start.mock.calls.length).toBe(1); + expect(vm.initialized).toBe(true); + }); + test('if it mounts with an initialized vm, it does not reinitialize the vm', () => { + const Component = () => <div />; + const WrappedComponent = vmManagerHOC(Component); + vm.initialized = true; + mount( + <WrappedComponent + store={store} + vm={vm} + /> + ); + expect(vm.attachAudioEngine.mock.calls.length).toBe(0); + expect(vm.setCompatibilityMode.mock.calls.length).toBe(0); + expect(vm.start.mock.calls.length).toBe(0); + expect(vm.initialized).toBe(true); + }); + test('if the isLoadingWithId prop becomes true, it loads project data into the vm', () => { + vm.loadProject = jest.fn(() => Promise.resolve()); + const mockedOnLoadedProject = jest.fn(); + const Component = () => <div />; + const WrappedComponent = vmManagerHOC(Component); + const mounted = mount( + <WrappedComponent + isLoadingWithId={false} + store={store} + vm={vm} + onLoadedProject={mockedOnLoadedProject} + /> + ); + mounted.setProps({ + 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)); + }); + test('if there is projectData, the child is rendered', () => { + const Component = () => <div />; + const WrappedComponent = vmManagerHOC(Component); + const mounted = mount( + <WrappedComponent + projectData="100" + store={store} + vm={vm} + /> + ); + expect(mounted.find('div').length).toBe(1); + }); + test('if there is no projectData, nothing is rendered', () => { + const Component = () => <div />; + const WrappedComponent = vmManagerHOC(Component); + const mounted = mount( + <WrappedComponent + store={store} + vm={vm} + /> + ); + expect(mounted.find('div').length).toBe(0); + }); +});