From 27c363a4a958bc3c88bcd1ae1c7346a203ad56fd Mon Sep 17 00:00:00 2001 From: Claudio Romano Date: Sat, 22 Sep 2018 15:42:34 +0200 Subject: [PATCH 1/2] refactor(install): introduce Vue.prototype.$t --- src/install.js | 71 +++++++++++++++++++++++++++----------------------- 1 file changed, 38 insertions(+), 33 deletions(-) diff --git a/src/install.js b/src/install.js index 8c75b11..18c4c58 100644 --- a/src/install.js +++ b/src/install.js @@ -15,10 +15,10 @@ export function install(_Vue) { const getByKey = (i18nOptions, i18nextOptions) => (key) => { if ( - i18nOptions && - i18nOptions.keyPrefix && - !key.includes(i18nextOptions.nsSeparator) - ) { + i18nOptions && + i18nOptions.keyPrefix && + !key.includes(i18nextOptions.nsSeparator) + ) { return `${i18nOptions.keyPrefix}.${key}`; } return key; @@ -39,38 +39,18 @@ export function install(_Vue) { }; Vue.mixin({ - computed: { - $t() { - const getKey = getByKey( - this._i18nOptions, - this.$i18n ? this.$i18n.i18next.options : {}, - ); - - if (this._i18nOptions && this._i18nOptions.namespaces) { - const { lng, namespaces } = this._i18nOptions; - - const fixedT = this.$i18n.i18next.getFixedT(lng, namespaces); - return (key, options) => - fixedT(getKey(key), options, this.$i18n.i18nLoadedAt); - } - - return (key, options) => - this.$i18n.i18next.t(getKey(key), options, this.$i18n.i18nLoadedAt); - }, - }, - beforeCreate() { const options = this.$options; if (options.i18n) { - this.$i18n = options.i18n; + this._i18n = options.i18n; } else if (options.parent && options.parent.$i18n) { - this.$i18n = options.parent.$i18n; + this._i18n = options.parent.$i18n; } let inlineTranslations = {}; - if (this.$i18n) { + if (this._i18n) { const getNamespace = - this.$i18n.options.getComponentNamespace || getComponentNamespace; + this._i18n.options.getComponentNamespace || getComponentNamespace; const { namespace, loadNamespace } = getNamespace(this); if (options.__i18n) { @@ -89,7 +69,7 @@ export function install(_Vue) { messages, } = this.$options.i18nOptions; let { namespaces } = this.$options.i18nOptions; - namespaces = namespaces || this.$i18n.i18next.options.defaultNS; + namespaces = namespaces || this._i18n.i18next.options.defaultNS; if (typeof namespaces === 'string') namespaces = [namespaces]; const namespacesToLoad = namespaces.concat([namespace]); @@ -99,7 +79,7 @@ export function install(_Vue) { } this._i18nOptions = { lng, namespaces: namespacesToLoad, keyPrefix }; - this.$i18n.i18next.loadNamespaces(namespaces); + this._i18n.i18next.loadNamespaces(namespaces); } else if (options.parent && options.parent._i18nOptions) { this._i18nOptions = { ...options.parent._i18nOptions }; this._i18nOptions.namespaces = [ @@ -110,13 +90,13 @@ export function install(_Vue) { this._i18nOptions = { namespaces: [namespace] }; } - if (loadNamespace && this.$i18n.options.loadComponentNamespace) { - this.$i18n.i18next.loadNamespaces([namespace]); + if (loadNamespace && this._i18n.options.loadComponentNamespace) { + this._i18n.i18next.loadNamespaces([namespace]); } const languages = Object.keys(inlineTranslations); languages.forEach((lang) => { - this.$i18n.i18next.addResourceBundle( + this._i18n.i18next.addResourceBundle( lang, namespace, { ...inlineTranslations[lang] }, @@ -125,9 +105,34 @@ export function install(_Vue) { ); }); } + + const getKey = getByKey( + this._i18nOptions, + this._i18n ? this._i18n.i18next.options : {}, + ); + + if (this._i18nOptions && this._i18nOptions.namespaces) { + const { lng, namespaces } = this._i18nOptions; + + const fixedT = this._i18n.i18next.getFixedT(lng, namespaces); + this._getI18nKey = (key, i18nextOptions) => + fixedT(getKey(key), i18nextOptions, this._i18n.i18nLoadedAt); + } else { + this._getI18nKey = (key, i18nextOptions) => + this._i18n.t(getKey(key), i18nextOptions, this._i18n.i18nLoadedAt); + } }, }); + // extend Vue.js + Object.defineProperty(Vue.prototype, '$i18n', { + get() { return this._i18n; }, + }); + + Vue.prototype.$t = function t(key, options) { + return this._getI18nKey(key, options); + }; + Vue.component(component.name, component); Vue.directive('t', { bind, update }); } From cc339656e24dea0f608f2e451fc714591423e603 Mon Sep 17 00:00:00 2001 From: Claudio Romano Date: Mon, 24 Sep 2018 10:33:52 +0200 Subject: [PATCH 2/2] feat(directives): introduce waitForDirective --- docs/guide/directive.md | 31 +++++- examples/app.js | 45 ++++---- src/directive.js | 5 + src/install.js | 6 +- src/wait.js | 51 +++++++++ test/unit/component.test.js | 9 ++ test/unit/wait.test.js | 206 ++++++++++++++++++++++++++++++++++++ 7 files changed, 332 insertions(+), 21 deletions(-) create mode 100644 src/wait.js create mode 100644 test/unit/wait.test.js diff --git a/docs/guide/directive.md b/docs/guide/directive.md index 2efc5e2..a63d8ba 100644 --- a/docs/guide/directive.md +++ b/docs/guide/directive.md @@ -1,4 +1,6 @@ -# Directive +# Directives + +## v-t Full Featured properties: @@ -33,3 +35,30 @@ Vue.component("app", { template: `

` }); ``` + + +## v-waitForT + +Wait for the i18next fot be initialized. If not initialized it sets the element to `hidden = true` and wait +for i18next to be initialized. + +```javascript +const locales = { + en: { + hello: "Hello" + } +}; + +i18next.init({ + lng: "en", + resources: { + en: { translation: locales.en } + } +}); + +const i18n = new VueI18next(i18next); + +Vue.component("app", { + template: `

$t("hello")

` +}); +``` diff --git a/examples/app.js b/examples/app.js index 0d982af..9ad7082 100644 --- a/examples/app.js +++ b/examples/app.js @@ -31,28 +31,32 @@ const i18n = new VueI18next(i18next); Vue.component('app', { template: ` +
-

Translation

$t: {{ $t("message.hello") }}

-
-
-

Interpolation

- - {{ $t("tos") }} - a - -
-
-

Prefix

- -
-
-

Interpolation

- -
-
`, +
+
+

Interpolation

+ + {{ $t("tos") }} + a + +
+
+

Prefix

+ +
+
+

Inline translations

+ +
+
+

Directive

+ +
+ `, }); Vue.component('language-changer', { @@ -113,6 +117,11 @@ Vue.component('inline-translations', { `, }); +Vue.component('with-directive', { + template: ` +
`, +}); + new Vue({ i18n, }).$mount('#app'); diff --git a/src/directive.js b/src/directive.js index 5f944ef..17bd9dd 100644 --- a/src/directive.js +++ b/src/directive.js @@ -86,3 +86,8 @@ export function update(el, binding, vnode, oldVNode) { t(el, binding, vnode); } + +export default { + bind, + update, +}; diff --git a/src/install.js b/src/install.js index 18c4c58..6229280 100644 --- a/src/install.js +++ b/src/install.js @@ -1,7 +1,8 @@ /* eslint-disable import/no-mutable-exports */ import deepmerge from 'deepmerge'; import component from './component'; -import { bind, update } from './directive'; +import directive from './directive'; +import waitDirective from './wait'; export let Vue; @@ -134,5 +135,6 @@ export function install(_Vue) { }; Vue.component(component.name, component); - Vue.directive('t', { bind, update }); + Vue.directive('t', directive); + Vue.directive('waitForT', waitDirective); } diff --git a/src/wait.js b/src/wait.js new file mode 100644 index 0000000..055a19a --- /dev/null +++ b/src/wait.js @@ -0,0 +1,51 @@ +/* eslint-disable no-param-reassign, no-unused-vars */ + +import { warn } from './utils'; + +function assert(vnode) { + const vm = vnode.context; + + if (!vm.$i18n) { + warn('No VueI18Next instance found in the Vue instance'); + return false; + } + + return true; +} + +function waitForIt(el, vnode) { + if (vnode.context.$i18n.i18next.isInitialized) { + el.hidden = false; + } else { + el.hidden = true; + const initialized = () => { + vnode.context.$forceUpdate(); + // due to emitter removing issue in i18next we need to delay remove + setTimeout(() => { + if (vnode.context && vnode.context.$i18n) { + vnode.context.$i18n.i18next.off('initialized', initialized); + } + }, 1000); + }; + vnode.context.$i18n.i18next.on('initialized', initialized); + } +} + +export function bind(el, binding, vnode) { + if (!assert(vnode)) { + return; + } + + waitForIt(el, vnode); +} + +export function update(el, binding, vnode, oldVNode) { + if (vnode.context.$i18n.i18next.isInitialized) { + el.hidden = false; + } +} + +export default { + bind, + update, +}; diff --git a/test/unit/component.test.js b/test/unit/component.test.js index f4a571c..247dc72 100644 --- a/test/unit/component.test.js +++ b/test/unit/component.test.js @@ -311,6 +311,15 @@ describe('Components with backend', () => { expect(root.textContent).to.equal('dev__common__test'); }); + + it('should wait for translation to be ready', async () => { + const root = vm.$refs.hello; + expect(root.textContent).to.equal('key1'); + backend.flush(); + await nextTick(); + + expect(root.textContent).to.equal('dev__common__test'); + }); }); describe('Nested namespaces', () => { diff --git a/test/unit/wait.test.js b/test/unit/wait.test.js new file mode 100644 index 0000000..44b3cf2 --- /dev/null +++ b/test/unit/wait.test.js @@ -0,0 +1,206 @@ +import BackendMock from '../helpers/backendMock'; +import { bind, update } from '../../src/wait'; +import sinon from 'sinon'; + +const backend = new BackendMock(); + +class I18nextMock { + + constructor() { + this.events = { on: [], off: [] }; + this.isInitialized = undefined; + } + + off(event, options) { + this.events.off.push({ event, options }); + } + + on(event, options) { + this.events.on.push({ event, options }); + } + + mockFireEvent(e) { + this.events.on + .filter(({ event }) => e === event) + .map(({ options }) => options()); + } +} + +function nextTick() { + return new Promise(resolve => Vue.nextTick(resolve)); +} + +function sleep(time = 50) { + return new Promise(resolve => setTimeout(() => resolve(), time)); +} + +describe('wait directive', () => { + describe('with already loaded resources', () => { + const i18next1 = i18next.createInstance(); + let vueI18Next; + beforeEach(() => { + i18next1.init({ + lng: 'en', + fallbackLng: 'en', + resources: { + en: { + translation: { hello: 'Hello' }, + }, + de: { + translation: { hello: 'Hallo' }, + }, + }, + }); + vueI18Next = new VueI18Next(i18next1); + }); + + it('should not wait if translations are already ready', async () => { + const el = document.createElement('div'); + const vm = new Vue({ + i18n: vueI18Next, + render(h) { + //

+ return h('p', { + ref: 'text', + directives: [ + { + name: 'waitForT', + rawName: 'v-waitForT', + }, + ], + }); + }, + }).$mount(el); + + await nextTick(); + expect(vm.$el.hidden).to.equal(false); + }); + + it('vuei18Next instance warning', async () => { + const el = document.createElement('div'); + const spy = sinon.spy(console, 'warn'); + new Vue({ + render(h) { + //

+ return h('p', { + ref: 'text', + directives: [ + { + name: 'waitForT', + rawName: 'v-waitForT', + }, + ], + }); + }, + }).$mount(el); + + await nextTick(); + expect(spy.notCalled).to.equal(false); + expect(spy.callCount).to.equal(1); + spy.restore(); + }); + + it('resets i18n listener workaround', async () => { + const i18next = new I18nextMock(); + const vm = { + context: { + $forceUpdate: () => undefined, + $i18n: { + i18next, + }, + }, + }; + + const spy = sinon.spy(vm.context, '$forceUpdate'); + + bind({}, null, vm); + + expect(i18next.events.on.length).to.equal(1); + expect(i18next.events.off.length).to.equal(0); + + i18next.mockFireEvent('initialized'); + expect(spy.called).to.equal(true); + + await sleep(1500); + + expect(i18next.events.off.length).to.equal(1); + }); + + it('resets i18n listener workaround and does it only if context is still valid', async () => { + const i18next = new I18nextMock(); + const vm = { + context: { + $forceUpdate: () => undefined, + $i18n: { + i18next, + }, + }, + }; + + const spy = sinon.spy(vm.context, '$forceUpdate'); + + bind({}, null, vm); + + expect(i18next.events.on.length).to.equal(1); + expect(i18next.events.off.length).to.equal(0); + + i18next.mockFireEvent('initialized'); + expect(spy.called).to.equal(true); + vm.context = undefined; + + await sleep(1500); + + expect(i18next.events.off.length).to.equal(0); + }); + + it('do not show on update if it is not initialized', async () => { + const el = { hidden: true }; + update(el, null, { context: { $i18n: { i18next: { isInitialized: false } } } }); + expect(el.hidden).to.equal(true); + }); + + it('do not show on update if it is not initialized', async () => { + const el = { hidden: true }; + update(el, null, { context: { $i18n: { i18next: { isInitialized: true } } } }); + expect(el.hidden).to.equal(false); + }); + }); + + describe('withBackend', () => { + const i18next1 = i18next.createInstance(); + let vueI18Next; + beforeEach(async () => { + i18next1.use(backend).init({ + lng: 'en', + }); + vueI18Next = new VueI18Next(i18next1); + + await sleep(50); + }); + + it('should wait for translation to be ready', async () => { + const el = document.createElement('div'); + const vm = new Vue({ + i18n: vueI18Next, + render(h) { + //

+ return h('p', { + ref: 'text', + directives: [ + { + name: 'waitForT', + rawName: 'v-waitForT', + }, + ], + }); + }, + }).$mount(el); + + await nextTick(); + expect(vm.$el.hidden).to.equal(true); + backend.flush(); + await nextTick(); + expect(vm.$el.hidden).to.equal(false); + }); + }); +});