diff --git a/packages/db-mongodb/src/queries/getLocalizedSortProperty.spec.ts b/packages/db-mongodb/src/queries/getLocalizedSortProperty.spec.ts index bc1153fa7..803a9a8c6 100644 --- a/packages/db-mongodb/src/queries/getLocalizedSortProperty.spec.ts +++ b/packages/db-mongodb/src/queries/getLocalizedSortProperty.spec.ts @@ -2,16 +2,19 @@ import { SanitizedConfig, sanitizeConfig } from 'payload/config' import { Config } from 'payload/config' import { getLocalizedSortProperty } from './getLocalizedSortProperty.js' -const config = sanitizeConfig({ - localization: { - locales: ['en', 'es'], - defaultLocale: 'en', - fallback: true, - }, -} as Config) as SanitizedConfig +let config: SanitizedConfig describe('get localized sort property', () => { - it('passes through a non-localized sort property', () => { + beforeAll(async () => { + config = (await sanitizeConfig({ + localization: { + locales: ['en', 'es'], + defaultLocale: 'en', + fallback: true, + }, + } as Config)) as SanitizedConfig + }) + it('passes through a non-localized sort property', async () => { const result = getLocalizedSortProperty({ segments: ['title'], config, diff --git a/packages/graphql/src/schema/buildObjectType.ts b/packages/graphql/src/schema/buildObjectType.ts index 903b8007d..1df3f23f3 100644 --- a/packages/graphql/src/schema/buildObjectType.ts +++ b/packages/graphql/src/schema/buildObjectType.ts @@ -462,6 +462,10 @@ function buildObjectType({ async resolve(parent, args, context: Context) { let depth = config.defaultDepth if (typeof args.depth !== 'undefined') depth = args.depth + if (typeof field?.editor === 'function') { + throw new Error('Attempted to access unsanitized rich text editor.') + } + const editor: RichTextAdapter = field?.editor // RichText fields have their own depth argument in GraphQL. diff --git a/packages/next/src/utilities/buildFieldSchemaMap/traverseFields.ts b/packages/next/src/utilities/buildFieldSchemaMap/traverseFields.ts index b32cb69f3..2169ffa28 100644 --- a/packages/next/src/utilities/buildFieldSchemaMap/traverseFields.ts +++ b/packages/next/src/utilities/buildFieldSchemaMap/traverseFields.ts @@ -68,6 +68,10 @@ export const traverseFields = ({ break case 'richText': + if (typeof field.editor === 'function') { + throw new Error('Attempted to access unsanitized rich text editor.') + } + if (typeof field.editor.generateSchemaMap === 'function') { field.editor.generateSchemaMap({ config, diff --git a/packages/payload/src/admin/RichText.ts b/packages/payload/src/admin/RichText.ts index f15b25c1a..5d01b5f0d 100644 --- a/packages/payload/src/admin/RichText.ts +++ b/packages/payload/src/admin/RichText.ts @@ -1,7 +1,7 @@ import type { I18n } from '@payloadcms/translations' import type { JSONSchema4 } from 'json-schema' -import type { SanitizedConfig } from '../config/types.js' +import type { Config, SanitizedConfig } from '../config/types.js' import type { Field, FieldBase, RichTextField, Validate } from '../fields/config/types.js' import type { PayloadRequestWithData, RequestContext } from '../types/index.js' import type { WithServerSideProps } from './elements/WithServerSideProps.js' @@ -84,3 +84,15 @@ export type RichTextAdapter< CellComponent: React.FC FieldComponent: React.FC> } + +export type RichTextAdapterProvider< + Value extends object = object, + AdapterProps = any, + ExtraFieldProperties = {}, +> = ({ + config, +}: { + config: SanitizedConfig +}) => + | Promise> + | RichTextAdapter diff --git a/packages/payload/src/admin/types.ts b/packages/payload/src/admin/types.ts index efd4ddb7b..af64aecee 100644 --- a/packages/payload/src/admin/types.ts +++ b/packages/payload/src/admin/types.ts @@ -1,4 +1,4 @@ -export type { RichTextAdapter, RichTextFieldProps } from './RichText.js' +export type { RichTextAdapter, RichTextAdapterProvider, RichTextFieldProps } from './RichText.js' export type { CellComponentProps, DefaultCellComponentProps } from './elements/Cell.js' export type { ConditionalDateProps } from './elements/DatePicker.js' export type { DayPickerProps, SharedProps, TimePickerProps } from './elements/DatePicker.js' diff --git a/packages/payload/src/collections/config/sanitize.ts b/packages/payload/src/collections/config/sanitize.ts index b8259af55..2b93538fb 100644 --- a/packages/payload/src/collections/config/sanitize.ts +++ b/packages/payload/src/collections/config/sanitize.ts @@ -1,6 +1,6 @@ import merge from 'deepmerge' -import type { Config } from '../../config/types.js' +import type { Config, SanitizedConfig } from '../../config/types.js' import type { CollectionConfig, SanitizedCollectionConfig } from './types.js' import baseAccountLockFields from '../../auth/baseFields/accountLock.js' @@ -17,10 +17,15 @@ import { isPlainObject } from '../../utilities/isPlainObject.js' import baseVersionFields from '../../versions/baseFields.js' import { authDefaults, defaults } from './defaults.js' -export const sanitizeCollection = ( +export const sanitizeCollection = async ( config: Config, collection: CollectionConfig, -): SanitizedCollectionConfig => { + /** + * If this property is set, RichText fields won't be sanitized immediately. Instead, they will be added to this array as promises + * so that you can sanitize them together, after the config has been sanitized. + */ + richTextSanitizationPromises?: Array<(config: SanitizedConfig) => Promise>, +): Promise => { // ///////////////////////////////// // Make copy of collection config // ///////////////////////////////// @@ -151,9 +156,10 @@ export const sanitizeCollection = ( // ///////////////////////////////// const validRelationships = config.collections.map((c) => c.slug) || [] - sanitized.fields = sanitizeFields({ + sanitized.fields = await sanitizeFields({ config, fields: sanitized.fields, + richTextSanitizationPromises, validRelationships, }) diff --git a/packages/payload/src/config/build.ts b/packages/payload/src/config/build.ts index 9f8bfc469..cfd1ee5d7 100644 --- a/packages/payload/src/config/build.ts +++ b/packages/payload/src/config/build.ts @@ -1,5 +1,3 @@ -/* eslint-disable no-use-before-define */ -/* eslint-disable no-nested-ternary */ import type { Config, SanitizedConfig } from './types.js' import { sanitizeConfig } from './sanitize.js' @@ -16,10 +14,8 @@ export async function buildConfig(config: Config): Promise { return plugin(configAfterPlugin) }, Promise.resolve(config)) - const sanitizedConfig = sanitizeConfig(configAfterPlugins) - - return sanitizedConfig + return await sanitizeConfig(configAfterPlugins) } - return sanitizeConfig(config) + return await sanitizeConfig(config) } diff --git a/packages/payload/src/config/sanitize.ts b/packages/payload/src/config/sanitize.ts index edbee695a..efc0ec921 100644 --- a/packages/payload/src/config/sanitize.ts +++ b/packages/payload/src/config/sanitize.ts @@ -12,7 +12,7 @@ import { defaultUserCollection } from '../auth/defaultUser.js' import { sanitizeCollection } from '../collections/config/sanitize.js' import { migrationsCollection } from '../database/migrations/migrationsCollection.js' import { InvalidConfiguration } from '../errors/index.js' -import sanitizeGlobals from '../globals/config/sanitize.js' +import { sanitizeGlobals } from '../globals/config/sanitize.js' import getPreferencesCollection from '../preferences/preferencesCollection.js' import checkDuplicateCollections from '../utilities/checkDuplicateCollections.js' import { isPlainObject } from '../utilities/isPlainObject.js' @@ -41,10 +41,10 @@ const sanitizeAdminConfig = (configToSanitize: Config): Partial ) } - return sanitizedConfig as Partial + return sanitizedConfig as unknown as Partial } -export const sanitizeConfig = (incomingConfig: Config): SanitizedConfig => { +export const sanitizeConfig = async (incomingConfig: Config): Promise => { const configWithDefaults: Config = merge(defaults, incomingConfig, { isMergeableObject: isPlainObject, }) as Config @@ -97,16 +97,25 @@ export const sanitizeConfig = (incomingConfig: Config): SanitizedConfig => { ...(incomingConfig?.i18n ?? {}), } - configWithDefaults.collections.push(getPreferencesCollection(configWithDefaults)) + configWithDefaults.collections.push(getPreferencesCollection(config as unknown as Config)) configWithDefaults.collections.push(migrationsCollection) - config.collections = config.collections.map((collection) => - sanitizeCollection(configWithDefaults, collection), - ) + const richTextSanitizationPromises: Array<(config: SanitizedConfig) => Promise> = [] + for (let i = 0; i < config.collections.length; i++) { + config.collections[i] = await sanitizeCollection( + config as unknown as Config, + config.collections[i], + richTextSanitizationPromises, + ) + } + checkDuplicateCollections(config.collections) if (config.globals.length > 0) { - config.globals = sanitizeGlobals(config as SanitizedConfig) + config.globals = await sanitizeGlobals( + config as unknown as Config, + richTextSanitizationPromises, + ) } if (config.serverURL !== '') { @@ -119,5 +128,20 @@ export const sanitizeConfig = (incomingConfig: Config): SanitizedConfig => { new Set(config.collections.map((c) => c.upload?.adapter).filter(Boolean)), ) + /* + Execute richText sanitization + */ + if (typeof incomingConfig.editor === 'function') { + config.editor = await incomingConfig.editor({ + config: config as SanitizedConfig, + }) + } + + const promises: Promise[] = [] + for (const sanitizeFunction of richTextSanitizationPromises) { + promises.push(sanitizeFunction(config as SanitizedConfig)) + } + await Promise.all(promises) + return config as SanitizedConfig } diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts index 6af84e082..ebfcc99a2 100644 --- a/packages/payload/src/config/types.ts +++ b/packages/payload/src/config/types.ts @@ -6,6 +6,7 @@ import type React from 'react' import type { default as sharp } from 'sharp' import type { DeepRequired } from 'ts-essentials' +import type { RichTextAdapterProvider } from '../admin/RichText.js' import type { DocumentTab, RichTextAdapter } from '../admin/types.js' import type { AdminView, ServerSideEditViewProps } from '../admin/views/types.js' import type { User } from '../auth/types.js' @@ -527,7 +528,7 @@ export type Config = { */ defaultMaxTextLength?: number /** Default richtext editor to use for richText fields */ - editor: RichTextAdapter + editor: RichTextAdapterProvider /** * Email Adapter * @@ -640,9 +641,11 @@ export type Config = { export type SanitizedConfig = Omit< DeepRequired, - 'collections' | 'endpoint' | 'globals' | 'i18n' | 'localization' | 'upload' + 'collections' | 'editor' | 'endpoint' | 'globals' | 'i18n' | 'localization' | 'upload' > & { collections: SanitizedCollectionConfig[] + /** Default richtext editor to use for richText fields */ + editor: RichTextAdapter endpoints: Endpoint[] globals: SanitizedGlobalConfig[] i18n: Required diff --git a/packages/payload/src/exports/i18n.ts b/packages/payload/src/exports/i18n.ts new file mode 100644 index 000000000..607795920 --- /dev/null +++ b/packages/payload/src/exports/i18n.ts @@ -0,0 +1 @@ +export { getLocalI18n } from '../translations/getLocalI18n.js' diff --git a/packages/payload/src/fields/config/sanitize.spec.ts b/packages/payload/src/fields/config/sanitize.spec.ts index 3dae7409c..9007f3638 100644 --- a/packages/payload/src/fields/config/sanitize.spec.ts +++ b/packages/payload/src/fields/config/sanitize.spec.ts @@ -1,6 +1,6 @@ -import { Config } from '../../config/types' -import { InvalidFieldName, InvalidFieldRelationship, MissingFieldType } from '../../errors' -import { sanitizeFields } from './sanitize' +import { Config } from '../../config/types.js' +import { InvalidFieldName, InvalidFieldRelationship, MissingFieldType } from '../../errors/index.js' +import { sanitizeFields } from './sanitize.js' import type { ArrayField, Block, @@ -9,11 +9,11 @@ import type { Field, NumberField, TextField, -} from './types' +} from './types.js' describe('sanitizeFields', () => { const config = {} as Config - it('should throw on missing type field', () => { + it('should throw on missing type field', async () => { const fields: Field[] = [ // @ts-expect-error { @@ -21,15 +21,15 @@ describe('sanitizeFields', () => { name: 'Some Collection', }, ] - expect(() => { - sanitizeFields({ + await expect(async () => { + await sanitizeFields({ config, fields, validRelationships: [], }) - }).toThrow(MissingFieldType) + }).rejects.toThrow(MissingFieldType) }) - it('should throw on invalid field name', () => { + it('should throw on invalid field name', async () => { const fields: Field[] = [ { label: 'some.collection', @@ -37,33 +37,35 @@ describe('sanitizeFields', () => { type: 'text', }, ] - expect(() => { - sanitizeFields({ + await expect(async () => { + await sanitizeFields({ config, fields, validRelationships: [], }) - }).toThrow(InvalidFieldName) + }).rejects.toThrow(InvalidFieldName) }) describe('auto-labeling', () => { - it('should populate label if missing', () => { + it('should populate label if missing', async () => { const fields: Field[] = [ { name: 'someField', type: 'text', }, ] - const sanitizedField = sanitizeFields({ - config, - fields, - validRelationships: [], - })[0] as TextField + const sanitizedField = ( + await sanitizeFields({ + config, + fields, + validRelationships: [], + }) + )[0] as TextField expect(sanitizedField.name).toStrictEqual('someField') expect(sanitizedField.label).toStrictEqual('Some Field') expect(sanitizedField.type).toStrictEqual('text') }) - it('should allow auto-label override', () => { + it('should allow auto-label override', async () => { const fields: Field[] = [ { label: 'Do not label', @@ -71,18 +73,20 @@ describe('sanitizeFields', () => { type: 'text', }, ] - const sanitizedField = sanitizeFields({ - config, - fields, - validRelationships: [], - })[0] as TextField + const sanitizedField = ( + await sanitizeFields({ + config, + fields, + validRelationships: [], + }) + )[0] as TextField expect(sanitizedField.name).toStrictEqual('someField') expect(sanitizedField.label).toStrictEqual('Do not label') expect(sanitizedField.type).toStrictEqual('text') }) describe('opt-out', () => { - it('should allow label opt-out', () => { + it('should allow label opt-out', async () => { const fields: Field[] = [ { label: false, @@ -90,17 +94,19 @@ describe('sanitizeFields', () => { type: 'text', }, ] - const sanitizedField = sanitizeFields({ - config, - fields, - validRelationships: [], - })[0] as TextField + const sanitizedField = ( + await sanitizeFields({ + config, + fields, + validRelationships: [], + }) + )[0] as TextField expect(sanitizedField.name).toStrictEqual('someField') expect(sanitizedField.label).toStrictEqual(false) expect(sanitizedField.type).toStrictEqual('text') }) - it('should allow label opt-out for arrays', () => { + it('should allow label opt-out for arrays', async () => { const arrayField: ArrayField = { fields: [ { @@ -112,17 +118,19 @@ describe('sanitizeFields', () => { name: 'items', type: 'array', } - const sanitizedField = sanitizeFields({ - config, - fields: [arrayField], - validRelationships: [], - })[0] as ArrayField + const sanitizedField = ( + await sanitizeFields({ + config, + fields: [arrayField], + validRelationships: [], + }) + )[0] as ArrayField expect(sanitizedField.name).toStrictEqual('items') expect(sanitizedField.label).toStrictEqual(false) expect(sanitizedField.type).toStrictEqual('array') expect(sanitizedField.labels).toBeUndefined() }) - it('should allow label opt-out for blocks', () => { + it('should allow label opt-out for blocks', async () => { const fields: Field[] = [ { blocks: [ @@ -141,11 +149,13 @@ describe('sanitizeFields', () => { type: 'blocks', }, ] - const sanitizedField = sanitizeFields({ - config, - fields, - validRelationships: [], - })[0] as BlockField + const sanitizedField = ( + await sanitizeFields({ + config, + fields, + validRelationships: [], + }) + )[0] as BlockField expect(sanitizedField.name).toStrictEqual('noLabelBlock') expect(sanitizedField.label).toStrictEqual(false) expect(sanitizedField.type).toStrictEqual('blocks') @@ -153,7 +163,7 @@ describe('sanitizeFields', () => { }) }) - it('should label arrays with plural and singular', () => { + it('should label arrays with plural and singular', async () => { const fields: Field[] = [ { fields: [ @@ -166,18 +176,20 @@ describe('sanitizeFields', () => { type: 'array', }, ] - const sanitizedField = sanitizeFields({ - config, - fields, - validRelationships: [], - })[0] as ArrayField + const sanitizedField = ( + await sanitizeFields({ + config, + fields, + validRelationships: [], + }) + )[0] as ArrayField expect(sanitizedField.name).toStrictEqual('items') expect(sanitizedField.label).toStrictEqual('Items') expect(sanitizedField.type).toStrictEqual('array') expect(sanitizedField.labels).toMatchObject({ plural: 'Items', singular: 'Item' }) }) - it('should label blocks with plural and singular', () => { + it('should label blocks with plural and singular', async () => { const fields: Field[] = [ { blocks: [ @@ -190,11 +202,13 @@ describe('sanitizeFields', () => { type: 'blocks', }, ] - const sanitizedField = sanitizeFields({ - config, - fields, - validRelationships: [], - })[0] as BlockField + const sanitizedField = ( + await sanitizeFields({ + config, + fields, + validRelationships: [], + }) + )[0] as BlockField expect(sanitizedField.name).toStrictEqual('specialBlock') expect(sanitizedField.label).toStrictEqual('Special Block') expect(sanitizedField.type).toStrictEqual('blocks') @@ -207,7 +221,7 @@ describe('sanitizeFields', () => { }) describe('relationships', () => { - it('should not throw on valid relationship', () => { + it('should not throw on valid relationship', async () => { const validRelationships = ['some-collection'] const fields: Field[] = [ { @@ -217,12 +231,12 @@ describe('sanitizeFields', () => { type: 'relationship', }, ] - expect(() => { - sanitizeFields({ config, fields, validRelationships }) + await expect(async () => { + await sanitizeFields({ config, fields, validRelationships }) }).not.toThrow() }) - it('should not throw on valid relationship - multiple', () => { + it('should not throw on valid relationship - multiple', async () => { const validRelationships = ['some-collection', 'another-collection'] const fields: Field[] = [ { @@ -232,12 +246,12 @@ describe('sanitizeFields', () => { type: 'relationship', }, ] - expect(() => { - sanitizeFields({ config, fields, validRelationships }) + await expect(async () => { + await sanitizeFields({ config, fields, validRelationships }) }).not.toThrow() }) - it('should not throw on valid relationship inside blocks', () => { + it('should not throw on valid relationship inside blocks', async () => { const validRelationships = ['some-collection'] const relationshipBlock: Block = { fields: [ @@ -258,12 +272,12 @@ describe('sanitizeFields', () => { type: 'blocks', }, ] - expect(() => { - sanitizeFields({ config, fields, validRelationships }) + await expect(async () => { + await sanitizeFields({ config, fields, validRelationships }) }).not.toThrow() }) - it('should throw on invalid relationship', () => { + it('should throw on invalid relationship', async () => { const validRelationships = ['some-collection'] const fields: Field[] = [ { @@ -273,12 +287,12 @@ describe('sanitizeFields', () => { type: 'relationship', }, ] - expect(() => { - sanitizeFields({ config, fields, validRelationships }) - }).toThrow(InvalidFieldRelationship) + await expect(async () => { + await sanitizeFields({ config, fields, validRelationships }) + }).rejects.toThrow(InvalidFieldRelationship) }) - it('should throw on invalid relationship - multiple', () => { + it('should throw on invalid relationship - multiple', async () => { const validRelationships = ['some-collection', 'another-collection'] const fields: Field[] = [ { @@ -288,12 +302,12 @@ describe('sanitizeFields', () => { type: 'relationship', }, ] - expect(() => { - sanitizeFields({ config, fields, validRelationships }) - }).toThrow(InvalidFieldRelationship) + await expect(async () => { + await sanitizeFields({ config, fields, validRelationships }) + }).rejects.toThrow(InvalidFieldRelationship) }) - it('should throw on invalid relationship inside blocks', () => { + it('should throw on invalid relationship inside blocks', async () => { const validRelationships = ['some-collection'] const relationshipBlock: Block = { fields: [ @@ -314,12 +328,12 @@ describe('sanitizeFields', () => { type: 'blocks', }, ] - expect(() => { - sanitizeFields({ config, fields, validRelationships }) - }).toThrow(InvalidFieldRelationship) + await expect(async () => { + await sanitizeFields({ config, fields, validRelationships }) + }).rejects.toThrow(InvalidFieldRelationship) }) - it('should defaultValue of checkbox to false if required and undefined', () => { + it('should defaultValue of checkbox to false if required and undefined', async () => { const fields: Field[] = [ { name: 'My Checkbox', @@ -328,16 +342,18 @@ describe('sanitizeFields', () => { }, ] - const sanitizedField = sanitizeFields({ - config, - fields, - validRelationships: [], - })[0] as CheckboxField + const sanitizedField = ( + await sanitizeFields({ + config, + fields, + validRelationships: [], + }) + )[0] as CheckboxField expect(sanitizedField.defaultValue).toStrictEqual(false) }) - it('should return empty field array if no fields', () => { - const sanitizedFields = sanitizeFields({ + it('should return empty field array if no fields', async () => { + const sanitizedFields = await sanitizeFields({ config, fields: [], validRelationships: [], diff --git a/packages/payload/src/fields/config/sanitize.ts b/packages/payload/src/fields/config/sanitize.ts index 5806cfa8d..3949d37ed 100644 --- a/packages/payload/src/fields/config/sanitize.ts +++ b/packages/payload/src/fields/config/sanitize.ts @@ -1,4 +1,4 @@ -import type { Config } from '../../config/types.js' +import type { Config, SanitizedConfig } from '../../config/types.js' import type { Field } from './types.js' import { MissingEditorProp } from '../../errors/MissingEditorProp.js' @@ -25,6 +25,12 @@ type Args = { * @default false */ requireFieldLevelRichTextEditor?: boolean + /** + * If this property is set, RichText fields won't be sanitized immediately. Instead, they will be added to this array as promises + * so that you can sanitize them together, after the config has been sanitized. + */ + richTextSanitizationPromises?: Array<(config: SanitizedConfig) => Promise> + /** * If not null, will validate that upload and relationship fields do not relate to a collection that is not in this array. * This validation will be skipped if validRelationships is null. @@ -32,17 +38,18 @@ type Args = { validRelationships: null | string[] } -export const sanitizeFields = ({ +export const sanitizeFields = async ({ config, existingFieldNames = new Set(), fields, requireFieldLevelRichTextEditor = false, + richTextSanitizationPromises, validRelationships, -}: Args): Field[] => { +}: Args): Promise => { if (!fields) return [] - return fields.map((unsanitizedField) => { - const field: Field = { ...unsanitizedField } + for (let i = 0; i < fields.length; i++) { + const field = fields[i] if (!field.type) throw new MissingFieldType(field) @@ -143,88 +150,95 @@ export const sanitizeFields = ({ // Make sure that the richText field has an editor if (field.type === 'richText') { - if (!field.editor) { - if (config.editor && !requireFieldLevelRichTextEditor) { - field.editor = config.editor - } else { - throw new MissingEditorProp(field) + const sanitizeRichText = async (_config: SanitizedConfig) => { + if (!field.editor) { + if (_config.editor && !requireFieldLevelRichTextEditor) { + // config.editor should be sanitized at this point + field.editor = _config.editor + } else { + throw new MissingEditorProp(field) + } } - } - // Add editor adapter hooks to field hooks - if (!field.hooks) field.hooks = {} + if (typeof field.editor === 'function') { + field.editor = await field.editor({ config: _config }) + } - if (field?.editor?.hooks?.afterRead?.length) { - field.hooks.afterRead = field.hooks.afterRead - ? field.hooks.afterRead.concat(field.editor.hooks.afterRead) - : field.editor.hooks.afterRead + // Add editor adapter hooks to field hooks + if (!field.hooks) field.hooks = {} + + const mergeHooks = (hookName: keyof typeof field.editor.hooks) => { + if (typeof field.editor === 'function') return + + if (field.editor?.hooks?.[hookName]?.length) { + field.hooks[hookName] = field.hooks[hookName] + ? field.hooks[hookName].concat(field.editor.hooks[hookName]) + : [...field.editor.hooks[hookName]] + } + } + + mergeHooks('afterRead') + mergeHooks('afterChange') + mergeHooks('beforeChange') + mergeHooks('beforeValidate') + mergeHooks('beforeDuplicate') } - if (field?.editor?.hooks?.beforeChange?.length) { - field.hooks.beforeChange = field.hooks.beforeChange - ? field.hooks.beforeChange.concat(field.editor.hooks.beforeChange) - : field.editor.hooks.beforeChange - } - if (field?.editor?.hooks?.beforeValidate?.length) { - field.hooks.beforeValidate = field.hooks.beforeValidate - ? field.hooks.beforeValidate.concat(field.editor.hooks.beforeValidate) - : field.editor.hooks.beforeValidate - } - if (field?.editor?.hooks?.beforeChange?.length) { - field.hooks.beforeChange = field.hooks.beforeChange - ? field.hooks.beforeChange.concat(field.editor.hooks.beforeChange) - : field.editor.hooks.beforeChange + if (richTextSanitizationPromises) { + richTextSanitizationPromises.push(sanitizeRichText) + } else { + await sanitizeRichText(config as unknown as SanitizedConfig) } } - // TODO: Handle sanitization for any lexical sub-fields here as well if ('fields' in field && field.fields) { - field.fields = sanitizeFields({ + field.fields = await sanitizeFields({ config, existingFieldNames: fieldAffectsData(field) ? new Set() : existingFieldNames, fields: field.fields, requireFieldLevelRichTextEditor, + richTextSanitizationPromises, validRelationships, }) } if (field.type === 'tabs') { - field.tabs = field.tabs.map((tab) => { - const unsanitizedTab = { ...tab } + for (let j = 0; j < field.tabs.length; j++) { + const tab = field.tabs[j] if (tabHasName(tab) && typeof tab.label === 'undefined') { - unsanitizedTab.label = toWords(tab.name) + tab.label = toWords(tab.name) } - unsanitizedTab.fields = sanitizeFields({ + tab.fields = await sanitizeFields({ config, existingFieldNames: tabHasName(tab) ? new Set() : existingFieldNames, fields: tab.fields, requireFieldLevelRichTextEditor, + richTextSanitizationPromises, validRelationships, }) - - return unsanitizedTab - }) + field.tabs[j] = tab + } } if ('blocks' in field && field.blocks) { - field.blocks = field.blocks.map((block) => { - const unsanitizedBlock = { ...block } - unsanitizedBlock.labels = !unsanitizedBlock.labels - ? formatLabels(unsanitizedBlock.slug) - : unsanitizedBlock.labels + for (let j = 0; j < field.blocks.length; j++) { + const block = field.blocks[j] + block.labels = !block.labels ? formatLabels(block.slug) : block.labels - unsanitizedBlock.fields = sanitizeFields({ + block.fields = await sanitizeFields({ config, existingFieldNames: new Set(), fields: block.fields, requireFieldLevelRichTextEditor, + richTextSanitizationPromises, validRelationships, }) - - return unsanitizedBlock - }) + field.blocks[j] = block + } } - return field - }) + fields[i] = field + } + + return fields } diff --git a/packages/payload/src/fields/config/types.ts b/packages/payload/src/fields/config/types.ts index c1ef6ee82..46570cab9 100644 --- a/packages/payload/src/fields/config/types.ts +++ b/packages/payload/src/fields/config/types.ts @@ -7,12 +7,12 @@ import type { CSSProperties } from 'react' import monacoeditor from 'monaco-editor' // IMPORTANT - DO NOT REMOVE: This is required for pnpm's default isolated mode to work - even though the import is not used. This is due to a typescript bug: https://github.com/microsoft/TypeScript/issues/47663#issuecomment-1519138189. (tsbugisolatedmode) import type React from 'react' +import type { RichTextAdapter, RichTextAdapterProvider } from '../../admin/RichText.js' import type { ConditionalDateProps, Description, ErrorProps, LabelProps, - RichTextAdapter, RowLabel, } from '../../admin/types.js' import type { SanitizedCollectionConfig, TypeWithID } from '../../collections/config/types.js' @@ -585,7 +585,9 @@ export type RichTextField< Label?: CustomComponent } } - editor?: RichTextAdapter + editor?: + | RichTextAdapter + | RichTextAdapterProvider type: 'richText' } & ExtraProperties @@ -594,7 +596,9 @@ export type RichTextFieldRequiredEditor< AdapterProps = any, ExtraProperties = object, > = Omit, 'editor'> & { - editor: RichTextAdapter + editor: + | RichTextAdapter + | RichTextAdapterProvider } export type ArrayField = FieldBase & { diff --git a/packages/payload/src/fields/hooks/afterRead/promise.ts b/packages/payload/src/fields/hooks/afterRead/promise.ts index 581eb7224..5d63dd258 100644 --- a/packages/payload/src/fields/hooks/afterRead/promise.ts +++ b/packages/payload/src/fields/hooks/afterRead/promise.ts @@ -141,6 +141,10 @@ export const promise = async ({ } case 'richText': { + if (typeof field?.editor === 'function') { + throw new Error('Attempted to access unsanitized rich text editor.') + } + const editor: RichTextAdapter = field?.editor // This is run here AND in the GraphQL Resolver if (editor?.populationPromises) { diff --git a/packages/payload/src/fields/validations.ts b/packages/payload/src/fields/validations.ts index 9c6cc36f0..55d9f2a90 100644 --- a/packages/payload/src/fields/validations.ts +++ b/packages/payload/src/fields/validations.ts @@ -264,6 +264,10 @@ export const richText: Validate = async value, options, ) => { + if (typeof options?.editor === 'function') { + throw new Error('Attempted to access unsanitized rich text editor.') + } + const editor: RichTextAdapter = options?.editor return editor.validate(value, options) diff --git a/packages/payload/src/globals/config/sanitize.ts b/packages/payload/src/globals/config/sanitize.ts index 463e971d8..73e9ade5e 100644 --- a/packages/payload/src/globals/config/sanitize.ts +++ b/packages/payload/src/globals/config/sanitize.ts @@ -1,4 +1,4 @@ -import type { Config } from '../../config/types.js' +import type { Config, SanitizedConfig } from '../../config/types.js' import type { SanitizedGlobalConfig } from './types.js' import defaultAccess from '../../auth/defaultAccess.js' @@ -8,60 +8,66 @@ import mergeBaseFields from '../../fields/mergeBaseFields.js' import { toWords } from '../../utilities/formatLabels.js' import baseVersionFields from '../../versions/baseFields.js' -const sanitizeGlobals = (config: Config): SanitizedGlobalConfig[] => { +export const sanitizeGlobals = async ( + config: Config, + /** + * If this property is set, RichText fields won't be sanitized immediately. Instead, they will be added to this array as promises + * so that you can sanitize them together, after the config has been sanitized. + */ + richTextSanitizationPromises?: Array<(config: SanitizedConfig) => Promise>, +): Promise => { const { collections, globals } = config - const sanitizedGlobals = globals.map((global) => { - const sanitizedGlobal = { ...global } - - sanitizedGlobal.label = sanitizedGlobal.label || toWords(sanitizedGlobal.slug) + for (let i = 0; i < globals.length; i++) { + const global = globals[i] + global.label = global.label || toWords(global.slug) // ///////////////////////////////// // Ensure that collection has required object structure // ///////////////////////////////// - sanitizedGlobal.endpoints = sanitizedGlobal.endpoints ?? [] - if (!sanitizedGlobal.hooks) sanitizedGlobal.hooks = {} - if (!sanitizedGlobal.access) sanitizedGlobal.access = {} - if (!sanitizedGlobal.admin) sanitizedGlobal.admin = {} + global.endpoints = global.endpoints ?? [] + if (!global.hooks) global.hooks = {} + if (!global.access) global.access = {} + if (!global.admin) global.admin = {} - if (!sanitizedGlobal.access.read) sanitizedGlobal.access.read = defaultAccess - if (!sanitizedGlobal.access.update) sanitizedGlobal.access.update = defaultAccess + if (!global.access.read) global.access.read = defaultAccess + if (!global.access.update) global.access.update = defaultAccess - if (!sanitizedGlobal.hooks.beforeValidate) sanitizedGlobal.hooks.beforeValidate = [] - if (!sanitizedGlobal.hooks.beforeChange) sanitizedGlobal.hooks.beforeChange = [] - if (!sanitizedGlobal.hooks.afterChange) sanitizedGlobal.hooks.afterChange = [] - if (!sanitizedGlobal.hooks.beforeRead) sanitizedGlobal.hooks.beforeRead = [] - if (!sanitizedGlobal.hooks.afterRead) sanitizedGlobal.hooks.afterRead = [] + if (!global.hooks.beforeValidate) global.hooks.beforeValidate = [] + if (!global.hooks.beforeChange) global.hooks.beforeChange = [] + if (!global.hooks.afterChange) global.hooks.afterChange = [] + if (!global.hooks.beforeRead) global.hooks.beforeRead = [] + if (!global.hooks.afterRead) global.hooks.afterRead = [] - if (sanitizedGlobal.versions) { - if (sanitizedGlobal.versions === true) sanitizedGlobal.versions = { drafts: false } + if (global.versions) { + if (global.versions === true) global.versions = { drafts: false } - if (sanitizedGlobal.versions.drafts) { - if (sanitizedGlobal.versions.drafts === true) { - sanitizedGlobal.versions.drafts = { + if (global.versions.drafts) { + if (global.versions.drafts === true) { + global.versions.drafts = { autosave: false, } } - if (sanitizedGlobal.versions.drafts.autosave === true) { - sanitizedGlobal.versions.drafts.autosave = { + if (global.versions.drafts.autosave === true) { + global.versions.drafts.autosave = { interval: 2000, } } - sanitizedGlobal.fields = mergeBaseFields(sanitizedGlobal.fields, baseVersionFields) + global.fields = mergeBaseFields(global.fields, baseVersionFields) } } - if (!sanitizedGlobal.custom) sanitizedGlobal.custom = {} + if (!global.custom) global.custom = {} // ///////////////////////////////// // Sanitize fields // ///////////////////////////////// let hasUpdatedAt = null let hasCreatedAt = null - sanitizedGlobal.fields.some((field) => { + global.fields.some((field) => { if (fieldAffectsData(field)) { if (field.name === 'updatedAt') hasUpdatedAt = true if (field.name === 'createdAt') hasCreatedAt = true @@ -69,7 +75,7 @@ const sanitizeGlobals = (config: Config): SanitizedGlobalConfig[] => { return hasCreatedAt && hasUpdatedAt }) if (!hasUpdatedAt) { - sanitizedGlobal.fields.push({ + global.fields.push({ name: 'updatedAt', type: 'date', admin: { @@ -80,7 +86,7 @@ const sanitizeGlobals = (config: Config): SanitizedGlobalConfig[] => { }) } if (!hasCreatedAt) { - sanitizedGlobal.fields.push({ + global.fields.push({ name: 'createdAt', type: 'date', admin: { @@ -92,16 +98,15 @@ const sanitizeGlobals = (config: Config): SanitizedGlobalConfig[] => { } const validRelationships = collections.map((c) => c.slug) || [] - sanitizedGlobal.fields = sanitizeFields({ + global.fields = await sanitizeFields({ config, - fields: sanitizedGlobal.fields, + fields: global.fields, + richTextSanitizationPromises, validRelationships, }) - return sanitizedGlobal as SanitizedGlobalConfig - }) + globals[i] = global + } - return sanitizedGlobals + return globals as SanitizedGlobalConfig[] } - -export default sanitizeGlobals diff --git a/packages/payload/src/utilities/configToJSONSchema.spec.ts b/packages/payload/src/utilities/configToJSONSchema.spec.ts index 6408c094f..6808fb1e1 100644 --- a/packages/payload/src/utilities/configToJSONSchema.spec.ts +++ b/packages/payload/src/utilities/configToJSONSchema.spec.ts @@ -4,7 +4,7 @@ import { sanitizeConfig } from '../config/sanitize.js' import { configToJSONSchema } from './configToJSONSchema.js' describe('configToJSONSchema', () => { - it('should handle optional arrays with required fields', () => { + it('should handle optional arrays with required fields', async () => { const config: Config = { collections: [ { @@ -27,7 +27,7 @@ describe('configToJSONSchema', () => { ], } - const sanitizedConfig = sanitizeConfig(config) + const sanitizedConfig = await sanitizeConfig(config) const schema = configToJSONSchema(sanitizedConfig, 'text') expect(schema?.definitions?.test).toStrictEqual({ diff --git a/packages/payload/src/utilities/configToJSONSchema.ts b/packages/payload/src/utilities/configToJSONSchema.ts index 81c52ed5d..7b07bdce1 100644 --- a/packages/payload/src/utilities/configToJSONSchema.ts +++ b/packages/payload/src/utilities/configToJSONSchema.ts @@ -195,6 +195,9 @@ export function fieldsToJSONSchema( } case 'richText': { + if (typeof field.editor === 'function') { + throw new Error('Attempted to access unsanitized rich text editor.') + } if (field.editor.outputSchema) { fieldSchema = field.editor.outputSchema({ collectionIDFieldTypes, diff --git a/packages/richtext-lexical/src/field/features/blocks/feature.server.ts b/packages/richtext-lexical/src/field/features/blocks/feature.server.ts index 08d6aacfc..19ed5a2d4 100644 --- a/packages/richtext-lexical/src/field/features/blocks/feature.server.ts +++ b/packages/richtext-lexical/src/field/features/blocks/feature.server.ts @@ -1,19 +1,13 @@ +import type { Config } from 'payload/config' import type { Block, BlockField, Field, FieldWithRichTextRequiredEditor } from 'payload/types' import { traverseFields } from '@payloadcms/next/utilities' import { baseBlockFields, sanitizeFields } from 'payload/config' -import { - beforeChangeTraverseFields, - beforeValidateTraverseFields, - deepCopyObject, - fieldsToJSONSchema, - formatLabels, -} from 'payload/utilities' +import { fieldsToJSONSchema, formatLabels } from 'payload/utilities' import type { FeatureProviderProviderServer } from '../types.js' import type { BlocksFeatureClientProps } from './feature.client.js' -import { cloneDeep } from '../../lexical/utils/cloneDeep.js' import { createNode } from '../typeUtilities.js' import { BlocksFeatureClientComponent } from './feature.client.js' import { BlockNode } from './nodes/BlocksNode.js' @@ -32,39 +26,38 @@ export const BlocksFeature: FeatureProviderProviderServer< BlocksFeatureProps, BlocksFeatureClientProps > = (props) => { - // Sanitization taken from payload/src/fields/config/sanitize.ts - - if (props?.blocks?.length) { - props.blocks = props.blocks.map((block) => { - const blockCopy = cloneDeep(block) - - return { - ...blockCopy, - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - fields: blockCopy.fields.concat(baseBlockFields as FieldWithRichTextRequiredEditor[]), - labels: !blockCopy.labels ? formatLabels(blockCopy.slug) : blockCopy.labels, - } - }) - // unSanitizedBlock.fields are sanitized in the React component and not here. - // That's because we do not have access to the payload config here. - } - - // Build clientProps - const clientProps: BlocksFeatureClientProps = { - reducedBlocks: [], - } - for (const block of props.blocks) { - clientProps.reducedBlocks.push({ - slug: block.slug, - fieldMap: [], - imageAltText: block.imageAltText, - imageURL: block.imageURL, - labels: block.labels, - }) - } - return { - feature: () => { + feature: async ({ config: _config }) => { + if (props?.blocks?.length) { + const validRelationships = _config.collections.map((c) => c.slug) || [] + + for (const block of props.blocks) { + block.fields = block.fields.concat(baseBlockFields as FieldWithRichTextRequiredEditor[]) + block.labels = !block.labels ? formatLabels(block.slug) : block.labels + + block.fields = (await sanitizeFields({ + config: _config as unknown as Config, + fields: block.fields, + requireFieldLevelRichTextEditor: true, + validRelationships, + })) as FieldWithRichTextRequiredEditor[] + } + } + + // Build clientProps + const clientProps: BlocksFeatureClientProps = { + reducedBlocks: [], + } + for (const block of props.blocks) { + clientProps.reducedBlocks.push({ + slug: block.slug, + fieldMap: [], + imageAltText: block.imageAltText, + imageURL: block.imageURL, + labels: block.labels, + }) + } + return { ClientComponent: BlocksFeatureClientComponent, clientFeatureProps: clientProps, @@ -104,26 +97,10 @@ export const BlocksFeature: FeatureProviderProviderServer< return currentSchema } - // sanitize blocks - const validRelationships = config.collections.map((c) => c.slug) || [] - - const sanitizedBlocks = props.blocks.map((block) => { - const blockCopy = cloneDeep(block) - return { - ...blockCopy, - fields: sanitizeFields({ - config, - fields: blockCopy.fields, - requireFieldLevelRichTextEditor: true, - validRelationships, - }), - } - }) - const blocksField: BlockField = { name: field?.name + '_lexical_blocks', type: 'blocks', - blocks: sanitizedBlocks, + blocks: props.blocks, } // This is only done so that interfaceNameDefinitions sets those block's interfaceNames. // we don't actually use the JSON Schema itself in the generated types yet. @@ -149,19 +126,6 @@ export const BlocksFeature: FeatureProviderProviderServer< props.blocks.find((block) => block.slug === blockType), ) - // sanitize blocks - const validRelationships = req.payload.config.collections.map((c) => c.slug) || [] - - const sanitizedBlock = { - ...block, - fields: sanitizeFields({ - config: req.payload.config, - fields: block.fields, - requireFieldLevelRichTextEditor: true, - validRelationships, - }), - } - await beforeChangeTraverseFields({ id: null, @@ -193,18 +157,7 @@ export const BlocksFeature: FeatureProviderProviderServer< props.blocks.find((block) => block.slug === blockType), ) - // sanitize blocks - const validRelationships = req.payload.config.collections.map((c) => c.slug) || [] - const sanitizedBlock = { - ...block, - fields: sanitizeFields({ - config: req.payload.config, - fields: block.fields, - requireFieldLevelRichTextEditor: true, - validRelationships, - }), - } await beforeValidateTraverseFields({ id: null, diff --git a/packages/richtext-lexical/src/field/features/blocks/populationPromise.ts b/packages/richtext-lexical/src/field/features/blocks/populationPromise.ts index 49afef133..8927bc540 100644 --- a/packages/richtext-lexical/src/field/features/blocks/populationPromise.ts +++ b/packages/richtext-lexical/src/field/features/blocks/populationPromise.ts @@ -1,7 +1,3 @@ -import type { Block } from 'payload/types' - -import { sanitizeFields } from 'payload/config' - import type { PopulationPromise } from '../types.js' import type { BlocksFeatureProps } from './feature.server.js' import type { SerializedBlockNode } from './nodes/BlocksNode.js' @@ -26,21 +22,8 @@ export const blockPopulationPromiseHOC = ( showHiddenFields, siblingDoc, }) => { - const blocks: Block[] = props.blocks const blockFieldData = node.fields - // Sanitize block's fields here. This is done here and not in the feature, because the payload config is available here - const payloadConfig = req.payload.config - const validRelationships = payloadConfig.collections.map((c) => c.slug) || [] - blocks.forEach((block) => { - block.fields = sanitizeFields({ - config: payloadConfig, - fields: block.fields, - requireFieldLevelRichTextEditor: true, - validRelationships, - }) - }) - // find block used in this node const block = props.blocks.find((block) => block.slug === blockFieldData.blockType) if (!block || !block?.fields?.length || !blockFieldData) { diff --git a/packages/richtext-lexical/src/field/features/blocks/validate.ts b/packages/richtext-lexical/src/field/features/blocks/validate.ts index 6ea53cd99..5099ce5e0 100644 --- a/packages/richtext-lexical/src/field/features/blocks/validate.ts +++ b/packages/richtext-lexical/src/field/features/blocks/validate.ts @@ -1,7 +1,4 @@ -import type { Block } from 'payload/types' - import { buildStateFromSchema } from '@payloadcms/ui/forms/buildStateFromSchema' -import { sanitizeFields } from 'payload/config' import type { NodeValidation } from '../types.js' import type { BlocksFeatureProps } from './feature.server.js' @@ -12,31 +9,11 @@ export const blockValidationHOC = ( ): NodeValidation => { return async ({ node, validation }) => { const blockFieldData = node.fields - const blocks: Block[] = props.blocks const { - options: { - id, - operation, - preferences, - req, - req: { - payload: { config }, - }, - }, + options: { id, operation, preferences, req }, } = validation - // Sanitize block's fields here. This is done here and not in the feature, because the payload config is available here - const validRelationships = config.collections.map((c) => c.slug) || [] - blocks.forEach((block) => { - block.fields = sanitizeFields({ - config, - fields: block.fields, - requireFieldLevelRichTextEditor: true, - validRelationships, - }) - }) - // find block const block = props.blocks.find((block) => block.slug === blockFieldData.blockType) diff --git a/packages/richtext-lexical/src/field/features/link/drawer/baseFields.ts b/packages/richtext-lexical/src/field/features/link/drawer/baseFields.ts index 90a4f62c2..335abe4e2 100644 --- a/packages/richtext-lexical/src/field/features/link/drawer/baseFields.ts +++ b/packages/richtext-lexical/src/field/features/link/drawer/baseFields.ts @@ -1,14 +1,14 @@ import type { User } from 'payload/auth' -import type { Config } from 'payload/config' -import type { Field, RadioField, TextField } from 'payload/types' +import type { SanitizedConfig } from 'payload/config' +import type { Field, FieldWithRichTextRequiredEditor, RadioField, TextField } from 'payload/types' import { validateUrl } from '../../../lexical/utils/url.js' export const getBaseFields = ( - config: Config, + config: SanitizedConfig, enabledCollections: false | string[], disabledCollections: false | string[], -): Field[] => { +): FieldWithRichTextRequiredEditor[] => { let enabledRelations: string[] /** @@ -122,5 +122,5 @@ export const getBaseFields = ( label: ({ t }) => t('fields:openInNewTab'), }) - return baseFields as Field[] + return baseFields as FieldWithRichTextRequiredEditor[] } diff --git a/packages/richtext-lexical/src/field/features/link/feature.server.ts b/packages/richtext-lexical/src/field/features/link/feature.server.ts index fe7a96dd1..f35011a40 100644 --- a/packages/richtext-lexical/src/field/features/link/feature.server.ts +++ b/packages/richtext-lexical/src/field/features/link/feature.server.ts @@ -1,8 +1,8 @@ -import type { I18n } from '@payloadcms/translations' -import type { SanitizedConfig } from 'payload/config' +import type { Config, SanitizedConfig } from 'payload/config' import type { Field, FieldWithRichTextRequiredEditor } from 'payload/types' import { traverseFields } from '@payloadcms/next/utilities' +import { sanitizeFields } from 'payload/config' import { deepCopyObject } from 'payload/utilities' import type { FeatureProviderProviderServer } from '../types.js' @@ -47,7 +47,6 @@ export type LinkFeatureServerProps = ExclusiveLinkCollectionsProps & { | ((args: { config: SanitizedConfig defaultFields: FieldWithRichTextRequiredEditor[] - i18n: I18n }) => FieldWithRichTextRequiredEditor[]) | FieldWithRichTextRequiredEditor[] } @@ -59,27 +58,32 @@ export const LinkFeature: FeatureProviderProviderServer { + feature: async ({ config: _config }) => { + const validRelationships = _config.collections.map((c) => c.slug) || [] + + const _transformedFields = transformExtraFields( + deepCopyObject(props.fields), + _config, + props.enabledCollections, + props.disabledCollections, + ) + + const sanitizedFields = (await sanitizeFields({ + config: _config as unknown as Config, + fields: _transformedFields, + requireFieldLevelRichTextEditor: true, + validRelationships, + })) as FieldWithRichTextRequiredEditor[] + props.fields = sanitizedFields + return { ClientComponent: LinkFeatureClientComponent, clientFeatureProps: { disabledCollections: props.disabledCollections, enabledCollections: props.enabledCollections, } as ExclusiveLinkCollectionsProps, - generateSchemaMap: ({ config, i18n, props }) => { - const transformedFields = transformExtraFields( - deepCopyObject(props.fields), - config, - i18n, - props.enabledCollections, - props.disabledCollections, - ) - - if ( - !transformedFields || - !Array.isArray(transformedFields) || - transformedFields.length === 0 - ) { + generateSchemaMap: ({ config, i18n }) => { + if (!sanitizedFields || !Array.isArray(sanitizedFields) || sanitizedFields.length === 0) { return null } @@ -87,11 +91,11 @@ export const LinkFeature: FeatureProviderProviderServer c.slug) || [] - schemaMap.set('fields', transformedFields) + schemaMap.set('fields', sanitizedFields) traverseFields({ config, - fields: transformedFields, + fields: sanitizedFields, i18n, schemaMap, schemaPath: 'fields', diff --git a/packages/richtext-lexical/src/field/features/link/plugins/floatingLinkEditor/utilities.ts b/packages/richtext-lexical/src/field/features/link/plugins/floatingLinkEditor/utilities.ts index 67d5bffaf..1b1769916 100644 --- a/packages/richtext-lexical/src/field/features/link/plugins/floatingLinkEditor/utilities.ts +++ b/packages/richtext-lexical/src/field/features/link/plugins/floatingLinkEditor/utilities.ts @@ -1,6 +1,5 @@ -import type { I18n } from '@payloadcms/translations' import type { SanitizedConfig } from 'payload/config' -import type { Field, GroupField } from 'payload/types' +import type { Field, FieldWithRichTextRequiredEditor, GroupField } from 'payload/types' import { getBaseFields } from '../../drawer/baseFields.js' @@ -10,18 +9,24 @@ import { getBaseFields } from '../../drawer/baseFields.js' // eslint-disable-next-line @typescript-eslint/require-await export function transformExtraFields( customFieldSchema: - | ((args: { config: SanitizedConfig; defaultFields: Field[]; i18n: I18n }) => Field[]) - | Field[], + | ((args: { + config: SanitizedConfig + defaultFields: FieldWithRichTextRequiredEditor[] + }) => FieldWithRichTextRequiredEditor[]) + | FieldWithRichTextRequiredEditor[], config: SanitizedConfig, - i18n: I18n, enabledCollections?: false | string[], disabledCollections?: false | string[], ): Field[] { - const baseFields: Field[] = getBaseFields(config, enabledCollections, disabledCollections) + const baseFields: FieldWithRichTextRequiredEditor[] = getBaseFields( + config, + enabledCollections, + disabledCollections, + ) const fields = typeof customFieldSchema === 'function' - ? customFieldSchema({ config, defaultFields: baseFields, i18n }) + ? customFieldSchema({ config, defaultFields: baseFields }) : baseFields // Wrap fields which are not part of the base schema in a group named 'fields' - otherwise they will be rendered but not saved diff --git a/packages/richtext-lexical/src/field/features/link/populationPromise.ts b/packages/richtext-lexical/src/field/features/link/populationPromise.ts index 77466c19b..010f56e66 100644 --- a/packages/richtext-lexical/src/field/features/link/populationPromise.ts +++ b/packages/richtext-lexical/src/field/features/link/populationPromise.ts @@ -1,12 +1,8 @@ -import { sanitizeFields } from 'payload/config' -import { deepCopyObject } from 'payload/utilities' - import type { PopulationPromise } from '../types.js' import type { LinkFeatureServerProps } from './feature.server.js' import type { SerializedLinkNode } from './nodes/types.js' import { recurseNestedFields } from '../../../populate/recurseNestedFields.js' -import { transformExtraFields } from './plugins/floatingLinkEditor/utilities.js' export const linkPopulationPromiseHOC = ( props: LinkFeatureServerProps, @@ -25,34 +21,14 @@ export const linkPopulationPromiseHOC = ( req, showHiddenFields, }) => { - // Sanitize link's fields here. This is done here and not in the feature, because the payload config is available here - const payloadConfig = req.payload.config - const validRelationships = payloadConfig.collections.map((c) => c.slug) || [] - - const transformedFields = transformExtraFields( - deepCopyObject(props.fields), - payloadConfig, - req.i18n, - props.enabledCollections, - props.disabledCollections, - ) - - // TODO: Sanitize & transform ahead of time! On startup! - const sanitizedFields = sanitizeFields({ - config: payloadConfig, - fields: transformedFields, - requireFieldLevelRichTextEditor: true, - validRelationships, - }) - - if (!sanitizedFields?.length) { + if (!props.fields?.length) { return } /** * Should populate all fields, including the doc field (for internal links), as it's treated like a normal field */ - if (Array.isArray(sanitizedFields)) { + if (Array.isArray(props.fields)) { recurseNestedFields({ context, currentDepth, @@ -62,7 +38,7 @@ export const linkPopulationPromiseHOC = ( depth, editorPopulationPromises, fieldPromises, - fields: sanitizedFields, + fields: props.fields, findMany, flattenLocales, overrideAccess, diff --git a/packages/richtext-lexical/src/field/features/types.ts b/packages/richtext-lexical/src/field/features/types.ts index e223de8eb..46bb02e72 100644 --- a/packages/richtext-lexical/src/field/features/types.ts +++ b/packages/richtext-lexical/src/field/features/types.ts @@ -84,14 +84,20 @@ export type FeatureProviderServer = { /** Keys of soft-dependencies needed for this feature. The FeatureProviders dependencies are optional, but are considered as last-priority in the loading process */ dependenciesSoft?: string[] + /** + * This is being called during the payload sanitization process + */ feature: (props: { + config: SanitizedConfig /** unSanitizedEditorConfig.features, but mapped */ featureProviderMap: ServerFeatureProviderMap // other resolved features, which have been loaded before this one. All features declared in 'dependencies' should be available here resolvedFeatures: ResolvedServerFeatureMap // unSanitized EditorConfig, unSanitizedEditorConfig: ServerEditorConfig - }) => ServerFeature + }) => + | Promise> + | ServerFeature key: string /** Props which were passed into your feature will have to be passed here. This will allow them to be used / read in other places of the code, e.g. wherever you can use useEditorConfigContext */ serverFeatureProps: ServerFeatureProps diff --git a/packages/richtext-lexical/src/field/features/upload/feature.server.ts b/packages/richtext-lexical/src/field/features/upload/feature.server.ts index 7cce4f6e5..337c269eb 100644 --- a/packages/richtext-lexical/src/field/features/upload/feature.server.ts +++ b/packages/richtext-lexical/src/field/features/upload/feature.server.ts @@ -1,3 +1,4 @@ +import type { Config } from 'payload/config' import type { Field, FieldWithRichTextRequiredEditor, @@ -8,6 +9,7 @@ import type { } from 'payload/types' import { traverseFields } from '@payloadcms/next/utilities' +import { sanitizeFields } from 'payload/config' import type { FeatureProviderProviderServer } from '../types.js' import type { UploadFeaturePropsClient } from './feature.client.js' @@ -51,7 +53,20 @@ export const UploadFeature: FeatureProviderProviderServer< } return { - feature: () => { + feature: async ({ config: _config }) => { + const validRelationships = _config.collections.map((c) => c.slug) || [] + + for (const collection in props.collections) { + if (props.collections[collection].fields?.length) { + props.collections[collection].fields = (await sanitizeFields({ + config: _config as unknown as Config, + fields: props.collections[collection].fields, + requireFieldLevelRichTextEditor: true, + validRelationships, + })) as FieldWithRichTextRequiredEditor[] + } + } + return { ClientComponent: UploadFeatureClientComponent, clientFeatureProps: clientProps, diff --git a/packages/richtext-lexical/src/field/features/upload/populationPromise.ts b/packages/richtext-lexical/src/field/features/upload/populationPromise.ts index 05c89b20f..29e25c58e 100644 --- a/packages/richtext-lexical/src/field/features/upload/populationPromise.ts +++ b/packages/richtext-lexical/src/field/features/upload/populationPromise.ts @@ -1,5 +1,3 @@ -import { sanitizeFields } from 'payload/config' - import type { PopulationPromise } from '../types.js' import type { UploadFeatureProps } from './feature.server.js' import type { SerializedUploadNode } from './nodes/UploadNode.js' @@ -25,8 +23,6 @@ export const uploadPopulationPromiseHOC = ( req, showHiddenFields, }) => { - const payloadConfig = req.payload.config - if (node?.value) { const collection = req.payload.collections[node?.relationTo] @@ -50,17 +46,7 @@ export const uploadPopulationPromiseHOC = ( ) } if (Array.isArray(props?.collections?.[node?.relationTo]?.fields)) { - const validRelationships = payloadConfig.collections.map((c) => c.slug) || [] - - // TODO: Sanitize & transform ahead of time! On startup! - const sanitizedFields = sanitizeFields({ - config: payloadConfig, - fields: props?.collections?.[node?.relationTo]?.fields, - requireFieldLevelRichTextEditor: true, - validRelationships, - }) - - if (!sanitizedFields?.length) { + if (!props?.collections?.[node?.relationTo]?.fields?.length) { return } recurseNestedFields({ @@ -70,7 +56,7 @@ export const uploadPopulationPromiseHOC = ( depth, editorPopulationPromises, fieldPromises, - fields: sanitizedFields, + fields: props?.collections?.[node?.relationTo]?.fields, findMany, flattenLocales, overrideAccess, diff --git a/packages/richtext-lexical/src/field/lexical/config/server/default.ts b/packages/richtext-lexical/src/field/lexical/config/server/default.ts index 1055550e4..3d059088c 100644 --- a/packages/richtext-lexical/src/field/lexical/config/server/default.ts +++ b/packages/richtext-lexical/src/field/lexical/config/server/default.ts @@ -23,7 +23,6 @@ import { ParagraphFeature } from '../../../features/paragraph/feature.server.js' import { RelationshipFeature } from '../../../features/relationship/feature.server.js' import { UploadFeature } from '../../../features/upload/feature.server.js' import { LexicalEditorTheme } from '../../theme/EditorTheme.js' -import { sanitizeServerEditorConfig } from './sanitize.js' export const defaultEditorLexicalConfig: LexicalEditorConfig = { namespace: 'lexical', @@ -56,6 +55,3 @@ export const defaultEditorConfig: ServerEditorConfig = { features: defaultEditorFeatures, lexical: defaultEditorLexicalConfig, } - -export const defaultSanitizedServerEditorConfig: SanitizedServerEditorConfig = - sanitizeServerEditorConfig(defaultEditorConfig) diff --git a/packages/richtext-lexical/src/field/lexical/config/server/loader.ts b/packages/richtext-lexical/src/field/lexical/config/server/loader.ts index 84a3c0543..66e575fd2 100644 --- a/packages/richtext-lexical/src/field/lexical/config/server/loader.ts +++ b/packages/richtext-lexical/src/field/lexical/config/server/loader.ts @@ -1,3 +1,5 @@ +import type { Config, SanitizedConfig } from 'payload/config' + import type { FeatureProviderServer, ResolvedServerFeatureMap, @@ -103,11 +105,13 @@ export function sortFeaturesForOptimalLoading( return topologicallySortFeatures(featureProviders) } -export function loadFeatures({ +export async function loadFeatures({ + config, unSanitizedEditorConfig, }: { + config: SanitizedConfig unSanitizedEditorConfig: ServerEditorConfig -}): ResolvedServerFeatureMap { +}): Promise { // First remove all duplicate features. The LAST feature with a given key wins. unSanitizedEditorConfig.features = unSanitizedEditorConfig.features .reverse() @@ -167,7 +171,8 @@ export function loadFeatures({ } } - const feature = featureProvider.feature({ + const feature = await featureProvider.feature({ + config, featureProviderMap, resolvedFeatures, unSanitizedEditorConfig, diff --git a/packages/richtext-lexical/src/field/lexical/config/server/sanitize.ts b/packages/richtext-lexical/src/field/lexical/config/server/sanitize.ts index 68a57a497..272aec86e 100644 --- a/packages/richtext-lexical/src/field/lexical/config/server/sanitize.ts +++ b/packages/richtext-lexical/src/field/lexical/config/server/sanitize.ts @@ -1,3 +1,5 @@ +import type { Config, SanitizedConfig } from 'payload/config' + import type { ResolvedServerFeatureMap, SanitizedServerFeatures } from '../../../features/types.js' import type { SanitizedServerEditorConfig, ServerEditorConfig } from '../types.js' @@ -81,10 +83,12 @@ export const sanitizeServerFeatures = ( return sanitized } -export function sanitizeServerEditorConfig( +export async function sanitizeServerEditorConfig( editorConfig: ServerEditorConfig, -): SanitizedServerEditorConfig { - const resolvedFeatureMap = loadFeatures({ + config: SanitizedConfig, +): Promise { + const resolvedFeatureMap = await loadFeatures({ + config, unSanitizedEditorConfig: editorConfig, }) diff --git a/packages/richtext-lexical/src/generateComponentMap.tsx b/packages/richtext-lexical/src/generateComponentMap.tsx index 8a0bb518a..565b51a7a 100644 --- a/packages/richtext-lexical/src/generateComponentMap.tsx +++ b/packages/richtext-lexical/src/generateComponentMap.tsx @@ -1,21 +1,16 @@ import type { RichTextAdapter } from 'payload/types' import { mapFields } from '@payloadcms/ui/utilities/buildComponentMap' -import { sanitizeFields } from 'payload/config' import React from 'react' import type { ResolvedServerFeatureMap } from './field/features/types.js' import type { GeneratedFeatureProviderComponent } from './types.js' -import { cloneDeep } from './field/lexical/utils/cloneDeep.js' - export const getGenerateComponentMap = (args: { resolvedFeatureMap: ResolvedServerFeatureMap }): RichTextAdapter['generateComponentMap'] => ({ WithServerSideProps, config, i18n, schemaPath }) => { - const validRelationships = config.collections.map((c) => c.slug) || [] - const componentMap = new Map() // turn args.resolvedFeatureMap into an array of [key, value] pairs, ordered by value.order, lowest order first: @@ -78,18 +73,11 @@ export const getGenerateComponentMap = if (schemas) { for (const [schemaKey, fields] of schemas.entries()) { - const sanitizedFields = sanitizeFields({ - config, - fields: cloneDeep(fields), - requireFieldLevelRichTextEditor: true, - validRelationships, - }) - const mappedFields = mapFields({ WithServerSideProps, config, disableAddingID: true, - fieldSchema: sanitizedFields, + fieldSchema: fields, i18n, parentPath: `${schemaPath}.feature.${featureKey}.fields.${schemaKey}`, readOnly: false, diff --git a/packages/richtext-lexical/src/generateSchemaMap.ts b/packages/richtext-lexical/src/generateSchemaMap.ts index ab09c389e..f0bcd6dbc 100644 --- a/packages/richtext-lexical/src/generateSchemaMap.ts +++ b/packages/richtext-lexical/src/generateSchemaMap.ts @@ -1,16 +1,10 @@ import type { RichTextAdapter } from 'payload/types' -import { sanitizeFields } from 'payload/config' - import type { ResolvedServerFeatureMap } from './field/features/types.js' -import { cloneDeep } from './field/lexical/utils/cloneDeep.js' - export const getGenerateSchemaMap = (args: { resolvedFeatureMap: ResolvedServerFeatureMap }): RichTextAdapter['generateSchemaMap'] => ({ config, i18n, schemaMap, schemaPath }) => { - const validRelationships = config.collections.map((c) => c.slug) || [] - for (const [featureKey, resolvedFeature] of args.resolvedFeatureMap.entries()) { if ( !('generateSchemaMap' in resolvedFeature) || @@ -28,14 +22,7 @@ export const getGenerateSchemaMap = if (schemas) { for (const [schemaKey, fields] of schemas.entries()) { - const sanitizedFields = sanitizeFields({ - config, - fields: cloneDeep(fields), - requireFieldLevelRichTextEditor: true, - validRelationships, - }) - - schemaMap.set(`${schemaPath}.feature.${featureKey}.${schemaKey}`, sanitizedFields) + schemaMap.set(`${schemaPath}.feature.${featureKey}.${schemaKey}`, fields) } } } diff --git a/packages/richtext-lexical/src/index.ts b/packages/richtext-lexical/src/index.ts index d97485e30..ef132f885 100644 --- a/packages/richtext-lexical/src/index.ts +++ b/packages/richtext-lexical/src/index.ts @@ -6,282 +6,297 @@ import { withNullableJSONSchemaType } from 'payload/utilities' import type { FeatureProviderServer, ResolvedServerFeatureMap } from './field/features/types.js' import type { SanitizedServerEditorConfig } from './field/lexical/config/types.js' -import type { AdapterProps, LexicalEditorProps, LexicalRichTextAdapter } from './types.js' +import type { AdapterProps, LexicalEditorProps, LexicalRichTextAdapterProvider } from './types.js' import { RichTextCell } from './cell/index.js' import { RichTextField } from './field/index.js' import { defaultEditorConfig, defaultEditorFeatures, - defaultSanitizedServerEditorConfig, } from './field/lexical/config/server/default.js' import { loadFeatures } from './field/lexical/config/server/loader.js' -import { sanitizeServerFeatures } from './field/lexical/config/server/sanitize.js' +import { + sanitizeServerEditorConfig, + sanitizeServerFeatures, +} from './field/lexical/config/server/sanitize.js' import { cloneDeep } from './field/lexical/utils/cloneDeep.js' import { getGenerateComponentMap } from './generateComponentMap.js' import { getGenerateSchemaMap } from './generateSchemaMap.js' import { populateLexicalPopulationPromises } from './populate/populateLexicalPopulationPromises.js' import { richTextValidateHOC } from './validate/index.js' -export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapter { - let resolvedFeatureMap: ResolvedServerFeatureMap = null +let defaultSanitizedServerEditorConfig: SanitizedServerEditorConfig = null - let finalSanitizedEditorConfig: SanitizedServerEditorConfig // For server only - if (!props || (!props.features && !props.lexical)) { - finalSanitizedEditorConfig = cloneDeep(defaultSanitizedServerEditorConfig) - resolvedFeatureMap = finalSanitizedEditorConfig.resolvedFeatureMap - } else { - let features: FeatureProviderServer[] = - props.features && typeof props.features === 'function' - ? props.features({ defaultFeatures: cloneDeep(defaultEditorFeatures) }) - : (props.features as FeatureProviderServer[]) - if (!features) { - features = cloneDeep(defaultEditorFeatures) - } +export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapterProvider { + return async ({ config }) => { + let resolvedFeatureMap: ResolvedServerFeatureMap = null - const lexical: LexicalEditorConfig = props.lexical + let finalSanitizedEditorConfig: SanitizedServerEditorConfig // For server only + if (!props || (!props.features && !props.lexical)) { + if (!defaultSanitizedServerEditorConfig) { + defaultSanitizedServerEditorConfig = await sanitizeServerEditorConfig( + defaultEditorConfig, + config, + ) + } - resolvedFeatureMap = loadFeatures({ - unSanitizedEditorConfig: { - features, + finalSanitizedEditorConfig = cloneDeep(defaultSanitizedServerEditorConfig) + + resolvedFeatureMap = finalSanitizedEditorConfig.resolvedFeatureMap + } else { + let features: FeatureProviderServer[] = + props.features && typeof props.features === 'function' + ? props.features({ defaultFeatures: cloneDeep(defaultEditorFeatures) }) + : (props.features as FeatureProviderServer[]) + if (!features) { + features = cloneDeep(defaultEditorFeatures) + } + + const lexical: LexicalEditorConfig = props.lexical + + resolvedFeatureMap = await loadFeatures({ + config, + unSanitizedEditorConfig: { + features, + lexical: lexical ? lexical : defaultEditorConfig.lexical, + }, + }) + + finalSanitizedEditorConfig = { + features: sanitizeServerFeatures(resolvedFeatureMap), lexical: lexical ? lexical : defaultEditorConfig.lexical, - }, - }) - - finalSanitizedEditorConfig = { - features: sanitizeServerFeatures(resolvedFeatureMap), - lexical: lexical ? lexical : defaultEditorConfig.lexical, - resolvedFeatureMap, + resolvedFeatureMap, + } } - } - return { - CellComponent: withMergedProps({ - Component: RichTextCell, - toMergeIntoProps: { lexicalEditorConfig: finalSanitizedEditorConfig.lexical }, - }), - FieldComponent: withMergedProps({ - Component: RichTextField, - toMergeIntoProps: { lexicalEditorConfig: finalSanitizedEditorConfig.lexical }, - }), - editorConfig: finalSanitizedEditorConfig, - generateComponentMap: getGenerateComponentMap({ - resolvedFeatureMap, - }), - generateSchemaMap: getGenerateSchemaMap({ - resolvedFeatureMap, - }), - /* hooks: { - afterChange: finalSanitizedEditorConfig.features.hooks.afterChange, - afterRead: finalSanitizedEditorConfig.features.hooks.afterRead, - beforeChange: finalSanitizedEditorConfig.features.hooks.beforeChange, - beforeDuplicate: finalSanitizedEditorConfig.features.hooks.beforeDuplicate, - beforeValidate: finalSanitizedEditorConfig.features.hooks.beforeValidate, - },*/ - /* // TODO: Figure out docWithLocales / originalSiblingDoc => node matching. Can't use indexes, as the order of nodes could technically change between hooks. - hooks: { - afterChange: [ - async ({ context, findMany, operation, overrideAccess, req, value }) => { - await recurseNodesAsync({ - callback: async (node) => { - const afterChangeHooks = finalSanitizedEditorConfig.features.hooks.afterChange - if (afterChangeHooks?.has(node.type)) { - for (const hook of afterChangeHooks.get(node.type)) { - node = await hook({ context, findMany, node, operation, overrideAccess, req }) + return { + CellComponent: withMergedProps({ + Component: RichTextCell, + toMergeIntoProps: { lexicalEditorConfig: finalSanitizedEditorConfig.lexical }, + }), + FieldComponent: withMergedProps({ + Component: RichTextField, + toMergeIntoProps: { lexicalEditorConfig: finalSanitizedEditorConfig.lexical }, + }), + editorConfig: finalSanitizedEditorConfig, + generateComponentMap: getGenerateComponentMap({ + resolvedFeatureMap, + }), + generateSchemaMap: getGenerateSchemaMap({ + resolvedFeatureMap, + }), + /* hooks: { + afterChange: finalSanitizedEditorConfig.features.hooks.afterChange, + afterRead: finalSanitizedEditorConfig.features.hooks.afterRead, + beforeChange: finalSanitizedEditorConfig.features.hooks.beforeChange, + beforeDuplicate: finalSanitizedEditorConfig.features.hooks.beforeDuplicate, + beforeValidate: finalSanitizedEditorConfig.features.hooks.beforeValidate, + },*/ + /* // TODO: Figure out docWithLocales / originalSiblingDoc => node matching. Can't use indexes, as the order of nodes could technically change between hooks. + hooks: { + afterChange: [ + async ({ context, findMany, operation, overrideAccess, req, value }) => { + await recurseNodesAsync({ + callback: async (node) => { + const afterChangeHooks = finalSanitizedEditorConfig.features.hooks.afterChange + if (afterChangeHooks?.has(node.type)) { + for (const hook of afterChangeHooks.get(node.type)) { + node = await hook({ context, findMany, node, operation, overrideAccess, req }) + } } - } - }, - nodes: (value as SerializedEditorState)?.root?.children ?? [], - }) - - return value - }, - ], - afterRead: [ - async ({ context, findMany, operation, overrideAccess, req, value }) => { - await recurseNodesAsync({ - callback: async (node) => { - const afterReadHooks = finalSanitizedEditorConfig.features.hooks.afterRead - if (afterReadHooks?.has(node.type)) { - for (const hook of afterReadHooks.get(node.type)) { - node = await hook({ context, findMany, node, operation, overrideAccess, req }) - } - } - }, - nodes: (value as SerializedEditorState)?.root?.children ?? [], - }) - - return value - }, - ], - beforeChange: [ - async ({ context, findMany, operation, overrideAccess, req, value }) => { - await recurseNodesAsync({ - callback: async (node) => { - const beforeChangeHooks = finalSanitizedEditorConfig.features.hooks.beforeChange - if (beforeChangeHooks?.has(node.type)) { - for (const hook of beforeChangeHooks.get(node.type)) { - node = await hook({ context, findMany, node, operation, overrideAccess, req }) - } - } - }, - nodes: (value as SerializedEditorState)?.root?.children ?? [], - }) - - return value - }, - ], - beforeDuplicate: [ - async ({ context, findMany, operation, overrideAccess, req, value }) => { - await recurseNodesAsync({ - callback: async (node) => { - const beforeDuplicateHooks = finalSanitizedEditorConfig.features.hooks.beforeDuplicate - if (beforeDuplicateHooks?.has(node.type)) { - for (const hook of beforeDuplicateHooks.get(node.type)) { - node = await hook({ context, findMany, node, operation, overrideAccess, req }) - } - } - }, - nodes: (value as SerializedEditorState)?.root?.children ?? [], - }) - - return value - }, - ], - beforeValidate: [ - async ({ context, findMany, operation, overrideAccess, req, value }) => { - await recurseNodesAsync({ - callback: async (node) => { - const beforeValidateHooks = finalSanitizedEditorConfig.features.hooks.beforeValidate - if (beforeValidateHooks?.has(node.type)) { - for (const hook of beforeValidateHooks.get(node.type)) { - /** - * We cannot pass the originalNode here, as there is no way to map one node to a previous one, as a previous originalNode might be in a different position - */ /* - node = await hook({ context, findMany, node, operation, overrideAccess, req }) - } - } - }, - nodes: (value as SerializedEditorState)?.root?.children ?? [], - }) - - return value - }, - ], - },*/ - outputSchema: ({ - collectionIDFieldTypes, - config, - field, - interfaceNameDefinitions, - isRequired, - }) => { - let outputSchema: JSONSchema4 = { - // This schema matches the SerializedEditorState type so far, that it's possible to cast SerializedEditorState to this schema without any errors. - // In the future, we should - // 1) allow recursive children - // 2) Pass in all the different types for every node added to the editorconfig. This can be done with refs in the schema. - type: withNullableJSONSchemaType('object', isRequired), - properties: { - root: { - type: 'object', - additionalProperties: false, - properties: { - type: { - type: 'string', }, - children: { - type: 'array', - items: { - type: 'object', - additionalProperties: true, - properties: { - type: { - type: 'string', - }, - version: { - type: 'integer', + nodes: (value as SerializedEditorState)?.root?.children ?? [], + }) + + return value + }, + ], + afterRead: [ + async ({ context, findMany, operation, overrideAccess, req, value }) => { + await recurseNodesAsync({ + callback: async (node) => { + const afterReadHooks = finalSanitizedEditorConfig.features.hooks.afterRead + if (afterReadHooks?.has(node.type)) { + for (const hook of afterReadHooks.get(node.type)) { + node = await hook({ context, findMany, node, operation, overrideAccess, req }) + } + } + }, + nodes: (value as SerializedEditorState)?.root?.children ?? [], + }) + + return value + }, + ], + beforeChange: [ + async ({ context, findMany, operation, overrideAccess, req, value }) => { + await recurseNodesAsync({ + callback: async (node) => { + const beforeChangeHooks = finalSanitizedEditorConfig.features.hooks.beforeChange + if (beforeChangeHooks?.has(node.type)) { + for (const hook of beforeChangeHooks.get(node.type)) { + node = await hook({ context, findMany, node, operation, overrideAccess, req }) + } + } + }, + nodes: (value as SerializedEditorState)?.root?.children ?? [], + }) + + return value + }, + ], + beforeDuplicate: [ + async ({ context, findMany, operation, overrideAccess, req, value }) => { + await recurseNodesAsync({ + callback: async (node) => { + const beforeDuplicateHooks = finalSanitizedEditorConfig.features.hooks.beforeDuplicate + if (beforeDuplicateHooks?.has(node.type)) { + for (const hook of beforeDuplicateHooks.get(node.type)) { + node = await hook({ context, findMany, node, operation, overrideAccess, req }) + } + } + }, + nodes: (value as SerializedEditorState)?.root?.children ?? [], + }) + + return value + }, + ], + beforeValidate: [ + async ({ context, findMany, operation, overrideAccess, req, value }) => { + await recurseNodesAsync({ + callback: async (node) => { + const beforeValidateHooks = finalSanitizedEditorConfig.features.hooks.beforeValidate + if (beforeValidateHooks?.has(node.type)) { + for (const hook of beforeValidateHooks.get(node.type)) { + /** + * We cannot pass the originalNode here, as there is no way to map one node to a previous one, as a previous originalNode might be in a different position + */ /* + node = await hook({ context, findMany, node, operation, overrideAccess, req }) + } + } + }, + nodes: (value as SerializedEditorState)?.root?.children ?? [], + }) + + return value + }, + ], + },*/ + outputSchema: ({ + collectionIDFieldTypes, + config, + field, + interfaceNameDefinitions, + isRequired, + }) => { + let outputSchema: JSONSchema4 = { + // This schema matches the SerializedEditorState type so far, that it's possible to cast SerializedEditorState to this schema without any errors. + // In the future, we should + // 1) allow recursive children + // 2) Pass in all the different types for every node added to the editorconfig. This can be done with refs in the schema. + type: withNullableJSONSchemaType('object', isRequired), + properties: { + root: { + type: 'object', + additionalProperties: false, + properties: { + type: { + type: 'string', + }, + children: { + type: 'array', + items: { + type: 'object', + additionalProperties: true, + properties: { + type: { + type: 'string', + }, + version: { + type: 'integer', + }, }, + required: ['type', 'version'], }, - required: ['type', 'version'], + }, + direction: { + oneOf: [ + { + enum: ['ltr', 'rtl'], + }, + { + type: 'null', + }, + ], + }, + format: { + type: 'string', + enum: ['left', 'start', 'center', 'right', 'end', 'justify', ''], // ElementFormatType, since the root node is an element + }, + indent: { + type: 'integer', + }, + version: { + type: 'integer', }, }, - direction: { - oneOf: [ - { - enum: ['ltr', 'rtl'], - }, - { - type: 'null', - }, - ], - }, - format: { - type: 'string', - enum: ['left', 'start', 'center', 'right', 'end', 'justify', ''], // ElementFormatType, since the root node is an element - }, - indent: { - type: 'integer', - }, - version: { - type: 'integer', - }, + required: ['children', 'direction', 'format', 'indent', 'type', 'version'], }, - required: ['children', 'direction', 'format', 'indent', 'type', 'version'], }, - }, - required: ['root'], - } - for (const modifyOutputSchema of finalSanitizedEditorConfig.features.generatedTypes - .modifyOutputSchemas) { - outputSchema = modifyOutputSchema({ - collectionIDFieldTypes, - config, - currentSchema: outputSchema, - field, - interfaceNameDefinitions, - isRequired, - }) - } + required: ['root'], + } + for (const modifyOutputSchema of finalSanitizedEditorConfig.features.generatedTypes + .modifyOutputSchemas) { + outputSchema = modifyOutputSchema({ + collectionIDFieldTypes, + config, + currentSchema: outputSchema, + field, + interfaceNameDefinitions, + isRequired, + }) + } - return outputSchema - }, - populationPromises({ - context, - currentDepth, - depth, - field, - fieldPromises, - findMany, - flattenLocales, - overrideAccess, - populationPromises, - req, - showHiddenFields, - siblingDoc, - }) { - // check if there are any features with nodes which have populationPromises for this field - if (finalSanitizedEditorConfig?.features?.populationPromises?.size) { - populateLexicalPopulationPromises({ - context, - currentDepth, - depth, - editorPopulationPromises: finalSanitizedEditorConfig.features.populationPromises, - field, - fieldPromises, - findMany, - flattenLocales, - overrideAccess, - populationPromises, - req, - showHiddenFields, - siblingDoc, - }) - } - }, - validate: richTextValidateHOC({ - editorConfig: finalSanitizedEditorConfig, - }), + return outputSchema + }, + populationPromises({ + context, + currentDepth, + depth, + field, + fieldPromises, + findMany, + flattenLocales, + overrideAccess, + populationPromises, + req, + showHiddenFields, + siblingDoc, + }) { + // check if there are any features with nodes which have populationPromises for this field + if (finalSanitizedEditorConfig?.features?.populationPromises?.size) { + populateLexicalPopulationPromises({ + context, + currentDepth, + depth, + editorPopulationPromises: finalSanitizedEditorConfig.features.populationPromises, + field, + fieldPromises, + findMany, + flattenLocales, + overrideAccess, + populationPromises, + req, + showHiddenFields, + siblingDoc, + }) + } + }, + validate: richTextValidateHOC({ + editorConfig: finalSanitizedEditorConfig, + }), + } } } @@ -435,7 +450,6 @@ export { defaultEditorConfig, defaultEditorFeatures, defaultEditorLexicalConfig, - defaultSanitizedServerEditorConfig, } from './field/lexical/config/server/default.js' export { loadFeatures, diff --git a/packages/richtext-lexical/src/types.ts b/packages/richtext-lexical/src/types.ts index 974b3111c..27dc9a4dd 100644 --- a/packages/richtext-lexical/src/types.ts +++ b/packages/richtext-lexical/src/types.ts @@ -1,6 +1,6 @@ import type { EditorConfig as LexicalEditorConfig, SerializedEditorState } from 'lexical' import type { FieldPermissions } from 'payload/auth' -import type { FieldTypes } from 'payload/config' +import type { FieldTypes, SanitizedConfig } from 'payload/config' import type { RichTextAdapter, RichTextFieldProps } from 'payload/types' import type React from 'react' @@ -18,11 +18,16 @@ export type LexicalEditorProps = { lexical?: LexicalEditorConfig } -// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents export type LexicalRichTextAdapter = RichTextAdapter & { editorConfig: SanitizedServerEditorConfig } +export type LexicalRichTextAdapterProvider = + /** + * This is being called during the payload sanitization process + */ + ({ config }: { config: SanitizedConfig }) => Promise + export type FieldProps = RichTextFieldProps & { fieldTypes: FieldTypes indexPath: string diff --git a/packages/richtext-slate/src/field/elements/link/LinkDrawer/baseFields.ts b/packages/richtext-slate/src/field/elements/link/LinkDrawer/baseFields.ts index 95b71395f..8ffaab052 100644 --- a/packages/richtext-slate/src/field/elements/link/LinkDrawer/baseFields.ts +++ b/packages/richtext-slate/src/field/elements/link/LinkDrawer/baseFields.ts @@ -1,8 +1,8 @@ import type { User } from 'payload/auth' -import type { Config } from 'payload/config' +import type { SanitizedConfig } from 'payload/config' import type { Field } from 'payload/types' -export const getBaseFields = (config: Config): Field[] => [ +export const getBaseFields = (config: SanitizedConfig): Field[] => [ { name: 'text', type: 'text', diff --git a/packages/richtext-slate/src/field/elements/link/utilities.tsx b/packages/richtext-slate/src/field/elements/link/utilities.tsx index 97cc4b7f6..009da3a72 100644 --- a/packages/richtext-slate/src/field/elements/link/utilities.tsx +++ b/packages/richtext-slate/src/field/elements/link/utilities.tsx @@ -1,5 +1,5 @@ import type { I18n } from '@payloadcms/translations' -import type { SanitizedConfig } from 'payload/config' +import type { Config, SanitizedConfig } from 'payload/config' import type { Field } from 'payload/types' import type { Editor } from 'slate' @@ -35,16 +35,15 @@ export const wrapLink = (editor: Editor): void => { */ export function transformExtraFields( customFieldSchema: - | ((args: { config: SanitizedConfig; defaultFields: Field[]; i18n: I18n }) => Field[]) + | ((args: { config: SanitizedConfig; defaultFields: Field[] }) => Field[]) | Field[], config: SanitizedConfig, - i18n: I18n, ): Field[] { const baseFields: Field[] = getBaseFields(config) const fields = typeof customFieldSchema === 'function' - ? customFieldSchema({ config, defaultFields: baseFields, i18n }) + ? customFieldSchema({ config, defaultFields: baseFields }) : baseFields // Wrap fields which are not part of the base schema in a group named 'fields' - otherwise they will be rendered but not saved diff --git a/packages/richtext-slate/src/generateComponentMap.tsx b/packages/richtext-slate/src/generateComponentMap.tsx index dd6813dcc..4def6f195 100644 --- a/packages/richtext-slate/src/generateComponentMap.tsx +++ b/packages/richtext-slate/src/generateComponentMap.tsx @@ -1,14 +1,12 @@ -import type { RichTextAdapter } from 'payload/types' +import type { Field, RichTextAdapter } from 'payload/types' import { mapFields } from '@payloadcms/ui/utilities/buildComponentMap' -import { sanitizeFields } from 'payload/config' import React from 'react' import type { AdapterArguments, RichTextCustomElement, RichTextCustomLeaf } from './types.js' import { elements as elementTypes } from './field/elements/index.js' import { linkFieldsSchemaPath } from './field/elements/link/shared.js' -import { transformExtraFields } from './field/elements/link/utilities.js' import { uploadFieldsSchemaPath } from './field/elements/upload/shared.js' import { defaultLeaves as leafTypes } from './field/leaves/index.js' @@ -17,8 +15,6 @@ export const getGenerateComponentMap = ({ WithServerSideProps, config, i18n }) => { const componentMap = new Map() - const validRelationships = config.collections.map((c) => c.slug) || [] - ;(args?.admin?.leaves || Object.values(leafTypes)).forEach((leaf) => { let leafObject: RichTextCustomLeaf @@ -66,16 +62,10 @@ export const getGenerateComponentMap = switch (element.name) { case 'link': { - const linkFields = sanitizeFields({ - config, - fields: transformExtraFields(args.admin?.link?.fields, config, i18n), - validRelationships, - }) - const mappedFields = mapFields({ WithServerSideProps, config, - fieldSchema: linkFields, + fieldSchema: args.admin?.link?.fields as Field[], i18n, readOnly: false, }) @@ -98,16 +88,10 @@ export const getGenerateComponentMap = uploadEnabledCollections.forEach((collection) => { if (args?.admin?.upload?.collections[collection.slug]?.fields) { - const uploadFields = sanitizeFields({ - config, - fields: args?.admin?.upload?.collections[collection.slug]?.fields, - validRelationships, - }) - const mappedFields = mapFields({ WithServerSideProps, config, - fieldSchema: uploadFields, + fieldSchema: args?.admin?.upload?.collections[collection.slug]?.fields, i18n, readOnly: false, }) diff --git a/packages/richtext-slate/src/generateSchemaMap.ts b/packages/richtext-slate/src/generateSchemaMap.ts index b04bba103..4f46e81ea 100644 --- a/packages/richtext-slate/src/generateSchemaMap.ts +++ b/packages/richtext-slate/src/generateSchemaMap.ts @@ -1,19 +1,14 @@ -import type { RichTextAdapter } from 'payload/types' - -import { sanitizeFields } from 'payload/config' +import type { Field, RichTextAdapter } from 'payload/types' import type { AdapterArguments, RichTextCustomElement } from './types.js' import { elements as elementTypes } from './field/elements/index.js' import { linkFieldsSchemaPath } from './field/elements/link/shared.js' -import { transformExtraFields } from './field/elements/link/utilities.js' import { uploadFieldsSchemaPath } from './field/elements/upload/shared.js' export const getGenerateSchemaMap = (args: AdapterArguments): RichTextAdapter['generateSchemaMap'] => - ({ config, i18n, schemaMap, schemaPath }) => { - const validRelationships = config.collections.map((c) => c.slug) || [] - + ({ config, schemaMap, schemaPath }) => { ;(args?.admin?.elements || Object.values(elementTypes)).forEach((el) => { let element: RichTextCustomElement @@ -26,13 +21,10 @@ export const getGenerateSchemaMap = if (element) { switch (element.name) { case 'link': { - const linkFields = sanitizeFields({ - config, - fields: transformExtraFields(args.admin?.link?.fields, config, i18n), - validRelationships, - }) - - schemaMap.set(`${schemaPath}.${linkFieldsSchemaPath}`, linkFields) + schemaMap.set( + `${schemaPath}.${linkFieldsSchemaPath}`, + args.admin?.link?.fields as Field[], + ) break } @@ -50,15 +42,9 @@ export const getGenerateSchemaMap = uploadEnabledCollections.forEach((collection) => { if (args?.admin?.upload?.collections[collection.slug]?.fields) { - const uploadFields = sanitizeFields({ - config, - fields: args?.admin?.upload?.collections[collection.slug]?.fields, - validRelationships, - }) - schemaMap.set( `${schemaPath}.${uploadFieldsSchemaPath}.${collection.slug}`, - uploadFields, + args?.admin?.upload?.collections[collection.slug]?.fields, ) } }) diff --git a/packages/richtext-slate/src/index.tsx b/packages/richtext-slate/src/index.tsx index 5af47ff2e..dc89bb68b 100644 --- a/packages/richtext-slate/src/index.tsx +++ b/packages/richtext-slate/src/index.tsx @@ -1,5 +1,7 @@ -import type { RichTextAdapter } from 'payload/types' +import type { Config } from 'payload/config' +import type { RichTextAdapterProvider } from 'payload/types' +import { sanitizeFields } from 'payload/config' import { withNullableJSONSchemaType } from 'payload/utilities' import type { AdapterArguments } from './types.js' @@ -7,61 +9,95 @@ import type { AdapterArguments } from './types.js' import { RichTextCell } from './cell/index.js' import { richTextRelationshipPromise } from './data/richTextRelationshipPromise.js' import { richTextValidate } from './data/validation.js' +import { transformExtraFields } from './field/elements/link/utilities.js' import { RichTextField } from './field/index.js' import { getGenerateComponentMap } from './generateComponentMap.js' import { getGenerateSchemaMap } from './generateSchemaMap.js' -export function slateEditor(args: AdapterArguments): RichTextAdapter { - return { - CellComponent: RichTextCell, - FieldComponent: RichTextField, - generateComponentMap: getGenerateComponentMap(args), - generateSchemaMap: getGenerateSchemaMap(args), - outputSchema: ({ isRequired }) => { - return { - type: withNullableJSONSchemaType('array', isRequired), - items: { - type: 'object', - }, +export function slateEditor( + args: AdapterArguments, +): RichTextAdapterProvider { + return async ({ config }) => { + const validRelationships = config.collections.map((c) => c.slug) || [] + + if (!args.admin) { + args.admin = {} + } + if (!args.admin.link) { + args.admin.link = {} + } + if (!args.admin.link.fields) { + args.admin.link.fields = [] + } + args.admin.link.fields = await sanitizeFields({ + config: config as unknown as Config, + fields: transformExtraFields(args.admin?.link?.fields, config), + validRelationships, + }) + + if (args?.admin?.upload?.collections) { + for (const collection of Object.keys(args.admin.upload.collections)) { + if (args?.admin?.upload?.collections[collection]?.fields) { + args.admin.upload.collections[collection].fields = await sanitizeFields({ + config: config as unknown as Config, + fields: args.admin?.upload?.collections[collection]?.fields, + validRelationships, + }) + } } - }, - populationPromises({ - context, - currentDepth, - depth, - field, - fieldPromises, - findMany, - flattenLocales, - overrideAccess, - populationPromises, - req, - showHiddenFields, - siblingDoc, - }) { - if ( - field.admin?.elements?.includes('relationship') || - field.admin?.elements?.includes('upload') || - field.admin?.elements?.includes('link') || - !field?.admin?.elements - ) { - richTextRelationshipPromise({ - context, - currentDepth, - depth, - field, - fieldPromises, - findMany, - flattenLocales, - overrideAccess, - populationPromises, - req, - showHiddenFields, - siblingDoc, - }) - } - }, - validate: richTextValidate, + } + + return { + CellComponent: RichTextCell, + FieldComponent: RichTextField, + generateComponentMap: getGenerateComponentMap(args), + generateSchemaMap: getGenerateSchemaMap(args), + outputSchema: ({ isRequired }) => { + return { + type: withNullableJSONSchemaType('array', isRequired), + items: { + type: 'object', + }, + } + }, + populationPromises({ + context, + currentDepth, + depth, + field, + fieldPromises, + findMany, + flattenLocales, + overrideAccess, + populationPromises, + req, + showHiddenFields, + siblingDoc, + }) { + if ( + field.admin?.elements?.includes('relationship') || + field.admin?.elements?.includes('upload') || + field.admin?.elements?.includes('link') || + !field?.admin?.elements + ) { + richTextRelationshipPromise({ + context, + currentDepth, + depth, + field, + fieldPromises, + findMany, + flattenLocales, + overrideAccess, + populationPromises, + req, + showHiddenFields, + siblingDoc, + }) + } + }, + validate: richTextValidate, + } } } diff --git a/packages/richtext-slate/src/types.ts b/packages/richtext-slate/src/types.ts index f55e41ece..618119ed9 100644 --- a/packages/richtext-slate/src/types.ts +++ b/packages/richtext-slate/src/types.ts @@ -1,5 +1,4 @@ -import type { I18n } from '@payloadcms/translations' -import type { SanitizedConfig } from 'payload/config' +import type { Config, SanitizedConfig } from 'payload/config' import type { Field, RichTextFieldProps } from 'payload/types' import type { Editor } from 'slate' @@ -58,9 +57,7 @@ export type AdapterArguments = { hideGutter?: boolean leaves?: RichTextLeaf[] link?: { - fields?: - | ((args: { config: SanitizedConfig; defaultFields: Field[]; i18n: I18n }) => Field[]) - | Field[] + fields?: ((args: { config: SanitizedConfig; defaultFields: Field[] }) => Field[]) | Field[] } placeholder?: Record | string rtl?: boolean diff --git a/packages/ui/src/providers/ComponentMap/buildComponentMap/fields.tsx b/packages/ui/src/providers/ComponentMap/buildComponentMap/fields.tsx index 18ebb5aff..a68e20b06 100644 --- a/packages/ui/src/providers/ComponentMap/buildComponentMap/fields.tsx +++ b/packages/ui/src/providers/ComponentMap/buildComponentMap/fields.tsx @@ -568,6 +568,9 @@ export const mapFields = (args: { style: field.admin?.style, width: field.admin?.width, } + if (typeof field?.editor === 'function') { + throw new Error('Attempted to access unsanitized rich text editor.') + } const RichTextFieldComponent = field.editor.FieldComponent const RichTextCellComponent = field.editor.CellComponent diff --git a/test/_community/collections/Posts/index.ts b/test/_community/collections/Posts/index.ts index 3eadb517f..bdb5949b3 100644 --- a/test/_community/collections/Posts/index.ts +++ b/test/_community/collections/Posts/index.ts @@ -1,6 +1,6 @@ import type { CollectionConfig } from 'payload/types' -import { mediaSlug } from '../Media/index.js' +import { BlocksFeature, lexicalEditor } from '@payloadcms/richtext-lexical' export const postsSlug = 'posts' @@ -18,6 +18,28 @@ export const PostsCollection: CollectionConfig = { name: 'richText', type: 'richText', }, + { + name: 'richText2', + type: 'richText', + editor: lexicalEditor({ + features: ({ defaultFeatures }) => [ + ...defaultFeatures, + BlocksFeature({ + blocks: [ + { + slug: 'testblock', + fields: [ + { + name: 'testfield', + type: 'text', + }, + ], + }, + ], + }), + ], + }), + }, // { // type: 'row', // fields: [], diff --git a/test/buildConfigWithDefaults.ts b/test/buildConfigWithDefaults.ts index 3447a17bf..c9a4e2869 100644 --- a/test/buildConfigWithDefaults.ts +++ b/test/buildConfigWithDefaults.ts @@ -189,5 +189,5 @@ export async function buildConfigWithDefaults( config.admin.disable = true } - return buildConfig(config) + return await buildConfig(config) }