perf: deduplicate blocks used in multiple places using new config.blocks property (#10905)

If you have multiple blocks that are used in multiple places, this can quickly blow up the size of your Payload Config. This will incur a performance hit, as more data is
1.  sent to the client (=> bloated `ClientConfig` and large initial html) and
2. processed on the server (permissions are calculated every single time you navigate to a page - this iterates through all blocks you have defined, even if they're duplicative)

This can be optimized by defining your block **once** in your Payload Config, and just referencing the block slug whenever it's used, instead of passing the entire block config. To do this, the block can be defined in the `blocks` array of the Payload Config. The slug can then be passed to the `blockReferences` array in the Blocks Field - the `blocks` array has to be empty for compatibility reasons.

```ts
import { buildConfig } from 'payload'
import { lexicalEditor, BlocksFeature } from '@payloadcms/richtext-lexical'

// Payload Config
const config = buildConfig({
  // Define the block once
  blocks: [
    {
      slug: 'TextBlock',
      fields: [
        {
          name: 'text',
          type: 'text',
        },
      ],
    },
  ],
  collections: [
    {
      slug: 'collection1',
      fields: [
        {
          name: 'content',
          type: 'blocks',
          // Reference the block by slug
          blockReferences: ['TextBlock'],
          blocks: [], // Required to be empty, for compatibility reasons
        },
      ],
    },
     {
      slug: 'collection2',
      fields: [
        {
          name: 'editor',
          type: 'richText',
          editor: lexicalEditor({
            BlocksFeature({
              // Same reference can be reused anywhere, even in the lexical editor, without incurred performance hit
              blocks: ['TextBlock'],
            })
          })
        },
      ],
    },
  ],
})
```

## v4.0 Plans

In 4.0, we will remove the `blockReferences` property, and allow string block references to be passed directly to the blocks `property`. Essentially, we'd remove the `blocks` property and rename `blockReferences` to `blocks`.

The reason we opted to a new property in this PR is to avoid breaking changes. Allowing strings to be passed to the `blocks` property will prevent plugins that iterate through fields / blocks from compiling.

## PR Changes

- Testing: This PR introduces a plugin that automatically converts blocks to block references. This is done in the fields__blocks test suite, to run our existing test suite using block references.

- Block References support: Most changes are similar. Everywhere we iterate through blocks, we have to now do the following:
1. Check if `field.blockReferences` is provided. If so, only iterate through that.
2. Check if the block is an object (= actual block), or string
3. If it's a string, pull the actual block from the Payload Config or from `payload.blocks`.

The exception is config sanitization and block type generations. This PR optimizes them so that each block is only handled once, instead of every time the block is referenced.

## Benchmarks

60 Block fields, each block field having the same 600 Blocks.

### Before:
**Initial HTML:** 195 kB
**Generated types:** takes 11 minutes, 461,209 lines

https://github.com/user-attachments/assets/11d49a4e-5414-4579-8050-e6346e552f56

### After:
**Initial HTML:** 73.6 kB
**Generated types:** takes 2 seconds, 35,810 lines

https://github.com/user-attachments/assets/3eab1a99-6c29-489d-add5-698df67780a3

### After Permissions Optimization (follow-up PR)
Initial HTML: 73.6 kB

https://github.com/user-attachments/assets/a909202e-45a8-4bf6-9a38-8c85813f1312


## Future Plans

1. This PR does not yet deduplicate block references during permissions calculation. We'll optimize that in a separate PR, as this one is already large enough
2. The same optimization can be done to deduplicate fields. One common use-case would be link field groups that may be referenced in multiple entities, outside of blocks. We might explore adding a new `fieldReferences` property, that allows you to reference those same `config.blocks`.
This commit is contained in:
Alessio Gravili
2025-02-13 17:08:20 -07:00
committed by GitHub
parent 152a9b6adf
commit 4c8cafd6a6
113 changed files with 40826 additions and 1656 deletions

View File

@@ -283,6 +283,7 @@ jobs:
- fields-relationship
- fields__collections__Array
- fields__collections__Blocks
- fields__collections__Blocks#config.blockreferences.ts
- fields__collections__Checkbox
- fields__collections__Collapsible
- fields__collections__ConditionalLogic
@@ -293,6 +294,7 @@ jobs:
- fields__collections__JSON
- fields__collections__Lexical__e2e__main
- fields__collections__Lexical__e2e__blocks
- fields__collections__Lexical__e2e__blocks#config.blockreferences.ts
- fields__collections__Number
- fields__collections__Point
- fields__collections__Radio

View File

@@ -295,6 +295,70 @@ export const CustomBlocksFieldLabelClient: BlocksFieldLabelClientComponent = ({
}
```
## Block References
If you have multiple blocks used in multiple places, your Payload Config can grow in size, potentially sending more data to the client and requiring more processing on the server. However, you can optimize performance by defining each block **once** in your Payload Config and then referencing its slug wherever it's used instead of passing the entire block config.
To do this, define the block in the `blocks` array of the Payload Config. Then, in the Blocks Field, pass the block slug to the `blockReferences` array - leaving the `blocks` array empty for compatibility reasons.
```ts
import { buildConfig } from 'payload'
import { lexicalEditor, BlocksFeature } from '@payloadcms/richtext-lexical'
// Payload Config
const config = buildConfig({
// Define the block once
blocks: [
{
slug: 'TextBlock',
fields: [
{
name: 'text',
type: 'text',
},
],
},
],
collections: [
{
slug: 'collection1',
fields: [
{
name: 'content',
type: 'blocks',
// Reference the block by slug
blockReferences: ['TextBlock'],
blocks: [], // Required to be empty, for compatibility reasons
},
],
},
{
slug: 'collection2',
fields: [
{
name: 'editor',
type: 'richText',
editor: lexicalEditor({
BlocksFeature({
// Same reference can be reused anywhere, even in the lexical editor, without incurred performance hit
blocks: ['TextBlock'],
})
})
},
],
},
],
})
```
<Banner type="warning">
**Reminder:**
Blocks referenced in the `blockReferences` array are treated as isolated from the collection / global config. This has the following implications:
1. The block config cannot be modified or extended in the collection config. It will be identical everywhere it's referenced.
2. Access control for blocks referenced in the `blockReferences` are run only once - data from the collection will not be available in the block's access control.
</Banner>
## TypeScript
As you build your own Block configs, you might want to store them in separate files but retain typing accordingly. To do so, you can import and use Payload's `Block` type:

View File

@@ -3,7 +3,6 @@ import type { IndexOptions, Schema, SchemaOptions, SchemaTypeOptions } from 'mon
import mongoose from 'mongoose'
import {
type ArrayField,
type Block,
type BlocksField,
type CheckboxField,
type CodeField,
@@ -193,11 +192,12 @@ const fieldToSchemaMap: Record<string, FieldSchemaGenerator> = {
schema.add({
[field.name]: localizeSchema(field, fieldSchema, payload.config.localization),
})
field.blocks.forEach((blockItem: Block) => {
;(field.blockReferences ?? field.blocks).forEach((blockItem) => {
const blockSchema = new mongoose.Schema({}, { _id: false, id: false })
blockItem.fields.forEach((blockField) => {
const block = typeof blockItem === 'string' ? payload.blocks[blockItem] : blockItem
block.fields.forEach((blockField) => {
const addFieldSchema: FieldSchemaGenerator = fieldToSchemaMap[blockField.type]
if (addFieldSchema) {
addFieldSchema(blockField, blockSchema, payload, buildSchemaOptions)
@@ -207,11 +207,11 @@ const fieldToSchemaMap: Record<string, FieldSchemaGenerator> = {
if (field.localized && payload.config.localization) {
payload.config.localization.localeCodes.forEach((localeCode) => {
// @ts-expect-error Possible incorrect typing in mongoose types, this works
schema.path(`${field.name}.${localeCode}`).discriminator(blockItem.slug, blockSchema)
schema.path(`${field.name}.${localeCode}`).discriminator(block.slug, blockSchema)
})
} else {
// @ts-expect-error Possible incorrect typing in mongoose types, this works
schema.path(field.name).discriminator(blockItem.slug, blockSchema)
schema.path(field.name).discriminator(block.slug, blockSchema)
}
})
},

View File

@@ -80,6 +80,10 @@ const hasRelationshipOrUploadField = ({ fields }: { fields: Field[] }): boolean
if ('blocks' in field) {
for (const block of field.blocks) {
if (typeof block === 'string') {
// Skip - string blocks have been added in v3 and thus don't need to be migrated
continue
}
if (hasRelationshipOrUploadField({ fields: block.fields })) {
return true
}

View File

@@ -65,18 +65,24 @@ export const getLocalizedSortProperty = ({
}
if (matchedField.type === 'blocks') {
nextFields = matchedField.blocks.reduce((flattenedBlockFields, block) => {
return [
...flattenedBlockFields,
...block.flattenedFields.filter(
(blockField) =>
(fieldAffectsData(blockField) &&
blockField.name !== 'blockType' &&
blockField.name !== 'blockName') ||
!fieldAffectsData(blockField),
),
]
}, [])
nextFields = (matchedField.blockReferences ?? matchedField.blocks).reduce(
(flattenedBlockFields, _block) => {
// TODO: iterate over blocks mapped to block slug in v4, or pass through payload.blocks
const block =
typeof _block === 'string' ? config.blocks.find((b) => b.slug === _block) : _block
return [
...flattenedBlockFields,
...block.flattenedFields.filter(
(blockField) =>
(fieldAffectsData(blockField) &&
blockField.name !== 'blockType' &&
blockField.name !== 'blockName') ||
!fieldAffectsData(blockField),
),
]
},
[],
)
}
const result = incomingResult ? `${incomingResult}.${localizedSegment}` : localizedSegment

View File

@@ -1,4 +1,10 @@
import type { FlattenedBlock, FlattenedField, Payload, RelationshipField } from 'payload'
import type {
FlattenedBlock,
FlattenedBlocksField,
FlattenedField,
Payload,
RelationshipField,
} from 'payload'
import { Types } from 'mongoose'
import { createArrayFromCommaDelineated } from 'payload'
@@ -40,14 +46,18 @@ const buildExistsQuery = (formattedValue, path, treatEmptyString = true) => {
// returns nestedField Field object from blocks.nestedField path because getLocalizedPaths splits them only for relationships
const getFieldFromSegments = ({
field,
payload,
segments,
}: {
field: FlattenedBlock | FlattenedField
payload: Payload
segments: string[]
}) => {
if ('blocks' in field) {
for (const block of field.blocks) {
const field = getFieldFromSegments({ field: block, segments })
if ('blocks' in field || 'blockReferences' in field) {
const _field: FlattenedBlocksField = field as FlattenedBlocksField
for (const _block of _field.blockReferences ?? _field.blocks) {
const block: FlattenedBlock = typeof _block === 'string' ? payload.blocks[_block] : _block
const field = getFieldFromSegments({ field: block, payload, segments })
if (field) {
return field
}
@@ -67,7 +77,7 @@ const getFieldFromSegments = ({
}
segments.shift()
return getFieldFromSegments({ field: foundField, segments })
return getFieldFromSegments({ field: foundField, payload, segments })
}
}
}
@@ -91,7 +101,7 @@ export const sanitizeQueryValue = ({
if (['array', 'blocks', 'group', 'tab'].includes(field.type) && path.includes('.')) {
const segments = path.split('.')
segments.shift()
const foundField = getFieldFromSegments({ field, segments })
const foundField = getFieldFromSegments({ field, payload, segments })
if (foundField) {
field = foundField

View File

@@ -123,7 +123,8 @@ const traverseFields = ({
case 'blocks': {
const blocksSelect = select[field.name] as SelectType
for (const block of field.blocks) {
for (const _block of field.blockReferences ?? field.blocks) {
const block = typeof _block === 'string' ? adapter.payload.blocks[_block] : _block
if (
(selectMode === 'include' && blocksSelect[block.slug] === true) ||
(selectMode === 'exclude' && typeof blocksSelect[block.slug] === 'undefined')

View File

@@ -1,7 +1,7 @@
import type { CollectionConfig, Field, SanitizedConfig, TraverseFieldsCallback } from 'payload'
import { Types } from 'mongoose'
import { APIError, traverseFields } from 'payload'
import { traverseFields } from 'payload'
import { fieldAffectsData } from 'payload/shared'
type Args = {
@@ -150,7 +150,7 @@ export const sanitizeRelationshipIDs = ({
}
}
traverseFields({ callback: sanitize, fields, fillEmpty: false, ref: data })
traverseFields({ callback: sanitize, config, fields, fillEmpty: false, ref: data })
return data
}

View File

@@ -185,7 +185,8 @@ export const traverseFields = ({
}
}
field.blocks.forEach((block) => {
;(field.blockReferences ?? field.blocks).forEach((_block) => {
const block = typeof _block === 'string' ? adapter.payload.blocks[_block] : _block
const blockKey = `_blocks_${block.slug}`
let blockSelect: boolean | SelectType | undefined

View File

@@ -1,4 +1,4 @@
import type { FlattenedField } from 'payload'
import type { FlattenedBlock, FlattenedField } from 'payload'
type Args = {
doc: Record<string, unknown>
@@ -51,7 +51,10 @@ export const traverseFields = ({ doc, fields, locale, path, rows }: Args) => {
Object.entries(rowData).forEach(([locale, localeRows]) => {
if (Array.isArray(localeRows)) {
localeRows.forEach((row, i) => {
const matchedBlock = field.blocks.find((block) => block.slug === row.blockType)
// Can ignore string blocks, as those were added in v3 and don't need to be migrated
const matchedBlock = field.blocks.find(
(block) => typeof block !== 'string' && block.slug === row.blockType,
) as FlattenedBlock | undefined
if (matchedBlock) {
return traverseFields({
@@ -69,7 +72,10 @@ export const traverseFields = ({ doc, fields, locale, path, rows }: Args) => {
if (Array.isArray(rowData)) {
rowData.forEach((row, i) => {
const matchedBlock = field.blocks.find((block) => block.slug === row.blockType)
// Can ignore string blocks, as those were added in v3 and don't need to be migrated
const matchedBlock = field.blocks.find(
(block) => typeof block !== 'string' && block.slug === row.blockType,
) as FlattenedBlock | undefined
if (matchedBlock) {
return traverseFields({

View File

@@ -42,6 +42,11 @@ export const traverseFields = (args: Args) => {
case 'blocks': {
return field.blocks.forEach((block) => {
// Can ignore string blocks, as those were added in v3 and don't need to be migrated
if (typeof block === 'string') {
return
}
const newTableName = args.adapter.tableNameMap.get(
`${args.rootTableName}_blocks_${toSnakeCase(block.slug)}`,
)

View File

@@ -1,6 +1,6 @@
import type { SQL } from 'drizzle-orm'
import type { SQLiteTableWithColumns } from 'drizzle-orm/sqlite-core'
import type { FlattenedField, NumberField, TextField } from 'payload'
import type { FlattenedBlock, FlattenedField, NumberField, TextField } from 'payload'
import { and, eq, like, sql } from 'drizzle-orm'
import { type PgTableWithColumns } from 'drizzle-orm/pg-core'
@@ -176,7 +176,12 @@ export const getTableColumnFromPath = ({
// find the block config using the value
const blockTypes = Array.isArray(value) ? value : [value]
blockTypes.forEach((blockType) => {
const block = field.blocks.find((block) => block.slug === blockType)
const block =
adapter.payload.blocks[blockType] ??
((field.blockReferences ?? field.blocks).find(
(block) => typeof block !== 'string' && block.slug === blockType,
) as FlattenedBlock | undefined)
newTableName = adapter.tableNameMap.get(
`${tableName}_blocks_${toSnakeCase(block.slug)}`,
)
@@ -201,11 +206,13 @@ export const getTableColumnFromPath = ({
}
}
const hasBlockField = field.blocks.some((block) => {
const hasBlockField = (field.blockReferences ?? field.blocks).some((_block) => {
const block = typeof _block === 'string' ? adapter.payload.blocks[_block] : _block
newTableName = adapter.tableNameMap.get(`${tableName}_blocks_${toSnakeCase(block.slug)}`)
constraintPath = `${constraintPath}${field.name}.%.`
let result
let result: TableColumn
const blockConstraints = []
const blockSelectFields = {}
try {

View File

@@ -343,7 +343,9 @@ export const traverseFields = ({
case 'blocks': {
const disableNotNullFromHere = Boolean(field.admin?.condition) || disableNotNull
field.blocks.forEach((block) => {
;(field.blockReferences ?? field.blocks).forEach((_block) => {
const block = typeof _block === 'string' ? adapter.payload.blocks[_block] : _block
const blockTableName = createTableName({
adapter,
config: block,

View File

@@ -1,6 +1,6 @@
import type { FlattenedField, JoinQuery, SanitizedConfig } from 'payload'
import type { FlattenedBlock, FlattenedField, JoinQuery, SanitizedConfig } from 'payload'
import { fieldAffectsData, fieldIsVirtual } from 'payload/shared'
import { fieldIsVirtual } from 'payload/shared'
import type { DrizzleAdapter } from '../../types.js'
import type { BlocksMap } from '../../utilities/createBlocksMap.js'
@@ -216,7 +216,11 @@ export const traverseFields = <T extends Record<string, unknown>>({
Object.entries(result[field.name]).forEach(([locale, localizedBlocks]) => {
result[field.name][locale] = localizedBlocks.map((row) => {
const block = field.blocks.find(({ slug }) => slug === row.blockType)
const block =
adapter.payload.blocks[row.blockType] ??
((field.blockReferences ?? field.blocks).find(
(block) => typeof block !== 'string' && block.slug === row.blockType,
) as FlattenedBlock | undefined)
if (block) {
const blockResult = traverseFields<T>({
@@ -265,7 +269,16 @@ export const traverseFields = <T extends Record<string, unknown>>({
row.id = row._uuid
delete row._uuid
}
const block = field.blocks.find(({ slug }) => slug === row.blockType)
if (typeof row.blockType !== 'string') {
return acc
}
const block =
adapter.payload.blocks[row.blockType] ??
((field.blockReferences ?? field.blocks).find(
(block) => typeof block !== 'string' && block.slug === row.blockType,
) as FlattenedBlock | undefined)
if (block) {
if (

View File

@@ -1,4 +1,4 @@
import type { FlattenedBlocksField } from 'payload'
import type { FlattenedBlock, FlattenedBlocksField } from 'payload'
import toSnakeCase from 'to-snake-case'
@@ -51,7 +51,13 @@ export const transformBlocks = ({
if (typeof blockRow.blockType !== 'string') {
return
}
const matchedBlock = field.blocks.find(({ slug }) => slug === blockRow.blockType)
const matchedBlock =
adapter.payload.blocks[blockRow.blockType] ??
((field.blockReferences ?? field.blocks).find(
(block) => typeof block !== 'string' && block.slug === blockRow.blockType,
) as FlattenedBlock | undefined)
if (!matchedBlock) {
return
}

View File

@@ -168,8 +168,8 @@ export const traverseFields = ({
}
if (field.type === 'blocks') {
field.blocks.forEach(({ slug }) => {
blocksToDelete.add(toSnakeCase(slug))
;(field.blockReferences ?? field.blocks).forEach((block) => {
blocksToDelete.add(toSnakeCase(typeof block === 'string' ? block : block.slug))
})
if (field.localized) {

View File

@@ -108,8 +108,15 @@ export function buildObjectType({
}
},
blocks: (objectTypeConfig: ObjectTypeConfig, field: BlocksField) => {
const blockTypes: GraphQLObjectType<any, any>[] = field.blocks.reduce((acc, block) => {
if (!graphqlResult.types.blockTypes[block.slug]) {
const blockTypes: GraphQLObjectType<any, any>[] = (
field.blockReferences ?? field.blocks
).reduce((acc, _block) => {
const blockSlug = typeof _block === 'string' ? _block : _block.slug
if (!graphqlResult.types.blockTypes[blockSlug]) {
// TODO: iterate over blocks mapped to block slug in v4, or pass through payload.blocks
const block =
typeof _block === 'string' ? config.blocks.find((b) => b.slug === _block) : _block
const interfaceName =
block?.interfaceName || block?.graphQL?.singularName || toWords(block.slug, true)
@@ -133,8 +140,8 @@ export function buildObjectType({
}
}
if (graphqlResult.types.blockTypes[block.slug]) {
acc.push(graphqlResult.types.blockTypes[block.slug])
if (graphqlResult.types.blockTypes[blockSlug]) {
acc.push(graphqlResult.types.blockTypes[blockSlug])
}
return acc

View File

@@ -2,6 +2,7 @@
import type { ClientField, LivePreviewConfig } from 'payload'
import { DndContext } from '@dnd-kit/core'
import { useConfig } from '@payloadcms/ui'
import { fieldSchemaToJSON } from 'payload/shared'
import React, { useCallback, useEffect, useState } from 'react'
@@ -43,6 +44,7 @@ export const LivePreviewProvider: React.FC<LivePreviewProviderProps> = ({
const iframeRef = React.useRef<HTMLIFrameElement>(null)
const [iframeHasLoaded, setIframeHasLoaded] = useState(false)
const { config } = useConfig()
const [zoom, setZoom] = useState(1)
@@ -59,7 +61,7 @@ export const LivePreviewProvider: React.FC<LivePreviewProviderProps> = ({
React.useState<LivePreviewConfig['breakpoints'][0]['name']>('responsive')
const [fieldSchemaJSON] = useState(() => {
return fieldSchemaToJSON(fieldSchema)
return fieldSchemaToJSON(fieldSchema, config)
})
// The toolbar needs to freely drag and drop around the page

View File

@@ -1,6 +1,7 @@
'use client'
import type { ClientField } from 'payload'
import { ChevronIcon, Pill, useTranslation } from '@payloadcms/ui'
import { ChevronIcon, Pill, useConfig, useTranslation } from '@payloadcms/ui'
import { fieldIsArrayType, fieldIsBlockType } from 'payload/shared'
import React, { useState } from 'react'
@@ -49,6 +50,7 @@ export const DiffCollapser: React.FC<Props> = ({
}) => {
const { t } = useTranslation()
const [isCollapsed, setIsCollapsed] = useState(initCollapsed)
const { config } = useConfig()
let changeCount = 0
@@ -69,6 +71,7 @@ export const DiffCollapser: React.FC<Props> = ({
changeCount = countChangedFieldsInRows({
comparisonRows,
config,
field,
locales,
versionRows,
@@ -76,6 +79,7 @@ export const DiffCollapser: React.FC<Props> = ({
} else {
changeCount = countChangedFields({
comparison,
config,
fields,
locales,
version,

View File

@@ -7,6 +7,7 @@ import type {
FieldDiffClientProps,
FieldDiffServerProps,
FieldTypes,
FlattenedBlock,
PayloadComponent,
PayloadRequest,
SanitizedFieldPermissions,
@@ -332,14 +333,27 @@ const buildVersionField = ({
for (let i = 0; i < blocksValue.length; i++) {
const comparisonRow = comparisonValue?.[i] || {}
const versionRow = blocksValue[i] || {}
const versionBlock = field.blocks.find((block) => block.slug === versionRow.blockType)
const blockSlugToMatch: string = versionRow.blockType
const versionBlock =
req.payload.blocks[blockSlugToMatch] ??
((field.blockReferences ?? field.blocks).find(
(block) => typeof block !== 'string' && block.slug === blockSlugToMatch,
) as FlattenedBlock | undefined)
let fields = []
if (versionRow.blockType === comparisonRow.blockType) {
fields = versionBlock.fields
} else {
const comparisonBlock = field.blocks.find((block) => block.slug === comparisonRow.blockType)
const comparisonBlockSlugToMatch: string = versionRow.blockType
const comparisonBlock =
req.payload.blocks[comparisonBlockSlugToMatch] ??
((field.blockReferences ?? field.blocks).find(
(block) => typeof block !== 'string' && block.slug === comparisonBlockSlugToMatch,
) as FlattenedBlock | undefined)
if (comparisonBlock) {
fields = getUniqueListBy<Field>(
[...versionBlock.fields, ...comparisonBlock.fields],

View File

@@ -3,7 +3,7 @@
import type { FieldDiffClientProps } from 'payload'
import { getTranslation } from '@payloadcms/translations'
import { useTranslation } from '@payloadcms/ui'
import { useConfig, useTranslation } from '@payloadcms/ui'
import './index.scss'
@@ -26,6 +26,7 @@ export const Iterable: React.FC<FieldDiffClientProps> = ({
}) => {
const { i18n } = useTranslation()
const { selectedLocales } = useSelectedLocales()
const { config } = useConfig()
const versionRowCount = Array.isArray(versionValue) ? versionValue.length : 0
const comparisonRowCount = Array.isArray(comparisonValue) ? comparisonValue.length : 0
@@ -63,6 +64,7 @@ export const Iterable: React.FC<FieldDiffClientProps> = ({
const { fields, versionFields } = getFieldsForRowComparison({
baseVersionField,
comparisonRow,
config,
field,
row: i,
versionRow,

View File

@@ -1,10 +1,11 @@
import type { ArrayFieldClient, BlocksFieldClient, ClientField } from 'payload'
import type { ArrayFieldClient, BlocksFieldClient, ClientConfig, ClientField } from 'payload'
import { fieldHasChanges } from './fieldHasChanges.js'
import { getFieldsForRowComparison } from './getFieldsForRowComparison.js'
type Args = {
comparison: unknown
config: ClientConfig
fields: ClientField[]
locales: string[] | undefined
version: unknown
@@ -14,7 +15,7 @@ type Args = {
* Recursively counts the number of changed fields between comparison and
* version data for a given set of fields.
*/
export function countChangedFields({ comparison, fields, locales, version }: Args) {
export function countChangedFields({ comparison, config, fields, locales, version }: Args) {
let count = 0
fields.forEach((field) => {
@@ -32,12 +33,18 @@ export function countChangedFields({ comparison, fields, locales, version }: Arg
locales.forEach((locale) => {
const comparisonRows = comparison?.[field.name]?.[locale] ?? []
const versionRows = version?.[field.name]?.[locale] ?? []
count += countChangedFieldsInRows({ comparisonRows, field, locales, versionRows })
count += countChangedFieldsInRows({
comparisonRows,
config,
field,
locales,
versionRows,
})
})
} else {
const comparisonRows = comparison?.[field.name] ?? []
const versionRows = version?.[field.name] ?? []
count += countChangedFieldsInRows({ comparisonRows, field, locales, versionRows })
count += countChangedFieldsInRows({ comparisonRows, config, field, locales, versionRows })
}
break
}
@@ -77,6 +84,7 @@ export function countChangedFields({ comparison, fields, locales, version }: Arg
case 'row': {
count += countChangedFields({
comparison,
config,
fields: field.fields,
locales,
version,
@@ -91,6 +99,7 @@ export function countChangedFields({ comparison, fields, locales, version }: Arg
locales.forEach((locale) => {
count += countChangedFields({
comparison: comparison?.[field.name]?.[locale],
config,
fields: field.fields,
locales,
version: version?.[field.name]?.[locale],
@@ -99,6 +108,7 @@ export function countChangedFields({ comparison, fields, locales, version }: Arg
} else {
count += countChangedFields({
comparison: comparison?.[field.name],
config,
fields: field.fields,
locales,
version: version?.[field.name],
@@ -116,6 +126,7 @@ export function countChangedFields({ comparison, fields, locales, version }: Arg
locales.forEach((locale) => {
count += countChangedFields({
comparison: comparison?.[tab.name]?.[locale],
config,
fields: tab.fields,
locales,
version: version?.[tab.name]?.[locale],
@@ -125,6 +136,7 @@ export function countChangedFields({ comparison, fields, locales, version }: Arg
// Named tab
count += countChangedFields({
comparison: comparison?.[tab.name],
config,
fields: tab.fields,
locales,
version: version?.[tab.name],
@@ -133,6 +145,7 @@ export function countChangedFields({ comparison, fields, locales, version }: Arg
// Unnamed tab
count += countChangedFields({
comparison,
config,
fields: tab.fields,
locales,
version,
@@ -160,6 +173,7 @@ export function countChangedFields({ comparison, fields, locales, version }: Arg
type countChangedFieldsInRowsArgs = {
comparisonRows: unknown[]
config: ClientConfig
field: ArrayFieldClient | BlocksFieldClient
locales: string[] | undefined
versionRows: unknown[]
@@ -167,6 +181,7 @@ type countChangedFieldsInRowsArgs = {
export function countChangedFieldsInRows({
comparisonRows = [],
config,
field,
locales,
versionRows = [],
@@ -181,6 +196,7 @@ export function countChangedFieldsInRows({
const { fields: rowFields } = getFieldsForRowComparison({
baseVersionField: { type: 'text', fields: [], path: '', schemaPath: '' }, // Doesn't matter, as we don't need the versionFields output here
comparisonRow,
config,
field,
row: i,
versionRow,
@@ -188,6 +204,7 @@ export function countChangedFieldsInRows({
count += countChangedFields({
comparison: comparisonRow,
config,
fields: rowFields,
locales,
version: versionRow,

View File

@@ -2,6 +2,8 @@ import type {
ArrayFieldClient,
BaseVersionField,
BlocksFieldClient,
ClientBlock,
ClientConfig,
ClientField,
VersionField,
} from 'payload'
@@ -17,12 +19,14 @@ import { getUniqueListBy } from 'payload/shared'
export function getFieldsForRowComparison({
baseVersionField,
comparisonRow,
config,
field,
row,
versionRow,
}: {
baseVersionField: BaseVersionField
comparisonRow: any
config: ClientConfig
field: ArrayFieldClient | BlocksFieldClient
row: number
versionRow: any
@@ -37,24 +41,37 @@ export function getFieldsForRowComparison({
: baseVersionField.fields
} else if (field.type === 'blocks') {
if (versionRow?.blockType === comparisonRow?.blockType) {
const matchedBlock = ('blocks' in field &&
field.blocks?.find((block) => block.slug === versionRow?.blockType)) || {
fields: [],
}
const matchedBlock: ClientBlock =
config?.blocksMap?.[versionRow?.blockType] ??
(((('blocks' in field || 'blockReferences' in field) &&
(field.blockReferences ?? field.blocks)?.find(
(block) => typeof block !== 'string' && block.slug === versionRow?.blockType,
)) || {
fields: [],
}) as ClientBlock)
fields = matchedBlock.fields
versionFields = baseVersionField.rows?.length
? baseVersionField.rows[row]
: baseVersionField.fields
} else {
const matchedVersionBlock = ('blocks' in field &&
field.blocks?.find((block) => block.slug === versionRow?.blockType)) || {
fields: [],
}
const matchedComparisonBlock = ('blocks' in field &&
field.blocks?.find((block) => block.slug === comparisonRow?.blockType)) || {
fields: [],
}
const matchedVersionBlock =
config?.blocksMap?.[versionRow?.blockType] ??
(((('blocks' in field || 'blockReferences' in field) &&
(field.blockReferences ?? field.blocks)?.find(
(block) => typeof block !== 'string' && block.slug === versionRow?.blockType,
)) || {
fields: [],
}) as ClientBlock)
const matchedComparisonBlock =
config?.blocksMap?.[comparisonRow?.blockType] ??
(((('blocks' in field || 'blockReferences' in field) &&
(field.blockReferences ?? field.blocks)?.find(
(block) => typeof block !== 'string' && block.slug === comparisonRow?.blockType,
)) || {
fields: [],
}) as ClientBlock)
fields = getUniqueListBy<ClientField>(
[...matchedVersionBlock.fields, ...matchedComparisonBlock.fields],

View File

@@ -1,10 +1,11 @@
// @ts-strict-ignore
/* eslint-disable @typescript-eslint/no-unused-expressions */
import type { AdminViewConfig } from '../../admin/views/types.js'
import type { SanitizedConfig } from '../../config/types.js'
import type { AddToImportMap, Imports, InternalImportMap } from './index.js'
import { iterateCollections } from './iterateCollections.js'
import { genImportMapIterateFields } from './iterateFields.js'
import { iterateGlobals } from './iterateGlobals.js'
export function iterateConfig({
@@ -38,6 +39,20 @@ export function iterateConfig({
imports,
})
if (config?.blocks) {
const blocks = Object.values(config.blocks)
if (blocks?.length) {
genImportMapIterateFields({
addToImportMap,
baseDir,
config,
fields: blocks,
importMap,
imports,
})
}
}
if (typeof config.admin?.avatar === 'object') {
addToImportMap(config.admin?.avatar?.Component)
}

View File

@@ -47,7 +47,7 @@ export function genImportMapIterateFields({
addToImportMap,
baseDir,
config,
fields: field.blocks,
fields: field.blocks.filter((block) => typeof block !== 'string'),
importMap,
imports,
})

View File

@@ -27,6 +27,7 @@ export const sanitizeCollection = async (
* so that you can sanitize them together, after the config has been sanitized.
*/
richTextSanitizationPromises?: Array<(config: SanitizedConfig) => Promise<void>>,
_validRelationships?: string[],
): Promise<SanitizedCollectionConfig> => {
// /////////////////////////////////
// Make copy of collection config
@@ -38,13 +39,8 @@ export const sanitizeCollection = async (
// Sanitize fields
// /////////////////////////////////
const validRelationships = (config.collections || []).reduce(
(acc, c) => {
acc.push(c.slug)
return acc
},
[collection.slug],
)
const validRelationships = _validRelationships ?? config.collections.map((c) => c.slug) ?? []
const joins: SanitizedJoins = {}
sanitized.fields = await sanitizeFields({
collectionConfig: sanitized,

View File

@@ -3,6 +3,8 @@ import type { I18nClient } from '@payloadcms/translations'
import type { DeepPartial } from 'ts-essentials'
import type { ImportMap } from '../bin/generateImportMap/index.js'
import type { ClientBlock } from '../fields/config/types.js'
import type { BlockSlug } from '../index.js'
import type {
LivePreviewConfig,
SanitizedConfig,
@@ -13,8 +15,8 @@ import {
type ClientCollectionConfig,
createClientCollectionConfigs,
} from '../collections/config/client.js'
import { createClientBlocks } from '../fields/config/client.js'
import { type ClientGlobalConfig, createClientGlobalConfigs } from '../globals/config/client.js'
export type ServerOnlyRootProperties = keyof Pick<
SanitizedConfig,
| 'bin'
@@ -39,10 +41,22 @@ export type ServerOnlyRootProperties = keyof Pick<
export type ServerOnlyRootAdminProperties = keyof Pick<SanitizedConfig['admin'], 'components'>
export type UnsanitizedClientConfig = {
admin: {
livePreview?: Omit<LivePreviewConfig, ServerOnlyLivePreviewProperties>
} & Omit<SanitizedConfig['admin'], 'components' | 'dependencies' | 'livePreview'>
blocks: ClientBlock[]
collections: ClientCollectionConfig[]
custom?: Record<string, any>
globals: ClientGlobalConfig[]
} & Omit<SanitizedConfig, 'admin' | 'collections' | 'globals' | 'i18n' | ServerOnlyRootProperties>
export type ClientConfig = {
admin: {
livePreview?: Omit<LivePreviewConfig, ServerOnlyLivePreviewProperties>
} & Omit<SanitizedConfig['admin'], 'components' | 'dependencies' | 'livePreview'>
blocks: ClientBlock[]
blocksMap: Record<BlockSlug, ClientBlock>
collections: ClientCollectionConfig[]
custom?: Record<string, any>
globals: ClientGlobalConfig[]
@@ -109,6 +123,16 @@ export const createClientConfig = ({
}
}
break
case 'blocks': {
;(clientConfig.blocks as ClientBlock[]) = createClientBlocks({
blocks: config.blocks,
defaultIDType: config.db.defaultIDType,
i18n,
importMap,
}).filter((block) => typeof block !== 'string') as ClientBlock[]
break
}
case 'collections':
;(clientConfig.collections as ClientCollectionConfig[]) = createClientCollectionConfigs({
collections: config.collections,
@@ -125,6 +149,7 @@ export const createClientConfig = ({
importMap,
})
break
case 'localization':
if (typeof config.localization === 'object' && config.localization) {
clientConfig.localization = {}

View File

@@ -4,7 +4,6 @@ import type { AcceptedLanguages } from '@payloadcms/translations'
import { en } from '@payloadcms/translations/languages/en'
import { deepMergeSimple } from '@payloadcms/translations/utilities'
import type { CollectionSlug, GlobalSlug } from '../index.js'
import type {
Config,
LocalizationConfigWithLabels,
@@ -20,9 +19,17 @@ import { migrationsCollection } from '../database/migrations/migrationsCollectio
import { DuplicateCollection, InvalidConfiguration } from '../errors/index.js'
import { defaultTimezones } from '../fields/baseFields/timezone/defaultTimezones.js'
import { sanitizeGlobal } from '../globals/config/sanitize.js'
import {
baseBlockFields,
type CollectionSlug,
formatLabels,
type GlobalSlug,
sanitizeFields,
} from '../index.js'
import { getLockedDocumentsCollection } from '../lockedDocuments/lockedDocumentsCollection.js'
import getPreferencesCollection from '../preferences/preferencesCollection.js'
import { getDefaultJobsCollection } from '../queues/config/jobsCollection.js'
import { flattenBlock } from '../utilities/flattenAllFields.js'
import { getSchedulePublishTask } from '../versions/schedule/job.js'
import { defaults } from './defaults.js'
@@ -217,6 +224,47 @@ export const sanitizeConfig = async (incomingConfig: Config): Promise<SanitizedC
const collectionSlugs = new Set<CollectionSlug>()
const validRelationships = [
...(config.collections.map((c) => c.slug) ?? []),
'payload-jobs',
'payload-locked-documents',
'payload-preferences',
]
/**
* Blocks sanitization needs to happen before collections, as collection/global join field sanitization needs config.blocks
* to be populated with the sanitized blocks
*/
config.blocks = []
if (incomingConfig.blocks?.length) {
for (const block of incomingConfig.blocks) {
const sanitizedBlock = block
if (sanitizedBlock._sanitized === true) {
continue
}
sanitizedBlock._sanitized = true
sanitizedBlock.fields = sanitizedBlock.fields.concat(baseBlockFields)
sanitizedBlock.labels = !sanitizedBlock.labels
? formatLabels(sanitizedBlock.slug)
: sanitizedBlock.labels
sanitizedBlock.fields = await sanitizeFields({
config: config as unknown as Config,
existingFieldNames: new Set(),
fields: sanitizedBlock.fields,
parentIsLocalized: false,
richTextSanitizationPromises,
validRelationships,
})
const flattenedSanitizedBlock = flattenBlock({ block })
config.blocks.push(flattenedSanitizedBlock)
}
}
for (let i = 0; i < config.collections.length; i++) {
if (collectionSlugs.has(config.collections[i].slug)) {
throw new DuplicateCollection('slug', config.collections[i].slug)
@@ -234,6 +282,7 @@ export const sanitizeConfig = async (incomingConfig: Config): Promise<SanitizedC
config as unknown as Config,
config.collections[i],
richTextSanitizationPromises,
validRelationships,
)
}
@@ -249,6 +298,7 @@ export const sanitizeConfig = async (incomingConfig: Config): Promise<SanitizedC
config as unknown as Config,
config.globals[i],
richTextSanitizationPromises,
validRelationships,
)
}
}
@@ -285,6 +335,7 @@ export const sanitizeConfig = async (incomingConfig: Config): Promise<SanitizedC
config as unknown as Config,
defaultJobsCollection,
richTextSanitizationPromises,
validRelationships,
)
configWithDefaults.collections.push(sanitizedJobsCollection)
@@ -295,6 +346,7 @@ export const sanitizeConfig = async (incomingConfig: Config): Promise<SanitizedC
config as unknown as Config,
getLockedDocumentsCollection(config as unknown as Config),
richTextSanitizationPromises,
validRelationships,
),
)
configWithDefaults.collections.push(
@@ -302,6 +354,7 @@ export const sanitizeConfig = async (incomingConfig: Config): Promise<SanitizedC
config as unknown as Config,
getPreferencesCollection(config as unknown as Config),
richTextSanitizationPromises,
validRelationships,
),
)
configWithDefaults.collections.push(
@@ -309,6 +362,7 @@ export const sanitizeConfig = async (incomingConfig: Config): Promise<SanitizedC
config as unknown as Config,
migrationsCollection,
richTextSanitizationPromises,
validRelationships,
),
)

View File

@@ -14,7 +14,7 @@ import type { JSONSchema4 } from 'json-schema'
import type { DestinationStream, Level, pino } from 'pino'
import type React from 'react'
import type { default as sharp } from 'sharp'
import type { DeepRequired } from 'ts-essentials'
import type { DeepRequired, MarkOptional } from 'ts-essentials'
import type { RichTextAdapterProvider } from '../admin/RichText.js'
import type { DocumentTabConfig, RichTextAdapter } from '../admin/types.js'
@@ -42,7 +42,14 @@ import type { DatabaseAdapterResult } from '../database/types.js'
import type { EmailAdapter, SendEmailOptions } from '../email/types.js'
import type { ErrorName } from '../errors/types.js'
import type { GlobalConfig, Globals, SanitizedGlobalConfig } from '../globals/config/types.js'
import type { JobsConfig, Payload, RequestContext, TypedUser } from '../index.js'
import type {
Block,
FlattenedBlock,
JobsConfig,
Payload,
RequestContext,
TypedUser,
} from '../index.js'
import type { PayloadRequest, Where } from '../types/index.js'
import type { PayloadLogger } from '../utilities/logger.js'
@@ -916,6 +923,7 @@ export type Config = {
}
/** Custom Payload bin scripts can be injected via the config. */
bin?: BinScriptConfig[]
blocks?: Block[]
/**
* Manage the datamodel of your application
*
@@ -943,12 +951,12 @@ export type Config = {
cookiePrefix?: string
/** Either a whitelist array of URLS to allow CORS requests from, or a wildcard string ('*') to accept incoming requests from any domain. */
cors?: '*' | CORSConfig | string[]
/** A whitelist array of URLs to allow Payload cookies to be accepted from as a form of CSRF protection. */
csrf?: string[]
/** Extension point to add your custom data. Server only. */
custom?: Record<string, any>
/** Pass in a database adapter for use on this project. */
db: DatabaseAdapterResult
/** Enable to expose more detailed error information. */
@@ -1183,6 +1191,7 @@ export type SanitizedConfig = {
admin: {
timezones: SanitizedTimezoneConfig
} & DeepRequired<Config['admin']>
blocks?: FlattenedBlock[]
collections: SanitizedCollectionConfig[]
/** Default richtext editor to use for richText fields */
editor?: RichTextAdapter<any, any, any>
@@ -1207,7 +1216,15 @@ export type SanitizedConfig = {
// E.g. in packages/ui/src/graphics/Account/index.tsx in getComponent, if avatar.Component is casted to what it's supposed to be,
// the result type is different
DeepRequired<Config>,
'admin' | 'collections' | 'editor' | 'endpoint' | 'globals' | 'i18n' | 'localization' | 'upload'
| 'admin'
| 'blocks'
| 'collections'
| 'editor'
| 'endpoint'
| 'globals'
| 'i18n'
| 'localization'
| 'upload'
>
export type EditConfig = EditConfigWithoutRoot | EditConfigWithRoot

View File

@@ -1,5 +1,5 @@
// @ts-strict-ignore
import type { Field, FlattenedField } from '../fields/config/types.js'
import type { Field, FlattenedBlock, FlattenedField } from '../fields/config/types.js'
import type { Payload } from '../index.js'
import type { PathToQuery } from './queryValidation/types.js'
@@ -55,7 +55,15 @@ export function getLocalizedPaths({
type: 'text',
}
} else {
for (const block of lastIncompletePath.field.blocks) {
for (const _block of lastIncompletePath.field.blockReferences ??
lastIncompletePath.field.blocks) {
let block: FlattenedBlock
if (typeof _block === 'string') {
block = payload?.blocks[_block]
} else {
block = _block
}
matchedField = block.flattenedFields.find((field) => field.name === segment)
if (matchedField) {
break

View File

@@ -146,7 +146,10 @@ export async function validateSearchParam({
if (fieldAccess[segment]) {
if ('fields' in fieldAccess[segment]) {
fieldAccess = fieldAccess[segment].fields
} else if ('blocks' in fieldAccess[segment]) {
} else if (
'blocks' in fieldAccess[segment] ||
'blockReferences' in fieldAccess[segment]
) {
fieldAccess = fieldAccess[segment]
} else {
fieldAccess = fieldAccess[segment]

View File

@@ -6,6 +6,7 @@ import type { I18nClient } from '@payloadcms/translations'
import type {
AdminClient,
ArrayFieldClient,
Block,
BlockJSX,
BlocksFieldClient,
ClientBlock,
@@ -25,7 +26,6 @@ import { getFromImportMap } from '../../bin/generateImportMap/getFromImportMap.j
import { MissingEditorProp } from '../../errors/MissingEditorProp.js'
import { fieldAffectsData } from '../../fields/config/types.js'
import { flattenTopLevelFields, type ImportMap } from '../../index.js'
import { removeUndefined } from '../../utilities/removeUndefined.js'
// Should not be used - ClientField should be used instead. This is why we don't export ClientField, we don't want people
// to accidentally use it instead of ClientField and get confused
@@ -75,6 +75,83 @@ type FieldWithDescription = {
admin: AdminClient
} & ClientField
export const createClientBlocks = ({
blocks,
defaultIDType,
i18n,
importMap,
}: {
blocks: (Block | string)[]
defaultIDType: Payload['config']['db']['defaultIDType']
i18n: I18nClient
importMap: ImportMap
}): (ClientBlock | string)[] | ClientBlock[] => {
const clientBlocks: (ClientBlock | string)[] = []
for (let i = 0; i < blocks.length; i++) {
const block = blocks[i]
if (typeof block === 'string') {
// Do not process blocks that are just strings - they are processed once in the client config
clientBlocks.push(block)
continue
}
const clientBlock: ClientBlock = {
slug: block.slug,
fields: [],
}
if (block.imageAltText) {
clientBlock.imageAltText = block.imageAltText
}
if (block.imageURL) {
clientBlock.imageURL = block.imageURL
}
if (block.admin?.custom) {
clientBlock.admin = {
custom: block.admin.custom,
}
}
if (block?.admin?.jsx) {
const jsxResolved = getFromImportMap<BlockJSX>({
importMap,
PayloadComponent: block.admin.jsx,
schemaPath: '',
})
clientBlock.jsx = jsxResolved
}
if (block.labels) {
clientBlock.labels = {} as unknown as LabelsClient
if (block.labels.singular) {
if (typeof block.labels.singular === 'function') {
clientBlock.labels.singular = block.labels.singular({ t: i18n.t })
} else {
clientBlock.labels.singular = block.labels.singular
}
if (typeof block.labels.plural === 'function') {
clientBlock.labels.plural = block.labels.plural({ t: i18n.t })
} else {
clientBlock.labels.plural = block.labels.plural
}
}
}
clientBlock.fields = createClientFields({
defaultIDType,
fields: block.fields,
i18n,
importMap,
})
clientBlocks.push(clientBlock)
}
return clientBlocks
}
export const createClientField = ({
defaultIDType,
field: incomingField,
@@ -209,63 +286,22 @@ export const createClientField = ({
}
}
if (incomingField.blockReferences?.length) {
field.blockReferences = createClientBlocks({
blocks: incomingField.blockReferences,
defaultIDType,
i18n,
importMap,
})
}
if (incomingField.blocks?.length) {
for (let i = 0; i < incomingField.blocks.length; i++) {
const block = incomingField.blocks[i]
// prevent $undefined from being passed through the rsc requests
const clientBlock = removeUndefined<ClientBlock>({
slug: block.slug,
fields: field.blocks?.[i]?.fields || [],
imageAltText: block.imageAltText,
imageURL: block.imageURL,
}) satisfies ClientBlock
if (block.admin?.custom) {
clientBlock.admin = {
custom: block.admin.custom,
}
}
if (block?.admin?.jsx) {
const jsxResolved = getFromImportMap<BlockJSX>({
importMap,
PayloadComponent: block.admin.jsx,
schemaPath: '',
})
clientBlock.jsx = jsxResolved
}
if (block.labels) {
clientBlock.labels = {} as unknown as LabelsClient
if (block.labels.singular) {
if (typeof block.labels.singular === 'function') {
clientBlock.labels.singular = block.labels.singular({ t: i18n.t })
} else {
clientBlock.labels.singular = block.labels.singular
}
if (typeof block.labels.plural === 'function') {
clientBlock.labels.plural = block.labels.plural({ t: i18n.t })
} else {
clientBlock.labels.plural = block.labels.plural
}
}
}
clientBlock.fields = createClientFields({
defaultIDType,
fields: block.fields,
i18n,
importMap,
})
if (!field.blocks) {
field.blocks = []
}
field.blocks[i] = clientBlock
}
field.blocks = createClientBlocks({
blocks: incomingField.blocks,
defaultIDType,
i18n,
importMap,
}) as ClientBlock[]
}
break

View File

@@ -224,7 +224,14 @@ export const sanitizeFields = async ({
}
if (field.type === 'blocks' && field.blocks) {
for (const block of field.blocks) {
if (field.blockReferences && field.blocks?.length) {
throw new Error('You cannot have both blockReferences and blocks in the same blocks field')
}
for (const block of field.blockReferences ?? field.blocks) {
if (typeof block === 'string') {
continue
}
if (block._sanitized === true) {
continue
}

View File

@@ -1,6 +1,6 @@
// @ts-strict-ignore
import type { SanitizedJoin, SanitizedJoins } from '../../collections/config/types.js'
import type { Config } from '../../config/types.js'
import type { Config, SanitizedConfig } from '../../config/types.js'
import type { FlattenedJoinField, JoinField, RelationshipField, UploadField } from './types.js'
import { APIError } from '../../errors/index.js'
@@ -91,6 +91,7 @@ export const sanitizeJoinField = ({
return
}
},
config: config as unknown as SanitizedConfig,
fields: joinCollection.fields,
})

View File

@@ -121,6 +121,7 @@ import type { SanitizedGlobalConfig } from '../../globals/config/types.js'
import type {
ArrayFieldValidation,
BlocksFieldValidation,
BlockSlug,
CheckboxFieldValidation,
CodeFieldValidation,
CollectionSlug,
@@ -1405,6 +1406,12 @@ export type BlocksField = {
*/
isSortable?: boolean
} & Admin
/**
* Like `blocks`, but allows you to also pass strings that are slugs of blocks defined in `config.blocks`.
*
* @todo `blockReferences` will be merged with `blocks` in 4.0
*/
blockReferences?: (Block | BlockSlug)[]
blocks: Block[]
defaultValue?: DefaultValue
labels?: Labels
@@ -1416,6 +1423,12 @@ export type BlocksField = {
export type BlocksFieldClient = {
admin?: AdminClient & Pick<BlocksField['admin'], 'initCollapsed' | 'isSortable'>
/**
* Like `blocks`, but allows you to also pass strings that are slugs of blocks defined in `config.blocks`.
*
* @todo `blockReferences` will be merged with `blocks` in 4.0
*/
blockReferences?: (ClientBlock | string)[]
blocks: ClientBlock[]
labels?: LabelsClient
} & FieldBaseClient &
@@ -1511,8 +1524,14 @@ export type FlattenedBlock = {
} & Block
export type FlattenedBlocksField = {
/**
* Like `blocks`, but allows you to also pass strings that are slugs of blocks defined in `config.blocks`.
*
* @todo `blockReferences` will be merged with `blocks` in 4.0
*/
blockReferences?: (FlattenedBlock | string)[]
blocks: FlattenedBlock[]
} & BlocksField
} & Omit<BlocksField, 'blockReferences' | 'blocks'>
export type FlattenedGroupField = {
flattenedFields: FlattenedField[]

View File

@@ -4,7 +4,7 @@ import type { SanitizedCollectionConfig } from '../../../collections/config/type
import type { SanitizedGlobalConfig } from '../../../globals/config/types.js'
import type { RequestContext } from '../../../index.js'
import type { JsonObject, PayloadRequest } from '../../../types/index.js'
import type { Field, TabAsField } from '../../config/types.js'
import type { Block, Field, TabAsField } from '../../config/types.js'
import { MissingEditorProp } from '../../../errors/index.js'
import { fieldAffectsData, tabHasName } from '../../config/types.js'
@@ -146,9 +146,13 @@ export const promise = async ({
const promises = []
rows.forEach((row, rowIndex) => {
const block = field.blocks.find(
(blockType) => blockType.slug === (row as JsonObject).blockType,
)
const blockTypeToMatch = (row as JsonObject).blockType
const block: Block | undefined =
req.payload.blocks[blockTypeToMatch] ??
((field.blockReferences ?? field.blocks).find(
(curBlock) => typeof curBlock !== 'string' && curBlock.slug === blockTypeToMatch,
) as Block | undefined)
if (block) {
promises.push(

View File

@@ -10,7 +10,7 @@ import type {
SelectMode,
SelectType,
} from '../../../types/index.js'
import type { Field, TabAsField } from '../../config/types.js'
import type { Block, Field, TabAsField } from '../../config/types.js'
import { MissingEditorProp } from '../../../errors/index.js'
import { fieldAffectsData, tabHasName } from '../../config/types.js'
@@ -453,9 +453,13 @@ export const promise = async ({
if (Array.isArray(rows)) {
rows.forEach((row, rowIndex) => {
const block = field.blocks.find(
(blockType) => blockType.slug === (row as JsonObject).blockType,
)
const blockTypeToMatch = (row as JsonObject).blockType
const block: Block | undefined =
req.payload.blocks[blockTypeToMatch] ??
((field.blockReferences ?? field.blocks).find(
(curBlock) => typeof curBlock !== 'string' && curBlock.slug === blockTypeToMatch,
) as Block | undefined)
let blockSelectMode = selectMode
@@ -525,9 +529,13 @@ export const promise = async ({
Object.values(rows).forEach((localeRows) => {
if (Array.isArray(localeRows)) {
localeRows.forEach((row, rowIndex) => {
const block = field.blocks.find(
(blockType) => blockType.slug === (row as JsonObject).blockType,
)
const blockTypeToMatch = row.blockType
const block: Block | undefined =
req.payload.blocks[blockTypeToMatch] ??
((field.blockReferences ?? field.blocks).find(
(curBlock) => typeof curBlock !== 'string' && curBlock.slug === blockTypeToMatch,
) as Block | undefined)
if (block) {
traverseFields({

View File

@@ -5,7 +5,7 @@ import type { ValidationFieldError } from '../../../errors/index.js'
import type { SanitizedGlobalConfig } from '../../../globals/config/types.js'
import type { RequestContext } from '../../../index.js'
import type { JsonObject, Operation, PayloadRequest } from '../../../types/index.js'
import type { Field, TabAsField, Validate } from '../../config/types.js'
import type { Block, Field, TabAsField, Validate } from '../../config/types.js'
import { MissingEditorProp } from '../../../errors/index.js'
import { deepMergeWithSourceArrays } from '../../../utilities/deepMerge.js'
@@ -278,7 +278,12 @@ export const promise = async ({
)
const blockTypeToMatch = (row as JsonObject).blockType || rowSiblingDoc.blockType
const block = field.blocks.find((blockType) => blockType.slug === blockTypeToMatch)
const block: Block | undefined =
req.payload.blocks[blockTypeToMatch] ??
((field.blockReferences ?? field.blocks).find(
(curBlock) => typeof curBlock !== 'string' && curBlock.slug === blockTypeToMatch,
) as Block | undefined)
if (block) {
promises.push(

View File

@@ -2,7 +2,7 @@
import type { SanitizedCollectionConfig } from '../../../collections/config/types.js'
import type { RequestContext } from '../../../index.js'
import type { JsonObject, PayloadRequest } from '../../../types/index.js'
import type { Field, FieldHookArgs, TabAsField } from '../../config/types.js'
import type { Block, Field, FieldHookArgs, TabAsField } from '../../config/types.js'
import { fieldAffectsData } from '../../config/types.js'
import { getFieldPathsModified as getFieldPaths } from '../../getFieldPaths.js'
@@ -183,9 +183,12 @@ export const promise = async <T>({
rows.forEach((row, rowIndex) => {
const blockTypeToMatch = row.blockType
const block = field.blocks.find(
(blockType) => blockType.slug === blockTypeToMatch,
)
const block: Block | undefined =
req.payload.blocks[blockTypeToMatch] ??
((field.blockReferences ?? field.blocks).find(
(curBlock) =>
typeof curBlock !== 'string' && curBlock.slug === blockTypeToMatch,
) as Block | undefined)
promises.push(
traverseFields({
@@ -278,7 +281,12 @@ export const promise = async <T>({
rows.forEach((row, rowIndex) => {
const blockTypeToMatch = row.blockType
const block = field.blocks.find((blockType) => blockType.slug === blockTypeToMatch)
const block: Block | undefined =
req.payload.blocks[blockTypeToMatch] ??
((field.blockReferences ?? field.blocks).find(
(curBlock) => typeof curBlock !== 'string' && curBlock.slug === blockTypeToMatch,
) as Block | undefined)
if (block) {
;(row as JsonObject).blockType = blockTypeToMatch

View File

@@ -4,7 +4,7 @@ import type { SanitizedCollectionConfig, TypeWithID } from '../../../collections
import type { SanitizedGlobalConfig } from '../../../globals/config/types.js'
import type { RequestContext } from '../../../index.js'
import type { JsonObject, JsonValue, PayloadRequest } from '../../../types/index.js'
import type { Field, TabAsField } from '../../config/types.js'
import type { Block, Field, TabAsField } from '../../config/types.js'
import { MissingEditorProp } from '../../../errors/index.js'
import { fieldAffectsData, tabHasName, valueIsValueWithRelation } from '../../config/types.js'
@@ -378,7 +378,12 @@ export const promise = async <T>({
rows.forEach((row, rowIndex) => {
const rowSiblingDoc = getExistingRowDoc(row as JsonObject, siblingDoc[field.name])
const blockTypeToMatch = (row as JsonObject).blockType || rowSiblingDoc.blockType
const block = field.blocks.find((blockType) => blockType.slug === blockTypeToMatch)
const block: Block | undefined =
req.payload.blocks[blockTypeToMatch] ??
((field.blockReferences ?? field.blocks).find(
(curBlock) => typeof curBlock !== 'string' && curBlock.slug === blockTypeToMatch,
) as Block | undefined)
if (block) {
;(row as JsonObject).blockType = blockTypeToMatch

View File

@@ -20,6 +20,7 @@ export const sanitizeGlobal = async (
* so that you can sanitize them together, after the config has been sanitized.
*/
richTextSanitizationPromises?: Array<(config: SanitizedConfig) => Promise<void>>,
_validRelationships?: string[],
): Promise<SanitizedGlobalConfig> => {
const { collections } = config
@@ -64,7 +65,8 @@ export const sanitizeGlobal = async (
}
// Sanitize fields
const validRelationships = collections.map((c) => c.slug) || []
const validRelationships = _validRelationships ?? config.collections.map((c) => c.slug) ?? []
global.fields = await sanitizeFields({
config,
fields: global.fields,

View File

@@ -75,7 +75,7 @@ import { generateImportMap, type ImportMap } from './bin/generateImportMap/index
import { checkPayloadDependencies } from './checkPayloadDependencies.js'
import localOperations from './collections/operations/local/index.js'
import { consoleEmailAdapter } from './email/consoleEmailAdapter.js'
import { fieldAffectsData } from './fields/config/types.js'
import { fieldAffectsData, type FlattenedBlock } from './fields/config/types.js'
import localGlobalOperations from './globals/operations/local/index.js'
import { getJobsLocalAPI } from './queues/localAPI.js'
import { isNextBuild } from './utilities/isNextBuild.js'
@@ -106,6 +106,9 @@ export interface GeneratedTypes {
}
}
blocksUntyped: {
[slug: string]: JsonObject
}
collectionsJoinsUntyped: {
[slug: string]: {
[schemaPath: string]: CollectionSlug
@@ -151,6 +154,11 @@ type ResolveCollectionType<T> = 'collections' extends keyof T
: // @ts-expect-error
T['collectionsUntyped']
type ResolveBlockType<T> = 'blocks' extends keyof T
? T['blocks']
: // @ts-expect-error
T['blocksUntyped']
type ResolveCollectionSelectType<T> = 'collectionsSelect' extends keyof T
? T['collectionsSelect']
: // @ts-expect-error
@@ -174,6 +182,8 @@ type ResolveGlobalSelectType<T> = 'globalsSelect' extends keyof T
// Applying helper types to GeneratedTypes
export type TypedCollection = ResolveCollectionType<GeneratedTypes>
export type TypedBlock = ResolveBlockType<GeneratedTypes>
export type TypedUploadCollection = NonNever<{
[K in keyof TypedCollection]:
| 'filename'
@@ -198,6 +208,8 @@ export type StringKeyOf<T> = Extract<keyof T, string>
// Define the types for slugs using the appropriate collections and globals
export type CollectionSlug = StringKeyOf<TypedCollection>
export type BlockSlug = StringKeyOf<TypedBlock>
export type UploadCollectionSlug = StringKeyOf<TypedUploadCollection>
type ResolveDbType<T> = 'db' extends keyof T
@@ -247,6 +259,8 @@ export class BasePayload {
authStrategies: AuthStrategy[]
blocks: Record<BlockSlug, FlattenedBlock> = {}
collections: Record<CollectionSlug, Collection> = {}
config: SanitizedConfig
@@ -602,7 +616,7 @@ export class BasePayload {
}
}
traverseFields({ callback: findCustomID, fields: collection.fields })
traverseFields({ callback: findCustomID, config: this.config, fields: collection.fields })
this.collections[collection.slug] = {
config: collection,
@@ -610,6 +624,11 @@ export class BasePayload {
}
}
this.blocks = this.config.blocks.reduce((blocks, block) => {
blocks[block.slug] = block
return blocks
}, {})
// Generate types on startup
if (process.env.NODE_ENV !== 'production' && this.config.typescript.autoGenerate !== false) {
// We cannot run it directly here, as generate-types imports json-schema-to-typescript, which breaks on turbopack.
@@ -811,6 +830,11 @@ export const reload = async (
return collections
}, {})
payload.blocks = config.blocks.reduce((blocks, block) => {
blocks[block.slug] = block
return blocks
}, {})
payload.globals = {
config: config.globals,
}
@@ -1044,6 +1068,7 @@ export {
createClientConfig,
serverOnlyAdminConfigProperties,
serverOnlyConfigProperties,
type UnsanitizedClientConfig,
} from './config/client.js'
export { defaults } from './config/defaults.js'

View File

@@ -294,14 +294,22 @@ export function fieldsToJSONSchema(
// Check for a case where no blocks are provided.
// We need to generate an empty array for this case, note that JSON schema 4 doesn't support empty arrays
// so the best we can get is `unknown[]`
const hasBlocks = Boolean(field.blocks.length)
const hasBlocks = Boolean(
field.blockReferences ? field.blockReferences.length : field.blocks.length,
)
fieldSchema = {
...baseFieldSchema,
type: withNullableJSONSchemaType('array', isRequired),
items: hasBlocks
? {
oneOf: field.blocks.map((block) => {
oneOf: (field.blockReferences ?? field.blocks).map((block) => {
if (typeof block === 'string') {
const resolvedBlock = config?.blocks?.find((b) => b.slug === block)
return {
$ref: `#/definitions/${resolvedBlock.interfaceName ?? resolvedBlock.slug}`,
}
}
const blockFieldSchemas = fieldsToJSONSchema(
collectionIDFieldTypes,
block.flattenedFields,
@@ -732,9 +740,11 @@ export function entityToJSONSchema(
}
export function fieldsToSelectJSONSchema({
config,
fields,
interfaceNameDefinitions,
}: {
config: SanitizedConfig
fields: FlattenedField[]
interfaceNameDefinitions: Map<string, JSONSchema4>
}): JSONSchema4 {
@@ -750,6 +760,7 @@ export function fieldsToSelectJSONSchema({
case 'group':
case 'tab': {
let fieldSchema: JSONSchema4 = fieldsToSelectJSONSchema({
config,
fields: field.flattenedFields,
interfaceNameDefinitions,
})
@@ -782,8 +793,13 @@ export function fieldsToSelectJSONSchema({
properties: {},
}
for (const block of field.blocks) {
for (const block of field.blockReferences ?? field.blocks) {
if (typeof block === 'string') {
continue // TODO
}
let blockSchema = fieldsToSelectJSONSchema({
config,
fields: block.flattenedFields,
interfaceNameDefinitions,
})
@@ -1038,6 +1054,7 @@ export function configToJSONSchema(
i18n,
)
const select = fieldsToSelectJSONSchema({
config,
fields: entity.flattenedFields,
interfaceNameDefinitions,
})
@@ -1081,6 +1098,42 @@ export function configToJSONSchema(
)
: {}
const blocksDefinition: JSONSchema4 = {
type: 'object',
additionalProperties: false,
properties: {},
required: [],
}
for (const block of config.blocks) {
const blockFieldSchemas = fieldsToJSONSchema(
collectionIDFieldTypes,
block.flattenedFields,
interfaceNameDefinitions,
config,
i18n,
)
const blockSchema: JSONSchema4 = {
type: 'object',
additionalProperties: false,
properties: {
...blockFieldSchemas.properties,
blockType: {
const: block.slug,
},
},
required: ['blockType', ...blockFieldSchemas.required],
}
const interfaceName = block.interfaceName ?? block.slug
interfaceNameDefinitions.set(interfaceName, blockSchema)
blocksDefinition.properties[block.slug] = {
$ref: `#/definitions/${interfaceName}`,
}
;(blocksDefinition.required as string[]).push(block.slug)
}
let jsonSchema: JSONSchema4 = {
additionalProperties: false,
definitions: {
@@ -1093,6 +1146,7 @@ export function configToJSONSchema(
type: 'object',
properties: {
auth: generateAuthOperationSchemas(config.collections),
blocks: blocksDefinition,
collections: generateEntitySchemas(config.collections || []),
collectionsJoins: generateCollectionJoinsSchemas(config.collections || []),
collectionsSelect: generateEntitySelectSchemas(config.collections || []),
@@ -1113,6 +1167,7 @@ export function configToJSONSchema(
'auth',
'db',
'jobs',
'blocks',
],
title: 'Config',
}

View File

@@ -1,3 +1,4 @@
import type { ClientConfig } from '../config/client.js'
// @ts-strict-ignore
import type { ClientField } from '../fields/config/client.js'
import type { FieldTypes } from '../fields/config/types.js'
@@ -12,7 +13,7 @@ export type FieldSchemaJSON = {
type: FieldTypes
}[]
export const fieldSchemaToJSON = (fields: ClientField[]): FieldSchemaJSON => {
export const fieldSchemaToJSON = (fields: ClientField[], config: ClientConfig): FieldSchemaJSON => {
return fields.reduce((acc, field) => {
let result = acc
@@ -21,13 +22,16 @@ export const fieldSchemaToJSON = (fields: ClientField[]): FieldSchemaJSON => {
acc.push({
name: field.name,
type: field.type,
fields: fieldSchemaToJSON([
...field.fields,
{
name: 'id',
type: 'text',
},
]),
fields: fieldSchemaToJSON(
[
...field.fields,
{
name: 'id',
type: 'text',
},
],
config,
),
})
break
@@ -36,15 +40,19 @@ export const fieldSchemaToJSON = (fields: ClientField[]): FieldSchemaJSON => {
acc.push({
name: field.name,
type: field.type,
blocks: field.blocks.reduce((acc, block) => {
blocks: (field.blockReferences ?? field.blocks).reduce((acc, _block) => {
const block = typeof _block === 'string' ? config.blocksMap[_block] : _block
acc[block.slug] = {
fields: fieldSchemaToJSON([
...block.fields,
{
name: 'id',
type: 'text',
},
]),
fields: fieldSchemaToJSON(
[
...block.fields,
{
name: 'id',
type: 'text',
},
],
config,
),
}
return acc
@@ -55,14 +63,14 @@ export const fieldSchemaToJSON = (fields: ClientField[]): FieldSchemaJSON => {
case 'collapsible': // eslint-disable no-fallthrough
case 'row':
result = result.concat(fieldSchemaToJSON(field.fields))
result = result.concat(fieldSchemaToJSON(field.fields, config))
break
case 'group':
acc.push({
name: field.name,
type: field.type,
fields: fieldSchemaToJSON(field.fields),
fields: fieldSchemaToJSON(field.fields, config),
})
break
@@ -86,12 +94,12 @@ export const fieldSchemaToJSON = (fields: ClientField[]): FieldSchemaJSON => {
tabFields.push({
name: tab.name,
type: field.type,
fields: fieldSchemaToJSON(tab.fields),
fields: fieldSchemaToJSON(tab.fields, config),
})
return
}
tabFields = tabFields.concat(fieldSchemaToJSON(tab.fields))
tabFields = tabFields.concat(fieldSchemaToJSON(tab.fields, config))
})
result = result.concat(tabFields)

View File

@@ -1,7 +1,21 @@
import type { Field, FlattenedField, FlattenedJoinField } from '../fields/config/types.js'
import type {
Block,
Field,
FlattenedBlock,
FlattenedBlocksField,
FlattenedField,
FlattenedJoinField,
} from '../fields/config/types.js'
import { tabHasName } from '../fields/config/types.js'
export const flattenBlock = ({ block }: { block: Block }): FlattenedBlock => {
return {
...block,
flattenedFields: flattenAllFields({ fields: block.fields }),
}
}
export const flattenAllFields = ({ fields }: { fields: Field[] }): FlattenedField[] => {
const result: FlattenedField[] = []
@@ -14,17 +28,34 @@ export const flattenAllFields = ({ fields }: { fields: Field[] }): FlattenedFiel
}
case 'blocks': {
const blocks = []
for (const block of field.blocks) {
blocks.push({
...block,
flattenedFields: flattenAllFields({ fields: block.fields }),
})
const blocks: FlattenedBlock[] = []
let blockReferences: (FlattenedBlock | string)[] | undefined = undefined
if (field.blockReferences) {
blockReferences = []
for (const block of field.blockReferences) {
if (typeof block === 'string') {
blockReferences.push(block)
continue
}
blockReferences.push(flattenBlock({ block }))
}
} else {
for (const block of field.blocks) {
if (typeof block === 'string') {
blocks.push(block)
continue
}
blocks.push(flattenBlock({ block }))
}
}
result.push({
const resultField: FlattenedBlocksField = {
...field,
blockReferences,
blocks,
})
}
result.push(resultField)
break
}

View File

@@ -172,13 +172,18 @@ export async function getEntityPolicies<T extends Args>(args: T): Promise<Return
})
}
if ('blocks' in field && field.blocks) {
if (
('blocks' in field && field.blocks) ||
('blockReferences' in field && field.blockReferences)
) {
if (!mutablePolicies[field.name]?.blocks) {
mutablePolicies[field.name].blocks = {}
}
await Promise.all(
field.blocks.map(async (block) => {
(field.blockReferences ?? field.blocks).map(async (_block) => {
const block = typeof _block === 'string' ? payload.blocks[_block] : _block // TODO: Skip over string blocks
if (!mutablePolicies[field.name].blocks?.[block.slug]) {
mutablePolicies[field.name].blocks[block.slug] = {
fields: {},

View File

@@ -1,43 +1,86 @@
// @ts-strict-ignore
import type { ArrayField, BlocksField, Field, TabAsField } from '../fields/config/types.js'
import type { Config, SanitizedConfig } from '../config/types.js'
import type { ArrayField, Block, BlocksField, Field, TabAsField } from '../fields/config/types.js'
import { fieldHasSubFields } from '../fields/config/types.js'
const traverseArrayOrBlocksField = ({
callback,
callbackStack,
config,
data,
field,
fillEmpty,
leavesFirst,
parentRef,
}: {
callback: TraverseFieldsCallback
callbackStack: TraverseFieldsCallback[]
config: Config | SanitizedConfig
data: Record<string, unknown>[]
field: ArrayField | BlocksField
fillEmpty: boolean
leavesFirst: boolean
parentRef?: unknown
}) => {
if (fillEmpty) {
if (field.type === 'array') {
traverseFields({ callback, fields: field.fields, parentRef })
traverseFields({
callback,
callbackStack,
config,
fields: field.fields,
isTopLevel: false,
leavesFirst,
parentRef,
})
}
if (field.type === 'blocks') {
field.blocks.forEach((block) => {
traverseFields({ callback, fields: block.fields, parentRef })
})
for (const _block of field.blockReferences ?? field.blocks) {
// TODO: iterate over blocks mapped to block slug in v4, or pass through payload.blocks
const block =
typeof _block === 'string' ? config.blocks.find((b) => b.slug === _block) : _block
traverseFields({
callback,
callbackStack,
config,
fields: block.fields,
isTopLevel: false,
leavesFirst,
parentRef,
})
}
}
return
}
for (const ref of data) {
let fields: Field[]
if (field.type === 'blocks' && typeof ref?.blockType === 'string') {
const block = field.blocks.find((block) => block.slug === ref.blockType)
// TODO: iterate over blocks mapped to block slug in v4, or pass through payload.blocks
const block = field.blockReferences
? ((config.blocks.find((b) => b.slug === ref.blockType) ??
field.blockReferences.find(
(b) => typeof b !== 'string' && b.slug === ref.blockType,
)) as Block)
: field.blocks.find((b) => b.slug === ref.blockType)
fields = block?.fields
} else if (field.type === 'array') {
fields = field.fields
}
if (fields) {
traverseFields({ callback, fields, fillEmpty, parentRef, ref })
traverseFields({
callback,
callbackStack,
config,
fields,
fillEmpty,
isTopLevel: false,
leavesFirst,
parentRef,
ref,
})
}
}
}
@@ -63,8 +106,18 @@ export type TraverseFieldsCallback = (args: {
type TraverseFieldsArgs = {
callback: TraverseFieldsCallback
callbackStack?: TraverseFieldsCallback[]
config: Config | SanitizedConfig
fields: (Field | TabAsField)[]
fillEmpty?: boolean
isTopLevel?: boolean
/**
* @default false
*
* if this is `true`, the callback functions of the leaf fields will be called before the parent fields.
* The return value of the callback function will be ignored.
*/
leavesFirst?: boolean
parentRef?: Record<string, unknown> | unknown
ref?: Record<string, unknown> | unknown
}
@@ -80,12 +133,20 @@ type TraverseFieldsArgs = {
*/
export const traverseFields = ({
callback,
callbackStack: _callbackStack = [],
config,
fields,
fillEmpty = true,
isTopLevel = true,
leavesFirst = false,
parentRef = {},
ref = {},
}: TraverseFieldsArgs): void => {
fields.some((field) => {
let callbackStack: TraverseFieldsCallback[] = []
if (!isTopLevel) {
callbackStack = _callbackStack
}
let skip = false
const next = () => {
skip = true
@@ -95,8 +156,10 @@ export const traverseFields = ({
return
}
if (callback && callback({ field, next, parentRef, ref })) {
if (!leavesFirst && callback && callback({ field, next, parentRef, ref })) {
return true
} else if (leavesFirst) {
callbackStack.push(callback)
}
if (skip) {
@@ -130,6 +193,7 @@ export const traverseFields = ({
if (
callback &&
!leavesFirst &&
callback({
field: { ...tab, type: 'tab' },
next,
@@ -138,6 +202,8 @@ export const traverseFields = ({
})
) {
return true
} else if (leavesFirst) {
callbackStack.push(callback)
}
tabRef = tabRef[tab.name]
@@ -147,8 +213,12 @@ export const traverseFields = ({
if (tabRef[key] && typeof tabRef[key] === 'object') {
traverseFields({
callback,
callbackStack,
config,
fields: tab.fields,
fillEmpty,
isTopLevel: false,
leavesFirst,
parentRef: currentParentRef,
ref: tabRef[key],
})
@@ -158,6 +228,7 @@ export const traverseFields = ({
} else {
if (
callback &&
!leavesFirst &&
callback({
field: { ...tab, type: 'tab' },
next,
@@ -166,14 +237,20 @@ export const traverseFields = ({
})
) {
return true
} else if (leavesFirst) {
callbackStack.push(callback)
}
}
if (!tab.localized) {
traverseFields({
callback,
callbackStack,
config,
fields: tab.fields,
fillEmpty,
isTopLevel: false,
leavesFirst,
parentRef: currentParentRef,
ref: tabRef,
})
@@ -226,8 +303,12 @@ export const traverseFields = ({
if (currentRef[key]) {
traverseFields({
callback,
callbackStack,
config,
fields: field.fields,
fillEmpty,
isTopLevel: false,
leavesFirst,
parentRef: currentParentRef,
ref: currentRef[key],
})
@@ -254,30 +335,46 @@ export const traverseFields = ({
traverseArrayOrBlocksField({
callback,
callbackStack,
config,
data: localeData,
field,
fillEmpty,
leavesFirst,
parentRef: currentParentRef,
})
}
} else if (Array.isArray(currentRef)) {
traverseArrayOrBlocksField({
callback,
callbackStack,
config,
data: currentRef as Record<string, unknown>[],
field,
fillEmpty,
leavesFirst,
parentRef: currentParentRef,
})
}
} else if (currentRef && typeof currentRef === 'object' && 'fields' in field) {
traverseFields({
callback,
callbackStack,
config,
fields: field.fields,
fillEmpty,
isTopLevel: false,
leavesFirst,
parentRef: currentParentRef,
ref: currentRef,
})
}
}
if (isTopLevel) {
callbackStack.reverse().forEach((cb) => {
cb({ field, next, parentRef, ref })
})
}
})
}

View File

@@ -161,6 +161,7 @@ export const multiTenantPlugin =
* Modify enabled collections
*/
addFilterOptionsToFields({
config: incomingConfig,
fields: collection.fields,
tenantEnabledCollectionSlugs: collectionSlugs,
tenantEnabledGlobalSlugs: globalCollectionSlugs,

View File

@@ -1,9 +1,10 @@
import type { Field, FilterOptionsProps, RelationshipField } from 'payload'
import type { Config, Field, FilterOptionsProps, RelationshipField, SanitizedConfig } from 'payload'
import { getCollectionIDType } from './getCollectionIDType.js'
import { getTenantFromCookie } from './getTenantFromCookie.js'
type AddFilterOptionsToFieldsArgs = {
config: Config | SanitizedConfig
fields: Field[]
tenantEnabledCollectionSlugs: string[]
tenantEnabledGlobalSlugs: string[]
@@ -12,6 +13,7 @@ type AddFilterOptionsToFieldsArgs = {
}
export function addFilterOptionsToFields({
config,
fields,
tenantEnabledCollectionSlugs,
tenantEnabledGlobalSlugs,
@@ -59,6 +61,7 @@ export function addFilterOptionsToFields({
field.type === 'group'
) {
addFilterOptionsToFields({
config,
fields: field.fields,
tenantEnabledCollectionSlugs,
tenantEnabledGlobalSlugs,
@@ -68,20 +71,30 @@ export function addFilterOptionsToFields({
}
if (field.type === 'blocks') {
field.blocks.forEach((block) => {
addFilterOptionsToFields({
fields: block.fields,
tenantEnabledCollectionSlugs,
tenantEnabledGlobalSlugs,
tenantFieldName,
tenantsCollectionSlug,
})
;(field.blockReferences ?? field.blocks).forEach((_block) => {
const block =
typeof _block === 'string'
? // TODO: iterate over blocks mapped to block slug in v4, or pass through payload.blocks
config?.blocks?.find((b) => b.slug === _block)
: _block
if (block?.fields) {
addFilterOptionsToFields({
config,
fields: block.fields,
tenantEnabledCollectionSlugs,
tenantEnabledGlobalSlugs,
tenantFieldName,
tenantsCollectionSlug,
})
}
})
}
if (field.type === 'tabs') {
field.tabs.forEach((tab) => {
addFilterOptionsToFields({
config,
fields: tab.fields,
tenantEnabledCollectionSlugs,
tenantEnabledGlobalSlugs,

View File

@@ -12,6 +12,7 @@ import {
Pill,
RenderFields,
SectionTitle,
useConfig,
useDocumentForm,
useDocumentInfo,
useEditDepth,
@@ -28,7 +29,12 @@ const baseClass = 'lexical-block'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { getTranslation } from '@payloadcms/translations'
import { $getNodeByKey } from 'lexical'
import { type BlocksFieldClient, type CollapsedPreferences, type FormState } from 'payload'
import {
type BlocksFieldClient,
type ClientBlock,
type CollapsedPreferences,
type FormState,
} from 'payload'
import { v4 as uuid } from 'uuid'
import type { BlockFields } from '../../server/nodes/BlocksNode.js'
@@ -72,6 +78,8 @@ export const BlockComponent: React.FC<Props> = (props) => {
const editDepth = useEditDepth()
const [errorCount, setErrorCount] = React.useState(0)
const { config } = useConfig()
const drawerSlug = formatDrawerSlug({
slug: `lexical-blocks-create-${uuidFromContext}-${formData.id}`,
depth: editDepth,
@@ -212,7 +220,11 @@ export const BlockComponent: React.FC<Props> = (props) => {
componentMapRenderedBlockPath
]?.[0] as BlocksFieldClient
const clientBlock = blocksField?.blocks?.[0]
const clientBlock: ClientBlock | undefined = blocksField.blockReferences
? typeof blocksField?.blockReferences?.[0] === 'string'
? config.blocksMap[blocksField?.blockReferences?.[0]]
: blocksField?.blockReferences?.[0]
: blocksField?.blocks?.[0]
const { i18n, t } = useTranslation<object, string>()

View File

@@ -3,7 +3,7 @@
import React, { createContext, useCallback, useEffect, useMemo, useRef } from 'react'
const baseClass = 'inline-block'
import type { BlocksFieldClient, Data, FormState } from 'payload'
import type { BlocksFieldClient, ClientBlock, Data, FormState } from 'payload'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { getTranslation } from '@payloadcms/translations'
@@ -16,6 +16,7 @@ import {
FormSubmit,
RenderFields,
ShimmerEffect,
useConfig,
useDocumentForm,
useDocumentInfo,
useEditDepth,
@@ -120,6 +121,7 @@ export const InlineBlockComponent: React.FC<Props> = (props) => {
const inlineBlockElemElemRef = useRef<HTMLDivElement | null>(null)
const { id, collectionSlug, getDocPreferences, globalSlug } = useDocumentInfo()
const { config } = useConfig()
const componentMapRenderedBlockPath = `${schemaPath}.lexical_internal_feature.blocks.lexical_inline_blocks.${formData.blockType}`
@@ -129,7 +131,11 @@ export const InlineBlockComponent: React.FC<Props> = (props) => {
componentMapRenderedBlockPath
]?.[0] as BlocksFieldClient
const clientBlock = blocksField?.blocks?.[0]
const clientBlock: ClientBlock | undefined = blocksField.blockReferences
? typeof blocksField?.blockReferences?.[0] === 'string'
? config.blocksMap[blocksField?.blockReferences?.[0]]
: blocksField?.blockReferences?.[0]
: blocksField?.blocks?.[0]
const clientBlockFields = clientBlock?.fields ?? []

View File

@@ -20,7 +20,7 @@ import { InlineBlockNode } from './nodes/InlineBlocksNode.js'
import { INSERT_BLOCK_COMMAND, INSERT_INLINE_BLOCK_COMMAND } from './plugin/commands.js'
import { BlocksPlugin } from './plugin/index.js'
export const BlocksFeatureClient = createClientFeature(
({ featureClientSchemaMap, props, schemaPath }) => {
({ config, featureClientSchemaMap, props, schemaPath }) => {
const schemaMapRenderedBlockPathPrefix = `${schemaPath}.lexical_internal_feature.blocks.lexical_blocks`
const schemaMapRenderedInlineBlockPathPrefix = `${schemaPath}.lexical_internal_feature.blocks.lexical_inline_blocks`
const clientSchema = featureClientSchemaMap['blocks']
@@ -47,13 +47,21 @@ export const BlocksFeatureClient = createClientFeature(
const clientBlocks: ClientBlock[] = blocksFields
.map((field) => {
return field.blocks[0]
return field.blockReferences
? typeof field.blockReferences[0] === 'string'
? config.blocksMap[field.blockReferences[0]]
: field.blockReferences[0]
: field.blocks[0]
})
.filter((block) => block !== undefined)
const clientInlineBlocks: ClientBlock[] = inlineBlocksFields
.map((field) => {
return field.blocks[0]
return field.blockReferences
? typeof field.blockReferences[0] === 'string'
? config.blocksMap[field.blockReferences[0]]
: field.blockReferences[0]
: field.blocks[0]
})
.filter((block) => block !== undefined)

View File

@@ -1,4 +1,11 @@
import type { Block, BlocksField, Config, FieldSchemaMap, FlattenedBlocksField } from 'payload'
import type {
Block,
BlocksField,
BlockSlug,
Config,
FieldSchemaMap,
FlattenedBlocksField,
} from 'payload'
import { fieldsToJSONSchema, flattenAllFields, sanitizeFields } from 'payload'
@@ -12,12 +19,12 @@ import { ServerInlineBlockNode } from './nodes/InlineBlocksNode.js'
import { blockValidationHOC } from './validate.js'
export type BlocksFeatureProps = {
blocks?: Block[]
inlineBlocks?: Block[]
blocks?: (Block | BlockSlug)[] | Block[]
inlineBlocks?: (Block | BlockSlug)[] | Block[]
}
export const BlocksFeature = createServerFeature<BlocksFeatureProps, BlocksFeatureProps>({
feature: async ({ config: _config, isRoot, parentIsLocalized, props }) => {
feature: async ({ config: _config, isRoot, parentIsLocalized, props: _props }) => {
const validRelationships = _config.collections.map((c) => c.slug) || []
const sanitized = await sanitizeFields({
@@ -26,12 +33,14 @@ export const BlocksFeature = createServerFeature<BlocksFeatureProps, BlocksFeatu
{
name: 'lexical_blocks',
type: 'blocks',
blocks: props.blocks ?? [],
blockReferences: _props.blocks ?? [],
blocks: [],
},
{
name: 'lexical_inline_blocks',
type: 'blocks',
blocks: props.inlineBlocks ?? [],
blockReferences: _props.inlineBlocks ?? [],
blocks: [],
},
],
parentIsLocalized,
@@ -39,8 +48,31 @@ export const BlocksFeature = createServerFeature<BlocksFeatureProps, BlocksFeatu
validRelationships,
})
props.blocks = (sanitized[0] as BlocksField).blocks
props.inlineBlocks = (sanitized[1] as BlocksField).blocks
const blockConfigs: Block[] = []
for (const _block of (sanitized[0] as BlocksField).blockReferences ??
(sanitized[0] as BlocksField).blocks) {
const block =
typeof _block === 'string' ? _config?.blocks?.find((b) => b.slug === _block) : _block
if (!block) {
throw new Error(
`Block not found for slug: ${typeof _block === 'string' ? _block : _block?.slug}`,
)
}
blockConfigs.push(block)
}
const inlineBlockConfigs: Block[] = []
for (const _block of (sanitized[1] as BlocksField).blockReferences ??
(sanitized[1] as BlocksField).blocks) {
const block =
typeof _block === 'string' ? _config?.blocks?.find((b) => b.slug === _block) : _block
if (!block) {
throw new Error(
`Block not found for slug: ${typeof _block === 'string' ? _block : _block?.slug}`,
)
}
inlineBlockConfigs.push(block)
}
return {
ClientFeature: '@payloadcms/richtext-lexical/client#BlocksFeatureClient',
@@ -53,30 +85,34 @@ export const BlocksFeature = createServerFeature<BlocksFeatureProps, BlocksFeatu
i18n,
interfaceNameDefinitions,
}) => {
if (!props?.blocks?.length && !props?.inlineBlocks?.length) {
if (!blockConfigs?.length && !inlineBlockConfigs?.length) {
return currentSchema
}
const fields: FlattenedBlocksField[] = []
if (props?.blocks?.length) {
if (blockConfigs?.length) {
fields.push({
name: field?.name + '_lexical_blocks',
type: 'blocks',
blocks: props.blocks.map((block) => ({
...block,
flattenedFields: flattenAllFields({ fields: block.fields }),
})),
blocks: blockConfigs.map((block) => {
return {
...block,
flattenedFields: flattenAllFields({ fields: block.fields }),
}
}),
})
}
if (props?.inlineBlocks?.length) {
if (inlineBlockConfigs?.length) {
fields.push({
name: field?.name + '_lexical_inline_blocks',
type: 'blocks',
blocks: props.inlineBlocks.map((block) => ({
...block,
flattenedFields: flattenAllFields({ fields: block.fields }),
})),
blocks: inlineBlockConfigs.map((block) => {
return {
...block,
flattenedFields: flattenAllFields({ fields: block.fields }),
}
}),
})
}
@@ -95,15 +131,15 @@ export const BlocksFeature = createServerFeature<BlocksFeatureProps, BlocksFeatu
return currentSchema
},
},
generateSchemaMap: ({ props }) => {
generateSchemaMap: ({ config }) => {
/**
* Add sub-fields to the schemaMap. E.g. if you have an array field as part of the block, and it runs addRow, it will request these
* sub-fields from the component map. Thus, we need to put them in the component map here.
*/
const schemaMap: FieldSchemaMap = new Map()
if (props?.blocks?.length) {
for (const block of props.blocks) {
if (blockConfigs?.length) {
for (const block of blockConfigs) {
const blockFields = [...block.fields]
if (block?.admin?.components) {
@@ -129,9 +165,9 @@ export const BlocksFeature = createServerFeature<BlocksFeatureProps, BlocksFeatu
}
}
if (props?.inlineBlocks?.length) {
if (inlineBlockConfigs?.length) {
// To generate block schemaMap which generates things like the componentMap for admin.Label
for (const block of props.inlineBlocks) {
for (const block of inlineBlockConfigs) {
const blockFields = [...block.fields]
if (block?.admin?.components) {
@@ -163,8 +199,8 @@ export const BlocksFeature = createServerFeature<BlocksFeatureProps, BlocksFeatu
},
i18n,
markdownTransformers: getBlockMarkdownTransformers({
blocks: props.blocks,
inlineBlocks: props.inlineBlocks,
blocks: blockConfigs,
inlineBlocks: inlineBlockConfigs,
}),
nodes: [
@@ -172,12 +208,12 @@ export const BlocksFeature = createServerFeature<BlocksFeatureProps, BlocksFeatu
// @ts-expect-error - TODO: fix this
getSubFields: ({ node }) => {
if (!node) {
if (props?.blocks?.length) {
if (blockConfigs?.length) {
return [
{
name: 'lexical_blocks',
type: 'blocks',
blocks: props.blocks,
blocks: blockConfigs,
},
]
}
@@ -186,26 +222,26 @@ export const BlocksFeature = createServerFeature<BlocksFeatureProps, BlocksFeatu
const blockType = node.fields.blockType
const block = props.blocks?.find((block) => block.slug === blockType)
const block = blockConfigs?.find((block) => block.slug === blockType)
return block?.fields
},
getSubFieldsData: ({ node }) => {
return node?.fields
},
graphQLPopulationPromises: [blockPopulationPromiseHOC(props.blocks)],
graphQLPopulationPromises: [blockPopulationPromiseHOC(blockConfigs)],
node: ServerBlockNode,
validations: [blockValidationHOC(props.blocks)],
validations: [blockValidationHOC(blockConfigs)],
}),
createNode({
// @ts-expect-error - TODO: fix this
getSubFields: ({ node }) => {
if (!node) {
if (props?.inlineBlocks?.length) {
if (inlineBlockConfigs?.length) {
return [
{
name: 'lexical_inline_blocks',
type: 'blocks',
blocks: props.inlineBlocks,
blocks: inlineBlockConfigs,
},
]
}
@@ -214,18 +250,18 @@ export const BlocksFeature = createServerFeature<BlocksFeatureProps, BlocksFeatu
const blockType = node.fields.blockType
const block = props.inlineBlocks?.find((block) => block.slug === blockType)
const block = inlineBlockConfigs?.find((block) => block.slug === blockType)
return block?.fields
},
getSubFieldsData: ({ node }) => {
return node?.fields
},
graphQLPopulationPromises: [blockPopulationPromiseHOC(props.inlineBlocks)],
graphQLPopulationPromises: [blockPopulationPromiseHOC(inlineBlockConfigs)],
node: ServerInlineBlockNode,
validations: [blockValidationHOC(props.inlineBlocks)],
validations: [blockValidationHOC(inlineBlockConfigs)],
}),
],
sanitizedServerFeatureProps: props,
sanitizedServerFeatureProps: _props,
}
},
key: 'blocks',

View File

@@ -6,6 +6,11 @@ import type { NodeValidation } from '../../typesServer.js'
import type { BlockFields, SerializedBlockNode } from './nodes/BlocksNode.js'
import type { SerializedInlineBlockNode } from './nodes/InlineBlocksNode.js'
/**
* Runs validation for blocks. This function will determine if the rich text field itself is valid. It does not handle
* block field error paths - this is done by the `beforeChangeTraverseFields` call in the `beforeChange` hook, called from the
* rich text adapter.
*/
export const blockValidationHOC = (
blocks: Block[],
): NodeValidation<SerializedBlockNode | SerializedInlineBlockNode> => {

View File

@@ -1,5 +1,5 @@
import type { SerializedEditorState } from 'lexical'
import type { Field, FieldAffectingData, RichTextField } from 'payload'
import type { Field, FieldAffectingData, PayloadRequest, RichTextField } from 'payload'
import type { SanitizedServerEditorConfig } from '../../../../lexical/config/types.js'
import type { AdapterProps, LexicalRichTextAdapter } from '../../../../types.js'

View File

@@ -268,6 +268,7 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte
originalNode: originalNodeIDMap[id],
parentRichTextFieldPath: path,
parentRichTextFieldSchemaPath: schemaPath,
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
previousNode: previousNodeIDMap[id]!,
req,
})
@@ -281,8 +282,10 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte
if (subFieldFn && subFieldDataFn) {
const subFields = subFieldFn({ node, req })
const nodeSiblingData = subFieldDataFn({ node, req }) ?? {}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
const nodeSiblingDoc = subFieldDataFn({ node: originalNodeIDMap[id]!, req }) ?? {}
const nodePreviousSiblingDoc =
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
subFieldDataFn({ node: previousNodeIDMap[id]!, req }) ?? {}
if (subFields?.length) {
@@ -540,6 +543,7 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte
originalNodeWithLocales: originalNodeWithLocalesIDMap[id],
parentRichTextFieldPath: path,
parentRichTextFieldSchemaPath: schemaPath,
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
previousNode: previousNodeIDMap[id]!,
req,
skipValidation: skipValidation!,
@@ -557,10 +561,12 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte
const nodeSiblingData = subFieldDataFn({ node, req }) ?? {}
const nodeSiblingDocWithLocales =
subFieldDataFn({
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
node: originalNodeWithLocalesIDMap[id]!,
req,
}) ?? {}
const nodePreviousSiblingDoc =
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
subFieldDataFn({ node: previousNodeIDMap[id]!, req }) ?? {}
if (subFields?.length) {
@@ -756,6 +762,7 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte
if (subFieldFn && subFieldDataFn) {
const subFields = subFieldFn({ node, req })
const nodeSiblingData = subFieldDataFn({ node, req }) ?? {}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
const nodeSiblingDoc = subFieldDataFn({ node: originalNodeIDMap[id]!, req }) ?? {}
if (subFields?.length) {

View File

@@ -75,6 +75,7 @@ async function migrateGlobal({
const found = migrateDocument({
document,
fields: global.fields,
payload,
})
if (found) {
@@ -159,6 +160,7 @@ async function migrateCollection({
const found = migrateDocument({
document,
fields: collection.fields,
payload,
})
if (found) {
@@ -189,13 +191,16 @@ async function migrateCollection({
function migrateDocument({
document,
fields,
payload,
}: {
document: Record<string, unknown>
fields: Field[]
payload: Payload
}): boolean {
return !!migrateDocumentFieldsRecursively({
data: document,
fields,
found: 0,
payload,
})
}

View File

@@ -1,4 +1,4 @@
import type { Field } from 'payload'
import type { Field, FlattenedBlock, Payload } from 'payload'
import { fieldAffectsData, fieldHasSubFields, fieldIsArrayType, tabHasName } from 'payload/shared'
@@ -15,12 +15,14 @@ type NestedRichTextFieldsArgs = {
fields: Field[]
found: number
payload: Payload
}
export const migrateDocumentFieldsRecursively = ({
data,
fields,
found,
payload,
}: NestedRichTextFieldsArgs): number => {
for (const field of fields) {
if (fieldHasSubFields(field) && !fieldIsArrayType(field)) {
@@ -29,12 +31,14 @@ export const migrateDocumentFieldsRecursively = ({
data: data[field.name] as Record<string, unknown>,
fields: field.fields,
found,
payload,
})
} else {
found += migrateDocumentFieldsRecursively({
data,
fields: field.fields,
found,
payload,
})
}
} else if (field.type === 'tabs') {
@@ -43,17 +47,25 @@ export const migrateDocumentFieldsRecursively = ({
data: (tabHasName(tab) ? data[tab.name] : data) as Record<string, unknown>,
fields: tab.fields,
found,
payload,
})
})
} else if (Array.isArray(data[field.name])) {
if (field.type === 'blocks') {
;(data[field.name] as Array<Record<string, unknown>>).forEach((row) => {
const block = field.blocks.find(({ slug }) => slug === row?.blockType)
const blockTypeToMatch: string = row?.blockType as string
const block =
payload?.blocks[blockTypeToMatch] ??
((field.blockReferences ?? field.blocks).find(
(block) => typeof block !== 'string' && block.slug === blockTypeToMatch,
) as FlattenedBlock | undefined)
if (block) {
found += migrateDocumentFieldsRecursively({
data: row,
fields: block.fields,
found,
payload,
})
}
})
@@ -65,6 +77,7 @@ export const migrateDocumentFieldsRecursively = ({
data: row,
fields: field.fields,
found,
payload,
})
})
}

View File

@@ -57,6 +57,7 @@ async function upgradeGlobal({
const found = upgradeDocument({
document,
fields: global.fields,
payload,
})
if (found) {
@@ -129,6 +130,7 @@ async function upgradeCollection({
const found = upgradeDocument({
document,
fields: collection.fields,
payload,
})
if (found) {
@@ -148,13 +150,16 @@ async function upgradeCollection({
function upgradeDocument({
document,
fields,
payload,
}: {
document: Record<string, unknown>
fields: Field[]
payload: Payload
}): boolean {
return !!upgradeDocumentFieldsRecursively({
data: document,
fields,
found: 0,
payload,
})
}

View File

@@ -1,5 +1,5 @@
import type { SerializedEditorState } from 'lexical'
import type { Field } from 'payload'
import type { Field, FlattenedBlock, Payload } from 'payload'
import { createHeadlessEditor } from '@lexical/headless'
import { fieldAffectsData, fieldHasSubFields, fieldIsArrayType, tabHasName } from 'payload/shared'
@@ -13,12 +13,14 @@ type NestedRichTextFieldsArgs = {
fields: Field[]
found: number
payload: Payload
}
export const upgradeDocumentFieldsRecursively = ({
data,
fields,
found,
payload,
}: NestedRichTextFieldsArgs): number => {
for (const field of fields) {
if (fieldHasSubFields(field) && !fieldIsArrayType(field)) {
@@ -27,12 +29,14 @@ export const upgradeDocumentFieldsRecursively = ({
data: data[field.name] as Record<string, unknown>,
fields: field.fields,
found,
payload,
})
} else {
found += upgradeDocumentFieldsRecursively({
data,
fields: field.fields,
found,
payload,
})
}
} else if (field.type === 'tabs') {
@@ -41,17 +45,26 @@ export const upgradeDocumentFieldsRecursively = ({
data: (tabHasName(tab) ? data[tab.name] : data) as Record<string, unknown>,
fields: tab.fields,
found,
payload,
})
})
} else if (Array.isArray(data[field.name])) {
if (field.type === 'blocks') {
;(data[field.name] as Record<string, unknown>[]).forEach((row) => {
const block = field.blocks.find(({ slug }) => slug === row?.blockType)
const blockTypeToMatch: string = row?.blockType as string
const block =
payload.blocks[blockTypeToMatch] ??
((field.blockReferences ?? field.blocks).find(
(block) => typeof block !== 'string' && block.slug === blockTypeToMatch,
) as FlattenedBlock | undefined)
if (block) {
found += upgradeDocumentFieldsRecursively({
data: row,
fields: block.fields,
found,
payload,
})
}
})
@@ -63,6 +76,7 @@ export const upgradeDocumentFieldsRecursively = ({
data: row,
fields: field.fields,
found,
payload,
})
})
}

View File

@@ -1,4 +1,4 @@
import type { Field, PayloadRequest, PopulateType } from 'payload'
import type { Field, FlattenedBlock, PayloadRequest, PopulateType } from 'payload'
import { fieldAffectsData, fieldHasSubFields, fieldIsArrayType, tabHasName } from 'payload/shared'
@@ -172,7 +172,11 @@ export const recurseNestedFields = ({
} else if (Array.isArray(data[field.name])) {
if (field.type === 'blocks') {
data[field.name].forEach((row, i) => {
const block = field.blocks.find(({ slug }) => slug === row?.blockType)
const block =
req.payload.blocks[row?.blockType] ??
((field.blockReferences ?? field.blocks).find(
(block) => typeof block !== 'string' && block.slug === row?.blockType,
) as FlattenedBlock | undefined)
if (block) {
recurseNestedFields({
currentDepth,

View File

@@ -4,19 +4,27 @@ import type { BlocksFieldClient, DefaultCellComponentProps } from 'payload'
import { getTranslation } from '@payloadcms/translations'
import React from 'react'
import { useConfig } from '../../../../../providers/Config/index.js'
import { useTranslation } from '../../../../../providers/Translation/index.js'
export interface BlocksCellProps extends DefaultCellComponentProps<BlocksFieldClient> {}
export const BlocksCell: React.FC<BlocksCellProps> = ({ cellData, field: { blocks, labels } }) => {
export const BlocksCell: React.FC<BlocksCellProps> = ({
cellData,
field: { blockReferences, blocks, labels },
}) => {
const { i18n } = useTranslation()
const { config } = useConfig()
const selectedBlocks = Array.isArray(cellData) ? cellData.map(({ blockType }) => blockType) : []
const translatedBlockLabels = blocks?.map((b) => ({
slug: b.slug,
label: getTranslation(b.labels.singular, i18n),
}))
const translatedBlockLabels = (blockReferences ?? blocks)?.map((b) => {
const block = typeof b === 'string' ? config.blocksMap[b] : b
return {
slug: block.slug,
label: getTranslation(block.labels.singular, i18n),
}
})
let label = `0 ${getTranslation(labels?.plural, i18n)}`

View File

@@ -23,7 +23,7 @@ const baseClass = 'blocks-field'
type BlocksFieldProps = {
addRow: (rowIndex: number, blockType: string) => Promise<void> | void
block: ClientBlock
blocks: ClientBlock[]
blocks: (ClientBlock | string)[] | ClientBlock[]
duplicateRow: (rowIndex: number) => void
errorCount: number
fields: ClientField[]

View File

@@ -9,14 +9,15 @@ import React, { useEffect, useState } from 'react'
import { Drawer } from '../../../elements/Drawer/index.js'
import { ThumbnailCard } from '../../../elements/ThumbnailCard/index.js'
import { DefaultBlockImage } from '../../../graphics/DefaultBlockImage/index.js'
import { useConfig } from '../../../providers/Config/index.js'
import { useTranslation } from '../../../providers/Translation/index.js'
import { BlockSearch } from './BlockSearch/index.js'
import './index.scss'
import { BlockSearch } from './BlockSearch/index.js'
export type Props = {
readonly addRow: (index: number, blockType?: string) => Promise<void> | void
readonly addRowIndex: number
readonly blocks: ClientBlock[]
readonly blocks: (ClientBlock | string)[]
readonly drawerSlug: string
readonly labels: Labels
}
@@ -40,6 +41,7 @@ export const BlocksDrawer: React.FC<Props> = (props) => {
const [filteredBlocks, setFilteredBlocks] = useState(blocks)
const { closeModal, isModalOpen } = useModal()
const { i18n, t } = useTranslation()
const { config } = useConfig()
useEffect(() => {
if (!isModalOpen(drawerSlug)) {
@@ -50,7 +52,8 @@ export const BlocksDrawer: React.FC<Props> = (props) => {
useEffect(() => {
const searchTermToUse = searchTerm.toLowerCase()
const matchingBlocks = blocks?.reduce((matchedBlocks, block) => {
const matchingBlocks = blocks?.reduce((matchedBlocks, _block) => {
const block = typeof _block === 'string' ? config.blocksMap[_block] : _block
const blockLabel = getBlockLabel(block, i18n)
if (blockLabel.includes(searchTermToUse)) {
matchedBlocks.push(block)
@@ -59,7 +62,7 @@ export const BlocksDrawer: React.FC<Props> = (props) => {
}, [])
setFilteredBlocks(matchingBlocks)
}, [searchTerm, blocks, i18n])
}, [searchTerm, blocks, i18n, config.blocksMap])
return (
<Drawer
@@ -69,7 +72,9 @@ export const BlocksDrawer: React.FC<Props> = (props) => {
<BlockSearch setSearchTerm={setSearchTerm} />
<div className={`${baseClass}__blocks-wrapper`}>
<ul className={`${baseClass}__blocks`}>
{filteredBlocks?.map((block, index) => {
{filteredBlocks?.map((_block, index) => {
const block = typeof _block === 'string' ? config.blocksMap[_block] : _block
const { slug, imageAltText, imageURL, labels: blockLabels } = block
return (

View File

@@ -10,7 +10,7 @@ import { BlocksDrawer } from './BlocksDrawer/index.js'
export const RowActions: React.FC<{
readonly addRow: (rowIndex: number, blockType: string) => Promise<void> | void
readonly blocks: ClientBlock[]
readonly blocks: (ClientBlock | string)[]
readonly blockType: string
readonly duplicateRow: (rowIndex: number, blockType: string) => void
readonly fields: ClientField[]

View File

@@ -1,5 +1,5 @@
'use client'
import type { BlocksFieldClientComponent } from 'payload'
import type { BlocksFieldClientComponent, ClientBlock } from 'payload'
import { getTranslation } from '@payloadcms/translations'
import React, { Fragment, useCallback } from 'react'
@@ -39,6 +39,7 @@ const BlocksFieldComponent: BlocksFieldClientComponent = (props) => {
field: {
name,
admin: { className, description, isSortable = true } = {},
blockReferences,
blocks,
label,
labels: labelsFromProps,
@@ -62,6 +63,7 @@ const BlocksFieldComponent: BlocksFieldClientComponent = (props) => {
const { code: locale } = useLocale()
const {
config: { localization },
config,
} = useConfig()
const drawerSlug = useDrawerSlug('blocks-drawer')
const submitted = useFormSubmitted()
@@ -260,7 +262,11 @@ const BlocksFieldComponent: BlocksFieldClientComponent = (props) => {
>
{rows.map((row, i) => {
const { blockType, isLoading } = row
const blockConfig = blocks.find((block) => block.slug === blockType)
const blockConfig: ClientBlock =
config.blocksMap[blockType] ??
((blockReferences ?? blocks).find(
(block) => typeof block !== 'string' && block.slug === blockType,
) as ClientBlock)
if (blockConfig) {
const rowPath = `${path}.${i}`
@@ -276,7 +282,7 @@ const BlocksFieldComponent: BlocksFieldClientComponent = (props) => {
{...draggableSortableItemProps}
addRow={addRow}
block={blockConfig}
blocks={blocks}
blocks={blockReferences ?? blocks}
duplicateRow={duplicateRow}
errorCount={rowErrorCount}
fields={blockConfig.fields}
@@ -346,7 +352,7 @@ const BlocksFieldComponent: BlocksFieldClientComponent = (props) => {
<BlocksDrawer
addRow={addRow}
addRowIndex={rows?.length || 0}
blocks={blocks}
blocks={blockReferences ?? blocks}
drawerSlug={drawerSlug}
labels={labels}
/>

View File

@@ -1,6 +1,7 @@
'use client'
import type {
ClientConfig,
ClientField,
JoinFieldClient,
JoinFieldClientComponent,
@@ -30,11 +31,13 @@ const ObjectId = (ObjectIdImport.default ||
*/
const getInitialDrawerData = ({
collectionSlug,
config,
docID,
fields,
segments,
}: {
collectionSlug: string
config: ClientConfig
docID: number | string
fields: ClientField[]
segments: string[]
@@ -68,6 +71,7 @@ const getInitialDrawerData = ({
return {
[field.name]: getInitialDrawerData({
collectionSlug,
config,
docID,
fields: field.fields,
segments: nextSegments,
@@ -78,6 +82,7 @@ const getInitialDrawerData = ({
if (field.type === 'array') {
const initialData = getInitialDrawerData({
collectionSlug,
config,
docID,
fields: field.fields,
segments: nextSegments,
@@ -91,9 +96,12 @@ const getInitialDrawerData = ({
}
if (field.type === 'blocks') {
for (const block of field.blocks) {
for (const _block of field.blockReferences ?? field.blocks) {
const block = typeof _block === 'string' ? config.blocksMap[_block] : _block
const blockInitialData = getInitialDrawerData({
collectionSlug,
config,
docID,
fields: block.fields,
segments: nextSegments,
@@ -127,7 +135,7 @@ const JoinFieldComponent: JoinFieldClientComponent = (props) => {
const { id: docID, docConfig } = useDocumentInfo()
const { getEntityConfig } = useConfig()
const { config, getEntityConfig } = useConfig()
const { customComponents: { AfterInput, BeforeInput, Description, Label } = {}, value } =
useField<PaginatedDocs>({
@@ -168,11 +176,12 @@ const JoinFieldComponent: JoinFieldClientComponent = (props) => {
return getInitialDrawerData({
collectionSlug: docConfig?.slug,
config,
docID,
fields: relatedCollection.fields,
segments: field.on.split('.'),
})
}, [getEntityConfig, field.collection, field.on, docConfig?.slug, docID])
}, [getEntityConfig, field.collection, field.on, docConfig?.slug, docID, config])
if (!docConfig) {
return null

View File

@@ -5,6 +5,7 @@ import type {
Field,
FieldSchemaMap,
FieldState,
FlattenedBlock,
FormState,
FormStateWithoutComponents,
PayloadRequest,
@@ -371,7 +372,13 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
const { promises, rowMetadata } = blocksValue.reduce(
(acc, row, i: number) => {
const block = field.blocks.find((blockType) => blockType.slug === row.blockType)
const blockTypeToMatch: string = row.blockType
const block =
req.payload.blocks[blockTypeToMatch] ??
((field.blockReferences ?? field.blocks).find(
(blockType) => typeof blockType !== 'string' && blockType.slug === blockTypeToMatch,
) as FlattenedBlock | undefined)
if (!block) {
throw new Error(
`Block with type "${row.blockType}" was found in block data, but no block with that type is defined in the config for field with schema path ${schemaPath}.`,

View File

@@ -1,4 +1,4 @@
import type { Data, Field, PayloadRequest, TabAsField, User } from 'payload'
import type { Data, Field, FlattenedBlock, PayloadRequest, TabAsField, User } from 'payload'
import { getDefaultValue } from 'payload'
import { fieldAffectsData, tabHasName } from 'payload/shared'
@@ -80,8 +80,12 @@ export const defaultValuePromise = async <T>({
if (Array.isArray(rows)) {
const promises = []
rows.forEach((row) => {
const blockTypeToMatch = row.blockType
const block = field.blocks.find((blockType) => blockType.slug === blockTypeToMatch)
const blockTypeToMatch: string = row.blockType
const block =
req.payload.blocks[blockTypeToMatch] ??
((field.blockReferences ?? field.blocks).find(
(blockType) => typeof blockType !== 'string' && blockType.slug === blockTypeToMatch,
) as FlattenedBlock | undefined)
if (block) {
row.blockType = blockTypeToMatch

View File

@@ -1,4 +1,10 @@
import type { ClientComponentProps, ClientField, FieldPaths, ServerComponentProps } from 'payload'
import type {
ClientComponentProps,
ClientField,
FieldPaths,
FlattenedBlock,
ServerComponentProps,
} from 'payload'
import { getTranslation } from '@payloadcms/translations'
import { createClientField, MissingEditorProp } from 'payload'
@@ -135,7 +141,12 @@ export const renderField: RenderFieldMethod = ({
case 'blocks': {
fieldState?.rows?.forEach((row, rowIndex) => {
const blockConfig = fieldConfig.blocks.find((block) => block.slug === row.blockType)
const blockTypeToMatch: string = row.blockType
const blockConfig =
req.payload.blocks[blockTypeToMatch] ??
((fieldConfig.blockReferences ?? fieldConfig.blocks).find(
(block) => typeof block !== 'string' && block.slug === blockTypeToMatch,
) as FlattenedBlock | undefined)
if (blockConfig.admin?.components && 'Label' in blockConfig.admin.components) {
if (!fieldState.customComponents) {

View File

@@ -6,6 +6,7 @@ import type {
ClientGlobalConfig,
CollectionSlug,
GlobalSlug,
UnsanitizedClientConfig,
} from 'payload'
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
@@ -39,16 +40,34 @@ export type ClientConfigContext = {
const RootConfigContext = createContext<ClientConfigContext | undefined>(undefined)
function sanitizeClientConfig(
unSanitizedConfig: ClientConfig | UnsanitizedClientConfig,
): ClientConfig {
if (!unSanitizedConfig?.blocks?.length || (unSanitizedConfig as ClientConfig).blocksMap) {
;(unSanitizedConfig as ClientConfig).blocksMap = {}
return unSanitizedConfig as ClientConfig
}
const sanitizedConfig: ClientConfig = { ...unSanitizedConfig } as ClientConfig
sanitizedConfig.blocksMap = {}
for (const block of unSanitizedConfig.blocks) {
sanitizedConfig.blocksMap[block.slug] = block
}
return sanitizedConfig
}
export const ConfigProvider: React.FC<{
readonly children: React.ReactNode
readonly config: ClientConfig
readonly config: ClientConfig | UnsanitizedClientConfig
}> = ({ children, config: configFromProps }) => {
const [config, setConfig] = useState<ClientConfig>(configFromProps)
const [config, setConfig] = useState<ClientConfig>(() => sanitizeClientConfig(configFromProps))
// Need to update local config state if config from props changes, for HMR.
// That way, config changes will be updated in the UI immediately without needing a refresh.
useEffect(() => {
setConfig(configFromProps)
setConfig(sanitizeClientConfig(configFromProps))
}, [configFromProps])
// Build lookup maps for collections and globals so we can do O(1) lookups by slug

View File

@@ -1,6 +1,7 @@
import type { I18n } from '@payloadcms/translations'
import {
type ClientBlock,
type ClientConfig,
type ClientField,
type ClientFieldSchemaMap,
@@ -60,7 +61,14 @@ export const traverseFields = ({
break
case 'blocks':
field.blocks.map((block) => {
;(field.blockReferences ?? field.blocks).map((_block) => {
const block =
typeof _block === 'string'
? config.blocksMap
? config.blocksMap[_block]
: config.blocks.find((block) => typeof block !== 'string' && block.slug === _block)
: _block
const blockSchemaPath = `${schemaPath}.${block.slug}`
clientSchemaMap.set(blockSchemaPath, block)

View File

@@ -47,7 +47,11 @@ export const traverseFields = ({
break
case 'blocks':
field.blocks.map((block) => {
;(field.blockReferences ?? field.blocks).map((_block) => {
// TODO: iterate over blocks mapped to block slug in v4, or pass through payload.blocks
const block =
typeof _block === 'string' ? config.blocks.find((b) => b.slug === _block) : _block
const blockSchemaPath = `${schemaPath}.${block.slug}`
schemaMap.set(blockSchemaPath, block)

View File

@@ -3,6 +3,7 @@ import {
type CollectionSlug,
type Data,
type Field,
type FlattenedBlock,
formatErrors,
type PayloadRequest,
} from 'payload'
@@ -21,7 +22,12 @@ export type CopyDataFromLocaleArgs = {
toLocale: string
}
function iterateFields(fields: Field[], fromLocaleData: Data, toLocaleData: Data): void {
function iterateFields(
fields: Field[],
fromLocaleData: Data,
toLocaleData: Data,
req: PayloadRequest,
): void {
fields.map((field) => {
if (fieldAffectsData(field)) {
switch (field.type) {
@@ -46,7 +52,7 @@ function iterateFields(fields: Field[], fromLocaleData: Data, toLocaleData: Data
toLocaleData[field.name][index].id = new ObjectId().toHexString()
}
iterateFields(field.fields, fromLocaleData[field.name][index], item)
iterateFields(field.fields, fromLocaleData[field.name][index], item, req)
}
})
}
@@ -67,17 +73,19 @@ function iterateFields(fields: Field[], fromLocaleData: Data, toLocaleData: Data
// if the field has a value - loop over the data from target
if (field.name in toLocaleData) {
toLocaleData[field.name].map((blockData: Data, index: number) => {
const blockFields = field.blocks.find(
({ slug }) => slug === blockData.blockType,
)?.fields
const block =
req.payload.blocks[blockData.blockType] ??
((field.blockReferences ?? field.blocks).find(
(block) => typeof block !== 'string' && block.slug === blockData.blockType,
) as FlattenedBlock | undefined)
// Generate new IDs if the field is localized to prevent errors with relational DBs.
if (field.localized) {
toLocaleData[field.name][index].id = new ObjectId().toHexString()
}
if (blockFields?.length) {
iterateFields(blockFields, fromLocaleData[field.name][index], blockData)
if (block?.fields?.length) {
iterateFields(block?.fields, fromLocaleData[field.name][index], blockData, req)
}
})
}
@@ -110,7 +118,7 @@ function iterateFields(fields: Field[], fromLocaleData: Data, toLocaleData: Data
case 'group': {
if (field.name in toLocaleData && fromLocaleData?.[field.name] !== undefined) {
iterateFields(field.fields, fromLocaleData[field.name], toLocaleData[field.name])
iterateFields(field.fields, fromLocaleData[field.name], toLocaleData[field.name], req)
}
break
}
@@ -119,17 +127,17 @@ function iterateFields(fields: Field[], fromLocaleData: Data, toLocaleData: Data
switch (field.type) {
case 'collapsible':
case 'row':
iterateFields(field.fields, fromLocaleData, toLocaleData)
iterateFields(field.fields, fromLocaleData, toLocaleData, req)
break
case 'tabs':
field.tabs.map((tab) => {
if (tabHasName(tab)) {
if (tab.name in toLocaleData && fromLocaleData?.[tab.name] !== undefined) {
iterateFields(tab.fields, fromLocaleData[tab.name], toLocaleData[tab.name])
iterateFields(tab.fields, fromLocaleData[tab.name], toLocaleData[tab.name], req)
}
} else {
iterateFields(tab.fields, fromLocaleData, toLocaleData)
iterateFields(tab.fields, fromLocaleData, toLocaleData, req)
}
})
break
@@ -138,8 +146,13 @@ function iterateFields(fields: Field[], fromLocaleData: Data, toLocaleData: Data
})
}
function mergeData(fromLocaleData: Data, toLocaleData: Data, fields: Field[]): Data {
iterateFields(fields, fromLocaleData, toLocaleData)
function mergeData(
fromLocaleData: Data,
toLocaleData: Data,
fields: Field[],
req: PayloadRequest,
): Data {
iterateFields(fields, fromLocaleData, toLocaleData, req)
return toLocaleData
}
@@ -254,7 +267,12 @@ export const copyDataFromLocale = async (args: CopyDataFromLocaleArgs) => {
slug: globalSlug,
data: overrideData
? fromLocaleData.value
: mergeData(fromLocaleData.value, toLocaleData.value, globals[globalSlug].config.fields),
: mergeData(
fromLocaleData.value,
toLocaleData.value,
globals[globalSlug].config.fields,
req,
),
locale: toLocale,
overrideAccess: false,
req,
@@ -269,6 +287,7 @@ export const copyDataFromLocale = async (args: CopyDataFromLocaleArgs) => {
fromLocaleData.value,
toLocaleData.value,
collections[collectionSlug].config.fields,
req,
),
locale: toLocale,
overrideAccess: false,

View File

@@ -4,7 +4,7 @@ import type { ClientConfig, ImportMap, SanitizedConfig } from 'payload'
import { createClientConfig } from 'payload'
import { cache } from 'react'
let cachedClientConfig = global._payload_clientConfig
let cachedClientConfig: ClientConfig | null = global._payload_clientConfig
if (!cachedClientConfig) {
cachedClientConfig = global._payload_clientConfig = null

View File

@@ -22,7 +22,4 @@ export const PostsCollection: CollectionConfig = {
}),
},
],
versions: {
drafts: true,
},
}

View File

@@ -64,6 +64,7 @@ export interface Config {
auth: {
users: UserAuthOperations;
};
blocks: {};
collections: {
posts: Post;
media: Media;
@@ -82,7 +83,7 @@ export interface Config {
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
};
db: {
defaultIDType: string;
defaultIDType: number;
};
globals: {
menu: Menu;
@@ -122,7 +123,7 @@ export interface UserAuthOperations {
* via the `definition` "posts".
*/
export interface Post {
id: string;
id: number;
title?: string | null;
content?: {
root: {
@@ -141,14 +142,13 @@ export interface Post {
} | null;
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media".
*/
export interface Media {
id: string;
id: number;
updatedAt: string;
createdAt: string;
url?: string | null;
@@ -192,7 +192,7 @@ export interface Media {
* via the `definition` "users".
*/
export interface User {
id: string;
id: number;
updatedAt: string;
createdAt: string;
email: string;
@@ -209,24 +209,24 @@ export interface User {
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: string;
id: number;
document?:
| ({
relationTo: 'posts';
value: string | Post;
value: number | Post;
} | null)
| ({
relationTo: 'media';
value: string | Media;
value: number | Media;
} | null)
| ({
relationTo: 'users';
value: string | User;
value: number | User;
} | null);
globalSlug?: string | null;
user: {
relationTo: 'users';
value: string | User;
value: number | User;
};
updatedAt: string;
createdAt: string;
@@ -236,10 +236,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?:
@@ -259,7 +259,7 @@ export interface PayloadPreference {
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: string;
id: number;
name?: string | null;
batch?: number | null;
updatedAt: string;
@@ -274,7 +274,6 @@ export interface PostsSelect<T extends boolean = true> {
content?: T;
updatedAt?: T;
createdAt?: T;
_status?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
@@ -379,7 +378,7 @@ export interface PayloadMigrationsSelect<T extends boolean = true> {
* via the `definition` "menu".
*/
export interface Menu {
id: string;
id: number;
globalText?: string | null;
updatedAt?: string | null;
createdAt?: string | null;

View File

@@ -64,6 +64,7 @@ export interface Config {
auth: {
users: UserAuthOperations;
};
blocks: {};
collections: {
uploads: Upload;
posts: Post;

2
test/benchmark-blocks/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/media
/media-gif

View File

@@ -0,0 +1,61 @@
import type { Block, BlocksField, BlockSlug } from 'payload'
export const generateBlocks = (
blockCount: number,
useReferences?: boolean,
): (Block | BlockSlug)[] => {
const blocks: (Block | BlockSlug)[] = []
for (let i = 0; i < blockCount; i++) {
if (useReferences) {
blocks.push(`block_${i}` as BlockSlug)
} else {
blocks.push({
slug: `block_${i}`,
fields: [
{
name: 'field1',
type: 'text',
},
{
name: 'field2',
type: 'text',
},
{
name: 'field3',
type: 'text',
},
{
name: 'field4',
type: 'number',
},
],
})
}
}
return blocks
}
export const generateBlockFields = (
blockCount: number,
containerCount: number,
useReferences?: boolean,
): BlocksField[] => {
const fields: BlocksField[] = []
for (let i = 0; i < containerCount; i++) {
const block: BlocksField = {
name: `blocksfield_${i}`,
type: 'blocks',
blocks: [],
}
if (useReferences) {
block.blockReferences = generateBlocks(blockCount, true)
} else {
block.blocks = generateBlocks(blockCount) as any
}
fields.push(block)
}
return fields
}

View File

@@ -0,0 +1,33 @@
import type { CollectionConfig } from 'payload'
export const mediaSlug = 'media'
export const MediaCollection: CollectionConfig = {
slug: mediaSlug,
access: {
create: () => true,
read: () => true,
},
fields: [],
upload: {
crop: true,
focalPoint: true,
imageSizes: [
{
name: 'thumbnail',
height: 200,
width: 200,
},
{
name: 'medium',
height: 800,
width: 800,
},
{
name: 'large',
height: 1200,
width: 1200,
},
],
},
}

View File

@@ -0,0 +1,28 @@
import type { CollectionConfig } from 'payload'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
export const postsSlug = 'posts'
export const PostsCollection: CollectionConfig = {
slug: postsSlug,
admin: {
useAsTitle: 'title',
},
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'content',
type: 'richText',
editor: lexicalEditor({
features: ({ defaultFeatures }) => [...defaultFeatures],
}),
},
],
versions: {
drafts: true,
},
}

View File

@@ -0,0 +1,56 @@
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import { fileURLToPath } from 'node:url'
import path from 'path'
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { devUser } from '../credentials.js'
import { generateBlockFields, generateBlocks } from './blocks/blocks.js'
import { MediaCollection } from './collections/Media/index.js'
import { PostsCollection, postsSlug } from './collections/Posts/index.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
const USE_BLOCK_REFERENCES = true
export default buildConfigWithDefaults({
collections: [
PostsCollection,
{
slug: 'pages',
access: {
create: () => true,
read: () => true,
},
fields: generateBlockFields(40, 30 * 20, USE_BLOCK_REFERENCES),
},
MediaCollection,
],
admin: {
importMap: {
baseDir: path.resolve(dirname),
},
},
editor: lexicalEditor({}),
// @ts-expect-error
blocks: USE_BLOCK_REFERENCES ? generateBlocks(30 * 20, false) : undefined,
onInit: async (payload) => {
await payload.create({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
},
})
await payload.create({
collection: postsSlug,
data: {
title: 'example post',
},
})
},
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
})

View File

@@ -0,0 +1,19 @@
import { rootParserOptions } from '../../eslint.config.js'
import { testEslintConfig } from '../eslint.config.js'
/** @typedef {import('eslint').Linter.Config} Config */
/** @type {Config[]} */
export const index = [
...testEslintConfig,
{
languageOptions: {
parserOptions: {
...rootParserOptions,
tsconfigRootDir: import.meta.dirname,
},
},
},
]
export default index

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
{
// extend your base config to share compilerOptions, etc
//"extends": "./tsconfig.json",
"compilerOptions": {
// ensure that nobody can accidentally use this config for a build
"noEmit": true
},
"include": [
// whatever paths you intend to lint
"./**/*.ts",
"./**/*.tsx"
]
}

View File

@@ -0,0 +1,3 @@
{
"extends": "../tsconfig.json",
}

9
test/benchmark-blocks/types.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
import type { RequestContext as OriginalRequestContext } from 'payload'
declare module 'payload' {
// Create a new interface that merges your additional fields with the original one
export interface RequestContext extends OriginalRequestContext {
myObject?: string
// ...
}
}

View File

@@ -35,10 +35,18 @@ const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
const {
_: [testSuiteArg = '_community'],
_: [_testSuiteArg = '_community'],
...args
} = minimist(process.argv.slice(2))
let testSuiteArg: string | undefined
let testSuiteConfigOverride: string | undefined
if (_testSuiteArg.includes('#')) {
;[testSuiteArg, testSuiteConfigOverride] = _testSuiteArg.split('#')
} else {
testSuiteArg = _testSuiteArg
}
if (!testSuiteArg || !fs.existsSync(path.resolve(dirname, testSuiteArg))) {
console.log(chalk.red(`ERROR: The test folder "${testSuiteArg}" does not exist`))
process.exit(0)
@@ -50,7 +58,7 @@ if (args.turbo === true) {
process.env.TURBOPACK = '1'
}
const { beforeTest } = await createTestHooks(testSuiteArg)
const { beforeTest } = await createTestHooks(testSuiteArg, testSuiteConfigOverride)
await beforeTest()
const { rootDir, adminRoute } = getNextRootDir(testSuiteArg)

171
test/fields/baseConfig.ts Normal file
View File

@@ -0,0 +1,171 @@
import type { CollectionConfig, Config } from 'payload'
import { fileURLToPath } from 'node:url'
import path from 'path'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
import ArrayFields from './collections/Array/index.js'
import BlockFields from './collections/Blocks/index.js'
import CheckboxFields from './collections/Checkbox/index.js'
import CodeFields from './collections/Code/index.js'
import CollapsibleFields from './collections/Collapsible/index.js'
import ConditionalLogic from './collections/ConditionalLogic/index.js'
import { CustomRowID } from './collections/CustomID/CustomRowID.js'
import { CustomTabID } from './collections/CustomID/CustomTabID.js'
import { CustomID } from './collections/CustomID/index.js'
import DateFields from './collections/Date/index.js'
import EmailFields from './collections/Email/index.js'
import GroupFields from './collections/Group/index.js'
import IndexedFields from './collections/Indexed/index.js'
import JSONFields from './collections/JSON/index.js'
import {
getLexicalFieldsCollection,
lexicalBlocks,
lexicalInlineBlocks,
} from './collections/Lexical/index.js'
import { LexicalAccessControl } from './collections/LexicalAccessControl/index.js'
import { LexicalInBlock } from './collections/LexicalInBlock/index.js'
import { LexicalLocalizedFields } from './collections/LexicalLocalized/index.js'
import { LexicalMigrateFields } from './collections/LexicalMigrate/index.js'
import { LexicalObjectReferenceBugCollection } from './collections/LexicalObjectReferenceBug/index.js'
import { LexicalRelationshipsFields } from './collections/LexicalRelationships/index.js'
import NumberFields from './collections/Number/index.js'
import PointFields from './collections/Point/index.js'
import RadioFields from './collections/Radio/index.js'
import RelationshipFields from './collections/Relationship/index.js'
import RichTextFields from './collections/RichText/index.js'
import RowFields from './collections/Row/index.js'
import SelectFields from './collections/Select/index.js'
import SelectVersionsFields from './collections/SelectVersions/index.js'
import TabsFields from './collections/Tabs/index.js'
import { TabsFields2 } from './collections/Tabs2/index.js'
import TextFields from './collections/Text/index.js'
import UIFields from './collections/UI/index.js'
import Uploads from './collections/Upload/index.js'
import Uploads2 from './collections/Upload2/index.js'
import UploadsMulti from './collections/UploadMulti/index.js'
import UploadsMultiPoly from './collections/UploadMultiPoly/index.js'
import UploadsPoly from './collections/UploadPoly/index.js'
import UploadRestricted from './collections/UploadRestricted/index.js'
import Uploads3 from './collections/Uploads3/index.js'
import TabsWithRichText from './globals/TabsWithRichText.js'
import { clearAndSeedEverything } from './seed.js'
export const collectionSlugs: CollectionConfig[] = [
getLexicalFieldsCollection({
blocks: lexicalBlocks,
inlineBlocks: lexicalInlineBlocks,
}),
LexicalMigrateFields,
LexicalLocalizedFields,
LexicalObjectReferenceBugCollection,
{
slug: 'users',
admin: {
useAsTitle: 'email',
},
auth: true,
fields: [
{
name: 'canViewConditionalField',
type: 'checkbox',
defaultValue: true,
},
],
},
LexicalInBlock,
LexicalAccessControl,
SelectVersionsFields,
ArrayFields,
BlockFields,
CheckboxFields,
CodeFields,
CollapsibleFields,
ConditionalLogic,
CustomID,
CustomTabID,
CustomRowID,
DateFields,
EmailFields,
RadioFields,
GroupFields,
RowFields,
IndexedFields,
JSONFields,
NumberFields,
PointFields,
RelationshipFields,
LexicalRelationshipsFields,
RichTextFields,
SelectFields,
TabsFields2,
TabsFields,
TextFields,
Uploads,
Uploads2,
Uploads3,
UploadsMulti,
UploadsPoly,
UploadsMultiPoly,
UploadRestricted,
UIFields,
]
export const baseConfig: Partial<Config> = {
collections: collectionSlugs,
globals: [TabsWithRichText],
blocks: [
{
slug: 'ConfigBlockTest',
fields: [
{
name: 'deduplicatedText',
type: 'text',
},
],
},
],
custom: {
client: {
'new-value': 'client available',
},
server: {
'new-server-value': 'only available on server',
},
},
admin: {
importMap: {
baseDir: path.resolve(dirname),
},
components: {
afterNavLinks: ['/components/AfterNavLinks.js#AfterNavLinks'],
},
custom: {
client: {
'new-value': 'client available',
},
},
timezones: {
supportedTimezones: ({ defaultTimezones }) => [
...defaultTimezones,
{ label: '(GMT-6) Monterrey, Nuevo Leon', value: 'America/Monterrey' },
],
defaultTimezone: 'America/Monterrey',
},
},
localization: {
defaultLocale: 'en',
fallback: true,
locales: ['en', 'es'],
},
onInit: async (payload) => {
if (process.env.SEED_IN_CONFIG_ONINIT !== 'false') {
await clearAndSeedEverything(payload)
}
},
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
}

View File

@@ -196,7 +196,9 @@ describe('Block fields', () => {
// ensure the block was appended to the rows
const firstRow = page.locator('#field-i18nBlocks .blocks-field__row').first()
await expect(firstRow).toBeVisible()
await expect(firstRow.locator('.blocks-field__block-pill-text')).toContainText('Text en')
await expect(firstRow.locator('.blocks-field__block-pill-textInI18nBlock')).toContainText(
'Text en',
)
})
test('should render custom block row label', async () => {

View File

@@ -53,7 +53,7 @@ export const getBlocksField = (prefix?: string): BlocksField => ({
type: 'blocks',
blocks: [
{
slug: 'text',
slug: 'textRequired',
fields: [
{
name: 'text',
@@ -64,6 +64,7 @@ export const getBlocksField = (prefix?: string): BlocksField => ({
},
{
slug: 'number',
interfaceName: 'NumberBlock',
fields: [
{
name: 'number',
@@ -155,7 +156,7 @@ const BlockFields: CollectionConfig = {
type: 'blocks',
blocks: [
{
slug: 'text',
slug: 'textInI18nBlock',
fields: [
{
name: 'text',
@@ -313,7 +314,7 @@ const BlockFields: CollectionConfig = {
type: 'blocks',
blocks: [
{
slug: 'block',
slug: 'blockWithMinRows',
fields: [
{
name: 'blockTitle',
@@ -396,6 +397,18 @@ const BlockFields: CollectionConfig = {
},
],
},
{
name: 'deduplicatedBlocks',
type: 'blocks',
blockReferences: ['ConfigBlockTest'],
blocks: [],
},
{
name: 'deduplicatedBlocks2',
type: 'blocks',
blockReferences: ['ConfigBlockTest'],
blocks: [],
},
],
}

View File

@@ -27,7 +27,7 @@ export const getBlocksFieldSeedData = (prefix?: string): any => [
},
{
blockName: 'Second sub block',
blockType: 'text',
blockType: 'textRequired',
text: 'second sub block',
},
],
@@ -40,11 +40,11 @@ export const blocksDoc: Partial<BlockField> = {
blocksWithMinRows: [
{
blockTitle: 'first row',
blockType: 'block',
blockType: 'blockWithMinRows',
},
{
blockTitle: 'second row',
blockType: 'block',
blockType: 'blockWithMinRows',
},
],
}

View File

@@ -234,7 +234,7 @@ export const TextBlock: Block = {
required: true,
},
],
slug: 'text',
slug: 'textRequired',
}
export const RadioButtonsBlock: Block = {
@@ -371,10 +371,10 @@ export const SelectFieldBlock: Block = {
}
export const SubBlockBlock: Block = {
slug: 'subBlock',
slug: 'subBlockLexical',
fields: [
{
name: 'subBlocks',
name: 'subBlocksLexical',
type: 'blocks',
blocks: [
{

View File

@@ -24,13 +24,13 @@ import {
saveDocAndAssert,
} from '../../../../../helpers.js'
import { AdminUrlUtil } from '../../../../../helpers/adminUrlUtil.js'
import { trackNetworkRequests } from '../../../../../helpers/e2e/trackNetworkRequests.js'
import { initPayloadE2ENoConfig } from '../../../../../helpers/initPayloadE2ENoConfig.js'
import { reInitializeDB } from '../../../../../helpers/reInitializeDB.js'
import { RESTClient } from '../../../../../helpers/rest.js'
import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../../../../../playwright.config.js'
import { lexicalFieldsSlug } from '../../../../slugs.js'
import { lexicalDocData } from '../../data.js'
import { trackNetworkRequests } from '../../../../../helpers/e2e/trackNetworkRequests.js'
const filename = fileURLToPath(import.meta.url)
const currentFolder = path.dirname(filename)
@@ -106,7 +106,7 @@ async function createInlineBlock({
*/
await page.keyboard.press(' ')
await page.keyboard.press('/')
await page.keyboard.type(name.includes(' ') ? name.split(' ')[0] : name)
await page.keyboard.type(name.includes(' ') ? (name.split(' ')?.[0] ?? name) : name)
// Create Rich Text Block
const slashMenuPopover = page.locator('#slash-menu .slash-menu-popup')
@@ -163,8 +163,8 @@ async function createInlineBlock({
}
async function assertLexicalDoc({
fn,
depth = 0,
fn,
}: {
depth?: number
fn: (args: {
@@ -299,7 +299,7 @@ describe('lexicalBlocks', () => {
// Check if the API result is correct
await assertLexicalDoc({
fn: async ({ lexicalWithBlocks }) => {
fn: ({ lexicalWithBlocks }) => {
const rscBlock: SerializedBlockNode = lexicalWithBlocks.root
.children[14] as SerializedBlockNode
const paragraphNode: SerializedParagraphNode = lexicalWithBlocks.root
@@ -360,13 +360,15 @@ describe('lexicalBlocks', () => {
const dependsOnSiblingData = newBlock.locator('#field-group__dependsOnSiblingData').first()
const dependsOnBlockData = newBlock.locator('#field-group__dependsOnBlockData').first()
await expect(page.locator('.payload-toast-container .payload-toast-item')).toBeHidden()
return {
topLevelDocTextField,
blockTextField,
blockGroupTextField,
blockTextField,
dependsOnDocData,
dependsOnSiblingData,
dependsOnBlockData,
dependsOnSiblingData,
topLevelDocTextField,
newBlock,
}
}
@@ -549,14 +551,15 @@ describe('lexicalBlocks', () => {
.locator('#field-group__textDependsOnSiblingData')
.first()
const dependsOnBlockData = newBlock.locator('#field-group__textDependsOnBlockData').first()
await expect(page.locator('.payload-toast-container .payload-toast-item')).toBeHidden()
return {
topLevelDocTextField,
blockTextField,
blockGroupTextField,
blockTextField,
dependsOnDocData,
dependsOnSiblingData,
dependsOnBlockData,
topLevelDocTextField,
newBlock,
}
}
@@ -570,7 +573,7 @@ describe('lexicalBlocks', () => {
await expect(page.locator('.payload-toast-container')).toHaveText(
'The following fields are invalid: Lexical With Blocks, LexicalWithBlocks > Group > Text Depends On Doc Data',
)
await expect(page.locator('.payload-toast-container')).not.toBeVisible()
await expect(page.locator('.payload-toast-container .payload-toast-item')).toBeHidden()
await trackNetworkRequests(
page,
@@ -593,7 +596,7 @@ describe('lexicalBlocks', () => {
await expect(page.locator('.payload-toast-container')).toHaveText(
'The following fields are invalid: Lexical With Blocks, LexicalWithBlocks > Group > Text Depends On Sibling Data',
)
await expect(page.locator('.payload-toast-container')).not.toBeVisible()
await expect(page.locator('.payload-toast-container .payload-toast-item')).toBeHidden()
await trackNetworkRequests(
page,
@@ -617,7 +620,7 @@ describe('lexicalBlocks', () => {
'The following fields are invalid: Lexical With Blocks, LexicalWithBlocks > Group > Text Depends On Block Data',
)
await expect(page.locator('.payload-toast-container')).not.toBeVisible()
await expect(page.locator('.payload-toast-container .payload-toast-item')).toBeHidden()
await trackNetworkRequests(
page,
@@ -701,7 +704,7 @@ describe('lexicalBlocks', () => {
await saveDocAndAssert(page)
await assertLexicalDoc({
fn: async ({ lexicalWithBlocks }) => {
fn: ({ lexicalWithBlocks }) => {
const blockNode: SerializedBlockNode = lexicalWithBlocks.root
.children[4] as SerializedBlockNode
@@ -761,7 +764,7 @@ describe('lexicalBlocks', () => {
await saveDocAndAssert(page)
await assertLexicalDoc({
fn: async ({ lexicalWithBlocks }) => {
fn: ({ lexicalWithBlocks }) => {
const blockNode: SerializedBlockNode = lexicalWithBlocks.root
.children[4] as SerializedBlockNode
const paragraphNodeInBlockNodeRichText = blockNode.fields.richTextField.root.children[1]
@@ -836,7 +839,7 @@ describe('lexicalBlocks', () => {
// Make sure it's being returned from the API as well
await assertLexicalDoc({
fn: async ({ lexicalWithBlocks }) => {
fn: ({ lexicalWithBlocks }) => {
expect(
(
(lexicalWithBlocks.root.children[0] as SerializedParagraphNode)
@@ -888,13 +891,17 @@ describe('lexicalBlocks', () => {
.first()
await expect(popoverHeading2Button).toBeVisible()
// scroll slash menu down
await popoverHeading2Button.hover()
await page.mouse.wheel(0, 250)
await expect(async () => {
// Make sure that, even though it's "visible", it's not actually covered by something else due to z-index issues
const popoverHeading2ButtonBoundingBox = await popoverHeading2Button.boundingBox()
expect(popoverHeading2ButtonBoundingBox).not.toBeNull()
expect(popoverHeading2ButtonBoundingBox).not.toBeUndefined()
expect(popoverHeading2ButtonBoundingBox.height).toBeGreaterThan(0)
expect(popoverHeading2ButtonBoundingBox.width).toBeGreaterThan(0)
expect(popoverHeading2ButtonBoundingBox?.height).toBeGreaterThan(0)
expect(popoverHeading2ButtonBoundingBox?.width).toBeGreaterThan(0)
// Now click the button to see if it actually works. Simulate an actual mouse click instead of using .click()
// by using page.mouse and the correct coordinates
@@ -903,8 +910,8 @@ describe('lexicalBlocks', () => {
// This is why we use page.mouse.click() here. It's the most effective way of detecting such a z-index issue
// and usually the only method which works.
const x = popoverHeading2ButtonBoundingBox.x
const y = popoverHeading2ButtonBoundingBox.y
const x = popoverHeading2ButtonBoundingBox?.x ?? 0
const y = popoverHeading2ButtonBoundingBox?.y ?? 0
await page.mouse.click(x, y, { button: 'left' })
@@ -954,13 +961,16 @@ describe('lexicalBlocks', () => {
await page.keyboard.type('text123')
await expect(newContentTextArea).toHaveText('text123')
await wait(1000)
await saveDocAndAssert(page)
await assertLexicalDoc({
fn: async ({ lexicalWithBlocks }) => {
fn: ({ lexicalWithBlocks }) => {
const blockNode: SerializedBlockNode = lexicalWithBlocks.root
.children[5] as SerializedBlockNode
const subBlocks = blockNode.fields.subBlocks
const subBlocks = blockNode.fields.subBlocksLexical
expect(subBlocks).toHaveLength(2)
@@ -1200,7 +1210,7 @@ describe('lexicalBlocks', () => {
await saveDocAndAssert(page)
await assertLexicalDoc({
fn: async ({ lexicalWithBlocks }) => {
fn: ({ lexicalWithBlocks }) => {
const radio1: SerializedBlockNode = lexicalWithBlocks.root
.children[8] as SerializedBlockNode
const radio2: SerializedBlockNode = lexicalWithBlocks.root
@@ -1437,7 +1447,7 @@ describe('lexicalBlocks', () => {
await expect(tab2Text1Field).toHaveValue('Some text2 changed')
await assertLexicalDoc({
fn: async ({ lexicalWithBlocks }) => {
fn: ({ lexicalWithBlocks }) => {
const tabBlockData: SerializedBlockNode = lexicalWithBlocks.root
.children[13] as SerializedBlockNode
@@ -1455,7 +1465,7 @@ describe('lexicalBlocks', () => {
await codeEditor.scrollIntoViewIfNeeded()
await expect(codeEditor).toBeVisible()
const height = (await codeEditor.boundingBox()).height
const height = (await codeEditor.boundingBox())?.height
await expect(() => {
expect(height).toBe(56)
@@ -1463,7 +1473,7 @@ describe('lexicalBlocks', () => {
await codeEditor.click()
await page.keyboard.press('Enter')
const height2 = (await codeEditor.boundingBox()).height
const height2 = (await codeEditor.boundingBox())?.height
await expect(() => {
expect(height2).toBe(74)
}).toPass()
@@ -1494,8 +1504,8 @@ describe('lexicalBlocks', () => {
test('ensure inline blocks can be created and its values can be mutated from outside their form', async () => {
const { richTextField } = await navigateToLexicalFields()
const { inlineBlockDrawer, saveDrawer } = await createInlineBlock({
richTextField,
name: 'My Inline Block',
richTextField,
})
// Click on react select in drawer, select 'value1'
@@ -1510,7 +1520,7 @@ describe('lexicalBlocks', () => {
await saveDocAndAssert(page)
// Check if the API result is correct
await assertLexicalDoc({
fn: async ({ lexicalWithBlocks }) => {
fn: ({ lexicalWithBlocks }) => {
const firstParagraph: SerializedParagraphNode = lexicalWithBlocks.root
.children[0] as SerializedParagraphNode
const inlineBlock: SerializedInlineBlockNode = firstParagraph
@@ -1548,13 +1558,13 @@ describe('lexicalBlocks', () => {
// Save and check api result
await saveDocAndAssert(page)
await assertLexicalDoc({
fn: async ({ lexicalWithBlocks }) => {
fn: ({ lexicalWithBlocks }) => {
const firstParagraph: SerializedParagraphNode = lexicalWithBlocks.root
.children[0] as SerializedParagraphNode
const inlineBlock: SerializedInlineBlockNode = firstParagraph
.children[1] as SerializedInlineBlockNode
await expect(inlineBlock.fields.key).toBe('value2')
expect(inlineBlock.fields.key).toBe('value2')
},
})
})
@@ -1562,8 +1572,8 @@ describe('lexicalBlocks', () => {
test('ensure upload fields within inline blocks store and populate correctly', async () => {
const { richTextField } = await navigateToLexicalFields()
const { inlineBlockDrawer, saveDrawer } = await createInlineBlock({
richTextField,
name: 'Avatar Group',
richTextField,
})
// Click button that says Add Avatar

View File

@@ -165,8 +165,8 @@ export function generateLexicalRichText(): TypedEditorState<
fields: {
id: '65298b2bdb4ef8c744a7faac',
blockName: 'Block Node, with Blocks Field, With RichText Field, With Relationship Node',
blockType: 'subBlock',
subBlocks: [
blockType: 'subBlockLexical',
subBlocksLexical: [
{
id: '65298b2edb4ef8c744a7faad',
richText: {

View File

@@ -1,6 +1,6 @@
import type { ServerEditorConfig } from '@payloadcms/richtext-lexical'
import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
import type { CollectionConfig } from 'payload'
import type { Block, BlockSlug, CollectionConfig } from 'payload'
import {
BlocksFeature,
@@ -37,364 +37,372 @@ import {
} from './blocks.js'
import { ModifyInlineBlockFeature } from './ModifyInlineBlockFeature/feature.server.js'
const editorConfig: ServerEditorConfig = {
features: [
...defaultEditorFeatures,
//TestRecorderFeature(),
TreeViewFeature(),
//HTMLConverterFeature(),
FixedToolbarFeature(),
LinkFeature({
fields: ({ defaultFields }) => [
...defaultFields,
{
name: 'rel',
type: 'select',
admin: {
description:
'The rel attribute defines the relationship between a linked resource and the current document. This is a custom link field.',
},
hasMany: true,
label: 'Rel Attribute',
options: ['noopener', 'noreferrer', 'nofollow'],
},
],
}),
UploadFeature({
collections: {
uploads: {
fields: [
{
name: 'caption',
type: 'richText',
editor: lexicalEditor(),
},
],
export const lexicalBlocks: (Block | BlockSlug)[] = [
ValidationBlock,
FilterOptionsBlock,
AsyncHooksBlock,
RichTextBlock,
TextBlock,
UploadAndRichTextBlock,
SelectFieldBlock,
RelationshipBlock,
RelationshipHasManyBlock,
SubBlockBlock,
RadioButtonsBlock,
ConditionalLayoutBlock,
TabBlock,
CodeBlock,
{
slug: 'myBlock',
admin: {
components: {},
},
fields: [
{
name: 'key',
label: () => {
return 'Key'
},
type: 'select',
options: ['value1', 'value2', 'value3'],
},
}),
ModifyInlineBlockFeature(),
BlocksFeature({
blocks: [
ValidationBlock,
FilterOptionsBlock,
AsyncHooksBlock,
RichTextBlock,
TextBlock,
UploadAndRichTextBlock,
SelectFieldBlock,
RelationshipBlock,
RelationshipHasManyBlock,
SubBlockBlock,
RadioButtonsBlock,
ConditionalLayoutBlock,
TabBlock,
CodeBlock,
{
slug: 'myBlock',
admin: {
components: {},
},
fields: [
{
name: 'key',
label: () => {
return 'Key'
},
type: 'select',
options: ['value1', 'value2', 'value3'],
},
],
},
{
slug: 'myBlockWithLabel',
admin: {
components: {
Label: '/collections/Lexical/blockComponents/LabelComponent.js#LabelComponent',
},
},
fields: [
{
name: 'key',
label: () => {
return 'Key'
},
type: 'select',
options: ['value1', 'value2', 'value3'],
},
],
},
{
slug: 'myBlockWithBlock',
admin: {
components: {
Block: '/collections/Lexical/blockComponents/BlockComponent.js#BlockComponent',
},
},
fields: [
{
name: 'key',
label: () => {
return 'Key'
},
type: 'select',
options: ['value1', 'value2', 'value3'],
},
],
},
{
slug: 'BlockRSC',
admin: {
components: {
Block: '/collections/Lexical/blockComponents/BlockComponentRSC.js#BlockComponentRSC',
},
},
fields: [
{
name: 'key',
label: () => {
return 'Key'
},
type: 'select',
options: ['value1', 'value2', 'value3'],
},
],
},
{
slug: 'myBlockWithBlockAndLabel',
admin: {
components: {
Block: '/collections/Lexical/blockComponents/BlockComponent.js#BlockComponent',
Label: '/collections/Lexical/blockComponents/LabelComponent.js#LabelComponent',
},
},
fields: [
{
name: 'key',
label: () => {
return 'Key'
},
type: 'select',
options: ['value1', 'value2', 'value3'],
},
],
},
],
inlineBlocks: [
{
slug: 'AvatarGroup',
interfaceName: 'AvatarGroupBlock',
fields: [
{
name: 'avatars',
type: 'array',
minRows: 1,
maxRows: 6,
fields: [
{
name: 'image',
type: 'upload',
relationTo: 'uploads',
},
],
},
],
},
{
slug: 'myInlineBlock',
admin: {
components: {},
},
fields: [
{
name: 'key',
label: () => {
return 'Key'
},
type: 'select',
options: ['value1', 'value2', 'value3'],
},
],
},
{
slug: 'myInlineBlockWithLabel',
admin: {
components: {
Label: '/collections/Lexical/inlineBlockComponents/LabelComponent.js#LabelComponent',
},
},
fields: [
{
name: 'key',
label: () => {
return 'Key'
},
type: 'select',
options: ['value1', 'value2', 'value3'],
},
],
},
{
slug: 'myInlineBlockWithBlock',
admin: {
components: {
Block: '/collections/Lexical/inlineBlockComponents/BlockComponent.js#BlockComponent',
},
},
fields: [
{
name: 'key',
label: () => {
return 'Key'
},
type: 'select',
options: ['value1', 'value2', 'value3'],
},
],
},
{
slug: 'myInlineBlockWithBlockAndLabel',
admin: {
components: {
Block: '/collections/Lexical/inlineBlockComponents/BlockComponent.js#BlockComponent',
Label: '/collections/Lexical/inlineBlockComponents/LabelComponent.js#LabelComponent',
},
},
fields: [
{
name: 'key',
label: () => {
return 'Key'
},
type: 'select',
options: ['value1', 'value2', 'value3'],
},
],
},
],
}),
EXPERIMENTAL_TableFeature(),
],
}
export const LexicalFields: CollectionConfig = {
slug: lexicalFieldsSlug,
access: {
read: () => true,
],
},
admin: {
listSearchableFields: ['title', 'richTextLexicalCustomFields'],
useAsTitle: 'title',
{
slug: 'myBlockWithLabel',
admin: {
components: {
Label: '/collections/Lexical/blockComponents/LabelComponent.js#LabelComponent',
},
},
fields: [
{
name: 'key',
label: () => {
return 'Key'
},
type: 'select',
options: ['value1', 'value2', 'value3'],
},
],
},
fields: [
{
name: 'title',
type: 'text',
required: true,
{
slug: 'myBlockWithBlock',
admin: {
components: {
Block: '/collections/Lexical/blockComponents/BlockComponent.js#BlockComponent',
},
},
{
name: 'lexicalRootEditor',
type: 'richText',
fields: [
{
name: 'key',
label: () => {
return 'Key'
},
type: 'select',
options: ['value1', 'value2', 'value3'],
},
],
},
{
slug: 'BlockRSC',
admin: {
components: {
Block: '/collections/Lexical/blockComponents/BlockComponentRSC.js#BlockComponentRSC',
},
},
{
name: 'lexicalSimple',
type: 'richText',
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
//TestRecorderFeature(),
TreeViewFeature(),
BlocksFeature({
blocks: [
RichTextBlock,
TextBlock,
UploadAndRichTextBlock,
SelectFieldBlock,
RelationshipBlock,
RelationshipHasManyBlock,
SubBlockBlock,
RadioButtonsBlock,
ConditionalLayoutBlock,
fields: [
{
name: 'key',
label: () => {
return 'Key'
},
type: 'select',
options: ['value1', 'value2', 'value3'],
},
],
},
{
slug: 'myBlockWithBlockAndLabel',
admin: {
components: {
Block: '/collections/Lexical/blockComponents/BlockComponent.js#BlockComponent',
Label: '/collections/Lexical/blockComponents/LabelComponent.js#LabelComponent',
},
},
fields: [
{
name: 'key',
label: () => {
return 'Key'
},
type: 'select',
options: ['value1', 'value2', 'value3'],
},
],
},
]
export const lexicalInlineBlocks: (Block | BlockSlug)[] = [
{
slug: 'AvatarGroup',
interfaceName: 'AvatarGroupBlock',
fields: [
{
name: 'avatars',
type: 'array',
minRows: 1,
maxRows: 6,
fields: [
{
name: 'image',
type: 'upload',
relationTo: 'uploads',
},
],
},
],
},
{
slug: 'myInlineBlock',
admin: {
components: {},
},
fields: [
{
name: 'key',
label: () => {
return 'Key'
},
type: 'select',
options: ['value1', 'value2', 'value3'],
},
],
},
{
slug: 'myInlineBlockWithLabel',
admin: {
components: {
Label: '/collections/Lexical/inlineBlockComponents/LabelComponent.js#LabelComponent',
},
},
fields: [
{
name: 'key',
label: () => {
return 'Key'
},
type: 'select',
options: ['value1', 'value2', 'value3'],
},
],
},
{
slug: 'myInlineBlockWithBlock',
admin: {
components: {
Block: '/collections/Lexical/inlineBlockComponents/BlockComponent.js#BlockComponent',
},
},
fields: [
{
name: 'key',
label: () => {
return 'Key'
},
type: 'select',
options: ['value1', 'value2', 'value3'],
},
],
},
{
slug: 'myInlineBlockWithBlockAndLabel',
admin: {
components: {
Block: '/collections/Lexical/inlineBlockComponents/BlockComponent.js#BlockComponent',
Label: '/collections/Lexical/inlineBlockComponents/LabelComponent.js#LabelComponent',
},
},
fields: [
{
name: 'key',
label: () => {
return 'Key'
},
type: 'select',
options: ['value1', 'value2', 'value3'],
},
],
},
]
export const getLexicalFieldsCollection: (args: {
blocks: (Block | BlockSlug)[]
inlineBlocks: (Block | BlockSlug)[]
}) => CollectionConfig = ({ blocks, inlineBlocks }) => {
const editorConfig: ServerEditorConfig = {
features: [
...defaultEditorFeatures,
//TestRecorderFeature(),
TreeViewFeature(),
//HTMLConverterFeature(),
FixedToolbarFeature(),
LinkFeature({
fields: ({ defaultFields }) => [
...defaultFields,
{
name: 'rel',
type: 'select',
admin: {
description:
'The rel attribute defines the relationship between a linked resource and the current document. This is a custom link field.',
},
hasMany: true,
label: 'Rel Attribute',
options: ['noopener', 'noreferrer', 'nofollow'],
},
],
}),
UploadFeature({
collections: {
uploads: {
fields: [
{
name: 'caption',
type: 'richText',
editor: lexicalEditor(),
},
],
}),
HeadingFeature({ enabledHeadingSizes: ['h2', 'h4'] }),
],
},
},
}),
ModifyInlineBlockFeature(),
BlocksFeature({
blocks,
inlineBlocks,
}),
EXPERIMENTAL_TableFeature(),
],
}
return {
slug: lexicalFieldsSlug,
access: {
read: () => true,
},
{
type: 'ui',
name: 'clearLexicalState',
admin: {
components: {
Field: {
path: '/collections/Lexical/components/ClearState.js#ClearState',
clientProps: {
fieldName: 'lexicalSimple',
admin: {
listSearchableFields: ['title', 'richTextLexicalCustomFields'],
useAsTitle: 'title',
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'lexicalRootEditor',
type: 'richText',
},
{
name: 'lexicalSimple',
type: 'richText',
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
//TestRecorderFeature(),
TreeViewFeature(),
BlocksFeature({
blocks: [
RichTextBlock,
TextBlock,
UploadAndRichTextBlock,
SelectFieldBlock,
RelationshipBlock,
RelationshipHasManyBlock,
SubBlockBlock,
RadioButtonsBlock,
ConditionalLayoutBlock,
],
}),
HeadingFeature({ enabledHeadingSizes: ['h2', 'h4'] }),
],
}),
},
{
type: 'ui',
name: 'clearLexicalState',
admin: {
components: {
Field: {
path: '/collections/Lexical/components/ClearState.js#ClearState',
clientProps: {
fieldName: 'lexicalSimple',
},
},
},
},
},
},
{
name: 'lexicalWithBlocks',
type: 'richText',
editor: lexicalEditor({
admin: {
hideGutter: false,
},
features: editorConfig.features,
}),
required: true,
},
//{
// name: 'rendered',
// type: 'ui',
// admin: {
// components: {
// Field: './collections/Lexical/LexicalRendered.js#LexicalRendered',
// },
// },
//},
{
name: 'lexicalWithBlocks_markdown',
type: 'textarea',
hooks: {
afterRead: [
async ({ data, req, siblingData }) => {
const yourSanitizedEditorConfig = await sanitizeServerEditorConfig(
editorConfig,
req.payload.config,
)
const headlessEditor = createHeadlessEditor({
nodes: getEnabledNodes({
editorConfig: yourSanitizedEditorConfig,
}),
})
const yourEditorState: SerializedEditorState = siblingData.lexicalWithBlocks
try {
headlessEditor.update(
() => {
headlessEditor.setEditorState(headlessEditor.parseEditorState(yourEditorState))
},
{ discrete: true },
)
} catch (e) {
/* empty */
}
// Export to markdown
let markdown: string = ''
headlessEditor.getEditorState().read(() => {
markdown = $convertToMarkdownString(
yourSanitizedEditorConfig?.features?.markdownTransformers,
)
})
return markdown
{
name: 'lexicalWithBlocks',
type: 'richText',
editor: lexicalEditor({
admin: {
hideGutter: false,
},
],
features: editorConfig.features,
}),
required: true,
},
},
],
//{
// name: 'rendered',
// type: 'ui',
// admin: {
// components: {
// Field: './collections/Lexical/LexicalRendered.js#LexicalRendered',
// },
// },
//},
{
name: 'lexicalWithBlocks_markdown',
type: 'textarea',
hooks: {
afterRead: [
async ({ data, req, siblingData }) => {
const yourSanitizedEditorConfig = await sanitizeServerEditorConfig(
editorConfig,
req.payload.config,
)
const headlessEditor = createHeadlessEditor({
nodes: getEnabledNodes({
editorConfig: yourSanitizedEditorConfig,
}),
})
const yourEditorState: SerializedEditorState = siblingData.lexicalWithBlocks
try {
headlessEditor.update(
() => {
headlessEditor.setEditorState(headlessEditor.parseEditorState(yourEditorState))
},
{ discrete: true },
)
} catch (e) {
/* empty */
}
// Export to markdown
let markdown: string = ''
headlessEditor.getEditorState().read(() => {
markdown = $convertToMarkdownString(
yourSanitizedEditorConfig?.features?.markdownTransformers,
)
})
return markdown
},
],
},
},
],
}
}

View File

@@ -34,7 +34,7 @@ export function generateLexicalLocalizedRichText(text1: string, text2: string, b
blockName: '',
textLocalized: text2,
counter: 1,
blockType: 'block',
blockType: 'blockLexicalLocalized',
},
},
],

View File

@@ -32,7 +32,7 @@ export const LexicalLocalizedFields: CollectionConfig = {
BlocksFeature({
blocks: [
{
slug: 'block',
slug: 'blockLexicalLocalized',
fields: [
{
name: 'textLocalized',
@@ -80,7 +80,7 @@ export const LexicalLocalizedFields: CollectionConfig = {
BlocksFeature({
blocks: [
{
slug: 'block',
slug: 'blockLexicalLocalized2',
fields: [
{
name: 'textLocalized',

Some files were not shown because too many files have changed in this diff Show More