From c99d83efe551da26242249b323b4708958bd4994 Mon Sep 17 00:00:00 2001 From: roman Date: Wed, 12 Aug 2026 15:44:41 +0200 Subject: [PATCH] refactor(i18n): migrate i18n from Flow to TypeScript --- .../{Composition.js => Composition.js.flow} | 0 src/components/i18n/Composition.ts | 179 ++++++++++++++++ ...essage.js => FormattedCompMessage.js.flow} | 0 src/components/i18n/FormattedCompMessage.ts | 198 ++++++++++++++++++ .../i18n/{Param.js => Param.js.flow} | 0 src/components/i18n/Param.ts | 60 ++++++ .../i18n/{Plural.js => Plural.js.flow} | 0 src/components/i18n/Plural.ts | 57 +++++ ...omposition.test.js => Composition.test.ts} | 0 .../{Param.test.js => Param.test.tsx} | 0 .../{Plural.test.js => Plural.test.tsx} | 13 +- .../i18n/{constants.js => constants.js.flow} | 0 src/components/i18n/constants.ts | 16 ++ .../i18n/{index.js => index.js.flow} | 0 src/components/i18n/index.ts | 7 + 15 files changed, 523 insertions(+), 7 deletions(-) rename src/components/i18n/{Composition.js => Composition.js.flow} (100%) create mode 100644 src/components/i18n/Composition.ts rename src/components/i18n/{FormattedCompMessage.js => FormattedCompMessage.js.flow} (100%) create mode 100644 src/components/i18n/FormattedCompMessage.ts rename src/components/i18n/{Param.js => Param.js.flow} (100%) create mode 100644 src/components/i18n/Param.ts rename src/components/i18n/{Plural.js => Plural.js.flow} (100%) create mode 100644 src/components/i18n/Plural.ts rename src/components/i18n/__tests__/{Composition.test.js => Composition.test.ts} (100%) rename src/components/i18n/__tests__/{Param.test.js => Param.test.tsx} (100%) rename src/components/i18n/__tests__/{Plural.test.js => Plural.test.tsx} (90%) rename src/components/i18n/{constants.js => constants.js.flow} (100%) create mode 100644 src/components/i18n/constants.ts rename src/components/i18n/{index.js => index.js.flow} (100%) create mode 100644 src/components/i18n/index.ts diff --git a/src/components/i18n/Composition.js b/src/components/i18n/Composition.js.flow similarity index 100% rename from src/components/i18n/Composition.js rename to src/components/i18n/Composition.js.flow diff --git a/src/components/i18n/Composition.ts b/src/components/i18n/Composition.ts new file mode 100644 index 0000000000..5af07e9fd6 --- /dev/null +++ b/src/components/i18n/Composition.ts @@ -0,0 +1,179 @@ +/* + * Utility class for the FormattedCompMessage component. + */ +import * as React from 'react'; +import MessageAccumulator from 'message-accumulator'; +import Node from 'ilib-tree-node'; + +import { JSTYPE_BOOLEAN, JSTYPE_NUMBER, JSTYPE_OBJECT, JSTYPE_STRING } from './constants'; + +interface CompositionNode { + children: CompositionNode[]; + extra?: React.ReactElement<{ + children?: React.ReactNode; + temp?: React.ReactNode | React.ReactNode[]; + }>; + value?: React.ReactNode; +} + +interface MessageAccumulatorInstance { + addParam: (element: React.ReactElement) => void; + addText: (text: string) => void; + getMinimalString: () => string; + getPrefix: () => CompositionNode[]; + getSuffix: () => CompositionNode[]; + pop: () => void; + push: (element: React.ReactElement) => void; +} + +type ComposableElement = React.ReactNode | React.ElementType; + +/** + * @class Compose a tree of React elements into a single string. + * + * @param {React.Element} element the element to compose + */ +class Composition { + element: ComposableElement; + + isComposed: boolean; + + ma: MessageAccumulatorInstance; + + keyIndex: number; + + constructor(element?: ComposableElement) { + this.element = element; + this.isComposed = false; + + this.ma = new MessageAccumulator(); + this.keyIndex = 0; + } + + recompose(element: ComposableElement): void { + switch (typeof element) { + case JSTYPE_OBJECT: + if (Array.isArray(element)) { + element.forEach(subelement => this.recompose(subelement)); + } else if (element) { + const reactElement = element as React.ReactElement<{ children?: React.ReactNode }>; + const elementType = reactElement.type; + const elementName = + typeof elementType === 'string' ? undefined : (elementType as { name?: string }).name; + if (elementType === 'Param' || elementName === 'Param') { + this.ma.addParam(reactElement); + } else { + this.ma.push(reactElement); + React.Children.forEach(reactElement.props.children, child => this.recompose(child)); + this.ma.pop(); + } + } + break; + + case JSTYPE_NUMBER: + case JSTYPE_BOOLEAN: + this.ma.addText(String(element)); + break; + + case JSTYPE_STRING: + this.ma.addText(element as string); + break; + + default: + break; + } + } + + /** + * Compose a tree of react elements to a string that can be translated. + * + * @return {string} a string representing the tree of react elements + */ + compose(): string { + if (!this.isComposed) { + this.recompose(this.element); + } + this.isComposed = true; + return this.ma.getMinimalString(); + } + + /** + * @private + */ + nextKey(): string { + const result = `key${this.keyIndex}`; + this.keyIndex += 1; + return result; + } + + /** + * @private + */ + mapToReactElements(node?: CompositionNode): React.ReactNode { + if (!node) return ''; + + let children: React.ReactNode | React.ReactNode[] = node.children.map(child => this.mapToReactElements(child)); + + const el = node.extra; + if (Array.isArray(children) && children.length === 0 && el?.props) { + const { temp } = el.props; + children = temp; + } + + const childrenWithLength = children as React.ReactNode[] | string; + if (childrenWithLength?.length === 1 && typeof childrenWithLength[0] === 'string') { + children = childrenWithLength[0]; + } + + const normalizedChildren = children as React.ReactNode[] | string; + if (el) { + return normalizedChildren?.length + ? React.cloneElement(el, { key: el.key || this.nextKey() }, children) + : React.cloneElement(el, { key: el.key || this.nextKey() }); + } + + if (normalizedChildren.length) { + return normalizedChildren.length > 1 ? children : normalizedChildren[0]; + } + + return node.value || ''; + } + + /** + * Convert a composed string back into an array of React elements. The elements are clones of + * the same ones that this composition was created with, so that they have the same type and + * props and such as the originals. The elements may be re-ordered from the original, however, + * if the grammar of the target language requires moving around text, HTML tags, or + * subcomponents. + * + * @param {string} string the string to decompose into a tree of React elements. + * @return {React.Element} a react element + */ + decompose(string: string): React.ReactNode { + if (!this.isComposed) { + // need to create the mapping first from names to react elements + this.compose(); + } + + const translation = MessageAccumulator.create(string, this.ma); + const nodeArray = [ + new Node({ + type: 'root', + use: 'start', + }), + ] + .concat(this.ma.getPrefix()) + .concat(translation.root.toArray().slice(1, -1)) + .concat(this.ma.getSuffix()) + .concat([ + new Node({ + type: 'root', + use: 'end', + }), + ]); + // convert to a tree again + return this.mapToReactElements(Node.fromArray(nodeArray)); + } +} + +export default Composition; diff --git a/src/components/i18n/FormattedCompMessage.js b/src/components/i18n/FormattedCompMessage.js.flow similarity index 100% rename from src/components/i18n/FormattedCompMessage.js rename to src/components/i18n/FormattedCompMessage.js.flow diff --git a/src/components/i18n/FormattedCompMessage.ts b/src/components/i18n/FormattedCompMessage.ts new file mode 100644 index 0000000000..43da3ad625 --- /dev/null +++ b/src/components/i18n/FormattedCompMessage.ts @@ -0,0 +1,198 @@ +// @deprecated, use FormattedMessage from react-intl v6 instead. +import * as React from 'react'; +import { injectIntl, IntlShape } from 'react-intl'; +import isNaN from 'lodash/isNaN'; + +import isDevEnvironment from '../../utils/env'; +import { CATEGORY_ZERO, CATEGORY_ONE, CATEGORY_TWO, CATEGORY_FEW, CATEGORY_MANY, CATEGORY_OTHER } from './constants'; +import Composition from './Composition'; +import type { PluralProps } from './Plural'; + +export interface FormattedCompMessageProps extends React.HTMLAttributes { + /** + * The text to translate. This may be a string or JSX. The defaultMessage prop may be + * given or the component may have children, but not both. + */ + children?: React.ReactNode; + /** + * Specify the pivot count to choose which plural form to use. + * When specified, this FormattedCompMessage component will choose one of the + * Plural elements in its children according to the value of this count + * and the linguistic rules of the locale which determine which numbers + * belong to which plural class. + */ + count?: number; + /** + * The text to translate. This may be a string or JSX. This prop may be + * given or the component may have children, but not both. + */ + defaultMessage?: React.ElementType | string; + /** + * A description to send to the translators to explain the context of + * this string. + */ + description: string; + /** The unique id of this string. */ + id: string; + /** + * The intl provider. This is injected into this component + * via the injectIntl function from react-intl. + */ + intl: IntlShape; + /** + * Specify the name of the HTML tag you would like to use to wrap the + * translations. + */ + tagName: string; +} + +type FormattedCompMessageState = { + composition: Composition; + source: string; +}; + +/** + * Replace the text inside of this component with a translation. This + * component is built on top of react-intl, so it works along with the + * regular react-intl components and objects you are used to, and it gets + * its translations from react intl as well. The FormattedCompMessage component can + * be used wherever it is valid to put JSX text. In regular Javascript + * code, you should continue to use the intl.formatMessage() call and + * extract your strings into a message.js file. + */ +class FormattedCompMessage extends React.Component { + static readonly defaultProps = { + tagName: 'span', + }; + + constructor(props: FormattedCompMessageProps) { + super(props); + + /* eslint-disable no-console */ + console.warn( + "box-ui-elements: the FormattedCompMessage component is deprecated! Use react-intl's FormattedMessage instead.", + ); + /* eslint-enable no-console */ + + // these parameters echo the ones in react-intl's FormattedMessage + // component, plus a few extra + const { + defaultMessage, // The English string + HTML + components that you want translated + count, // the pivot count to choose a plural form + children, // the components within the body + } = this.props; + + const sourceElements = defaultMessage || children; + + if (sourceElements) { + const composition = new Composition(sourceElements); + let source = ''; + + if (!isNaN(Number(count))) { + if (children) { + source = this.composePluralString(children); + } else if (isDevEnvironment()) { + throw new Error('Cannot use count prop on a FormattedCompMessage component that has no children.'); + } + } else { + source = composition.compose(); + } + + this.state = { + source, + composition, + }; + } + } + + /** + * Search for any Plural elements in the children, and + * then construct the English source string in the correct + * format for react-intl to use for pluralization + * @param {React.Element} children the children of this node + * @return {string} the composed plural string + */ + composePluralString(children: React.ReactNode): string { + const categories: Partial> = {}; + React.Children.forEach(children, child => { + if (React.isValidElement(child)) { + const childType = child.type as React.ElementType & { name?: string }; + if (childType.name !== 'Plural') { + return; + } + + const childComposition = new Composition(child.props.children); + categories[child.props.category] = childComposition.compose(); + } + }); + if (!categories.one || !categories.other) { + if (isDevEnvironment()) { + throw new Error( + 'Cannot use count prop on a FormattedCompMessage component without giving both a "one" and "other" Plural component in the children.', + ); + } + } + // add these to the string in a particular order so that + // we always end up with the same string regardless of + // the order that the Plural elements were specified in + // the source code + const categoriesString = [ + CATEGORY_ZERO, + CATEGORY_ONE, + CATEGORY_TWO, + CATEGORY_FEW, + CATEGORY_MANY, + CATEGORY_OTHER, + ] + .map(category => (categories[category] ? ` ${category} {${categories[category]}}` : '')) + .join(''); + + // see the intl-messageformat project for an explanation of this syntax + return `{count, plural,${categoriesString}}`; + } + + render() { + const { count, tagName, intl, description, id, ...rest } = this.props; + delete rest.defaultMessage; + const { composition, source } = this.state; + const values: Record = {}; + if (typeof count === 'number') { + // make sure intl.formatMessage switches properly on the count + values.count = count; + } + + // react-intl will do the correct plurals if necessary + const descriptor = { + id, + defaultMessage: source, + description, + } as const; + const translation = intl.formatMessage(descriptor, values); + + // always wrap the translated string in a tag to contain everything + // and to give us a spot to record the id. The resource id is the + // the id in mojito for the string. Having this attr has these advantages: + // 1. When debugging i18n or translation problems, it is MUCH easier to find + // the exact string to fix in Mojito rather than guessing. It might be useful + // for general debugging as well to map from something you see in the UI to + // the actual code that implements it. + // 2. It can be used by an in-context linguistic review tool. The tool code + // can contact mojito and retrieve the English for any translation errors that + // the reviewer finds and submit translation tickets to Jira and/or fixed + // translations directly back to Mojito. + // 3. It can be used by the planned "text experiment framework" to identify + // whole strings in the UI that can be A/B tested in various languages without + // publishing new versions of the code. + return React.createElement( + tagName, + { + key: id, + 'x-resource-id': id, + ...rest, + }, + composition.decompose(translation), + ); + } +} + +export default injectIntl(FormattedCompMessage); diff --git a/src/components/i18n/Param.js b/src/components/i18n/Param.js.flow similarity index 100% rename from src/components/i18n/Param.js rename to src/components/i18n/Param.js.flow diff --git a/src/components/i18n/Param.ts b/src/components/i18n/Param.ts new file mode 100644 index 0000000000..43f0d86da9 --- /dev/null +++ b/src/components/i18n/Param.ts @@ -0,0 +1,60 @@ +import * as React from 'react'; + +import { + JSTYPE_BOOLEAN, + JSTYPE_FUNCTION, + JSTYPE_NUMBER, + JSTYPE_OBJECT, + JSTYPE_STRING, + JSTYPE_UNDEFINED, +} from './constants'; + +export interface ParamProps { + /** A description of this parameter to help translators understand its meaning */ + description: string; + /** The value of this parameter */ + value: unknown; +} + +/** + * @class A placeholder for a replacement parameter in the body of a FormattedCompMessage + * component. + * + * This component renders into the value of the named parameter to the FormattedCompMessage + * component. Children are not allowed in this component and typically, it is used with + * the self-closing syntax. + * + * @example + *
+ *   
+ *     The file  has been deleted.
+ *   
+ * 
+ */ +const Param = ({ value }: ParamProps): React.ReactNode => { + switch (typeof value) { + case JSTYPE_BOOLEAN: + case JSTYPE_NUMBER: + return String(value); + + case JSTYPE_FUNCTION: + return (value as () => React.ReactNode)(); + + case JSTYPE_STRING: + return value as string; + + case JSTYPE_OBJECT: + if (value === null) { + return ''; + } + if (React.isValidElement(value)) { + return value; + } + return value.toString(); + case JSTYPE_UNDEFINED: + default: + return ''; + } +}; + +export default Param; diff --git a/src/components/i18n/Plural.js b/src/components/i18n/Plural.js.flow similarity index 100% rename from src/components/i18n/Plural.js rename to src/components/i18n/Plural.js.flow diff --git a/src/components/i18n/Plural.ts b/src/components/i18n/Plural.ts new file mode 100644 index 0000000000..41c6f0c419 --- /dev/null +++ b/src/components/i18n/Plural.ts @@ -0,0 +1,57 @@ +// @deprecated, use FormattedPlural from react-intl v6 instead. +import * as React from 'react'; + +export interface PluralProps { + /** The plural category for this string */ + category: + | 'zero' + | 'one' + | 'two' + | 'few' + | 'many' + | 'other' + | '=0' + | '=1' + | '=2' + | '=3' + | '=4' + | '=5' + | '=6' + | '=7' + | '=8' + | '=9' + | '=10' + | '=11' + | '=12' + | '=13' + | '=14' + | '=15' + | '=16' + | '=17' + | '=18' + | '=19'; + /** The content associated with the plural category */ + children: React.ReactNode; +} + +/** + * @class Encloses a plural string for a particular plural category. + * + * The categories for English are "one" for singular and "other" for plural. Both are + * required when writing plurals with the FormattedCompMessage component in source code.

+ * + * This component does not add any functionality beyond its contents, and its only + * purpose is to enclose some JSX to use for a particular plural category. + * + * See the [Unicode CLDR description of plural category + * rules](http://cldr.unicode.org/index/cldr-spec/plural-rules) for more details. + */ +const Plural = ({ children }: PluralProps): React.ReactNode => { + /* eslint-disable no-console */ + console.warn("box-ui-elements: the Plural component is deprecated! Use react-intl's FormattedPlural instead."); + /* eslint-enable no-console */ + + return children; +}; + +export default Plural; diff --git a/src/components/i18n/__tests__/Composition.test.js b/src/components/i18n/__tests__/Composition.test.ts similarity index 100% rename from src/components/i18n/__tests__/Composition.test.js rename to src/components/i18n/__tests__/Composition.test.ts diff --git a/src/components/i18n/__tests__/Param.test.js b/src/components/i18n/__tests__/Param.test.tsx similarity index 100% rename from src/components/i18n/__tests__/Param.test.js rename to src/components/i18n/__tests__/Param.test.tsx diff --git a/src/components/i18n/__tests__/Plural.test.js b/src/components/i18n/__tests__/Plural.test.tsx similarity index 90% rename from src/components/i18n/__tests__/Plural.test.js rename to src/components/i18n/__tests__/Plural.test.tsx index 0aef0c808f..4e3aaca282 100644 --- a/src/components/i18n/__tests__/Plural.test.js +++ b/src/components/i18n/__tests__/Plural.test.tsx @@ -1,18 +1,17 @@ import * as React from 'react'; import { mount } from 'enzyme'; -import PropTypes from 'prop-types'; import Plural from '../Plural'; import Composition from '../Composition'; -function Link(props) { - return {props.children}; +interface LinkProps { + children: React.ReactNode; + to: string; } -Link.propTypes = { - to: PropTypes.string, - children: PropTypes.any, -}; +function Link({ children, to }: LinkProps) { + return {children}; +} describe('components/i18n/Plural', () => { test('should correctly render simple Plural', () => { diff --git a/src/components/i18n/constants.js b/src/components/i18n/constants.js.flow similarity index 100% rename from src/components/i18n/constants.js rename to src/components/i18n/constants.js.flow diff --git a/src/components/i18n/constants.ts b/src/components/i18n/constants.ts new file mode 100644 index 0000000000..c4df9252b2 --- /dev/null +++ b/src/components/i18n/constants.ts @@ -0,0 +1,16 @@ +/* ------------------ Javascript Object Types ----------------- */ +export const JSTYPE_BOOLEAN = 'boolean'; +export const JSTYPE_FUNCTION = 'function'; +export const JSTYPE_NUMBER = 'number'; +export const JSTYPE_OBJECT = 'object'; +export const JSTYPE_STRING = 'string'; +export const JSTYPE_SYMBOL = 'symbol'; +export const JSTYPE_UNDEFINED = 'undefined'; + +/* ------------------ Plural Category Names For react-intl ----------------- */ +export const CATEGORY_ZERO = 'zero'; +export const CATEGORY_ONE = 'one'; +export const CATEGORY_TWO = 'two'; +export const CATEGORY_FEW = 'few'; +export const CATEGORY_MANY = 'many'; +export const CATEGORY_OTHER = 'other'; diff --git a/src/components/i18n/index.js b/src/components/i18n/index.js.flow similarity index 100% rename from src/components/i18n/index.js rename to src/components/i18n/index.js.flow diff --git a/src/components/i18n/index.ts b/src/components/i18n/index.ts new file mode 100644 index 0000000000..0c1cb8268e --- /dev/null +++ b/src/components/i18n/index.ts @@ -0,0 +1,7 @@ +// @deprecated, use FormattedPlural or FormattedMessage from react-intl v6 instead. +export { default as Param } from './Param'; +export type { ParamProps } from './Param'; +export { default as Plural } from './Plural'; +export type { PluralProps } from './Plural'; +export { default } from './FormattedCompMessage'; +export type { FormattedCompMessageProps } from './FormattedCompMessage';