From cc339656e24dea0f608f2e451fc714591423e603 Mon Sep 17 00:00:00 2001 From: Claudio Romano Date: Mon, 24 Sep 2018 10:33:52 +0200 Subject: [PATCH] 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); + }); + }); +});