Skip to content

Commit ccbdcc2

Browse files
authored
fix(plugin-import-export): fix CSV import of arrays and richText nest… (#16923)
Backport of #16922
1 parent c039e55 commit ccbdcc2

7 files changed

Lines changed: 516 additions & 16 deletions

File tree

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import { processRichTextField } from './processRichTextField.js'
4+
5+
// Minimal helpers to keep assertions readable
6+
const asRecord = (v: unknown) => v as Record<string, unknown>
7+
const asArray = (v: unknown) => v as unknown[]
8+
9+
// ─── Fixtures ────────────────────────────────────────────────────────────────
10+
11+
const textNode = (overrides: Record<string, unknown> = {}) => ({
12+
detail: 0,
13+
format: 0,
14+
mode: 'normal',
15+
text: 'Hello',
16+
type: 'text',
17+
version: 1,
18+
...overrides,
19+
})
20+
21+
const paragraphNode = (children: unknown[] = [textNode()]) => ({
22+
children,
23+
direction: 'ltr',
24+
format: '',
25+
indent: 0,
26+
type: 'paragraph',
27+
version: 1,
28+
})
29+
30+
const lexicalDoc = (children: unknown[] = [paragraphNode()]) => ({
31+
children,
32+
direction: 'ltr',
33+
format: '',
34+
indent: 0,
35+
type: 'root',
36+
version: 1,
37+
})
38+
39+
// ─── Primitive pass-through ──────────────────────────────────────────────────
40+
41+
describe('processRichTextField — primitive pass-through', () => {
42+
it('should return null unchanged', () => {
43+
expect(processRichTextField(null)).toBeNull()
44+
})
45+
46+
it('should return undefined unchanged', () => {
47+
expect(processRichTextField(undefined)).toBeUndefined()
48+
})
49+
50+
it('should return a number unchanged', () => {
51+
expect(processRichTextField(42)).toBe(42)
52+
})
53+
54+
it('should return false unchanged', () => {
55+
expect(processRichTextField(false)).toBe(false)
56+
})
57+
})
58+
59+
// ─── String input (the recovered-from-missed-hook path) ─────────────────────
60+
61+
describe('processRichTextField — JSON string input', () => {
62+
it('should parse a valid JSON string into an object', () => {
63+
const doc = lexicalDoc()
64+
expect(processRichTextField(JSON.stringify(doc))).toEqual(doc)
65+
})
66+
67+
it('should return an unparseable string unchanged', () => {
68+
expect(processRichTextField('not json')).toBe('not json')
69+
})
70+
71+
it('should return an empty string unchanged', () => {
72+
expect(processRichTextField('')).toBe('')
73+
})
74+
75+
it('should parse a string and still convert numeric string properties', () => {
76+
const node = { type: 'text', detail: '0', format: '3', indent: '0', version: '1', text: 'hi' }
77+
const result = asRecord(processRichTextField(JSON.stringify(node)))
78+
expect(result.detail).toBe(0)
79+
expect(result.format).toBe(3)
80+
expect(result.indent).toBe(0)
81+
expect(result.version).toBe(1)
82+
})
83+
})
84+
85+
// ─── Numeric property coercion ───────────────────────────────────────────────
86+
87+
describe('processRichTextField — numeric property coercion', () => {
88+
it('should convert detail, format, indent, version from strings to numbers', () => {
89+
const input = textNode({ detail: '0', format: '0', indent: '0', version: '1' })
90+
const result = asRecord(processRichTextField(input))
91+
expect(result.detail).toBe(0)
92+
expect(result.format).toBe(0)
93+
expect(result.indent).toBe(0)
94+
expect(result.version).toBe(1)
95+
})
96+
97+
it('should convert start and value from strings to numbers (list/listitem nodes)', () => {
98+
const listItemNode = { type: 'listitem', start: '1', value: '2', version: '1' }
99+
const result = asRecord(processRichTextField(listItemNode))
100+
expect(result.start).toBe(1)
101+
expect(result.value).toBe(2)
102+
})
103+
104+
it('should convert textFormat and textStyle from strings to numbers', () => {
105+
const input = { type: 'text', textFormat: '4', textStyle: '0', version: '1', text: 'x' }
106+
const result = asRecord(processRichTextField(input))
107+
expect(result.textFormat).toBe(4)
108+
expect(result.textStyle).toBe(0)
109+
})
110+
111+
it('should leave a non-numeric string in a numeric-property slot unchanged', () => {
112+
// Lexical paragraph format can be "" (empty) or alignment strings
113+
const input = paragraphNode()
114+
const result = asRecord(processRichTextField(input))
115+
expect(result.format).toBe('')
116+
})
117+
118+
it('should leave already-numeric values unchanged', () => {
119+
const input = textNode({ detail: 0, format: 3, version: 1 })
120+
const result = asRecord(processRichTextField(input))
121+
expect(result.detail).toBe(0)
122+
expect(result.format).toBe(3)
123+
expect(result.version).toBe(1)
124+
})
125+
126+
it('should not convert boolean values on numeric-property keys', () => {
127+
const input = { type: 'custom', format: true, version: '1' }
128+
const result = asRecord(processRichTextField(input))
129+
expect(result.format).toBe(true)
130+
expect(result.version).toBe(1)
131+
})
132+
})
133+
134+
// ─── Recursive children processing ──────────────────────────────────────────
135+
136+
describe('processRichTextField — recursive children', () => {
137+
it('should recursively convert numeric strings inside children arrays', () => {
138+
const input = paragraphNode([textNode({ detail: '0', format: '0', version: '1' })])
139+
const result = asRecord(processRichTextField(input))
140+
const child = asRecord(asArray(result.children)[0])
141+
expect(child.detail).toBe(0)
142+
expect(child.format).toBe(0)
143+
expect(child.version).toBe(1)
144+
})
145+
146+
it('should handle three levels of nesting (root → paragraph → text)', () => {
147+
const input = lexicalDoc([paragraphNode([textNode({ version: '1', format: '2' })])])
148+
const result = asRecord(processRichTextField(input))
149+
const paragraph = asRecord(asArray(result.children)[0])
150+
const text = asRecord(asArray(paragraph.children)[0])
151+
expect(text.version).toBe(1)
152+
expect(text.format).toBe(2)
153+
})
154+
155+
it('should handle multiple children at the same level', () => {
156+
const input = lexicalDoc([
157+
paragraphNode([
158+
textNode({ text: 'Bold', format: '1' }),
159+
textNode({ text: ' normal', format: '0' }),
160+
]),
161+
paragraphNode([textNode({ text: 'Second para', version: '1' })]),
162+
])
163+
const result = asRecord(processRichTextField(input))
164+
const firstPara = asRecord(asArray(result.children)[0])
165+
const secondPara = asRecord(asArray(result.children)[1])
166+
const bold = asRecord(asArray(firstPara.children)[0])
167+
const normal = asRecord(asArray(firstPara.children)[1])
168+
const secondText = asRecord(asArray(secondPara.children)[0])
169+
expect(bold.format).toBe(1)
170+
expect(normal.format).toBe(0)
171+
expect(secondText.version).toBe(1)
172+
})
173+
174+
it('should skip null and non-object entries in children without throwing', () => {
175+
const input = { type: 'root', version: 1, children: [null, undefined, 'stray', textNode()] }
176+
const result = asRecord(processRichTextField(input))
177+
const children = asArray(result.children)
178+
expect(children[0]).toBeNull()
179+
expect(children[1]).toBeUndefined()
180+
expect(children[2]).toBe('stray')
181+
expect(asRecord(children[3]).type).toBe('text')
182+
})
183+
})
184+
185+
// ─── Nested non-children objects ─────────────────────────────────────────────
186+
187+
describe('processRichTextField — nested non-children objects', () => {
188+
it('should recurse into plain nested objects (e.g. decorator node data)', () => {
189+
const input = {
190+
type: 'decorator',
191+
version: '1',
192+
data: { detail: '2', label: 'keep me' },
193+
}
194+
const result = asRecord(processRichTextField(input))
195+
const data = asRecord(result.data)
196+
expect(result.version).toBe(1)
197+
expect(data.detail).toBe(2)
198+
expect(data.label).toBe('keep me')
199+
})
200+
})
201+
202+
// ─── Full Lexical document round-trips ───────────────────────────────────────
203+
204+
describe('processRichTextField — full document', () => {
205+
it('should produce a stable result when called on an already-processed object', () => {
206+
const doc = lexicalDoc()
207+
const firstPass = processRichTextField(doc)
208+
const secondPass = processRichTextField(firstPass)
209+
expect(secondPass).toEqual(firstPass)
210+
})
211+
212+
it('should fully round-trip a complex Lexical document serialised to JSON', () => {
213+
const doc = lexicalDoc([
214+
paragraphNode([
215+
textNode({ text: 'Bold', format: '1', detail: '0', indent: '0', version: '1' }),
216+
textNode({ text: ' normal', format: '0', version: '1' }),
217+
]),
218+
{
219+
type: 'list',
220+
listType: 'number',
221+
start: '1',
222+
version: '1',
223+
indent: '0',
224+
direction: 'ltr',
225+
children: [
226+
{ type: 'listitem', value: '1', detail: '0', format: '0', version: '1', text: 'Item A' },
227+
{ type: 'listitem', value: '2', detail: '0', format: '0', version: '1', text: 'Item B' },
228+
],
229+
},
230+
])
231+
232+
const result = asRecord(processRichTextField(JSON.stringify(doc)))
233+
234+
const para = asRecord(asArray(result.children)[0])
235+
const boldText = asRecord(asArray(para.children)[0])
236+
expect(boldText.format).toBe(1)
237+
expect(boldText.detail).toBe(0)
238+
239+
const list = asRecord(asArray(result.children)[1])
240+
expect(list.start).toBe(1)
241+
expect(list.indent).toBe(0)
242+
243+
const itemA = asRecord(asArray(list.children)[0])
244+
const itemB = asRecord(asArray(list.children)[1])
245+
expect(itemA.value).toBe(1)
246+
expect(itemB.value).toBe(2)
247+
})
248+
})

packages/plugin-import-export/src/utilities/processRichTextField.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,14 @@
33
* Lexical expects certain properties to be numbers, not strings.
44
*/
55
export const processRichTextField = (value: unknown): unknown => {
6+
if (typeof value === 'string') {
7+
try {
8+
value = JSON.parse(value)
9+
} catch {
10+
return value
11+
}
12+
}
13+
614
if (!value || typeof value !== 'object') {
715
return value
816
}

packages/plugin-import-export/src/utilities/unflattenObject.spec.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { FlattenedField, PayloadRequest } from 'payload'
22

3+
import { getImportFieldFunctions } from './getImportFieldFunctions.js'
34
import { unflattenObject } from './unflattenObject.js'
45

56
import { describe, it, expect, vi } from 'vitest'
@@ -564,6 +565,95 @@ describe('unflattenObject', () => {
564565
})
565566
})
566567

568+
describe('blocks with nested arrays', () => {
569+
const faqFields: FlattenedField[] = [
570+
{
571+
name: 'faqContent',
572+
type: 'blocks',
573+
blocks: [
574+
{
575+
slug: 'faqSection',
576+
flattenedFields: [
577+
{
578+
name: 'faqs',
579+
type: 'array',
580+
flattenedFields: [
581+
{ name: 'id', type: 'text' },
582+
{ name: 'question', type: 'text' },
583+
{ name: 'answer', type: 'richText' },
584+
],
585+
},
586+
],
587+
},
588+
],
589+
},
590+
] as unknown as FlattenedField[]
591+
592+
it('should unflatten a nested array inside a block as an array, not an object with numeric keys', () => {
593+
const data = {
594+
faqContent_0_faqSection_id: '6a1714b81e5f4cdbb51f18b1',
595+
faqContent_0_faqSection_faqs_0_id: '6a1714c01e5f4cdbb51f18b2',
596+
faqContent_0_faqSection_faqs_0_question: 'ipsum',
597+
faqContent_0_faqSection_blockType: 'faqSection',
598+
}
599+
600+
const result = unflattenObject({ data, fields: faqFields, req: mockReq })
601+
602+
const faqContent = result.faqContent as Array<Record<string, unknown>>
603+
expect(Array.isArray(faqContent)).toBe(true)
604+
expect(faqContent).toHaveLength(1)
605+
expect(faqContent[0]!.blockType).toBe('faqSection')
606+
607+
const faqs = faqContent[0]!.faqs
608+
expect(Array.isArray(faqs)).toBe(true)
609+
610+
const faqItems = faqs as Array<Record<string, unknown>>
611+
expect(faqItems[0]!.question).toBe('ipsum')
612+
expect(faqItems[0]!.id).toBe('6a1714c01e5f4cdbb51f18b2')
613+
})
614+
615+
it('should parse a richText JSON string inside a nested array within a block', () => {
616+
const richTextValue = {
617+
root: {
618+
children: [
619+
{
620+
children: [
621+
{ detail: 0, format: 0, mode: 'normal', text: 'porksum', type: 'text', version: 1 },
622+
],
623+
direction: null,
624+
format: '',
625+
indent: 0,
626+
type: 'paragraph',
627+
version: 1,
628+
},
629+
],
630+
direction: null,
631+
format: '',
632+
indent: 0,
633+
type: 'root',
634+
version: 1,
635+
},
636+
}
637+
638+
const data = {
639+
faqContent_0_faqSection_id: '6a1714b81e5f4cdbb51f18b1',
640+
faqContent_0_faqSection_faqs_0_id: '6a1714c01e5f4cdbb51f18b2',
641+
faqContent_0_faqSection_faqs_0_question: 'ipsum',
642+
faqContent_0_faqSection_faqs_0_answer: JSON.stringify(richTextValue),
643+
faqContent_0_faqSection_blockType: 'faqSection',
644+
}
645+
646+
const importFieldHooks = getImportFieldFunctions({ fields: faqFields })
647+
const result = unflattenObject({ data, fields: faqFields, importFieldHooks, req: mockReq })
648+
649+
const faqContent = result.faqContent as Array<Record<string, unknown>>
650+
const faqs = faqContent[0]!.faqs as Array<Record<string, unknown>>
651+
652+
expect(typeof faqs[0]!.answer).not.toBe('string')
653+
expect(faqs[0]!.answer).toEqual(richTextValue)
654+
})
655+
})
656+
567657
describe('edge cases', () => {
568658
it('should handle empty data', () => {
569659
const result = unflattenObject({ data: {}, fields: [], req: mockReq })

0 commit comments

Comments
 (0)