Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 179 additions & 0 deletions src/components/i18n/Composition.ts
Original file line number Diff line number Diff line change
@@ -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;
198 changes: 198 additions & 0 deletions src/components/i18n/FormattedCompMessage.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement> {
/**
* 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;
/**
Comment thread
bonchevskyi marked this conversation as resolved.
* 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<FormattedCompMessageProps, FormattedCompMessageState> {
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,
};
}
}
Comment thread
bonchevskyi marked this conversation as resolved.

/**
* 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<Record<PluralProps['category'], string>> = {};
React.Children.forEach(children, child => {
if (React.isValidElement<PluralProps>(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('');
Comment thread
bonchevskyi marked this conversation as resolved.

// 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<string, number> = {};
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);
File renamed without changes.
Loading
Loading