diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 2578f37a5..b587046bf 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 eb35882f2..397ba6fcf 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 19b8ff86c..08059892b 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 670262f7a..926eaf3d0 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 f7aec4bbd..1fcf5a341 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 219cbd2a4..60292c7cf 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 51215d7c6..c7445cfaf 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 bdecaa406..948b7b29b 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 3ed85d0c7..6a98b8c4e 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 8304c659b..4b9f6f113 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 1a6b6cea2..994864459 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 bbd72fe56..5717a6507 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 9cd8161cf..67b8be712 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 660d5c825..d7078cc5f 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 136eb7a38..92e714d43 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 f51e9cc2f..ed011d82a 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 f1753d800..2b88c1ce3 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 58bed3d58..3ea06d3cb 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 2c5a158e2..81f58b659 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 daca26ab1..b6b54686f 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 6faccf83f..24e8cdd05 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 046c4bed4..3470e2549 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 e8612ca75..13bf791b2 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 77c7af067..9e93eeb71 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 602782ef4..cd4b78c42 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 30a7bb76e..f42836b1e 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 0befb3696..16f615abb 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 4a2961ea0..b6232bb77 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 6a3e3001d..b9d6f7f5a 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 1c9d5f366..6fda8b646 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 32cd4822a..5eea4c12b 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 79df250ac..f89c9ef40 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 d851a81c6..6c1bdee45 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 9784e60b8..3c1452d71 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 20fbad922..8d694db6f 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 2fca395a3..81ecd5a2e 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 1a12ea058..e715b16cb 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 6beaf3ce0..d95aa0b96 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 81bbc93d4..1072775bc 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 263b0c247..289b2ae58 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 afd459066..2249be3ba 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 a4acbd69d..3460f7e83 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 534b4ac89..0b94e9f1e 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 8815d511e..e2b0c4ebf 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 48853a68a..1233c1a85 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 ae8b3b45a..189e6f26a 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 5ede4e234..96d254a93 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 380099992..9257c0ac5 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 485bdbc2b..a7348e12c 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