From a35b6640d749b0d7875e8f389fec8437fd6ea3bf Mon Sep 17 00:00:00 2001 From: Andreas Resch Date: Thu, 28 Jan 2021 16:49:44 +0100 Subject: [PATCH 1/9] add namespace translate service --- .../src/lib/namespace-translate.service.ts | 106 +++++ .../tests/namespace-translate.service.spec.ts | 437 ++++++++++++++++++ 2 files changed, 543 insertions(+) create mode 100644 projects/ngx-translate/core/src/lib/namespace-translate.service.ts create mode 100644 projects/ngx-translate/core/tests/namespace-translate.service.spec.ts diff --git a/projects/ngx-translate/core/src/lib/namespace-translate.service.ts b/projects/ngx-translate/core/src/lib/namespace-translate.service.ts new file mode 100644 index 00000000..f43039db --- /dev/null +++ b/projects/ngx-translate/core/src/lib/namespace-translate.service.ts @@ -0,0 +1,106 @@ +import { Inject, Injectable, InjectionToken, Provider } from '@angular/core'; +import { Observable } from 'rxjs'; +import { TranslateService } from './translate.service'; +import { isDefined } from './util'; + +const TRANSLATION_NAMESPACE = new InjectionToken('TRANSLATION_NAMESPACE'); + +/** + * Wraps the `getParsedResult`, `get`, `getStreamOnTranslationChange`, `stream` and `instant` functions of the + * TranslateService and prefixes the key given to those functions with the provided namespace. + * + * To access the functionality of this service in your html files use the `namespace-translate` pipe or + * the `namespaceTranslate` directive + * + * Use the @see `namespaceTranslateServiceProvider` function to provide this service + * to your component, service, pipe, module, ... + */ +@Injectable() +export class NamespaceTranslateService { + + constructor(private readonly translateService: TranslateService, + @Inject(TRANSLATION_NAMESPACE) private readonly namespace: string) { + } + + /** + * Returns the parsed result of the translations + */ + public getParsedResult(translations: any, key: any, interpolateParams?: Object): any { + const namespacedKey = this.getNamespacedKey(key, true); + return this.translateService.getParsedResult(translations, namespacedKey, interpolateParams); + } + + /** + * Gets the translated value of a key for the given namespace (or an array of keys) + * @returns the translated key, or an object of translated keys + */ + public get(key: string | Array, interpolateParams?: Object): Observable { + const namespacedKey = this.getNamespacedKey(key); + return this.translateService.get(namespacedKey, interpolateParams); + } + + /** + * Returns a stream of translated values of a key (or an array of keys) which updates + * whenever the translation changes. + * @returns A stream of the translated key, or an object of translated keys + */ + public getStreamOnTranslationChange(key: string | Array, interpolateParams?: Object): Observable { + const namespacedKey = this.getNamespacedKey(key); + return this.translateService.getStreamOnTranslationChange(namespacedKey, interpolateParams); + } + + /** + * Returns a stream of translated values of a key for the given namespace (or an array of keys) which updates + * whenever the language changes. + * @returns A stream of the translated key, or an object of translated keys + */ + public stream(key: string | Array, interpolateParams?: Object): Observable { + const namespacedKey = this.getNamespacedKey(key); + return this.translateService.stream(namespacedKey, interpolateParams); + } + + /** + * Returns a translation instantly from the internal state of loaded translation. + * All rules regarding the current language, the preferred language of even fallback languages will be used except any promise handling. + */ + public instant(key: string | Array, interpolateParams?: Object): string | any { + const namespacedKey = this.getNamespacedKey(key); + return this.translateService.instant(namespacedKey, interpolateParams); + } + + + /** + * Prefixes the given key(s) with the namespace and returns the new key(s) + * @param key The key to prefix with the namespace. + */ + private getNamespacedKey(key: string | Array, skipKeyCheck?: boolean) { + if (!skipKeyCheck && (!isDefined(key) || !key.length)) { + throw new Error(`Parameter "key" required`); + } else if (!isDefined(key) || !key.length) { + // returns the given value unmodified + return key; + } + + if (key instanceof Array) { + return key.map(k => this.namespace + "." + k); + } + return this.namespace + "." + key; + } +} + +const namespaceTranslateFactory = (namespace: string) => (translate: TranslateService) => { + return new NamespaceTranslateService(translate, namespace); +} + +/** + * provides the NamespaceTranslateService to your component, service, pipe, module, ... + * @param namespace The namespace that should be prefixed to keys given functions of the NamespaceTranslateService. + * It should not end with a "." because it inserted automatically between the namespace and the key! + */ +export function namespaceTranslateServiceProvider(namespace: string): Provider { + return { + provide: NamespaceTranslateService, + useFactory: namespaceTranslateFactory(namespace), + deps: [TranslateService] + } +} diff --git a/projects/ngx-translate/core/tests/namespace-translate.service.spec.ts b/projects/ngx-translate/core/tests/namespace-translate.service.spec.ts new file mode 100644 index 00000000..f180ee89 --- /dev/null +++ b/projects/ngx-translate/core/tests/namespace-translate.service.spec.ts @@ -0,0 +1,437 @@ +import { TestBed } from "@angular/core/testing"; +import { Observable, of, zip } from "rxjs"; +import { take, toArray, first } from 'rxjs/operators'; +import { TranslateLoader, TranslateModule, TranslateService, NamespaceTranslateService, namespaceTranslateServiceProvider } from '../src/public_api'; +import * as flatten from "flat"; + +let translations: any = { "NAMESPACE": { "TEST": "This is a namespace test" } }; + +class FakeLoader implements TranslateLoader { + getTranslation(lang: string): Observable { + return of(translations); + } +} + +describe('NamespaceTranslateService', () => { + + describe('getNamespacedKey', () => { + let namespaceTranslate: NamespaceTranslateService; + let translate: TranslateService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot({ + loader: { provide: TranslateLoader, useClass: FakeLoader } + }) + ], + providers: [namespaceTranslateServiceProvider("NAMESPACE")] + }); + namespaceTranslate = TestBed.inject(NamespaceTranslateService); + translate = TestBed.inject(TranslateService); + }); + + afterEach(() => { + namespaceTranslate = undefined; + translate = undefined; + translations = { "NAMESPACE": { "TEST": "This is a namespace test" } }; + }); + + it('should prefix namespace to single key', () => { + const result = namespaceTranslate["getNamespacedKey"]("TEST"); + expect(result).toBe("NAMESPACE.TEST"); + }); + + it('should prefix namespace to multiple keys', () => { + const result = namespaceTranslate["getNamespacedKey"](["TEST1", "TEST2"]); + expect(result).toEqual(["NAMESPACE.TEST1", "NAMESPACE.TEST2"]); + }); + + it('should throw if you forget the key', () => { + + expect(() => { + namespaceTranslate["getNamespacedKey"](undefined); + }).toThrowError('Parameter "key" required'); + + expect(() => { + namespaceTranslate["getNamespacedKey"](''); + }).toThrowError('Parameter "key" required'); + + expect(() => { + namespaceTranslate["getNamespacedKey"](null); + }).toThrowError('Parameter "key" required'); + }); + + it('should return unchanged key if no key is given and skipKeyCheck is true', () => { + + expect( + namespaceTranslate["getNamespacedKey"](undefined, true) + ).toBeUndefined() + + expect( + namespaceTranslate["getNamespacedKey"]('', true) + ).toBe(''); + + expect( + namespaceTranslate["getNamespacedKey"](null, true) + ).toBeNull(); + + }); + + }); + + describe('interaction between TranslateService and NamespaceTranslateService', () => { + let namespaceTranslate: NamespaceTranslateService; + let translate: TranslateService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot({ + loader: { provide: TranslateLoader, useClass: FakeLoader } + }) + ], + providers: [namespaceTranslateServiceProvider("NAMESPACE")] + }); + namespaceTranslate = TestBed.inject(NamespaceTranslateService); + translate = TestBed.inject(TranslateService); + }); + + afterEach(() => { + namespaceTranslate = undefined; + translate = undefined; + translations = { "NAMESPACE": { "TEST": "This is a namespace test" } }; + }); + + it('is defined', () => { + expect(NamespaceTranslateService).toBeDefined(); + expect(namespaceTranslate).toBeDefined(); + expect(namespaceTranslate instanceof NamespaceTranslateService).toBeTruthy(); + }); + + it('should be able to get translations', () => { + translations = { "NAMESPACE": { "TEST": "This is a test", "TEST2": "This is another test" } }; + translate.use('en'); + + // this will request the translation from the backend because we use a static files loader for TranslateService + namespaceTranslate.get('TEST').subscribe((res: string) => { + expect(res).toEqual('This is a test'); + }); + + + // this will request the translation from downloaded translations without making a request to the backend + namespaceTranslate.get('TEST2').subscribe((res: string) => { + expect(res).toEqual('This is another test'); + }); + }); + + it('should be able to get an array translations', () => { + translations = { "NAMESPACE": { "TEST": "This is a test", "TEST2": "This is another test2" } }; + translate.use('en'); + + // this will request the translation from the backend because we use a static files loader for TranslateService + namespaceTranslate.get(['TEST', 'TEST2']).subscribe((res: string) => { + expect(res).toEqual(flatten(translations)); + }); + }); + + it("should fallback to the default language", () => { + translations = {}; + translate.use('fr'); + + namespaceTranslate.get('TEST').subscribe((res: string) => { + expect(res).toEqual('NAMESPACE.TEST'); + + translate.setDefaultLang('nl'); + translate.setTranslation('nl', { "NAMESPACE": { "TEST": "Dit is een test" } }); + + namespaceTranslate.get('TEST').subscribe((res2: string) => { + expect(res2).toEqual('Dit is een test'); + expect(translate.getDefaultLang()).toEqual('nl'); + }); + }); + }); + + it("should use the default language by default", () => { + translate.setDefaultLang('nl'); + translate.setTranslation('nl', { "NAMESPACE": { "TEST": "Dit is een test" } }); + + namespaceTranslate.get('TEST').subscribe((res: string) => { + expect(res).toEqual('Dit is een test'); + }); + }); + + it("should return the key when it doesn't find a translation", () => { + translate.use('en'); + + namespaceTranslate.get('TEST2').subscribe((res: string) => { + expect(res).toEqual('NAMESPACE.TEST2'); + }); + }); + + it("should return the key when you haven't defined any translation", () => { + namespaceTranslate.get('TEST').subscribe((res: string) => { + expect(res).toEqual('NAMESPACE.TEST'); + }); + }); + + it('should return an empty value', () => { + translate.setDefaultLang('en'); + translate.setTranslation('en', { "NAMESPACE": { "TEST": "" } }); + + namespaceTranslate.get('TEST').subscribe((res: string) => { + expect(res).toEqual(''); + }); + }); + + it('should be able to get translations with params', () => { + translations = { "NAMESPACE": { "TEST": "This is a test {{param}}" } }; + translate.use('en'); + + namespaceTranslate.get('TEST', { param: 'with param' }).subscribe((res: string) => { + expect(res).toEqual('This is a test with param'); + }); + + }); + + it('should be able to get translations with nested params', () => { + translations = { "NAMESPACE": { "TEST": "This is a test {{param.value}}" } }; + translate.use('en'); + + namespaceTranslate.get('TEST', { param: { value: 'with param' } }).subscribe((res: string) => { + expect(res).toEqual('This is a test with param'); + }); + + }); + + it('should throw if you forget the key', () => { + translate.use('en'); + + expect(() => { + namespaceTranslate.get(undefined); + }).toThrowError('Parameter "key" required'); + + expect(() => { + namespaceTranslate.get(''); + }).toThrowError('Parameter "key" required'); + + expect(() => { + namespaceTranslate.get(null); + }).toThrowError('Parameter "key" required'); + + expect(() => { + namespaceTranslate.instant(undefined); + }).toThrowError('Parameter "key" required'); + }); + + it('should be able to get translations with nested keys', () => { + translations = { "NAMESPACE": { "TEST": { "TEST": "This is a test" }, "TEST2": { "TEST2": { "TEST2": "This is another test" } } } }; + translate.use('en'); + + namespaceTranslate.get('TEST.TEST').subscribe((res: string) => { + expect(res).toEqual('This is a test'); + }); + + + namespaceTranslate.get('TEST2.TEST2.TEST2').subscribe((res: string) => { + expect(res).toEqual('This is another test'); + }); + }); + + it("shouldn't call the current loader if you set the translation yourself", (done: Function) => { + translations = {}; + translate.setTranslation('en', { "NAMESPACE": { "TEST": "This is a test" } }); + translate.use('en'); + + namespaceTranslate.get('TEST').subscribe((res: string) => { + expect(res).toEqual('This is a test'); + expect(translations).toEqual({}); + done(); + }); + }); + + it('should be able to get a stream array', (done: Function) => { + let tr = { "NAMESPACE": { "TEST": "This is a test", "TEST2": "This is a test2" } }; + translate.setTranslation('en', tr); + translate.use('en'); + + namespaceTranslate.getStreamOnTranslationChange(['TEST', 'TEST2']).subscribe((res: any) => { + expect(res).toEqual(flatten(tr)); + done(); + }); + }); + + it('should initially return the same value for getStreamOnTranslationChange and non-streaming get', (done: Function) => { + translations = { "NAMESPACE": { "TEST": "This is a test" } }; + translate.use('en'); + + zip(namespaceTranslate.getStreamOnTranslationChange('TEST'), namespaceTranslate.get('TEST')).subscribe((value: [string, string]) => { + const [streamed, nonStreamed] = value; + expect(streamed).toEqual('This is a test'); + expect(streamed).toEqual(nonStreamed); + done(); + }); + }); + + it('should be able to stream a translation on translation change', (done: Function) => { + translations = { "NAMESPACE": { "TEST": "This is a test" } }; + translate.use('en'); + + namespaceTranslate.getStreamOnTranslationChange('TEST').pipe(take(3), toArray()).subscribe((res: string[]) => { + const expected = ['This is a test', 'I changed the test value!', 'I changed it again!']; + expect(res).toEqual(expected); + done(); + }); + translate.setTranslation('en', { "NAMESPACE": { "TEST": "I changed the test value!" } }); + translate.setTranslation('en', { "NAMESPACE": { "TEST": "I changed it again!" } }); + }); + + it('should interpolate the same param into each streamed value when get strean on translation change', (done: Function) => { + translations = { "NAMESPACE": { "TEST": "This is a test {{param}}" } }; + translate.use('en'); + + namespaceTranslate.getStreamOnTranslationChange('TEST', { param: 'with param' }).subscribe((res: string[]) => { + const expected = 'This is a test with param'; + expect(res).toEqual(expected); + done(); + }); + }); + + it('should be able to stream a translation for the current language', (done: Function) => { + translations = { "NAMESPACE": { "TEST": "This is a test" } }; + translate.use('en'); + + namespaceTranslate.stream('TEST').subscribe((res: string) => { + expect(res).toEqual('This is a test'); + done(); + }); + }); + + it('should be able to stream a translation of an array for the current language', (done: Function) => { + let tr = { "NAMESPACE": { "TEST": "This is a test", "TEST2": "This is a test2" } }; + translate.setTranslation('en', tr); + translate.use('en'); + + namespaceTranslate.stream(['TEST', 'TEST2']).subscribe((res: any) => { + expect(res).toEqual(flatten(tr)); + done(); + }); + }); + + it('should initially return the same value for streaming and non-streaming get', (done: Function) => { + translations = { "NAMESPACE": { "TEST": "This is a test" } }; + translate.use('en'); + + zip(namespaceTranslate.stream('TEST'), namespaceTranslate.get('TEST')).subscribe((value: [string, string]) => { + const [streamed, nonStreamed] = value; + expect(streamed).toEqual('This is a test'); + expect(streamed).toEqual(nonStreamed); + done(); + }); + }); + + it('should update streaming translations on language change', (done: Function) => { + translations = { "NAMESPACE": { "TEST": "This is a test" } }; + translate.use('en'); + + namespaceTranslate.stream('TEST').pipe(take(3), toArray()).subscribe((res: string[]) => { + const expected = ['This is a test', 'Dit is een test', 'This is a test']; + expect(res).toEqual(expected); + done(); + }); + + translate.setTranslation('nl', { "NAMESPACE": { "TEST": "Dit is een test" } }); + translate.use('nl'); + translate.use('en'); + }); + + it('should update lazy streaming translations on translation change', (done: Function) => { + translations = { "NAMESPACE": { "TEST": "This is a test" } }; + translate.use('en'); + + const translation$ = namespaceTranslate.getStreamOnTranslationChange('TEST'); + + translate.setTranslation('en', { "NAMESPACE": { "TEST": "This is a test2" } }); + + translation$.pipe(first()).subscribe((res: string[]) => { + const expected = "This is a test2"; + expect(res).toEqual(expected); + done(); + }); + }); + + it('should update lazy streaming translations on language change', (done: Function) => { + translations = { "NAMESPACE": { "TEST": "This is a test" } }; + translate.use('en'); + + const translation$ = namespaceTranslate.stream('TEST'); + + translate.setTranslation('nl', { "NAMESPACE": { "TEST": "Dit is een test" } }); + translate.use('nl'); + + translation$.pipe(first()).subscribe((res: string[]) => { + const expected = 'Dit is een test'; + expect(res).toEqual(expected); + done(); + }); + }); + + it('should update streaming translations of an array on language change', (done: Function) => { + const en = { "NAMESPACE": { "TEST": "This is a test", "TEST2": "This is a test2" } }; + const nl = { "NAMESPACE": { "TEST": "Dit is een test", "TEST2": "Dit is een test2" } }; + translate.setTranslation('en', en); + translate.use('en'); + + namespaceTranslate.stream(['TEST', 'TEST2']).pipe(take(3), toArray()).subscribe((res: any[]) => { + const expected = [flatten(en), flatten(nl), flatten(en)]; + expect(res).toEqual(expected); + done(); + }); + + translate.setTranslation('nl', nl); + translate.use('nl'); + translate.use('en'); + }); + + it('should interpolate the same param into each streamed value', (done: Function) => { + translations = { "NAMESPACE": { "TEST": "This is a test {{param}}" } }; + translate.use('en'); + + namespaceTranslate.stream('TEST', { param: 'with param' }).pipe(take(3), toArray()).subscribe((res: string[]) => { + const expected = [ + 'This is a test with param', + 'Dit is een test with param', + 'This is a test with param' + ]; + expect(res).toEqual(expected); + done(); + }); + + translate.setTranslation('nl', { "NAMESPACE": { "TEST": "Dit is een test {{param}}" } }); + translate.use('nl'); + translate.use('en'); + }); + + it('should be able to get instant translations', () => { + translate.setTranslation('en', { "NAMESPACE": { "TEST": "This is a test" } }); + translate.use('en'); + + expect(namespaceTranslate.instant('TEST')).toEqual('This is a test'); + }); + + it('should be able to get instant translations of an array', () => { + let tr = { "NAMESPACE": { "TEST": "This is a test", "TEST2": "This is a test2" } }; + translate.setTranslation('en', tr); + translate.use('en'); + + expect(namespaceTranslate.instant(['TEST', 'TEST2'])).toEqual(flatten(tr)); + }); + + it('should return the key if instant translations are not available', () => { + translate.setTranslation('en', { "NAMESPACE": { "TEST": "This is a test" } }); + translate.use('en'); + + expect(namespaceTranslate.instant('TEST2')).toEqual('NAMESPACE.TEST2'); + }); + }); +}); From 560db075fd9730870b057b87689f0927d3a3ac2a Mon Sep 17 00:00:00 2001 From: Andreas Resch Date: Thu, 28 Jan 2021 16:53:04 +0100 Subject: [PATCH 2/9] move logic of translate directive to base-translate directive preparation to add a namespace-translate directive --- .../core/src/lib/base-translate.directive.ts | 165 ++++++++++++++++++ .../core/src/lib/translate.directive.ts | 163 ++--------------- 2 files changed, 180 insertions(+), 148 deletions(-) create mode 100644 projects/ngx-translate/core/src/lib/base-translate.directive.ts diff --git a/projects/ngx-translate/core/src/lib/base-translate.directive.ts b/projects/ngx-translate/core/src/lib/base-translate.directive.ts new file mode 100644 index 00000000..acbb5cf9 --- /dev/null +++ b/projects/ngx-translate/core/src/lib/base-translate.directive.ts @@ -0,0 +1,165 @@ +import { AfterViewChecked, ChangeDetectorRef, ElementRef, Input, OnDestroy } from '@angular/core'; +import { Subscription, isObservable, Observable } from 'rxjs'; +import { DefaultLangChangeEvent, LangChangeEvent, TranslateService, TranslationChangeEvent } from './translate.service'; +import { equals, isDefined } from './util'; + +export abstract class BaseTranslateDirective implements AfterViewChecked, OnDestroy { + + abstract getParsedResult(translations: any, key: string | Array, interpolateParams?: Object): any; + abstract get(key: string | Array, interpolateParams?: Object): Observable; + + key: string; + lastParams: any; + currentParams: any; + onLangChangeSub: Subscription; + onDefaultLangChangeSub: Subscription; + onTranslationChangeSub: Subscription; + + constructor(protected translateService: TranslateService, private element: ElementRef, private _ref: ChangeDetectorRef) { + // subscribe to onTranslationChange event, in case the translations of the current lang change + if (!this.onTranslationChangeSub) { + this.onTranslationChangeSub = this.translateService.onTranslationChange.subscribe((event: TranslationChangeEvent) => { + if (event.lang === this.translateService.currentLang) { + this.checkNodes(true, event.translations); + } + }); + } + + // subscribe to onLangChange event, in case the language changes + if (!this.onLangChangeSub) { + this.onLangChangeSub = this.translateService.onLangChange.subscribe((event: LangChangeEvent) => { + this.checkNodes(true, event.translations); + }); + } + + // subscribe to onDefaultLangChange event, in case the default language changes + if (!this.onDefaultLangChangeSub) { + this.onDefaultLangChangeSub = this.translateService.onDefaultLangChange.subscribe((event: DefaultLangChangeEvent) => { + this.checkNodes(true); + }); + } + } + + setTranslate(key: string) { + if (key) { + this.key = key; + this.checkNodes(); + } + } + + setTranslateParams(params: any) { + if (!equals(this.currentParams, params)) { + this.currentParams = params; + this.checkNodes(true); + } + } + + ngAfterViewChecked() { + this.checkNodes(); + } + + checkNodes(forceUpdate = false, translations?: any) { + let nodes: NodeList = this.element.nativeElement.childNodes; + // if the element is empty + if (!nodes.length) { + // we add the key as content + this.setContent(this.element.nativeElement, this.key); + nodes = this.element.nativeElement.childNodes; + } + for (let i = 0; i < nodes.length; ++i) { + let node: any = nodes[i]; + if (node.nodeType === 3) { // node type 3 is a text node + let key: string; + if (forceUpdate) { + node.lastKey = null; + } + if (isDefined(node.lookupKey)) { + key = node.lookupKey; + } else if (this.key) { + key = this.key; + } else { + let content = this.getContent(node); + let trimmedContent = content.trim(); + if (trimmedContent.length) { + node.lookupKey = trimmedContent; + // we want to use the content as a key, not the translation value + if (content !== node.currentValue) { + key = trimmedContent; + // the content was changed from the user, we'll use it as a reference if needed + node.originalContent = content || node.originalContent; + } else if (node.originalContent) { // the content seems ok, but the lang has changed + // the current content is the translation, not the key, use the last real content as key + key = node.originalContent.trim(); + } else if (content !== node.currentValue) { + // we want to use the content as a key, not the translation value + key = trimmedContent; + // the content was changed from the user, we'll use it as a reference if needed + node.originalContent = content || node.originalContent; + } + } + } + this.updateValue(key, node, translations); + } + } + } + + updateValue(key: string, node: any, translations: any) { + if (key) { + if (node.lastKey === key && this.lastParams === this.currentParams) { + return; + } + + this.lastParams = this.currentParams; + + let onTranslation = (res: string) => { + if (res !== key) { + node.lastKey = key; + } + if (!node.originalContent) { + node.originalContent = this.getContent(node); + } + node.currentValue = isDefined(res) ? res : (node.originalContent || key); + // we replace in the original content to preserve spaces that we might have trimmed + this.setContent(node, this.key ? node.currentValue : node.originalContent.replace(key, node.currentValue)); + this._ref.markForCheck(); + }; + + if (isDefined(translations)) { + let res = this.getParsedResult(translations, key, this.currentParams); + if (isObservable(res)) { + res.subscribe(onTranslation); + } else { + onTranslation(res); + } + } else { + this.get(key, this.currentParams).subscribe(onTranslation); + } + } + } + + getContent(node: any): string { + return isDefined(node.textContent) ? node.textContent : node.data; + } + + setContent(node: any, content: string): void { + if (isDefined(node.textContent)) { + node.textContent = content; + } else { + node.data = content; + } + } + + ngOnDestroy() { + if (this.onLangChangeSub) { + this.onLangChangeSub.unsubscribe(); + } + + if (this.onDefaultLangChangeSub) { + this.onDefaultLangChangeSub.unsubscribe(); + } + + if (this.onTranslationChangeSub) { + this.onTranslationChangeSub.unsubscribe(); + } + } +} diff --git a/projects/ngx-translate/core/src/lib/translate.directive.ts b/projects/ngx-translate/core/src/lib/translate.directive.ts index 542b6940..520ecbf0 100644 --- a/projects/ngx-translate/core/src/lib/translate.directive.ts +++ b/projects/ngx-translate/core/src/lib/translate.directive.ts @@ -1,164 +1,31 @@ -import {AfterViewChecked, ChangeDetectorRef, Directive, ElementRef, Input, OnDestroy} from '@angular/core'; -import {Subscription, isObservable} from 'rxjs'; -import {DefaultLangChangeEvent, LangChangeEvent, TranslateService, TranslationChangeEvent} from './translate.service'; -import {equals, isDefined} from './util'; +import { AfterViewChecked, ChangeDetectorRef, Directive, ElementRef, Input, OnDestroy } from '@angular/core'; +import { TranslateService } from './translate.service'; +import { BaseTranslateDirective } from './base-translate.directive'; +import { Observable } from 'rxjs'; @Directive({ selector: '[translate],[ngx-translate]' }) -export class TranslateDirective implements AfterViewChecked, OnDestroy { - key: string; - lastParams: any; - currentParams: any; - onLangChangeSub: Subscription; - onDefaultLangChangeSub: Subscription; - onTranslationChangeSub: Subscription; +export class TranslateDirective extends BaseTranslateDirective implements AfterViewChecked, OnDestroy { - @Input() set translate(key: string) { - if (key) { - this.key = key; - this.checkNodes(); - } + @Input() set translate(value: string) { + this.setTranslate(value); } - @Input() set translateParams(params: any) { - if (!equals(this.currentParams, params)) { - this.currentParams = params; - this.checkNodes(true); - } + @Input() set translateParams(value: any) { + this.setTranslateParams(value); } - constructor(private translateService: TranslateService, private element: ElementRef, private _ref: ChangeDetectorRef) { - // subscribe to onTranslationChange event, in case the translations of the current lang change - if (!this.onTranslationChangeSub) { - this.onTranslationChangeSub = this.translateService.onTranslationChange.subscribe((event: TranslationChangeEvent) => { - if (event.lang === this.translateService.currentLang) { - this.checkNodes(true, event.translations); - } - }); - } - - // subscribe to onLangChange event, in case the language changes - if (!this.onLangChangeSub) { - this.onLangChangeSub = this.translateService.onLangChange.subscribe((event: LangChangeEvent) => { - this.checkNodes(true, event.translations); - }); - } - - // subscribe to onDefaultLangChange event, in case the default language changes - if (!this.onDefaultLangChangeSub) { - this.onDefaultLangChangeSub = this.translateService.onDefaultLangChange.subscribe((event: DefaultLangChangeEvent) => { - this.checkNodes(true); - }); - } - } - - ngAfterViewChecked() { - this.checkNodes(); - } - - checkNodes(forceUpdate = false, translations?: any) { - let nodes: NodeList = this.element.nativeElement.childNodes; - // if the element is empty - if (!nodes.length) { - // we add the key as content - this.setContent(this.element.nativeElement, this.key); - nodes = this.element.nativeElement.childNodes; - } - for (let i = 0; i < nodes.length; ++i) { - let node: any = nodes[i]; - if (node.nodeType === 3) { // node type 3 is a text node - let key: string; - if (forceUpdate) { - node.lastKey = null; - } - if(isDefined(node.lookupKey)) { - key = node.lookupKey; - } else if (this.key) { - key = this.key; - } else { - let content = this.getContent(node); - let trimmedContent = content.trim(); - if (trimmedContent.length) { - node.lookupKey = trimmedContent; - // we want to use the content as a key, not the translation value - if (content !== node.currentValue) { - key = trimmedContent; - // the content was changed from the user, we'll use it as a reference if needed - node.originalContent = content || node.originalContent; - } else if (node.originalContent) { // the content seems ok, but the lang has changed - // the current content is the translation, not the key, use the last real content as key - key = node.originalContent.trim(); - } else if (content !== node.currentValue) { - // we want to use the content as a key, not the translation value - key = trimmedContent; - // the content was changed from the user, we'll use it as a reference if needed - node.originalContent = content || node.originalContent; - } - } - } - this.updateValue(key, node, translations); - } - } - } - - updateValue(key: string, node: any, translations: any) { - if (key) { - if (node.lastKey === key && this.lastParams === this.currentParams) { - return; - } - - this.lastParams = this.currentParams; - - let onTranslation = (res: string) => { - if (res !== key) { - node.lastKey = key; - } - if (!node.originalContent) { - node.originalContent = this.getContent(node); - } - node.currentValue = isDefined(res) ? res : (node.originalContent || key); - // we replace in the original content to preserve spaces that we might have trimmed - this.setContent(node, this.key ? node.currentValue : node.originalContent.replace(key, node.currentValue)); - this._ref.markForCheck(); - }; - - if (isDefined(translations)) { - let res = this.translateService.getParsedResult(translations, key, this.currentParams); - if (isObservable(res)) { - res.subscribe(onTranslation); - } else { - onTranslation(res); - } - } else { - this.translateService.get(key, this.currentParams).subscribe(onTranslation); - } - } + constructor(translateService: TranslateService, element: ElementRef, _ref: ChangeDetectorRef) { + super(translateService, element, _ref); } - getContent(node: any): string { - return isDefined(node.textContent) ? node.textContent : node.data; + getParsedResult(translations: any, key: string | string[], interpolateParams?: Object): any { + return this.translateService.getParsedResult(translations, key, interpolateParams); } - setContent(node: any, content: string): void { - if (isDefined(node.textContent)) { - node.textContent = content; - } else { - node.data = content; - } + get(key: string | string[], interpolateParams?: Object): Observable { + return this.translateService.get(key, interpolateParams); } - ngOnDestroy() { - if (this.onLangChangeSub) { - this.onLangChangeSub.unsubscribe(); - } - - if (this.onDefaultLangChangeSub) { - this.onDefaultLangChangeSub.unsubscribe(); - } - - if (this.onTranslationChangeSub) { - this.onTranslationChangeSub.unsubscribe(); - } - } } From 4084b01d3bfb3c375a115195ef0461877d4e662c Mon Sep 17 00:00:00 2001 From: Andreas Resch Date: Thu, 28 Jan 2021 16:54:05 +0100 Subject: [PATCH 3/9] add namespace translate directive --- .../src/lib/namespace-translate.directive.ts | 30 ++ .../namespace.translate.directive.spec.ts | 273 ++++++++++++++++++ 2 files changed, 303 insertions(+) create mode 100644 projects/ngx-translate/core/src/lib/namespace-translate.directive.ts create mode 100644 projects/ngx-translate/core/tests/namespace.translate.directive.spec.ts diff --git a/projects/ngx-translate/core/src/lib/namespace-translate.directive.ts b/projects/ngx-translate/core/src/lib/namespace-translate.directive.ts new file mode 100644 index 00000000..7a2c97f4 --- /dev/null +++ b/projects/ngx-translate/core/src/lib/namespace-translate.directive.ts @@ -0,0 +1,30 @@ +import { AfterViewChecked, ChangeDetectorRef, Directive, ElementRef, Input, OnDestroy } from '@angular/core'; +import { TranslateService } from './translate.service'; +import { BaseTranslateDirective } from './base-translate.directive'; +import { NamespaceTranslateService } from './namespace-translate.service'; + +@Directive({ + selector: '[namespace-translate],[ngx-namespace-translate]' +}) +export class NamespaceTranslateDirective extends BaseTranslateDirective implements AfterViewChecked, OnDestroy { + + @Input("namespace-translate") set namespaceTranslate(value: string) { + this.setTranslate(value); + } + + @Input() set translateParams(value: any) { + this.setTranslateParams(value); + } + + constructor(private namespaceTranslateService: NamespaceTranslateService, translateService: TranslateService, element: ElementRef, _ref: ChangeDetectorRef) { + super(translateService, element, _ref); + } + + getParsedResult(translations: any, key: string | string[], interpolateParams?: Object) { + return this.namespaceTranslateService.getParsedResult(translations, key, interpolateParams); + } + get(key: string | string[], interpolateParams?: Object) { + return this.namespaceTranslateService.get(key, interpolateParams); + } + +} diff --git a/projects/ngx-translate/core/tests/namespace.translate.directive.spec.ts b/projects/ngx-translate/core/tests/namespace.translate.directive.spec.ts new file mode 100644 index 00000000..97c1c1c7 --- /dev/null +++ b/projects/ngx-translate/core/tests/namespace.translate.directive.spec.ts @@ -0,0 +1,273 @@ +import { ChangeDetectionStrategy, Component, ElementRef, Injectable, ViewChild, ViewContainerRef } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { namespaceTranslateServiceProvider, TranslateModule, TranslateService } from '../src/public_api'; + +@Injectable() +@Component({ + selector: 'hmx-app', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [namespaceTranslateServiceProvider("NAMESPACE")], + template: ` +
TEST
+
TEST.VALUE
+
Some init content
+
+
TEST1 Hey TEST2
+
Some init content
+
TEST
+
TEST
+
TEST
+
+ TEST +
+ ` +}) +class App { + viewContainerRef: ViewContainerRef; + @ViewChild('noKey', { static: true }) noKey: ElementRef; + @ViewChild('contentAsKey', { static: true }) contentAsKey: ElementRef; + @ViewChild('withKey', { static: true }) withKey: ElementRef; + @ViewChild('withOtherElements', { static: true }) withOtherElements: ElementRef; + @ViewChild('withParams', { static: true }) withParams: ElementRef; + @ViewChild('withParamsNoKey', { static: true }) withParamsNoKey: ElementRef; + @ViewChild('noContent', { static: true }) noContent: ElementRef; + @ViewChild('leadingSpaceNoKeyNoParams') leadingSpaceNoKeyNoParams: ElementRef; + @ViewChild('trailingSpaceNoKeyNoParams') trailingSpaceNoKeyNoParams: ElementRef; + @ViewChild('withSpaceAndLineBreakNoKeyNoParams') withSpaceAndLineBreakNoKeyNoParams: ElementRef; + value = { value: 'ok' }; + + constructor(viewContainerRef: ViewContainerRef) { + this.viewContainerRef = viewContainerRef; + } +} + +describe('NamespaceTranslateDirective', () => { + let translate: TranslateService; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot() + ], + declarations: [App] + }); + translate = TestBed.inject(TranslateService); + + fixture = (TestBed).createComponent(App); + fixture.detectChanges(); + }); + + afterEach(() => { + translate = undefined; + fixture = undefined; + }); + + it('should translate a string using the container value', () => { + expect(fixture.componentInstance.noKey.nativeElement.innerHTML).toEqual('NAMESPACE.TEST'); + + translate.setTranslation('en', { "NAMESPACE": { "TEST": "This is a test" } }); + translate.use('en'); + + expect(fixture.componentInstance.noKey.nativeElement.innerHTML).toEqual('This is a test'); + }); + + it('should translate a string using the container value as a key', () => { + expect(fixture.componentInstance.contentAsKey.nativeElement.innerHTML).toEqual('NAMESPACE.TEST.VALUE'); + + translate.setTranslation('en', { "NAMESPACE": { "TEST": { "VALUE": "This is a test" } } }); + translate.use('en'); + + expect(fixture.componentInstance.contentAsKey.nativeElement.innerHTML).toEqual('This is a test'); + }); + + it('should translate a string using the key value', () => { + // replace the content with the key + expect(fixture.componentInstance.withKey.nativeElement.innerHTML).toEqual('NAMESPACE.TEST'); + + translate.setTranslation('en', { "NAMESPACE": { "TEST": "This is a test" } }); + translate.use('en'); + + expect(fixture.componentInstance.withKey.nativeElement.innerHTML).toEqual('This is a test'); + }); + + it('should translate first child strings with elements in the middle', () => { + // replace the content with the key + expect(fixture.componentInstance.withOtherElements.nativeElement.innerHTML).toEqual('NAMESPACE.TEST1 Hey NAMESPACE.TEST2'); + + translate.setTranslation('en', { "NAMESPACE": { "TEST1": "Awesome", "TEST2": "it works" } }); + translate.use('en'); + + expect(fixture.componentInstance.withOtherElements.nativeElement.innerHTML).toEqual('Awesome Hey it works'); + }); + + it('should translate first child strings without recursion', () => { + // replace the content with the key + expect(fixture.componentInstance.withOtherElements.nativeElement.innerHTML).toEqual('NAMESPACE.TEST1 Hey NAMESPACE.TEST2'); + + translate.setTranslation('en', { "NAMESPACE": { "TEST1": "TEST2", "TEST2": "it works" } }); + translate.use('en'); + + expect(fixture.componentInstance.withOtherElements.nativeElement.innerHTML).toEqual('TEST2 Hey it works'); + }); + + it('should translate a string with params and a key', () => { + // replace the content with the key + expect(fixture.componentInstance.withParams.nativeElement.innerHTML).toEqual('NAMESPACE.TEST'); + + translate.setTranslation('en', { "NAMESPACE": { "TEST": "It is {{value}}" } }); + translate.use('en'); + + expect(fixture.componentInstance.withParams.nativeElement.innerHTML).toEqual('It is ok'); + }); + + it('should translate a string with params and no key', () => { + // replace the content with the key + expect(fixture.componentInstance.withParamsNoKey.nativeElement.innerHTML).toEqual('NAMESPACE.TEST'); + + translate.setTranslation('en', { "NAMESPACE": { "TEST": "It is {{value}}" } }); + translate.use('en'); + + expect(fixture.componentInstance.withParamsNoKey.nativeElement.innerHTML).toEqual('It is ok'); + }); + + it('should update the translation when params change', () => { + // replace the content with the key + expect(fixture.componentInstance.withParams.nativeElement.innerHTML).toEqual('NAMESPACE.TEST'); + expect(fixture.componentInstance.withParamsNoKey.nativeElement.innerHTML).toEqual('NAMESPACE.TEST'); + + translate.setTranslation('en', { "NAMESPACE": { "TEST": "It is {{value}}" } }); + translate.use('en'); + + expect(fixture.componentInstance.withParams.nativeElement.innerHTML).toEqual('It is ok'); + expect(fixture.componentInstance.withParamsNoKey.nativeElement.innerHTML).toEqual('It is ok'); + fixture.componentInstance.value = { value: 'changed' }; + fixture.detectChanges(); + + expect(fixture.componentInstance.withParams.nativeElement.innerHTML).toEqual('It is changed'); + expect(fixture.componentInstance.withParamsNoKey.nativeElement.innerHTML).toEqual('It is changed'); + }); + + it('should update the DOM when the lang changes and the translation key starts with space', () => { + expect(fixture.componentInstance.leadingSpaceNoKeyNoParams.nativeElement.innerHTML).toEqual(' NAMESPACE.TEST'); + + const en = "This is a test - with leading spaces in translation key"; + const fr = "C'est un test - avec un espace de tête dans la clé de traduction"; + const leadingSpaceFromKey = ' '; + translate.setTranslation('en', { "NAMESPACE": { "TEST": en } }); + translate.setTranslation('fr', { "NAMESPACE": { "TEST": fr } }); + + translate.use('en'); + expect(fixture.componentInstance.leadingSpaceNoKeyNoParams.nativeElement.innerHTML).toEqual(leadingSpaceFromKey + en); + + translate.use('fr'); + expect(fixture.componentInstance.leadingSpaceNoKeyNoParams.nativeElement.innerHTML).toEqual(leadingSpaceFromKey + fr); + }); + + it('should update the DOM when the lang changes and the translation key has line breaks and spaces', () => { + expect(fixture.componentInstance.withSpaceAndLineBreakNoKeyNoParams.nativeElement.innerHTML).toEqual(' NAMESPACE.TEST '); + + const en = "This is a test - with trailing spaces in translation key"; + const fr = "C'est un test - avec un espace de fuite dans la clé de traduction"; + const whiteSpaceFromKey = ' '; + translate.setTranslation('en', { "NAMESPACE": { "TEST": en } }); + translate.setTranslation('fr', { "NAMESPACE": { "TEST": fr } }); + + translate.use('en'); + expect(fixture.componentInstance.withSpaceAndLineBreakNoKeyNoParams.nativeElement.innerHTML).toEqual(whiteSpaceFromKey + en + whiteSpaceFromKey); + + translate.use('fr'); + expect(fixture.componentInstance.withSpaceAndLineBreakNoKeyNoParams.nativeElement.innerHTML).toEqual(whiteSpaceFromKey + fr + whiteSpaceFromKey); + }); + + it('should update the DOM when the lang changes and the translation key ends with space', () => { + expect(fixture.componentInstance.trailingSpaceNoKeyNoParams.nativeElement.innerHTML).toEqual('NAMESPACE.TEST '); + + const en = "This is a test - with spaces and line breaks in translation key"; + const fr = "C'est un test - avec des espaces et sauts de lignes dans la clé de traduction"; + const trailingSpaceFromKey = ' '; + translate.setTranslation('en', { "NAMESPACE": { "TEST": en } }); + translate.setTranslation('fr', { "NAMESPACE": { "TEST": fr } }); + + translate.use('en'); + expect(fixture.componentInstance.trailingSpaceNoKeyNoParams.nativeElement.innerHTML).toEqual(en + trailingSpaceFromKey); + + translate.use('fr'); + expect(fixture.componentInstance.trailingSpaceNoKeyNoParams.nativeElement.innerHTML).toEqual(fr + trailingSpaceFromKey); + }); + + it('should update the DOM when the lang changes', () => { + expect(fixture.componentInstance.noKey.nativeElement.innerHTML).toEqual('NAMESPACE.TEST'); + expect(fixture.componentInstance.withParams.nativeElement.innerHTML).toEqual('NAMESPACE.TEST'); + expect(fixture.componentInstance.noContent.nativeElement.innerHTML).toEqual('NAMESPACE.TEST'); + + translate.setTranslation('en', { "NAMESPACE": { "TEST": "This is a test" } }); + translate.setTranslation('fr', { "NAMESPACE": { "TEST": "C'est un test" } }); + + translate.use('en'); + expect(fixture.componentInstance.noKey.nativeElement.innerHTML).toEqual('This is a test'); + expect(fixture.componentInstance.withParams.nativeElement.innerHTML).toEqual('This is a test'); + expect(fixture.componentInstance.noContent.nativeElement.innerHTML).toEqual('This is a test'); + + translate.use('fr'); + expect(fixture.componentInstance.noKey.nativeElement.innerHTML).toEqual("C'est un test"); + expect(fixture.componentInstance.withParams.nativeElement.innerHTML).toEqual("C'est un test"); + expect(fixture.componentInstance.noContent.nativeElement.innerHTML).toEqual("C'est un test"); + }); + + it('should update the DOM when the lang changes and the translation ends with space', () => { + expect(fixture.componentInstance.noKey.nativeElement.innerHTML).toEqual('NAMESPACE.TEST'); + expect(fixture.componentInstance.withParams.nativeElement.innerHTML).toEqual('NAMESPACE.TEST'); + expect(fixture.componentInstance.noContent.nativeElement.innerHTML).toEqual('NAMESPACE.TEST'); + + const en = " This is a test - with spaces "; + const fr = " C'est un test - avec espaces "; + + translate.setTranslation('en', { "NAMESPACE": { "TEST": en } }); + translate.setTranslation('fr', { "NAMESPACE": { "TEST": fr } }); + + translate.use('en'); + expect(fixture.componentInstance.noKey.nativeElement.innerHTML).toEqual(`${en}`); + expect(fixture.componentInstance.withParams.nativeElement.innerHTML).toEqual(en); + expect(fixture.componentInstance.noContent.nativeElement.innerHTML).toEqual(en); + + translate.use('fr'); + expect(fixture.componentInstance.noKey.nativeElement.innerHTML).toEqual(`${fr}`); + expect(fixture.componentInstance.withParams.nativeElement.innerHTML).toEqual(fr); + expect(fixture.componentInstance.noContent.nativeElement.innerHTML).toEqual(fr); + }); + + it('should update the DOM when the default lang changes', () => { + expect(fixture.componentInstance.noKey.nativeElement.innerHTML).toEqual('NAMESPACE.TEST'); + + translate.setTranslation('en', { "NAMESPACE": { "TEST": "This is a test" } }); + translate.setTranslation('fr', { "NAMESPACE": { "TEST": "C'est un test" } }); + translate.setDefaultLang('en'); + expect(fixture.componentInstance.noKey.nativeElement.innerHTML).toEqual('This is a test'); + + translate.setDefaultLang('fr'); + expect(fixture.componentInstance.noKey.nativeElement.innerHTML).toEqual("C'est un test"); + }); + + it('should unsubscribe from lang change subscription on destroy', () => { + expect(fixture.componentInstance.withParamsNoKey.nativeElement.innerHTML).toEqual('NAMESPACE.TEST'); + + fixture.destroy(); + + translate.setTranslation('en', { "NAMESPACE": { "TEST": "This is a test" } }); + translate.use('en'); + + expect(fixture.componentInstance.withParamsNoKey.nativeElement.innerHTML).toEqual('NAMESPACE.TEST'); + }); + + it('should unsubscribe from default lang change subscription on destroy', () => { + expect(fixture.componentInstance.withParamsNoKey.nativeElement.innerHTML).toEqual('NAMESPACE.TEST'); + + fixture.destroy(); + + translate.setTranslation('en', { "NAMESPACE": { "TEST": "This is a test" } }); + translate.setDefaultLang('en'); + + expect(fixture.componentInstance.withParamsNoKey.nativeElement.innerHTML).toEqual('NAMESPACE.TEST'); + }); +}); From c9c984219684c75646f9ac5f1552defa1c66b87d Mon Sep 17 00:00:00 2001 From: Andreas Resch Date: Thu, 28 Jan 2021 16:57:00 +0100 Subject: [PATCH 4/9] move logic of translate pipe to base-translate pipe preparation to add namespace translate pipe --- .../core/src/lib/base-translate.pipe.ts | 134 ++++++++++++++++++ .../core/src/lib/translate.pipe.ts | 131 ++--------------- 2 files changed, 145 insertions(+), 120 deletions(-) create mode 100644 projects/ngx-translate/core/src/lib/base-translate.pipe.ts diff --git a/projects/ngx-translate/core/src/lib/base-translate.pipe.ts b/projects/ngx-translate/core/src/lib/base-translate.pipe.ts new file mode 100644 index 00000000..ffdcbdb7 --- /dev/null +++ b/projects/ngx-translate/core/src/lib/base-translate.pipe.ts @@ -0,0 +1,134 @@ +import { ChangeDetectorRef, OnDestroy, PipeTransform } from '@angular/core'; +import { isObservable } from 'rxjs'; +import { LangChangeEvent, TranslateService, TranslationChangeEvent } from './translate.service'; +import { equals, isDefined } from './util'; +import { Subscription } from 'rxjs'; + +export abstract class BaseTranslatePipe implements PipeTransform, OnDestroy { + + protected abstract getParsedResult(translations: any, key: string | Array, interpolateParams?: Object); + protected abstract get(key: string | Array, interpolateParams?: Object); + protected abstract pipeName: string; + + value: string = ''; + lastKey: string; + lastParams: any[]; + onTranslationChange: Subscription; + onLangChange: Subscription; + onDefaultLangChange: Subscription; + + constructor(protected translate: TranslateService, private _ref: ChangeDetectorRef) { + } + + updateValue(key: string, interpolateParams?: Object, translations?: any): void { + let onTranslation = (res: string) => { + this.value = res !== undefined ? res : key; + this.lastKey = key; + this._ref.markForCheck(); + }; + if (translations) { + let res = this.getParsedResult(translations, key, interpolateParams); + if (isObservable(res.subscribe)) { + res.subscribe(onTranslation); + } else { + onTranslation(res); + } + } + this.get(key, interpolateParams).subscribe(onTranslation); + } + + transform(query: string, ...args: any[]): any { + if (!query || !query.length) { + return query; + } + + // if we ask another time for the same key, return the last value + if (equals(query, this.lastKey) && equals(args, this.lastParams)) { + return this.value; + } + + let interpolateParams: Object; + if (isDefined(args[0]) && args.length) { + if (typeof args[0] === 'string' && args[0].length) { + // we accept objects written in the template such as {n:1}, {'n':1}, {n:'v'} + // which is why we might need to change it to real JSON objects such as {"n":1} or {"n":"v"} + let validArgs: string = args[0] + .replace(/(\')?([a-zA-Z0-9_]+)(\')?(\s)?:/g, '"$2":') + .replace(/:(\s)?(\')(.*?)(\')/g, ':"$3"'); + try { + interpolateParams = JSON.parse(validArgs); + } catch (e) { + throw new SyntaxError(`Wrong parameter in ${this.pipeName}. Expected a valid Object, received: ${args[0]}`); + } + } else if (typeof args[0] === 'object' && !Array.isArray(args[0])) { + interpolateParams = args[0]; + } + } + + // store the query, in case it changes + this.lastKey = query; + + // store the params, in case they change + this.lastParams = args; + + // set the value + this.updateValue(query, interpolateParams); + + // if there is a subscription to onLangChange, clean it + this._dispose(); + + // subscribe to onTranslationChange event, in case the translations change + if (!this.onTranslationChange) { + this.onTranslationChange = this.translate.onTranslationChange.subscribe((event: TranslationChangeEvent) => { + if (this.lastKey && event.lang === this.translate.currentLang) { + this.lastKey = null; + this.updateValue(query, interpolateParams, event.translations); + } + }); + } + + // subscribe to onLangChange event, in case the language changes + if (!this.onLangChange) { + this.onLangChange = this.translate.onLangChange.subscribe((event: LangChangeEvent) => { + if (this.lastKey) { + this.lastKey = null; // we want to make sure it doesn't return the same value until it's been updated + this.updateValue(query, interpolateParams, event.translations); + } + }); + } + + // subscribe to onDefaultLangChange event, in case the default language changes + if (!this.onDefaultLangChange) { + this.onDefaultLangChange = this.translate.onDefaultLangChange.subscribe(() => { + if (this.lastKey) { + this.lastKey = null; // we want to make sure it doesn't return the same value until it's been updated + this.updateValue(query, interpolateParams); + } + }); + } + + return this.value; + } + + /** + * Clean any existing subscription to change events + */ + private _dispose(): void { + if (typeof this.onTranslationChange !== 'undefined') { + this.onTranslationChange.unsubscribe(); + this.onTranslationChange = undefined; + } + if (typeof this.onLangChange !== 'undefined') { + this.onLangChange.unsubscribe(); + this.onLangChange = undefined; + } + if (typeof this.onDefaultLangChange !== 'undefined') { + this.onDefaultLangChange.unsubscribe(); + this.onDefaultLangChange = undefined; + } + } + + ngOnDestroy(): void { + this._dispose(); + } +} diff --git a/projects/ngx-translate/core/src/lib/translate.pipe.ts b/projects/ngx-translate/core/src/lib/translate.pipe.ts index b1f4ea3c..df5f4965 100644 --- a/projects/ngx-translate/core/src/lib/translate.pipe.ts +++ b/projects/ngx-translate/core/src/lib/translate.pipe.ts @@ -1,134 +1,25 @@ -import {ChangeDetectorRef, EventEmitter, Injectable, OnDestroy, Pipe, PipeTransform} from '@angular/core'; -import {isObservable} from 'rxjs'; -import {DefaultLangChangeEvent, LangChangeEvent, TranslateService, TranslationChangeEvent} from './translate.service'; -import {equals, isDefined} from './util'; -import { Subscription } from 'rxjs'; +import { ChangeDetectorRef, Injectable, OnDestroy, Pipe, PipeTransform } from '@angular/core'; +import { TranslateService } from './translate.service'; +import { BaseTranslatePipe } from './base-translate.pipe'; @Injectable() @Pipe({ name: 'translate', pure: false // required to update the value when the promise is resolved }) -export class TranslatePipe implements PipeTransform, OnDestroy { - value: string = ''; - lastKey: string; - lastParams: any[]; - onTranslationChange: Subscription; - onLangChange: Subscription; - onDefaultLangChange: Subscription; +export class TranslatePipe extends BaseTranslatePipe implements PipeTransform, OnDestroy { - constructor(private translate: TranslateService, private _ref: ChangeDetectorRef) { + constructor(translate: TranslateService, _ref: ChangeDetectorRef) { + super(translate, _ref); } - updateValue(key: string, interpolateParams?: Object, translations?: any): void { - let onTranslation = (res: string) => { - this.value = res !== undefined ? res : key; - this.lastKey = key; - this._ref.markForCheck(); - }; - if (translations) { - let res = this.translate.getParsedResult(translations, key, interpolateParams); - if (isObservable(res.subscribe)) { - res.subscribe(onTranslation); - } else { - onTranslation(res); - } - } - this.translate.get(key, interpolateParams).subscribe(onTranslation); + protected getParsedResult(translations: any, key: string | string[], interpolateParams?: Object) { + return this.translate.getParsedResult(translations, key, interpolateParams); } - - transform(query: string, ...args: any[]): any { - if (!query || !query.length) { - return query; - } - - // if we ask another time for the same key, return the last value - if (equals(query, this.lastKey) && equals(args, this.lastParams)) { - return this.value; - } - - let interpolateParams: Object; - if (isDefined(args[0]) && args.length) { - if (typeof args[0] === 'string' && args[0].length) { - // we accept objects written in the template such as {n:1}, {'n':1}, {n:'v'} - // which is why we might need to change it to real JSON objects such as {"n":1} or {"n":"v"} - let validArgs: string = args[0] - .replace(/(\')?([a-zA-Z0-9_]+)(\')?(\s)?:/g, '"$2":') - .replace(/:(\s)?(\')(.*?)(\')/g, ':"$3"'); - try { - interpolateParams = JSON.parse(validArgs); - } catch (e) { - throw new SyntaxError(`Wrong parameter in TranslatePipe. Expected a valid Object, received: ${args[0]}`); - } - } else if (typeof args[0] === 'object' && !Array.isArray(args[0])) { - interpolateParams = args[0]; - } - } - - // store the query, in case it changes - this.lastKey = query; - - // store the params, in case they change - this.lastParams = args; - - // set the value - this.updateValue(query, interpolateParams); - - // if there is a subscription to onLangChange, clean it - this._dispose(); - - // subscribe to onTranslationChange event, in case the translations change - if (!this.onTranslationChange) { - this.onTranslationChange = this.translate.onTranslationChange.subscribe((event: TranslationChangeEvent) => { - if (this.lastKey && event.lang === this.translate.currentLang) { - this.lastKey = null; - this.updateValue(query, interpolateParams, event.translations); - } - }); - } - - // subscribe to onLangChange event, in case the language changes - if (!this.onLangChange) { - this.onLangChange = this.translate.onLangChange.subscribe((event: LangChangeEvent) => { - if (this.lastKey) { - this.lastKey = null; // we want to make sure it doesn't return the same value until it's been updated - this.updateValue(query, interpolateParams, event.translations); - } - }); - } - - // subscribe to onDefaultLangChange event, in case the default language changes - if (!this.onDefaultLangChange) { - this.onDefaultLangChange = this.translate.onDefaultLangChange.subscribe(() => { - if (this.lastKey) { - this.lastKey = null; // we want to make sure it doesn't return the same value until it's been updated - this.updateValue(query, interpolateParams); - } - }); - } - - return this.value; + protected get(key: string | string[], interpolateParams?: Object) { + return this.translate.get(key, interpolateParams); } - /** - * Clean any existing subscription to change events - */ - private _dispose(): void { - if (typeof this.onTranslationChange !== 'undefined') { - this.onTranslationChange.unsubscribe(); - this.onTranslationChange = undefined; - } - if (typeof this.onLangChange !== 'undefined') { - this.onLangChange.unsubscribe(); - this.onLangChange = undefined; - } - if (typeof this.onDefaultLangChange !== 'undefined') { - this.onDefaultLangChange.unsubscribe(); - this.onDefaultLangChange = undefined; - } - } + protected pipeName = "TranslatePipe"; - ngOnDestroy(): void { - this._dispose(); - } } From d1560af62ed2d763d9307d81287e948c79f23055 Mon Sep 17 00:00:00 2001 From: Andreas Resch Date: Thu, 28 Jan 2021 16:57:17 +0100 Subject: [PATCH 5/9] add namespace translate pipe --- .../core/src/lib/namespace-translate.pipe.ts | 27 ++ .../tests/namespace.translate.pipe.spec.ts | 273 ++++++++++++++++++ 2 files changed, 300 insertions(+) create mode 100644 projects/ngx-translate/core/src/lib/namespace-translate.pipe.ts create mode 100644 projects/ngx-translate/core/tests/namespace.translate.pipe.spec.ts diff --git a/projects/ngx-translate/core/src/lib/namespace-translate.pipe.ts b/projects/ngx-translate/core/src/lib/namespace-translate.pipe.ts new file mode 100644 index 00000000..29546546 --- /dev/null +++ b/projects/ngx-translate/core/src/lib/namespace-translate.pipe.ts @@ -0,0 +1,27 @@ +import { ChangeDetectorRef, Injectable, OnDestroy, Pipe, PipeTransform } from '@angular/core'; +import { TranslateService } from './translate.service'; +import { BaseTranslatePipe } from './base-translate.pipe'; +import { NamespaceTranslateService } from './namespace-translate.service'; + +@Injectable() +@Pipe({ + name: 'namespaceTranslate', + pure: false // required to update the value when the promise is resolved +}) +export class NamespaceTranslatePipe extends BaseTranslatePipe implements PipeTransform, OnDestroy { + + + constructor(private namespaceTranslate: NamespaceTranslateService, translate: TranslateService, _ref: ChangeDetectorRef) { + super(translate, _ref); + } + + protected getParsedResult(translations: any, key: string | string[], interpolateParams?: Object) { + return this.namespaceTranslate.getParsedResult(translations, key, interpolateParams); + } + protected get(key: string | string[], interpolateParams?: Object) { + return this.namespaceTranslate.get(key, interpolateParams); + } + + protected pipeName = "NamespaceTranslatePipe"; + +} diff --git a/projects/ngx-translate/core/tests/namespace.translate.pipe.spec.ts b/projects/ngx-translate/core/tests/namespace.translate.pipe.spec.ts new file mode 100644 index 00000000..baf52589 --- /dev/null +++ b/projects/ngx-translate/core/tests/namespace.translate.pipe.spec.ts @@ -0,0 +1,273 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Injectable, ViewContainerRef } from "@angular/core"; +import { TestBed } from "@angular/core/testing"; +import { Observable, of } from "rxjs"; +import { DefaultLangChangeEvent, LangChangeEvent, TranslateLoader, TranslateModule, TranslateService } from "../src/public_api"; +import { NamespaceTranslatePipe } from '../src/lib/namespace-translate.pipe'; +import { NamespaceTranslateService, namespaceTranslateServiceProvider } from '../src/lib/namespace-translate.service'; + +class FakeChangeDetectorRef extends ChangeDetectorRef { + markForCheck(): void { + } + + detach(): void { + } + + detectChanges(): void { + } + + checkNoChanges(): void { + } + + reattach(): void { + } +} + +@Injectable() +@Component({ + selector: 'hmx-app', + changeDetection: ChangeDetectionStrategy.OnPush, + template: `{{'TEST' | namespaceTranslate}}` +}) +class App { + viewContainerRef: ViewContainerRef; + + constructor(viewContainerRef: ViewContainerRef) { + this.viewContainerRef = viewContainerRef; + } +} + +let translations: any = { "NAMESPACE": { "TEST": "This is a test" } }; + +class FakeLoader implements TranslateLoader { + getTranslation(lang: string): Observable { + return of(translations); + } +} + +describe('NamespaceTranslatePipe', () => { + let translate: TranslateService; + let namespaceTranslate: NamespaceTranslateService; + let namespaceTranslatePipe: NamespaceTranslatePipe; + let ref: any; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot({ + loader: { provide: TranslateLoader, useClass: FakeLoader } + }) + ], + providers: [namespaceTranslateServiceProvider("NAMESPACE")], + declarations: [App] + }); + translate = TestBed.inject(TranslateService); + namespaceTranslate = TestBed.inject(NamespaceTranslateService); + ref = new FakeChangeDetectorRef(); + namespaceTranslatePipe = new NamespaceTranslatePipe(namespaceTranslate, translate, ref); + }); + + afterEach(() => { + translate = undefined; + translations = { "NAMESPACE": { "TEST": "This is a test" } }; + namespaceTranslatePipe = undefined; + ref = undefined; + }); + + it('is defined', () => { + expect(namespaceTranslatePipe).toBeDefined(); + expect(namespaceTranslatePipe).toBeDefined(); + expect(namespaceTranslatePipe instanceof NamespaceTranslatePipe).toBeTruthy(); + }); + + it('should translate a string', () => { + translate.setTranslation('en', { "NAMESPACE": { "TEST": "This is a test" } }); + translate.use('en'); + + expect(namespaceTranslatePipe.transform('TEST')).toEqual("This is a test"); + }); + + it('should call markForChanges when it translates a string', () => { + translate.setTranslation('en', { "NAMESPACE": { "TEST": "This is a test" } }); + translate.use('en'); + spyOn(ref, 'markForCheck').and.callThrough(); + + namespaceTranslatePipe.transform('TEST'); + expect(ref.markForCheck).toHaveBeenCalled(); + }); + + it('should translate a string with object parameters', () => { + translate.setTranslation('en', { "NAMESPACE": { "TEST": "This is a test {{param}}" } }); + translate.use('en'); + + expect(namespaceTranslatePipe.transform('TEST', { param: "with param" })).toEqual("This is a test with param"); + }); + + it('should translate a string with object as string parameters', () => { + translate.setTranslation('en', { "NAMESPACE": { "TEST": "This is a test {{param}}" } }); + translate.use('en'); + + expect(namespaceTranslatePipe.transform('TEST', '{param: "with param"}')).toEqual("This is a test with param"); + expect(namespaceTranslatePipe.transform('TEST', '{"param": "with param"}')).toEqual("This is a test with param"); + expect(namespaceTranslatePipe.transform('TEST', "{param: 'with param'}")).toEqual("This is a test with param"); + expect(namespaceTranslatePipe.transform('TEST', "{'param' : 'with param'}")).toEqual("This is a test with param"); + }); + + it('should translate a string with object as multiple string parameters', () => { + translate.setTranslation('en', { "NAMESPACE": { "TEST": "This is a test {{param1}} {{param2}}" } }); + translate.use('en'); + + expect(namespaceTranslatePipe.transform('TEST', '{param1: "with param-1", param2: "and param-2"}')) + .toEqual("This is a test with param-1 and param-2"); + expect(namespaceTranslatePipe.transform('TEST', '{"param1": "with param-1", "param2": "and param-2"}')) + .toEqual("This is a test with param-1 and param-2"); + expect(namespaceTranslatePipe.transform('TEST', "{param1: 'with param-1', param2: 'and param-2'}")) + .toEqual("This is a test with param-1 and param-2"); + expect(namespaceTranslatePipe.transform('TEST', "{'param1' : 'with param-1', 'param2': 'and param-2'}")) + .toEqual("This is a test with param-1 and param-2"); + }); + + it('should translate a string with object as nested string parameters', () => { + translate.setTranslation('en', { "NAMESPACE": { "TEST": "This is a test {{param.one}} {{param.two}}" } }); + translate.use('en'); + + expect(namespaceTranslatePipe.transform('TEST', '{param: {one: "with param-1", two: "and param-2"}}')) + .toEqual("This is a test with param-1 and param-2"); + expect(namespaceTranslatePipe.transform('TEST', '{"param": {"one": "with param-1", "two": "and param-2"}}')) + .toEqual("This is a test with param-1 and param-2"); + expect(namespaceTranslatePipe.transform('TEST', "{param: {one: 'with param-1', two: 'and param-2'}}")) + .toEqual("This is a test with param-1 and param-2"); + expect(namespaceTranslatePipe.transform('TEST', "{'param' : {'one': 'with param-1', 'two': 'and param-2'}}")) + .toEqual("This is a test with param-1 and param-2"); + }); + + it('should update the value when the parameters change', () => { + translate.setTranslation('en', { "NAMESPACE": { "TEST": "This is a test {{param}}" } }); + translate.use('en'); + + spyOn(namespaceTranslatePipe, 'updateValue').and.callThrough(); + spyOn(ref, 'markForCheck').and.callThrough(); + + expect(namespaceTranslatePipe.transform('TEST', { param: "with param" })).toEqual("This is a test with param"); + // same value, shouldn't call 'updateValue' again + expect(namespaceTranslatePipe.transform('TEST', { param: "with param" })).toEqual("This is a test with param"); + // different param, should call 'updateValue' + expect(namespaceTranslatePipe.transform('TEST', { param: "with param2" })).toEqual("This is a test with param2"); + expect(namespaceTranslatePipe.updateValue).toHaveBeenCalledTimes(2); + expect(ref.markForCheck).toHaveBeenCalledTimes(2); + }); + + it("should throw if you don't give an object parameter", () => { + translate.setTranslation('en', { "NAMESPACE": { "TEST": "This is a test {{param}}" } }); + translate.use('en'); + let param = 'param: "with param"'; + + expect(() => { + namespaceTranslatePipe.transform('TEST', param); + }).toThrowError(`Wrong parameter in NamespaceTranslatePipe. Expected a valid Object, received: ${param}`); + }); + + it("should return given falsey or non length query", () => { + translate.setTranslation('en', { "NAMESPACE": { "TEST": "This is a test" } }); + translate.use('en'); + + expect(namespaceTranslatePipe.transform(null)).toBeNull(); + expect(namespaceTranslatePipe.transform(undefined)).toBeUndefined(); + expect(namespaceTranslatePipe.transform(1234 as any)).toBe(1234); + }); + + describe('should update translations on lang change', () => { + it('with fake loader', (done) => { + translate.setTranslation('en', { "NAMESPACE": { "TEST": "This is a test" } }); + translate.setTranslation('fr', { "NAMESPACE": { "TEST": "C'est un test" } }); + translate.use('en'); + + expect(namespaceTranslatePipe.transform('TEST')).toEqual("This is a test"); + + // this will be resolved at the next lang change + let subscription = translate.onLangChange.subscribe((res: LangChangeEvent) => { + expect(res.lang).toEqual('fr'); + expect(namespaceTranslatePipe.transform('TEST')).toEqual("C'est un test"); + subscription.unsubscribe(); + done(); + }); + + translate.use('fr'); + }); + + it('with file loader', (done) => { + translate.use('en'); + expect(namespaceTranslatePipe.transform('TEST')).toEqual("This is a test"); + + // this will be resolved at the next lang change + let subscription = translate.onLangChange.subscribe((res: LangChangeEvent) => { + // let it update the translations + setTimeout(() => { + expect(res.lang).toEqual('fr'); + expect(namespaceTranslatePipe.transform('TEST')).toEqual("C'est un test"); + subscription.unsubscribe(); + done(); + }); + }); + + translations = { "NAMESPACE": { "TEST": "C'est un test" } }; + translate.use('fr'); + }); + + it('should detect changes with OnPush', () => { + let fixture = (TestBed).createComponent(App); + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement.innerHTML).toEqual("NAMESPACE.TEST"); + translate.use('en'); + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement.innerHTML).toEqual("This is a test"); + }); + }); + + describe('should update translations on default lang change', () => { + it('with fake loader', (done) => { + translate.setTranslation('en', { "NAMESPACE": { "TEST": "This is a test" } }); + translate.setTranslation('fr', { "NAMESPACE": { "TEST": "C'est un test" } }); + translate.setDefaultLang('en'); + + expect(namespaceTranslatePipe.transform('TEST')).toEqual("This is a test"); + + // this will be resolved at the next lang change + let subscription = translate.onDefaultLangChange.subscribe((res: DefaultLangChangeEvent) => { + expect(res.lang).toEqual('fr'); + expect(namespaceTranslatePipe.transform('TEST')).toEqual("C'est un test"); + subscription.unsubscribe(); + done(); + }); + + translate.setDefaultLang('fr'); + }); + + it('with file loader', (done) => { + translate.setDefaultLang('en'); + expect(namespaceTranslatePipe.transform('TEST')).toEqual("This is a test"); + + // this will be resolved at the next lang change + let subscription = translate.onDefaultLangChange.subscribe((res: DefaultLangChangeEvent) => { + // let it update the translations + setTimeout(() => { + expect(res.lang).toEqual('fr'); + expect(namespaceTranslatePipe.transform('TEST')).toEqual("C'est un test"); + subscription.unsubscribe(); + done(); + }); + }); + + translations = { "NAMESPACE": { "TEST": "C'est un test" } }; + translate.setDefaultLang('fr'); + }); + + it('should detect changes with OnPush', () => { + let fixture = (TestBed).createComponent(App); + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement.innerHTML).toEqual("NAMESPACE.TEST"); + translate.setDefaultLang('en'); + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement.innerHTML).toEqual("This is a test"); + }); + }); +}); From eb4e66ea96d13190c33c3576bf13d0973ba6d1bb Mon Sep 17 00:00:00 2001 From: Andreas Resch Date: Thu, 28 Jan 2021 16:58:12 +0100 Subject: [PATCH 6/9] add namespace features to public api and module --- projects/ngx-translate/core/src/public_api.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/projects/ngx-translate/core/src/public_api.ts b/projects/ngx-translate/core/src/public_api.ts index ed7d484b..89c14ec5 100644 --- a/projects/ngx-translate/core/src/public_api.ts +++ b/projects/ngx-translate/core/src/public_api.ts @@ -7,6 +7,8 @@ import {TranslateDirective} from "./lib/translate.directive"; import {TranslatePipe} from "./lib/translate.pipe"; import {TranslateStore} from "./lib/translate.store"; import {USE_DEFAULT_LANG, DEFAULT_LANGUAGE, USE_STORE, TranslateService, USE_EXTEND} from "./lib/translate.service"; +import { NamespaceTranslateDirective } from "./lib/namespace-translate.directive"; +import { NamespaceTranslatePipe } from "./lib/namespace-translate.pipe"; export * from "./lib/translate.loader"; export * from "./lib/translate.service"; @@ -16,6 +18,9 @@ export * from "./lib/translate.compiler"; export * from "./lib/translate.directive"; export * from "./lib/translate.pipe"; export * from "./lib/translate.store"; +export * from "./lib/namespace-translate.service"; +export * from "./lib/namespace-translate.pipe"; +export * from "./lib/namespace-translate.directive"; export interface TranslateModuleConfig { loader?: Provider; @@ -33,11 +38,15 @@ export interface TranslateModuleConfig { @NgModule({ declarations: [ TranslatePipe, - TranslateDirective + TranslateDirective, + NamespaceTranslateDirective, + NamespaceTranslatePipe ], exports: [ TranslatePipe, - TranslateDirective + TranslateDirective, + NamespaceTranslateDirective, + NamespaceTranslatePipe ] }) export class TranslateModule { From 664feca2359ad1b6e24fe9fbf5182bd776a0a008 Mon Sep 17 00:00:00 2001 From: Andreas Resch Date: Thu, 28 Jan 2021 16:59:02 +0100 Subject: [PATCH 7/9] install flat package as dev-dependency for tests used in the namepsace translate service, directive and pipe tests --- package-lock.json | 12 ++++++++++++ package.json | 2 ++ 2 files changed, 14 insertions(+) diff --git a/package-lock.json b/package-lock.json index e82eb646..ee6e9f2c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3279,6 +3279,12 @@ "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==", "dev": true }, + "@types/flat": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@types/flat/-/flat-5.0.1.tgz", + "integrity": "sha512-ykRODHi9G9exJdTZvQggsqCUtB7jqiwLHcXCjNMb7zgWx6Lc2bydIUYBG1+It6VXZVFaeROv6HqPjDCAsoPG3w==", + "dev": true + }, "@types/glob": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.1.2.tgz", @@ -7496,6 +7502,12 @@ "resolve-dir": "^1.0.1" } }, + "flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true + }, "flatted": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/flatted/-/flatted-2.0.1.tgz", diff --git a/package.json b/package.json index 5b3b2bb6..a6c7f410 100644 --- a/package.json +++ b/package.json @@ -26,12 +26,14 @@ "@angular/cli": "10.0.0", "@angular/compiler-cli": "10.0.0", "@angular/language-service": "10.0.0", + "@types/flat": "^5.0.1", "@types/jasmine": "^3.3.13", "@types/jasminewd2": "^2.0.6", "@types/node": "^12.0.10", "codelyzer": "^5.2.2", "commitizen": "^3.1.1", "cz-conventional-changelog": "^2.1.0", + "flat": "^5.0.2", "jasmine-core": "~3.4.0", "jasmine-spec-reporter": "^4.2.1", "karma": "^4.1.0", From 28f7b26b2e4129f8140530d2137e638477fd3115 Mon Sep 17 00:00:00 2001 From: Andreas Resch Date: Fri, 29 Jan 2021 10:44:25 +0100 Subject: [PATCH 8/9] document namespace translate functionality in readme --- README.md | 147 ++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 109 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 3c98074b..f73c8082 100644 --- a/README.md +++ b/README.md @@ -7,34 +7,38 @@ Simple example using ngx-translate: https://stackblitz.com/github/ngx-translate/ Get the complete changelog here: https://github.com/ngx-translate/core/releases ## Table of Contents -* [Installation](#installation) -* [Usage](#usage) - * [Import the TranslateModule](#1-import-the-translatemodule) - * [SharedModule](#sharedmodule) - * [Lazy loaded modules](#lazy-loaded-modules) - * [Configuration](#configuration) - * [AoT](#aot) - * [Define the default language for the application](#2-define-the-default-language-for-the-application) - * [Init the TranslateService for your application](#3-init-the-translateservice-for-your-application) - * [Define the translations](#4-define-the-translations) - * [Use the service, the pipe or the directive](#5-use-the-service-the-pipe-or-the-directive) - * [Use HTML tags](#6-use-html-tags) -* [API](#api) - * [TranslateService](#translateservice) - * [Properties](#properties) - * [Methods](#methods) - * [Write & use your own loader](#write--use-your-own-loader) - * [Example](#example) - * [How to use a compiler to preprocess translation values](#how-to-use-a-compiler-to-preprocess-translation-values) - * [How to handle missing translations](#how-to-handle-missing-translations) - * [Example](#example-1) - * [Parser](#parser) - * [Methods](#methods) -* [FAQ](#faq) - * [I'm getting an error `npm ERR! peerinvalid Peer [...]`](#im-getting-an-error-npm-err-peerinvalid-peer-) -* [Plugins](#plugins) -* [Editors](#editors) -* [Additional Framework Support](#additional-framework-support) + - [Installation](#installation) + - [Usage](#usage) + - [1. Import the `TranslateModule`:](#1-import-the-translatemodule) + - [SharedModule](#sharedmodule) + - [Lazy loaded modules](#lazy-loaded-modules) + - [Configuration](#configuration) + - [AoT](#aot) + - [2. Define the `default language` for the application](#2-define-the-default-language-for-the-application) + - [3. Init the `TranslateService` for your application:](#3-init-the-translateservice-for-your-application) + - [4. Define the translations:](#4-define-the-translations) + - [5. Use the service, the pipe or the directive:](#5-use-the-service-the-pipe-or-the-directive) + - [6. Use HTML tags:](#6-use-html-tags) + - [7. Use the namespace-translate service, pipe or directive:](#7-use-the-namespace-translate-service-pipe-or-directive) + - [API](#api) + - [TranslateService](#translateservice) + - [Properties:](#properties) + - [Methods:](#methods) + - [Write & use your own loader](#write--use-your-own-loader) + - [Example](#example) + - [How to use a compiler to preprocess translation values](#how-to-use-a-compiler-to-preprocess-translation-values) + - [How to handle missing translations](#how-to-handle-missing-translations) + - [Example:](#example-1) + - [Parser](#parser) + - [Methods:](#methods-1) + - [FAQ](#faq) + - [I'm getting an error `npm ERR! peerinvalid Peer [...]`](#im-getting-an-error-npm-err-peerinvalid-peer-) + - [I want to hot reload the translations in my application but `reloadLang` does not work](#i-want-to-hot-reload-the-translations-in-my-application-but-reloadlang-does-not-work) + - [Plugins](#plugins) + - [Editors](#editors) + - [Extensions](#extensions) + - [VScode](#vscode) + - [Additional Framework Support](#additional-framework-support) ## Installation @@ -47,16 +51,16 @@ npm install @ngx-translate/core --save Choose the version corresponding to your Angular version: - Angular | @ngx-translate/core | @ngx-translate/http-loader - ----------- | ------------------- | -------------------------- - 10 | 13.x+ | 6.x+ - 9 | 12.x+ | 5.x+ - 8 | 12.x+ | 4.x+ - 7 | 11.x+ | 4.x+ - 6 | 10.x | 3.x - 5 | 8.x to 9.x | 1.x to 2.x - 4.3 | 7.x or less | 1.x to 2.x - 2 to 4.2.x | 7.x or less | 0.x + | Angular | @ngx-translate/core | @ngx-translate/http-loader | + | ---------- | ------------------- | -------------------------- | + | 10 | 13.x+ | 6.x+ | + | 9 | 12.x+ | 5.x+ | + | 8 | 12.x+ | 4.x+ | + | 7 | 11.x+ | 4.x+ | + | 6 | 10.x | 3.x | + | 5 | 8.x to 9.x | 1.x to 2.x | + | 4.3 | 7.x or less | 1.x to 2.x | + | 2 to 4.2.x | 7.x or less | 0.x | --- @@ -363,6 +367,60 @@ To render them, simply use the `innerHTML` attribute with the pipe on any elemen
``` +#### 7. Use the namespace-translate service, pipe or directive: + +If you have a big complex application it can be tedious to provide the sometimes pretty long path to the translations for the specific component. +For this reason we provide a `NamespaceTranslateService`, `NamespaceTranslatePipe` and `NamespaceTranslateDirective`. + +> The NamespaceTranslateService only provides functions to get translations for a specific key. It does not provide functions to set translations, language or do anything else. For all other things than getting the translations continue using the TranslateService! + +To use one of these you have to provide the namespace-translate service to the component via the `namespaceTranslateServiceProvider` as shown in the following example: + +```ts +import {Component} from '@angular/core'; +import {NamespaceTranslateService, namespaceTranslateServiceProvider} from '@ngx-translate/core'; + +@Component({ + selector: 'my-deep-nested-component', + template: ` +
{{ 'HELLO' | namespaceTranslate:param }}
+
Hello
+ `, + providers: [namespaceTranslateServiceProvider( + "PATH.TO.MY.DEEP.NESTED.COMPONENT" + )] +}) +export class MyDeepNestedComponent { + param = {value: 'world'}; + + constructor(namespaceTranslate: NamespaceTranslateService) { + const instant = namespaceTranslate.instant("HELLO", param); + + const observable = namespaceTranslate.get("HELLO", param); + } +} +``` + +The translation object for the example above would look like the following: + +```json +{ + "PATH":{ + "TO":{ + "MY":{ + "DEEP":{ + "NESTED":{ + "COMPONENT":{ + "HELLO": "hello {{value}}" + } + } + } + } + } + } +} +``` + ## API ### TranslateService @@ -494,6 +552,19 @@ Setup the Missing Translation Handler in your module import by adding it to the export class AppModule { } ``` +### NamespaceTranslateService + +#### Properties: +- `private readonly namespace: string`: The namespace with which all keys given to one of the function of this instance should be prefixed. +- `private readonly translate: TranslateService`: The global instance of the [`TranslateService`](###TranslateService) + +#### Methods: + +- `get(key: string|Array, interpolateParams?: Object): Observable`: Gets the translated value of a key (or an array of keys) or the key if the value was not found +- `getStreamOnTranslationChange(key: string|Array, interpolateParams?: Object): Observable`: Returns a stream of translated values of a key (or an array of keys) or the key if the value was not found. Without any `onTranslationChange` events this returns the same value as `get` but it will also emit new values whenever the translation changes. +- `stream(key: string|Array, interpolateParams?: Object): Observable`: Returns a stream of translated values of a key (or an array of keys) or the key if the value was not found. Without any `onLangChange` events this returns the same value as `get` but it will also emit new values whenever the used language changes. +- `instant(key: string|Array, interpolateParams?: Object): string|Object`: Gets the instant translated value of a key (or an array of keys). /!\ This method is **synchronous** and the default file loader is asynchronous. You are responsible for knowing when your translations have been loaded and it is safe to use this method. If you are not sure then you should use the `get` method instead. + ### Parser If you need it for some reason, you can use the `TranslateParser` service. From ce4588b960d080769ca39d7fafc540fd73c4da4d Mon Sep 17 00:00:00 2001 From: Andreas Resch Date: Fri, 29 Jan 2021 15:27:22 +0100 Subject: [PATCH 9/9] remove the namespaceTranslateServiceProvider angular doesn't allow the usage of function in providers with the default settings. --- README.md | 22 +++++--- .../src/lib/namespace-translate.service.ts | 37 +++++++------- projects/ngx-translate/core/src/public_api.ts | 50 +++++++++---------- .../tests/namespace-translate.service.spec.ts | 6 +-- .../namespace.translate.directive.spec.ts | 4 +- .../tests/namespace.translate.pipe.spec.ts | 7 ++- 6 files changed, 69 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index f73c8082..a1b754fb 100644 --- a/README.md +++ b/README.md @@ -372,13 +372,25 @@ To render them, simply use the `innerHTML` attribute with the pipe on any elemen If you have a big complex application it can be tedious to provide the sometimes pretty long path to the translations for the specific component. For this reason we provide a `NamespaceTranslateService`, `NamespaceTranslatePipe` and `NamespaceTranslateDirective`. -> The NamespaceTranslateService only provides functions to get translations for a specific key. It does not provide functions to set translations, language or do anything else. For all other things than getting the translations continue using the TranslateService! +> The NamespaceTranslateService only provides functions to get translations for a specific key(s). It does not provide functions to set translations, language or do anything else. For all other things than getting the translations continue using the TranslateService! -To use one of these you have to provide the namespace-translate service to the component via the `namespaceTranslateServiceProvider` as shown in the following example: +The NamespaceTranslateService is not provided via the TranslateModule! + +To use the NamespaceTranslateService, Pipe or Module you have to provide a `TRANSLATION_NAMESPACE` AND the `NamespaceTranslateService` in your component or directive. + +Read this if you don't know the specifics how Angular DI works: + +If you provide the `TRANSLATION_NAMESPACE` and the `NamespaceTranslateService` in a module, every component and directive declared by the module will share the namespace, unless you provide the booth the `TRANSLATION_NAMESPACE` AND `NamespaceTranslateService` in the `providers` section. + +> Also be aware of the fact, that all the child components/directives of a component/directive have access to things provided in the `providers` section of a parent component/directive! + +So if you don't provide booth the `TRANSLATION_NAMESPACE` and the `NamespaceTranslateService` in the providers section you will share either the instance of the `NamespaceTranslateService` or the `TRANSLATION_NAMESPACE` with the parent component/directive. + + Example usage: ```ts import {Component} from '@angular/core'; -import {NamespaceTranslateService, namespaceTranslateServiceProvider} from '@ngx-translate/core'; +import {NamespaceTranslateServiceNamespaceTranslateProvider} from '@ngx-translate/core'; @Component({ selector: 'my-deep-nested-component', @@ -386,9 +398,7 @@ import {NamespaceTranslateService, namespaceTranslateServiceProvider} from '@ngx
{{ 'HELLO' | namespaceTranslate:param }}
Hello
`, - providers: [namespaceTranslateServiceProvider( - "PATH.TO.MY.DEEP.NESTED.COMPONENT" - )] + providers: [{ provide: TRANSLATION_NAMESPACE, useValue: "PATH.TO.MY.DEEP.NESTED.COMPONENT"}, NamespaceTranslateService] }) export class MyDeepNestedComponent { param = {value: 'world'}; diff --git a/projects/ngx-translate/core/src/lib/namespace-translate.service.ts b/projects/ngx-translate/core/src/lib/namespace-translate.service.ts index f43039db..56736d93 100644 --- a/projects/ngx-translate/core/src/lib/namespace-translate.service.ts +++ b/projects/ngx-translate/core/src/lib/namespace-translate.service.ts @@ -3,7 +3,7 @@ import { Observable } from 'rxjs'; import { TranslateService } from './translate.service'; import { isDefined } from './util'; -const TRANSLATION_NAMESPACE = new InjectionToken('TRANSLATION_NAMESPACE'); +export const TRANSLATION_NAMESPACE = new InjectionToken('TRANSLATION_NAMESPACE'); /** * Wraps the `getParsedResult`, `get`, `getStreamOnTranslationChange`, `stream` and `instant` functions of the @@ -12,7 +12,7 @@ const TRANSLATION_NAMESPACE = new InjectionToken('TRANSLATION_NAMESPACE' * To access the functionality of this service in your html files use the `namespace-translate` pipe or * the `namespaceTranslate` directive * - * Use the @see `namespaceTranslateServiceProvider` function to provide this service + * Use the @see `NamespaceTranslateProvider.forChild` function to provide this service * to your component, service, pipe, module, ... */ @Injectable() @@ -88,19 +88,22 @@ export class NamespaceTranslateService { } } -const namespaceTranslateFactory = (namespace: string) => (translate: TranslateService) => { - return new NamespaceTranslateService(translate, namespace); -} +// const namespaceTranslateFactory = (namespace: string) => (translate: TranslateService) => { +// return new NamespaceTranslateService(translate, namespace); +// } -/** - * provides the NamespaceTranslateService to your component, service, pipe, module, ... - * @param namespace The namespace that should be prefixed to keys given functions of the NamespaceTranslateService. - * It should not end with a "." because it inserted automatically between the namespace and the key! - */ -export function namespaceTranslateServiceProvider(namespace: string): Provider { - return { - provide: NamespaceTranslateService, - useFactory: namespaceTranslateFactory(namespace), - deps: [TranslateService] - } -} +// // @dynamic +// export class NamespaceTranslateProvider { +// /** +// * provides the NamespaceTranslateService to your component, service, pipe, module, ... +// * @param namespace The namespace that should be prefixed to keys given functions of the NamespaceTranslateService. +// * It should not end with a "." because it inserted automatically between the namespace and the key! +// */ +// static forChild(namespace: string): Provider { +// return { +// provide: NamespaceTranslateService, +// useFactory: namespaceTranslateFactory(namespace), +// deps: [TranslateService] +// } +// } +// } diff --git a/projects/ngx-translate/core/src/public_api.ts b/projects/ngx-translate/core/src/public_api.ts index 89c14ec5..c4df1f03 100644 --- a/projects/ngx-translate/core/src/public_api.ts +++ b/projects/ngx-translate/core/src/public_api.ts @@ -1,12 +1,12 @@ -import {NgModule, ModuleWithProviders, Provider} from "@angular/core"; -import {TranslateLoader, TranslateFakeLoader} from "./lib/translate.loader"; -import {MissingTranslationHandler, FakeMissingTranslationHandler} from "./lib/missing-translation-handler"; -import {TranslateParser, TranslateDefaultParser} from "./lib/translate.parser"; -import {TranslateCompiler, TranslateFakeCompiler} from "./lib/translate.compiler"; -import {TranslateDirective} from "./lib/translate.directive"; -import {TranslatePipe} from "./lib/translate.pipe"; -import {TranslateStore} from "./lib/translate.store"; -import {USE_DEFAULT_LANG, DEFAULT_LANGUAGE, USE_STORE, TranslateService, USE_EXTEND} from "./lib/translate.service"; +import { NgModule, ModuleWithProviders, Provider } from "@angular/core"; +import { TranslateLoader, TranslateFakeLoader } from "./lib/translate.loader"; +import { MissingTranslationHandler, FakeMissingTranslationHandler } from "./lib/missing-translation-handler"; +import { TranslateParser, TranslateDefaultParser } from "./lib/translate.parser"; +import { TranslateCompiler, TranslateFakeCompiler } from "./lib/translate.compiler"; +import { TranslateDirective } from "./lib/translate.directive"; +import { TranslatePipe } from "./lib/translate.pipe"; +import { TranslateStore } from "./lib/translate.store"; +import { USE_DEFAULT_LANG, DEFAULT_LANGUAGE, USE_STORE, TranslateService, USE_EXTEND } from "./lib/translate.service"; import { NamespaceTranslateDirective } from "./lib/namespace-translate.directive"; import { NamespaceTranslatePipe } from "./lib/namespace-translate.pipe"; @@ -57,15 +57,15 @@ export class TranslateModule { return { ngModule: TranslateModule, providers: [ - config.loader || {provide: TranslateLoader, useClass: TranslateFakeLoader}, - config.compiler || {provide: TranslateCompiler, useClass: TranslateFakeCompiler}, - config.parser || {provide: TranslateParser, useClass: TranslateDefaultParser}, - config.missingTranslationHandler || {provide: MissingTranslationHandler, useClass: FakeMissingTranslationHandler}, + config.loader || { provide: TranslateLoader, useClass: TranslateFakeLoader }, + config.compiler || { provide: TranslateCompiler, useClass: TranslateFakeCompiler }, + config.parser || { provide: TranslateParser, useClass: TranslateDefaultParser }, + config.missingTranslationHandler || { provide: MissingTranslationHandler, useClass: FakeMissingTranslationHandler }, TranslateStore, - {provide: USE_STORE, useValue: config.isolate}, - {provide: USE_DEFAULT_LANG, useValue: config.useDefaultLang}, - {provide: USE_EXTEND, useValue: config.extend}, - {provide: DEFAULT_LANGUAGE, useValue: config.defaultLanguage}, + { provide: USE_STORE, useValue: config.isolate }, + { provide: USE_DEFAULT_LANG, useValue: config.useDefaultLang }, + { provide: USE_EXTEND, useValue: config.extend }, + { provide: DEFAULT_LANGUAGE, useValue: config.defaultLanguage }, TranslateService ] }; @@ -78,14 +78,14 @@ export class TranslateModule { return { ngModule: TranslateModule, providers: [ - config.loader || {provide: TranslateLoader, useClass: TranslateFakeLoader}, - config.compiler || {provide: TranslateCompiler, useClass: TranslateFakeCompiler}, - config.parser || {provide: TranslateParser, useClass: TranslateDefaultParser}, - config.missingTranslationHandler || {provide: MissingTranslationHandler, useClass: FakeMissingTranslationHandler}, - {provide: USE_STORE, useValue: config.isolate}, - {provide: USE_DEFAULT_LANG, useValue: config.useDefaultLang}, - {provide: USE_EXTEND, useValue: config.extend}, - {provide: DEFAULT_LANGUAGE, useValue: config.defaultLanguage}, + config.loader || { provide: TranslateLoader, useClass: TranslateFakeLoader }, + config.compiler || { provide: TranslateCompiler, useClass: TranslateFakeCompiler }, + config.parser || { provide: TranslateParser, useClass: TranslateDefaultParser }, + config.missingTranslationHandler || { provide: MissingTranslationHandler, useClass: FakeMissingTranslationHandler }, + { provide: USE_STORE, useValue: config.isolate }, + { provide: USE_DEFAULT_LANG, useValue: config.useDefaultLang }, + { provide: USE_EXTEND, useValue: config.extend }, + { provide: DEFAULT_LANGUAGE, useValue: config.defaultLanguage }, TranslateService ] }; diff --git a/projects/ngx-translate/core/tests/namespace-translate.service.spec.ts b/projects/ngx-translate/core/tests/namespace-translate.service.spec.ts index f180ee89..56e74f31 100644 --- a/projects/ngx-translate/core/tests/namespace-translate.service.spec.ts +++ b/projects/ngx-translate/core/tests/namespace-translate.service.spec.ts @@ -1,7 +1,7 @@ import { TestBed } from "@angular/core/testing"; import { Observable, of, zip } from "rxjs"; import { take, toArray, first } from 'rxjs/operators'; -import { TranslateLoader, TranslateModule, TranslateService, NamespaceTranslateService, namespaceTranslateServiceProvider } from '../src/public_api'; +import { TranslateLoader, TranslateModule, TranslateService, NamespaceTranslateService, TRANSLATION_NAMESPACE } from '../src/public_api'; import * as flatten from "flat"; let translations: any = { "NAMESPACE": { "TEST": "This is a namespace test" } }; @@ -25,7 +25,7 @@ describe('NamespaceTranslateService', () => { loader: { provide: TranslateLoader, useClass: FakeLoader } }) ], - providers: [namespaceTranslateServiceProvider("NAMESPACE")] + providers: [{ provide: TRANSLATION_NAMESPACE, useValue: "NAMESPACE" }] }); namespaceTranslate = TestBed.inject(NamespaceTranslateService); translate = TestBed.inject(TranslateService); @@ -91,7 +91,7 @@ describe('NamespaceTranslateService', () => { loader: { provide: TranslateLoader, useClass: FakeLoader } }) ], - providers: [namespaceTranslateServiceProvider("NAMESPACE")] + providers: [{ provide: TRANSLATION_NAMESPACE, useValue: "NAMESPACE" }] }); namespaceTranslate = TestBed.inject(NamespaceTranslateService); translate = TestBed.inject(TranslateService); diff --git a/projects/ngx-translate/core/tests/namespace.translate.directive.spec.ts b/projects/ngx-translate/core/tests/namespace.translate.directive.spec.ts index 97c1c1c7..09f609f6 100644 --- a/projects/ngx-translate/core/tests/namespace.translate.directive.spec.ts +++ b/projects/ngx-translate/core/tests/namespace.translate.directive.spec.ts @@ -1,12 +1,12 @@ import { ChangeDetectionStrategy, Component, ElementRef, Injectable, ViewChild, ViewContainerRef } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { namespaceTranslateServiceProvider, TranslateModule, TranslateService } from '../src/public_api'; +import { TranslateModule, TranslateService, TRANSLATION_NAMESPACE } from '../src/public_api'; @Injectable() @Component({ selector: 'hmx-app', changeDetection: ChangeDetectionStrategy.OnPush, - providers: [namespaceTranslateServiceProvider("NAMESPACE")], + providers: [{ provide: TRANSLATION_NAMESPACE, useValue: "NAMESPACE" }], template: `
TEST
TEST.VALUE
diff --git a/projects/ngx-translate/core/tests/namespace.translate.pipe.spec.ts b/projects/ngx-translate/core/tests/namespace.translate.pipe.spec.ts index baf52589..2042c79b 100644 --- a/projects/ngx-translate/core/tests/namespace.translate.pipe.spec.ts +++ b/projects/ngx-translate/core/tests/namespace.translate.pipe.spec.ts @@ -1,9 +1,8 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Injectable, ViewContainerRef } from "@angular/core"; import { TestBed } from "@angular/core/testing"; import { Observable, of } from "rxjs"; -import { DefaultLangChangeEvent, LangChangeEvent, TranslateLoader, TranslateModule, TranslateService } from "../src/public_api"; -import { NamespaceTranslatePipe } from '../src/lib/namespace-translate.pipe'; -import { NamespaceTranslateService, namespaceTranslateServiceProvider } from '../src/lib/namespace-translate.service'; +import { DefaultLangChangeEvent, LangChangeEvent, TranslateLoader, TranslateModule, TranslateService, NamespaceTranslatePipe, NamespaceTranslateService, TRANSLATION_NAMESPACE } from "../src/public_api"; + class FakeChangeDetectorRef extends ChangeDetectorRef { markForCheck(): void { @@ -57,7 +56,7 @@ describe('NamespaceTranslatePipe', () => { loader: { provide: TranslateLoader, useClass: FakeLoader } }) ], - providers: [namespaceTranslateServiceProvider("NAMESPACE")], + providers: [{ provide: TRANSLATION_NAMESPACE, useValue: "NAMESPACE" }], declarations: [App] }); translate = TestBed.inject(TranslateService);