diff --git a/package.json b/package.json index c598483d7c9475bca9f1260e7c91246c5624403a..e2121c84bd3caf84306129d59ea0ca41c07918cc 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,6 @@ "react-draggable": "3.0.5", "react-ga": "2.5.3", "react-intl": "2.4.0", - "react-intl-redux": "0.7.0", "react-modal": "3.4.4", "react-popover": "0.5.7", "react-redux": "5.0.7", diff --git a/src/containers/blocks.jsx b/src/containers/blocks.jsx index 03639242144a9c67369d98b9dc04831e291e3bd1..fa51f47733859039615b280c0dddb21b3b03bd47 100644 --- a/src/containers/blocks.jsx +++ b/src/containers/blocks.jsx @@ -52,7 +52,8 @@ class Blocks extends React.Component { 'onVisualReport', 'onWorkspaceUpdate', 'onWorkspaceMetricsChange', - 'setBlocks' + 'setBlocks', + 'setLocale' ]); this.ScratchBlocks.prompt = this.handlePromptStart; this.state = { @@ -86,7 +87,7 @@ class Blocks extends React.Component { addFunctionListener(this.workspace, 'zoom', this.onWorkspaceMetricsChange); this.attachVM(); - this.props.vm.setLocale(this.props.locale, this.props.messages); + this.setLocale(); analytics.pageview('/editors/blocks'); } @@ -109,7 +110,7 @@ class Blocks extends React.Component { } if (prevProps.locale !== this.props.locale) { - this.props.vm.setLocale(this.props.locale, this.props.messages); + this.setLocale(); } if (prevProps.toolboxXML !== this.props.toolboxXML) { @@ -144,6 +145,16 @@ class Blocks extends React.Component { clearTimeout(this.toolboxUpdateTimeout); } + setLocale () { + this.workspace.getFlyout().setRecyclingEnabled(false); + this.ScratchBlocks.ScratchMsgs.setLocale(this.props.locale); + this.props.vm.setLocale(this.props.locale, this.props.messages); + + this.workspace.updateToolbox(this.props.toolboxXML); + this.props.vm.refreshWorkspace(); + this.workspace.getFlyout().setRecyclingEnabled(true); + } + updateToolbox () { this.toolboxUpdateTimeout = false; @@ -461,8 +472,8 @@ const mapStateToProps = state => ({ state.scratchGui.mode.isFullScreen ), extensionLibraryVisible: state.scratchGui.modals.extensionLibrary, - locale: state.intl.locale, - messages: state.intl.messages, + locale: state.scratchGui.locales.locale, + messages: state.scratchGui.locales.messages[state.scratchGui.locales.locale], toolboxXML: state.scratchGui.toolbox.toolboxXML, customProceduresVisible: state.scratchGui.customProcedures.active }); diff --git a/src/containers/language-selector.jsx b/src/containers/language-selector.jsx index 49fb60943ada6bdefa77f95548dc40e2b3080d79..0b61984becbf6a0751c7bd93c6128091ebfbf195 100644 --- a/src/containers/language-selector.jsx +++ b/src/containers/language-selector.jsx @@ -2,7 +2,7 @@ import bindAll from 'lodash.bindall'; import PropTypes from 'prop-types'; import React from 'react'; import {connect} from 'react-redux'; -import {updateIntl} from 'react-intl-redux'; +import {selectLocale} from '../reducers/locales'; import {closeLanguageMenu} from '../reducers/menus'; import LanguageSelectorComponent from '../components/language-selector/language-selector.jsx'; @@ -15,7 +15,10 @@ class LanguageSelector extends React.Component { ]); } handleChange (e) { - this.props.onChangeLanguage(e.target.value); + const newLocale = e.target.value; + if (this.props.locales.hasOwnProperty(newLocale)) { + this.props.onChangeLanguage(newLocale); + } } render () { const { @@ -36,16 +39,19 @@ class LanguageSelector extends React.Component { LanguageSelector.propTypes = { children: PropTypes.node, + currentLocale: PropTypes.string.isRequired, + locales: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)), onChangeLanguage: PropTypes.func.isRequired }; const mapStateToProps = state => ({ - currentLocale: state.intl.locale + currentLocale: state.scratchGui.locales.locale, + locales: state.scratchGui.locales.messages }); const mapDispatchToProps = dispatch => ({ onChangeLanguage: locale => { - dispatch(updateIntl({locale: locale, messages: {}})); + dispatch(selectLocale(locale)); dispatch(closeLanguageMenu()); } }); diff --git a/src/containers/menu.jsx b/src/containers/menu.jsx index 865ad19d3d1181b62c0125a7efbc7ca8c7302b57..e9a51cd4ced969134675d81635388e16c0d5507a 100644 --- a/src/containers/menu.jsx +++ b/src/containers/menu.jsx @@ -13,12 +13,17 @@ class Menu extends React.Component { 'handleClick', 'ref' ]); - if (props.open) this.addListeners(); + } + componentDidMount () { + if (this.props.open) this.addListeners(); } componentDidUpdate (prevProps) { if (this.props.open && !prevProps.open) this.addListeners(); if (!this.props.open && prevProps.open) this.removeListeners(); } + componentWillUnmount () { + this.removeListeners(); + } addListeners () { document.addEventListener('mouseup', this.handleClick); } diff --git a/src/index.js b/src/index.js index 3bfa737f52db778b0196009bcfecb83c96f94795..f47b279bce033d037381b77641801a60d567e269 100644 --- a/src/index.js +++ b/src/index.js @@ -1,12 +1,10 @@ import GUI from './containers/gui.jsx'; -import GuiReducer, {guiInitialState, guiMiddleware, initFullScreen, initPlayer} from './reducers/gui'; +import GuiReducer, {guiInitialState, guiMiddleware, initFullScreen, initPlayer, initLocale} from './reducers/gui'; import {ScratchPaintReducer} from 'scratch-paint'; -import IntlReducer from './reducers/intl'; import {setFullScreen, setPlayer} from './reducers/mode'; import {setAppElement} from 'react-modal'; const guiReducers = { - intl: IntlReducer, scratchGui: GuiReducer, scratchPaint: ScratchPaintReducer }; @@ -19,6 +17,7 @@ export { guiMiddleware, initPlayer, initFullScreen, + initLocale, setFullScreen, setPlayer }; diff --git a/src/lib/app-state-hoc.jsx b/src/lib/app-state-hoc.jsx index dc62aa31bca70f8a05ea90cc698d8050c811ea7c..7c23c48393394aa6e08dadcb07e707a4199b52ea 100644 --- a/src/lib/app-state-hoc.jsx +++ b/src/lib/app-state-hoc.jsx @@ -2,12 +2,9 @@ import React from 'react'; import PropTypes from 'prop-types'; import {Provider} from 'react-redux'; import {createStore, combineReducers, compose} from 'redux'; +import ConnectedIntlProvider from './connected-intl-provider.jsx'; -import {intlShape} from 'react-intl'; -import {IntlProvider, updateIntl} from 'react-intl-redux'; -import intlReducer from '../reducers/intl.js'; - -import guiReducer, {guiInitialState, guiMiddleware, initFullScreen, initPlayer} from '../reducers/gui'; +import guiReducer, {guiInitialState, guiMiddleware, initFullScreen, initLocale, initPlayer} from '../reducers/gui'; import {setPlayer, setFullScreen} from '../reducers/mode.js'; @@ -34,20 +31,22 @@ const AppStateHOC = function (WrappedComponent) { initializedGui = initPlayer(initializedGui); } const reducer = combineReducers({ - intl: intlReducer, scratchGui: guiReducer, scratchPaint: ScratchPaintReducer }); + if (window.location.search.indexOf('locale=') !== -1 || + window.location.search.indexOf('lang=') !== -1) { + const locale = window.location.search.match(/(?:locale|lang)=([\w]+)/)[1]; + initializedGui = initLocale(initializedGui, locale); + } + this.store = createStore( reducer, {scratchGui: initializedGui}, enhancer); } componentDidUpdate (prevProps) { - if (prevProps.intl !== this.props.intl) { - this.store.dispatch(updateIntl(this.props.intl)); - } if (prevProps.isPlayerOnly !== this.props.isPlayerOnly) { this.store.dispatch(setPlayer(this.props.isPlayerOnly)); } @@ -57,22 +56,20 @@ const AppStateHOC = function (WrappedComponent) { } render () { const { - intl, // eslint-disable-line no-unused-vars isFullScreen, // eslint-disable-line no-unused-vars isPlayerOnly, // eslint-disable-line no-unused-vars ...componentProps } = this.props; return ( <Provider store={this.store}> - <IntlProvider> + <ConnectedIntlProvider> <WrappedComponent {...componentProps} /> - </IntlProvider> + </ConnectedIntlProvider> </Provider> ); } } AppStateWrapper.propTypes = { - intl: intlShape, isFullScreen: PropTypes.bool, isPlayerOnly: PropTypes.bool }; diff --git a/src/lib/connected-intl-provider.jsx b/src/lib/connected-intl-provider.jsx new file mode 100644 index 0000000000000000000000000000000000000000..e1eacb3adc077a83ca2bb52b631d6453e1c316d0 --- /dev/null +++ b/src/lib/connected-intl-provider.jsx @@ -0,0 +1,10 @@ +import {IntlProvider as ReactIntlProvider} from 'react-intl'; +import {connect} from 'react-redux'; + +const mapStateToProps = state => ({ + key: state.scratchGui.locales.locale, + locale: state.scratchGui.locales.locale, + messages: state.scratchGui.locales.messages[state.scratchGui.locales.locale] +}); + +export default connect(mapStateToProps)(ReactIntlProvider); diff --git a/src/lib/hash-parser-hoc.jsx b/src/lib/hash-parser-hoc.jsx index 04250aa6e9f1297f662574d649469e3bb610c333..f3ac92542808f2e6e7ee1f9acbdf3978c3b4e7c8 100644 --- a/src/lib/hash-parser-hoc.jsx +++ b/src/lib/hash-parser-hoc.jsx @@ -24,9 +24,9 @@ const HashParserHOC = function (WrappedComponent) { window.removeEventListener('hashchange', this.handleHashChange); } handleHashChange () { - let projectId = window.location.hash.substring(1); + const hashMatch = window.location.hash.match(/#(\d+)/); + const projectId = hashMatch === null ? 0 : hashMatch[1]; if (projectId !== this.state.projectId) { - if (projectId.length < 1) projectId = 0; this.setState({projectId: projectId}); } } diff --git a/src/playground/intl.js b/src/playground/intl.js deleted file mode 100644 index 42b72b003e59fed430ea5ae7d1a73f50e9f40bad..0000000000000000000000000000000000000000 --- a/src/playground/intl.js +++ /dev/null @@ -1,19 +0,0 @@ -import {addLocaleData} from 'react-intl'; - -import localeData from 'scratch-l10n'; -import editorMessages from 'scratch-l10n/locales/editor-msgs'; - -Object.keys(localeData).forEach(locale => { - addLocaleData(localeData[locale].localeData); -}); - -const intlDefault = { - defaultLocale: 'en', - locale: 'en', - messages: editorMessages.en -}; - -export { - intlDefault as default, - editorMessages -}; diff --git a/src/reducers/gui.js b/src/reducers/gui.js index 829f0b0446171e56ca2c3157ea316c5611727d2a..810fce7c213ac30aa8ce2fae0ce957b182271631 100644 --- a/src/reducers/gui.js +++ b/src/reducers/gui.js @@ -7,6 +7,7 @@ import blockDragReducer, {blockDragInitialState} from './block-drag'; import editorTabReducer, {editorTabInitialState} from './editor-tab'; import hoveredTargetReducer, {hoveredTargetInitialState} from './hovered-target'; import menuReducer, {menuInitialState} from './menus'; +import localesReducer, {localesInitialState} from './locales'; import modalReducer, {modalsInitialState} from './modals'; import modeReducer, {modeInitialState} from './mode'; import monitorReducer, {monitorsInitialState} from './monitors'; @@ -26,6 +27,7 @@ const guiInitialState = { colorPicker: colorPickerInitialState, customProcedures: customProceduresInitialState, editorTab: editorTabInitialState, + locales: localesInitialState, mode: modeInitialState, hoveredTarget: hoveredTargetInitialState, stageSize: stageSizeInitialState, @@ -58,6 +60,20 @@ const initFullScreen = function (currentState) { }} ); }; +const initLocale = function (currentState, locale) { + if (currentState.locales.messages.hasOwnProperty(locale)) { + return Object.assign( + {}, + currentState, + {locales: { + locale: locale, + messages: currentState.locales.messages + }} + ); + } + // don't change locale if it's not in the current messages + return currentState; +}; const guiReducer = combineReducers({ assetDrag: assetDragReducer, blockDrag: blockDragReducer, @@ -65,6 +81,7 @@ const guiReducer = combineReducers({ colorPicker: colorPickerReducer, customProcedures: customProceduresReducer, editorTab: editorTabReducer, + locales: localesReducer, mode: modeReducer, hoveredTarget: hoveredTargetReducer, stageSize: stageSizeReducer, @@ -82,5 +99,6 @@ export { guiInitialState, guiMiddleware, initFullScreen, + initLocale, initPlayer }; diff --git a/src/reducers/intl.js b/src/reducers/intl.js deleted file mode 100644 index 8c49ee10475c5a960c35d6eba723203dbb772091..0000000000000000000000000000000000000000 --- a/src/reducers/intl.js +++ /dev/null @@ -1,14 +0,0 @@ -import {intlReducer} from 'react-intl-redux'; - -const intlInitialState = { - intl: { - defaultLocale: 'en', - locale: 'en', - messages: {} - } -}; - -export { - intlReducer as default, - intlInitialState -}; diff --git a/src/reducers/locales.js b/src/reducers/locales.js new file mode 100644 index 0000000000000000000000000000000000000000..d1336a91e3e76beb755ac9e094e22d4a173707a7 --- /dev/null +++ b/src/reducers/locales.js @@ -0,0 +1,53 @@ +import {addLocaleData} from 'react-intl'; + +import {localeData} from 'scratch-l10n'; +import editorMessages from 'scratch-l10n/locales/editor-msgs'; + +addLocaleData(localeData); + +const UPDATE_LOCALES = 'scratch-gui/locales/UPDATE_LOCALES'; +const SELECT_LOCALE = 'scratch-gui/locales/SELECT_LOCALE'; + +const initialState = { + locale: 'en', + messages: editorMessages +}; + +const reducer = function (state, action) { + if (typeof state === 'undefined') state = initialState; + switch (action.type) { + case SELECT_LOCALE: + return Object.assign({}, state, { + locale: action.locale, + messages: state.messages + }); + case UPDATE_LOCALES: + return Object.assign({}, state, { + locale: state.locale, + messages: action.messages + }); + default: + return state; + } +}; + +const selectLocale = function (locale) { + return { + type: SELECT_LOCALE, + locale: locale + }; +}; + +const setLocales = function (localesMessages) { + return { + type: UPDATE_LOCALES, + messages: localesMessages + }; +}; + +export { + reducer as default, + initialState as localesInitialState, + selectLocale, + setLocales +}; diff --git a/test/unit/util/hash-project-loader-hoc.test.jsx b/test/unit/util/hash-project-loader-hoc.test.jsx index 5cf78747ad9b580c2aa506f1e949daa57f7f26e1..1242385d62241588f60b89ea9be57f169f1516fd 100644 --- a/test/unit/util/hash-project-loader-hoc.test.jsx +++ b/test/unit/util/hash-project-loader-hoc.test.jsx @@ -8,9 +8,9 @@ describe('HashParserHOC', () => { 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 = '#winning'; + window.location.hash = '#1234567'; const mounted = mount(<WrappedComponent />); - expect(mounted.state().projectId).toEqual('winning'); + expect(mounted.state().projectId).toEqual('1234567'); }); test('when there is no hash, it passes 0 as the projectId', () => { @@ -21,14 +21,22 @@ describe('HashParserHOC', () => { expect(mounted.state().projectId).toEqual(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); + }); + 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); - window.location.hash = '#winning'; + window.location.hash = '#1234567'; mounted.instance().handleHashChange(); - expect(mounted.state().projectId).toEqual('winning'); + expect(mounted.state().projectId).toEqual('1234567'); }); });