diff --git a/src/lib/hash-parser-hoc.jsx b/src/lib/hash-parser-hoc.jsx new file mode 100644 index 0000000000000000000000000000000000000000..cf3953824662b2219f04a5587c60ed7f60b19e6a --- /dev/null +++ b/src/lib/hash-parser-hoc.jsx @@ -0,0 +1,49 @@ +import React from 'react'; + +/* 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 + */ +const HashParserHOC = function (WrappedComponent) { + class HashParserComponent extends React.Component { + constructor (props) { + super(props); + this.fetchProjectId = this.fetchProjectId.bind(this); + this.updateProject = this.updateProject.bind(this); + this.state = { + projectId: null + }; + } + componentDidMount () { + window.addEventListener('hashchange', this.updateProject); + this.updateProject(); + } + componentWillUnmount () { + window.removeEventListener('hashchange', this.updateProject); + } + fetchProjectId () { + return window.location.hash.substring(1); + } + updateProject () { + let projectId = this.fetchProjectId(); + if (projectId !== this.state.projectId) { + if (projectId.length < 1) projectId = 0; + this.setState({projectId: projectId}); + } + } + render () { + return ( + <WrappedComponent + projectId={this.state.projectId} + {...this.props} + /> + ); + } + } + + return HashParserComponent; +}; + +export { + HashParserHOC as default +}; diff --git a/src/lib/project-loader-hoc.jsx b/src/lib/project-loader-hoc.jsx index cad9367802759f35cec601e4eb89a4ede989503b..b513be019eeca89ecee09d5eede9830e30f19355 100644 --- a/src/lib/project-loader-hoc.jsx +++ b/src/lib/project-loader-hoc.jsx @@ -5,9 +5,8 @@ import analytics from './analytics'; import log from './log'; import storage from './storage'; -/* Higher Order Component to provide behavior for loading projects by id from - * the window's hash (#this part in the url) or by projectId prop passed in from - * the parent (i.e. scratch-www) +/* 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 */ @@ -15,52 +14,40 @@ const ProjectLoaderHOC = function (WrappedComponent) { class ProjectLoaderComponent extends React.Component { constructor (props) { super(props); - this.fetchProjectId = this.fetchProjectId.bind(this); this.updateProject = this.updateProject.bind(this); this.state = { - projectId: null, projectData: null, fetchingProject: false }; } componentDidMount () { - window.addEventListener('hashchange', this.updateProject); - this.updateProject(); + this.updateProject(this.props.projectId); } - componentWillUpdate (nextProps, nextState) { - if (this.state.projectId !== nextState.projectId) { + componentWillUpdate (nextProps) { + if (this.props.projectId !== nextProps.projectId) { this.setState({fetchingProject: true}, () => { - storage - .load(storage.AssetType.Project, this.state.projectId, storage.DataFormat.JSON) - .then(projectAsset => projectAsset && this.setState({ - projectData: projectAsset.data, - fetchingProject: false - })) - .catch(err => log.error(err)); + this.updateProject(nextProps.projectId); }); } } - componentWillUnmount () { - window.removeEventListener('hashchange', this.updateProject); - } - fetchProjectId () { - return window.location.hash.substring(1); - } - updateProject () { - let projectId = this.props.projectId || this.fetchProjectId(); - if (projectId !== this.state.projectId) { - if (projectId.length < 1) projectId = 0; - this.setState({projectId: projectId}); - - if (projectId !== 0) { - analytics.event({ - category: 'project', - action: 'Load Project', - value: projectId, - nonInteraction: true - }); - } - } + 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', + value: projectId, + nonInteraction: true + }); + } + }) + .catch(err => log.error(err)); } render () { const { @@ -78,7 +65,10 @@ const ProjectLoaderHOC = function (WrappedComponent) { } } ProjectLoaderComponent.propTypes = { - projectId: PropTypes.string + projectId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]) + }; + ProjectLoaderComponent.defaultProps = { + projectId: 0 }; return ProjectLoaderComponent; diff --git a/src/playground/blocks-only.jsx b/src/playground/blocks-only.jsx index af330d39d81c4de0a59e82c26a53275aa6e4b370..9a925424cc2d1d5c1ea4d9b38e855f95ff3a3f3e 100644 --- a/src/playground/blocks-only.jsx +++ b/src/playground/blocks-only.jsx @@ -5,7 +5,7 @@ import {connect} from 'react-redux'; import Controls from '../containers/controls.jsx'; import Blocks from '../containers/blocks.jsx'; import GUI from '../containers/gui.jsx'; -import ProjectLoaderHOC from '../lib/project-loader-hoc.jsx'; +import HashParserHOC from '../lib/hash-parser-hoc.jsx'; import styles from './blocks-only.css'; @@ -26,7 +26,7 @@ const BlocksOnly = props => ( </GUI> ); -const App = ProjectLoaderHOC(BlocksOnly); +const App = 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 b55a34c4ea6b0b70563a9f189284ae82f0019d30..a1b6fba5cea1963787f616f6932e2bda15aaf957 100644 --- a/src/playground/compatibility-testing.jsx +++ b/src/playground/compatibility-testing.jsx @@ -6,7 +6,7 @@ import Controls from '../containers/controls.jsx'; import Stage from '../containers/stage.jsx'; import Box from '../components/box/box.jsx'; import GUI from '../containers/gui.jsx'; -import ProjectLoaderHOC from '../lib/project-loader-hoc.jsx'; +import HashParserHOC from '../lib/hash-parser-hoc.jsx'; const mapStateToProps = state => ({vm: state.vm}); @@ -71,7 +71,7 @@ class Player extends React.Component { } } -const App = ProjectLoaderHOC(Player); +const App = HashParserHOC(Player); const appTarget = document.createElement('div'); document.body.appendChild(appTarget); diff --git a/src/playground/index.jsx b/src/playground/index.jsx index 7d138947034ac47ff0b4657b2611f2102f7e2e46..60e018fb6501fa7e865916dd4920595390b7a780 100644 --- a/src/playground/index.jsx +++ b/src/playground/index.jsx @@ -4,6 +4,7 @@ import ReactDOM from 'react-dom'; import analytics from '../lib/analytics'; import GUI from '../containers/gui.jsx'; +import HashParserHOC from '../lib/hash-parser-hoc.jsx'; import styles from './index.css'; @@ -20,5 +21,6 @@ appTarget.className = styles.app; document.body.appendChild(appTarget); GUI.setAppElement(appTarget); +const WrappedGui = HashParserHOC(GUI); -ReactDOM.render(<GUI />, appTarget); +ReactDOM.render(<WrappedGui />, appTarget); diff --git a/src/playground/player.jsx b/src/playground/player.jsx index 25dfc9bfcd13bb8c95457c045be9c672b77f4ef0..4ecae4c0c13af54df9cf36b25475fe4e027dfe53 100644 --- a/src/playground/player.jsx +++ b/src/playground/player.jsx @@ -3,6 +3,8 @@ import ReactDOM from 'react-dom'; import Box from '../components/box/box.jsx'; import GUI from '../containers/gui.jsx'; +import HashParserHOC from '../lib/hash-parser-hoc.jsx'; +const WrappedGui = HashParserHOC(GUI); if (process.env.NODE_ENV === 'production' && typeof window === 'object') { // Warn before navigating away @@ -12,7 +14,7 @@ if (process.env.NODE_ENV === 'production' && typeof window === 'object') { import styles from './player.css'; const Player = () => ( <Box className={styles.stageOnly}> - <GUI + <WrappedGui isPlayerOnly isFullScreen={false} /> diff --git a/test/unit/util/hash-project-loader-hoc.test.jsx b/test/unit/util/hash-project-loader-hoc.test.jsx new file mode 100644 index 0000000000000000000000000000000000000000..9ab9af432accc09ae0ed7781b2642386ed056f59 --- /dev/null +++ b/test/unit/util/hash-project-loader-hoc.test.jsx @@ -0,0 +1,64 @@ +import React from 'react'; +import ProjectLoaderHOC from '../../../src/lib/project-loader-hoc.jsx'; +import HashParserHOC from '../../../src/lib/hash-parser-hoc.jsx'; +import storage from '../../../src/lib/storage'; +import {mount} from 'enzyme'; + +jest.mock('react-ga'); + +describe('Hash/ProjectLoaderHOC', () => { + test('when there is no project data, it renders null', () => { + const Component = ({projectData}) => <div>{projectData}</div>; + const WrappedComponent = HashParserHOC(ProjectLoaderHOC(Component)); + window.location.hash = '#winning'; + const originalLoad = storage.load; + storage.load = jest.fn(() => Promise.resolve(null)); + const mounted = mount(<WrappedComponent />); + storage.load = originalLoad; + window.location.hash = ''; + const mountedDiv = mounted.find('div'); + expect(mountedDiv.exists()).toEqual(false); + }); + + test('when there is no hash, it loads the default project', () => { + const Component = ({projectData}) => <div>{projectData}</div>; + const WrappedComponent = HashParserHOC(ProjectLoaderHOC(Component)); + window.location.hash = ''; + const originalLoad = storage.load; + storage.load = jest.fn((type, id) => Promise.resolve(id)); + const mounted = mount(<WrappedComponent />); + expect(mounted.state().projectId).toEqual(0); + expect(storage.load).toHaveBeenCalledWith( + storage.AssetType.Project, 0, storage.DataFormat.JSON + ); + storage.load = originalLoad; + }); + + test('when there is a hash, it tries to load that project', () => { + const Component = ({projectData}) => <div>{projectData}</div>; + const WrappedComponent = HashParserHOC(ProjectLoaderHOC(Component)); + window.location.hash = '#winning'; + const originalLoad = storage.load; + storage.load = jest.fn((type, id) => Promise.resolve({data: id})); + const mounted = mount(<WrappedComponent />); + expect(mounted.state().projectId).toEqual('winning'); + expect(storage.load).toHaveBeenLastCalledWith( + storage.AssetType.Project, 'winning', storage.DataFormat.JSON + ); + storage.load = originalLoad; + }); + + test('when hash change happens, the project data state is changed', () => { + const Component = ({projectData}) => <div>{projectData}</div>; + const WrappedComponent = HashParserHOC(ProjectLoaderHOC(Component)); + window.location.hash = ''; + const mounted = mount(<WrappedComponent />); + expect(mounted.state().projectId).toEqual(0); + const originalLoad = storage.load; + storage.load = jest.fn((type, id) => Promise.resolve({data: id})); + window.location.hash = '#winning'; + mounted.instance().updateProject(); + expect(mounted.state().projectId).toEqual('winning'); + storage.load = originalLoad; + }); +}); diff --git a/test/unit/util/project-loader-hoc.test.jsx b/test/unit/util/project-loader-hoc.test.jsx index 5acde098e53b8630f1a9b2dbc74177faadcb70d8..30278892f4c1b87a09a983d93f196ee3f6d37525 100644 --- a/test/unit/util/project-loader-hoc.test.jsx +++ b/test/unit/util/project-loader-hoc.test.jsx @@ -6,58 +6,31 @@ import {mount} from 'enzyme'; jest.mock('react-ga'); describe('ProjectLoaderHOC', () => { - test('when there is no project data, it renders null', () => { - const Component = ({projectData}) => <div>{projectData}</div>; - const WrappedComponent = ProjectLoaderHOC(Component); - window.location.hash = '#winning'; - const originalLoad = storage.load; - storage.load = jest.fn(() => Promise.resolve(null)); - const mounted = mount(<WrappedComponent />); - storage.load = originalLoad; - window.location.hash = ''; - const mountedDiv = mounted.find('div'); - expect(mountedDiv.exists()).toEqual(false); - }); - test('when there is no hash, it loads the default project', () => { + test('when there is no id, it loads (default) project id 0', () => { const Component = ({projectData}) => <div>{projectData}</div>; const WrappedComponent = ProjectLoaderHOC(Component); - window.location.hash = ''; const originalLoad = storage.load; storage.load = jest.fn((type, id) => Promise.resolve(id)); const mounted = mount(<WrappedComponent />); - expect(mounted.state().projectId).toEqual(0); + expect(mounted.props().projectId).toEqual(0); expect(storage.load).toHaveBeenCalledWith( storage.AssetType.Project, 0, storage.DataFormat.JSON ); storage.load = originalLoad; }); - test('when there is a hash, it tries to load that project', () => { + test('when there is an id, it tries to load that project', () => { const Component = ({projectData}) => <div>{projectData}</div>; const WrappedComponent = ProjectLoaderHOC(Component); - window.location.hash = '#winning'; const originalLoad = storage.load; storage.load = jest.fn((type, id) => Promise.resolve({data: id})); - const mounted = mount(<WrappedComponent />); - expect(mounted.state().projectId).toEqual('winning'); + const mounted = mount(<WrappedComponent projectId="100" />); + expect(mounted.props().projectId).toEqual('100'); expect(storage.load).toHaveBeenLastCalledWith( - storage.AssetType.Project, 'winning', storage.DataFormat.JSON + storage.AssetType.Project, '100', storage.DataFormat.JSON ); storage.load = originalLoad; }); - test('when hash change happens, the project data state is changed', () => { - const Component = ({projectData}) => <div>{projectData}</div>; - const WrappedComponent = ProjectLoaderHOC(Component); - window.location.hash = ''; - const mounted = mount(<WrappedComponent />); - expect(mounted.state().projectId).toEqual(0); - const originalLoad = storage.load; - storage.load = jest.fn((type, id) => Promise.resolve({data: id})); - window.location.hash = '#winning'; - mounted.instance().updateProject(); - expect(mounted.state().projectId).toEqual('winning'); - storage.load = originalLoad; - }); });