fix(live-preview): client-side live preview failed to populate localized fields (#13794)

Fixes #13756 

The findByID endpoint, by default, expects the data for localized fields
to be an object, values mapped to the locale. This is not the case for
client-side live preview, as we send already-flattened data to the
findByID endpoint.

For localized fields where the value is an object (richText/json/group),
the afterRead hook handler would attempt to flatten the field value,
even though it was already flattened.

## Solution

The solution is to expose a `flattenLocales` arg to the findByID
endpoint (default: true) and pass `flattenLocales: false` from the
client-side live preview request handler.

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1211334752795627
This commit is contained in:
Alessio Gravili
2025-09-15 09:16:52 -07:00
committed by GitHub
parent 555228b712
commit b34e5eadf4
14 changed files with 262 additions and 131 deletions

View File

@@ -31,6 +31,12 @@ export const RelationshipsBlock: React.FC<RelationshipsBlockProps> = (props) =>
{data?.richTextLexical && (
<RichText content={data.richTextLexical} renderUploadFilenameOnly />
)}
<p>
<b>Rich Text Lexical Localized:</b>
</p>
{data?.richTextLexicalLocalized && (
<RichText content={data.richTextLexicalLocalized} renderUploadFilenameOnly />
)}
<p>
<b>Upload:</b>
</p>

View File

@@ -92,6 +92,18 @@ export const Pages: CollectionConfig = {
],
}),
},
{
label: 'Rich Text — Lexical — Localized',
type: 'richText',
name: 'richTextLexicalLocalized',
localized: true,
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures,
BlocksFeature({ blocks: ['mediaBlock'] }),
],
}),
},
{
name: 'relationshipAsUpload',
type: 'upload',

View File

@@ -129,7 +129,13 @@ describe('Collections - Live Preview', () => {
} as MessageEvent as LivePreviewMessageEvent<Page>,
initialData: {
title: 'Test Page',
id: 1,
id: 1 as number | string,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
hero: {
type: 'highImpact',
},
slug: 'testPage',
} as Page,
})
@@ -177,6 +183,24 @@ describe('Collections - Live Preview', () => {
expect(mergedData.title).toEqual('Test Page (Changed)')
})
it('— strings - merges localized data', async () => {
const initialData = await createPageWithInitialData({
title: 'Test Page',
})
const mergedData = await mergeData({
depth: 1,
incomingData: {
...initialData,
localizedTitle: 'Test Page (Changed)',
},
initialData,
collectionSlug: pagesSlug,
})
expect(mergedData.localizedTitle).toEqual('Test Page (Changed)')
})
it('— arrays - can clear all rows', async () => {
const initialData = await createPageWithInitialData({
title: 'Test Page',
@@ -751,7 +775,7 @@ describe('Collections - Live Preview', () => {
expect(merge2.richTextSlate[3].value).toMatchObject(media)
})
it('— relationships - populates within Lexical rich text editor', async () => {
async function lexicalTest(fieldName: string) {
const initialData = await createPageWithInitialData({
title: 'Test Page',
})
@@ -762,7 +786,7 @@ describe('Collections - Live Preview', () => {
collectionSlug: pagesSlug,
incomingData: {
...initialData,
richTextLexical: {
[fieldName]: {
root: {
type: 'root',
format: '',
@@ -804,12 +828,12 @@ describe('Collections - Live Preview', () => {
initialData,
})
expect(merge1.richTextLexical.root.children).toHaveLength(3)
expect(merge1.richTextLexical.root.children[0].type).toEqual('relationship')
expect(merge1.richTextLexical.root.children[0].value).toMatchObject(testPost)
expect(merge1.richTextLexical.root.children[1].type).toEqual('paragraph')
expect(merge1.richTextLexical.root.children[2].type).toEqual('upload')
expect(merge1.richTextLexical.root.children[2].value).toMatchObject(media)
expect(merge1[fieldName].root.children).toHaveLength(3)
expect(merge1[fieldName].root.children[0].type).toEqual('relationship')
expect(merge1[fieldName].root.children[0].value).toMatchObject(testPost)
expect(merge1[fieldName].root.children[1].type).toEqual('paragraph')
expect(merge1[fieldName].root.children[2].type).toEqual('upload')
expect(merge1[fieldName].root.children[2].value).toMatchObject(media)
// Add a node before the populated one
const merge2 = await mergeData({
@@ -817,7 +841,7 @@ describe('Collections - Live Preview', () => {
collectionSlug: pagesSlug,
incomingData: {
...merge1,
richTextLexical: {
[fieldName]: {
root: {
type: 'root',
format: '',
@@ -867,13 +891,23 @@ describe('Collections - Live Preview', () => {
initialData: merge1,
})
expect(merge2.richTextLexical.root.children).toHaveLength(4)
expect(merge2.richTextLexical.root.children[0].type).toEqual('relationship')
expect(merge2.richTextLexical.root.children[0].value).toMatchObject(testPost)
expect(merge2.richTextLexical.root.children[1].type).toEqual('paragraph')
expect(merge2.richTextLexical.root.children[2].type).toEqual('paragraph')
expect(merge2.richTextLexical.root.children[3].type).toEqual('upload')
expect(merge2.richTextLexical.root.children[3].value).toMatchObject(media)
expect(merge2[fieldName].root.children).toHaveLength(4)
expect(merge2[fieldName].root.children[0].type).toEqual('relationship')
expect(merge2[fieldName].root.children[0].value).toMatchObject(testPost)
expect(merge2[fieldName].root.children[1].type).toEqual('paragraph')
expect(merge2[fieldName].root.children[2].type).toEqual('paragraph')
expect(merge2[fieldName].root.children[3].type).toEqual('upload')
expect(merge2[fieldName].root.children[3].value).toMatchObject(media)
}
// eslint-disable-next-line jest/expect-expect
it('— relationships - populates within Lexical rich text editor', async () => {
await lexicalTest('richTextLexical')
})
// eslint-disable-next-line jest/expect-expect
it('— relationships - populates within Localized Lexical rich text editor', async () => {
await lexicalTest('richTextLexicalLocalized')
})
it('— relationships - re-populates externally updated relationships', async () => {

View File

@@ -98,7 +98,7 @@ export interface Config {
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
};
db: {
defaultIDType: number;
defaultIDType: string;
};
globals: {
header: Header;
@@ -142,7 +142,7 @@ export interface UserAuthOperations {
export interface MediaBlock {
invertBackground?: boolean | null;
position?: ('default' | 'fullscreen') | null;
media: number | Media;
media: string | Media;
id?: string | null;
blockName?: string | null;
blockType: 'mediaBlock';
@@ -152,7 +152,7 @@ export interface MediaBlock {
* via the `definition` "media".
*/
export interface Media {
id: number;
id: string;
alt: string;
updatedAt: string;
createdAt: string;
@@ -171,7 +171,7 @@ export interface Media {
* via the `definition` "users".
*/
export interface User {
id: number;
id: string;
updatedAt: string;
createdAt: string;
email: string;
@@ -195,9 +195,9 @@ export interface User {
* via the `definition` "pages".
*/
export interface Page {
id: number;
id: string;
slug: string;
tenant?: (number | null) | Tenant;
tenant?: (string | null) | Tenant;
title: string;
hero: {
type: 'none' | 'highImpact' | 'lowImpact';
@@ -206,7 +206,7 @@ export interface Page {
[k: string]: unknown;
}[]
| null;
media?: (number | null) | Media;
media?: (string | null) | Media;
};
layout?:
| (
@@ -225,11 +225,11 @@ export interface Page {
reference?:
| ({
relationTo: 'posts';
value: number | Post;
value: string | Post;
} | null)
| ({
relationTo: 'pages';
value: number | Page;
value: string | Page;
} | null);
url?: string | null;
label: string;
@@ -262,11 +262,11 @@ export interface Page {
reference?:
| ({
relationTo: 'posts';
value: number | Post;
value: string | Post;
} | null)
| ({
relationTo: 'pages';
value: number | Page;
value: string | Page;
} | null);
url?: string | null;
label: string;
@@ -285,7 +285,7 @@ export interface Page {
| {
invertBackground?: boolean | null;
position?: ('default' | 'fullscreen') | null;
media: number | Media;
media: string | Media;
id?: string | null;
blockName?: string | null;
blockType: 'mediaBlock';
@@ -298,12 +298,12 @@ export interface Page {
| null;
populateBy?: ('collection' | 'selection') | null;
relationTo?: 'posts' | null;
categories?: (number | Category)[] | null;
categories?: (string | Category)[] | null;
limit?: number | null;
selectedDocs?:
| {
relationTo: 'posts';
value: number | Post;
value: string | Post;
}[]
| null;
/**
@@ -312,7 +312,7 @@ export interface Page {
populatedDocs?:
| {
relationTo: 'posts';
value: number | Post;
value: string | Post;
}[]
| null;
/**
@@ -326,7 +326,7 @@ export interface Page {
)[]
| null;
localizedTitle?: string | null;
relationToLocalized?: (number | null) | Post;
relationToLocalized?: (string | null) | Post;
richTextSlate?:
| {
[k: string]: unknown;
@@ -347,22 +347,37 @@ export interface Page {
};
[k: string]: unknown;
} | null;
relationshipAsUpload?: (number | null) | Media;
relationshipMonoHasOne?: (number | null) | Post;
relationshipMonoHasMany?: (number | Post)[] | null;
richTextLexicalLocalized?: {
root: {
type: string;
children: {
type: string;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
relationshipAsUpload?: (string | null) | Media;
relationshipMonoHasOne?: (string | null) | Post;
relationshipMonoHasMany?: (string | Post)[] | null;
relationshipPolyHasOne?: {
relationTo: 'posts';
value: number | Post;
value: string | Post;
} | null;
relationshipPolyHasMany?:
| {
relationTo: 'posts';
value: number | Post;
value: string | Post;
}[]
| null;
arrayOfRelationships?:
| {
uploadInArray?: (number | null) | Media;
uploadInArray?: (string | null) | Media;
richTextInArray?: {
root: {
type: string;
@@ -378,28 +393,28 @@ export interface Page {
};
[k: string]: unknown;
} | null;
relationshipInArrayMonoHasOne?: (number | null) | Post;
relationshipInArrayMonoHasMany?: (number | Post)[] | null;
relationshipInArrayMonoHasOne?: (string | null) | Post;
relationshipInArrayMonoHasMany?: (string | Post)[] | null;
relationshipInArrayPolyHasOne?: {
relationTo: 'posts';
value: number | Post;
value: string | Post;
} | null;
relationshipInArrayPolyHasMany?:
| {
relationTo: 'posts';
value: number | Post;
value: string | Post;
}[]
| null;
id?: string | null;
}[]
| null;
tab?: {
relationshipInTab?: (number | null) | Post;
relationshipInTab?: (string | null) | Post;
};
meta?: {
title?: string | null;
description?: string | null;
image?: (number | null) | Media;
image?: (string | null) | Media;
};
updatedAt: string;
createdAt: string;
@@ -409,7 +424,7 @@ export interface Page {
* via the `definition` "tenants".
*/
export interface Tenant {
id: number;
id: string;
title: string;
clientURL: string;
updatedAt: string;
@@ -420,9 +435,9 @@ export interface Tenant {
* via the `definition` "posts".
*/
export interface Post {
id: number;
id: string;
slug: string;
tenant?: (number | null) | Tenant;
tenant?: (string | null) | Tenant;
title: string;
hero: {
type: 'none' | 'highImpact' | 'lowImpact';
@@ -431,7 +446,7 @@ export interface Post {
[k: string]: unknown;
}[]
| null;
media?: (number | null) | Media;
media?: (string | null) | Media;
};
layout?:
| (
@@ -450,11 +465,11 @@ export interface Post {
reference?:
| ({
relationTo: 'posts';
value: number | Post;
value: string | Post;
} | null)
| ({
relationTo: 'pages';
value: number | Page;
value: string | Page;
} | null);
url?: string | null;
label: string;
@@ -487,11 +502,11 @@ export interface Post {
reference?:
| ({
relationTo: 'posts';
value: number | Post;
value: string | Post;
} | null)
| ({
relationTo: 'pages';
value: number | Page;
value: string | Page;
} | null);
url?: string | null;
label: string;
@@ -510,7 +525,7 @@ export interface Post {
| {
invertBackground?: boolean | null;
position?: ('default' | 'fullscreen') | null;
media: number | Media;
media: string | Media;
id?: string | null;
blockName?: string | null;
blockType: 'mediaBlock';
@@ -523,12 +538,12 @@ export interface Post {
| null;
populateBy?: ('collection' | 'selection') | null;
relationTo?: 'posts' | null;
categories?: (number | Category)[] | null;
categories?: (string | Category)[] | null;
limit?: number | null;
selectedDocs?:
| {
relationTo: 'posts';
value: number | Post;
value: string | Post;
}[]
| null;
/**
@@ -537,7 +552,7 @@ export interface Post {
populatedDocs?:
| {
relationTo: 'posts';
value: number | Post;
value: string | Post;
}[]
| null;
/**
@@ -550,12 +565,12 @@ export interface Post {
}
)[]
| null;
relatedPosts?: (number | Post)[] | null;
relatedPosts?: (string | Post)[] | null;
localizedTitle?: string | null;
meta?: {
title?: string | null;
description?: string | null;
image?: (number | null) | Media;
image?: (string | null) | Media;
};
updatedAt: string;
createdAt: string;
@@ -566,7 +581,7 @@ export interface Post {
* via the `definition` "categories".
*/
export interface Category {
id: number;
id: string;
title?: string | null;
updatedAt: string;
createdAt: string;
@@ -576,9 +591,9 @@ export interface Category {
* via the `definition` "ssr".
*/
export interface Ssr {
id: number;
id: string;
slug: string;
tenant?: (number | null) | Tenant;
tenant?: (string | null) | Tenant;
title: string;
hero: {
type: 'none' | 'highImpact' | 'lowImpact';
@@ -587,7 +602,7 @@ export interface Ssr {
[k: string]: unknown;
}[]
| null;
media?: (number | null) | Media;
media?: (string | null) | Media;
};
layout?:
| (
@@ -606,11 +621,11 @@ export interface Ssr {
reference?:
| ({
relationTo: 'posts';
value: number | Post;
value: string | Post;
} | null)
| ({
relationTo: 'pages';
value: number | Page;
value: string | Page;
} | null);
url?: string | null;
label: string;
@@ -643,11 +658,11 @@ export interface Ssr {
reference?:
| ({
relationTo: 'posts';
value: number | Post;
value: string | Post;
} | null)
| ({
relationTo: 'pages';
value: number | Page;
value: string | Page;
} | null);
url?: string | null;
label: string;
@@ -666,7 +681,7 @@ export interface Ssr {
| {
invertBackground?: boolean | null;
position?: ('default' | 'fullscreen') | null;
media: number | Media;
media: string | Media;
id?: string | null;
blockName?: string | null;
blockType: 'mediaBlock';
@@ -679,12 +694,12 @@ export interface Ssr {
| null;
populateBy?: ('collection' | 'selection') | null;
relationTo?: 'posts' | null;
categories?: (number | Category)[] | null;
categories?: (string | Category)[] | null;
limit?: number | null;
selectedDocs?:
| {
relationTo: 'posts';
value: number | Post;
value: string | Post;
}[]
| null;
/**
@@ -693,7 +708,7 @@ export interface Ssr {
populatedDocs?:
| {
relationTo: 'posts';
value: number | Post;
value: string | Post;
}[]
| null;
/**
@@ -709,7 +724,7 @@ export interface Ssr {
meta?: {
title?: string | null;
description?: string | null;
image?: (number | null) | Media;
image?: (string | null) | Media;
};
updatedAt: string;
createdAt: string;
@@ -719,9 +734,9 @@ export interface Ssr {
* via the `definition` "ssr-autosave".
*/
export interface SsrAutosave {
id: number;
id: string;
slug: string;
tenant?: (number | null) | Tenant;
tenant?: (string | null) | Tenant;
title: string;
hero: {
type: 'none' | 'highImpact' | 'lowImpact';
@@ -730,7 +745,7 @@ export interface SsrAutosave {
[k: string]: unknown;
}[]
| null;
media?: (number | null) | Media;
media?: (string | null) | Media;
};
layout?:
| (
@@ -749,11 +764,11 @@ export interface SsrAutosave {
reference?:
| ({
relationTo: 'posts';
value: number | Post;
value: string | Post;
} | null)
| ({
relationTo: 'pages';
value: number | Page;
value: string | Page;
} | null);
url?: string | null;
label: string;
@@ -786,11 +801,11 @@ export interface SsrAutosave {
reference?:
| ({
relationTo: 'posts';
value: number | Post;
value: string | Post;
} | null)
| ({
relationTo: 'pages';
value: number | Page;
value: string | Page;
} | null);
url?: string | null;
label: string;
@@ -809,7 +824,7 @@ export interface SsrAutosave {
| {
invertBackground?: boolean | null;
position?: ('default' | 'fullscreen') | null;
media: number | Media;
media: string | Media;
id?: string | null;
blockName?: string | null;
blockType: 'mediaBlock';
@@ -822,12 +837,12 @@ export interface SsrAutosave {
| null;
populateBy?: ('collection' | 'selection') | null;
relationTo?: 'posts' | null;
categories?: (number | Category)[] | null;
categories?: (string | Category)[] | null;
limit?: number | null;
selectedDocs?:
| {
relationTo: 'posts';
value: number | Post;
value: string | Post;
}[]
| null;
/**
@@ -836,7 +851,7 @@ export interface SsrAutosave {
populatedDocs?:
| {
relationTo: 'posts';
value: number | Post;
value: string | Post;
}[]
| null;
/**
@@ -852,7 +867,7 @@ export interface SsrAutosave {
meta?: {
title?: string | null;
description?: string | null;
image?: (number | null) | Media;
image?: (string | null) | Media;
};
updatedAt: string;
createdAt: string;
@@ -865,7 +880,7 @@ export interface SsrAutosave {
* via the `definition` "collection-level-config".
*/
export interface CollectionLevelConfig {
id: number;
id: string;
title?: string | null;
updatedAt: string;
createdAt: string;
@@ -875,48 +890,48 @@ export interface CollectionLevelConfig {
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: number;
id: string;
document?:
| ({
relationTo: 'users';
value: number | User;
value: string | User;
} | null)
| ({
relationTo: 'pages';
value: number | Page;
value: string | Page;
} | null)
| ({
relationTo: 'posts';
value: number | Post;
value: string | Post;
} | null)
| ({
relationTo: 'ssr';
value: number | Ssr;
value: string | Ssr;
} | null)
| ({
relationTo: 'ssr-autosave';
value: number | SsrAutosave;
value: string | SsrAutosave;
} | null)
| ({
relationTo: 'tenants';
value: number | Tenant;
value: string | Tenant;
} | null)
| ({
relationTo: 'categories';
value: number | Category;
value: string | Category;
} | null)
| ({
relationTo: 'media';
value: number | Media;
value: string | Media;
} | null)
| ({
relationTo: 'collection-level-config';
value: number | CollectionLevelConfig;
value: string | CollectionLevelConfig;
} | null);
globalSlug?: string | null;
user: {
relationTo: 'users';
value: number | User;
value: string | User;
};
updatedAt: string;
createdAt: string;
@@ -926,10 +941,10 @@ export interface PayloadLockedDocument {
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: number;
id: string;
user: {
relationTo: 'users';
value: number | User;
value: string | User;
};
key?: string | null;
value?:
@@ -949,7 +964,7 @@ export interface PayloadPreference {
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: number;
id: string;
name?: string | null;
batch?: number | null;
updatedAt: string;
@@ -1071,6 +1086,7 @@ export interface PagesSelect<T extends boolean = true> {
relationToLocalized?: T;
richTextSlate?: T;
richTextLexical?: T;
richTextLexicalLocalized?: T;
relationshipAsUpload?: T;
relationshipMonoHasOne?: T;
relationshipMonoHasMany?: T;
@@ -1489,7 +1505,7 @@ export interface PayloadMigrationsSelect<T extends boolean = true> {
* via the `definition` "header".
*/
export interface Header {
id: number;
id: string;
navItems?:
| {
link: {
@@ -1498,11 +1514,11 @@ export interface Header {
reference?:
| ({
relationTo: 'posts';
value: number | Post;
value: string | Post;
} | null)
| ({
relationTo: 'pages';
value: number | Page;
value: string | Page;
} | null);
url?: string | null;
label: string;
@@ -1522,7 +1538,7 @@ export interface Header {
* via the `definition` "footer".
*/
export interface Footer {
id: number;
id: string;
navItems?:
| {
link: {
@@ -1531,11 +1547,11 @@ export interface Footer {
reference?:
| ({
relationTo: 'posts';
value: number | Post;
value: string | Post;
} | null)
| ({
relationTo: 'pages';
value: number | Page;
value: string | Page;
} | null);
url?: string | null;
label: string;

View File

@@ -9,6 +9,7 @@ export const home: Omit<Page, 'createdAt' | 'id' | 'updatedAt'> = {
description: 'This is an example of live preview on a page.',
},
tenant: '{{TENANT_1_ID}}',
localizedTitle: 'Localized Title',
hero: {
type: 'highImpact',
richText: [
@@ -169,6 +170,35 @@ export const home: Omit<Page, 'createdAt' | 'id' | 'updatedAt'> = {
direction: null,
},
},
richTextLexicalLocalized: {
root: {
type: 'root',
format: '',
indent: 0,
version: 1,
children: [
{
children: [],
direction: null,
format: '',
indent: 0,
type: 'paragraph',
version: 1,
},
{
format: '',
type: 'upload',
version: 1,
fields: null,
relationTo: 'media',
value: {
id: '{{MEDIA_ID}}',
},
},
],
direction: null,
},
},
relationshipMonoHasMany: ['{{POST_1_ID}}'],
relationshipMonoHasOne: '{{POST_1_ID}}',
relationshipPolyHasMany: [{ relationTo: 'posts', value: '{{POST_1_ID}}' }],