diff --git a/README.md b/README.md index 33e04a7..7fc8119 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,9 @@ Get the complete changelog here: https://github.com/ngx-translate/core/releases * [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) + * [Use translateNamespace directive](#7-use-translate-namespace) + * [Use translateContext directive](#8-use-translate-context) + * [API](#api) * [TranslateService](#translateservice) * [Properties](#properties) @@ -357,6 +360,85 @@ To render them, simply use the `innerHTML` attribute with the pipe on any elemen
``` +#### 7. Use translate namespace: + +You can namespace your keys using the `*translateNamespace` structural directive + +```html + +``` + +Would translate the `somApp.pageX.contentComponent.key1`, `somApp.pageX.contentComponent.key2` and `somApp.pageX.contentComponent.key3` keys. + +The namespaces directive are also stackable: + +```html +// app.component.html +
+ +
+``` + +```html +// pageX.component.html +
+ +
+``` + +```html +// content.component.html + +``` + +> **Note:** +> +> To avoid breaking changes, the translation resolution will fallback to a non namespaced key if the key isn't found in a given namespace. + +#### 8. Use translate context: + +You can use the `*translateContext` structural directive to provide translation params to all child elements. + +```json +{ + "person": { + "bio": { + "nameColumn": "{{firstName}} {{lastName}}", + "addressColumn": "{{address}} {{city}} {{state}}", + "age": "{{age}} years old" + } + } +} +``` + +```html + +``` + +The context are also stackable. Meaning that context from parent dom element will be inherited and merged with the current context. + +Since `*translateNamespace` and `*translateContext` are structural directives and Angular limits one structural directive per element, it is possible to provide the namespace on the `*translateContext` directive: + +```html + +``` + ## API ### TranslateService diff --git a/projects/ngx-translate/core/src/lib/translate-context.directive.ts b/projects/ngx-translate/core/src/lib/translate-context.directive.ts new file mode 100644 index 0000000..bfaf9fe --- /dev/null +++ b/projects/ngx-translate/core/src/lib/translate-context.directive.ts @@ -0,0 +1,30 @@ +import { Directive, Inject, Input, Optional, Self, SkipSelf, TemplateRef, ViewContainerRef } from '@angular/core'; +import { TranslateContextService } from './translate-context.service'; +import { TranslateService } from './translate.service'; + +@Directive({ + selector: '[translateContext],[ngx-translateContext]', + providers: [{ provide: TranslateService, useClass: TranslateContextService }] +}) +export class TranslateContextDirective { + + constructor( + @Inject(TranslateService) @Self() private contextTranslateService: TranslateContextService, + @Inject(TranslateService) @Optional() @SkipSelf() private readonly parentContext: TranslateService, + private templateRef: TemplateRef, + private viewContainer: ViewContainerRef + ) { + this.contextTranslateService.parentContext = this.parentContext; + } + + + @Input() set translateContext(params: {}) { + this.contextTranslateService.params = params; + this.viewContainer.clear(); + this.viewContainer.createEmbeddedView(this.templateRef); + } + + @Input() set translateContextNamespace(namespace: string) { + this.contextTranslateService.namespace = namespace; + } +} \ No newline at end of file diff --git a/projects/ngx-translate/core/src/lib/translate-context.service.ts b/projects/ngx-translate/core/src/lib/translate-context.service.ts new file mode 100644 index 0000000..d2b21c1 --- /dev/null +++ b/projects/ngx-translate/core/src/lib/translate-context.service.ts @@ -0,0 +1,31 @@ +import { Injectable } from "@angular/core"; +import { TranslateService } from "./translate.service"; +import { Observable, zip } from "rxjs"; +import { map } from "rxjs/operators"; + +@Injectable() +export class TranslateContextService extends TranslateService { + + params = {}; + namespace: string | null = null; + + parentContext!: TranslateContextService | TranslateService; + + public override get(key: string | Array, interpolateParams?: any | undefined): Observable { + + const paramsWithContext = {...this.params, ...interpolateParams}; + + let result$ = this.parentContext.get(key, paramsWithContext); + + if(this.namespace) { + const namespacedKey = `${this.namespace}.${key}`; + + result$ = zip(result$, this.parentContext.get(namespacedKey, paramsWithContext)).pipe( + map( ([label, namespacedLabel]) => namespacedLabel === namespacedKey ? label : namespacedLabel ) + ); + } + + return result$; + } + +} diff --git a/projects/ngx-translate/core/src/lib/translate-namespace.directive.ts b/projects/ngx-translate/core/src/lib/translate-namespace.directive.ts new file mode 100644 index 0000000..03a888d --- /dev/null +++ b/projects/ngx-translate/core/src/lib/translate-namespace.directive.ts @@ -0,0 +1,27 @@ +import { Directive, Inject, Input, Optional, Self, SkipSelf, TemplateRef, ViewContainerRef } from '@angular/core'; +import { TranslateContextService } from './translate-context.service'; +import { TranslateService } from './translate.service'; + + +@Directive({ + selector: '[translateNamespace],[ngx-translateNamespace]', + providers: [{ provide: TranslateService, useClass: TranslateContextService }] +}) +export class TranslateNamespaceDirective { + + constructor( + @Inject(TranslateService) @Self() private contextTranslateService: TranslateContextService, + @Inject(TranslateService) @Optional() @SkipSelf() private readonly parentContext: TranslateService, + private templateRef: TemplateRef, + private viewContainer: ViewContainerRef + ) { + this.contextTranslateService.parentContext = this.parentContext; + } + + + @Input() set translateNamespace(namespace: string) { + this.contextTranslateService.namespace = namespace; + this.viewContainer.clear(); + this.viewContainer.createEmbeddedView(this.templateRef); + } +} \ No newline at end of file diff --git a/projects/ngx-translate/core/src/public_api.ts b/projects/ngx-translate/core/src/public_api.ts index ed7d484..9145481 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 { TranslateContextDirective } from "./lib/translate-context.directive"; +import { TranslateNamespaceDirective } from "./lib/translate-namespace.directive"; export * from "./lib/translate.loader"; export * from "./lib/translate.service"; @@ -16,6 +18,8 @@ export * from "./lib/translate.compiler"; export * from "./lib/translate.directive"; export * from "./lib/translate.pipe"; export * from "./lib/translate.store"; +export * from "./lib/translate-context.directive"; +export * from "./lib/translate-namespace.directive"; export interface TranslateModuleConfig { loader?: Provider; @@ -33,11 +37,15 @@ export interface TranslateModuleConfig { @NgModule({ declarations: [ TranslatePipe, - TranslateDirective + TranslateDirective, + TranslateContextDirective, + TranslateNamespaceDirective ], exports: [ TranslatePipe, - TranslateDirective + TranslateDirective, + TranslateContextDirective, + TranslateNamespaceDirective ] }) export class TranslateModule { diff --git a/projects/ngx-translate/core/tests/translate-context.directive.spec.ts b/projects/ngx-translate/core/tests/translate-context.directive.spec.ts new file mode 100644 index 0000000..fe91956 --- /dev/null +++ b/projects/ngx-translate/core/tests/translate-context.directive.spec.ts @@ -0,0 +1,127 @@ +import { Component } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateContextDirective } from '../src/lib/translate-context.directive'; +import { TranslateModule, TranslateService } from '../src/public_api'; + + +const labels = { + LABEL: 'context: {{param1}} {{param2}} {{param3}}', + + gc: { + key1: 'gc.k1', + key2: 'gc.k2', + key3: 'gc.k3' + }, + + overrides: { + gc: { + key1: 'root.k1', + key2: 'root.k2' + } + } + +}; + +@Component({ + selector: 'root-component', + template: ` +
+
+ +
+ + ` +}) +class RootComponent { + rootContext = { param1: 'r1', param2: 'r2', param3: 'r3' }; +} + +@Component({ + selector: 'child-component', + template: ` +
+ +
+
+ +
+ ` +}) +class ChildComponent { + childContext = { param2: 'c2' } +} + +@Component({ + selector: 'grandchild-component', + template: ` +
+ +
+
+
+ + +
+ +
+
+
+
+ ` +}) +class GrandChildComponent { + grandchildContext = { param3: 'gc3' } +} + + +describe('TranslateContextDirective', () => { + let fixture: ComponentFixture; + let nativeElement: HTMLElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [RootComponent, ChildComponent, GrandChildComponent, TranslateContextDirective], + imports: [TranslateModule.forRoot()] + }) + .compileComponents(); + }); + + beforeEach(() => { + const translateService = TestBed.inject(TranslateService); + translateService.setTranslation('en', labels); + translateService.currentLang = 'en'; + + fixture = TestBed.createComponent(RootComponent); + fixture.detectChanges(); + nativeElement = fixture.nativeElement; + }); + + + + it('should provide context params for immediate view children', () => { + expect(nativeElement.getElementsByClassName('root-with-context')[0].innerHTML).toBe('context: r1 r2 r3'); + }); + + it('should inherit context params from parent component', () => { + expect(nativeElement.getElementsByClassName('child-no-context')[0].innerHTML).toBe('context: r1 r2 r3'); + }); + + it('should merge context params over inherited context', () => { + expect(nativeElement.getElementsByClassName('child-with-context')[0].innerHTML).toBe('context: r1 c2 r3'); + }); + + it('should inherit context params from parent and grand parent components', () => { + expect(nativeElement.getElementsByClassName('grandchild-no-context')[0].innerHTML).toBe('context: r1 c2 r3'); + }); + + it('should merge context params over parent and grand parent context', () => { + expect(nativeElement.getElementsByClassName('grandchild-with-context')[0].innerHTML).toBe('context: r1 c2 gc3'); + }); + + it('should prefix the key with the context namespace', () => { + expect(nativeElement.getElementsByClassName('grandchild-key1')[0].innerHTML).toBe('root.k1'); + expect(nativeElement.getElementsByClassName('grandchild-key2')[0].innerHTML).toBe('root.k2'); + expect(nativeElement.getElementsByClassName('grandchild-key3')[0].innerHTML).toBe('gc.k3'); + }); + +}); diff --git a/projects/ngx-translate/core/tests/translate-namespace.directive.spec.ts b/projects/ngx-translate/core/tests/translate-namespace.directive.spec.ts new file mode 100644 index 0000000..1e5645a --- /dev/null +++ b/projects/ngx-translate/core/tests/translate-namespace.directive.spec.ts @@ -0,0 +1,91 @@ +import { Component } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateNamespaceDirective } from '../src/lib/translate-namespace.directive'; +import { TranslateModule, TranslateService } from '../src/public_api'; + + +const labels = { + gc: { + key1: 'gc.k1', + key2: 'gc.k2', + key3: 'gc.k3' + }, + + overrides: { + gc: { + key1: 'root.k1', + key2: 'root.k2' + } + } + +}; + +@Component({ + selector: 'root-component', + template: ` +
+ +
+ + ` +}) +class RootComponent { + rootContext = { param1: 'r1', param2: 'r2', param3: 'r3' }; +} + +@Component({ + selector: 'child-component', + template: ` + + ` +}) +class ChildComponent { + childContext = { param2: 'c2' } +} + +@Component({ + selector: 'grandchild-component', + template: ` +
+ +
+
+
+
+ ` +}) +class GrandChildComponent { + grandchildContext = { param3: 'gc3' } +} + + +describe('TranslateNamespaceDirective', () => { + let fixture: ComponentFixture; + let nativeElement: HTMLElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [RootComponent, ChildComponent, GrandChildComponent, TranslateNamespaceDirective], + imports: [TranslateModule.forRoot()] + }) + .compileComponents(); + }); + + beforeEach(() => { + const translateService = TestBed.inject(TranslateService); + translateService.setTranslation('en', labels); + translateService.currentLang = 'en'; + + fixture = TestBed.createComponent(RootComponent); + fixture.detectChanges(); + nativeElement = fixture.nativeElement; + }); + + + it('should prefix the key with the context namespace', () => { + expect(nativeElement.getElementsByClassName('grandchild-key1')[0].innerHTML).toBe('root.k1'); + expect(nativeElement.getElementsByClassName('grandchild-key2')[0].innerHTML).toBe('root.k2'); + expect(nativeElement.getElementsByClassName('grandchild-key3')[0].innerHTML).toBe('gc.k3'); + }); + +});