feat(live-preview): client-side live preview: simplify population, support hooks and lexical block population (#13619)

Alternative solution to
https://github.com/payloadcms/payload/pull/11104. Big thanks to
@andershermansen and @GermanJablo for kickstarting work on a solution
and bringing this to our attention. This PR copies over the live-preview
test suite example from his PR.

Fixes https://github.com/payloadcms/payload/issues/5285,
https://github.com/payloadcms/payload/issues/6071 and
https://github.com/payloadcms/payload/issues/8277. Potentially fixes
#11801

This PR completely gets rid of our client-side live preview field
traversal + population and all logic related to it, and instead lets the
findByID endpoint handle it.

The data sent through the live preview message event is now passed to
findByID via the newly added `data` attribute. The findByID endpoint
will then use this data and run hooks on it (which run population),
instead of fetching the data from the database.

This new API basically behaves like a `/api/populate?data=` endpoint,
with the benefit that it runs all the hooks. Another use-case for it
will be rendering lexical data. Sometimes you may only have unpopulated
data available. This functionality allows you to then populate the
lexical portion of it on-the-fly, so that you can properly render it to
JSX while displaying images.

## Benefits
- a lot less code to maintain. No duplicative population logic
- much faster - one single API request instead of one request per
relationship to populate
- all payload features are now correctly supported (population and
hooks)
- since hooks are now running for client-side live preview, this means
the `lexicalHTML` field is now supported! This was a long-running issue
- this fixes a lot of population inconsistencies that we previously did
not know of. For example, it previously populated lexical and slate
relationships even if the data was saved in an incorrect format

## [Method Override
(POST)](https://payloadcms.com/docs/rest-api/overview#using-method-override-post)
change

The population request to the findByID endpoint is sent as a post
request, so that we can pass through the `data` without having to
squeeze it into the url params. To do that, it uses the
`X-Payload-HTTP-Method-Override` header.

Previously, this functionality still expected the data to be sent
through as URL search params - just passed to the body instead of the
URL. In this PR, I made it possible to pass it as JSON instead. This
means:

- the receiving endpoint will receive the data under `req.data` and is
not able to read it from the search params
- this means existing endpoints won't support this functionality unless
they also attempt to read from req.data.
- for the purpose of this PR, the findByID endpoint was modified to
support this behavior. This functionality is documented as it can be
useful for user-defined endpoints as well.

Passing data as json has the following benefits:

- it's more performant - no need to serialize and deserialize data to
search params via `qs-esm`. This is especially important here, as we are
passing large amounts of json data
- the current implementation was serializing the data incorrectly,
leading to incorrect data within nested lexical nodes

**Note for people passing their own live preview `requestHandler`:**
instead of sending a GET request to populate documents, you will now
need to send a POST request to the findByID endpoint and pass additional
headers. Additionally, you will need to send through the arguments as
JSON instead of search params and include `data` as an argument. Here is
the updated defaultRequestHandler for reference:

```ts
const defaultRequestHandler: CollectionPopulationRequestHandler = ({
  apiPath,
  data,
  endpoint,
  serverURL,
}) => {
  const url = `${serverURL}${apiPath}/${endpoint}`

  return fetch(url, {
    body: JSON.stringify(data),
    credentials: 'include',
    headers: {
      'Content-Type': 'application/json',
      'X-Payload-HTTP-Method-Override': 'GET',
    },
    method: 'POST',
  })
}
```

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1211124793355068
  - https://app.asana.com/0/0/1211124793355066
This commit is contained in:
Alessio Gravili
2025-09-05 11:40:52 -07:00
committed by GitHub
parent 0c44c3bdd9
commit 6e203db33c
39 changed files with 523 additions and 1374 deletions

View File

@@ -773,3 +773,28 @@ const res = await fetch(`${api}/${collectionSlug}?depth=1&locale=en`, {
},
})
```
### Passing as JSON
When using `X-Payload-HTTP-Method-Override`, it expects the body to be a query string. If you want to pass JSON instead, you can set the `Content-Type` to `application/json` and include the JSON body in the request.
#### Example
```ts
const res = await fetch(`${api}/${collectionSlug}/${id}`, {
// Only the findByID endpoint supports HTTP method overrides with JSON data
method: 'POST',
credentials: 'include',
headers: {
'Accept-Language': i18n.language,
'Content-Type': 'application/json',
'X-Payload-HTTP-Method-Override': 'GET',
},
body: JSON.stringify({
depth: 1,
locale: 'en',
}),
})
```
This can be more efficient for large JSON payloads, as you avoid converting data to and from query strings. However, only certain endpoints support this. Supported endpoints will read the parsed body under a `data` property, instead of reading from query parameters as with standard GET requests.

View File

@@ -11,7 +11,7 @@ keywords: lexical, richtext, html
There are two main approaches to convert your Lexical-based rich text to HTML:
1. **Generate HTML on-demand (Recommended)**: Convert JSON to HTML wherever you need it, on-demand.
2. **Generate HTML within your Collection**: Create a new field that automatically converts your saved JSON content to HTML. This is not recommended because it adds overhead to the Payload API and may not work well with live preview.
2. **Generate HTML within your Collection**: Create a new field that automatically converts your saved JSON content to HTML. This is not recommended because it adds overhead to the Payload API.
### On-demand
@@ -101,10 +101,7 @@ export const MyRSCComponent = async ({
### HTML field
The `lexicalHTMLField()` helper converts JSON to HTML and saves it in a field that is updated every time you read it via an `afterRead` hook. It's generally not recommended for two reasons:
1. It creates a column with duplicate content in another format.
2. In [client-side live preview](/docs/live-preview/client), it makes it not "live".
The `lexicalHTMLField()` helper converts JSON to HTML and saves it in a field that is updated every time you read it via an `afterRead` hook. It's generally not recommended, as it creates a column with duplicate content in another format.
Consider using the [on-demand HTML converter above](/docs/rich-text/converting-html#on-demand-recommended) or the [JSX converter](/docs/rich-text/converting-jsx) unless you have a good reason.

View File

@@ -1,22 +1,12 @@
import type { FieldSchemaJSON } from 'payload'
import type { CollectionPopulationRequestHandler, LivePreviewMessageEvent } from './types.js'
import { isLivePreviewEvent } from './isLivePreviewEvent.js'
import { mergeData } from './mergeData.js'
const _payloadLivePreview: {
fieldSchema: FieldSchemaJSON | undefined
// eslint-disable-next-line @typescript-eslint/no-explicit-any
previousData: any
} = {
/**
* For performance reasons, `fieldSchemaJSON` will only be sent once on the initial message
* We need to cache this value so that it can be used across subsequent messages
* To do this, save `fieldSchemaJSON` when it arrives as a global variable
* Send this cached value to `mergeData`, instead of `eventData.fieldSchemaJSON` directly
*/
fieldSchema: undefined,
/**
* Each time the data is merged, cache the result as a `previousData` variable
* This will ensure changes compound overtop of each other
@@ -35,26 +25,13 @@ export const handleMessage = async <T extends Record<string, any>>(args: {
const { apiRoute, depth, event, initialData, requestHandler, serverURL } = args
if (isLivePreviewEvent(event, serverURL)) {
const { data, externallyUpdatedRelationship, fieldSchemaJSON, locale } = event.data
if (!_payloadLivePreview?.fieldSchema && fieldSchemaJSON) {
_payloadLivePreview.fieldSchema = fieldSchemaJSON
}
if (!_payloadLivePreview?.fieldSchema) {
// eslint-disable-next-line no-console
console.warn(
'Payload Live Preview: No `fieldSchemaJSON` was received from the parent window. Unable to merge data.',
)
return initialData
}
const { collectionSlug, data, globalSlug, locale } = event.data
const mergedData = await mergeData<T>({
apiRoute,
collectionSlug,
depth,
externallyUpdatedRelationship,
fieldSchema: _payloadLivePreview.fieldSchema,
globalSlug,
incomingData: data,
initialData: _payloadLivePreview?.previousData || initialData,
locale,

View File

@@ -4,6 +4,5 @@ export { isLivePreviewEvent } from './isLivePreviewEvent.js'
export { mergeData } from './mergeData.js'
export { ready } from './ready.js'
export { subscribe } from './subscribe.js'
export { traverseRichText } from './traverseRichText.js'
export type { LivePreviewMessageEvent } from './types.js'
export { unsubscribe } from './unsubscribe.js'

View File

@@ -1,115 +1,60 @@
import type { DocumentEvent, FieldSchemaJSON, PaginatedDocs } from 'payload'
import type { CollectionPopulationRequestHandler } from './types.js'
import type { CollectionPopulationRequestHandler, PopulationsByCollection } from './types.js'
import { traverseFields } from './traverseFields.js'
const defaultRequestHandler = ({
const defaultRequestHandler: CollectionPopulationRequestHandler = ({
apiPath,
data,
endpoint,
serverURL,
}: {
apiPath: string
endpoint: string
serverURL: string
}) => {
const url = `${serverURL}${apiPath}/${endpoint}`
return fetch(url, {
body: JSON.stringify(data),
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-Payload-HTTP-Method-Override': 'GET',
},
method: 'POST',
})
}
// Relationships are only updated when their `id` or `relationTo` changes, by comparing the old and new values
// This needs to also happen when locale changes, except this is not not part of the API response
// Instead, we keep track of the old locale ourselves and trigger a re-population when it changes
let prevLocale: string | undefined
export const mergeData = async <T extends Record<string, any>>(args: {
apiRoute?: string
/**
* @deprecated Use `requestHandler` instead
*/
collectionPopulationRequestHandler?: CollectionPopulationRequestHandler
collectionSlug?: string
depth?: number
externallyUpdatedRelationship?: DocumentEvent
fieldSchema: FieldSchemaJSON
globalSlug?: string
incomingData: Partial<T>
initialData: T
locale?: string
requestHandler?: CollectionPopulationRequestHandler
returnNumberOfRequests?: boolean
serverURL: string
}): Promise<
{
_numberOfRequests?: number
} & T
> => {
}): Promise<T> => {
const {
apiRoute,
collectionSlug,
depth,
externallyUpdatedRelationship,
fieldSchema,
globalSlug,
incomingData,
initialData,
locale,
returnNumberOfRequests,
serverURL,
} = args
const result = { ...initialData }
const requestHandler = args.requestHandler || defaultRequestHandler
const populationsByCollection: PopulationsByCollection = {}
const result = await requestHandler({
apiPath: apiRoute || '/api',
data: {
data: incomingData,
depth,
locale,
},
endpoint: encodeURI(
`${collectionSlug ?? globalSlug}${collectionSlug ? `/${initialData.id}` : ''}`,
),
serverURL,
}).then((res) => res.json())
traverseFields({
externallyUpdatedRelationship,
fieldSchema,
incomingData,
localeChanged: prevLocale !== locale,
populationsByCollection,
result,
})
await Promise.all(
Object.entries(populationsByCollection).map(async ([collection, populations]) => {
let res: PaginatedDocs
const ids = new Set(populations.map(({ id }) => id))
const requestHandler =
args.collectionPopulationRequestHandler || args.requestHandler || defaultRequestHandler
try {
res = await requestHandler({
apiPath: apiRoute || '/api',
endpoint: encodeURI(
`${collection}?depth=${depth}&limit=${ids.size}&where[id][in]=${Array.from(ids).join(',')}${locale ? `&locale=${locale}` : ''}`,
),
serverURL,
}).then((res) => res.json())
if (res?.docs?.length > 0) {
res.docs.forEach((doc) => {
populationsByCollection[collection]?.forEach((population) => {
if (population.id === doc.id) {
population.ref[population.accessor] = doc
}
})
})
}
} catch (err) {
console.error(err) // eslint-disable-line no-console
}
}),
)
prevLocale = locale
return {
...result,
...(returnNumberOfRequests
? { _numberOfRequests: Object.keys(populationsByCollection).length }
: {}),
}
return result
}

View File

@@ -1,299 +0,0 @@
import type { DocumentEvent, FieldSchemaJSON } from 'payload'
import type { PopulationsByCollection } from './types.js'
import { traverseRichText } from './traverseRichText.js'
export const traverseFields = <T extends Record<string, any>>(args: {
externallyUpdatedRelationship?: DocumentEvent
fieldSchema: FieldSchemaJSON
incomingData: T
localeChanged: boolean
populationsByCollection: PopulationsByCollection
result: Record<string, any>
}): void => {
const {
externallyUpdatedRelationship,
fieldSchema: fieldSchemas,
incomingData,
localeChanged,
populationsByCollection,
result,
} = args
fieldSchemas.forEach((fieldSchema) => {
if ('name' in fieldSchema && typeof fieldSchema.name === 'string') {
const fieldName = fieldSchema.name
switch (fieldSchema.type) {
case 'array':
if (
!incomingData[fieldName] &&
incomingData[fieldName] !== undefined &&
result?.[fieldName] !== undefined
) {
result[fieldName] = []
}
if (Array.isArray(incomingData[fieldName])) {
result[fieldName] = incomingData[fieldName].map((incomingRow, i) => {
if (!result[fieldName]) {
result[fieldName] = []
}
if (!result[fieldName][i]) {
result[fieldName][i] = {}
}
traverseFields({
externallyUpdatedRelationship,
fieldSchema: fieldSchema.fields!,
incomingData: incomingRow,
localeChanged,
populationsByCollection,
result: result[fieldName][i],
})
return result[fieldName][i]
})
}
break
case 'blocks':
if (Array.isArray(incomingData[fieldName])) {
result[fieldName] = incomingData[fieldName].map((incomingBlock, i) => {
const incomingBlockJSON = fieldSchema.blocks?.[incomingBlock.blockType]
if (!result[fieldName]) {
result[fieldName] = []
}
if (
!result[fieldName][i] ||
result[fieldName][i].id !== incomingBlock.id ||
result[fieldName][i].blockType !== incomingBlock.blockType
) {
result[fieldName][i] = {
blockType: incomingBlock.blockType,
}
}
traverseFields({
externallyUpdatedRelationship,
fieldSchema: incomingBlockJSON!.fields!,
incomingData: incomingBlock,
localeChanged,
populationsByCollection,
result: result[fieldName][i],
})
return result[fieldName][i]
})
} else {
result[fieldName] = []
}
break
case 'group':
// falls through
case 'tabs':
if (!result[fieldName]) {
result[fieldName] = {}
}
traverseFields({
externallyUpdatedRelationship,
fieldSchema: fieldSchema.fields!,
incomingData: incomingData[fieldName] || {},
localeChanged,
populationsByCollection,
result: result[fieldName],
})
break
case 'relationship':
// falls through
case 'upload':
// Handle `hasMany` relationships
if (fieldSchema.hasMany && Array.isArray(incomingData[fieldName])) {
if (!result[fieldName] || !incomingData[fieldName].length) {
result[fieldName] = []
}
incomingData[fieldName].forEach((incomingRelation, i) => {
// Handle `hasMany` polymorphic
if (Array.isArray(fieldSchema.relationTo)) {
// if the field doesn't exist on the result, create it
// the value will be populated later
if (!result[fieldName][i]) {
result[fieldName][i] = {
relationTo: incomingRelation.relationTo,
}
}
const oldID = result[fieldName][i]?.value?.id
const oldRelation = result[fieldName][i]?.relationTo
const newID = incomingRelation.value
const newRelation = incomingRelation.relationTo
const hasChanged = newID !== oldID || newRelation !== oldRelation
const hasUpdated =
newRelation === externallyUpdatedRelationship?.entitySlug &&
newID === externallyUpdatedRelationship?.id
if (hasChanged || hasUpdated || localeChanged) {
if (!populationsByCollection[newRelation]) {
populationsByCollection[newRelation] = []
}
populationsByCollection[newRelation].push({
id: incomingRelation.value,
accessor: 'value',
ref: result[fieldName][i],
})
}
} else {
// Handle `hasMany` monomorphic
const hasChanged = incomingRelation !== result[fieldName][i]?.id
const hasUpdated =
fieldSchema.relationTo === externallyUpdatedRelationship?.entitySlug &&
incomingRelation === externallyUpdatedRelationship?.id
if (hasChanged || hasUpdated || localeChanged) {
if (!populationsByCollection[fieldSchema.relationTo!]) {
populationsByCollection[fieldSchema.relationTo!] = []
}
populationsByCollection[fieldSchema.relationTo!]?.push({
id: incomingRelation,
accessor: i,
ref: result[fieldName],
})
}
}
})
} else {
// Handle `hasOne` polymorphic
if (Array.isArray(fieldSchema.relationTo)) {
// if the field doesn't exist on the result, create it
// the value will be populated later
if (!result[fieldName]) {
result[fieldName] = {
relationTo: incomingData[fieldName]?.relationTo,
}
}
const hasNewValue =
incomingData[fieldName] &&
typeof incomingData[fieldName] === 'object' &&
incomingData[fieldName] !== null
const hasOldValue =
result[fieldName] &&
typeof result[fieldName] === 'object' &&
result[fieldName] !== null
const newID = hasNewValue
? typeof incomingData[fieldName].value === 'object'
? incomingData[fieldName].value.id
: incomingData[fieldName].value
: ''
const oldID = hasOldValue
? typeof result[fieldName].value === 'object'
? result[fieldName].value.id
: result[fieldName].value
: ''
const newRelation = hasNewValue ? incomingData[fieldName].relationTo : ''
const oldRelation = hasOldValue ? result[fieldName].relationTo : ''
const hasChanged = newID !== oldID || newRelation !== oldRelation
const hasUpdated =
newRelation === externallyUpdatedRelationship?.entitySlug &&
newID === externallyUpdatedRelationship?.id
// if the new value/relation is different from the old value/relation
// populate the new value, otherwise leave it alone
if (hasChanged || hasUpdated || localeChanged) {
// if the new value is not empty, populate it
// otherwise set the value to null
if (newID) {
if (!populationsByCollection[newRelation]) {
populationsByCollection[newRelation] = []
}
populationsByCollection[newRelation].push({
id: newID,
accessor: 'value',
ref: result[fieldName],
})
} else {
result[fieldName] = null
}
}
} else {
// Handle `hasOne` monomorphic
const newID: number | string | undefined =
(incomingData[fieldName] &&
typeof incomingData[fieldName] === 'object' &&
incomingData[fieldName].id) ||
incomingData[fieldName]
const oldID: number | string | undefined =
(result[fieldName] &&
typeof result[fieldName] === 'object' &&
result[fieldName].id) ||
result[fieldName]
const hasChanged = newID !== oldID
const hasUpdated =
fieldSchema.relationTo === externallyUpdatedRelationship?.entitySlug &&
newID === externallyUpdatedRelationship?.id
// if the new value is different from the old value
// populate the new value, otherwise leave it alone
if (hasChanged || hasUpdated || localeChanged) {
// if the new value is not empty, populate it
// otherwise set the value to null
if (newID) {
if (!populationsByCollection[fieldSchema.relationTo!]) {
populationsByCollection[fieldSchema.relationTo!] = []
}
populationsByCollection[fieldSchema.relationTo!]?.push({
id: newID,
accessor: fieldName,
ref: result as Record<string, unknown>,
})
} else {
result[fieldName] = null
}
}
}
}
break
case 'richText':
result[fieldName] = traverseRichText({
externallyUpdatedRelationship,
incomingData: incomingData[fieldName],
populationsByCollection,
result: result[fieldName],
})
break
default:
result[fieldName] = incomingData[fieldName]
}
}
})
}

View File

@@ -1,105 +0,0 @@
import type { DocumentEvent } from 'payload'
import type { PopulationsByCollection } from './types.js'
export const traverseRichText = ({
externallyUpdatedRelationship,
incomingData,
populationsByCollection,
result,
}: {
externallyUpdatedRelationship?: DocumentEvent
incomingData: any
populationsByCollection: PopulationsByCollection
result: any
}): any => {
if (Array.isArray(incomingData)) {
if (!result) {
result = []
}
result = incomingData.map((item, index) => {
if (!result[index]) {
result[index] = item
}
return traverseRichText({
externallyUpdatedRelationship,
incomingData: item,
populationsByCollection,
result: result[index],
})
})
} else if (incomingData && typeof incomingData === 'object') {
if (!result) {
result = {}
}
// Remove keys from `result` that do not appear in `incomingData`
// There's likely another way to do this,
// But recursion and references make this very difficult
Object.keys(result).forEach((key) => {
if (!(key in incomingData)) {
delete result[key]
}
})
// Iterate over the keys of `incomingData` and populate `result`
Object.keys(incomingData).forEach((key) => {
if (!result[key]) {
// Instantiate the key in `result` if it doesn't exist
// Ensure its type matches the type of the `incomingData`
// We don't have a schema to check against here
result[key] =
incomingData[key] && typeof incomingData[key] === 'object'
? Array.isArray(incomingData[key])
? []
: {}
: undefined
}
const isRelationship = key === 'value' && 'relationTo' in incomingData
if (isRelationship) {
// or if there are no keys besides id
const needsPopulation =
!result.value ||
typeof result.value !== 'object' ||
(typeof result.value === 'object' &&
Object.keys(result.value).length === 1 &&
'id' in result.value)
const hasChanged =
result &&
typeof result === 'object' &&
result.value.id === externallyUpdatedRelationship?.id
if (needsPopulation || hasChanged) {
if (!populationsByCollection[incomingData.relationTo]) {
populationsByCollection[incomingData.relationTo] = []
}
populationsByCollection[incomingData.relationTo]?.push({
id:
incomingData[key] && typeof incomingData[key] === 'object'
? incomingData[key].id
: incomingData[key],
accessor: 'value',
ref: result,
})
}
} else {
result[key] = traverseRichText({
externallyUpdatedRelationship,
incomingData: incomingData[key],
populationsByCollection,
result: result[key],
})
}
})
} else {
result = incomingData
}
return result
}

View File

@@ -1,11 +1,13 @@
import type { DocumentEvent, FieldSchemaJSON } from 'payload'
import type { DocumentEvent } from 'payload'
export type CollectionPopulationRequestHandler = ({
apiPath,
data,
endpoint,
serverURL,
}: {
apiPath: string
data: Record<string, any>
endpoint: string
serverURL: string
}) => Promise<Response>
@@ -14,18 +16,11 @@ export type LivePreviewArgs = {}
export type LivePreview = void
export type PopulationsByCollection = {
[slug: string]: Array<{
accessor: number | string
id: number | string
ref: Record<string, unknown>
}>
}
export type LivePreviewMessageEvent<T> = MessageEvent<{
collectionSlug?: string
data: T
externallyUpdatedRelationship?: DocumentEvent
fieldSchemaJSON: FieldSchemaJSON
globalSlug?: string
locale?: string
type: 'payload-live-preview'
}>

View File

@@ -11,16 +11,21 @@ import { sanitizeSelectParam } from '../../utilities/sanitizeSelectParam.js'
import { findByIDOperation } from '../operations/findByID.js'
export const findByIDHandler: PayloadHandler = async (req) => {
const { searchParams } = req
const { data, searchParams } = req
const { id, collection } = getRequestCollectionWithID(req)
const depth = searchParams.get('depth')
const trash = searchParams.get('trash') === 'true'
const depth = data ? data.depth : searchParams.get('depth')
const trash = data ? data.trash : searchParams.get('trash') === 'true'
const result = await findByIDOperation({
id,
collection,
data: data
? data?.data
: searchParams.get('data')
? JSON.parse(searchParams.get('data') as string)
: undefined,
depth: isNumber(depth) ? Number(depth) : undefined,
draft: searchParams.get('draft') === 'true',
draft: data ? data.draft : searchParams.get('draft') === 'true',
joins: sanitizeJoinParams(req.query.joins as JoinParams),
populate: sanitizePopulateParam(req.query.populate),
req,

View File

@@ -32,6 +32,11 @@ import { buildAfterOperation } from './utils.js'
export type Arguments = {
collection: Collection
currentDepth?: number
/**
* You may pass the document data directly which will skip the `db.findOne` database query.
* This is useful if you want to use this endpoint solely for running hooks and populating data.
*/
data?: Record<string, unknown>
depth?: number
disableErrors?: boolean
draft?: boolean
@@ -163,7 +168,8 @@ export const findByIDOperation = async <
throw new NotFound(t)
}
let result: DataFromCollectionSlug<TSlug> = (await req.payload.db.findOne(findOneArgs))!
let result: DataFromCollectionSlug<TSlug> =
(args.data as DataFromCollectionSlug<TSlug>) ?? (await req.payload.db.findOne(findOneArgs))!
if (!result) {
if (!disableErrors) {

View File

@@ -41,6 +41,11 @@ export type Options<
* @internal
*/
currentDepth?: number
/**
* You may pass the document data directly which will skip the `db.findOne` database query.
* This is useful if you want to use this endpoint solely for running hooks and populating data.
*/
data?: Record<string, unknown>
/**
* [Control auto-population](https://payloadcms.com/docs/queries/depth) of nested relationship and upload fields.
*/
@@ -126,6 +131,7 @@ export async function findByIDLocal<
id,
collection: collectionSlug,
currentDepth,
data,
depth,
disableErrors = false,
draft = false,
@@ -150,6 +156,7 @@ export async function findByIDLocal<
id,
collection,
currentDepth,
data,
depth,
disableErrors,
draft,

View File

@@ -75,8 +75,6 @@ export {
export { extractID } from '../utilities/extractID.js'
export { fieldSchemaToJSON } from '../utilities/fieldSchemaToJSON.js'
export { flattenAllFields } from '../utilities/flattenAllFields.js'
export { flattenTopLevelFields } from '../utilities/flattenTopLevelFields.js'
export { formatAdminURL } from '../utilities/formatAdminURL.js'

View File

@@ -1671,7 +1671,6 @@ export {
type CustomVersionParser,
} from './utilities/dependencies/dependencyChecker.js'
export { getDependencies } from './utilities/dependencies/getDependencies.js'
export type { FieldSchemaJSON } from './utilities/fieldSchemaToJSON.js'
export {
findUp,
findUpSync,

View File

@@ -1,129 +0,0 @@
import type { ClientConfig } from '../config/client.js'
import type { ClientField } from '../fields/config/client.js'
import type { Field, FieldTypes } from '../fields/config/types.js'
import { fieldAffectsData } from '../fields/config/types.js'
export type FieldSchemaJSON = {
blocks?: FieldSchemaJSON // TODO: conditionally add based on `type`
fields?: FieldSchemaJSON // TODO: conditionally add based on `type`
hasMany?: boolean // TODO: conditionally add based on `type`
name: string
relationTo?: string // TODO: conditionally add based on `type`
slug?: string // TODO: conditionally add based on `type`
type: FieldTypes
}[]
export const fieldSchemaToJSON = (
fields: (ClientField | Field)[],
config: ClientConfig,
): FieldSchemaJSON => {
return fields.reduce((acc, field) => {
let result = acc
switch (field.type) {
case 'array':
acc.push({
name: field.name,
type: field.type,
fields: fieldSchemaToJSON(
[
...field.fields,
{
name: 'id',
type: 'text',
},
],
config,
),
})
break
case 'blocks':
acc.push({
name: field.name,
type: field.type,
blocks: (field.blockReferences ?? field.blocks).reduce((acc, _block) => {
const block = typeof _block === 'string' ? config.blocksMap[_block]! : _block
;(acc as any)[block.slug] = {
fields: fieldSchemaToJSON(
[
...block.fields,
{
name: 'id',
type: 'text',
},
],
config,
),
}
return acc
}, {} as FieldSchemaJSON),
})
break
case 'collapsible': // eslint-disable no-fallthrough
case 'row':
result = result.concat(fieldSchemaToJSON(field.fields, config))
break
case 'group':
if (fieldAffectsData(field)) {
acc.push({
name: field.name,
type: field.type,
fields: fieldSchemaToJSON(field.fields, config),
})
} else {
result = result.concat(fieldSchemaToJSON(field.fields, config))
}
break
case 'relationship': // eslint-disable no-fallthrough
case 'upload':
acc.push({
name: field.name,
type: field.type,
hasMany: 'hasMany' in field ? Boolean(field.hasMany) : false, // TODO: type this
relationTo: field.relationTo as string,
})
break
case 'tabs': {
let tabFields: FieldSchemaJSON = []
field.tabs.forEach((tab) => {
if ('name' in tab) {
tabFields.push({
name: tab.name,
type: field.type,
fields: fieldSchemaToJSON(tab.fields, config),
})
return
}
tabFields = tabFields.concat(fieldSchemaToJSON(tab.fields, config))
})
result = result.concat(tabFields)
break
}
default:
if ('name' in field) {
acc.push({
name: field.name,
type: field.type,
})
}
}
return result
}, [] as FieldSchemaJSON)
}

View File

@@ -84,21 +84,44 @@ export const handleEndpoints = async ({
(request.headers.get('X-Payload-HTTP-Method-Override') === 'GET' ||
request.headers.get('X-HTTP-Method-Override') === 'GET')
) {
const search = await request.text()
let url = request.url
let data: any = undefined
if (request.headers.get('Content-Type') === 'application/x-www-form-urlencoded') {
const search = await request.text()
url = `${request.url}?${search}`
} else if (request.headers.get('Content-Type') === 'application/json') {
// May not be supported by every endpoint
data = await request.json()
// locale and fallbackLocale is read by createPayloadRequest to populate req.locale and req.fallbackLocale
// => add to searchParams
if (data?.locale) {
url += `?locale=${data.locale}`
}
if (data?.fallbackLocale) {
url += `&fallbackLocale=${data.depth}`
}
}
const req = new Request(url, {
// @ts-expect-error // TODO: check if this is required
cache: request.cache,
credentials: request.credentials,
headers: request.headers,
method: 'GET',
signal: request.signal,
})
if (data) {
// @ts-expect-error attach data to request - less overhead than using urlencoded
req.data = data
}
const url = `${request.url}?${new URLSearchParams(search).toString()}`
const response = await handleEndpoints({
basePath,
config: incomingConfig,
path,
request: new Request(url, {
// @ts-expect-error // TODO: check if this is required
cache: request.cache,
credentials: request.credentials,
headers: request.headers,
method: 'GET',
signal: request.signal,
}),
request: req,
})
return response

View File

@@ -7,13 +7,14 @@ import React, { useEffect } from 'react'
import { useAllFormFields } from '../../../forms/Form/context.js'
import { useDocumentEvents } from '../../../providers/DocumentEvents/index.js'
import { useDocumentInfo } from '../../../providers/DocumentInfo/index.js'
import { useLivePreviewContext } from '../../../providers/LivePreview/context.js'
import { useLocale } from '../../../providers/Locale/index.js'
import { ShimmerEffect } from '../../ShimmerEffect/index.js'
import { DeviceContainer } from '../Device/index.js'
import './index.scss'
import { IFrame } from '../IFrame/index.js'
import { LivePreviewToolbar } from '../Toolbar/index.js'
import './index.scss'
const baseClass = 'live-preview-window'
@@ -21,7 +22,6 @@ export const LivePreviewWindow: React.FC<EditViewProps> = (props) => {
const {
appIsReady,
breakpoint,
fieldSchemaJSON,
iframeHasLoaded,
iframeRef,
isLivePreviewing,
@@ -35,10 +35,8 @@ export const LivePreviewWindow: React.FC<EditViewProps> = (props) => {
const { mostRecentUpdate } = useDocumentEvents()
const prevWindowType =
React.useRef<ReturnType<typeof useLivePreviewContext>['previewWindowType']>(undefined)
const [formState] = useAllFormFields()
const { id, collectionSlug, globalSlug } = useDocumentInfo()
// For client-side apps, send data through `window.postMessage`
// The preview could either be an iframe embedded on the page
@@ -53,20 +51,16 @@ export const LivePreviewWindow: React.FC<EditViewProps> = (props) => {
if (formState && window && 'postMessage' in window && appIsReady) {
const values = reduceFieldsToValues(formState, true)
// To reduce on large `postMessage` payloads, only send `fieldSchemaToJSON` one time
// To do this, the underlying JS function maintains a cache of this value
// So we need to send it through each time the window type changes
// But only once per window type change, not on every render, because this is a potentially large obj
const shouldSendSchema =
!prevWindowType.current || prevWindowType.current !== previewWindowType
prevWindowType.current = previewWindowType
if (!values.id) {
values.id = id
}
const message = {
type: 'payload-live-preview',
collectionSlug,
data: values,
externallyUpdatedRelationship: mostRecentUpdate,
fieldSchemaJSON: shouldSendSchema ? fieldSchemaJSON : undefined,
globalSlug,
locale: locale.code,
}
@@ -83,13 +77,15 @@ export const LivePreviewWindow: React.FC<EditViewProps> = (props) => {
}, [
formState,
url,
collectionSlug,
globalSlug,
iframeHasLoaded,
id,
previewWindowType,
popupRef,
appIsReady,
iframeRef,
setIframeHasLoaded,
fieldSchemaJSON,
mostRecentUpdate,
locale,
isLivePreviewing,

View File

@@ -1,6 +1,5 @@
'use client'
import type { LivePreviewConfig } from 'payload'
import type { fieldSchemaToJSON } from 'payload/shared'
import type { Dispatch } from 'react'
import type React from 'react'
@@ -13,7 +12,6 @@ export interface LivePreviewContextType {
appIsReady: boolean
breakpoint: LivePreviewConfig['breakpoints'][number]['name']
breakpoints: LivePreviewConfig['breakpoints']
fieldSchemaJSON?: ReturnType<typeof fieldSchemaToJSON>
iframeHasLoaded: boolean
iframeRef: React.RefObject<HTMLIFrameElement | null>
isLivePreviewEnabled: boolean
@@ -54,7 +52,6 @@ export const LivePreviewContext = createContext<LivePreviewContextType>({
appIsReady: false,
breakpoint: undefined,
breakpoints: undefined,
fieldSchemaJSON: undefined,
iframeHasLoaded: false,
iframeRef: undefined,
isLivePreviewEnabled: undefined,

View File

@@ -2,13 +2,11 @@
import type { CollectionPreferences, LivePreviewConfig } from 'payload'
import { DndContext } from '@dnd-kit/core'
import { fieldSchemaToJSON } from 'payload/shared'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { usePopupWindow } from '../../hooks/usePopupWindow.js'
import { useDocumentInfo } from '../../providers/DocumentInfo/index.js'
import { usePreferences } from '../../providers/Preferences/index.js'
import { useConfig } from '../Config/index.js'
import { customCollisionDetection } from './collisionDetection.js'
import { LivePreviewContext } from './context.js'
import { sizeReducer } from './sizeReducer.js'
@@ -90,8 +88,6 @@ export const LivePreviewProvider: React.FC<LivePreviewProviderProps> = ({
const [iframeHasLoaded, setIframeHasLoaded] = useState(false)
const { config, getEntityConfig } = useConfig()
const [zoom, setZoom] = useState(1)
const [position, setPosition] = useState({ x: 0, y: 0 })
@@ -103,13 +99,9 @@ export const LivePreviewProvider: React.FC<LivePreviewProviderProps> = ({
width: 0,
})
const entityConfig = getEntityConfig({ collectionSlug, globalSlug })
const [breakpoint, setBreakpoint] =
React.useState<LivePreviewConfig['breakpoints'][0]['name']>('responsive')
const [fieldSchemaJSON] = useState(() => fieldSchemaToJSON(entityConfig?.fields || [], config))
// The toolbar needs to freely drag and drop around the page
const handleDragEnd = (ev) => {
// only update position if the toolbar is completely within the preview area
@@ -232,7 +224,6 @@ export const LivePreviewProvider: React.FC<LivePreviewProviderProps> = ({
appIsReady,
breakpoint,
breakpoints,
fieldSchemaJSON,
iframeHasLoaded,
iframeRef,
isLivePreviewEnabled,

8
pnpm-lock.yaml generated
View File

@@ -44,7 +44,7 @@ importers:
version: 1.54.1
'@sentry/nextjs':
specifier: ^8.33.1
version: 8.37.1(@opentelemetry/core@1.27.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.54.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.27.0(@opentelemetry/api@1.9.0))(next@15.4.4(@opentelemetry/api@1.9.0)(@playwright/test@1.54.1)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.77.4))(react@19.1.0)(webpack@5.96.1(@swc/core@1.11.29))
version: 8.37.1(@opentelemetry/core@1.27.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.54.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.27.0(@opentelemetry/api@1.9.0))(next@15.4.4(@opentelemetry/api@1.9.0)(@playwright/test@1.54.1)(babel-plugin-macros@3.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.77.4))(react@19.1.0)(webpack@5.96.1(@swc/core@1.11.29))
'@sentry/node':
specifier: ^8.33.1
version: 8.37.1
@@ -1143,7 +1143,7 @@ importers:
dependencies:
'@sentry/nextjs':
specifier: ^8.33.1
version: 8.37.1(@opentelemetry/core@1.27.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.54.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.27.0(@opentelemetry/api@1.9.0))(next@15.4.4(@opentelemetry/api@1.9.0)(@playwright/test@1.54.1)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.77.4))(react@19.1.0)(webpack@5.96.1(@swc/core@1.11.29))
version: 8.37.1(@opentelemetry/core@1.27.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.54.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.27.0(@opentelemetry/api@1.9.0))(next@15.4.4(@opentelemetry/api@1.9.0)(@playwright/test@1.54.1)(babel-plugin-macros@3.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.77.4))(react@19.1.0)(webpack@5.96.1(@swc/core@1.11.29))
'@sentry/types':
specifier: ^8.33.1
version: 8.37.1
@@ -2045,7 +2045,7 @@ importers:
version: link:../packages/ui
'@sentry/nextjs':
specifier: ^8.33.1
version: 8.37.1(@opentelemetry/core@1.27.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.54.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.27.0(@opentelemetry/api@1.9.0))(next@15.4.4(@opentelemetry/api@1.9.0)(@playwright/test@1.54.1)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.77.4))(react@19.1.0)(webpack@5.96.1(@swc/core@1.11.29))
version: 8.37.1(@opentelemetry/core@1.27.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.54.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.27.0(@opentelemetry/api@1.9.0))(next@15.4.4(@opentelemetry/api@1.9.0)(@playwright/test@1.54.1)(babel-plugin-macros@3.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.77.4))(react@19.1.0)(webpack@5.96.1(@swc/core@1.11.29))
'@sentry/react':
specifier: ^7.77.0
version: 7.119.2(react@19.1.0)
@@ -16606,7 +16606,7 @@ snapshots:
'@sentry/utils': 7.119.2
localforage: 1.10.0
'@sentry/nextjs@8.37.1(@opentelemetry/core@1.27.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.54.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.27.0(@opentelemetry/api@1.9.0))(next@15.4.4(@opentelemetry/api@1.9.0)(@playwright/test@1.54.1)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.77.4))(react@19.1.0)(webpack@5.96.1(@swc/core@1.11.29))':
'@sentry/nextjs@8.37.1(@opentelemetry/core@1.27.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.54.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.27.0(@opentelemetry/api@1.9.0))(next@15.4.4(@opentelemetry/api@1.9.0)(@playwright/test@1.54.1)(babel-plugin-macros@3.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.77.4))(react@19.1.0)(webpack@5.96.1(@swc/core@1.11.29))':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/instrumentation-http': 0.53.0(@opentelemetry/api@1.9.0)

View File

@@ -36,7 +36,9 @@ export async function generateStaticParams() {
try {
const pages = await getDocs<Page>('pages')
return pages?.map(({ slug }) => slug)
return pages?.map((page) => {
return { slug: page.slug }
})
} catch (_err) {
return []
}

View File

@@ -38,7 +38,9 @@ export async function generateStaticParams() {
process.env.PAYLOAD_DROP_DATABASE = 'false'
try {
const ssrPosts = await getDocs<Post>(postsSlug)
return ssrPosts?.map(({ slug }) => slug)
return ssrPosts?.map((page) => {
return { slug: page.slug }
})
} catch (error) {
return []
}

View File

@@ -45,7 +45,9 @@ export async function generateStaticParams() {
process.env.PAYLOAD_DROP_DATABASE = 'false'
try {
const ssrPages = await getDocs<Page>(ssrAutosavePagesSlug)
return ssrPages?.map(({ slug }) => slug)
return ssrPages?.map((page) => {
return { slug: page.slug }
})
} catch (_err) {
return []
}

View File

@@ -45,7 +45,9 @@ export async function generateStaticParams() {
process.env.PAYLOAD_DROP_DATABASE = 'false'
try {
const ssrPages = await getDocs<Page>(ssrPagesSlug)
return ssrPages?.map(({ slug }) => slug)
return ssrPages?.map((page) => {
return { slug: page.slug }
})
} catch (_err) {
return []
}

View File

@@ -29,7 +29,7 @@ export const RelationshipsBlock: React.FC<RelationshipsBlockProps> = (props) =>
<b>Rich Text Lexical:</b>
</p>
{data?.richTextLexical && (
<RichText content={data.richTextLexical} renderUploadFilenameOnly serializer="lexical" />
<RichText content={data.richTextLexical} renderUploadFilenameOnly />
)}
<p>
<b>Upload:</b>

View File

@@ -52,6 +52,8 @@ export const Image: React.FC<MediaProps> = (props) => {
src = `${PAYLOAD_SERVER_URL}/api/media/file/${filename}`
}
if (!src) return null
// NOTE: this is used by the browser to determine which image to download at different screen sizes
const sizes = Object.entries(breakpoints)
.map(([, value]) => `(max-width: ${value}px) ${value}px`)

View File

@@ -8,12 +8,12 @@ const RichText: React.FC<{
className?: string
content: any
renderUploadFilenameOnly?: boolean
serializer?: 'lexical' | 'slate'
}> = ({ className, content, renderUploadFilenameOnly, serializer = 'slate' }) => {
}> = ({ className, content, renderUploadFilenameOnly }) => {
if (!content) {
return null
}
const serializer = Array.isArray(content) ? 'slate' : 'lexical'
return (
<div className={[classes.richText, className].filter(Boolean).join(' ')}>
{serializer === 'slate'
@@ -22,5 +22,4 @@ const RichText: React.FC<{
</div>
)
}
export default RichText

View File

@@ -1,12 +1,12 @@
import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
import type { DefaultTypedEditorState } from '@payloadcms/richtext-lexical'
import React from 'react'
import { CMSLink } from '../Link/index.js'
import { Media } from '../Media/index.js'
import { MediaBlock } from '../../_blocks/MediaBlock/index.js'
const serializer = (
content?: SerializedEditorState['root']['children'],
content?: DefaultTypedEditorState['root']['children'],
renderUploadFilenameOnly?: boolean,
): React.ReactNode | React.ReactNode[] =>
content?.map((node, i) => {
@@ -79,11 +79,17 @@ const serializer = (
}
return <Media key={i} resource={node?.value} />
case 'block':
switch (node.fields.blockType) {
case 'mediaBlock':
return <MediaBlock key={i} {...node.fields} />
}
}
})
const serializeLexical = (
content?: SerializedEditorState,
content?: DefaultTypedEditorState,
renderUploadFilenameOnly?: boolean,
): React.ReactNode | React.ReactNode[] => {
return serializer(content?.root?.children, renderUploadFilenameOnly)

View File

@@ -1,6 +1,6 @@
import type { CollectionConfig } from 'payload'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import { BlocksFeature, lexicalEditor } from '@payloadcms/richtext-lexical'
import { slateEditor } from '@payloadcms/richtext-slate'
import { Archive } from '../blocks/ArchiveBlock/index.js'
@@ -85,7 +85,12 @@ export const Pages: CollectionConfig = {
label: 'Rich Text — Lexical',
type: 'richText',
name: 'richTextLexical',
editor: lexicalEditor({}),
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures,
BlocksFeature({ blocks: ['mediaBlock'] }),
],
}),
},
{
name: 'relationshipAsUpload',

View File

@@ -3,6 +3,7 @@ import path from 'path'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { MediaBlock } from './blocks/MediaBlock/index.js'
import { Categories } from './collections/Categories.js'
import { CollectionLevelConfig } from './collections/CollectionLevelConfig.js'
import { Media } from './collections/Media.js'
@@ -61,4 +62,5 @@ export default buildConfigWithDefaults({
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
blocks: [MediaBlock],
})

File diff suppressed because it is too large Load Diff

View File

@@ -65,7 +65,9 @@ export interface Config {
auth: {
users: UserAuthOperations;
};
blocks: {};
blocks: {
mediaBlock: MediaBlock;
};
collections: {
users: User;
pages: Page;
@@ -96,7 +98,7 @@ export interface Config {
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
};
db: {
defaultIDType: string;
defaultIDType: number;
};
globals: {
header: Header;
@@ -133,12 +135,43 @@ export interface UserAuthOperations {
password: string;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "mediaBlock".
*/
export interface MediaBlock {
invertBackground?: boolean | null;
position?: ('default' | 'fullscreen') | null;
media: number | Media;
id?: string | null;
blockName?: string | null;
blockType: 'mediaBlock';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media".
*/
export interface Media {
id: number;
alt: string;
updatedAt: string;
createdAt: string;
url?: string | null;
thumbnailURL?: string | null;
filename?: string | null;
mimeType?: string | null;
filesize?: number | null;
width?: number | null;
height?: number | null;
focalX?: number | null;
focalY?: number | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
*/
export interface User {
id: string;
id: number;
updatedAt: string;
createdAt: string;
email: string;
@@ -162,9 +195,9 @@ export interface User {
* via the `definition` "pages".
*/
export interface Page {
id: string;
id: number;
slug: string;
tenant?: (string | null) | Tenant;
tenant?: (number | null) | Tenant;
title: string;
hero: {
type: 'none' | 'highImpact' | 'lowImpact';
@@ -173,7 +206,7 @@ export interface Page {
[k: string]: unknown;
}[]
| null;
media?: (string | null) | Media;
media?: (number | null) | Media;
};
layout?:
| (
@@ -192,11 +225,11 @@ export interface Page {
reference?:
| ({
relationTo: 'posts';
value: string | Post;
value: number | Post;
} | null)
| ({
relationTo: 'pages';
value: string | Page;
value: number | Page;
} | null);
url?: string | null;
label: string;
@@ -229,11 +262,11 @@ export interface Page {
reference?:
| ({
relationTo: 'posts';
value: string | Post;
value: number | Post;
} | null)
| ({
relationTo: 'pages';
value: string | Page;
value: number | Page;
} | null);
url?: string | null;
label: string;
@@ -252,7 +285,7 @@ export interface Page {
| {
invertBackground?: boolean | null;
position?: ('default' | 'fullscreen') | null;
media: string | Media;
media: number | Media;
id?: string | null;
blockName?: string | null;
blockType: 'mediaBlock';
@@ -265,12 +298,12 @@ export interface Page {
| null;
populateBy?: ('collection' | 'selection') | null;
relationTo?: 'posts' | null;
categories?: (string | Category)[] | null;
categories?: (number | Category)[] | null;
limit?: number | null;
selectedDocs?:
| {
relationTo: 'posts';
value: string | Post;
value: number | Post;
}[]
| null;
/**
@@ -279,7 +312,7 @@ export interface Page {
populatedDocs?:
| {
relationTo: 'posts';
value: string | Post;
value: number | Post;
}[]
| null;
/**
@@ -293,7 +326,7 @@ export interface Page {
)[]
| null;
localizedTitle?: string | null;
relationToLocalized?: (string | null) | Post;
relationToLocalized?: (number | null) | Post;
richTextSlate?:
| {
[k: string]: unknown;
@@ -314,22 +347,22 @@ export interface Page {
};
[k: string]: unknown;
} | null;
relationshipAsUpload?: (string | null) | Media;
relationshipMonoHasOne?: (string | null) | Post;
relationshipMonoHasMany?: (string | Post)[] | null;
relationshipAsUpload?: (number | null) | Media;
relationshipMonoHasOne?: (number | null) | Post;
relationshipMonoHasMany?: (number | Post)[] | null;
relationshipPolyHasOne?: {
relationTo: 'posts';
value: string | Post;
value: number | Post;
} | null;
relationshipPolyHasMany?:
| {
relationTo: 'posts';
value: string | Post;
value: number | Post;
}[]
| null;
arrayOfRelationships?:
| {
uploadInArray?: (string | null) | Media;
uploadInArray?: (number | null) | Media;
richTextInArray?: {
root: {
type: string;
@@ -345,28 +378,28 @@ export interface Page {
};
[k: string]: unknown;
} | null;
relationshipInArrayMonoHasOne?: (string | null) | Post;
relationshipInArrayMonoHasMany?: (string | Post)[] | null;
relationshipInArrayMonoHasOne?: (number | null) | Post;
relationshipInArrayMonoHasMany?: (number | Post)[] | null;
relationshipInArrayPolyHasOne?: {
relationTo: 'posts';
value: string | Post;
value: number | Post;
} | null;
relationshipInArrayPolyHasMany?:
| {
relationTo: 'posts';
value: string | Post;
value: number | Post;
}[]
| null;
id?: string | null;
}[]
| null;
tab?: {
relationshipInTab?: (string | null) | Post;
relationshipInTab?: (number | null) | Post;
};
meta?: {
title?: string | null;
description?: string | null;
image?: (string | null) | Media;
image?: (number | null) | Media;
};
updatedAt: string;
createdAt: string;
@@ -376,39 +409,20 @@ export interface Page {
* via the `definition` "tenants".
*/
export interface Tenant {
id: string;
id: number;
title: string;
clientURL: string;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media".
*/
export interface Media {
id: string;
alt: string;
updatedAt: string;
createdAt: string;
url?: string | null;
thumbnailURL?: string | null;
filename?: string | null;
mimeType?: string | null;
filesize?: number | null;
width?: number | null;
height?: number | null;
focalX?: number | null;
focalY?: number | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts".
*/
export interface Post {
id: string;
id: number;
slug: string;
tenant?: (string | null) | Tenant;
tenant?: (number | null) | Tenant;
title: string;
hero: {
type: 'none' | 'highImpact' | 'lowImpact';
@@ -417,7 +431,7 @@ export interface Post {
[k: string]: unknown;
}[]
| null;
media?: (string | null) | Media;
media?: (number | null) | Media;
};
layout?:
| (
@@ -436,11 +450,11 @@ export interface Post {
reference?:
| ({
relationTo: 'posts';
value: string | Post;
value: number | Post;
} | null)
| ({
relationTo: 'pages';
value: string | Page;
value: number | Page;
} | null);
url?: string | null;
label: string;
@@ -473,11 +487,11 @@ export interface Post {
reference?:
| ({
relationTo: 'posts';
value: string | Post;
value: number | Post;
} | null)
| ({
relationTo: 'pages';
value: string | Page;
value: number | Page;
} | null);
url?: string | null;
label: string;
@@ -496,7 +510,7 @@ export interface Post {
| {
invertBackground?: boolean | null;
position?: ('default' | 'fullscreen') | null;
media: string | Media;
media: number | Media;
id?: string | null;
blockName?: string | null;
blockType: 'mediaBlock';
@@ -509,12 +523,12 @@ export interface Post {
| null;
populateBy?: ('collection' | 'selection') | null;
relationTo?: 'posts' | null;
categories?: (string | Category)[] | null;
categories?: (number | Category)[] | null;
limit?: number | null;
selectedDocs?:
| {
relationTo: 'posts';
value: string | Post;
value: number | Post;
}[]
| null;
/**
@@ -523,7 +537,7 @@ export interface Post {
populatedDocs?:
| {
relationTo: 'posts';
value: string | Post;
value: number | Post;
}[]
| null;
/**
@@ -536,12 +550,12 @@ export interface Post {
}
)[]
| null;
relatedPosts?: (string | Post)[] | null;
relatedPosts?: (number | Post)[] | null;
localizedTitle?: string | null;
meta?: {
title?: string | null;
description?: string | null;
image?: (string | null) | Media;
image?: (number | null) | Media;
};
updatedAt: string;
createdAt: string;
@@ -552,7 +566,7 @@ export interface Post {
* via the `definition` "categories".
*/
export interface Category {
id: string;
id: number;
title?: string | null;
updatedAt: string;
createdAt: string;
@@ -562,9 +576,9 @@ export interface Category {
* via the `definition` "ssr".
*/
export interface Ssr {
id: string;
id: number;
slug: string;
tenant?: (string | null) | Tenant;
tenant?: (number | null) | Tenant;
title: string;
hero: {
type: 'none' | 'highImpact' | 'lowImpact';
@@ -573,7 +587,7 @@ export interface Ssr {
[k: string]: unknown;
}[]
| null;
media?: (string | null) | Media;
media?: (number | null) | Media;
};
layout?:
| (
@@ -592,11 +606,11 @@ export interface Ssr {
reference?:
| ({
relationTo: 'posts';
value: string | Post;
value: number | Post;
} | null)
| ({
relationTo: 'pages';
value: string | Page;
value: number | Page;
} | null);
url?: string | null;
label: string;
@@ -629,11 +643,11 @@ export interface Ssr {
reference?:
| ({
relationTo: 'posts';
value: string | Post;
value: number | Post;
} | null)
| ({
relationTo: 'pages';
value: string | Page;
value: number | Page;
} | null);
url?: string | null;
label: string;
@@ -652,7 +666,7 @@ export interface Ssr {
| {
invertBackground?: boolean | null;
position?: ('default' | 'fullscreen') | null;
media: string | Media;
media: number | Media;
id?: string | null;
blockName?: string | null;
blockType: 'mediaBlock';
@@ -665,12 +679,12 @@ export interface Ssr {
| null;
populateBy?: ('collection' | 'selection') | null;
relationTo?: 'posts' | null;
categories?: (string | Category)[] | null;
categories?: (number | Category)[] | null;
limit?: number | null;
selectedDocs?:
| {
relationTo: 'posts';
value: string | Post;
value: number | Post;
}[]
| null;
/**
@@ -679,7 +693,7 @@ export interface Ssr {
populatedDocs?:
| {
relationTo: 'posts';
value: string | Post;
value: number | Post;
}[]
| null;
/**
@@ -695,7 +709,7 @@ export interface Ssr {
meta?: {
title?: string | null;
description?: string | null;
image?: (string | null) | Media;
image?: (number | null) | Media;
};
updatedAt: string;
createdAt: string;
@@ -705,9 +719,9 @@ export interface Ssr {
* via the `definition` "ssr-autosave".
*/
export interface SsrAutosave {
id: string;
id: number;
slug: string;
tenant?: (string | null) | Tenant;
tenant?: (number | null) | Tenant;
title: string;
hero: {
type: 'none' | 'highImpact' | 'lowImpact';
@@ -716,7 +730,7 @@ export interface SsrAutosave {
[k: string]: unknown;
}[]
| null;
media?: (string | null) | Media;
media?: (number | null) | Media;
};
layout?:
| (
@@ -735,11 +749,11 @@ export interface SsrAutosave {
reference?:
| ({
relationTo: 'posts';
value: string | Post;
value: number | Post;
} | null)
| ({
relationTo: 'pages';
value: string | Page;
value: number | Page;
} | null);
url?: string | null;
label: string;
@@ -772,11 +786,11 @@ export interface SsrAutosave {
reference?:
| ({
relationTo: 'posts';
value: string | Post;
value: number | Post;
} | null)
| ({
relationTo: 'pages';
value: string | Page;
value: number | Page;
} | null);
url?: string | null;
label: string;
@@ -795,7 +809,7 @@ export interface SsrAutosave {
| {
invertBackground?: boolean | null;
position?: ('default' | 'fullscreen') | null;
media: string | Media;
media: number | Media;
id?: string | null;
blockName?: string | null;
blockType: 'mediaBlock';
@@ -808,12 +822,12 @@ export interface SsrAutosave {
| null;
populateBy?: ('collection' | 'selection') | null;
relationTo?: 'posts' | null;
categories?: (string | Category)[] | null;
categories?: (number | Category)[] | null;
limit?: number | null;
selectedDocs?:
| {
relationTo: 'posts';
value: string | Post;
value: number | Post;
}[]
| null;
/**
@@ -822,7 +836,7 @@ export interface SsrAutosave {
populatedDocs?:
| {
relationTo: 'posts';
value: string | Post;
value: number | Post;
}[]
| null;
/**
@@ -838,7 +852,7 @@ export interface SsrAutosave {
meta?: {
title?: string | null;
description?: string | null;
image?: (string | null) | Media;
image?: (number | null) | Media;
};
updatedAt: string;
createdAt: string;
@@ -851,7 +865,7 @@ export interface SsrAutosave {
* via the `definition` "collection-level-config".
*/
export interface CollectionLevelConfig {
id: string;
id: number;
title?: string | null;
updatedAt: string;
createdAt: string;
@@ -861,48 +875,48 @@ export interface CollectionLevelConfig {
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: string;
id: number;
document?:
| ({
relationTo: 'users';
value: string | User;
value: number | User;
} | null)
| ({
relationTo: 'pages';
value: string | Page;
value: number | Page;
} | null)
| ({
relationTo: 'posts';
value: string | Post;
value: number | Post;
} | null)
| ({
relationTo: 'ssr';
value: string | Ssr;
value: number | Ssr;
} | null)
| ({
relationTo: 'ssr-autosave';
value: string | SsrAutosave;
value: number | SsrAutosave;
} | null)
| ({
relationTo: 'tenants';
value: string | Tenant;
value: number | Tenant;
} | null)
| ({
relationTo: 'categories';
value: string | Category;
value: number | Category;
} | null)
| ({
relationTo: 'media';
value: string | Media;
value: number | Media;
} | null)
| ({
relationTo: 'collection-level-config';
value: string | CollectionLevelConfig;
value: number | CollectionLevelConfig;
} | null);
globalSlug?: string | null;
user: {
relationTo: 'users';
value: string | User;
value: number | User;
};
updatedAt: string;
createdAt: string;
@@ -912,10 +926,10 @@ export interface PayloadLockedDocument {
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: string;
id: number;
user: {
relationTo: 'users';
value: string | User;
value: number | User;
};
key?: string | null;
value?:
@@ -935,7 +949,7 @@ export interface PayloadPreference {
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: string;
id: number;
name?: string | null;
batch?: number | null;
updatedAt: string;
@@ -1475,7 +1489,7 @@ export interface PayloadMigrationsSelect<T extends boolean = true> {
* via the `definition` "header".
*/
export interface Header {
id: string;
id: number;
navItems?:
| {
link: {
@@ -1484,11 +1498,11 @@ export interface Header {
reference?:
| ({
relationTo: 'posts';
value: string | Post;
value: number | Post;
} | null)
| ({
relationTo: 'pages';
value: string | Page;
value: number | Page;
} | null);
url?: string | null;
label: string;
@@ -1508,7 +1522,7 @@ export interface Header {
* via the `definition` "footer".
*/
export interface Footer {
id: string;
id: number;
navItems?:
| {
link: {
@@ -1517,11 +1531,11 @@ export interface Footer {
reference?:
| ({
relationTo: 'posts';
value: string | Post;
value: number | Post;
} | null)
| ({
relationTo: 'pages';
value: string | Page;
value: number | Page;
} | null);
url?: string | null;
label: string;

View File

@@ -36,7 +36,9 @@ export async function generateStaticParams() {
try {
const pages = await getDocs<Page>('pages')
return pages?.map(({ slug }) => slug)
return pages?.map((page) => {
return { slug: page.slug }
})
} catch (_err) {
return []
}

View File

@@ -38,7 +38,9 @@ export async function generateStaticParams() {
process.env.PAYLOAD_DROP_DATABASE = 'false'
try {
const ssrPosts = await getDocs<Post>(postsSlug)
return ssrPosts?.map(({ slug }) => slug)
return ssrPosts?.map((page) => {
return { slug: page.slug }
})
} catch (error) {
return []
}

View File

@@ -45,7 +45,9 @@ export async function generateStaticParams() {
process.env.PAYLOAD_DROP_DATABASE = 'false'
try {
const ssrPages = await getDocs<Page>(ssrAutosavePagesSlug)
return ssrPages?.map(({ slug }) => slug)
return ssrPages?.map((page) => {
return { slug: page.slug }
})
} catch (_err) {
return []
}

View File

@@ -45,7 +45,9 @@ export async function generateStaticParams() {
process.env.PAYLOAD_DROP_DATABASE = 'false'
try {
const ssrPages = await getDocs<Page>(ssrPagesSlug)
return ssrPages?.map(({ slug }) => slug)
return ssrPages?.map((page) => {
return { slug: page.slug }
})
} catch (_err) {
return []
}

View File

@@ -29,7 +29,7 @@ export const RelationshipsBlock: React.FC<RelationshipsBlockProps> = (props) =>
<b>Rich Text Lexical:</b>
</p>
{data?.richTextLexical && (
<RichText content={data.richTextLexical} renderUploadFilenameOnly serializer="lexical" />
<RichText content={data.richTextLexical} renderUploadFilenameOnly />
)}
<p>
<b>Upload:</b>

View File

@@ -8,12 +8,12 @@ const RichText: React.FC<{
className?: string
content: any
renderUploadFilenameOnly?: boolean
serializer?: 'lexical' | 'slate'
}> = ({ className, content, renderUploadFilenameOnly, serializer = 'slate' }) => {
}> = ({ className, content, renderUploadFilenameOnly }) => {
if (!content) {
return null
}
const serializer = Array.isArray(content) ? 'slate' : 'lexical'
return (
<div className={[classes.richText, className].filter(Boolean).join(' ')}>
{serializer === 'slate'

View File

@@ -176,20 +176,44 @@ export const home: Omit<Page, 'createdAt' | 'id' | 'updatedAt'> = {
arrayOfRelationships: [
{
uploadInArray: '{{MEDIA_ID}}',
richTextInArray: [
{
richTextInArray: {
root: {
type: 'root',
format: '',
indent: 0,
version: 1,
children: [
{
text: ' ',
format: '',
type: 'relationship',
version: 1,
relationTo: postsSlug,
value: {
id: '{{POST_1_ID}}',
},
},
{
children: [],
direction: null,
format: '',
indent: 0,
type: 'paragraph',
version: 1,
},
{
format: '',
type: 'upload',
version: 1,
fields: null,
relationTo: 'media',
value: {
id: '{{MEDIA_ID}}',
},
},
],
relationTo: postsSlug,
type: 'relationship',
value: {
id: '{{POST_1_ID}}',
},
direction: null,
},
],
},
relationshipInArrayMonoHasMany: ['{{POST_1_ID}}'],
relationshipInArrayMonoHasOne: '{{POST_1_ID}}',
relationshipInArrayPolyHasMany: [{ relationTo: 'posts', value: '{{POST_1_ID}}' }],

View File

@@ -20,6 +20,20 @@ const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export const seed: Config['onInit'] = async (payload) => {
const existingUser = await payload.find({
collection: 'users',
where: {
email: {
equals: devUser.email,
},
},
})
// Seed already ran => this is likely a consecutive, uncached getPayload call
if (existingUser.docs.length) {
return
}
const uploadsDir = path.resolve(dirname, './media')
removeFiles(path.normalize(uploadsDir))