From 893beb5a15783a4ec31832d3087166335f5b6c4e Mon Sep 17 00:00:00 2001
From: Ben Wheeler <wheeler.benjamin@gmail.com>
Date: Thu, 13 Dec 2018 21:57:12 -0500
Subject: [PATCH] added library-item container, moved costume rotation to
 library items

---
 src/components/library-item/library-item.jsx |  34 ++---
 src/components/library/library.jsx           |  63 ++++-----
 src/containers/library-item.jsx              | 134 +++++++++++++++++++
 src/containers/sprite-library.jsx            |  50 +------
 test/integration/costumes.test.js            |  17 +++
 5 files changed, 187 insertions(+), 111 deletions(-)
 create mode 100644 src/containers/library-item.jsx

diff --git a/src/components/library-item/library-item.jsx b/src/components/library-item/library-item.jsx
index 03a9d6b78..c9ba6ea55 100644
--- a/src/components/library-item/library-item.jsx
+++ b/src/components/library-item/library-item.jsx
@@ -10,24 +10,14 @@ import classNames from 'classnames';
 import bluetoothIconURL from './bluetooth.svg';
 import internetConnectionIconURL from './internet-connection.svg';
 
-class LibraryItem extends React.PureComponent {
+class LibraryItemComponent extends React.PureComponent {
     constructor (props) {
         super(props);
         bindAll(this, [
-            'handleBlur',
             'handleClick',
-            'handleFocus',
-            'handleKeyPress',
-            'handleMouseEnter',
-            'handleMouseLeave'
+            'handleKeyPress'
         ]);
     }
-    handleBlur () {
-        this.props.onBlur(this.props.id);
-    }
-    handleFocus () {
-        this.props.onFocus(this.props.id);
-    }
     handleClick (e) {
         if (!this.props.disabled) {
             this.props.onSelect(this.props.id);
@@ -40,12 +30,6 @@ class LibraryItem extends React.PureComponent {
             this.props.onSelect(this.props.id);
         }
     }
-    handleMouseEnter () {
-        this.props.onMouseEnter(this.props.id);
-    }
-    handleMouseLeave () {
-        this.props.onMouseLeave(this.props.id);
-    }
     render () {
         return this.props.featured ? (
             <div
@@ -146,12 +130,12 @@ class LibraryItem extends React.PureComponent {
                 )}
                 role="button"
                 tabIndex="0"
-                onBlur={this.handleBlur}
+                onBlur={this.props.onBlur}
                 onClick={this.handleClick}
-                onFocus={this.handleFocus}
+                onFocus={this.props.onFocus}
                 onKeyPress={this.handleKeyPress}
-                onMouseEnter={this.handleMouseEnter}
-                onMouseLeave={this.handleMouseLeave}
+                onMouseEnter={this.props.onMouseEnter}
+                onMouseLeave={this.props.onMouseLeave}
             >
                 {/* Layers of wrapping is to prevent layout thrashing on animation */}
                 <Box className={styles.libraryItemImageContainerWrapper}>
@@ -168,7 +152,7 @@ class LibraryItem extends React.PureComponent {
     }
 }
 
-LibraryItem.propTypes = {
+LibraryItemComponent.propTypes = {
     bluetoothRequired: PropTypes.bool,
     collaborator: PropTypes.string,
     description: PropTypes.oneOfType([
@@ -194,8 +178,8 @@ LibraryItem.propTypes = {
     onSelect: PropTypes.func.isRequired
 };
 
-LibraryItem.defaultProps = {
+LibraryItemComponent.defaultProps = {
     disabled: false
 };
 
-export default LibraryItem;
+export default LibraryItemComponent;
diff --git a/src/components/library/library.jsx b/src/components/library/library.jsx
index 8ecb76181..9e43ddd6a 100644
--- a/src/components/library/library.jsx
+++ b/src/components/library/library.jsx
@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
 import React from 'react';
 import {defineMessages, injectIntl, intlShape} from 'react-intl';
 
-import LibraryItem from '../library-item/library-item.jsx';
+import LibraryItem from '../../containers/library-item.jsx';
 import Modal from '../../containers/modal.jsx';
 import Divider from '../divider/divider.jsx';
 import Filter from '../filter/filter.jsx';
@@ -33,11 +33,9 @@ class LibraryComponent extends React.Component {
     constructor (props) {
         super(props);
         bindAll(this, [
-            'handleBlur',
             'handleClose',
             'handleFilterChange',
             'handleFilterClear',
-            'handleFocus',
             'handleMouseEnter',
             'handleMouseLeave',
             'handleSelect',
@@ -56,12 +54,6 @@ class LibraryComponent extends React.Component {
             this.scrollToTop();
         }
     }
-    handleBlur (id) {
-        this.handleMouseLeave(id);
-    }
-    handleFocus (id) {
-        this.handleMouseEnter(id);
-    }
     handleSelect (id) {
         this.handleClose();
         this.props.onItemSelected(this.getFilteredData()[id]);
@@ -76,10 +68,10 @@ class LibraryComponent extends React.Component {
             selectedTag: tag.toLowerCase()
         });
     }
-    handleMouseEnter (id) {
+    handleMouseEnter (id) { // no longer used for sprite costume switching, only for other libraries
         if (this.props.onItemMouseEnter) this.props.onItemMouseEnter(this.getFilteredData()[id]);
     }
-    handleMouseLeave (id) {
+    handleMouseLeave (id) { // no longer used for sprite costume switching, only for other libraries
         if (this.props.onItemMouseLeave) this.props.onItemMouseLeave(this.getFilteredData()[id]);
     }
     handleFilterChange (event) {
@@ -172,33 +164,28 @@ class LibraryComponent extends React.Component {
                     })}
                     ref={this.setFilteredDataRef}
                 >
-                    {this.getFilteredData().map((dataItem, index) => {
-                        const scratchURL = dataItem.md5 ?
-                            `https://cdn.assets.scratch.mit.edu/internalapi/asset/${dataItem.md5}/get/` :
-                            dataItem.rawURL;
-                        return (
-                            <LibraryItem
-                                bluetoothRequired={dataItem.bluetoothRequired}
-                                collaborator={dataItem.collaborator}
-                                description={dataItem.description}
-                                disabled={dataItem.disabled}
-                                extensionId={dataItem.extensionId}
-                                featured={dataItem.featured}
-                                hidden={dataItem.hidden}
-                                iconURL={scratchURL}
-                                id={index}
-                                insetIconURL={dataItem.insetIconURL}
-                                internetConnectionRequired={dataItem.internetConnectionRequired}
-                                key={`item_${index}`}
-                                name={dataItem.name}
-                                onBlur={this.handleBlur}
-                                onFocus={this.handleFocus}
-                                onMouseEnter={this.handleMouseEnter}
-                                onMouseLeave={this.handleMouseLeave}
-                                onSelect={this.handleSelect}
-                            />
-                        );
-                    })}
+                    {this.getFilteredData().map((dataItem, index) => (
+                        <LibraryItem
+                            bluetoothRequired={dataItem.bluetoothRequired}
+                            collaborator={dataItem.collaborator}
+                            description={dataItem.description}
+                            disabled={dataItem.disabled}
+                            extensionId={dataItem.extensionId}
+                            featured={dataItem.featured}
+                            hidden={dataItem.hidden}
+                            iconMd5={dataItem.md5}
+                            iconRawURL={dataItem.rawURL}
+                            icons={dataItem.json && dataItem.json.costumes}
+                            id={index}
+                            insetIconURL={dataItem.insetIconURL}
+                            internetConnectionRequired={dataItem.internetConnectionRequired}
+                            key={`item_${index}`}
+                            name={dataItem.name}
+                            onMouseEnter={this.handleMouseEnter}
+                            onMouseLeave={this.handleMouseLeave}
+                            onSelect={this.handleSelect}
+                        />
+                    ))}
                 </div>
             </Modal>
         );
diff --git a/src/containers/library-item.jsx b/src/containers/library-item.jsx
new file mode 100644
index 000000000..7f81b4389
--- /dev/null
+++ b/src/containers/library-item.jsx
@@ -0,0 +1,134 @@
+import bindAll from 'lodash.bindall';
+import PropTypes from 'prop-types';
+import React from 'react';
+import {injectIntl} from 'react-intl';
+
+import LibraryItemComponent from '../components/library-item/library-item.jsx';
+
+class LibraryItem extends React.PureComponent {
+    constructor (props) {
+        super(props);
+        bindAll(this, [
+            'handleBlur',
+            'handleFocus',
+            'handleMouseEnter',
+            'handleMouseLeave',
+            'rotateIcon',
+            'startRotatingIcons',
+            'stopRotatingIcons'
+        ]);
+        this.state = {
+            iconIndex: 0,
+            isRotatingIcon: false
+        };
+    }
+    componentWillUnmount () {
+        clearInterval(this.intervalId);
+    }
+    handleBlur (id) {
+        this.handleMouseLeave(id);
+    }
+    handleFocus (id) {
+        this.handleMouseEnter(id);
+    }
+    handleMouseEnter () {
+        this.props.onMouseEnter(this.props.id);
+        if (this.props.icons && this.props.icons.length) {
+            this.stopRotatingIcons();
+            this.setState({
+                isRotatingIcon: true
+            }, this.startRotatingIcons);
+        }
+    }
+    handleMouseLeave () {
+        this.props.onMouseLeave(this.props.id);
+        if (this.props.icons && this.props.icons.length) {
+            this.setState({
+                isRotatingIcon: false
+            }, this.stopRotatingIcons);
+        }
+    }
+    startRotatingIcons () {
+        this.rotateIcon();
+        this.intervalId = setInterval(this.rotateIcon, 300);
+    }
+    stopRotatingIcons () {
+        if (this.intervalId) {
+            this.intervalId = clearInterval(this.intervalId);
+        }
+    }
+    rotateIcon () {
+        const nextIconIndex = (this.state.iconIndex + 1) % this.props.icons.length;
+        this.setState({iconIndex: nextIconIndex});
+    }
+    curIconMd5 () {
+        if (this.props.icons &&
+            this.state.isRotatingIcon &&
+            this.state.iconIndex < this.props.icons.length &&
+            this.props.icons[this.state.iconIndex] &&
+            this.props.icons[this.state.iconIndex].baseLayerMD5) {
+            return this.props.icons[this.state.iconIndex].baseLayerMD5;
+        }
+        return this.props.iconMd5;
+    }
+    render () {
+        const iconMd5 = this.curIconMd5();
+        const iconURL = iconMd5 ?
+            `https://cdn.assets.scratch.mit.edu/internalapi/asset/${iconMd5}/get/` :
+            this.props.iconRawURL;
+        return (
+            <LibraryItemComponent
+                bluetoothRequired={this.props.bluetoothRequired}
+                collaborator={this.props.collaborator}
+                description={this.props.description}
+                disabled={this.props.disabled}
+                extensionId={this.props.extensionId}
+                featured={this.props.featured}
+                hidden={this.props.hidden}
+                iconURL={iconURL}
+                icons={this.props.icons}
+                id={this.props.id}
+                insetIconURL={this.props.insetIconURL}
+                internetConnectionRequired={this.props.internetConnectionRequired}
+                name={this.props.name}
+                onBlur={this.handleBlur}
+                onFocus={this.handleFocus}
+                onMouseEnter={this.handleMouseEnter}
+                onMouseLeave={this.handleMouseLeave}
+                onSelect={this.props.onSelect}
+            />
+        );
+    }
+}
+
+LibraryItem.propTypes = {
+    bluetoothRequired: PropTypes.bool,
+    collaborator: PropTypes.string,
+    description: PropTypes.oneOfType([
+        PropTypes.string,
+        PropTypes.node
+    ]),
+    disabled: PropTypes.bool,
+    extensionId: PropTypes.string,
+    featured: PropTypes.bool,
+    hidden: PropTypes.bool,
+    iconMd5: PropTypes.string,
+    iconRawURL: PropTypes.string,
+    icons: PropTypes.arrayOf(
+        PropTypes.shape({
+            baseLayerMD5: PropTypes.string
+        })
+    ),
+    id: PropTypes.number.isRequired,
+    insetIconURL: PropTypes.string,
+    internetConnectionRequired: PropTypes.bool,
+    name: PropTypes.oneOfType([
+        PropTypes.string,
+        PropTypes.node
+    ]),
+    onMouseEnter: PropTypes.func.isRequired,
+    onMouseLeave: PropTypes.func.isRequired,
+    onSelect: PropTypes.func.isRequired
+};
+
+export default injectIntl(LibraryItem);
diff --git a/src/containers/sprite-library.jsx b/src/containers/sprite-library.jsx
index 72efa4fc9..b1e53386b 100644
--- a/src/containers/sprite-library.jsx
+++ b/src/containers/sprite-library.jsx
@@ -22,21 +22,8 @@ class SpriteLibrary extends React.PureComponent {
     constructor (props) {
         super(props);
         bindAll(this, [
-            'handleItemSelect',
-            'handleMouseEnter',
-            'handleMouseLeave',
-            'rotateCostume',
-            'startRotatingCostumes',
-            'stopRotatingCostumes'
+            'handleItemSelect'
         ]);
-        this.state = {
-            activeSprite: null,
-            costumeIndex: 0,
-            sprites: spriteLibraryContent
-        };
-    }
-    componentWillUnmount () {
-        clearInterval(this.intervalId);
     }
     handleItemSelect (item) {
         this.props.vm.addSprite(JSON.stringify(item.json)).then(() => {
@@ -48,46 +35,13 @@ class SpriteLibrary extends React.PureComponent {
             label: item.name
         });
     }
-    handleMouseEnter (item) {
-        this.stopRotatingCostumes();
-        this.setState({activeSprite: item}, this.startRotatingCostumes);
-    }
-    handleMouseLeave () {
-        this.stopRotatingCostumes();
-    }
-    startRotatingCostumes () {
-        if (!this.state.activeSprite) return;
-        this.rotateCostume();
-        this.intervalId = setInterval(this.rotateCostume, 300);
-    }
-    stopRotatingCostumes () {
-        this.intervalId = clearInterval(this.intervalId);
-    }
-    rotateCostume () {
-        const costumes = this.state.activeSprite.json.costumes;
-        const nextCostumeIndex = (this.state.costumeIndex + 1) % costumes.length;
-        this.setState({
-            costumeIndex: nextCostumeIndex,
-            sprites: this.state.sprites.map(sprite => {
-                if (sprite.name === this.state.activeSprite.name) {
-                    return {
-                        ...sprite,
-                        md5: sprite.json.costumes[nextCostumeIndex].baseLayerMD5
-                    };
-                }
-                return sprite;
-            })
-        });
-    }
     render () {
         return (
             <LibraryComponent
-                data={this.state.sprites}
+                data={spriteLibraryContent}
                 id="spriteLibrary"
                 tags={spriteTags}
                 title={this.props.intl.formatMessage(messages.libraryTitle)}
-                onItemMouseEnter={this.handleMouseEnter}
-                onItemMouseLeave={this.handleMouseLeave}
                 onItemSelected={this.handleItemSelect}
                 onRequestClose={this.props.onRequestClose}
             />
diff --git a/test/integration/costumes.test.js b/test/integration/costumes.test.js
index d46e1a4b0..5181c429a 100644
--- a/test/integration/costumes.test.js
+++ b/test/integration/costumes.test.js
@@ -164,4 +164,21 @@ describe('Working with costumes', () => {
         const logs = await getLogs();
         await expect(logs).toEqual([]);
     });
+
+    test('Costumes animate on mouseover', async () => {
+        await loadUri(uri);
+        await clickXpath('//button[@title="Try It"]');
+        await clickXpath('//button[@aria-label="Choose a Sprite"]');
+        const searchElement = await findByXpath("//input[@placeholder='Search']");
+        await searchElement.sendKeys('abb');
+        const abbyElement = await findByXpath('//*[span[text()="Abby"]]');
+        driver.actions()
+            .mouseMove(abbyElement)
+            .perform();
+        // wait for one of Abby's alternate costumes to appear
+        await findByXpath('//img[@src="https://cdn.assets.scratch.mit.edu/internalapi/asset/b6e23922f23b49ddc6f62f675e77417c.svg/get/"]');
+        const logs = await getLogs();
+        await expect(logs).toEqual([]);
+    });
+
 });
-- 
GitLab