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

fix(i18n): allow list formatter without arguments in Translate component #6135

Merged
merged 2 commits into from Mar 27, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
53 changes: 42 additions & 11 deletions packages/sanity/src/core/i18n/Translate.tsx
@@ -1,6 +1,7 @@
import {type TFunction} from 'i18next'
import {type ComponentType, createElement, type ReactNode, useMemo} from 'react'

import {useListFormat} from '../hooks/useListFormat'
import {type CloseTagToken, simpleParser, type TextToken, type Token} from './simpleParser'

const COMPONENT_NAME_RE = /^[A-Z]/
Expand All @@ -20,6 +21,8 @@ const RECOGNIZED_HTML_TAGS = [
'sup',
]

type FormatterFns = {list: (value: Iterable<string>) => string}

/**
* A map of component names to React components. The component names are the names used within the
* locale resources, eg a key of `SearchTerm` should be rendered as `<SearchTerm/>` or
Expand Down Expand Up @@ -106,33 +109,43 @@ export function Translate(props: TranslationProps) {
})

const tokens = useMemo(() => simpleParser(translated), [translated])
return <>{render(tokens, props.values, props.components || {})}</>
const listFormat = useListFormat()
const formatters: FormatterFns = {
list: (listValues: Iterable<string>) => listFormat.format(listValues),
rexxars marked this conversation as resolved.
Show resolved Hide resolved
}
return <>{render(tokens, props.values, props.components || {}, formatters)}</>
}

function render(
tokens: Token[],
values: TranslationProps['values'],
componentMap: TranslateComponentMap,
formatters: FormatterFns,
): ReactNode {
const [head, ...tail] = tokens
if (!head) {
return null
}
if (head.type === 'interpolation') {
const value = values ? values[head.variable] : undefined
if (typeof value === 'undefined') {
return `{{${head.variable}}}`
}

const formattedValue = applyFormatters(value, head.formatters || [], formatters)

return (
<>
{!values || typeof values[head.variable] === 'undefined'
? `{{${head.variable}}}`
: values[head.variable]}
{render(tail, values, componentMap)}
{formattedValue}
{render(tail, values, componentMap, formatters)}
</>
)
}
if (head.type === 'text') {
return (
<>
{head.text}
{render(tail, values, componentMap)}
{render(tail, values, componentMap, formatters)}
</>
)
}
Expand All @@ -145,7 +158,7 @@ function render(
return (
<>
<Component />
{render(tail, values, componentMap)}
{render(tail, values, componentMap, formatters)}
</>
)
}
Expand All @@ -171,15 +184,33 @@ function render(

return Component ? (
<>
<Component>{render(children, values, componentMap)}</Component>
{render(remaining, values, componentMap)}
<Component>{render(children, values, componentMap, formatters)}</Component>
{render(remaining, values, componentMap, formatters)}
</>
) : (
<>
{createElement(head.name, {}, render(children, values, componentMap))}
{render(remaining, values, componentMap)}
{createElement(head.name, {}, render(children, values, componentMap, formatters))}
{render(remaining, values, componentMap, formatters)}
</>
)
}
return null
}

function applyFormatters(
value: Required<TranslationProps>['values'][string],
formatters: string[],
formatterFns: FormatterFns,
): string {
let formattedValue = value
for (const formatter of formatters) {
if (formatter === 'list') {
if (Array.isArray(value)) {
formattedValue = formatterFns.list(value)
} else {
throw new Error('List formatter used on non-array value')
}
}
}
return `${formattedValue}`
}
14 changes: 14 additions & 0 deletions packages/sanity/src/core/i18n/__tests__/Translate.test.tsx
Expand Up @@ -137,4 +137,18 @@ describe('Translate component', () => {
'An escaped, <strong>interpolated</strong> thing',
)
})

it('it allows using list formatter for interpolated values', async () => {
const wrapper = await getWrapper([
createBundle({peopleSignedUp: '{{count}} people signed up: {{people, list}}'}),
])
const people = ['Bjørge', 'Rita', 'Espen']
const {findByTestId} = render(
<TestComponent i18nKey="peopleSignedUp" values={{count: people.length, people}} />,
{wrapper},
)
expect(await findByTestId('output')).toHaveTextContent(
'3 people signed up: Bjørge, Rita, and Espen',
)
})
})
13 changes: 10 additions & 3 deletions packages/sanity/src/core/i18n/__tests__/simpleParser.test.ts
Expand Up @@ -107,11 +107,18 @@ describe('simpleParser', () => {
{type: 'tagClose', name: 'Bold'},
])
})
test('interpolations with allowed formatters', () => {
expect(simpleParser('{{count}} people signed up: {{people, list}}')).toMatchObject([
{type: 'interpolation', variable: 'count'},
{type: 'text', text: ' people signed up: '},
{type: 'interpolation', variable: 'people', formatters: ['list']},
])
})
})
describe('simpleParser - errors', () => {
test('formatters in interpolations', () => {
expect(() => simpleParser('This is not allowed: {{countries, list}}')).toThrow(
`Interpolations with formatters are not supported when using <Translate>. Found "countries, list". Utilize "useTranslation" instead, or format the values passed to <Translate> ahead of time.`,
test('other formatters in interpolations', () => {
expect(() => simpleParser('This is not allowed: {{count, number}}')).toThrow(
`Interpolations with formatters are not supported when using <Translate>. Found "{{count, number}}". Utilize "useTranslation" instead, or format the values passed to <Translate> ahead of time.`,
)
})
test('unpaired tags', () => {
Expand Down
22 changes: 15 additions & 7 deletions packages/sanity/src/core/i18n/simpleParser.ts
Expand Up @@ -33,6 +33,7 @@ export type TextToken = {
export type InterpolationToken = {
type: 'interpolation'
variable: string
formatters?: string[]
}

/**
Expand Down Expand Up @@ -154,14 +155,21 @@ function textTokenWithInterpolation(text: string): Token[] {
}

function parseInterpolation(interpolation: string): InterpolationToken {
const variable = interpolation.replace(/^\{\{|\}\}$/g, '').trim()
// Disallow formatters for interpolations when using the `Translate` function:
// Since we do not have a _key_ to format (only a substring), we do not want i18next to look up
// a matching string value for the "stub" value. We could potentially change this in the future,
// if we feel it is a useful feature.
if (variable.includes(',')) {
const [variable, ...formatters] = interpolation
.replace(/^\{\{|\}\}$/g, '')
.trim()
.split(/\s*,\s*/)

// To save us from reimplementing all of i18next's formatter logic, we only curently support the
// `list` formatter, and only without any arguments. This may change in the future, but deeming
// this good enough for now.
if (formatters.length === 1 && formatters[0] === 'list') {
return {type: 'interpolation', variable, formatters}
}

if (formatters.length > 0) {
throw new Error(
`Interpolations with formatters are not supported when using <Translate>. Found "${variable}". Utilize "useTranslation" instead, or format the values passed to <Translate> ahead of time.`,
`Interpolations with formatters are not supported when using <Translate>. Found "${interpolation}". Utilize "useTranslation" instead, or format the values passed to <Translate> ahead of time.`,
)
}

Expand Down