Skip to content
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

Integrate @formatjs/intl as a replacement for t() #7296

Merged
merged 17 commits into from
Jul 28, 2023
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
Loading