diff --git a/package-lock.json b/package-lock.json index 663fce518..125be3142 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@babel/helpers": "^7.20.7", "@babel/preset-typescript": "^7.21.0", "@babel/traverse": "^7.20.7", + "@messageformat/core": "3.4.0", "babel-loader": "^9.1.2", "babel-plugin-import": "^1.13.6", "react": "18.3.1", @@ -2453,6 +2454,50 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@messageformat/core": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@messageformat/core/-/core-3.4.0.tgz", + "integrity": "sha512-NgCFubFFIdMWJGN5WuQhHCNmzk7QgiVfrViFxcS99j7F5dDS5EP6raR54I+2ydhe4+5/XTn/YIEppFaqqVWHsw==", + "license": "MIT", + "dependencies": { + "@messageformat/date-skeleton": "^1.0.0", + "@messageformat/number-skeleton": "^1.0.0", + "@messageformat/parser": "^5.1.0", + "@messageformat/runtime": "^3.0.1", + "make-plural": "^7.0.0", + "safe-identifier": "^0.4.1" + } + }, + "node_modules/@messageformat/date-skeleton": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@messageformat/date-skeleton/-/date-skeleton-1.1.0.tgz", + "integrity": "sha512-rmGAfB1tIPER+gh3p/RgA+PVeRE/gxuQ2w4snFWPF5xtb5mbWR7Cbw7wCOftcUypbD6HVoxrVdyyghPm3WzP5A==", + "license": "MIT" + }, + "node_modules/@messageformat/number-skeleton": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@messageformat/number-skeleton/-/number-skeleton-1.2.0.tgz", + "integrity": "sha512-xsgwcL7J7WhlHJ3RNbaVgssaIwcEyFkBqxHdcdaiJzwTZAWEOD8BuUFxnxV9k5S0qHN3v/KzUpq0IUpjH1seRg==", + "license": "MIT" + }, + "node_modules/@messageformat/parser": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@messageformat/parser/-/parser-5.1.1.tgz", + "integrity": "sha512-3p0YRGCcTUCYvBKLIxtDDyrJ0YijGIwrTRu1DT8gIviIDZru8H23+FkY6MJBzM1n9n20CiM4VeDYuBsrrwnLjg==", + "license": "MIT", + "dependencies": { + "moo": "^0.5.1" + } + }, + "node_modules/@messageformat/runtime": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@messageformat/runtime/-/runtime-3.0.1.tgz", + "integrity": "sha512-6RU5ol2lDtO8bD9Yxe6CZkl0DArdv0qkuoZC+ZwowU+cdRlVE1157wjCmlA5Rsf1Xc/brACnsZa5PZpEDfTFFg==", + "license": "MIT", + "dependencies": { + "make-plural": "^7.0.0" + } + }, "node_modules/@parcel/watcher": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.4.1.tgz", @@ -6477,6 +6522,12 @@ "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", "dev": true }, + "node_modules/make-plural": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/make-plural/-/make-plural-7.4.0.tgz", + "integrity": "sha512-4/gC9KVNTV6pvYg2gFeQYTW3mWaoJt7WZE5vrp1KnQDgW92JtYZnzmZT81oj/dUTqAIu0ufI2x3dkgu3bB1tYg==", + "license": "Unicode-DFS-2016" + }, "node_modules/makeerror": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", @@ -6581,6 +6632,12 @@ "node": ">=10" } }, + "node_modules/moo": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.2.tgz", + "integrity": "sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==", + "license": "BSD-3-Clause" + }, "node_modules/mrmime": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz", @@ -7381,6 +7438,12 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true }, + "node_modules/safe-identifier": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/safe-identifier/-/safe-identifier-0.4.2.tgz", + "integrity": "sha512-6pNbSMW6OhAi9j+N8V+U715yBQsaWJ7eyEUaOrawX+isg5ZxhUlV1NipNtgaKHmFGiABwt+ZF04Ii+3Xjkg+8w==", + "license": "ISC" + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", diff --git a/package.json b/package.json index 1d771ea53..43bec9a64 100644 --- a/package.json +++ b/package.json @@ -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", "babel-loader": "^9.1.2", "babel-plugin-import": "^1.13.6", "react": "18.3.1", @@ -68,7 +69,7 @@ "jest": { "rootDir": "src/main/frontend", "setupFilesAfterEnv": [ - "setupTests.js" + "setupTests.ts" ], "transform": { "^.+\\.(ts|tsx)$": "ts-jest" diff --git a/src/main/frontend/common/RestClient.tsx b/src/main/frontend/common/RestClient.tsx index dc970ebcc..9db2fc41e 100644 --- a/src/main/frontend/common/RestClient.tsx +++ b/src/main/frontend/common/RestClient.tsx @@ -1,7 +1,5 @@ -import { - Result, - StageInfo, -} from "../pipeline-graph-view/pipeline-graph/main/PipelineGraphModel"; +import { Result, StageInfo } from "../pipeline-graph-view/pipeline-graph/main"; +import { ResourceBundle } from "./i18n/translations"; export interface RunStatus { stages: StageInfo[]; @@ -83,3 +81,23 @@ export async function getConsoleTextOffset( return null; } } + +export async function getResourceBundle( + resource: string, +): Promise { + try { + const baseUrl: string = document.head.dataset.rooturl ?? ""; + 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 undefined; + } +} diff --git a/src/main/frontend/common/i18n/choice-formatter.spec.ts b/src/main/frontend/common/i18n/choice-formatter.spec.ts new file mode 100644 index 000000000..eb23988a0 --- /dev/null +++ b/src/main/frontend/common/i18n/choice-formatter.spec.ts @@ -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 choice("1", "en", "")).toThrow(); + }); +}); diff --git a/src/main/frontend/common/i18n/choice-formatter.ts b/src/main/frontend/common/i18n/choice-formatter.ts new file mode 100644 index 000000000..cc2080004 --- /dev/null +++ b/src/main/frontend/common/i18n/choice-formatter.ts @@ -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, +}; diff --git a/src/main/frontend/common/i18n/i18n-provider.tsx b/src/main/frontend/common/i18n/i18n-provider.tsx new file mode 100644 index 000000000..e75dd5ec0 --- /dev/null +++ b/src/main/frontend/common/i18n/i18n-provider.tsx @@ -0,0 +1,42 @@ +import React, { + Context, + createContext, + FunctionComponent, + ReactNode, + useEffect, + useState, +} from "react"; +import { + defaultTranslations, + getTranslations, + Translations, +} from "./translations"; + +export const I18NContext: Context = createContext( + new Translations({}), +); + +interface I18NProviderProps { + children: ReactNode; +} + +export const I18NProvider: FunctionComponent = ({ + children, +}) => { + const locale = document.getElementById("root")?.dataset.userLocale ?? "en"; + const [translations, setTranslations] = useState( + defaultTranslations(locale), + ); + + useEffect(() => { + const fetchTranslations = async () => { + const translations = await getTranslations(locale); + setTranslations(translations); + }; + fetchTranslations(); + }, []); + + return ( + {children} + ); +}; diff --git a/src/main/frontend/common/i18n/translations.spec.ts b/src/main/frontend/common/i18n/translations.spec.ts new file mode 100644 index 000000000..58e764573 --- /dev/null +++ b/src/main/frontend/common/i18n/translations.spec.ts @@ -0,0 +1,60 @@ +jest.mock("../RestClient", () => ({ + getResourceBundle: jest.fn(), +})); + +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 () => { + (getResourceBundle as jest.Mock).mockResolvedValue({ + "A.property": "a value", + "Another.property": "with another value", + "One.more.property": "with {one} more value", + }); + const translations = await getTranslations("en"); + + expect(getResourceBundle).toHaveBeenCalledWith("hudson.Messages"); + expect(getResourceBundle).toHaveBeenCalledWith("hudson.model.Run.index"); + 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", + ); + }); + + it("should use the default messages if undefined returned", async () => { + (getResourceBundle as jest.Mock).mockResolvedValue(undefined); + + const translations = await getTranslations("en"); + + expect(translations.get("Util.second")({ 0: 5 })).toEqual("5 sec"); + expect(translations.get("Util.day")({ 0: 1 })).toEqual("1 day"); + expect(translations.get("Util.day")({ 0: 2 })).toEqual("2 days"); + expect(translations.get("A.property")()).toEqual(""); + }); + }); +}); diff --git a/src/main/frontend/common/i18n/translations.ts b/src/main/frontend/common/i18n/translations.ts new file mode 100644 index 000000000..86479d8f5 --- /dev/null +++ b/src/main/frontend/common/i18n/translations.ts @@ -0,0 +1,83 @@ +import MessageFormat, { MessageFunction } from "@messageformat/core"; +import { getResourceBundle } from "../RestClient"; +import { choiceFormatter } from "./choice-formatter"; + +export interface ResourceBundle { + [key: string]: string; +} + +interface Message { + [key: string]: MessageFunction<"string">; +} + +export class Translations { + private readonly mapping: Message; + + constructor(mapping: Message) { + this.mapping = mapping; + } + + get(key: string): MessageFunction<"string"> { + const message = this.mapping[key]; + if (message != null) { + return message; + } + console.debug(`Translation for ${key} not found, using fallback`); + return (params) => { + return params == undefined ? "" : Object.values(params as any).join(" "); + }; + } +} + +export function messageFormat(locale: string) { + return new MessageFormat(locale, { + customFormatters: { + choice: choiceFormatter, + }, + }); +} + +export async function getTranslations(locale: string): Promise { + let [timingMessages, runMessages] = await Promise.all([ + getResourceBundle("hudson.Messages"), + getResourceBundle("hudson.model.Run.index"), + ]); + + const messages = { + ...DEFAULT_MESSAGES, + ...timingMessages, + ...runMessages, + }; + + const fmt = messageFormat(locale); + + const mapping: Message = Object.fromEntries( + Object.entries(messages).map(([key, value]) => [key, fmt.compile(value)]), + ); + + return new Translations(mapping); +} + +const DEFAULT_MESSAGES: ResourceBundle = { + "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 [ + key, + fmt.compile(value), + ]), + ), + ); +} diff --git a/src/main/frontend/common/utils/timings.spec.ts b/src/main/frontend/common/utils/timings.spec.ts deleted file mode 100644 index 04c1faca1..000000000 --- a/src/main/frontend/common/utils/timings.spec.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { total, paused, started } from "./timings"; - -describe("Timings", () => { - describe("Total", () => { - it("should format milliseconds to hours, minutes, and seconds", () => { - // First check 359 days. - expect(total(31_017_600_000)).toBe("11 mo"); - // And 362 days. - expect(total(31_276_800_000)).toBe("12 mo"); - // 11.25 years - Check that if the first unit has 2 or more digits, a second unit isn't used. - expect(total(354_780_000_000)).toBe("11 yr"); - // 9.25 years - Check that if the first unit has only 1 digit, a second unit is used. - expect(total(291_708_000_000)).toBe("9 yr 3 mo"); - // 3 months 14 days - expect(total(8_985_600_000)).toBe("3 mo 14 day"); - // 2 day 4 hours - expect(total(187_200_000)).toBe("2 day 4 hr"); - // 8 hours 46 minutes - expect(total(31_560_000)).toBe("8 hr 46 min"); - // 67 seconds -> 1 minute 7 seconds - expect(total(67_000)).toBe("1 min 7 sec"); - // 17 seconds - Check that times less than a minute only use seconds. - expect(total(17_000)).toBe("17 sec"); - // 1712ms -> 1.7sec - expect(total(1_712)).toBe("1.7 sec"); - // 171ms -> 0.17sec - expect(total(171)).toBe("0.17 sec"); - // 101ms -> 0.10sec - expect(total(101)).toBe("0.1 sec"); - // 17ms - expect(total(17)).toBe("17 ms"); - // 1ms - expect(total(1)).toBe("1 ms"); - }); - }); - - describe("paused", () => { - it("should prefix the time with Queued", () => { - expect(paused(1000)).toBe("Queued 1 sec"); - expect(paused(100)).toBe("Queued 0.1 sec"); - expect(paused(10)).toBe("Queued 10 ms"); - expect(paused(1)).toBe("Queued 1 ms"); - }); - }); - - describe("started", () => { - const now = Date.now(); - - beforeEach(() => { - jest.useFakeTimers().setSystemTime(now); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it("should return empty string if since is 0", () => { - expect(started(0)).toBe(""); - }); - - it("should prefix the time with Started and end with ago", () => { - expect(started(now - 1000)).toBe("Started 1 sec ago"); - expect(started(now - 100)).toBe("Started 0.1 sec ago"); - expect(started(now - 10)).toBe("Started 10 ms ago"); - expect(started(now - 1)).toBe("Started 1 ms ago"); - }); - }); -}); diff --git a/src/main/frontend/common/utils/timings.spec.tsx b/src/main/frontend/common/utils/timings.spec.tsx new file mode 100644 index 000000000..19749aedb --- /dev/null +++ b/src/main/frontend/common/utils/timings.spec.tsx @@ -0,0 +1,117 @@ +/** * @jest-environment jsdom */ + +import React from "react"; +import { Paused, Started, Total } from "./timings"; +import { render } from "@testing-library/react"; +import { I18NContext } from "../i18n/i18n-provider"; +import { Translations } from "../i18n/translations"; +import MessageFormat from "@messageformat/core"; + +describe("Timings", () => { + const msg = new MessageFormat("en"); + + const translations = new Translations({ + "Util.year": msg.compile("{0} yr"), + "Util.month": msg.compile("{0} mo"), + "Util.day": msg.compile("{0} day"), + "Util.hour": msg.compile("{0} hr"), + "Util.minute": msg.compile("{0} min"), + "Util.second": msg.compile("{0} sec"), + "Util.millisecond": msg.compile("{0} ms"), + startedAgo: msg.compile("Started {0} ago"), + }); + + function process(child: any) { + return render( + {child}, + ); + } + describe("Total", () => { + function getTotal(ms: number) { + return process(); + } + + it("should format milliseconds to hours, minutes, and seconds", () => { + // First check 359 days. + expect(getTotal(31_017_600_000).getByText("11 mo")).toBeInTheDocument(); + // And 362 days. + expect(getTotal(31_276_800_000).getByText("12 mo")).toBeInTheDocument(); + // 11.25 years - Check that if the first unit has 2 or more digits, a second unit isn't used. + expect(getTotal(354_780_000_000).getByText("11 yr")).toBeInTheDocument(); + // 9.25 years - Check that if the first unit has only 1 digit, a second unit is used. + expect( + getTotal(291_708_000_000).getByText("9 yr 3 mo"), + ).toBeInTheDocument(); + // 3 months 14 days + expect( + getTotal(8_985_600_000).getByText("3 mo 14 day"), + ).toBeInTheDocument(); + // 2 day 4 hours + expect(getTotal(187_200_000).getByText("2 day 4 hr")).toBeInTheDocument(); + // 8 hours 46 minutes + expect(getTotal(31_560_000).getByText("8 hr 46 min")).toBeInTheDocument(); + // 67 seconds -> 1 minute 7 seconds + expect(getTotal(67_000).getByText("1 min 7 sec")).toBeInTheDocument(); + // 17 seconds - Check that times less than a minute only use seconds. + expect(getTotal(17_000).getByText("17 sec")).toBeInTheDocument(); + // 1712ms -> 1.7sec + expect(getTotal(1_712).getByText("1.7 sec")).toBeInTheDocument(); + // 171ms -> 0.17sec + expect(getTotal(171).getByText("0.17 sec")).toBeInTheDocument(); + // 101ms -> 0.10sec + expect(getTotal(101).getByText("0.1 sec")).toBeInTheDocument(); + // 17ms + expect(getTotal(17).getByText("17 ms")).toBeInTheDocument(); + // 1ms + expect(getTotal(1).getByText("1 ms")).toBeInTheDocument(); + }); + }); + + describe("paused", () => { + function getPaused(since: number) { + return process(); + } + + it("should prefix the time with Queued", () => { + expect(getPaused(1000).getByText("Queued 1 sec")).toBeInTheDocument(); + expect(getPaused(100).getByText("Queued 0.1 sec")).toBeInTheDocument(); + expect(getPaused(10).getByText("Queued 10 ms")).toBeInTheDocument(); + expect(getPaused(1).getByText("Queued 1 ms")).toBeInTheDocument(); + }); + }); + + describe("started", () => { + const now = Date.now(); + + beforeEach(() => { + jest.useFakeTimers().setSystemTime(now); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + function getStarted(since: number) { + return process(); + } + + it("should return empty element if since is 0", () => { + expect(getStarted(0).container.innerHTML).toBe(""); + }); + + it("should prefix the time with Started and end with ago", () => { + expect( + getStarted(now - 1000).getByText("Started 1 sec ago"), + ).toBeInTheDocument(); + expect( + getStarted(now - 100).getByText("Started 0.1 sec ago"), + ).toBeInTheDocument(); + expect( + getStarted(now - 10).getByText("Started 10 ms ago"), + ).toBeInTheDocument(); + expect( + getStarted(now - 1).getByText("Started 1 ms ago"), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/src/main/frontend/common/utils/timings.ts b/src/main/frontend/common/utils/timings.tsx similarity index 53% rename from src/main/frontend/common/utils/timings.ts rename to src/main/frontend/common/utils/timings.tsx index 6e5ff5773..41f3ce170 100644 --- a/src/main/frontend/common/utils/timings.ts +++ b/src/main/frontend/common/utils/timings.tsx @@ -1,3 +1,8 @@ +import React from "react"; +import { useContext } from "react"; +import { Translations } from "../i18n/translations"; +import { I18NContext } from "../i18n/i18n-provider"; + const ONE_SECOND_MS: number = 1000; const ONE_MINUTE_MS: number = 60 * ONE_SECOND_MS; const ONE_HOUR_MS: number = 60 * ONE_MINUTE_MS; @@ -5,7 +10,14 @@ const ONE_DAY_MS: number = 24 * ONE_HOUR_MS; const ONE_MONTH_MS: number = 30 * ONE_DAY_MS; const ONE_YEAR_MS: number = 365 * ONE_DAY_MS; -// TODO: 16/04/2025 How to support i18n like the methods this was copied from +const YEAR = "Util.year"; +const MONTH = "Util.month"; +const HOURS = "Util.hour"; +const DAY = "Util.day"; +const MINUTE = "Util.minute"; +const SECOND = "Util.second"; +const MILLIS = "Util.millisecond"; +const STARTED_AGO = "startedAgo"; /** * Create a string representation of a time duration. @@ -29,8 +41,12 @@ function makeTimeSpanString( * @see https://github.com/jenkinsci/jenkins/blob/f9edeb0c0485fddfc03a7e1710ac5cf2b35ec497/core/src/main/java/hudson/Util.java#L734 * * @param duration number of milliseconds. + * @param translations the translations to get the labels from. */ -function getTimeSpanString(duration: number): string { +function getTimeSpanString( + duration: number, + translations: Translations, +): string { const years = Math.floor(duration / ONE_YEAR_MS); duration %= ONE_YEAR_MS; const months = Math.floor(duration / ONE_MONTH_MS); @@ -42,46 +58,79 @@ function getTimeSpanString(duration: number): string { const minutes = Math.floor(duration / ONE_MINUTE_MS); duration %= ONE_MINUTE_MS; const seconds = Math.floor(duration / ONE_SECOND_MS); - const millis = duration % ONE_SECOND_MS; + const millis = duration % ONE_SECOND_MS; if (years > 0) { - return makeTimeSpanString(years, `${years} yr`, months, `${months} mo`); + return makeTimeSpanString( + years, + translations.get(YEAR)({ "0": years }), + months, + translations.get(MONTH)({ "0": months }), + ); } else if (months > 0) { - return makeTimeSpanString(months, `${months} mo`, days, `${days} day`); + return makeTimeSpanString( + months, + translations.get(MONTH)({ "0": months }), + days, + translations.get(DAY)({ "0": days }), + ); } else if (days > 0) { - return makeTimeSpanString(days, `${days} day`, hours, `${hours} hr`); + return makeTimeSpanString( + days, + translations.get(DAY)({ "0": days }), + hours, + translations.get(HOURS)({ "0": hours }), + ); } else if (hours > 0) { - return makeTimeSpanString(hours, `${hours} hr`, minutes, `${minutes} min`); + return makeTimeSpanString( + hours, + translations.get(HOURS)({ "0": hours }), + minutes, + translations.get(MINUTE)({ "0": minutes }), + ); } else if (minutes > 0) { return makeTimeSpanString( minutes, - `${minutes} min`, + translations.get(MINUTE)({ "0": minutes }), seconds, - `${seconds} sec`, + translations.get(SECOND)({ "0": seconds }), ); } else if (seconds >= 10) { - return `${seconds} sec`; + return translations.get(SECOND)({ "0": seconds }); } else if (seconds >= 1) { - return `${seconds + Math.floor(millis / 100) / 10} sec`; + return translations.get(SECOND)({ + "0": seconds + Math.floor(millis / 100) / 10, + }); } else if (millis >= 100) { - return `${Math.floor(millis / 10) / 100} sec`; + return translations.get(SECOND)({ "0": Math.floor(millis / 10) / 100 }); } else { - return `${millis} ms`; + return translations.get(MILLIS)({ "0": millis }); } } -export function total(ms: number): string { - return `${getTimeSpanString(ms)}`; +export function Total({ ms }: { ms: number }) { + const translations = useContext(I18NContext); + return <>{getTimeSpanString(ms, translations)}; } -export function paused(since: number): string { - return `Queued ${getTimeSpanString(since)}`; +export function Paused({ since }: { since: number }) { + const translations = useContext(I18NContext); + return <>{`Queued ${getTimeSpanString(since, translations)}`}; } -export function started(since: number): string { - return since == 0 - ? "" - : `Started ${getTimeSpanString(Math.abs(since - Date.now()))} ago`; +export function Started({ since }: { since: number }) { + const translations = useContext(I18NContext); + if (since == 0) { + return <>; + } + + return ( + <> + {translations.get(STARTED_AGO)({ + "0": getTimeSpanString(Math.abs(since - Date.now()), translations), + })} + + ); } export function exact(since: number): string { diff --git a/src/main/frontend/pipeline-console-view/app.tsx b/src/main/frontend/pipeline-console-view/app.tsx index 2939b79a2..8620e7462 100644 --- a/src/main/frontend/pipeline-console-view/app.tsx +++ b/src/main/frontend/pipeline-console-view/app.tsx @@ -1,10 +1,16 @@ import React from "react"; import { lazy } from "react"; +import { I18NProvider } from "../common/i18n/i18n-provider"; + const PipelineConsole = lazy( () => import("./pipeline-console/main/PipelineConsole"), ); export default function App() { - return ; + return ( + + + + ); } diff --git a/src/main/frontend/pipeline-console-view/pipeline-console/main/ConsoleLogCard.spec.tsx b/src/main/frontend/pipeline-console-view/pipeline-console/main/ConsoleLogCard.spec.tsx index a9237e6ae..262c47a54 100644 --- a/src/main/frontend/pipeline-console-view/pipeline-console/main/ConsoleLogCard.spec.tsx +++ b/src/main/frontend/pipeline-console-view/pipeline-console/main/ConsoleLogCard.spec.tsx @@ -2,7 +2,6 @@ (global as any).TextEncoder = require("util").TextEncoder; -import "@testing-library/jest-dom"; import React from "react"; import ConsoleLogCard, { ConsoleLogCardProps } from "./ConsoleLogCard"; import { ConsoleLogStreamProps } from "./ConsoleLogStream"; diff --git a/src/main/frontend/pipeline-console-view/pipeline-console/main/ConsoleLogCard.tsx b/src/main/frontend/pipeline-console-view/pipeline-console/main/ConsoleLogCard.tsx index c1b1f0e8f..a85fb7089 100644 --- a/src/main/frontend/pipeline-console-view/pipeline-console/main/ConsoleLogCard.tsx +++ b/src/main/frontend/pipeline-console-view/pipeline-console/main/ConsoleLogCard.tsx @@ -8,9 +8,9 @@ import { } from "./PipelineConsoleModel"; import StatusIcon from "../../../common/components/status-icon"; -import { total } from "../../../common/utils/timings"; import { classNames } from "../../../common/utils/classnames"; import Tooltip from "../../../common/components/tooltip"; +import { Total } from "../../../common/utils/timings"; const ConsoleLogStream = React.lazy(() => import("./ConsoleLogStream")); @@ -113,7 +113,7 @@ export default function ConsoleLogCard(props: ConsoleLogCardProps) { color: "var(--text-color-secondary)", }} > - {total(props.step.totalDurationMillis)} + diff --git a/src/main/frontend/pipeline-console-view/pipeline-console/main/ConsoleLogStream.spec.tsx b/src/main/frontend/pipeline-console-view/pipeline-console/main/ConsoleLogStream.spec.tsx index a519ecd29..99e955a8d 100644 --- a/src/main/frontend/pipeline-console-view/pipeline-console/main/ConsoleLogStream.spec.tsx +++ b/src/main/frontend/pipeline-console-view/pipeline-console/main/ConsoleLogStream.spec.tsx @@ -1,6 +1,5 @@ /** * @jest-environment jsdom */ -import "@testing-library/jest-dom"; import React, { ReactElement } from "react"; import ConsoleLogStream, { ConsoleLogStreamProps } from "./ConsoleLogStream"; import { Result, StepInfo, StepLogBufferInfo } from "./PipelineConsoleModel"; diff --git a/src/main/frontend/pipeline-console-view/pipeline-console/main/DataTreeView.tsx b/src/main/frontend/pipeline-console-view/pipeline-console/main/DataTreeView.tsx index d4ae4e5e0..666ea16ba 100644 --- a/src/main/frontend/pipeline-console-view/pipeline-console/main/DataTreeView.tsx +++ b/src/main/frontend/pipeline-console-view/pipeline-console/main/DataTreeView.tsx @@ -4,7 +4,7 @@ import { StageInfo, } from "../../../pipeline-graph-view/pipeline-graph/main/"; import "./data-tree-view.scss"; -import { total } from "../../../common/utils/timings"; +import { Total } from "../../../common/utils/timings"; import StatusIcon from "../../../common/components/status-icon"; import { classNames } from "../../../common/utils/classnames"; @@ -87,7 +87,7 @@ const TreeNode = React.memo(({ stage, selected, onSelect }: TreeNodeProps) => { {stage.name} {stage.state === Result.running && ( - {total(stage.totalDurationMillis)} + )} diff --git a/src/main/frontend/pipeline-console-view/pipeline-console/main/stage-details.spec.tsx b/src/main/frontend/pipeline-console-view/pipeline-console/main/stage-details.spec.tsx index 192960bb4..13453bb16 100644 --- a/src/main/frontend/pipeline-console-view/pipeline-console/main/stage-details.spec.tsx +++ b/src/main/frontend/pipeline-console-view/pipeline-console/main/stage-details.spec.tsx @@ -8,7 +8,6 @@ import { Result, StageInfo, } from "../../../pipeline-graph-view/pipeline-graph/main"; -import { paused } from "../../../common/utils/timings"; import StageDetails from "./stage-details"; describe("StageDetails", () => { @@ -41,7 +40,7 @@ describe("StageDetails", () => { it("does not show pause time if pauseDurationMillis is 0", () => { render(); - expect(screen.queryByText(paused(0))).not.toBeInTheDocument(); + expect(screen.queryByText("Queued")).not.toBeInTheDocument(); }); it("disables dropdown if stage is synthetic", () => { diff --git a/src/main/frontend/pipeline-console-view/pipeline-console/main/stage-details.tsx b/src/main/frontend/pipeline-console-view/pipeline-console/main/stage-details.tsx index ca2221b76..b782e7997 100644 --- a/src/main/frontend/pipeline-console-view/pipeline-console/main/stage-details.tsx +++ b/src/main/frontend/pipeline-console-view/pipeline-console/main/stage-details.tsx @@ -5,7 +5,7 @@ import StageNodeLink from "./StageNodeLink"; import StatusIcon, { resultToColor, } from "../../../common/components/status-icon"; -import { exact, paused, started, total } from "../../../common/utils/timings"; +import { exact, Paused, Started, Total } from "../../../common/utils/timings"; import Dropdown from "../../../common/components/dropdown"; import Tooltip from "../../../common/components/tooltip"; @@ -47,7 +47,7 @@ export default function StageDetails({ stage }: StageDetailsProps) { fill="currentColor" /> - {total(stage.totalDurationMillis)} +
  • @@ -68,7 +68,7 @@ export default function StageDetails({ stage }: StageDetailsProps) { d="M256 128v144h96" /> - {started(stage.startTimeMillis)} +
  • {stage.pauseDurationMillis !== 0 && ( @@ -87,7 +87,7 @@ export default function StageDetails({ stage }: StageDetailsProps) { fill="currentColor" /> - {paused(stage.pauseDurationMillis)} + )} diff --git a/src/main/frontend/setupTests.js b/src/main/frontend/setupTests.js deleted file mode 100644 index 9e785813a..000000000 --- a/src/main/frontend/setupTests.js +++ /dev/null @@ -1 +0,0 @@ -require("@testing-library/jest-dom"); diff --git a/src/main/frontend/setupTests.ts b/src/main/frontend/setupTests.ts new file mode 100644 index 000000000..d0de870dc --- /dev/null +++ b/src/main/frontend/setupTests.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom"; diff --git a/src/main/resources/io/jenkins/plugins/pipelinegraphview/consoleview/PipelineConsoleViewAction/index.jelly b/src/main/resources/io/jenkins/plugins/pipelinegraphview/consoleview/PipelineConsoleViewAction/index.jelly index 8e93e0bb8..c079b02dc 100644 --- a/src/main/resources/io/jenkins/plugins/pipelinegraphview/consoleview/PipelineConsoleViewAction/index.jelly +++ b/src/main/resources/io/jenkins/plugins/pipelinegraphview/consoleview/PipelineConsoleViewAction/index.jelly @@ -56,7 +56,8 @@

    ${it.iconColor.description} ${it.startTimeString} ago in ${it.durationString}

    + data-previous-run-path="${it.previousBuildUrl != null ? rootURL + '/' + it.previousBuildUrl : null}" + data-user-locale="${request2.getLocale().toLanguageTag()}"/>