Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
efc39f4
Add a new endpoint to retrieve the message bundle and call it in TS
lewisbirks Apr 19, 2025
ac04ff8
introduce react components for i18n
lewisbirks Apr 22, 2025
268fa63
Update tests to work with react components and add more imports to th…
lewisbirks Apr 22, 2025
1c9c335
chore: linting
lewisbirks Apr 22, 2025
4dc0684
Add translations to other pages
lewisbirks Apr 23, 2025
c3dfda9
Remove unneedede code
lewisbirks Apr 23, 2025
895bf23
Merge branch 'main' into fix-internationalisation-of-timing
lewisbirks Apr 23, 2025
465a26c
Revert "Add translations to other pages"
lewisbirks Apr 23, 2025
8dee413
Merge remote-tracking branch 'origin/main' into fix-internationalisat…
lewisbirks Apr 23, 2025
fe8f46b
add default translations
lewisbirks Apr 23, 2025
e0d3147
use users locale in the library
lewisbirks Apr 23, 2025
014a3ff
test the choice formatter
lewisbirks Apr 23, 2025
65a3b15
Merge branch 'main' into fix-internationalisation-of-timing
lewisbirks Apr 23, 2025
9c87c6a
Merge branch 'main' into fix-internationalisation-of-timing
lewisbirks Apr 23, 2025
8e76979
apply code review comments
lewisbirks Apr 23, 2025
73dea4d
remove base url function
lewisbirks Apr 23, 2025
8b76be8
return undefined from getResourceBundle and use defaultTranslations …
lewisbirks Apr 23, 2025
c03d2cf
add translations for started xxx ago as well
lewisbirks Apr 23, 2025
ad41d46
update started tests
lewisbirks Apr 23, 2025
d4a0fe5
Merge remote-tracking branch 'upstream/main' into fix-internationalis…
lewisbirks Apr 23, 2025
dbb79e6
Merge branch 'main' into fix-internationalisation-of-timing
lewisbirks Apr 23, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"@babel/helpers": "^7.20.7",
"@babel/preset-typescript": "^7.21.0",
"@babel/traverse": "^7.20.7",
"@messageformat/core": "3.4.0",
"@react-spring/web": "9.7.5",
"babel-loader": "^9.1.2",
"babel-plugin-import": "^1.13.6",
Expand Down Expand Up @@ -73,7 +74,7 @@
"jest": {
"rootDir": "src/main/frontend",
"setupFilesAfterEnv": [
"<rootDir>setupTests.js"
"<rootDir>setupTests.ts"
],
"transform": {
"^.+\\.(ts|tsx)$": "ts-jest"
Expand Down
29 changes: 25 additions & 4 deletions src/main/frontend/common/RestClient.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
import {
Result,
StageInfo,
} from "../pipeline-graph-view/pipeline-graph/main/PipelineGraphModel";
import { Result, StageInfo } from "../pipeline-graph-view/pipeline-graph/main";

export interface RunStatus {
stages: StageInfo[];
Expand Down Expand Up @@ -83,3 +80,27 @@ export async function getConsoleTextOffset(
return null;
}
}

const baseUrl = (): string =>
document.getElementsByTagName("head")[0].getAttribute("data-rooturl") ?? "";

export interface ResourceBundle {
[key: string]: string;
}

export async function getResourceBundle(
resource: string,
): Promise<ResourceBundle> {
try {
let response = await fetch(
`${baseUrl()}/i18n/resourceBundle?baseName=${resource}`,
);
if (!response.ok) throw response.statusText;
return (await response.json()).data;
} catch (e) {
console.error(
`Caught error when fetching resource bundle ${resource}: '${e}'`,
);
return {};
}
}
28 changes: 28 additions & 0 deletions src/main/frontend/common/i18n/choice-formatter.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { choiceFormatter } from "./choice-formatter";

describe("Choice formatter", () => {
const { formatter: choice } = choiceFormatter;

it("should select right option", () => {
// Base cases
expect(choice("0", "en", "0#first|1#second|1<third")).toEqual("first");
expect(choice("1", "en", "0#first|1#second|1<third")).toEqual("second");
expect(choice("2", "en", "0#first|1#second|1<third")).toEqual("third");

// No match
expect(choice("-1", "en", "0#first|1#second|1<third")).toEqual("first");

// Floating-point numbers
expect(choice("1.5", "en", "1#first|1.5#second|2#third")).toEqual("second");

// Invalid input
expect(choice("abc", "en", "1#first|2#second")).toEqual("first");

// Boundary conditions
expect(choice("1", "en", "1#first|1<second")).toEqual("first");
expect(choice("1.0000000001", "en", "1#one|1<second")).toEqual("second");

// Empty argument
expect(() => choice("1", "en", "")).toThrow();
});
});
69 changes: 69 additions & 0 deletions src/main/frontend/common/i18n/choice-formatter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// The library @messageformat/core supports all the Java MessageFormat
// implementation apart from ChoiceFormat which it states is deprecated,
// this is a simple attempt at implementing this without any of the required
// validation as it is expected that this
// would already have happened to be used within Jelly
import { CustomFormatter } from "@messageformat/core";

function nextUp(current: number): number {
if (isNaN(current) || current === Number.POSITIVE_INFINITY) {
return current;
}
if (current === 0) {
return Number.MIN_VALUE;
}
const next = current + Number.EPSILON;
// The final multiplication (current * (1 + Number.EPSILON)) is needed to handle cases where adding
// Number.EPSILON to current does not result in a larger number due to floating-point precision limitations.
// This ensures that the next representable floating-point number greater than current is returned,
// even when current + Number.EPSILON equals current.
return next === current ? current * (1 + Number.EPSILON) : next;
}

type Choice = {
value: string;
limit: number;
};

function choice(value: unknown, locale: string, arg: string | null): string {
const parts = arg!.split("|");
const _value = Number(value);
const choices: Choice[] = [];
// a simple attempt to copy java.text.ChoiceFormat.applyPattern
// we can assume that these are correctly parsed formats as otherwise java code would have complained
// so a part is made up of a number and operator and a value
// the valid operators are <, ≤, # (which means equal)
for (let part of parts) {
// let's iterate through the part until we reach an operator
for (let i = 0; i < part.length; i++) {
const char = part.charAt(i);
if (char === "<" || char === "\u2264" || char === "#") {
const operator = char;
const number = Number(part.substring(0, i));
choices.push({
value: part.substring(i + 1),
limit: operator === "<" ? nextUp(number) : number,
});
break;
}
}
}
// now we copy java.text.ChoiceFormat.format(double, java.lang.StringBuffer, java.text.FieldPosition)
let i = 0;
for (i = 0; i < choices.length; ++i) {
if (!(_value >= choices[i].limit)) {
// same as number < choiceLimits, except catches NaN
break;
}
}
--i;
if (i < 0) {
i = 0;
}
return choices[i].value;
}

export const choiceFormatter: { arg: "string"; formatter: CustomFormatter } = {
arg: "string",
formatter: choice,
};
60 changes: 60 additions & 0 deletions src/main/frontend/common/i18n/i18n-provider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import React, {
Context,
createContext,
FunctionComponent,
ReactNode,
useEffect,
useState,
} from "react";
import { getTranslations, Translations, messageFormat } from "./translations";

export const I18NContext: Context<Translations> = createContext(
new Translations({}),
);

interface I18NProviderProps {
children: ReactNode;
}

function defaultTranslations() {
const fmt = messageFormat("en");
const messages = {
"Util.millisecond": "{0} ms",
"Util.second": "{0} sec",
"Util.minute": "{0} min",
"Util.hour": "{0} hr",
"Util.day": "{0} {0,choice,0#days|1#day|1<days}",
"Util.month": "{0} mo",
"Util.year": "{0} yr",
};

return new Translations(
Object.fromEntries(
Object.entries(messages).map(([key, value]) => [key, fmt.compile(value)]),
),
);
}

export const I18NProvider: FunctionComponent<I18NProviderProps> = ({
children,
}) => {
const locale =
document
.querySelector("div[data-user-locale]")
?.getAttribute("data-user-locale") ?? "en";
const [translations, setTranslations] = useState<Translations>(
defaultTranslations(),
);

useEffect(() => {
const fetchTranslations = async () => {
const translations = await getTranslations(locale);
setTranslations(translations);
};
fetchTranslations();
}, []);

return (
<I18NContext.Provider value={translations}>{children}</I18NContext.Provider>
);
};
47 changes: 47 additions & 0 deletions src/main/frontend/common/i18n/translations.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
jest.mock("../RestClient", () => ({
getResourceBundle: jest.fn().mockResolvedValue({
"A.property": "a value",
"Another.property": "with another value",
"One.more.property": "with {one} more value",
}),
}));

import { getResourceBundle } from "../RestClient";
import { getTranslations, messageFormat, Translations } from "./translations";

describe("Translations", () => {
describe("Get translation", () => {
const fmt = messageFormat("en");

const translations = new Translations({
"Property.name": fmt.compile("{arg} world"),
});

it("should use known mapped message", () => {
expect(translations.get("Property.name")({ arg: "hello" })).toEqual(
"hello world",
);
});

it("should use fallback formatter with unknown property", () => {
expect(
translations.get("Unknown.property.name")({ arg: "hello" }),
).toEqual("hello");
});
});

describe("Get Translations", () => {
it("should compile found resource bundle", async () => {
const translations = await getTranslations("en");

expect(getResourceBundle).toBeCalledWith("hudson.Messages");
expect(translations.get("A.property")()).toEqual("a value");
expect(translations.get("Another.property")()).toEqual(
"with another value",
);
expect(translations.get("One.more.property")({ one: "some" })).toEqual(
"with some more value",
);
});
});
});
Loading