diff --git a/client/boot/index.js b/client/boot/index.js index 06a1ce22896656..3cf76a01f4cb6f 100644 --- a/client/boot/index.js +++ b/client/boot/index.js @@ -43,6 +43,7 @@ var config = require( 'config' ), accessibleFocus = require( 'lib/accessible-focus' ), TitleStore = require( 'lib/screen-title/store' ), renderWithReduxStore = require( 'lib/react-helpers' ).renderWithReduxStore, + bindWpLocaleState = require( 'lib/wp/localization' ).bindState, // The following components require the i18n mixin, so must be required after i18n is initialized Layout; @@ -160,6 +161,8 @@ function boot() { function reduxStoreReady( reduxStore ) { let layoutSection, layout, layoutElement, validSections = []; + bindWpLocaleState( reduxStore ); + if ( config.isEnabled( 'support-user' ) ) { require( 'lib/user/support-user-interop' )( reduxStore ); } diff --git a/client/lib/wp/browser.js b/client/lib/wp/browser.js index 886bf5c4d0c34b..5e9ee512644d35 100644 --- a/client/lib/wp/browser.js +++ b/client/lib/wp/browser.js @@ -11,6 +11,7 @@ const debug = debugFactory( 'calypso:wp' ); import wpcomUndocumented from 'lib/wpcom-undocumented'; import config from 'config'; import wpcomSupport from 'lib/wp/support'; +import { injectLocalization } from './localization'; const addSyncHandlerWrapper = config.isEnabled( 'sync-handler' ); let wpcom; @@ -40,11 +41,14 @@ if ( config.isEnabled( 'oauth' ) ) { } ); } +if ( config.isEnabled( 'support-user' ) ) { + wpcom = wpcomSupport( wpcom ); +} + +// Inject localization helpers to `wpcom` instance +wpcom = injectLocalization( wpcom ); + /** * Expose `wpcom` */ -if ( config.isEnabled( 'support-user' ) ) { - module.exports = wpcomSupport( wpcom ); -} else { - module.exports = wpcom; -} +module.exports = wpcom; diff --git a/client/lib/wp/localization/Makefile b/client/lib/wp/localization/Makefile new file mode 100644 index 00000000000000..17beb4217276df --- /dev/null +++ b/client/lib/wp/localization/Makefile @@ -0,0 +1,10 @@ +REPORTER ?= spec +NODE_BIN := ../../../../node_modules/.bin +MOCHA ?= $(NODE_BIN)/mocha +BASE_DIR := $(NODE_BIN)/../.. +NODE_PATH := $(BASE_DIR)/client + +test: + @NODE_ENV=test NODE_PATH=$(NODE_PATH) $(MOCHA) --compilers jsx:babel/register,js:babel/register --reporter $(REPORTER) + +.PHONY: test diff --git a/client/lib/wp/localization/README.md b/client/lib/wp/localization/README.md new file mode 100644 index 00000000000000..54f8b36fe7a69c --- /dev/null +++ b/client/lib/wp/localization/README.md @@ -0,0 +1,16 @@ +wpcom.js Localization +===================== + +This module enables the extension of a `wpcom.js` instance to include localization helper functions. Specifically, the modified instance will include a new `withLocale` function, which will result in the subsequent chained request being localized according to the current user's preferred locale. + +## Usage + +The helper is already bound for the global instance of `wpcom.js` used in Calypso. To take advantage of the localization helpers, call the `withLocale` function at the start of your request chain. + +```js +import wpcom from 'lib/wp'; + +wpcom.withLocale().site( siteId ).postTypesList().then( ( data ) => { + // `data` is a localized response +} ); +``` diff --git a/client/lib/wp/localization/index.js b/client/lib/wp/localization/index.js new file mode 100644 index 00000000000000..47ab2b28501ad8 --- /dev/null +++ b/client/lib/wp/localization/index.js @@ -0,0 +1,74 @@ +/** + * External dependencies + */ +import qs from 'querystring'; + +/** + * Internal dependencies + */ +import { getCurrentUserLocale } from 'state/current-user/selectors'; + +/** + * Module variables + */ +let locale; + +/** + * Given a WPCOM parameter set, modifies the query such that a non-default + * locale is added to the query parameter. + * + * @param {Object} params Original parameters + * @return {Object} Revised parameters, if non-default locale + */ +export function addLocaleQueryParam( params ) { + if ( ! locale || 'en' === locale ) { + return params; + } + + let query = qs.parse( params.query ); + return Object.assign( params, { + query: qs.stringify( Object.assign( query, { locale } ) ) + } ); +}; + +/** + * Modifies a WPCOM instance, returning an updated instance with included + * localization helpers. Specifically, this adds a new `withLocale` method to + * the base instance for indicating the request should be localized. + * + * @param {Object} wpcom Original WPCOM instance + * @return {Object} Modified WPCOM instance with localization helpers + */ +export function injectLocalization( wpcom ) { + const request = wpcom.request.bind( wpcom ); + return Object.assign( wpcom, { + withLocale: function() { + this.localize = true; + return this; + }, + + request: function( params, callback ) { + if ( this.localize ) { + this.localize = false; + return request( addLocaleQueryParam( params ), callback ); + } + + return request( params, callback ); + } + } ); +} + +/** + * Subscribes to the provided Redux store instance, updating the known locale + * value to the latest value when state changes. + * + * @param {Object} store Redux store instance + */ +export function bindState( store ) { + function setLocale() { + locale = getCurrentUserLocale( store.getState() ); + } + + store.subscribe( setLocale ); + setLocale(); +} diff --git a/client/lib/wp/localization/test/index.js b/client/lib/wp/localization/test/index.js new file mode 100644 index 00000000000000..7ece55fee236ba --- /dev/null +++ b/client/lib/wp/localization/test/index.js @@ -0,0 +1,146 @@ +/** + * External dependencies + */ +import { expect } from 'chai'; +import rewire from 'rewire'; +import mockery from 'mockery'; +import sinon from 'sinon'; + +describe( 'localization', () => { + let localization, addLocaleQueryParam, injectLocalization, bindState; + let getCurrentUserLocaleMock = sinon.stub(); + + before( () => { + // Mock user locale state selector + mockery.enable( { + warnOnReplace: false, + warnOnUnregistered: false + } ); + mockery.registerMock( 'state/current-user/selectors', { + getCurrentUserLocale: () => getCurrentUserLocaleMock() + } ); + + // Prepare module for rewiring + localization = rewire( '../' ); + addLocaleQueryParam = localization.addLocaleQueryParam; + injectLocalization = localization.injectLocalization; + bindState = localization.bindState; + } ); + + beforeEach( () => { + localization.__set__( 'locale', undefined ); + } ); + + after( function() { + mockery.disable(); + } ); + + describe( '#addLocaleQueryParam()', () => { + it( 'should not modify params if locale unknown', () => { + const params = addLocaleQueryParam( { query: 'search=foo' } ); + + expect( params ).to.eql( { query: 'search=foo' } ); + } ); + + it( 'should not modify params if locale is default', () => { + localization.__set__( 'locale', 'en' ); + const params = addLocaleQueryParam( { query: 'search=foo' } ); + + expect( params ).to.eql( { query: 'search=foo' } ); + } ); + + it( 'should include the locale query parameter for a non-default locale', () => { + localization.__set__( 'locale', 'fr' ); + const params = addLocaleQueryParam( { query: 'search=foo' } ); + + expect( params ).to.eql( { + query: 'search=foo&locale=fr' + } ); + } ); + } ); + + describe( '#injectLocalization()', () => { + it( 'should return a modified object', () => { + let wpcom = { request() {} }; + injectLocalization( wpcom ); + + expect( wpcom.withLocale ).to.be.a( 'function' ); + } ); + + it( 'should override the default request method', () => { + const request = () => {}; + let wpcom = { request }; + injectLocalization( wpcom ); + + expect( wpcom.request ).to.not.equal( request ); + } ); + + it( 'should not modify params if `withLocale` not used', ( done ) => { + localization.__set__( 'locale', 'fr' ); + let wpcom = { + request( params ) { + expect( params.query ).to.equal( 'search=foo' ); + done(); + } + }; + + injectLocalization( wpcom ); + wpcom.request( { query: 'search=foo' } ); + } ); + + it( 'should modify params if `withLocale` is used', ( done ) => { + localization.__set__( 'locale', 'fr' ); + let wpcom = { + request( params ) { + expect( params.query ).to.equal( 'search=foo&locale=fr' ); + done(); + } + }; + + injectLocalization( wpcom ); + wpcom.withLocale().request( { query: 'search=foo' } ); + } ); + + it( 'should not modify the request after `withLocale` is used', ( done ) => { + localization.__set__( 'locale', 'fr' ); + let assert = false; + let wpcom = { + request( params ) { + if ( ! assert ) { + return; + } + + expect( params.query ).to.equal( 'search=foo' ); + done(); + } + }; + + injectLocalization( wpcom ); + wpcom.withLocale().request( { query: 'search=foo' } ); + assert = true; + wpcom.request( { query: 'search=foo' } ); + } ); + } ); + + describe( '#bindState()', () => { + it( 'should set initial locale from state', () => { + getCurrentUserLocaleMock = sinon.stub().returns( 'fr' ); + bindState( { subscribe() {}, getState() {} } ); + expect( localization.__get__( 'locale' ) ).to.equal( 'fr' ); + } ); + + it( 'should subscribe to the store, setting locale on change', () => { + let listener; + bindState( { + subscribe( _listener ) { + listener = _listener; + }, + getState() {} + } ); + getCurrentUserLocaleMock = sinon.stub().returns( 'de' ); + listener(); + + expect( localization.__get__( 'locale' ) ).to.equal( 'de' ); + } ); + } ); +} ); diff --git a/client/lib/wp/node.js b/client/lib/wp/node.js index 9191aa32f9bc7e..603d4edb6db9d1 100644 --- a/client/lib/wp/node.js +++ b/client/lib/wp/node.js @@ -1,13 +1,17 @@ /** * Internal dependencies */ -var wpcom = require( 'lib/wpcom-undocumented' ); -var config = require( 'config' ); +import wpcomUndocumented from 'lib/wpcom-undocumented'; +import config from 'config'; +import { injectLocalization } from './localization'; -wpcom = wpcom( require( 'wpcom-xhr-request' ) ); +let wpcom = wpcomUndocumented( require( 'wpcom-xhr-request' ) ); if ( config.isEnabled( 'support-user' ) ) { wpcom = require( 'lib/wp/support' )( wpcom ); } +// Inject localization helpers to `wpcom` instance +wpcom = injectLocalization( wpcom ); + module.exports = wpcom; diff --git a/client/state/current-user/selectors.js b/client/state/current-user/selectors.js index fb9a9541d87355..09f8dc8c25136a 100644 --- a/client/state/current-user/selectors.js +++ b/client/state/current-user/selectors.js @@ -16,3 +16,18 @@ export function getCurrentUser( state ) { return getUser( state, state.currentUser.id ); } + +/** + * Returns the locale slug for the current user. + * + * @param {Object} state Global state tree + * @return {?String} Current user locale + */ +export function getCurrentUserLocale( state ) { + const user = getCurrentUser( state ); + if ( ! user ) { + return null; + } + + return user.localeSlug || null; +} diff --git a/client/state/current-user/test/selectors.js b/client/state/current-user/test/selectors.js index b7157180502414..684e9c60907d5a 100644 --- a/client/state/current-user/test/selectors.js +++ b/client/state/current-user/test/selectors.js @@ -6,7 +6,10 @@ import { expect } from 'chai'; /** * Internal dependencies */ -import { getCurrentUser } from '../selectors'; +import { + getCurrentUser, + getCurrentUserLocale +} from '../selectors'; describe( 'selectors', () => { describe( '#getCurrentUser()', () => { @@ -35,4 +38,46 @@ describe( 'selectors', () => { expect( selected ).to.eql( { ID: 73705554, login: 'testonesite2014' } ); } ); } ); + + describe( '#getCurrentUserLocale', () => { + it( 'should return null if the current user is not set', () => { + const locale = getCurrentUserLocale( { + currentUser: { + id: null + } + } ); + + expect( locale ).to.be.null; + } ); + + it( 'should return null if the current user locale slug is not set', () => { + const locale = getCurrentUserLocale( { + users: { + items: { + 73705554: { ID: 73705554, login: 'testonesite2014' } + } + }, + currentUser: { + id: 73705554 + } + } ); + + expect( locale ).to.be.null; + } ); + + it( 'should return the current user locale slug', () => { + const locale = getCurrentUserLocale( { + users: { + items: { + 73705554: { ID: 73705554, login: 'testonesite2014', localeSlug: 'fr' } + } + }, + currentUser: { + id: 73705554 + } + } ); + + expect( locale ).to.equal( 'fr' ); + } ); + } ); } );