diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 2578f37a5a..b587046bfd 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -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
diff --git a/docs/fields/blocks.mdx b/docs/fields/blocks.mdx
index eb35882f23..397ba6fcf5 100644
--- a/docs/fields/blocks.mdx
+++ b/docs/fields/blocks.mdx
@@ -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'],
+ })
+ })
+ },
+ ],
+ },
+ ],
+})
+```
+
+
+ **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.
+
+
## 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:
diff --git a/packages/db-mongodb/src/models/buildSchema.ts b/packages/db-mongodb/src/models/buildSchema.ts
index 19b8ff86c0..08059892b0 100644
--- a/packages/db-mongodb/src/models/buildSchema.ts
+++ b/packages/db-mongodb/src/models/buildSchema.ts
@@ -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 = {
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 = {
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)
}
})
},
diff --git a/packages/db-mongodb/src/predefinedMigrations/migrateRelationshipsV2_V3.ts b/packages/db-mongodb/src/predefinedMigrations/migrateRelationshipsV2_V3.ts
index 670262f7aa..926eaf3d08 100644
--- a/packages/db-mongodb/src/predefinedMigrations/migrateRelationshipsV2_V3.ts
+++ b/packages/db-mongodb/src/predefinedMigrations/migrateRelationshipsV2_V3.ts
@@ -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
}
diff --git a/packages/db-mongodb/src/queries/getLocalizedSortProperty.ts b/packages/db-mongodb/src/queries/getLocalizedSortProperty.ts
index f7aec4bbd4..1fcf5a341d 100644
--- a/packages/db-mongodb/src/queries/getLocalizedSortProperty.ts
+++ b/packages/db-mongodb/src/queries/getLocalizedSortProperty.ts
@@ -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
diff --git a/packages/db-mongodb/src/queries/sanitizeQueryValue.ts b/packages/db-mongodb/src/queries/sanitizeQueryValue.ts
index 219cbd2a44..60292c7cf7 100644
--- a/packages/db-mongodb/src/queries/sanitizeQueryValue.ts
+++ b/packages/db-mongodb/src/queries/sanitizeQueryValue.ts
@@ -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
diff --git a/packages/db-mongodb/src/utilities/buildProjectionFromSelect.ts b/packages/db-mongodb/src/utilities/buildProjectionFromSelect.ts
index 51215d7c6f..c7445cfaff 100644
--- a/packages/db-mongodb/src/utilities/buildProjectionFromSelect.ts
+++ b/packages/db-mongodb/src/utilities/buildProjectionFromSelect.ts
@@ -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')
diff --git a/packages/db-mongodb/src/utilities/sanitizeRelationshipIDs.ts b/packages/db-mongodb/src/utilities/sanitizeRelationshipIDs.ts
index bdecaa4066..948b7b29b9 100644
--- a/packages/db-mongodb/src/utilities/sanitizeRelationshipIDs.ts
+++ b/packages/db-mongodb/src/utilities/sanitizeRelationshipIDs.ts
@@ -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
}
diff --git a/packages/drizzle/src/find/traverseFields.ts b/packages/drizzle/src/find/traverseFields.ts
index 3ed85d0c71..6a98b8c4ef 100644
--- a/packages/drizzle/src/find/traverseFields.ts
+++ b/packages/drizzle/src/find/traverseFields.ts
@@ -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
diff --git a/packages/drizzle/src/postgres/predefinedMigrations/v2-v3/fetchAndResave/traverseFields.ts b/packages/drizzle/src/postgres/predefinedMigrations/v2-v3/fetchAndResave/traverseFields.ts
index 8304c659be..4b9f6f1132 100644
--- a/packages/drizzle/src/postgres/predefinedMigrations/v2-v3/fetchAndResave/traverseFields.ts
+++ b/packages/drizzle/src/postgres/predefinedMigrations/v2-v3/fetchAndResave/traverseFields.ts
@@ -1,4 +1,4 @@
-import type { FlattenedField } from 'payload'
+import type { FlattenedBlock, FlattenedField } from 'payload'
type Args = {
doc: Record
@@ -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({
diff --git a/packages/drizzle/src/postgres/predefinedMigrations/v2-v3/traverseFields.ts b/packages/drizzle/src/postgres/predefinedMigrations/v2-v3/traverseFields.ts
index 1a6b6cea2c..9948644594 100644
--- a/packages/drizzle/src/postgres/predefinedMigrations/v2-v3/traverseFields.ts
+++ b/packages/drizzle/src/postgres/predefinedMigrations/v2-v3/traverseFields.ts
@@ -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)}`,
)
diff --git a/packages/drizzle/src/queries/getTableColumnFromPath.ts b/packages/drizzle/src/queries/getTableColumnFromPath.ts
index bbd72fe56a..5717a6507e 100644
--- a/packages/drizzle/src/queries/getTableColumnFromPath.ts
+++ b/packages/drizzle/src/queries/getTableColumnFromPath.ts
@@ -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 {
diff --git a/packages/drizzle/src/schema/traverseFields.ts b/packages/drizzle/src/schema/traverseFields.ts
index 9cd8161cfc..67b8be7129 100644
--- a/packages/drizzle/src/schema/traverseFields.ts
+++ b/packages/drizzle/src/schema/traverseFields.ts
@@ -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,
diff --git a/packages/drizzle/src/transform/read/traverseFields.ts b/packages/drizzle/src/transform/read/traverseFields.ts
index 660d5c8259..d7078cc5f7 100644
--- a/packages/drizzle/src/transform/read/traverseFields.ts
+++ b/packages/drizzle/src/transform/read/traverseFields.ts
@@ -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 = >({
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({
@@ -265,7 +269,16 @@ export const traverseFields = >({
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 (
diff --git a/packages/drizzle/src/transform/write/blocks.ts b/packages/drizzle/src/transform/write/blocks.ts
index 136eb7a38d..92e714d43f 100644
--- a/packages/drizzle/src/transform/write/blocks.ts
+++ b/packages/drizzle/src/transform/write/blocks.ts
@@ -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
}
diff --git a/packages/drizzle/src/transform/write/traverseFields.ts b/packages/drizzle/src/transform/write/traverseFields.ts
index f51e9cc2f6..ed011d82a5 100644
--- a/packages/drizzle/src/transform/write/traverseFields.ts
+++ b/packages/drizzle/src/transform/write/traverseFields.ts
@@ -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) {
diff --git a/packages/graphql/src/schema/buildObjectType.ts b/packages/graphql/src/schema/buildObjectType.ts
index f1753d8001..2b88c1ce3d 100644
--- a/packages/graphql/src/schema/buildObjectType.ts
+++ b/packages/graphql/src/schema/buildObjectType.ts
@@ -108,8 +108,15 @@ export function buildObjectType({
}
},
blocks: (objectTypeConfig: ObjectTypeConfig, field: BlocksField) => {
- const blockTypes: GraphQLObjectType[] = field.blocks.reduce((acc, block) => {
- if (!graphqlResult.types.blockTypes[block.slug]) {
+ const blockTypes: GraphQLObjectType[] = (
+ 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
diff --git a/packages/next/src/views/LivePreview/Context/index.tsx b/packages/next/src/views/LivePreview/Context/index.tsx
index 58bed3d585..3ea06d3cb2 100644
--- a/packages/next/src/views/LivePreview/Context/index.tsx
+++ b/packages/next/src/views/LivePreview/Context/index.tsx
@@ -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 = ({
const iframeRef = React.useRef(null)
const [iframeHasLoaded, setIframeHasLoaded] = useState(false)
+ const { config } = useConfig()
const [zoom, setZoom] = useState(1)
@@ -59,7 +61,7 @@ export const LivePreviewProvider: React.FC = ({
React.useState('responsive')
const [fieldSchemaJSON] = useState(() => {
- return fieldSchemaToJSON(fieldSchema)
+ return fieldSchemaToJSON(fieldSchema, config)
})
// The toolbar needs to freely drag and drop around the page
diff --git a/packages/next/src/views/Version/RenderFieldsToDiff/DiffCollapser/index.tsx b/packages/next/src/views/Version/RenderFieldsToDiff/DiffCollapser/index.tsx
index 2c5a158e2a..81f58b6592 100644
--- a/packages/next/src/views/Version/RenderFieldsToDiff/DiffCollapser/index.tsx
+++ b/packages/next/src/views/Version/RenderFieldsToDiff/DiffCollapser/index.tsx
@@ -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 = ({
}) => {
const { t } = useTranslation()
const [isCollapsed, setIsCollapsed] = useState(initCollapsed)
+ const { config } = useConfig()
let changeCount = 0
@@ -69,6 +71,7 @@ export const DiffCollapser: React.FC = ({
changeCount = countChangedFieldsInRows({
comparisonRows,
+ config,
field,
locales,
versionRows,
@@ -76,6 +79,7 @@ export const DiffCollapser: React.FC = ({
} else {
changeCount = countChangedFields({
comparison,
+ config,
fields,
locales,
version,
diff --git a/packages/next/src/views/Version/RenderFieldsToDiff/buildVersionFields.tsx b/packages/next/src/views/Version/RenderFieldsToDiff/buildVersionFields.tsx
index daca26ab10..b6b54686f5 100644
--- a/packages/next/src/views/Version/RenderFieldsToDiff/buildVersionFields.tsx
+++ b/packages/next/src/views/Version/RenderFieldsToDiff/buildVersionFields.tsx
@@ -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(
[...versionBlock.fields, ...comparisonBlock.fields],
diff --git a/packages/next/src/views/Version/RenderFieldsToDiff/fields/Iterable/index.tsx b/packages/next/src/views/Version/RenderFieldsToDiff/fields/Iterable/index.tsx
index 6faccf83f7..24e8cdd054 100644
--- a/packages/next/src/views/Version/RenderFieldsToDiff/fields/Iterable/index.tsx
+++ b/packages/next/src/views/Version/RenderFieldsToDiff/fields/Iterable/index.tsx
@@ -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 = ({
}) => {
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 = ({
const { fields, versionFields } = getFieldsForRowComparison({
baseVersionField,
comparisonRow,
+ config,
field,
row: i,
versionRow,
diff --git a/packages/next/src/views/Version/RenderFieldsToDiff/utilities/countChangedFields.ts b/packages/next/src/views/Version/RenderFieldsToDiff/utilities/countChangedFields.ts
index 046c4bed4c..3470e25496 100644
--- a/packages/next/src/views/Version/RenderFieldsToDiff/utilities/countChangedFields.ts
+++ b/packages/next/src/views/Version/RenderFieldsToDiff/utilities/countChangedFields.ts
@@ -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,
diff --git a/packages/next/src/views/Version/RenderFieldsToDiff/utilities/getFieldsForRowComparison.ts b/packages/next/src/views/Version/RenderFieldsToDiff/utilities/getFieldsForRowComparison.ts
index e8612ca758..13bf791b22 100644
--- a/packages/next/src/views/Version/RenderFieldsToDiff/utilities/getFieldsForRowComparison.ts
+++ b/packages/next/src/views/Version/RenderFieldsToDiff/utilities/getFieldsForRowComparison.ts
@@ -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(
[...matchedVersionBlock.fields, ...matchedComparisonBlock.fields],
diff --git a/packages/payload/src/bin/generateImportMap/iterateConfig.ts b/packages/payload/src/bin/generateImportMap/iterateConfig.ts
index 77c7af067f..9e93eeb718 100644
--- a/packages/payload/src/bin/generateImportMap/iterateConfig.ts
+++ b/packages/payload/src/bin/generateImportMap/iterateConfig.ts
@@ -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)
}
diff --git a/packages/payload/src/bin/generateImportMap/iterateFields.ts b/packages/payload/src/bin/generateImportMap/iterateFields.ts
index 602782ef41..cd4b78c42f 100644
--- a/packages/payload/src/bin/generateImportMap/iterateFields.ts
+++ b/packages/payload/src/bin/generateImportMap/iterateFields.ts
@@ -47,7 +47,7 @@ export function genImportMapIterateFields({
addToImportMap,
baseDir,
config,
- fields: field.blocks,
+ fields: field.blocks.filter((block) => typeof block !== 'string'),
importMap,
imports,
})
diff --git a/packages/payload/src/collections/config/sanitize.ts b/packages/payload/src/collections/config/sanitize.ts
index 30a7bb76ee..f42836b1e9 100644
--- a/packages/payload/src/collections/config/sanitize.ts
+++ b/packages/payload/src/collections/config/sanitize.ts
@@ -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>,
+ _validRelationships?: string[],
): Promise => {
// /////////////////////////////////
// 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,
diff --git a/packages/payload/src/config/client.ts b/packages/payload/src/config/client.ts
index 0befb3696f..16f615abbf 100644
--- a/packages/payload/src/config/client.ts
+++ b/packages/payload/src/config/client.ts
@@ -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
+export type UnsanitizedClientConfig = {
+ admin: {
+ livePreview?: Omit
+ } & Omit
+ blocks: ClientBlock[]
+ collections: ClientCollectionConfig[]
+ custom?: Record
+ globals: ClientGlobalConfig[]
+} & Omit
+
export type ClientConfig = {
admin: {
livePreview?: Omit
} & Omit
+ blocks: ClientBlock[]
+ blocksMap: Record
collections: ClientCollectionConfig[]
custom?: Record
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 = {}
diff --git a/packages/payload/src/config/sanitize.ts b/packages/payload/src/config/sanitize.ts
index 4a2961ea0f..b6232bb775 100644
--- a/packages/payload/src/config/sanitize.ts
+++ b/packages/payload/src/config/sanitize.ts
@@ -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()
+ 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
-
/** 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
+ blocks?: FlattenedBlock[]
collections: SanitizedCollectionConfig[]
/** Default richtext editor to use for richText fields */
editor?: RichTextAdapter
@@ -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,
- 'admin' | 'collections' | 'editor' | 'endpoint' | 'globals' | 'i18n' | 'localization' | 'upload'
+ | 'admin'
+ | 'blocks'
+ | 'collections'
+ | 'editor'
+ | 'endpoint'
+ | 'globals'
+ | 'i18n'
+ | 'localization'
+ | 'upload'
>
export type EditConfig = EditConfigWithoutRoot | EditConfigWithRoot
diff --git a/packages/payload/src/database/getLocalizedPaths.ts b/packages/payload/src/database/getLocalizedPaths.ts
index 6a3e3001d5..b9d6f7f5a5 100644
--- a/packages/payload/src/database/getLocalizedPaths.ts
+++ b/packages/payload/src/database/getLocalizedPaths.ts
@@ -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
diff --git a/packages/payload/src/database/queryValidation/validateSearchParams.ts b/packages/payload/src/database/queryValidation/validateSearchParams.ts
index 1c9d5f3668..6fda8b6462 100644
--- a/packages/payload/src/database/queryValidation/validateSearchParams.ts
+++ b/packages/payload/src/database/queryValidation/validateSearchParams.ts
@@ -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]
diff --git a/packages/payload/src/fields/config/client.ts b/packages/payload/src/fields/config/client.ts
index 32cd4822aa..5eea4c12ba 100644
--- a/packages/payload/src/fields/config/client.ts
+++ b/packages/payload/src/fields/config/client.ts
@@ -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({
+ 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({
- 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({
- 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
diff --git a/packages/payload/src/fields/config/sanitize.ts b/packages/payload/src/fields/config/sanitize.ts
index 79df250ac5..f89c9ef409 100644
--- a/packages/payload/src/fields/config/sanitize.ts
+++ b/packages/payload/src/fields/config/sanitize.ts
@@ -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
}
diff --git a/packages/payload/src/fields/config/sanitizeJoinField.ts b/packages/payload/src/fields/config/sanitizeJoinField.ts
index d851a81c63..6c1bdee457 100644
--- a/packages/payload/src/fields/config/sanitizeJoinField.ts
+++ b/packages/payload/src/fields/config/sanitizeJoinField.ts
@@ -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,
})
diff --git a/packages/payload/src/fields/config/types.ts b/packages/payload/src/fields/config/types.ts
index 9784e60b83..3c1452d711 100644
--- a/packages/payload/src/fields/config/types.ts
+++ b/packages/payload/src/fields/config/types.ts
@@ -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
+ /**
+ * 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
export type FlattenedGroupField = {
flattenedFields: FlattenedField[]
diff --git a/packages/payload/src/fields/hooks/afterChange/promise.ts b/packages/payload/src/fields/hooks/afterChange/promise.ts
index 20fbad922f..8d694db6f0 100644
--- a/packages/payload/src/fields/hooks/afterChange/promise.ts
+++ b/packages/payload/src/fields/hooks/afterChange/promise.ts
@@ -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(
diff --git a/packages/payload/src/fields/hooks/afterRead/promise.ts b/packages/payload/src/fields/hooks/afterRead/promise.ts
index 2fca395a3f..81ecd5a2e2 100644
--- a/packages/payload/src/fields/hooks/afterRead/promise.ts
+++ b/packages/payload/src/fields/hooks/afterRead/promise.ts
@@ -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({
diff --git a/packages/payload/src/fields/hooks/beforeChange/promise.ts b/packages/payload/src/fields/hooks/beforeChange/promise.ts
index 1a12ea058c..e715b16cbf 100644
--- a/packages/payload/src/fields/hooks/beforeChange/promise.ts
+++ b/packages/payload/src/fields/hooks/beforeChange/promise.ts
@@ -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(
diff --git a/packages/payload/src/fields/hooks/beforeDuplicate/promise.ts b/packages/payload/src/fields/hooks/beforeDuplicate/promise.ts
index 6beaf3ce05..d95aa0b962 100644
--- a/packages/payload/src/fields/hooks/beforeDuplicate/promise.ts
+++ b/packages/payload/src/fields/hooks/beforeDuplicate/promise.ts
@@ -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 ({
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 ({
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
diff --git a/packages/payload/src/fields/hooks/beforeValidate/promise.ts b/packages/payload/src/fields/hooks/beforeValidate/promise.ts
index 81bbc93d4c..1072775bcb 100644
--- a/packages/payload/src/fields/hooks/beforeValidate/promise.ts
+++ b/packages/payload/src/fields/hooks/beforeValidate/promise.ts
@@ -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 ({
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
diff --git a/packages/payload/src/globals/config/sanitize.ts b/packages/payload/src/globals/config/sanitize.ts
index 263b0c2472..289b2ae58b 100644
--- a/packages/payload/src/globals/config/sanitize.ts
+++ b/packages/payload/src/globals/config/sanitize.ts
@@ -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>,
+ _validRelationships?: string[],
): Promise => {
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,
diff --git a/packages/payload/src/index.ts b/packages/payload/src/index.ts
index afd459066d..2249be3ba2 100644
--- a/packages/payload/src/index.ts
+++ b/packages/payload/src/index.ts
@@ -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 = 'collections' extends keyof T
: // @ts-expect-error
T['collectionsUntyped']
+type ResolveBlockType = 'blocks' extends keyof T
+ ? T['blocks']
+ : // @ts-expect-error
+ T['blocksUntyped']
+
type ResolveCollectionSelectType = 'collectionsSelect' extends keyof T
? T['collectionsSelect']
: // @ts-expect-error
@@ -174,6 +182,8 @@ type ResolveGlobalSelectType = 'globalsSelect' extends keyof T
// Applying helper types to GeneratedTypes
export type TypedCollection = ResolveCollectionType
+export type TypedBlock = ResolveBlockType
+
export type TypedUploadCollection = NonNever<{
[K in keyof TypedCollection]:
| 'filename'
@@ -198,6 +208,8 @@ export type StringKeyOf = Extract
// Define the types for slugs using the appropriate collections and globals
export type CollectionSlug = StringKeyOf
+export type BlockSlug = StringKeyOf
+
export type UploadCollectionSlug = StringKeyOf
type ResolveDbType = 'db' extends keyof T
@@ -247,6 +259,8 @@ export class BasePayload {
authStrategies: AuthStrategy[]
+ blocks: Record = {}
+
collections: Record = {}
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'
diff --git a/packages/payload/src/utilities/configToJSONSchema.ts b/packages/payload/src/utilities/configToJSONSchema.ts
index a4acbd69da..3460f7e83d 100644
--- a/packages/payload/src/utilities/configToJSONSchema.ts
+++ b/packages/payload/src/utilities/configToJSONSchema.ts
@@ -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
}): 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',
}
diff --git a/packages/payload/src/utilities/fieldSchemaToJSON.ts b/packages/payload/src/utilities/fieldSchemaToJSON.ts
index 534b4ac895..0b94e9f1eb 100644
--- a/packages/payload/src/utilities/fieldSchemaToJSON.ts
+++ b/packages/payload/src/utilities/fieldSchemaToJSON.ts
@@ -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)
diff --git a/packages/payload/src/utilities/flattenAllFields.ts b/packages/payload/src/utilities/flattenAllFields.ts
index 8815d511e8..e2b0c4ebfb 100644
--- a/packages/payload/src/utilities/flattenAllFields.ts
+++ b/packages/payload/src/utilities/flattenAllFields.ts
@@ -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
}
diff --git a/packages/payload/src/utilities/getEntityPolicies.ts b/packages/payload/src/utilities/getEntityPolicies.ts
index 48853a68a4..1233c1a85e 100644
--- a/packages/payload/src/utilities/getEntityPolicies.ts
+++ b/packages/payload/src/utilities/getEntityPolicies.ts
@@ -172,13 +172,18 @@ export async function getEntityPolicies(args: T): Promise {
+ (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: {},
diff --git a/packages/payload/src/utilities/traverseFields.ts b/packages/payload/src/utilities/traverseFields.ts
index ae8b3b45a7..189e6f26a1 100644
--- a/packages/payload/src/utilities/traverseFields.ts
+++ b/packages/payload/src/utilities/traverseFields.ts
@@ -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[]
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 | unknown
ref?: Record | 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[],
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 })
+ })
+ }
})
}
diff --git a/packages/plugin-multi-tenant/src/index.ts b/packages/plugin-multi-tenant/src/index.ts
index 5ede4e2343..96d254a938 100644
--- a/packages/plugin-multi-tenant/src/index.ts
+++ b/packages/plugin-multi-tenant/src/index.ts
@@ -161,6 +161,7 @@ export const multiTenantPlugin =
* Modify enabled collections
*/
addFilterOptionsToFields({
+ config: incomingConfig,
fields: collection.fields,
tenantEnabledCollectionSlugs: collectionSlugs,
tenantEnabledGlobalSlugs: globalCollectionSlugs,
diff --git a/packages/plugin-multi-tenant/src/utilities/addFilterOptionsToFields.ts b/packages/plugin-multi-tenant/src/utilities/addFilterOptionsToFields.ts
index 3800999921..9257c0ac59 100644
--- a/packages/plugin-multi-tenant/src/utilities/addFilterOptionsToFields.ts
+++ b/packages/plugin-multi-tenant/src/utilities/addFilterOptionsToFields.ts
@@ -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,
diff --git a/packages/richtext-lexical/src/features/blocks/client/component/index.tsx b/packages/richtext-lexical/src/features/blocks/client/component/index.tsx
index 485bdbc2b2..a7348e12cc 100644
--- a/packages/richtext-lexical/src/features/blocks/client/component/index.tsx
+++ b/packages/richtext-lexical/src/features/blocks/client/component/index.tsx
@@ -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) => {
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) => {
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