From a04554229f9311595c5d1624839f01bf1c6a8dc7 Mon Sep 17 00:00:00 2001 From: chrisgarrity <chrisg@media.mit.edu> Date: Tue, 19 Jun 2018 15:40:47 -0400 Subject: [PATCH] Initial version of loading or switching language. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `?locale=<localecode>` to load in a new langage. Add `?enable=language` to enable the language selector Or combine the two: `?enable=language&locale=<localecode>` Still accepts a hash, but requires that the hash be a sequence of digits. Added new locales reducer to gui: - initializes default react-intl/localeData - initializes default locale messages for all locales - default locale is ‘en’ (can be overridden by URL) Removed dependency on react-intl-redux, added ConnectedIntlProvider (uses correct locale messages from redux based on current locale) --- package.json | 1 - src/containers/blocks.jsx | 21 ++++++-- src/containers/language-selector.jsx | 14 +++-- src/containers/menu.jsx | 7 ++- src/index.js | 5 +- src/lib/app-state-hoc.jsx | 23 ++++---- src/lib/connected-intl-provider.jsx | 10 ++++ src/lib/hash-parser-hoc.jsx | 4 +- src/playground/intl.js | 19 ------- src/reducers/gui.js | 18 +++++++ src/reducers/intl.js | 14 ----- src/reducers/locales.js | 53 +++++++++++++++++++ .../util/hash-project-loader-hoc.test.jsx | 16 ++++-- 13 files changed, 139 insertions(+), 66 deletions(-) create mode 100644 src/lib/connected-intl-provider.jsx delete mode 100644 src/playground/intl.js delete mode 100644 src/reducers/intl.js create mode 100644 src/reducers/locales.js diff --git a/package.json b/package.json index c598483d7..e2121c84b 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 036392421..fa51f4773 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 49fb60943..0b61984be 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 865ad19d3..e9a51cd4c 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 3bfa737f5..f47b279bc 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 dc62aa31b..7c23c4839 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 000000000..e1eacb3ad --- /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 04250aa6e..f3ac92542 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 42b72b003..000000000 --- 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 829f0b044..810fce7c2 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 8c49ee104..000000000 --- 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 000000000..d1336a91e --- /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 5cf78747a..1242385d6 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'); }); }); -- GitLab