feat(richtext-lexical)!: initialize lexical during sanitization (#6119)

BREAKING:

- sanitizeFields is now an async function
- the richText adapters now return a function instead of returning the adapter directly
This commit is contained in:
Alessio Gravili
2024-04-30 15:09:32 -04:00
committed by GitHub
parent 9a636a3cfb
commit d9bb51fdc7
44 changed files with 829 additions and 795 deletions

View File

@@ -2,16 +2,19 @@ import { SanitizedConfig, sanitizeConfig } from 'payload/config'
import { Config } from 'payload/config'
import { getLocalizedSortProperty } from './getLocalizedSortProperty.js'
const config = sanitizeConfig({
let config: SanitizedConfig
describe('get localized sort property', () => {
beforeAll(async () => {
config = (await sanitizeConfig({
localization: {
locales: ['en', 'es'],
defaultLocale: 'en',
fallback: true,
},
} as Config) as SanitizedConfig
describe('get localized sort property', () => {
it('passes through a non-localized sort property', () => {
} as Config)) as SanitizedConfig
})
it('passes through a non-localized sort property', async () => {
const result = getLocalizedSortProperty({
segments: ['title'],
config,

View File

@@ -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.

View File

@@ -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,

View File

@@ -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<any>
FieldComponent: React.FC<RichTextFieldProps<Value, AdapterProps, ExtraFieldProperties>>
}
export type RichTextAdapterProvider<
Value extends object = object,
AdapterProps = any,
ExtraFieldProperties = {},
> = ({
config,
}: {
config: SanitizedConfig
}) =>
| Promise<RichTextAdapter<Value, AdapterProps, ExtraFieldProperties>>
| RichTextAdapter<Value, AdapterProps, ExtraFieldProperties>

View File

@@ -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'

View File

@@ -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<void>>,
): Promise<SanitizedCollectionConfig> => {
// /////////////////////////////////
// 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,
})

View File

@@ -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<SanitizedConfig> {
return plugin(configAfterPlugin)
}, Promise.resolve(config))
const sanitizedConfig = sanitizeConfig(configAfterPlugins)
return sanitizedConfig
return await sanitizeConfig(configAfterPlugins)
}
return sanitizeConfig(config)
return await sanitizeConfig(config)
}

View File

@@ -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<SanitizedConfig>
)
}
return sanitizedConfig as Partial<SanitizedConfig>
return sanitizedConfig as unknown as Partial<SanitizedConfig>
}
export const sanitizeConfig = (incomingConfig: Config): SanitizedConfig => {
export const sanitizeConfig = async (incomingConfig: Config): Promise<SanitizedConfig> => {
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<void>> = []
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<void>[] = []
for (const sanitizeFunction of richTextSanitizationPromises) {
promises.push(sanitizeFunction(config as SanitizedConfig))
}
await Promise.all(promises)
return config as SanitizedConfig
}

View File

@@ -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<any, any, any>
editor: RichTextAdapterProvider<any, any, any>
/**
* Email Adapter
*
@@ -640,9 +641,11 @@ export type Config = {
export type SanitizedConfig = Omit<
DeepRequired<Config>,
'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<any, any, any>
endpoints: Endpoint[]
globals: SanitizedGlobalConfig[]
i18n: Required<I18nOptions>

View File

@@ -0,0 +1 @@
export { getLocalI18n } from '../translations/getLocalI18n.js'

View File

@@ -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({
const sanitizedField = (
await sanitizeFields({
config,
fields,
validRelationships: [],
})[0] as TextField
})
)[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({
const sanitizedField = (
await sanitizeFields({
config,
fields,
validRelationships: [],
})[0] as TextField
})
)[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({
const sanitizedField = (
await sanitizeFields({
config,
fields,
validRelationships: [],
})[0] as TextField
})
)[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({
const sanitizedField = (
await sanitizeFields({
config,
fields: [arrayField],
validRelationships: [],
})[0] as ArrayField
})
)[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({
const sanitizedField = (
await sanitizeFields({
config,
fields,
validRelationships: [],
})[0] as BlockField
})
)[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({
const sanitizedField = (
await sanitizeFields({
config,
fields,
validRelationships: [],
})[0] as ArrayField
})
)[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({
const sanitizedField = (
await sanitizeFields({
config,
fields,
validRelationships: [],
})[0] as BlockField
})
)[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({
const sanitizedField = (
await sanitizeFields({
config,
fields,
validRelationships: [],
})[0] as CheckboxField
})
)[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: [],

View File

@@ -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<void>>
/**
* 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<Field[]> => {
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') {
const sanitizeRichText = async (_config: SanitizedConfig) => {
if (!field.editor) {
if (config.editor && !requireFieldLevelRichTextEditor) {
field.editor = config.editor
if (_config.editor && !requireFieldLevelRichTextEditor) {
// config.editor should be sanitized at this point
field.editor = _config.editor
} else {
throw new MissingEditorProp(field)
}
}
if (typeof field.editor === 'function') {
field.editor = await field.editor({ config: _config })
}
// Add editor adapter hooks to field hooks
if (!field.hooks) field.hooks = {}
if (field?.editor?.hooks?.afterRead?.length) {
field.hooks.afterRead = field.hooks.afterRead
? field.hooks.afterRead.concat(field.editor.hooks.afterRead)
: field.editor.hooks.afterRead
}
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
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 (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
}

View File

@@ -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<LabelProps>
}
}
editor?: RichTextAdapter<Value, AdapterProps, AdapterProps>
editor?:
| RichTextAdapter<Value, AdapterProps, AdapterProps>
| RichTextAdapterProvider<Value, AdapterProps, AdapterProps>
type: 'richText'
} & ExtraProperties
@@ -594,7 +596,9 @@ export type RichTextFieldRequiredEditor<
AdapterProps = any,
ExtraProperties = object,
> = Omit<RichTextField<Value, AdapterProps, ExtraProperties>, 'editor'> & {
editor: RichTextAdapter<Value, AdapterProps, ExtraProperties>
editor:
| RichTextAdapter<Value, AdapterProps, AdapterProps>
| RichTextAdapterProvider<Value, AdapterProps, AdapterProps>
}
export type ArrayField = FieldBase & {

View File

@@ -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) {

View File

@@ -264,6 +264,10 @@ export const richText: Validate<object, unknown, unknown, RichTextField> = 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)

View File

@@ -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<void>>,
): Promise<SanitizedGlobalConfig[]> => {
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
})
return sanitizedGlobals
globals[i] = global
}
export default sanitizeGlobals
return globals as SanitizedGlobalConfig[]
}

View File

@@ -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({

View File

@@ -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,

View File

@@ -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,21 +26,22 @@ 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,
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[]
}
})
// 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
@@ -63,8 +58,6 @@ export const BlocksFeature: FeatureProviderProviderServer<
})
}
return {
feature: () => {
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,

View File

@@ -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) {

View File

@@ -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<SerializedBlockNode> => {
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)

View File

@@ -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[]
}

View File

@@ -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<LinkFeatureServerProps,
props = {}
}
return {
feature: () => {
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<LinkFeatureServerProps,
const validRelationships = config.collections.map((c) => c.slug) || []
schemaMap.set('fields', transformedFields)
schemaMap.set('fields', sanitizedFields)
traverseFields({
config,
fields: transformedFields,
fields: sanitizedFields,
i18n,
schemaMap,
schemaPath: 'fields',

View File

@@ -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

View File

@@ -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,

View File

@@ -84,14 +84,20 @@ export type FeatureProviderServer<ServerFeatureProps, ClientFeatureProps> = {
/** 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<ServerFeatureProps, ClientFeatureProps>
}) =>
| Promise<ServerFeature<ServerFeatureProps, ClientFeatureProps>>
| ServerFeature<ServerFeatureProps, ClientFeatureProps>
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

View File

@@ -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,

View File

@@ -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,

View File

@@ -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)

View File

@@ -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<ResolvedServerFeatureMap> {
// 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,

View File

@@ -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<SanitizedServerEditorConfig> {
const resolvedFeatureMap = await loadFeatures({
config,
unSanitizedEditorConfig: editorConfig,
})

View File

@@ -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,

View File

@@ -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)
}
}
}

View File

@@ -6,29 +6,42 @@ 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 defaultSanitizedServerEditorConfig: SanitizedServerEditorConfig = null
export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapterProvider {
return async ({ config }) => {
let resolvedFeatureMap: ResolvedServerFeatureMap = null
let finalSanitizedEditorConfig: SanitizedServerEditorConfig // For server only
if (!props || (!props.features && !props.lexical)) {
if (!defaultSanitizedServerEditorConfig) {
defaultSanitizedServerEditorConfig = await sanitizeServerEditorConfig(
defaultEditorConfig,
config,
)
}
finalSanitizedEditorConfig = cloneDeep(defaultSanitizedServerEditorConfig)
resolvedFeatureMap = finalSanitizedEditorConfig.resolvedFeatureMap
} else {
let features: FeatureProviderServer<unknown, unknown>[] =
@@ -41,7 +54,8 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte
const lexical: LexicalEditorConfig = props.lexical
resolvedFeatureMap = loadFeatures({
resolvedFeatureMap = await loadFeatures({
config,
unSanitizedEditorConfig: {
features,
lexical: lexical ? lexical : defaultEditorConfig.lexical,
@@ -284,6 +298,7 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte
}),
}
}
}
export { AlignFeature } from './field/features/align/feature.server.js'
export { BlockQuoteFeature } from './field/features/blockquote/feature.server.js'
@@ -435,7 +450,6 @@ export {
defaultEditorConfig,
defaultEditorFeatures,
defaultEditorLexicalConfig,
defaultSanitizedServerEditorConfig,
} from './field/lexical/config/server/default.js'
export {
loadFeatures,

View File

@@ -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<SerializedEditorState, AdapterProps, any> & {
editorConfig: SanitizedServerEditorConfig
}
export type LexicalRichTextAdapterProvider =
/**
* This is being called during the payload sanitization process
*/
({ config }: { config: SanitizedConfig }) => Promise<LexicalRichTextAdapter>
export type FieldProps = RichTextFieldProps<SerializedEditorState, AdapterProps, AdapterProps> & {
fieldTypes: FieldTypes
indexPath: string

View File

@@ -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',

View File

@@ -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

View File

@@ -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,
})

View File

@@ -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,
)
}
})

View File

@@ -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,11 +9,44 @@ 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<any[], AdapterArguments, any> {
export function slateEditor(
args: AdapterArguments,
): RichTextAdapterProvider<any[], AdapterArguments, any> {
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,
})
}
}
}
return {
CellComponent: RichTextCell,
FieldComponent: RichTextField,
@@ -64,6 +99,7 @@ export function slateEditor(args: AdapterArguments): RichTextAdapter<any[], Adap
validate: richTextValidate,
}
}
}
export { ElementButton } from './field/elements/Button.js'

View File

@@ -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, string> | string
rtl?: boolean

View File

@@ -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

View File

@@ -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: [],

View File

@@ -189,5 +189,5 @@ export async function buildConfigWithDefaults(
config.admin.disable = true
}
return buildConfig(config)
return await buildConfig(config)
}