Skip to content

Commit

Permalink
Integrate @formatjs/intl as a replacement for t() (uyuni-project#…
Browse files Browse the repository at this point in the history
…7296)

* Initial implementation

* Update docs

* Test global binding

* Cleanup

* Update translations to new format

* Upgrade more translations

* Update more translations

* Add tests, clean up

* Deprecate test-utils t()

* Update licenses

* Cleanup

* Update commentary

* Fix pagination translation

* Refactor preferred locale

* Cleanup

* Add changes

* Cleanup
  • Loading branch information
Etheryte authored and nodeg committed Aug 4, 2023
1 parent 7feeef8 commit 95df108
Show file tree
Hide file tree
Showing 55 changed files with 663 additions and 260 deletions.
2 changes: 1 addition & 1 deletion web/html/src/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ module.exports = {
["^\\u0000"],
// HMR needs to be imported before everything else
["^react-hot-loader/root"],
["^react$"],
["^react$", "^react-dom$"],
// Fullcalendar needs to be imported before its plugins
["^@fullcalendar/react"],
// Packages
Expand Down
11 changes: 5 additions & 6 deletions web/html/src/components/FormulaForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,10 +134,9 @@ class FormulaForm extends React.Component<Props, State> {
else {
if (data.formula_list.filter((formula) => formula === data.formula_name).length > 1) {
this.state.warnings.push(
t(
'Multiple Group formulas detected. Only one formula for "{0}" can be used on each system!',
capitalize(data.formula_name)
)
t('Multiple Group formulas detected. Only one formula for "{name}" can be used on each system!', {
name: capitalize(data.formula_name),
})
);
}
const rawLayout = data.layout;
Expand Down Expand Up @@ -166,10 +165,10 @@ class FormulaForm extends React.Component<Props, State> {
if (data.errors) {
const messages: string[] = [];
if (data.errors.required && data.errors.required.length > 0) {
messages.push(t("Please input required fields: {0}", data.errors.required.join(", ")));
messages.push(t("Please input required fields: {fields}", { fields: data.errors.required.join(", ") }));
}
if (data.errors.invalid && data.errors.invalid.length > 0) {
messages.push(t("Invalid format of fields: {0}", data.errors.invalid.join(", ")));
messages.push(t("Invalid format of fields: {fields}", { fields: data.errors.invalid.join(", ") }));
}
this.setState({
messages: [],
Expand Down
1 change: 0 additions & 1 deletion web/html/src/components/ace-editor.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import * as React from "react";

import ReactDOM from "react-dom";

type Props = {
Expand Down
31 changes: 16 additions & 15 deletions web/html/src/components/dialog/ActionConfirm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,26 +68,27 @@ export class ActionConfirm extends React.Component<Props, State> {
)}
{this.props.selected.length === 1 && (
<span>
{t(
"Are you sure you want to {0} {1} ",
this.state.force && this.props.forceName
? this.props.forceName.toLowerCase()
: this.props.name.toLowerCase(),
this.props.itemName.toLowerCase()
)}
{/* TODO: Here and below, this translation logic needs to be changed to whole sentences from parents */}
{t("Are you sure you want to {action} {name}", {
action:
this.state.force && this.props.forceName
? this.props.forceName.toLowerCase()
: this.props.name.toLowerCase(),
name: this.props.itemName.toLowerCase(),
})}
<strong>{this.props.selected[0].name}</strong>?
</span>
)}
{this.props.selected.length > 1 && (
<span>
{t(
"Are you sure you want to {0} the selected {1}s? ({2} {1}s selected)",
this.state.force && this.props.forceName
? this.props.forceName.toLowerCase()
: this.props.name.toLowerCase(),
this.props.itemName.toLowerCase(),
this.props.selected.length
)}
{t("Are you sure you want to {action} the selected {name}s? ({count} {name}s selected)", {
action:
this.state.force && this.props.forceName
? this.props.forceName.toLowerCase()
: this.props.name.toLowerCase(),
name: this.props.itemName.toLowerCase(),
count: this.props.selected.length,
})}
?
</span>
)}
Expand Down
14 changes: 9 additions & 5 deletions web/html/src/components/package/PackageListActionScheduler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,13 +79,17 @@ export class PackageListActionScheduler extends React.Component<Props, State> {
const msg = MessagesUtils.info(
this.state.actionChain ? (
<span>
{t(
"Action has been successfully added to the action chain '{0}'.",
<ActionChainLink id={data}>{this.state.actionChain.text}</ActionChainLink>
)}
{t("Action has been successfully added to the action chain <link>'{name}'</link>.", {
name: this.state.actionChain.text,
link: (str) => <ActionChainLink id={data}>{str}</ActionChainLink>,
})}
</span>
) : (
<span>{t("The action has been {0}.", <ActionLink id={data}>{t("scheduled")}</ActionLink>)}</span>
<span>
{t("The action has been <link>scheduled</link>.", {
link: (str) => <ActionLink id={data}>{str}</ActionLink>,
})}
</span>
)
);

Expand Down
41 changes: 22 additions & 19 deletions web/html/src/components/pagination.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,28 +90,31 @@ const PageSelector = (props: PageSelectorProps) => {
if (props.lastPage > 1) {
return (
<div className="table-page-information">
{t("Page")}
&nbsp;
<select
className="display-number small-select"
defaultValue={props.currentValue}
value={props.currentValue}
onChange={(e) => props.onChange(parseInt(e.target.value, 10))}
>
{Array.from(Array(props.lastPage)).map((o, i) => (
<option value={i + 1} key={i + 1}>
{i + 1}
</option>
))}
</select>
&nbsp;
{t("of")}
&nbsp;
{props.lastPage}
{t("Page <dropdown></dropdown> of {total}", {
dropdown: () => (
<select
className="display-number small-select"
value={props.currentValue}
onChange={(e) => props.onChange(parseInt(e.target.value, 10))}
key="select"
>
{Array.from(Array(props.lastPage)).map((_, i) => (
<option value={i + 1} key={i + 1}>
{i + 1}
</option>
))}
</select>
),
total: props.lastPage,
})}
</div>
);
} else {
return <div className="table-page-information">{t("Page {0} of {1}", props.currentValue, props.lastPage)}</div>;
return (
<div className="table-page-information">
{t("Page {current} of {total}", { current: props.currentValue, total: props.lastPage })}
</div>
);
}
};

Expand Down
2 changes: 1 addition & 1 deletion web/html/src/components/salt-state-popup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ class SaltStatePopup extends React.Component<SaltStatePopupProps> {
title = this.props.saltState && (
<span>
{icon}
{t("Configuration Channel: {0}", this.props.saltState.name)}
{t("Configuration Channel: {name}", { name: this.props.saltState.name })}
</span>
);

Expand Down
16 changes: 7 additions & 9 deletions web/html/src/components/states-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -381,10 +381,9 @@ class StatesPicker extends React.Component<StatesPickerProps, StatesPickerState>
<TextField
id="search-field"
value={this.state.filter}
placeholder={t(
"Search in {0}",
this.props.type === "state" ? t("states") : t("configuration channels")
)}
placeholder={
this.props.type === "state" ? t("Search in states") : t("Search in configuration channels")
}
onChange={this.onSearchChange}
onPressEnter={this.search}
/>
Expand All @@ -399,12 +398,11 @@ class StatesPicker extends React.Component<StatesPickerProps, StatesPickerState>

{this.state.rank ? (
<div className="col-md-offset-2 col-md-8">
<h2>{t("Edit {0} Ranks", this.props.type === "state" ? t("State") : t("Channel"))}</h2>
<h2>{this.props.type === "state" ? t("Edit State Ranks") : t("Edit Channel Ranks")}</h2>
<p>
{t(
"Edit the ranking of the {0} by dragging them.",
this.props.type === "state" ? t("states") : t("configuration channels")
)}
{this.props.type === "state"
? t("Edit the ranking of the states by dragging them.")
: t("Edit the ranking of the configuration channels by dragging them.")}
</p>
<RankingTable
items={currentAssignment}
Expand Down
7 changes: 5 additions & 2 deletions web/html/src/components/table/SearchPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,13 @@ export function SearchPanel(props: SearchPanelProps) {
})
)}
<div className="d-inline-block table-search-select-all">
<span>{t("Items {0} - {1} of {2}", props.fromItem, props.toItem, props.itemCount)}&nbsp;&nbsp;</span>
<span>
{t("Items {from} - {to} of {total}", { from: props.fromItem, to: props.toItem, total: props.itemCount })}
&nbsp;&nbsp;
</span>
{props.selectable && props.selectedCount > 0 && (
<span>
{t("({0} selected)", props.selectedCount)}&nbsp;
{t("({selectedCount} selected)", { selectedCount: props.selectedCount })}&nbsp;
<button className="btn-link" onClick={props.onClear}>
{t("Clear")}
</button>
Expand Down
49 changes: 49 additions & 0 deletions web/html/src/core/intl/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import ReactDOMServer from "react-dom/server";

import { t } from "./index";

describe("new t()", () => {
test("passthrough", () => {
expect(t("foo")).toEqual("foo");
expect(t("undefined")).toEqual("undefined");
expect(t("")).toEqual("");
// In case someone acidentally passes an `any` typed variable with a non-string value, try and recover
expect(t(undefined as any)).toEqual("");
expect(t(null as any)).toEqual("");
});

test("named placeholders", () => {
expect(t("foo {insert} bar", { insert: "something" })).toEqual("foo something bar");
expect(t("foo {insert} bar", { insert: undefined })).toEqual("foo bar");
});

test("tags", () => {
const input = "foo <link>bar</link>";
const inputArgs = {
link: (str) => <a href="/">{str}</a>,
};
const expected = 'foo <a href="/">bar</a>';

expect(ReactDOMServer.renderToStaticMarkup(<>{t(input, inputArgs)}</>)).toEqual(expected);
});

test("tags with named placeholders", () => {
const input = "foo <link>{insert}</link> bar";
const inputArgs = {
insert: "something",
link: (str) => <a href="/">{str}</a>,
};
const expected = 'foo <a href="/">something</a> bar';

expect(ReactDOMServer.renderToStaticMarkup(<>{t(input, inputArgs)}</>)).toEqual(expected);
});

// This behavior allows existing `handleResponseError` implementations to pass `{ arg: undefined }` even when there is no arg
test("extra args are ignored", () => {
const input = "foo bar";
const inputArgs = { tea: "cup", and: undefined };
const expected = "foo bar";

expect(t(input, inputArgs)).toEqual(expected);
});
});
83 changes: 83 additions & 0 deletions web/html/src/core/intl/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { createIntl, createIntlCache } from "@formatjs/intl";
import Gettext from "node-gettext";

import { jsFormatPreferredLocale } from "core/user-preferences";

import type { Values } from "./inferValues";

const gt = new Gettext();
const domain = "messages";
const poData = getPoAsJson(window.preferredLocale);
gt.addTranslations("", domain, poData);

/**
* Get the translation data. If the file is not found e.g. because the language is not (yet) supported
* return an empty string and use the default translation en_US
*/
function getPoAsJson(locale?: string) {
if (!locale) {
return "";
}
try {
return require(`../../../../po/${locale}.po`);
} catch (_) {
return "";
}
}

// We proxy every translation request directly to gettext so we can use the po files as-is without any transformations
const alwaysExists = { configurable: true, enumerable: true };
const messages = new Proxy(
{},
{
get(_, key) {
return gt.gettext(key);
},
getOwnPropertyDescriptor() {
return alwaysExists;
},
}
);

const cache = createIntlCache();
const intl = createIntl(
{
locale: jsFormatPreferredLocale,
messages,
},
cache
);

// This is exported for tests, everywhere else feel free to use the global reference
export const t = <Message extends string>(
// This is always the default string in English, even if the page is in another locale
defaultMessage: Message,
/**
* An object providing values to placeholders, e.g. for `"example {foo}"`, providing `{ foo: "text" }` would return `"example text"`.
*
* DOM nodes, React components, etc can also be used, e.g. `"example <bold>text</bold>"` and `{ bold: str => <b>{str}</b> }` would give `"example <b>text</b>"`.
*/
// We could optionally ` | Record<string, any>` here if we wanted to be more lax about values in some contexts while keeping autocomplete
values?: Values<Message>
) => {
// react-intl is unhappy when an emtpy string is used as an id
if (!defaultMessage) {
return "";
}

return intl.formatMessage(
{
id: defaultMessage,
defaultMessage: defaultMessage,
},
values
);
};

export type tType = typeof t;

window.t = t;

// If we need to, we have the option to export stuff such as formatNumber etc here in the future

export default {};
22 changes: 22 additions & 0 deletions web/html/src/core/intl/inferValues.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* Infer possible placeholder and tag values for a given translatable string, e.g. for `"example {foo}"`, infer
* `PartialRecord<"foo", any>`.
* See https://stackoverflow.com/a/71906104/1470607
*
* If we ever find a case where this breaks, drop it and replace it with a simple `Record<string, any>`, however
* currently it makes autocomplete work nicely
*/

// eslint-disable-next-line @typescript-eslint/no-unused-vars
type PlaceholderKeys<T extends string, KS extends string = never> = T extends `${infer F}{${infer K}}${infer R}`
? PlaceholderKeys<R, K | KS>
: KS;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
type TagKeys<T extends string, KS extends string = never> = T extends `${infer F}</${infer K}>${infer R}`
? TagKeys<R, K | KS>
: KS;
type PossibleKeysOf<T extends string> = PlaceholderKeys<T> | TagKeys<T>;
type PartialRecord<K extends keyof any, T> = {
[P in K]?: T;
};
export type Values<T extends string> = PartialRecord<PossibleKeysOf<T>, any>;
Loading

0 comments on commit 95df108

Please sign in to comment.