-
-
Notifications
You must be signed in to change notification settings - Fork 73
Introduce i18n API for the frontend #631
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
timja
merged 21 commits into
jenkinsci:main
from
lewisbirks:fix-internationalisation-of-timing
Apr 23, 2025
Merged
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 ac04ff8
introduce react components for i18n
lewisbirks 268fa63
Update tests to work with react components and add more imports to th…
lewisbirks 1c9c335
chore: linting
lewisbirks 4dc0684
Add translations to other pages
lewisbirks c3dfda9
Remove unneedede code
lewisbirks 895bf23
Merge branch 'main' into fix-internationalisation-of-timing
lewisbirks 465a26c
Revert "Add translations to other pages"
lewisbirks 8dee413
Merge remote-tracking branch 'origin/main' into fix-internationalisat…
lewisbirks fe8f46b
add default translations
lewisbirks e0d3147
use users locale in the library
lewisbirks 014a3ff
test the choice formatter
lewisbirks 65a3b15
Merge branch 'main' into fix-internationalisation-of-timing
lewisbirks 9c87c6a
Merge branch 'main' into fix-internationalisation-of-timing
lewisbirks 8e76979
apply code review comments
lewisbirks 73dea4d
remove base url function
lewisbirks 8b76be8
return undefined from getResourceBundle and use defaultTranslations …
lewisbirks c03d2cf
add translations for started xxx ago as well
lewisbirks ad41d46
update started tests
lewisbirks d4a0fe5
Merge remote-tracking branch 'upstream/main' into fix-internationalis…
lewisbirks dbb79e6
Merge branch 'main' into fix-internationalisation-of-timing
lewisbirks File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ); | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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", | ||
| ); | ||
| }); | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.