Skip to content

Commit 473ccc2

Browse files
authored
Encapsulate message format to translations (#680)
1 parent 42d998f commit 473ccc2

File tree

11 files changed

+148
-169
lines changed

11 files changed

+148
-169
lines changed

src/main/frontend/common/RestClient.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import {
22
Result,
33
StageInfo,
44
} from "../pipeline-graph-view/pipeline-graph/main/index.ts";
5-
import { ResourceBundle } from "./i18n/translations.ts";
5+
import { ResourceBundle } from "./i18n/index.ts";
66

77
export interface RunStatus {
88
stages: StageInfo[];

src/main/frontend/common/i18n/i18n-provider.tsx

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,14 @@ import React, {
77
useState,
88
} from "react";
99
import {
10-
defaultTranslations,
11-
getTranslations,
10+
defaultMessages,
11+
getMessages,
1212
ResourceBundleName,
13-
Translations,
14-
} from "./translations.ts";
13+
Messages,
14+
} from "./messages.ts";
1515

16-
export const I18NContext: Context<Translations> = createContext(
17-
new Translations({}),
16+
export const I18NContext: Context<Messages> = createContext(
17+
defaultMessages("en"),
1818
);
1919

2020
interface I18NProviderProps {
@@ -28,19 +28,17 @@ export const I18NProvider: FunctionComponent<I18NProviderProps> = ({
2828
bundles,
2929
locale,
3030
}) => {
31-
const [translations, setTranslations] = useState<Translations>(
32-
defaultTranslations(locale),
33-
);
31+
const [messages, setMessages] = useState<Messages>(defaultMessages(locale));
3432

3533
useEffect(() => {
36-
const fetchTranslations = async () => {
37-
const translations = await getTranslations(locale, bundles);
38-
setTranslations(translations);
34+
const fetchMessages = async () => {
35+
const found = await getMessages(locale, bundles);
36+
setMessages(found);
3937
};
40-
fetchTranslations();
38+
fetchMessages();
4139
}, []);
4240

4341
return (
44-
<I18NContext.Provider value={translations}>{children}</I18NContext.Provider>
42+
<I18NContext.Provider value={messages}>{children}</I18NContext.Provider>
4543
);
4644
};
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export { Messages, ResourceBundleName } from "./messages.ts";
2+
export type { ResourceBundle } from "./messages.ts";
3+
export * from "./i18n-provider.tsx";
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { Mock, vi } from "vitest";
2+
3+
import { getResourceBundle } from "../RestClient.tsx";
4+
import { getMessages, ResourceBundleName, Messages } from "./messages.ts";
5+
6+
vi.mock("../RestClient.tsx", () => ({
7+
getResourceBundle: vi.fn(),
8+
}));
9+
10+
describe("Messages", () => {
11+
describe("Format message", () => {
12+
const messages = new Messages(
13+
{
14+
"Property.name": "{arg} world",
15+
},
16+
"en",
17+
);
18+
19+
it("should use known mapped message", () => {
20+
expect(messages.format("Property.name", { arg: "hello" })).toEqual(
21+
"hello world",
22+
);
23+
});
24+
25+
it("should use fallback formatter with unknown property", () => {
26+
expect(
27+
messages.format("Unknown.property.name", { arg: "hello" }),
28+
).toEqual("hello");
29+
});
30+
});
31+
32+
describe("Get Messages", () => {
33+
it("should compile found resource bundle", async () => {
34+
(getResourceBundle as Mock).mockResolvedValue({
35+
"A.property": "a value",
36+
"Another.property": "with another value",
37+
"One.more.property": "with {one} more value",
38+
});
39+
const messages = await getMessages("en", [ResourceBundleName.messages]);
40+
41+
expect(getResourceBundle).toHaveBeenCalledWith(
42+
"io.jenkins.plugins.pipelinegraphview.Messages",
43+
);
44+
expect(messages.format("A.property")).toEqual("a value");
45+
expect(messages.format("Another.property")).toEqual("with another value");
46+
expect(messages.format("One.more.property", { one: "some" })).toEqual(
47+
"with some more value",
48+
);
49+
});
50+
51+
it("should use the default messages if undefined returned", async () => {
52+
(getResourceBundle as Mock).mockResolvedValue(undefined);
53+
54+
const messages = await getMessages("en", [ResourceBundleName.messages]);
55+
56+
expect(messages.format("Util.second", { 0: 5 })).toEqual("5 sec");
57+
expect(messages.format("Util.day", { 0: 1 })).toEqual("1 day");
58+
expect(messages.format("Util.day", { 0: 2 })).toEqual("2 days");
59+
expect(messages.format("A.property")).toEqual("");
60+
});
61+
});
62+
});

src/main/frontend/common/i18n/translations.ts renamed to src/main/frontend/common/i18n/messages.ts

Lines changed: 26 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,26 @@ import MessageFormat, { MessageFunction } from "@messageformat/core";
22
import { getResourceBundle } from "../RestClient.tsx";
33
import { choiceFormatter } from "./choice-formatter.ts";
44

5-
export interface ResourceBundle {
5+
export type ResourceBundle = {
66
[key: string]: string;
7-
}
8-
9-
interface Message {
10-
[key: string]: MessageFunction<"string">;
11-
}
7+
};
128

13-
export class Translations {
14-
private readonly mapping: Message;
9+
export class Messages {
10+
private readonly mapping: Record<string, MessageFunction<"string">>;
1511

16-
constructor(mapping: Message) {
17-
this.mapping = mapping;
12+
constructor(messages: ResourceBundle, locale: string) {
13+
const entries = Object.entries(messages);
14+
if (entries.length === 0) {
15+
this.mapping = {};
16+
} else {
17+
const fmt = messageFormat(locale);
18+
this.mapping = Object.fromEntries(
19+
entries.map(([key, value]) => [key, fmt.compile(value)]),
20+
);
21+
}
1822
}
1923

20-
get(key: string): MessageFunction<"string"> {
24+
private get(key: string): MessageFunction<"string"> {
2125
const message = this.mapping[key];
2226
if (message != null) {
2327
return message;
@@ -29,9 +33,14 @@ export class Translations {
2933
return params === undefined ? "" : Object.values(params as any).join(" ");
3034
};
3135
}
36+
37+
format(key: string, args: Record<string, any> | null = null): string {
38+
const message = this.get(key);
39+
return args === null ? message() : message(args);
40+
}
3241
}
3342

34-
export function messageFormat(locale: string) {
43+
function messageFormat(locale: string) {
3544
return new MessageFormat(locale, {
3645
customFormatters: {
3746
choice: choiceFormatter,
@@ -43,10 +52,10 @@ export enum ResourceBundleName {
4352
messages = "io.jenkins.plugins.pipelinegraphview.Messages",
4453
}
4554

46-
export async function getTranslations(
55+
export async function getMessages(
4756
locale: string,
4857
bundleNames: ResourceBundleName[],
49-
): Promise<Translations> {
58+
): Promise<Messages> {
5059
const bundles = await Promise.all(
5160
bundleNames.map((name) => getResourceBundle(name).then((r) => r ?? {})),
5261
);
@@ -56,13 +65,7 @@ export async function getTranslations(
5665
DEFAULT_MESSAGES,
5766
);
5867

59-
const fmt = messageFormat(locale);
60-
61-
const mapping: Message = Object.fromEntries(
62-
Object.entries(messages).map(([key, value]) => [key, fmt.compile(value)]),
63-
);
64-
65-
return new Translations(mapping);
68+
return new Messages(messages, locale);
6669
}
6770

6871
const DEFAULT_MESSAGES: ResourceBundle = {
@@ -77,15 +80,6 @@ const DEFAULT_MESSAGES: ResourceBundle = {
7780
noBuilds: "No builds",
7881
};
7982

80-
export function defaultTranslations(locale: string) {
81-
const fmt = messageFormat(locale);
82-
83-
return new Translations(
84-
Object.fromEntries(
85-
Object.entries(DEFAULT_MESSAGES).map(([key, value]) => [
86-
key,
87-
fmt.compile(value),
88-
]),
89-
),
90-
);
83+
export function defaultMessages(locale: string): Messages {
84+
return new Messages(DEFAULT_MESSAGES, locale);
9185
}

src/main/frontend/common/i18n/translations.spec.ts

Lines changed: 0 additions & 72 deletions
This file was deleted.

src/main/frontend/common/utils/timings.spec.tsx

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,29 +4,29 @@ import { vi } from "vitest";
44
import React from "react";
55
import { Paused, Started, Total } from "./timings.tsx";
66
import { render } from "@testing-library/react";
7-
import { I18NContext } from "../i18n/i18n-provider.tsx";
8-
import { Translations } from "../i18n/translations.ts";
9-
import MessageFormat from "@messageformat/core";
7+
import { I18NContext, Messages } from "../i18n/index.ts";
108

119
describe("Timings", () => {
12-
const msg = new MessageFormat("en");
13-
14-
const translations = new Translations({
15-
"Util.year": msg.compile("{0} yr"),
16-
"Util.month": msg.compile("{0} mo"),
17-
"Util.day": msg.compile("{0} day"),
18-
"Util.hour": msg.compile("{0} hr"),
19-
"Util.minute": msg.compile("{0} min"),
20-
"Util.second": msg.compile("{0} sec"),
21-
"Util.millisecond": msg.compile("{0} ms"),
22-
startedAgo: msg.compile("Started {0} ago"),
23-
});
10+
const translations = new Messages(
11+
{
12+
"Util.year": "{0} yr",
13+
"Util.month": "{0} mo",
14+
"Util.day": "{0} day",
15+
"Util.hour": "{0} hr",
16+
"Util.minute": "{0} min",
17+
"Util.second": "{0} sec",
18+
"Util.millisecond": "{0} ms",
19+
startedAgo: "Started {0} ago",
20+
},
21+
"en",
22+
);
2423

2524
function process(child: any) {
2625
return render(
2726
<I18NContext.Provider value={translations}>{child}</I18NContext.Provider>,
2827
);
2928
}
29+
3030
describe("Total", () => {
3131
function getTotal(ms: number) {
3232
return process(<Total ms={ms} />);

0 commit comments

Comments
 (0)