Skip to content

Commit 0cf1f06

Browse files
authored
fix(content-translator): translate hasMany text fields per entry (#167)
1 parent 7fd7f8d commit 0cf1f06

6 files changed

Lines changed: 108 additions & 3 deletions

File tree

content-translator/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## Unreleased
44

5+
- fix: translate each entry of `hasMany` text fields individually so keyword/tag lists are translated instead of crashing
56
- fix: translate fields inside unnamed (presentational) groups instead of throwing an "Unnamed groups are currently not supported" error
67
- fix: skip fields and tabs named `__proto__`, `constructor`, or `prototype` during traversal to avoid prototype-polluting writes when a user-supplied Payload config contains such a name
78

content-translator/dev/payload-types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ export interface Page {
146146
};
147147
[k: string]: unknown;
148148
} | null;
149+
keywords?: string[] | null;
149150
meta?: {
150151
title?: string | null;
151152
description?: string | null;
@@ -332,6 +333,7 @@ export interface PagesSelect<T extends boolean = true> {
332333
title?: T;
333334
slug?: T;
334335
content?: T;
336+
keywords?: T;
335337
meta?:
336338
| T
337339
| {

content-translator/dev/src/collections/pages.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ export const pagesSchema: CollectionConfig = {
2222
required: false,
2323
localized: true,
2424
},
25+
{
26+
// hasMany text field: each keyword is translated individually
27+
name: 'keywords',
28+
type: 'text',
29+
hasMany: true,
30+
localized: true,
31+
},
2532
{
2633
name: 'meta',
2734
type: 'group',

content-translator/dev/src/seed.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ interface AuthorSeedData {
88
}
99

1010
interface PageSeedData {
11+
keywords: string[]
1112
slug: string
1213
title: string
1314
}
@@ -73,18 +74,22 @@ export const seed = async (payload: Payload) => {
7374
const pages: PageSeedData[] = [
7475
{
7576
slug: 'home',
77+
keywords: ['welcome', 'home page', 'getting started'],
7678
title: 'Welcome to Our Website',
7779
},
7880
{
7981
slug: 'about',
82+
keywords: ['company', 'team', 'our story'],
8083
title: 'About Our Company',
8184
},
8285
{
8386
slug: 'services',
87+
keywords: ['services', 'solutions', 'consulting'],
8488
title: 'Our Services and Solutions',
8589
},
8690
{
8791
slug: 'contact',
92+
keywords: ['contact', 'support', 'get in touch'],
8893
title: 'Get in Touch With Us',
8994
},
9095
]

content-translator/src/translate/traverseFields.ts

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,7 @@ export const traverseFields = ({
320320

321321
break
322322
case 'text':
323-
case 'textarea':
323+
case 'textarea': {
324324
if (field.custom && typeof field.custom === 'object' && field.custom.translatorSkip) {
325325
break
326326
}
@@ -337,13 +337,43 @@ export const traverseFields = ({
337337
break
338338
}
339339

340+
const fieldValue = siblingDataFrom[field.name]
341+
342+
// `hasMany` text fields store an array of strings (e.g. keywords /
343+
// tags). Translate each element individually - sending the whole
344+
// array as a single value makes the resolver return a non-string,
345+
// which then crashes in he.decode(...) ("e.replace is not a
346+
// function"). Pre-seed the target with the originals and replace
347+
// each entry in place as its translation resolves, so a skipped or
348+
// failed element keeps its original text.
349+
if (Array.isArray(fieldValue)) {
350+
const translatedArray = [...fieldValue]
351+
siblingDataTranslated[field.name] = translatedArray
352+
353+
fieldValue.forEach((item, itemIndex) => {
354+
if (typeof item !== 'string' || isEmpty(item)) {
355+
return
356+
}
357+
358+
valuesToTranslate.push({
359+
onTranslate: (translated) => {
360+
translatedArray[itemIndex] = translated
361+
},
362+
value: item,
363+
})
364+
})
365+
366+
break
367+
}
368+
340369
valuesToTranslate.push({
341-
onTranslate: (translated: string) => {
370+
onTranslate: (translated) => {
342371
siblingDataTranslated[field.name] = translated
343372
},
344-
value: siblingDataFrom[field.name],
373+
value: fieldValue,
345374
})
346375
break
376+
}
347377

348378
default:
349379
break

content-translator/test/traverseFields.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,66 @@ describe('traverseFields - emptyOnly with missing target sub-objects (#137)', ()
224224
})
225225
})
226226

227+
describe('traverseFields - hasMany text fields', () => {
228+
test('translates each element of a hasMany text field individually', () => {
229+
const fields: Field[] = [{ name: 'keywords', type: 'text', hasMany: true, localized: true }]
230+
231+
const translated = runTraverse(fields, { keywords: ['alpha', 'beta', 'gamma'] }, false)
232+
233+
assert.deepEqual(translated.keywords, [
234+
'TRANSLATED:alpha',
235+
'TRANSLATED:beta',
236+
'TRANSLATED:gamma',
237+
])
238+
})
239+
240+
test('keeps non-string entries in place while translating the rest', () => {
241+
const fields: Field[] = [{ name: 'keywords', type: 'text', hasMany: true, localized: true }]
242+
243+
const translatedData: Record<string, unknown> = {}
244+
const valuesToTranslate: ValueToTranslate[] = []
245+
246+
traverseFields({
247+
dataFrom: { keywords: ['alpha', 42, 'gamma'] },
248+
emptyOnly: false,
249+
fields,
250+
payloadConfig,
251+
translatedData,
252+
valuesToTranslate,
253+
})
254+
255+
// Only the two strings are sent to the resolver; the non-string is left alone.
256+
assert.equal(valuesToTranslate.length, 2)
257+
258+
for (const v of valuesToTranslate) {
259+
v.onTranslate(`TRANSLATED:${v.value}`)
260+
}
261+
262+
assert.deepEqual(translatedData.keywords, ['TRANSLATED:alpha', 42, 'TRANSLATED:gamma'])
263+
})
264+
265+
test('does not send the whole array as a single value', () => {
266+
const fields: Field[] = [{ name: 'keywords', type: 'text', hasMany: true, localized: true }]
267+
268+
const valuesToTranslate: ValueToTranslate[] = []
269+
270+
traverseFields({
271+
dataFrom: { keywords: ['alpha', 'beta'] },
272+
emptyOnly: false,
273+
fields,
274+
payloadConfig,
275+
translatedData: {},
276+
valuesToTranslate,
277+
})
278+
279+
assert.equal(valuesToTranslate.length, 2)
280+
assert.deepEqual(
281+
valuesToTranslate.map((v) => v.value),
282+
['alpha', 'beta'],
283+
)
284+
})
285+
})
286+
227287
describe('traverseFields - unnamed (presentational) groups', () => {
228288
test('translates a localized field inside an unnamed group stored in place', () => {
229289
const fields: Field[] = [

0 commit comments

Comments
 (0)