diff --git a/package.json b/package.json index 7b4f644b6e5dea8b3be139dd1aa3c8cc733917aa..88c91d05bbb06f423da68daa39486cb0a629d5cb 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "bowser": "1.9.4", "chromedriver": "2.43.1", "classnames": "2.2.6", + "computed-style-to-inline-style": "3.0.0", "copy-webpack-plugin": "^4.5.1", "core-js": "2.5.7", "css-loader": "^1.0.0", @@ -99,13 +100,13 @@ "redux-throttle": "0.1.1", "rimraf": "^2.6.1", "scratch-audio": "0.1.0-prerelease.20181023202904", - "scratch-blocks": "0.1.0-prerelease.1541019511", + "scratch-blocks": "0.1.0-prerelease.1541605931", "scratch-l10n": "3.0.20181031234255", - "scratch-paint": "0.2.0-prerelease.20181029151725", - "scratch-render": "0.1.0-prerelease.20181029125804", + "scratch-paint": "0.2.0-prerelease.20181105210331", + "scratch-render": "0.1.0-prerelease.20181102130522", "scratch-storage": "1.2.0", - "scratch-svg-renderer": "0.2.0-prerelease.20181024192149", - "scratch-vm": "0.2.0-prerelease.20181030160328", + "scratch-svg-renderer": "0.2.0-prerelease.20181101210634", + "scratch-vm": "0.2.0-prerelease.20181107153250", "selenium-webdriver": "3.6.0", "startaudiocontext": "1.2.1", "style-loader": "^0.23.0", diff --git a/src/components/alerts/alert.css b/src/components/alerts/alert.css index c4f00a741912c0ea92b73d5f387b759f707dce4b..0787a9fb12436804149417d930db8afd01de5c71 100644 --- a/src/components/alerts/alert.css +++ b/src/components/alerts/alert.css @@ -30,7 +30,29 @@ } .alert-close-button { + outline-style:none; +} + +.alert-close-button-container { margin-top: 7px; margin-right: 4px; outline-style:none; + width: 30px; + height: 30px; +} + +.connection-button { + padding: 0.55rem 0.9rem; + border-radius: 0.35rem; + background: #FF8C1A; + color: white; + font-weight: 700; + font-size: 0.77rem; + margin: 0.25rem; + border: none; + cursor: pointer; + display: flex; + align-items: center; + margin-right: 13px; + outline-style:none; } diff --git a/src/components/alerts/alert.jsx b/src/components/alerts/alert.jsx index d0f65309e086f3cc2582131ce21677c2d9a4845e..2ec848984c77aaac28a9a85f6a3bce048089b617 100644 --- a/src/components/alerts/alert.jsx +++ b/src/components/alerts/alert.jsx @@ -1,5 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; +import {FormattedMessage} from 'react-intl'; import Box from '../box/box.jsx'; import CloseButton from '../close-button/close-button.jsx'; @@ -9,7 +10,9 @@ import styles from './alert.css'; const AlertComponent = ({ iconURL, message, - onCloseAlert + onCloseAlert, + onReconnect, + showReconnect }) => ( <Box className={styles.alert} @@ -23,19 +26,37 @@ const AlertComponent = ({ ) : null} {message} </div> - <CloseButton - className={styles.alertCloseButton} - color={CloseButton.COLOR_ORANGE} - size={CloseButton.SIZE_LARGE} - onClick={onCloseAlert} - /> + {showReconnect ? ( + <button + className={styles.connectionButton} + onClick={onReconnect} + > + <FormattedMessage + defaultMessage="Reconnect" + description="Button to reconnect the device" + id="gui.connection.reconnect" + /> + </button> + ) : null} + <Box + className={styles.alertCloseButtonContainer} + > + <CloseButton + className={styles.alertCloseButton} + color={CloseButton.COLOR_ORANGE} + size={CloseButton.SIZE_LARGE} + onClick={onCloseAlert} + /> + </Box> </Box> ); AlertComponent.propTypes = { iconURL: PropTypes.string, message: PropTypes.string, - onCloseAlert: PropTypes.func.isRequired + onCloseAlert: PropTypes.func.isRequired, + onReconnect: PropTypes.func, + showReconnect: PropTypes.bool.isRequired }; export default AlertComponent; diff --git a/src/components/alerts/alerts.jsx b/src/components/alerts/alerts.jsx index 1835a50a820e7de90443449ecec60c54f33779f5..9a87f766c376a0562225e118c9ef53cea0d29f4f 100644 --- a/src/components/alerts/alerts.jsx +++ b/src/components/alerts/alerts.jsx @@ -15,10 +15,12 @@ const AlertsComponent = ({ > {alertsList.map((a, index) => ( <Alert + extensionId={a.extensionId} iconURL={a.iconURL} index={index} key={index} message={a.message} + showReconnect={a.showReconnect} onCloseAlert={onCloseAlert} /> ))} diff --git a/src/components/backpack/backpack.css b/src/components/backpack/backpack.css index 33585ca90de66225f89228aa5aa9a35f6798adca..6eddfa905c4a6f79eb1017d149b7030e32aa278a 100644 --- a/src/components/backpack/backpack.css +++ b/src/components/backpack/backpack.css @@ -71,5 +71,21 @@ .backpack-item { min-width: 4rem; margin: 0 0.25rem; +} + +.backpack-item img { mix-blend-mode: multiply; /* Make white transparent for thumnbnails */ } + +.more { + background: $motion-primary; + color: $ui-white; + border: none; + outline: none; + font-weight: bold; + border-radius: 0.5rem; + font-size: 0.85rem; + padding: 0.5rem; + margin: 0.5rem; + cursor: pointer; +} diff --git a/src/components/backpack/backpack.jsx b/src/components/backpack/backpack.jsx index f5f4500393bc73d80e888d7b7b90b97ad6cde567..f5d1a0ae280b81849055830aab20bd0b575a868a 100644 --- a/src/components/backpack/backpack.jsx +++ b/src/components/backpack/backpack.jsx @@ -25,10 +25,12 @@ const Backpack = ({ error, expanded, loading, + showMore, onToggle, onDelete, onMouseEnter, - onMouseLeave + onMouseLeave, + onMore }) => ( <div className={styles.backpackContainer}> <div @@ -98,6 +100,18 @@ const Backpack = ({ onDeleteButtonClick={onDelete} /> ))} + {showMore && ( + <button + className={styles.more} + onClick={onMore} + > + <FormattedMessage + defaultMessage="More" + description="Load more from backpack" + id="gui.backpack.more" + /> + </button> + )} </div> ) : ( <div className={styles.statusMessage}> @@ -129,9 +143,11 @@ Backpack.propTypes = { expanded: PropTypes.bool, loading: PropTypes.bool, onDelete: PropTypes.func, + onMore: PropTypes.func, onMouseEnter: PropTypes.func, onMouseLeave: PropTypes.func, - onToggle: PropTypes.func + onToggle: PropTypes.func, + showMore: PropTypes.bool }; Backpack.defaultProps = { @@ -140,6 +156,8 @@ Backpack.defaultProps = { dragOver: false, expanded: false, loading: false, + showMore: false, + onMore: null, onToggle: null }; diff --git a/src/components/gui/gui.css b/src/components/gui/gui.css index 3e4d763c4817107abd738ec6c4c8367aae8246d6..eb4f1c929a39974b58fe42c951057b2ab4454bc6 100644 --- a/src/components/gui/gui.css +++ b/src/components/gui/gui.css @@ -303,7 +303,7 @@ $fade-out-distance: 15px; /* Alerts */ .alerts-container { - width: 448px; + width: 520px; z-index: $z-index-alerts; left: 0; right: 0; diff --git a/src/components/menu-bar/login-dropdown.jsx b/src/components/menu-bar/login-dropdown.jsx index 11d23856c8f35edbec895c3b311073b8cc7ec743..0116d7342dfff258c1b8205aac36eb182bf381c6 100644 --- a/src/components/menu-bar/login-dropdown.jsx +++ b/src/components/menu-bar/login-dropdown.jsx @@ -7,11 +7,38 @@ eventually be consolidated. import classNames from 'classnames'; import PropTypes from 'prop-types'; import React from 'react'; +import {defineMessages} from 'react-intl'; import MenuBarMenu from './menu-bar-menu.jsx'; import styles from './login-dropdown.css'; +// these are here as a hack to get them translated, so that equivalent messages will be translated +// when passed in from www via gui's renderLogin() function +const LoginDropdownMessages = defineMessages({ // eslint-disable-line no-unused-vars + username: { + defaultMessage: 'Username', + description: 'Label for login username input', + id: 'general.username' + }, + password: { + defaultMessage: 'Password', + description: 'Label for login password input', + id: 'general.password' + }, + signin: { + defaultMessage: 'Sign in', + description: 'Button text for user to sign in', + id: 'general.signIn' + }, + needhelp: { + defaultMessage: 'Need Help?', + description: 'Button text for user to indicate that they need help', + id: 'login.needHelp' + } +}); + + const LoginDropdown = ({ className, isOpen, diff --git a/src/components/menu-bar/menu-bar.jsx b/src/components/menu-bar/menu-bar.jsx index f7af33ec0bf7eeda60b8d757e2870a71e99d3229..87af1e683916e3a26d886a5bf14f6a2c9d0cb279 100644 --- a/src/components/menu-bar/menu-bar.jsx +++ b/src/components/menu-bar/menu-bar.jsx @@ -150,12 +150,13 @@ class MenuBar extends React.Component { } } handleClickNew () { - // if canCreateNew===true, it's safe to replace current project, since we will auto-save first. - // else confirm first. - const readyToReplaceProject = this.props.canCreateNew || + // if canSave===true and canCreateNew===true, it's safe to replace current project, + // since we will auto-save first. Else, confirm first. + const readyToReplaceProject = (this.props.canSave && this.props.canCreateNew) || confirm('Replace contents of the current project?'); // eslint-disable-line no-alert + this.props.onRequestCloseFile(); if (readyToReplaceProject) { - this.props.onClickNew(this.props.canCreateNew); + this.props.onClickNew(this.props.canSave && this.props.canCreateNew); } } handleClickRemix () { @@ -337,37 +338,25 @@ class MenuBar extends React.Component { place={this.props.isRtl ? 'left' : 'right'} onRequestClose={this.props.onRequestCloseFile} > - <MenuItem - isRtl={this.props.isRtl} - onClick={this.handleClickNew} - > - {newProjectMessage} - </MenuItem> + <MenuSection> + <MenuItem + isRtl={this.props.isRtl} + onClick={this.handleClickNew} + > + {newProjectMessage} + </MenuItem> + </MenuSection> <MenuSection> {this.props.canSave ? ( <MenuItem onClick={this.handleClickSave}> {saveNowMessage} </MenuItem> - ) : (this.props.showComingSoon ? ( - <MenuItemTooltip - id="save" - isRtl={this.props.isRtl} - > - <MenuItem>{saveNowMessage}</MenuItem> - </MenuItemTooltip> - ) : [])} + ) : []} {this.props.canCreateCopy ? ( <MenuItem onClick={this.handleClickSaveAsCopy}> {createCopyMessage} </MenuItem> - ) : (this.props.showComingSoon ? ( - <MenuItemTooltip - id="copy" - isRtl={this.props.isRtl} - > - <MenuItem>{createCopyMessage}</MenuItem> - </MenuItemTooltip> - ) : [])} + ) : []} {this.props.canRemix ? ( <MenuItem onClick={this.handleClickRemix}> {remixMessage} @@ -376,8 +365,9 @@ class MenuBar extends React.Component { </MenuSection> <MenuSection> <SBFileUploader onUpdateProjectTitle={this.props.onUpdateProjectTitle}> - {(renderFileInput, loadProject) => ( + {(className, renderFileInput, loadProject) => ( <MenuItem + className={className} onClick={loadProject} > <FormattedMessage @@ -391,8 +381,9 @@ class MenuBar extends React.Component { </MenuItem> )} </SBFileUploader> - <SB3Downloader>{downloadProject => ( + <SB3Downloader>{(className, downloadProject) => ( <MenuItem + className={className} onClick={this.handleCloseFileMenuAndThen(downloadProject)} > <FormattedMessage @@ -752,7 +743,7 @@ const mapDispatchToProps = dispatch => ({ onRequestCloseLanguage: () => dispatch(closeLanguageMenu()), onClickLogin: () => dispatch(openLoginMenu()), onRequestCloseLogin: () => dispatch(closeLoginMenu()), - onClickNew: canCreateNew => dispatch(requestNewProject(canCreateNew)), + onClickNew: needSave => dispatch(requestNewProject(needSave)), onClickRemix: () => dispatch(remixProject()), onClickSave: () => dispatch(updateProject()), onClickSaveAsCopy: () => dispatch(saveProjectAsCopy()), diff --git a/src/components/menu/menu.jsx b/src/components/menu/menu.jsx index 4e6a6b229170d5d8f0a7844c3fa58ff3425972bc..e69068052cb4e3b3027c3550bada15c1f0812e80 100644 --- a/src/components/menu/menu.jsx +++ b/src/components/menu/menu.jsx @@ -55,9 +55,10 @@ MenuItem.propTypes = { const addDividerClassToFirstChild = (child, id) => ( React.cloneElement(child, { - className: classNames(child.className, { - [styles.menuSection]: id === 0 - }), + className: classNames( + child.className, + {[styles.menuSection]: id === 0} + ), key: id }) ); diff --git a/src/components/spinner/spinner.css b/src/components/spinner/spinner.css new file mode 100644 index 0000000000000000000000000000000000000000..89d22534859ecdd0a82f5686f5fcbe38103387b0 --- /dev/null +++ b/src/components/spinner/spinner.css @@ -0,0 +1,54 @@ +@import "../../css/colors.css"; + +.spinner { + width: 1rem; + height: 1rem; + display: inline-block; + position: relative; + border-radius: 50%; + border-width: .1875rem; + border-style: solid; + border-color: $ui-white-transparent; +} + +.spinner::after, .spinner::before { + width: .625rem; + height: .625rem; + content: ''; + border-radius: 50%; + display: block; +} + +.spinner::after { + position: absolute; + top: -.1875rem; + left: -.1875rem; + border: .1875rem solid transparent; + border-top-color: $ui-white; + animation: spin 1.5s cubic-bezier(0.4, 0.1, 0.4, 1) infinite; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} + +.spinner.orange { + border-color: $error-transparent; +} + +.spinner.orange::after { + border-top-color: $error-primary; +} + +.spinner.white { + border-color: $ui-white-transparent; +} +.spinner.white::after { + border-top-color: $ui-white; +} diff --git a/src/components/spinner/spinner.jsx b/src/components/spinner/spinner.jsx new file mode 100644 index 0000000000000000000000000000000000000000..08685ccb315220d25d1dd90f81b541f574808722 --- /dev/null +++ b/src/components/spinner/spinner.jsx @@ -0,0 +1,26 @@ +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import React from 'react'; + +import styles from './spinner.css'; + +const SpinnerComponent = function (props) { + const { + className + } = props; + return ( + <div + className={classNames( + styles.spinner, + className + )} + /> + ); +}; +SpinnerComponent.propTypes = { + className: PropTypes.string +}; +SpinnerComponent.defaultProps = { + className: '' +}; +export default SpinnerComponent; diff --git a/src/components/sprite-selector-item/sprite-selector-item.css b/src/components/sprite-selector-item/sprite-selector-item.css index d29d15c82c0777cf8eecec14ae027972715cc506..7a2c1f4cea61ece8df041a7ae9bb05502a8d4652 100644 --- a/src/components/sprite-selector-item/sprite-selector-item.css +++ b/src/components/sprite-selector-item/sprite-selector-item.css @@ -42,6 +42,7 @@ .sprite-image { margin: auto; user-select: none; + pointer-events: none; max-width: 32px; max-height: 32px; } @@ -80,7 +81,7 @@ .delete-button { position: absolute; top: 0.125rem; - z-index: 1; + z-index: auto; } [dir="ltr"] .delete-button { diff --git a/src/components/sprite-selector/sprite-list.jsx b/src/components/sprite-selector/sprite-list.jsx index c10362a44797f876322f4c133290c16b0c712154..17ba2dc215345c14ddc3f4291ca7cc2700927e20 100644 --- a/src/components/sprite-selector/sprite-list.jsx +++ b/src/components/sprite-selector/sprite-list.jsx @@ -57,7 +57,8 @@ const SpriteList = function (props) { DragConstants.COSTUME, DragConstants.SOUND, DragConstants.BACKPACK_COSTUME, - DragConstants.BACKPACK_SOUND].includes(draggingType); + DragConstants.BACKPACK_SOUND, + DragConstants.BACKPACK_CODE].includes(draggingType); return ( <SortableAsset diff --git a/src/containers/alert.jsx b/src/containers/alert.jsx index cb38427ad751c6a73b85a2da7ee0565e9ecec21d..62ff58448a03c172687a9a3c5518b516b3f0b187 100644 --- a/src/containers/alert.jsx +++ b/src/containers/alert.jsx @@ -1,40 +1,68 @@ import React from 'react'; import bindAll from 'lodash.bindall'; import PropTypes from 'prop-types'; +import {connect} from 'react-redux'; import AlertComponent from '../components/alerts/alert.jsx'; +import {openConnectionModal} from '../reducers/modals'; +import {setConnectionModalExtensionId} from '../reducers/connection-modal'; class Alert extends React.Component { constructor (props) { super(props); bindAll(this, [ - 'handleOnCloseAlert' + 'handleOnCloseAlert', + 'handleOnReconnect' ]); } handleOnCloseAlert () { this.props.onCloseAlert(this.props.index); } + handleOnReconnect () { + this.props.onOpenConnectionModal(this.props.extensionId); + this.handleOnCloseAlert(); + } render () { const { index, // eslint-disable-line no-unused-vars iconURL, - message + message, + showReconnect } = this.props; return ( <AlertComponent iconURL={iconURL} message={message} + showReconnect={showReconnect} onCloseAlert={this.handleOnCloseAlert} + onReconnect={this.handleOnReconnect} /> ); } } +const mapStateToProps = state => ({ + state: state +}); + +const mapDispatchToProps = dispatch => ({ + onOpenConnectionModal: id => { + dispatch(setConnectionModalExtensionId(id)); + dispatch(openConnectionModal()); + } +}); + Alert.propTypes = { + extensionId: PropTypes.string, iconURL: PropTypes.string, index: PropTypes.number, message: PropTypes.string, - onCloseAlert: PropTypes.func.isRequired + onCloseAlert: PropTypes.func.isRequired, + onOpenConnectionModal: PropTypes.func, + showReconnect: PropTypes.bool.isRequired }; -export default Alert; +export default connect( + mapStateToProps, + mapDispatchToProps +)(Alert); diff --git a/src/containers/backpack.jsx b/src/containers/backpack.jsx index cca9eaab2a62e8f50f4dc6469db8fee9498d5c15..1a22a185e956db246d371aa8e1a10eb5ca883e6c 100644 --- a/src/containers/backpack.jsx +++ b/src/containers/backpack.jsx @@ -29,11 +29,12 @@ class Backpack extends React.Component { 'handleToggle', 'handleDelete', 'getBackpackAssetURL', - 'refreshContents', + 'getContents', 'handleMouseEnter', 'handleMouseLeave', 'handleBlockDragEnd', - 'handleBlockDragUpdate' + 'handleBlockDragUpdate', + 'handleMore' ]); this.state = { // While the DroppableHOC manages drop interactions for asset tiles, @@ -42,8 +43,8 @@ class Backpack extends React.Component { blockDragOutsideWorkspace: false, blockDragOverBackpack: false, error: false, - offset: 0, itemsPerPage: 20, + moreToLoad: false, loading: false, expanded: false, contents: [] @@ -72,12 +73,12 @@ class Backpack extends React.Component { } handleToggle () { const newState = !this.state.expanded; - this.setState({expanded: newState, offset: 0}, () => { + this.setState({expanded: newState, contents: []}, () => { // Emit resize on window to get blocks to resize window.dispatchEvent(new Event('resize')); }); if (newState) { - this.refreshContents(); + this.getContents(); } } handleDrop (dragInfo) { @@ -98,39 +99,69 @@ class Backpack extends React.Component { } if (!payloader) return; - payloader(dragInfo.payload, this.props.vm) - .then(payload => saveBackpackObject({ - host: this.props.host, - token: this.props.token, - username: this.props.username, - ...payload - })) - .then(this.refreshContents); + // Creating the payload is async, so set loading before starting + this.setState({loading: true}, () => { + payloader(dragInfo.payload, this.props.vm) + .then(payload => saveBackpackObject({ + host: this.props.host, + token: this.props.token, + username: this.props.username, + ...payload + })) + .then(item => { + this.setState({ + loading: false, + contents: [item].concat(this.state.contents) + }); + }) + .catch(error => { + this.setState({error: true, loading: false}); + throw error; + }); + }); } handleDelete (id) { - deleteBackpackObject({ - host: this.props.host, - token: this.props.token, - username: this.props.username, - id: id - }).then(this.refreshContents); - } - refreshContents () { - if (this.props.token && this.props.username) { - this.setState({loading: true, error: false}); - getBackpackContents({ + this.setState({loading: true}, () => { + deleteBackpackObject({ host: this.props.host, token: this.props.token, username: this.props.username, - offset: this.state.offset, - limit: this.state.itemsPerPage + id: id }) - .then(contents => { - this.setState({contents, loading: false}); + .then(() => { + this.setState({ + loading: false, + contents: this.state.contents.filter(o => o.id !== id) + }); }) - .catch(() => { + .catch(error => { this.setState({error: true, loading: false}); + throw error; }); + }); + } + getContents () { + if (this.props.token && this.props.username) { + this.setState({loading: true, error: false}, () => { + getBackpackContents({ + host: this.props.host, + token: this.props.token, + username: this.props.username, + offset: this.state.contents.length, + limit: this.state.itemsPerPage + }) + .then(contents => { + this.setState({ + contents: this.state.contents.concat(contents), + moreToLoad: contents.length === this.state.itemsPerPage, + loading: false + }); + }) + .catch(error => { + this.setState({error: true, loading: false}); + throw error; + }); + }); } } handleBlockDragUpdate (isOutsideWorkspace) { @@ -150,11 +181,14 @@ class Backpack extends React.Component { blockDragOverBackpack: false }); } - handleBlockDragEnd (blocks) { + handleBlockDragEnd (blocks, topBlockId) { if (this.state.blockDragOverBackpack) { this.handleDrop({ dragType: DragConstants.CODE, - payload: blocks + payload: { + blockObjects: blocks, + topBlockId: topBlockId + } }); } this.setState({ @@ -162,6 +196,9 @@ class Backpack extends React.Component { blockDragOutsideWorkspace: false }); } + handleMore () { + this.getContents(); + } render () { return ( <DroppableBackpack @@ -170,8 +207,10 @@ class Backpack extends React.Component { error={this.state.error} expanded={this.state.expanded} loading={this.state.loading} + showMore={this.state.moreToLoad} onDelete={this.handleDelete} onDrop={this.handleDrop} + onMore={this.handleMore} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} onToggle={this.props.host ? this.handleToggle : null} diff --git a/src/containers/sb-file-uploader.jsx b/src/containers/sb-file-uploader.jsx index ffa4e0122b8cd12265a573cb60050c2f8eaef973..f7bacfd7391db72a85483840f977bfe12c507c6a 100644 --- a/src/containers/sb-file-uploader.jsx +++ b/src/containers/sb-file-uploader.jsx @@ -107,13 +107,14 @@ class SBFileUploader extends React.Component { ); } render () { - return this.props.children(this.renderFileInput, this.handleClick); + return this.props.children(this.props.className, this.renderFileInput, this.handleClick); } } SBFileUploader.propTypes = { canSave: PropTypes.bool, // eslint-disable-line react/no-unused-prop-types children: PropTypes.func, + className: PropTypes.string, intl: intlShape.isRequired, loadingState: PropTypes.oneOf(LoadingStates), onLoadingFinished: PropTypes.func, @@ -123,6 +124,9 @@ SBFileUploader.propTypes = { loadProject: PropTypes.func }) }; +SBFileUploader.defaultProps = { + className: '' +}; const mapStateToProps = state => ({ loadingState: state.scratchGui.projectState.loadingState, vm: state.scratchGui.vm diff --git a/src/containers/sb3-downloader.jsx b/src/containers/sb3-downloader.jsx index 703674c62398a0a71a2424118d9155e556583c01..47df7bacaebe341abc571d3c9ca7e22e2d024e34 100644 --- a/src/containers/sb3-downloader.jsx +++ b/src/containers/sb3-downloader.jsx @@ -52,6 +52,7 @@ class SB3Downloader extends React.Component { children } = this.props; return children( + this.props.className, this.downloadProject ); } @@ -67,10 +68,14 @@ const getProjectFilename = (curTitle, defaultTitle) => { SB3Downloader.propTypes = { children: PropTypes.func, + className: PropTypes.string, onSaveFinished: PropTypes.func, projectFilename: PropTypes.string, saveProjectSb3: PropTypes.func }; +SB3Downloader.defaultProps = { + className: '' +}; const mapStateToProps = state => ({ saveProjectSb3: state.scratchGui.vm.saveProjectSb3.bind(state.scratchGui.vm), diff --git a/src/containers/sprite-selector-item.jsx b/src/containers/sprite-selector-item.jsx index c448df486b5861c998e7094e89ea55c12bc74fe3..f284be34e1990ee13936d1dfdc6cbcc204433faf 100644 --- a/src/containers/sprite-selector-item.jsx +++ b/src/containers/sprite-selector-item.jsx @@ -119,7 +119,7 @@ class SpriteSelectorItem extends React.Component { render () { const { /* eslint-disable no-unused-vars */ - assetId, + asset, id, index, onClick, diff --git a/src/containers/stage-selector.jsx b/src/containers/stage-selector.jsx index e8f0fc9df0b2fefbb31f1c18ba52bc56f48bdc2f..08ffd3730a520da9a16a92af68fde070d8e3a1ee 100644 --- a/src/containers/stage-selector.jsx +++ b/src/containers/stage-selector.jsx @@ -12,6 +12,7 @@ import DragConstants from '../lib/drag-constants'; import DropAreaHOC from '../lib/drop-area-hoc.jsx'; import {emptyCostume} from '../lib/empty-assets'; import sharedMessages from '../lib/shared-messages'; +import {fetchCode} from '../lib/backpack-api'; import StageSelectorComponent from '../components/stage-selector/stage-selector.jsx'; @@ -22,7 +23,8 @@ const dragTypes = [ DragConstants.COSTUME, DragConstants.SOUND, DragConstants.BACKPACK_COSTUME, - DragConstants.BACKPACK_SOUND + DragConstants.BACKPACK_SOUND, + DragConstants.BACKPACK_CODE ]; const DroppableStage = DropAreaHOC(dragTypes)(StageSelectorComponent); @@ -99,6 +101,12 @@ class StageSelector extends React.Component { md5: dragInfo.payload.body, name: dragInfo.payload.name }, this.props.id); + } else if (dragInfo.dragType === DragConstants.BACKPACK_CODE) { + fetchCode(dragInfo.payload.bodyUrl) + .then(blocks => { + this.props.vm.shareBlocksToTarget(blocks, this.props.id); + this.props.vm.refreshWorkspace(); + }); } } setFileInput (input) { diff --git a/src/containers/target-pane.jsx b/src/containers/target-pane.jsx index cde8d4af41f8feb9d02b9ac101b53674b01e92c1..b48e3d232acd59d6a7428b3df89aa3e4404cd594 100644 --- a/src/containers/target-pane.jsx +++ b/src/containers/target-pane.jsx @@ -19,6 +19,7 @@ import {handleFileUpload, spriteUpload} from '../lib/file-uploader.js'; import sharedMessages from '../lib/shared-messages'; import {emptySprite} from '../lib/empty-assets'; import {highlightTarget} from '../reducers/targets'; +import {fetchSprite, fetchCode} from '../lib/backpack-api'; class TargetPane extends React.Component { constructor (props) { @@ -166,8 +167,7 @@ class TargetPane extends React.Component { } else if (dragInfo.dragType === DragConstants.BACKPACK_SPRITE) { // TODO storage does not have a way of loading zips right now, and may never need it. // So for now just grab the zip manually. - fetch(dragInfo.payload.bodyUrl) - .then(response => response.arrayBuffer()) + fetchSprite(dragInfo.payload.bodyUrl) .then(sprite3Zip => this.props.vm.addSprite(sprite3Zip)); } else if (targetId) { // Something is being dragged over one of the sprite tiles or the backdrop. @@ -192,6 +192,12 @@ class TargetPane extends React.Component { md5: dragInfo.payload.body, name: dragInfo.payload.name }, targetId); + } else if (dragInfo.dragType === DragConstants.BACKPACK_CODE) { + fetchCode(dragInfo.payload.bodyUrl) + .then(blocks => { + this.props.vm.shareBlocksToTarget(blocks, targetId); + this.props.vm.refreshWorkspace(); + }); } } } @@ -231,6 +237,7 @@ class TargetPane extends React.Component { const { onSelectSprite, // eslint-disable-line no-unused-vars + onActivateBlocksTab, // eslint-disable-line no-unused-vars ...targetPaneProps } = TargetPaneComponent.propTypes; diff --git a/src/lib/backpack-api.js b/src/lib/backpack-api.js index 4a6f7f6b24ccf2e9fba256fbed97d28ccf5c8aa7..550d37d43d6bebfc7c329b4c49f62f38e2ad3490 100644 --- a/src/lib/backpack-api.js +++ b/src/lib/backpack-api.js @@ -4,6 +4,14 @@ import soundPayload from './backpack/sound-payload'; import spritePayload from './backpack/sprite-payload'; import codePayload from './backpack/code-payload'; +// Add a new property for the full thumbnail url, which includes the host. +// Also include a full body url for loading sprite zips +// TODO retreiving the images through storage would allow us to remove this. +const includeFullUrls = (item, host) => Object.assign({}, item, { + thumbnailUrl: `${host}/${item.thumbnail}`, + bodyUrl: `${host}/${item.body}` +}); + const getBackpackContents = ({ host, username, @@ -20,15 +28,7 @@ const getBackpackContents = ({ if (error || response.statusCode !== 200) { return reject(); } - // Add a new property for the full thumbnail url, which includes the host. - // Also include a full body url for loading sprite zips - // TODO retreiving the images through storage would allow us to remove this. - return resolve(response.body.map(item => ( - Object.assign({}, item, { - thumbnailUrl: `${host}/${item.thumbnail}`, - bodyUrl: `${host}/${item.body}` - }) - ))); + return resolve(response.body.map(item => includeFullUrls(item, host))); }); }); @@ -51,7 +51,7 @@ const saveBackpackObject = ({ if (error || response.statusCode !== 200) { return reject(); } - return resolve(response.body); + return resolve(includeFullUrls(response.body, host)); }); }); @@ -73,6 +73,22 @@ const deleteBackpackObject = ({ }); }); +// Two types of backpack items are not retreivable through storage +// code, as json and sprite3 as arraybuffer zips. +const fetchAs = (responseType, uri) => new Promise((resolve, reject) => { + xhr({uri, responseType}, (error, response) => { + if (error || response.statusCode !== 200) { + return reject(); + } + return resolve(response.body); + }); +}); + +// These two helpers allow easy fetching of backpack code and sprite zips +// Use the curried fetchAs here so the consumer does not worry about XHR responseTypes +const fetchCode = fetchAs.bind(null, 'json'); +const fetchSprite = fetchAs.bind(null, 'arraybuffer'); + export { getBackpackContents, saveBackpackObject, @@ -80,5 +96,7 @@ export { costumePayload, soundPayload, spritePayload, - codePayload + codePayload, + fetchCode, + fetchSprite }; diff --git a/src/lib/backpack/block-to-image.js b/src/lib/backpack/block-to-image.js new file mode 100644 index 0000000000000000000000000000000000000000..eac4753b3c914f33541a675055ff2da481440770 --- /dev/null +++ b/src/lib/backpack/block-to-image.js @@ -0,0 +1,58 @@ +import computedStyleToInlineStyle from 'computed-style-to-inline-style'; +import ScratchBlocks from 'scratch-blocks'; + +/** + * Given a blockId, return a data-uri image that can be used to create a thumbnail. + * @param {string} blockId the ID of the block to imagify + * @return {Promise} resolves to a data-url of a picture of the blocks + */ +export default function (blockId) { + // Not sure any better way to access the scratch-blocks workspace than this... + const block = ScratchBlocks.getMainWorkspace().getBlockById(blockId); + const blockSvg = block.getSvgRoot().cloneNode(true /* deep */); + + // Once we have the cloned SVG, do the rest in a setTimeout to prevent + // blocking the drag end from finishing promptly. + return new Promise(resolve => { + setTimeout(() => { + // Strip entities that cannot be inlined + blockSvg.innerHTML = blockSvg.innerHTML.replace(/ /g, ' '); + + // Create an <svg> element to put the cloned blockSvg inside + const NS = 'http://www.w3.org/2000/svg'; + const svg = document.createElementNS(NS, 'svg'); + svg.appendChild(blockSvg); + + // Needs to be on the DOM to get CSS properties and correct sizing + document.body.appendChild(svg); + + const padding = 10; + const extraHatPadding = 16; + const topPadding = padding + (blockSvg.getAttribute('data-shapes') === 'hat' ? extraHatPadding : 0); + const leftPadding = padding; + blockSvg.setAttribute('transform', `translate(${leftPadding} ${topPadding})`); + + const bounds = blockSvg.getBoundingClientRect(); + svg.setAttribute('width', bounds.width + (2 * padding)); + svg.setAttribute('height', bounds.height + (2 * padding)); + + // We need to inline the styles set by CSS rules because + // not all the styles are set directly on the SVG. This makes the + // image styled the same way the block actually appears. + // TODO this doesn't handle images that are xlink:href in the SVG + computedStyleToInlineStyle(svg, { + recursive: true, + // Enumerate the specific properties we need to inline. + // Specifically properties that are set from CSS in scratch-blocks + properties: ['fill', 'font-family', 'font-size', 'font-weight'] + }); + + const svgString = (new XMLSerializer()).serializeToString(svg); + + // Once we have the svg as a string, remove it from the DOM + svg.parentNode.removeChild(svg); + + resolve(`data:image/svg+xml;utf-8,${encodeURIComponent(svgString)}`); + }, 10); + }); +} diff --git a/src/lib/backpack/code-payload.js b/src/lib/backpack/code-payload.js index 6c190ea08bba98d173ca26c70eb409984142006d..3ec2e85c25671079023513c1bcb93ddd2bccc662 100644 --- a/src/lib/backpack/code-payload.js +++ b/src/lib/backpack/code-payload.js @@ -1,16 +1,20 @@ -import codeThumbnail from './code-thumbnail'; +import blockToImage from './block-to-image'; +import jpegThumbnail from './jpeg-thumbnail'; -const codePayload = code => { +const codePayload = ({blockObjects, topBlockId}) => { const payload = { type: 'script', // Needs to match backpack-server type name name: 'code', // All code currently gets the same name mime: 'application/json', - body: btoa(JSON.stringify(code)), // Base64 encode the json - thumbnail: codeThumbnail // TODO make code thumbnail dynamic + body: btoa(JSON.stringify(blockObjects)) // Base64 encode the json }; - // Return a promise to make it consistent with other payload constructors like costume-payload - return new Promise(resolve => resolve(payload)); + return blockToImage(topBlockId) + .then(jpegThumbnail) + .then(thumbnail => { + payload.thumbnail = thumbnail.replace('data:image/jpeg;base64,', ''); + return payload; + }); }; export default codePayload; diff --git a/src/lib/backpack/code-thumbnail.js b/src/lib/backpack/code-thumbnail.js deleted file mode 100644 index e657144bcb1c5c12a8fe99c6ffbb51a3234779df..0000000000000000000000000000000000000000 --- a/src/lib/backpack/code-thumbnail.js +++ /dev/null @@ -1,3 +0,0 @@ -// image/jpeg base64 encoded code thumbnail image -// eslint-disable-next-line max-len -export default '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'; diff --git a/src/lib/backpack/jpeg-thumbnail.js b/src/lib/backpack/jpeg-thumbnail.js index 623daf3befdefb74a18b9ab98337484a5691c829..f0fbd28d25fe9406d8c111bba2849b4df3c53e0a 100644 --- a/src/lib/backpack/jpeg-thumbnail.js +++ b/src/lib/backpack/jpeg-thumbnail.js @@ -1,4 +1,4 @@ -const jpegThumbnail = dataUrl => new Promise(resolve => { +const jpegThumbnail = dataUrl => new Promise((resolve, reject) => { const image = new Image(); image.onload = () => { const canvas = document.createElement('canvas'); @@ -14,6 +14,9 @@ const jpegThumbnail = dataUrl => new Promise(resolve => { // TODO we can play with the `quality` option here to optimize file size resolve(canvas.toDataURL('image/jpeg', 0.92 /* quality */)); // Default quality is 0.92 }; + image.onerror = err => { + reject(err); + }; image.src = dataUrl; }); diff --git a/src/lib/blocks.js b/src/lib/blocks.js index 57283adbbc7033a20ab286719ce5b0b230df51a4..212831d0aaab7af48c4a76300403438923d9fa6e 100644 --- a/src/lib/blocks.js +++ b/src/lib/blocks.js @@ -220,5 +220,9 @@ export default function (vm) { return ScratchBlocks.StatusButtonState.NOT_READY; }; + ScratchBlocks.FieldNote.playNote_ = function (noteNum, extensionId) { + vm.runtime.emit('PLAY_NOTE', noteNum, extensionId); + }; + return ScratchBlocks; } diff --git a/src/lib/cloud-provider.js b/src/lib/cloud-provider.js index ff3d34c40bc8472e4652d037fa09e82bb211bb73..9ca5424e3fb1941b94ce7bb603a76b99680afe79 100644 --- a/src/lib/cloud-provider.js +++ b/src/lib/cloud-provider.js @@ -79,11 +79,14 @@ class CloudProvider { msg.user = this.username; msg.project_id = this.projectId; + // Optional string params can use simple falsey undefined check if (dataName) msg.name = dataName; - if (dataValue) msg.value = dataValue; - if (dataIndex) msg.index = dataIndex; if (dataNewName) msg.new_name = dataNewName; + // Optional number params need different undefined check + if (typeof dataValue !== 'undefined') msg.value = dataValue; + if (typeof dataValue !== 'undefined') msg.index = dataIndex; + const dataToWrite = JSON.stringify(msg); this.sendCloudData(dataToWrite); } diff --git a/src/lib/make-toolbox-xml.js b/src/lib/make-toolbox-xml.js index 92748b20f61af930ede35531be858ead618d5b16..936859666aff40e8c4749299b753562ea7cadbdd 100644 --- a/src/lib/make-toolbox-xml.js +++ b/src/lib/make-toolbox-xml.js @@ -312,7 +312,7 @@ const sound = function (isStage, targetId) { </shadow> </value> </block> - <block id="volume" type="sound_volume"/> + <block id="${targetId}_volume" type="sound_volume"/> ${categorySeparator} </category> `; diff --git a/src/reducers/alerts.js b/src/reducers/alerts.js index fefc97b37327eb2310dfa1b430afe1bfccf992cb..ee5ef0931596878c9c0cf59102921848fb2f8873 100644 --- a/src/reducers/alerts.js +++ b/src/reducers/alerts.js @@ -15,11 +15,14 @@ const reducer = function (state, action) { const newList = state.alertsList.slice(); const newAlert = {message: action.data.message}; const extensionId = action.data.extensionId; + newAlert.showReconnect = false; if (extensionId) { // if it's an extension const extension = extensionData.find(ext => ext.extensionId === extensionId); if (extension && extension.name) { // TODO: is this the right place to assemble this message? + newAlert.extensionId = extensionId; newAlert.message = `${newAlert.message} ${extension.name}.`; + newAlert.showReconnect = true; } if (extension && extension.smallPeripheralImage) { newAlert.iconURL = extension.smallPeripheralImage; diff --git a/src/reducers/project-state.js b/src/reducers/project-state.js index 963ea9ec110f9538bd2e52eb89ac4e7bea5b7951..e264f18a48079260c7763471aebd51035ac03d51 100644 --- a/src/reducers/project-state.js +++ b/src/reducers/project-state.js @@ -379,8 +379,8 @@ const setProjectId = id => ({ projectId: id }); -const requestNewProject = canCreateNew => { - if (canCreateNew) return {type: START_UPDATING_BEFORE_CREATING_NEW}; +const requestNewProject = needSave => { + if (needSave) return {type: START_UPDATING_BEFORE_CREATING_NEW}; return {type: START_FETCHING_NEW}; }; diff --git a/test/fixtures/100-100.svg b/test/fixtures/100-100.svg new file mode 100644 index 0000000000000000000000000000000000000000..108f7aa1bc9c4c6cd0d5b4a4728ae0261d5342b7 Binary files /dev/null and b/test/fixtures/100-100.svg differ diff --git a/test/fixtures/gh-3582-png.png b/test/fixtures/gh-3582-png.png new file mode 100644 index 0000000000000000000000000000000000000000..cc1f1464e957dbddf5bc1aa4b8915b988bc97f0f Binary files /dev/null and b/test/fixtures/gh-3582-png.png differ diff --git a/test/integration/connection-modal.test.js b/test/integration/connection-modal.test.js new file mode 100644 index 0000000000000000000000000000000000000000..117be5ce5ed7031092fb40b8a120b68074d9cfcc --- /dev/null +++ b/test/integration/connection-modal.test.js @@ -0,0 +1,66 @@ +import path from 'path'; +import SeleniumHelper from '../helpers/selenium-helper'; + +const { + clickText, + clickXpath, + findByText, + getDriver, + getLogs, + loadUri +} = new SeleniumHelper(); + +const uri = path.resolve(__dirname, '../../build/index.html'); + +let driver; + +// The tests below require Scratch Link to be unavailable, so we can trigger +// an error modal. To make sure this is always true, we came up with the idea of +// injecting javascript that overwrites the global Websocket object with one that +// attempts to connect to a fake socket address. +const websocketFakeoutJs = `var RealWebSocket = WebSocket; + WebSocket = function () { + return new RealWebSocket("wss://fake.fake"); + }`; + +describe('Hardware extension connection modal', () => { + beforeAll(() => { + driver = getDriver(); + }); + + afterAll(async () => { + await driver.quit(); + }); + + test('Message saying Scratch Link is unavailable (BLE)', async () => { + await loadUri(uri); + await clickXpath('//button[@title="Try It"]'); + + await driver.executeScript(websocketFakeoutJs); + + await clickXpath('//button[@title="Add Extension"]'); + + await clickText('micro:bit'); + await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for modal to open + findByText('Scratch Link'); // Scratch Link is mentioned in the error modal + + const logs = await getLogs(); + await expect(logs).toEqual([]); + }); + + test('Message saying Scratch Link is unavailable (BT)', async () => { + await loadUri(uri); + await clickXpath('//button[@title="Try It"]'); + + await driver.executeScript(websocketFakeoutJs); + + await clickXpath('//button[@title="Add Extension"]'); + + await clickText('EV3'); + await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for modal to open + findByText('Scratch Link'); // Scratch Link is mentioned in the error modal + + const logs = await getLogs(); + await expect(logs).toEqual([]); + }); +}); diff --git a/test/integration/costumes.test.js b/test/integration/costumes.test.js index 7aa3b969fc2e152cd461c1c649f392e5fc00dfc9..78c7b911e191c316c0c9b0bd4aa4439edafc32f5 100644 --- a/test/integration/costumes.test.js +++ b/test/integration/costumes.test.js @@ -129,4 +129,35 @@ describe('Working with costumes', () => { const logs = await getLogs(); await expect(logs).toEqual([]); }); + + test('Adding an svg from file', async () => { + await loadUri(uri); + await clickXpath('//button[@title="Try It"]'); + await clickText('Costumes'); + const el = await findByXpath('//button[@aria-label="Choose a Costume"]'); + await driver.actions().mouseMove(el) + .perform(); + await driver.sleep(500); // Wait for thermometer menu to come up + const input = await findByXpath('//input[@type="file"]'); + await input.sendKeys(path.resolve(__dirname, '../fixtures/100-100.svg')); + await clickText('100-100', scope.costumesTab); // Name from filename + await clickText('100 x 100', scope.costumesTab); // Size is right + const logs = await getLogs(); + await expect(logs).toEqual([]); + }); + + test('Adding a png from file (gh-3582)', async () => { + await loadUri(uri); + await clickXpath('//button[@title="Try It"]'); + await clickText('Costumes'); + const el = await findByXpath('//button[@aria-label="Choose a Costume"]'); + await driver.actions().mouseMove(el) + .perform(); + await driver.sleep(500); // Wait for thermometer menu to come up + const input = await findByXpath('//input[@type="file"]'); + await input.sendKeys(path.resolve(__dirname, '../fixtures/gh-3582-png.png')); + await clickText('gh-3582-png', scope.costumesTab); + const logs = await getLogs(); + await expect(logs).toEqual([]); + }); }); diff --git a/test/integration/menu-bar.test.js b/test/integration/menu-bar.test.js index a5c4ede2b7e2b2d8d932d34bb1f257013bdf38b6..ca1a4308b7a97c72a1f824e559b00a1e64859722 100644 --- a/test/integration/menu-bar.test.js +++ b/test/integration/menu-bar.test.js @@ -31,25 +31,24 @@ describe('Menu bar settings', () => { await findByXpath('//*[li[span[text()="New"]] and not(@data-tip="tooltip")]'); }); - test('File->Save now should NOT be enabled', async () => { + test('File->Upload should be enabled', async () => { await loadUri(uri); await clickXpath('//button[@title="Try It"]'); await clickXpath( '//div[contains(@class, "menu-bar_menu-bar-item") and ' + 'contains(@class, "menu-bar_hoverable")][span[text()="File"]]' ); - await findByXpath('//*[li[span[text()="Save now"]] and @data-tip="tooltip"]'); + await findByXpath('//*[li[span[text()="Upload from your computer"]] and not(@data-tip="tooltip")]'); }); - - test('File->Save as a copy should NOT be enabled', async () => { + test('File->Download should be enabled', async () => { await loadUri(uri); await clickXpath('//button[@title="Try It"]'); await clickXpath( '//div[contains(@class, "menu-bar_menu-bar-item") and ' + 'contains(@class, "menu-bar_hoverable")][span[text()="File"]]' ); - await findByXpath('//*[li[span[text()="Save as a copy"]] and @data-tip="tooltip"]'); + await findByXpath('//*[li[span[text()="Download to your computer"]] and not(@data-tip="tooltip")]'); }); test('Share button should NOT be enabled', async () => { diff --git a/test/integration/sprites.test.js b/test/integration/sprites.test.js index 83a07793dc7264c4a555c5d6d017802806003eaa..ca6a84ebaca6bdb7f5de3938e5410495e12023f6 100644 --- a/test/integration/sprites.test.js +++ b/test/integration/sprites.test.js @@ -74,4 +74,39 @@ describe('Working with sprites', () => { const logs = await getLogs(); await expect(logs).toEqual([]); }); + + test('Adding a sprite by uploading a png', async () => { + await loadUri(uri); + await clickXpath('//button[@title="Try It"]'); + const el = await findByXpath('//button[@aria-label="Choose a Sprite"]'); + await driver.actions().mouseMove(el) + .perform(); + await driver.sleep(500); // Wait for thermometer menu to come up + const input = await findByXpath('//input[@type="file"]'); + await input.sendKeys(path.resolve(__dirname, '../fixtures/gh-3582-png.png')); + await clickText('gh-3582-png', scope.spriteTile); + const logs = await getLogs(); + await expect(logs).toEqual([]); + }); + + // This test fails because uploading an SVG as a sprite changes the scaling + // Enable when this is fixed issues/3608 + test.skip('Adding a sprite by uploading an svg (gh-3608)', async () => { + await loadUri(uri); + await clickXpath('//button[@title="Try It"]'); + const el = await findByXpath('//button[@aria-label="Choose a Sprite"]'); + await driver.actions().mouseMove(el) + .perform(); + await driver.sleep(500); // Wait for thermometer menu to come up + const input = await findByXpath('//input[@type="file"]'); + await input.sendKeys(path.resolve(__dirname, '../fixtures/100-100.svg')); + await clickText('100-100', scope.spriteTile); // Sprite is named for costume filename + + // Check to make sure the size is right + await clickText('Costumes'); + await clickText('100-100-costume1', scope.costumesTab); // The name of the costume + await clickText('100 x 100', scope.costumesTab); // The size of the costume + const logs = await getLogs(); + await expect(logs).toEqual([]); + }); }); diff --git a/test/unit/util/cloud-provider.test.js b/test/unit/util/cloud-provider.test.js new file mode 100644 index 0000000000000000000000000000000000000000..65ebbc06e4b50d36c6e111e6cea83ccf3679c795 --- /dev/null +++ b/test/unit/util/cloud-provider.test.js @@ -0,0 +1,44 @@ +import CloudProvider from '../../../src/lib/cloud-provider'; + +// Disable window.WebSocket +global.WebSocket = null; + +describe('CloudProvider', () => { + let cloudProvider = null; + let sentMessage = null; + + beforeEach(() => { + cloudProvider = new CloudProvider(); + // Stub connection + cloudProvider.connection = { + send: msg => { + sentMessage = msg; + } + }; + }); + + test('updateVariable', () => { + cloudProvider.updateVariable('hello', 1); + const obj = JSON.parse(sentMessage); + expect(obj.method).toEqual('set'); + expect(obj.name).toEqual('hello'); + expect(obj.value).toEqual(1); + }); + + test('updateVariable with falsey value', () => { + cloudProvider.updateVariable('hello', 0); + const obj = JSON.parse(sentMessage); + expect(obj.method).toEqual('set'); + expect(obj.name).toEqual('hello'); + expect(obj.value).toEqual(0); + }); + + test('writeToServer with falsey index value', () => { + cloudProvider.writeToServer('method', 'name', 5, 0); + const obj = JSON.parse(sentMessage); + expect(obj.method).toEqual('method'); + expect(obj.name).toEqual('name'); + expect(obj.value).toEqual(5); + expect(obj.index).toEqual(0); + }); +});