Skip to content

Commit 8eaf2bd

Browse files
authored
Merge pull request #299 from cal-smith/i18n
feat(i18n): support observables and variables
2 parents ba82d73 + 0cb4c51 commit 8eaf2bd

File tree

10 files changed

+263
-25
lines changed

10 files changed

+263
-25
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ dist
77
demo/bundle
88
.idea
99
.vscode
10+
.cache
1011
doc/
1112
documentation/
1213
dist/

src/i18n/en.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,9 @@
9595
"ITEMS_PER_PAGE": "Items per page:",
9696
"OPEN_LIST_OF_OPTIONS": "Open list of options",
9797
"BACKWARD": "Backward",
98-
"FORWARD": "Forward"
98+
"FORWARD": "Forward",
99+
"TOTAL_ITEMS": "{{start}}-{{end}} of {{total}} items",
100+
"TOTAL_PAGES": "{{current}} of {{last}} pages"
99101
},
100102
"TABLE": {
101103
"GO_TO_PAGE": "Go to page",

src/i18n/i18n.module.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { NgModule, SkipSelf, Optional } from "@angular/core";
22

33
import { I18n } from "./i18n.service";
4+
import { ReplacePipe } from "./replace.pipe";
45

5-
export { I18n } from "./i18n.service";
6+
export { I18n, replace } from "./i18n.service";
7+
export { ReplacePipe } from "./replace.pipe";
68

79
// either provides a new instance of ModalPlaceholderService, or returns the parent
810
export function I18N_SERVICE_PROVIDER_FACTORY(parentService: I18n) {
@@ -17,6 +19,8 @@ export const I18N_SERVICE_PROVIDER = {
1719
};
1820

1921
@NgModule({
22+
declarations: [ReplacePipe],
23+
exports: [ReplacePipe],
2024
providers: [
2125
I18n,
2226
I18N_SERVICE_PROVIDER

src/i18n/i18n.service.ts

Lines changed: 112 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,126 @@
11
import { Injectable } from "@angular/core";
2+
import { BehaviorSubject } from "rxjs";
3+
import { map } from "rxjs/operators";
24

35
const EN = require("./en.json");
46

7+
/**
8+
* Takes the `Observable` returned from `i18n.get` and an object of variables to replace.
9+
*
10+
* The keys specify the variable name in the string.
11+
*
12+
* Example:
13+
* ```typescript
14+
* service.set({ "TEST": "{{foo}} {{bar}}" });
15+
*
16+
* service.replace(service.get("TEST"), { foo: "test", bar: "asdf" })
17+
* ```
18+
*
19+
* Produces: `"test asdf"`
20+
*
21+
* @param subject the translation to replace variables on
22+
* @param variables object of variables to replace
23+
*/
24+
export const replace = (subject, variables) => subject.pipe(
25+
map<string, void>(str => {
26+
const keys = Object.keys(variables);
27+
for (const key of keys) {
28+
const value = variables[key];
29+
while (str.includes(`{{${key}}}`)) {
30+
str = str.replace(`{{${key}}}`, value);
31+
}
32+
}
33+
return str;
34+
})
35+
);
36+
37+
/**
38+
* The I18n service is a minimal internal service used to supply our components with translated strings.
39+
*
40+
* All the components that support I18n also support directly passed strings.
41+
* Usage of I18n is optional, and it is not recommended for application use (libraries like ngx-translate
42+
* are a better choice)
43+
*
44+
*/
545
@Injectable()
646
export class I18n {
747
protected translationStrings = EN;
848

49+
protected translations = new Map();
50+
51+
/**
52+
* Set/update the translations from an object. Also notifies all participating components of the update.
53+
*
54+
* @param strings an object of strings, should follow the same format as src/i18n/en.json
55+
*/
956
public set(strings) {
1057
this.translationStrings = Object.assign({}, EN, strings);
58+
const translations = Array.from(this.translations);
59+
for (const [path, subject] of translations) {
60+
subject.next(this.getValueFromPath(path));
61+
}
62+
}
63+
64+
/**
65+
* When a path is specified returns an observable that will resolve to the translation string value.
66+
*
67+
* Returns the full translations object if path is not specified.
68+
*
69+
* @param path optional, looks like `"NOTIFICATION.CLOSE_BUTTON"`
70+
*/
71+
public get(path?) {
72+
if (!path) {
73+
return this.translationStrings;
74+
}
75+
try {
76+
// we run this here to validate the path exists before adding it to the translation map
77+
const value = this.getValueFromPath(path);
78+
if (this.translations.has(path)) {
79+
return this.translations.get(path);
80+
}
81+
const translation = new BehaviorSubject(value);
82+
this.translations.set(path, translation);
83+
return translation;
84+
} catch (err) {
85+
console.error(err);
86+
}
87+
}
88+
89+
/**
90+
* Takes the `Observable` returned from `i18n.get` and an object of variables to replace.
91+
*
92+
* The keys specify the variable name in the string.
93+
*
94+
* Example:
95+
* ```
96+
* service.set({ "TEST": "{{foo}} {{bar}}" });
97+
*
98+
* service.replace(service.get("TEST"), { foo: "test", bar: "asdf" })
99+
* ```
100+
*
101+
* Produces: `"test asdf"`
102+
*
103+
* @param subject the translation to replace variables on
104+
* @param variables object of variables to replace
105+
*/
106+
public replace(subject, variables) {
107+
return replace(subject, variables);
11108
}
12109

13-
public get() {
14-
return this.translationStrings;
110+
/**
111+
* Trys to resolve a value from the provided path.
112+
*
113+
* @param path looks like `"NOTIFICATION.CLOSE_BUTTON"`
114+
*/
115+
protected getValueFromPath(path) {
116+
let value = this.translationStrings;
117+
for (const segment of path.split(".")) {
118+
if (value[segment]) {
119+
value = value[segment];
120+
} else {
121+
throw new Error(`no key ${segment} at ${path}`);
122+
}
123+
}
124+
return value;
15125
}
16126
}

src/i18n/i18n.spec.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { I18n } from "./i18n.service";
2+
3+
const EN = require("./en.json");
4+
5+
let service;
6+
7+
describe("i18n service", () => {
8+
beforeEach(() => {
9+
service = new I18n();
10+
});
11+
12+
it("should translate a string", done => {
13+
service.get("BANNER.CLOSE_BUTTON").subscribe(value => {
14+
expect(value).toBe(EN.BANNER.CLOSE_BUTTON);
15+
done();
16+
});
17+
});
18+
19+
it("should update strings", done => {
20+
service.set({ "BANNER": { "CLOSE_BUTTON": "test" }});
21+
22+
service.get("BANNER.CLOSE_BUTTON").subscribe(value => {
23+
expect(value).toBe("test");
24+
done();
25+
});
26+
});
27+
28+
it("should emit updated string", () => {
29+
const subject = service.get("BANNER.CLOSE_BUTTON");
30+
31+
const spy = spyOn(subject, "next");
32+
33+
service.set({ "BANNER": { "CLOSE_BUTTON": "test" } });
34+
35+
expect(spy).toHaveBeenCalled();
36+
});
37+
38+
it("should replace variables", done => {
39+
service.set({ "TEST": "{{foo}} bar"});
40+
41+
service.replace(service.get("TEST"), {foo: "test"}).subscribe(value => {
42+
expect(value).toBe("test bar");
43+
done();
44+
});
45+
});
46+
47+
it("should replace multiple of the same variable", done => {
48+
service.set({ "TEST": "{{foo}} {{foo}}" });
49+
50+
service.replace(service.get("TEST"), { foo: "test" }).subscribe(value => {
51+
expect(value).toBe("test test");
52+
done();
53+
});
54+
});
55+
56+
it("should replace multiple variables", done => {
57+
service.set({ "TEST": "{{foo}} {{bar}}" });
58+
59+
service.replace(service.get("TEST"), { foo: "test", bar: "asdf" }).subscribe(value => {
60+
expect(value).toBe("test asdf");
61+
done();
62+
});
63+
});
64+
});

src/i18n/replace.pipe.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { Pipe, PipeTransform } from "@angular/core";
2+
import { replace } from "./i18n.service";
3+
4+
@Pipe({
5+
name: "i18nReplace"
6+
})
7+
export class ReplacePipe implements PipeTransform {
8+
transform(value, variables) {
9+
return replace(value, variables);
10+
}
11+
}

src/notification/notification-content.interface.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ export interface NotificationContent {
44
target?: string;
55
duration?: number;
66
smart?: boolean;
7-
closeLabel?: string;
7+
closeLabel?: any;
88
message: string;
99
}
1010

src/notification/notification.component.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
import { NotificationContent } from "./notification-content.interface";
1212
import { I18n } from "./../i18n/i18n.module";
1313
import { NotificationDisplayService } from "./notification-display.service";
14+
import { of } from "rxjs";
1415

1516
/**
1617
* Notification messages are displayed toward the top of the UI and do not interrupt user’s work.
@@ -33,7 +34,7 @@ import { NotificationDisplayService } from "./notification-display.service";
3334
<button
3435
(click)="onClose()"
3536
class="bx--inline-notification__close-button"
36-
[attr.aria-label]="notificationObj.closeLabel"
37+
[attr.aria-label]="notificationObj.closeLabel | async"
3738
type="button">
3839
<svg
3940
class="bx--inline-notification__close-icon"
@@ -59,6 +60,9 @@ export class Notification {
5960
return this._notificationObj;
6061
}
6162
set notificationObj(obj: NotificationContent) {
63+
if (obj.closeLabel) {
64+
obj.closeLabel = of(obj.closeLabel);
65+
}
6266
this._notificationObj = Object.assign({}, this.defaultNotificationObj, obj);
6367
}
6468

@@ -87,7 +91,7 @@ export class Notification {
8791
title: "",
8892
message: "",
8993
type: "info",
90-
closeLabel: this.i18n.get().NOTIFICATION.CLOSE_BUTTON
94+
closeLabel: this.i18n.get("NOTIFICATION.CLOSE_BUTTON")
9195
};
9296
protected _notificationObj: NotificationContent = Object.assign({}, this.defaultNotificationObj);
9397

src/notification/notification.stories.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { withKnobs, boolean, object } from "@storybook/addon-knobs/angular";
66
import { Component } from "@angular/core";
77

88
import { NotificationModule, NotificationService } from "./notification.module";
9+
import { I18n } from "../i18n/i18n.module";
910

1011
@Component({
1112
selector: "app-notification-story",

0 commit comments

Comments
 (0)