Skip to content

Commit

Permalink
feat: add literal string replacement support for react localizer util (
Browse files Browse the repository at this point in the history
…#17273)

* feat: add literal string replacement support for react localizer util

* feat: support string literal inside the component

* refactor: improve naming

* doc: add a comment
  • Loading branch information
PatrykBuniX committed May 7, 2024
1 parent 792f3c3 commit 2353b6d
Show file tree
Hide file tree
Showing 3 changed files with 214 additions and 59 deletions.
96 changes: 96 additions & 0 deletions src/script/util/LocalizerUtil/ReactLocalizerUtil.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
*
*/

import {render} from '@testing-library/react';

import {replaceReactComponents} from './ReactLocalizerUtil';

describe('replaceReactComponents', () => {
Expand Down Expand Up @@ -65,4 +67,98 @@ describe('replaceReactComponents', () => {

expect(result).toHaveLength(4);
});

it('replaces literal strings with a component', () => {
const username = 'Patryk';
const result = replaceReactComponents('Hello {{username}}!', [
{
exactMatch: '{{username}}',
render: () => <strong>{username}</strong>,
},
]);
const {getByText} = render(<div>{result}</div>);

expect(getByText(username)).toBeTruthy();
});

it('replaces literal strings with a string', () => {
const username = 'Przemek';
const result = replaceReactComponents('Hello {{username}}!', [
{
exactMatch: '{{username}}',
render: () => username,
},
]);

const {getByTestId} = render(<p data-uie-name="parent">{result}</p>);

expect(result).toHaveLength(3);
expect(getByTestId('parent').textContent).toEqual('Hello Przemek!');
});

it('replaces multiple literal strings', () => {
const username1 = 'John';
const username2 = 'Jerry';
const result = replaceReactComponents(`Hello {{username1}} and {{username2}}, my name is also {{username1}}!`, [
{
exactMatch: '{{username1}}',
render: () => <u>{username1}</u>,
},
{
exactMatch: '{{username2}}',
render: () => <u>{username2}</u>,
},
]);

const {getByTestId} = render(<p data-uie-name="parent">{result}</p>);

expect(result).toHaveLength(7);
expect(getByTestId('parent').textContent).toEqual('Hello John and Jerry, my name is also John!');
});

it('replaces components and literal strings at the same time', () => {
const username1 = 'Tom';
const username2 = 'Tim';
const result = replaceReactComponents(`Hello [bold]${username1}[/bold] and {{username2}}!`, [
{
start: '[bold]',
end: '[/bold]',
render: text => <strong>{text}</strong>,
},
{
exactMatch: '{{username2}}',
render: () => <u>{username2}</u>,
},
]);

const {getByTestId} = render(<p data-uie-name="parent">{result}</p>);

expect(result).toHaveLength(5);
expect(getByTestId('parent').textContent).toEqual('Hello Tom and Tim!');
});

it('replaces literal string inside of a component', () => {
const username = 'Jake';
const username2 = 'Marco';
const result = replaceReactComponents(`Hello [bold]{{username}}[/bold], [bold]Paul[/bold] and {{username2}}!`, [
{
start: '[bold]',
end: '[/bold]',
render: text => <strong>{text}</strong>,
},
{
exactMatch: '{{username}}',
render: () => <u>{username}</u>,
},
{
exactMatch: '{{username2}}',
render: () => <u>{username2}</u>,
},
]);

const {getByTestId} = render(<p data-uie-name="parent">{result}</p>);

expect(result).toHaveLength(7);
expect(getByTestId('parent').textContent).toEqual('Hello Jake, Paul and Marco!');
});
});
59 changes: 0 additions & 59 deletions src/script/util/LocalizerUtil/ReactLocalizerUtil.ts

This file was deleted.

118 changes: 118 additions & 0 deletions src/script/util/LocalizerUtil/ReactLocalizerUtil.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/*
* Wire
* Copyright (C) 2023 Wire Swiss GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see http://www.gnu.org/licenses/.
*
*/

import React from 'react';

interface ComponentReplacement {
start: string;
end: string;
render: (text: string) => React.ReactNode;
}

interface StringReplacement {
exactMatch: string;
render: () => React.ReactNode | string;
}

type Replacement = ComponentReplacement | StringReplacement;

function sanitizeRegexp(text: string) {
return text.replaceAll('[', '\\[').replaceAll(']', '\\]');
}

/**
* Will replace all occurences of `replacements` by a React component returned by `render`.
*/
export function replaceReactComponents(html: string, replacements: Replacement[]): React.ReactNode[] {
const [stringReplacements, componentReplacements] = replacements.reduce(
(acc, replacement) => {
if ('exactMatch' in replacement) {
acc[0].push(replacement);
} else {
acc[1].push(replacement);
}
return acc;
},
[[], []] as [StringReplacement[], ComponentReplacement[]],
);

if (!componentReplacements.length && !stringReplacements.length) {
return [html];
}

const componentsSplitRegexpStr = componentReplacements.length
? `(${componentReplacements
.map(replacement => `${sanitizeRegexp(replacement.start)}.+?${sanitizeRegexp(replacement.end)}`)
.join('|')})`
: null;

const stringSplitRegexpStr = stringReplacements.length
? `(${stringReplacements.map(replacement => sanitizeRegexp(replacement.exactMatch)).join('|')})`
: null;

const regexpStr = [componentsSplitRegexpStr, stringSplitRegexpStr].filter(Boolean).join('|');

const splitRegexp = new RegExp(regexpStr, 'g');

return html
.split(splitRegexp)
.map(node => {
if (!node) {
return false;
}
const componentsReplacementMatch = componentReplacements.find(
replacement => node.startsWith(replacement.start) && node.endsWith(replacement.end),
);

if (componentsReplacementMatch) {
const text = node.substring(
componentsReplacementMatch.start.length,
node.length - componentsReplacementMatch.end.length,
);

// There is a special case where we have a string replacement inside a component replacement.
if (stringSplitRegexpStr) {
const regexp = new RegExp(stringSplitRegexpStr, 'g');
const split = text.split(regexp);
return split
.map(node => {
const stringReplacementMatch = stringReplacements.find(replacement => node === replacement.exactMatch);
if (stringReplacementMatch) {
return stringReplacementMatch.render();
}
return componentsReplacementMatch.render(node);
})
.filter(Boolean)
.map((node, index) => <React.Fragment key={index}>{node}</React.Fragment>);
}

return componentsReplacementMatch.render(text);
}

const stringReplacementMatch = stringReplacements.find(replacement => node === replacement.exactMatch);

if (stringReplacementMatch) {
return stringReplacementMatch.render();
}

return node;
})
.filter(Boolean)
.map((node, index) => <React.Fragment key={index}>{node}</React.Fragment>); // Make sure we have a different key for each node.
}

0 comments on commit 2353b6d

Please sign in to comment.