Skip to content

Commit 4471731

Browse files
authored
Introduce i18n API for the frontend (#631)
1 parent 288602c commit 4471731

21 files changed

+575
-109
lines changed

package-lock.json

+63
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"@babel/helpers": "^7.20.7",
2727
"@babel/preset-typescript": "^7.21.0",
2828
"@babel/traverse": "^7.20.7",
29+
"@messageformat/core": "3.4.0",
2930
"babel-loader": "^9.1.2",
3031
"babel-plugin-import": "^1.13.6",
3132
"react": "18.3.1",
@@ -68,7 +69,7 @@
6869
"jest": {
6970
"rootDir": "src/main/frontend",
7071
"setupFilesAfterEnv": [
71-
"<rootDir>setupTests.js"
72+
"<rootDir>setupTests.ts"
7273
],
7374
"transform": {
7475
"^.+\\.(ts|tsx)$": "ts-jest"

src/main/frontend/common/RestClient.tsx

+22-4
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
1-
import {
2-
Result,
3-
StageInfo,
4-
} from "../pipeline-graph-view/pipeline-graph/main/PipelineGraphModel";
1+
import { Result, StageInfo } from "../pipeline-graph-view/pipeline-graph/main";
2+
import { ResourceBundle } from "./i18n/translations";
53

64
export interface RunStatus {
75
stages: StageInfo[];
@@ -83,3 +81,23 @@ export async function getConsoleTextOffset(
8381
return null;
8482
}
8583
}
84+
85+
export async function getResourceBundle(
86+
resource: string,
87+
): Promise<ResourceBundle | undefined> {
88+
try {
89+
const baseUrl: string = document.head.dataset.rooturl ?? "";
90+
let response = await fetch(
91+
`${baseUrl}/i18n/resourceBundle?baseName=${resource}`,
92+
);
93+
if (!response.ok) {
94+
throw response.statusText;
95+
}
96+
return (await response.json()).data;
97+
} catch (e) {
98+
console.error(
99+
`Caught error when fetching resource bundle ${resource}: '${e}'`,
100+
);
101+
return undefined;
102+
}
103+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { choiceFormatter } from "./choice-formatter";
2+
3+
describe("Choice formatter", () => {
4+
const { formatter: choice } = choiceFormatter;
5+
6+
it("should select right option", () => {
7+
// Base cases
8+
expect(choice("0", "en", "0#first|1#second|1<third")).toEqual("first");
9+
expect(choice("1", "en", "0#first|1#second|1<third")).toEqual("second");
10+
expect(choice("2", "en", "0#first|1#second|1<third")).toEqual("third");
11+
12+
// No match
13+
expect(choice("-1", "en", "0#first|1#second|1<third")).toEqual("first");
14+
15+
// Floating-point numbers
16+
expect(choice("1.5", "en", "1#first|1.5#second|2#third")).toEqual("second");
17+
18+
// Invalid input
19+
expect(choice("abc", "en", "1#first|2#second")).toEqual("first");
20+
21+
// Boundary conditions
22+
expect(choice("1", "en", "1#first|1<second")).toEqual("first");
23+
expect(choice("1.0000000001", "en", "1#one|1<second")).toEqual("second");
24+
25+
// Empty argument
26+
expect(() => choice("1", "en", "")).toThrow();
27+
});
28+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// The library @messageformat/core supports all the Java MessageFormat
2+
// implementation apart from ChoiceFormat which it states is deprecated,
3+
// this is a simple attempt at implementing this without any of the required
4+
// validation as it is expected that this
5+
// would already have happened to be used within Jelly
6+
import { CustomFormatter } from "@messageformat/core";
7+
8+
function nextUp(current: number): number {
9+
if (isNaN(current) || current === Number.POSITIVE_INFINITY) {
10+
return current;
11+
}
12+
if (current === 0) {
13+
return Number.MIN_VALUE;
14+
}
15+
const next = current + Number.EPSILON;
16+
// The final multiplication (current * (1 + Number.EPSILON)) is needed to handle cases where adding
17+
// Number.EPSILON to current does not result in a larger number due to floating-point precision limitations.
18+
// This ensures that the next representable floating-point number greater than current is returned,
19+
// even when current + Number.EPSILON equals current.
20+
return next === current ? current * (1 + Number.EPSILON) : next;
21+
}
22+
23+
type Choice = {
24+
value: string;
25+
limit: number;
26+
};
27+
28+
function choice(value: unknown, locale: string, arg: string | null): string {
29+
const parts = arg!.split("|");
30+
const _value = Number(value);
31+
const choices: Choice[] = [];
32+
// a simple attempt to copy java.text.ChoiceFormat.applyPattern
33+
// we can assume that these are correctly parsed formats as otherwise java code would have complained
34+
// so a part is made up of a number and operator and a value
35+
// the valid operators are <, ≤, # (which means equal)
36+
for (let part of parts) {
37+
// let's iterate through the part until we reach an operator
38+
for (let i = 0; i < part.length; i++) {
39+
const char = part.charAt(i);
40+
if (char === "<" || char === "\u2264" || char === "#") {
41+
const operator = char;
42+
const number = Number(part.substring(0, i));
43+
choices.push({
44+
value: part.substring(i + 1),
45+
limit: operator === "<" ? nextUp(number) : number,
46+
});
47+
break;
48+
}
49+
}
50+
}
51+
// now we copy java.text.ChoiceFormat.format(double, java.lang.StringBuffer, java.text.FieldPosition)
52+
let i = 0;
53+
for (i = 0; i < choices.length; ++i) {
54+
if (!(_value >= choices[i].limit)) {
55+
// same as number < choiceLimits, except catches NaN
56+
break;
57+
}
58+
}
59+
--i;
60+
if (i < 0) {
61+
i = 0;
62+
}
63+
return choices[i].value;
64+
}
65+
66+
export const choiceFormatter: { arg: "string"; formatter: CustomFormatter } = {
67+
arg: "string",
68+
formatter: choice,
69+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import React, {
2+
Context,
3+
createContext,
4+
FunctionComponent,
5+
ReactNode,
6+
useEffect,
7+
useState,
8+
} from "react";
9+
import {
10+
defaultTranslations,
11+
getTranslations,
12+
Translations,
13+
} from "./translations";
14+
15+
export const I18NContext: Context<Translations> = createContext(
16+
new Translations({}),
17+
);
18+
19+
interface I18NProviderProps {
20+
children: ReactNode;
21+
}
22+
23+
export const I18NProvider: FunctionComponent<I18NProviderProps> = ({
24+
children,
25+
}) => {
26+
const locale = document.getElementById("root")?.dataset.userLocale ?? "en";
27+
const [translations, setTranslations] = useState<Translations>(
28+
defaultTranslations(locale),
29+
);
30+
31+
useEffect(() => {
32+
const fetchTranslations = async () => {
33+
const translations = await getTranslations(locale);
34+
setTranslations(translations);
35+
};
36+
fetchTranslations();
37+
}, []);
38+
39+
return (
40+
<I18NContext.Provider value={translations}>{children}</I18NContext.Provider>
41+
);
42+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
jest.mock("../RestClient", () => ({
2+
getResourceBundle: jest.fn(),
3+
}));
4+
5+
import { getResourceBundle } from "../RestClient";
6+
import { getTranslations, messageFormat, Translations } from "./translations";
7+
8+
describe("Translations", () => {
9+
describe("Get translation", () => {
10+
const fmt = messageFormat("en");
11+
12+
const translations = new Translations({
13+
"Property.name": fmt.compile("{arg} world"),
14+
});
15+
16+
it("should use known mapped message", () => {
17+
expect(translations.get("Property.name")({ arg: "hello" })).toEqual(
18+
"hello world",
19+
);
20+
});
21+
22+
it("should use fallback formatter with unknown property", () => {
23+
expect(
24+
translations.get("Unknown.property.name")({ arg: "hello" }),
25+
).toEqual("hello");
26+
});
27+
});
28+
29+
describe("Get Translations", () => {
30+
it("should compile found resource bundle", async () => {
31+
(getResourceBundle as jest.Mock).mockResolvedValue({
32+
"A.property": "a value",
33+
"Another.property": "with another value",
34+
"One.more.property": "with {one} more value",
35+
});
36+
const translations = await getTranslations("en");
37+
38+
expect(getResourceBundle).toHaveBeenCalledWith("hudson.Messages");
39+
expect(getResourceBundle).toHaveBeenCalledWith("hudson.model.Run.index");
40+
expect(translations.get("A.property")()).toEqual("a value");
41+
expect(translations.get("Another.property")()).toEqual(
42+
"with another value",
43+
);
44+
expect(translations.get("One.more.property")({ one: "some" })).toEqual(
45+
"with some more value",
46+
);
47+
});
48+
49+
it("should use the default messages if undefined returned", async () => {
50+
(getResourceBundle as jest.Mock).mockResolvedValue(undefined);
51+
52+
const translations = await getTranslations("en");
53+
54+
expect(translations.get("Util.second")({ 0: 5 })).toEqual("5 sec");
55+
expect(translations.get("Util.day")({ 0: 1 })).toEqual("1 day");
56+
expect(translations.get("Util.day")({ 0: 2 })).toEqual("2 days");
57+
expect(translations.get("A.property")()).toEqual("");
58+
});
59+
});
60+
});

0 commit comments

Comments
 (0)