perf: faster page navigation by speeding up createClientConfig, speed up version fetching, speed up lexical init. Up to 100x faster (#9457)

If you had a lot of fields and collections, createClientConfig would be
extremely slow, as it was copying a lot of memory. In my test config
with a lot of fields and collections, it took 4 seconds(!!).

And not only that, it also ran between every single page navigation.

This PR significantly speeds up the createClientConfig function. In my
test config, its execution speed went from 4 seconds to 50 ms.
Additionally, createClientConfig is now properly cached in both dev &
prod. It no longer runs between every single page navigation. Even if
you trigger a full page reload, createClientConfig will be cached and
not run again. Despite that, HMR remains fully-functional.

This will make payload feel noticeably faster for large configs -
especially if it contains a lot of richtext fields, as it was previously
deep-copying the relatively large richText editor configs over and over
again.

## Before - 40 sec navigation speed

https://github.com/user-attachments/assets/fe6b707a-459b-44c6-982a-b277f6cbb73f

## After - 1 sec navigation speed

https://github.com/user-attachments/assets/384fba63-dc32-4396-b3c2-0353fcac6639

## Todo

- [x] Implement ClientSchemaMap and cache it, to remove
createClientField call in our form state endpoint
- [x] Enable schemaMap caching for dev
- [x] Cache lexical clientField generation, or add it to the parent
clientConfig

## Lexical changes

Red: old / removed
Green: new

![CleanShot 2024-11-22 at 21 07
41@2x](https://github.com/user-attachments/assets/f8321218-763c-4120-9353-076c381f33fb)

### Speed up version queries

This PR comes with performance optimizations for fetching versions
before a document is loaded. Not only does it use the new select API to
limit the fields it queries, it also completely skips a database query
if the current document is published.

### Speed up lexical init

Removes a bunch of unnecessary deep copying of lexical objects which
caused higher memory usage and slower load times. Additionally, the
lexical default config sanitization now happens less often.
This commit is contained in:
Alessio Gravili
2024-11-26 14:31:14 -07:00
committed by GitHub
parent 67a9d669b6
commit fd0ff51296
58 changed files with 1512 additions and 694 deletions

View File

@@ -296,6 +296,7 @@ jobs:
- admin__e2e__3 - admin__e2e__3
- admin-root - admin-root
- auth - auth
- auth-basic
- field-error-states - field-error-states
- fields-relationship - fields-relationship
- fields - fields

View File

@@ -17,7 +17,7 @@ const customReactVersionParser: CustomVersionParser = (version) => {
let checkedDependencies = false let checkedDependencies = false
export const checkDependencies = async () => { export const checkDependencies = () => {
if ( if (
process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !== 'production' &&
process.env.PAYLOAD_DISABLE_DEPENDENCY_CHECKER !== 'true' && process.env.PAYLOAD_DISABLE_DEPENDENCY_CHECKER !== 'true' &&
@@ -26,7 +26,7 @@ export const checkDependencies = async () => {
checkedDependencies = true checkedDependencies = true
// First check if there are mismatching dependency versions of next / react packages // First check if there are mismatching dependency versions of next / react packages
await payloadCheckDependencies({ void payloadCheckDependencies({
dependencyGroups: [ dependencyGroups: [
{ {
name: 'react', name: 'react',

View File

@@ -3,12 +3,12 @@ import type { ImportMap, SanitizedConfig, ServerFunctionClient } from 'payload'
import { rtlLanguages } from '@payloadcms/translations' import { rtlLanguages } from '@payloadcms/translations'
import { RootProvider } from '@payloadcms/ui' import { RootProvider } from '@payloadcms/ui'
import { getClientConfig } from '@payloadcms/ui/utilities/getClientConfig'
import { headers as getHeaders, cookies as nextCookies } from 'next/headers.js' import { headers as getHeaders, cookies as nextCookies } from 'next/headers.js'
import { getPayload, parseCookies } from 'payload' import { getPayload, parseCookies } from 'payload'
import React from 'react' import React from 'react'
import { getNavPrefs } from '../../elements/Nav/getNavPrefs.js' import { getNavPrefs } from '../../elements/Nav/getNavPrefs.js'
import { getClientConfig } from '../../utilities/getClientConfig.js'
import { getRequestLanguage } from '../../utilities/getRequestLanguage.js' import { getRequestLanguage } from '../../utilities/getRequestLanguage.js'
import { getRequestTheme } from '../../utilities/getRequestTheme.js' import { getRequestTheme } from '../../utilities/getRequestTheme.js'
import { initReq } from '../../utilities/initReq.js' import { initReq } from '../../utilities/initReq.js'
@@ -33,7 +33,7 @@ export const RootLayout = async ({
readonly importMap: ImportMap readonly importMap: ImportMap
readonly serverFunction: ServerFunctionClient readonly serverFunction: ServerFunctionClient
}) => { }) => {
await checkDependencies() checkDependencies()
const config = await configPromise const config = await configPromise
@@ -54,7 +54,7 @@ export const RootLayout = async ({
const payload = await getPayload({ config, importMap }) const payload = await getPayload({ config, importMap })
const { i18n, permissions, req, user } = await initReq(config) const { i18n, permissions, user } = await initReq(config)
const dir = (rtlLanguages as unknown as AcceptedLanguages[]).includes(languageCode) const dir = (rtlLanguages as unknown as AcceptedLanguages[]).includes(languageCode)
? 'RTL' ? 'RTL'
@@ -86,7 +86,7 @@ export const RootLayout = async ({
const navPrefs = await getNavPrefs({ payload, user }) const navPrefs = await getNavPrefs({ payload, user })
const clientConfig = await getClientConfig({ const clientConfig = getClientConfig({
config, config,
i18n, i18n,
importMap, importMap,

View File

@@ -1,23 +0,0 @@
import type { I18nClient } from '@payloadcms/translations'
import type { ClientConfig, ImportMap, SanitizedConfig } from 'payload'
import { createClientConfig } from 'payload'
import { cache } from 'react'
export const getClientConfig = cache(
async (args: {
config: SanitizedConfig
i18n: I18nClient
importMap: ImportMap
}): Promise<ClientConfig> => {
const { config, i18n, importMap } = args
const clientConfig = createClientConfig({
config,
i18n,
importMap,
})
return Promise.resolve(clientConfig)
},
)

View File

@@ -102,6 +102,7 @@ export const Account: React.FC<AdminViewProps> = async ({
await getVersions({ await getVersions({
id: user.id, id: user.id,
collectionConfig, collectionConfig,
doc: data,
docPermissions, docPermissions,
locale: locale?.code, locale: locale?.code,
payload, payload,

View File

@@ -10,6 +10,13 @@ import { sanitizeID } from '@payloadcms/ui/shared'
type Args = { type Args = {
collectionConfig?: SanitizedCollectionConfig collectionConfig?: SanitizedCollectionConfig
/**
* Optional - performance optimization.
* If a document has been fetched before fetching versions, pass it here.
* If this document is set to published, we can skip the query to find out if a published document exists,
* as the passed in document is proof of its existence.
*/
doc?: Record<string, any>
docPermissions: SanitizedDocumentPermissions docPermissions: SanitizedDocumentPermissions
globalConfig?: SanitizedGlobalConfig globalConfig?: SanitizedGlobalConfig
id?: number | string id?: number | string
@@ -27,9 +34,11 @@ type Result = Promise<{
// TODO: in the future, we can parallelize some of these queries // TODO: in the future, we can parallelize some of these queries
// this will speed up the API by ~30-100ms or so // this will speed up the API by ~30-100ms or so
// Note from the future: I have attempted parallelizing these queries, but it made this function almost 2x slower.
export const getVersions = async ({ export const getVersions = async ({
id: idArg, id: idArg,
collectionConfig, collectionConfig,
doc,
docPermissions, docPermissions,
globalConfig, globalConfig,
locale, locale,
@@ -37,7 +46,7 @@ export const getVersions = async ({
user, user,
}: Args): Result => { }: Args): Result => {
const id = sanitizeID(idArg) const id = sanitizeID(idArg)
let publishedQuery let publishedDoc
let hasPublishedDoc = false let hasPublishedDoc = false
let mostRecentVersionIsAutosaved = false let mostRecentVersionIsAutosaved = false
let unpublishedVersionCount = 0 let unpublishedVersionCount = 0
@@ -70,37 +79,49 @@ export const getVersions = async ({
} }
if (versionsConfig?.drafts) { if (versionsConfig?.drafts) {
publishedQuery = await payload.find({ // Find out if a published document exists
collection: collectionConfig.slug, if (doc?._status === 'published') {
depth: 0, publishedDoc = doc
locale: locale || undefined, } else {
user, publishedDoc = (
where: { await payload.find({
and: [ collection: collectionConfig.slug,
{ depth: 0,
or: [ limit: 1,
locale: locale || undefined,
pagination: false,
select: {
updatedAt: true,
},
user,
where: {
and: [
{ {
_status: { or: [
equals: 'published', {
}, _status: {
equals: 'published',
},
},
{
_status: {
exists: false,
},
},
],
}, },
{ {
_status: { id: {
exists: false, equals: id,
}, },
}, },
], ],
}, },
{ })
id: { )?.docs?.[0]
equals: id, }
},
},
],
},
})
if (publishedQuery.docs?.[0]) { if (publishedDoc) {
hasPublishedDoc = true hasPublishedDoc = true
} }
@@ -109,6 +130,9 @@ export const getVersions = async ({
collection: collectionConfig.slug, collection: collectionConfig.slug,
depth: 0, depth: 0,
limit: 1, limit: 1,
select: {
autosave: true,
},
user, user,
where: { where: {
and: [ and: [
@@ -130,7 +154,7 @@ export const getVersions = async ({
} }
} }
if (publishedQuery.docs?.[0]?.updatedAt) { if (publishedDoc?.updatedAt) {
;({ totalDocs: unpublishedVersionCount } = await payload.countVersions({ ;({ totalDocs: unpublishedVersionCount } = await payload.countVersions({
collection: collectionConfig.slug, collection: collectionConfig.slug,
user, user,
@@ -148,7 +172,7 @@ export const getVersions = async ({
}, },
{ {
updatedAt: { updatedAt: {
greater_than: publishedQuery.docs[0].updatedAt, greater_than: publishedDoc.updatedAt,
}, },
}, },
], ],
@@ -159,6 +183,7 @@ export const getVersions = async ({
;({ totalDocs: versionCount } = await payload.countVersions({ ;({ totalDocs: versionCount } = await payload.countVersions({
collection: collectionConfig.slug, collection: collectionConfig.slug,
depth: 0,
user, user,
where: { where: {
and: [ and: [
@@ -173,15 +198,23 @@ export const getVersions = async ({
} }
if (globalConfig) { if (globalConfig) {
// Find out if a published document exists
if (versionsConfig?.drafts) { if (versionsConfig?.drafts) {
publishedQuery = await payload.findGlobal({ if (doc?._status === 'published') {
slug: globalConfig.slug, publishedDoc = doc
depth: 0, } else {
locale, publishedDoc = await payload.findGlobal({
user, slug: globalConfig.slug,
}) depth: 0,
locale,
select: {
updatedAt: true,
},
user,
})
}
if (publishedQuery?._status === 'published') { if (publishedDoc?._status === 'published') {
hasPublishedDoc = true hasPublishedDoc = true
} }
@@ -204,7 +237,7 @@ export const getVersions = async ({
} }
} }
if (publishedQuery?.updatedAt) { if (publishedDoc?.updatedAt) {
;({ totalDocs: unpublishedVersionCount } = await payload.countGlobalVersions({ ;({ totalDocs: unpublishedVersionCount } = await payload.countGlobalVersions({
depth: 0, depth: 0,
global: globalConfig.slug, global: globalConfig.slug,
@@ -218,7 +251,7 @@ export const getVersions = async ({
}, },
{ {
updatedAt: { updatedAt: {
greater_than: publishedQuery.updatedAt, greater_than: publishedDoc.updatedAt,
}, },
}, },
], ],

View File

@@ -1,46 +1,11 @@
import type { I18nClient } from '@payloadcms/translations' import type { Data, DocumentPreferences, FormState, PayloadRequest, VisibleEntities } from 'payload'
import type {
ClientConfig,
Data,
DocumentPreferences,
FormState,
ImportMap,
PayloadRequest,
SanitizedConfig,
VisibleEntities,
} from 'payload'
import { getClientConfig } from '@payloadcms/ui/utilities/getClientConfig'
import { headers as getHeaders } from 'next/headers.js' import { headers as getHeaders } from 'next/headers.js'
import { createClientConfig, getAccessResults, isEntityHidden, parseCookies } from 'payload' import { getAccessResults, isEntityHidden, parseCookies } from 'payload'
import { renderDocument } from './index.js' import { renderDocument } from './index.js'
let cachedClientConfig = global._payload_clientConfig
if (!cachedClientConfig) {
cachedClientConfig = global._payload_clientConfig = null
}
export const getClientConfig = (args: {
config: SanitizedConfig
i18n: I18nClient
importMap: ImportMap
}): ClientConfig => {
const { config, i18n, importMap } = args
if (cachedClientConfig && process.env.NODE_ENV !== 'development') {
return cachedClientConfig
}
cachedClientConfig = createClientConfig({
config,
i18n,
importMap,
})
return cachedClientConfig
}
type RenderDocumentResult = { type RenderDocumentResult = {
data: any data: any
Document: React.ReactNode Document: React.ReactNode

View File

@@ -1,10 +1,4 @@
import type { import type { AdminViewProps, Data, PayloadComponent, ServerSideEditViewProps } from 'payload'
AdminViewProps,
Data,
PayloadComponent,
ServerProps,
ServerSideEditViewProps,
} from 'payload'
import { DocumentInfoProvider, EditDepthProvider, HydrateAuthProvider } from '@payloadcms/ui' import { DocumentInfoProvider, EditDepthProvider, HydrateAuthProvider } from '@payloadcms/ui'
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent' import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
@@ -137,6 +131,7 @@ export const renderDocument = async ({
getVersions({ getVersions({
id: idFromArgs, id: idFromArgs,
collectionConfig, collectionConfig,
doc,
docPermissions, docPermissions,
globalConfig, globalConfig,
locale: locale?.code, locale: locale?.code,

View File

@@ -1,45 +1,12 @@
import type { I18nClient } from '@payloadcms/translations'
import type { ListPreferences } from '@payloadcms/ui' import type { ListPreferences } from '@payloadcms/ui'
import type { import type { ListQuery, PayloadRequest, VisibleEntities } from 'payload'
ClientConfig,
ImportMap,
ListQuery,
PayloadRequest,
SanitizedConfig,
VisibleEntities,
} from 'payload'
import { getClientConfig } from '@payloadcms/ui/utilities/getClientConfig'
import { headers as getHeaders } from 'next/headers.js' import { headers as getHeaders } from 'next/headers.js'
import { createClientConfig, getAccessResults, isEntityHidden, parseCookies } from 'payload' import { getAccessResults, isEntityHidden, parseCookies } from 'payload'
import { renderListView } from './index.js' import { renderListView } from './index.js'
let cachedClientConfig = global._payload_clientConfig
if (!cachedClientConfig) {
cachedClientConfig = global._payload_clientConfig = null
}
export const getClientConfig = (args: {
config: SanitizedConfig
i18n: I18nClient
importMap: ImportMap
}): ClientConfig => {
const { config, i18n, importMap } = args
if (cachedClientConfig && process.env.NODE_ENV !== 'development') {
return cachedClientConfig
}
cachedClientConfig = createClientConfig({
config,
i18n,
importMap,
})
return cachedClientConfig
}
type RenderListResult = { type RenderListResult = {
List: React.ReactNode List: React.ReactNode
preferences: ListPreferences preferences: ListPreferences

View File

@@ -4,12 +4,12 @@ import type { ImportMap, SanitizedConfig } from 'payload'
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent' import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
import { formatAdminURL } from '@payloadcms/ui/shared' import { formatAdminURL } from '@payloadcms/ui/shared'
import { getClientConfig } from '@payloadcms/ui/utilities/getClientConfig'
import { notFound, redirect } from 'next/navigation.js' import { notFound, redirect } from 'next/navigation.js'
import React, { Fragment } from 'react' import React, { Fragment } from 'react'
import { DefaultTemplate } from '../../templates/Default/index.js' import { DefaultTemplate } from '../../templates/Default/index.js'
import { MinimalTemplate } from '../../templates/Minimal/index.js' import { MinimalTemplate } from '../../templates/Minimal/index.js'
import { getClientConfig } from '../../utilities/getClientConfig.js'
import { initPage } from '../../utilities/initPage/index.js' import { initPage } from '../../utilities/initPage/index.js'
import { getViewFromConfig } from './getViewFromConfig.js' import { getViewFromConfig } from './getViewFromConfig.js'
@@ -115,7 +115,7 @@ export const RootPage = async ({
redirect(adminRoute) redirect(adminRoute)
} }
const clientConfig = await getClientConfig({ const clientConfig = getClientConfig({
config, config,
i18n: initPageResult?.req.i18n, i18n: initPageResult?.req.i18n,
importMap, importMap,

View File

@@ -6,6 +6,7 @@ import type { ClientBlock, ClientField, Field } from '../../fields/config/types.
import type { DocumentPreferences } from '../../preferences/types.js' import type { DocumentPreferences } from '../../preferences/types.js'
import type { Operation, Payload, PayloadRequest } from '../../types/index.js' import type { Operation, Payload, PayloadRequest } from '../../types/index.js'
import type { import type {
ClientFieldSchemaMap,
ClientTab, ClientTab,
Data, Data,
FieldSchemaMap, FieldSchemaMap,
@@ -67,6 +68,7 @@ export type FieldPaths = {
export type ServerComponentProps = { export type ServerComponentProps = {
clientField: ClientFieldWithOptionalType clientField: ClientFieldWithOptionalType
clientFieldSchemaMap: ClientFieldSchemaMap
collectionSlug: string collectionSlug: string
data: Data data: Data
field: Field field: Field

View File

@@ -3,8 +3,16 @@ import type React from 'react'
import type { ImportMap } from '../bin/generateImportMap/index.js' import type { ImportMap } from '../bin/generateImportMap/index.js'
import type { SanitizedConfig } from '../config/types.js' import type { SanitizedConfig } from '../config/types.js'
import type { Block, Field, FieldTypes, Tab } from '../fields/config/types.js' import type {
Block,
ClientBlock,
ClientField,
Field,
FieldTypes,
Tab,
} from '../fields/config/types.js'
import type { JsonObject } from '../types/index.js' import type { JsonObject } from '../types/index.js'
import type { ClientTab } from './fields/Tabs.js'
import type { import type {
BuildFormStateArgs, BuildFormStateArgs,
Data, Data,
@@ -489,3 +497,13 @@ export type FieldSchemaMap = Map<
| Field | Field
| Tab | Tab
> >
export type ClientFieldSchemaMap = Map<
SchemaPath,
| {
fields: ClientField[]
}
| ClientBlock
| ClientField
| ClientTab
>

View File

@@ -1,7 +1,7 @@
import { checkDependencies } from './utilities/dependencies/dependencyChecker.js' import { checkDependencies } from './utilities/dependencies/dependencyChecker.js'
import { PAYLOAD_PACKAGE_LIST } from './versions/payloadPackageList.js' import { PAYLOAD_PACKAGE_LIST } from './versions/payloadPackageList.js'
export async function checkPayloadDependencies() { export function checkPayloadDependencies() {
const dependencies = [...PAYLOAD_PACKAGE_LIST] const dependencies = [...PAYLOAD_PACKAGE_LIST]
if (process.env.PAYLOAD_CI_DEPENDENCY_CHECKER !== 'true') { if (process.env.PAYLOAD_CI_DEPENDENCY_CHECKER !== 'true') {
@@ -9,7 +9,7 @@ export async function checkPayloadDependencies() {
} }
// First load. First check if there are mismatching dependency versions of payload packages // First load. First check if there are mismatching dependency versions of payload packages
await checkDependencies({ void checkDependencies({
dependencyGroups: [ dependencyGroups: [
{ {
name: 'payload', name: 'payload',

View File

@@ -9,10 +9,10 @@ import type {
} from '../../config/types.js' } from '../../config/types.js'
import type { ClientField } from '../../fields/config/client.js' import type { ClientField } from '../../fields/config/client.js'
import type { Payload } from '../../types/index.js' import type { Payload } from '../../types/index.js'
import type { SanitizedUploadConfig } from '../../uploads/types.js'
import type { SanitizedCollectionConfig } from './types.js' import type { SanitizedCollectionConfig } from './types.js'
import { createClientFields } from '../../fields/config/client.js' import { createClientFields } from '../../fields/config/client.js'
import { deepCopyObjectSimple } from '../../utilities/deepCopyObject.js'
export type ServerOnlyCollectionProperties = keyof Pick< export type ServerOnlyCollectionProperties = keyof Pick<
SanitizedCollectionConfig, SanitizedCollectionConfig,
@@ -47,12 +47,19 @@ export type ClientCollectionConfig = {
| 'preview' | 'preview'
| ServerOnlyCollectionAdminProperties | ServerOnlyCollectionAdminProperties
> >
auth?: { verify?: true } & Omit<
SanitizedCollectionConfig['auth'],
'forgotPassword' | 'strategies' | 'verify'
>
fields: ClientField[] fields: ClientField[]
labels: { labels: {
plural: StaticLabel plural: StaticLabel
singular: StaticLabel singular: StaticLabel
} }
} & Omit<SanitizedCollectionConfig, 'admin' | 'fields' | 'labels' | ServerOnlyCollectionProperties> } & Omit<
SanitizedCollectionConfig,
'admin' | 'auth' | 'fields' | 'labels' | ServerOnlyCollectionProperties
>
const serverOnlyCollectionProperties: Partial<ServerOnlyCollectionProperties>[] = [ const serverOnlyCollectionProperties: Partial<ServerOnlyCollectionProperties>[] = [
'hooks', 'hooks',
@@ -93,97 +100,147 @@ export const createClientCollectionConfig = ({
i18n: I18nClient i18n: I18nClient
importMap: ImportMap importMap: ImportMap
}): ClientCollectionConfig => { }): ClientCollectionConfig => {
const clientCollection = deepCopyObjectSimple( const clientCollection = {} as Partial<ClientCollectionConfig>
collection,
true,
) as unknown as ClientCollectionConfig
clientCollection.fields = createClientFields({ for (const key in collection) {
clientFields: clientCollection?.fields || [], if (serverOnlyCollectionProperties.includes(key as any)) {
defaultIDType, continue
fields: collection.fields,
i18n,
importMap,
})
serverOnlyCollectionProperties.forEach((key) => {
if (key in clientCollection) {
delete clientCollection[key]
} }
}) switch (key) {
case 'admin':
if ('upload' in clientCollection && typeof clientCollection.upload === 'object') { if (!collection.admin) {
serverOnlyUploadProperties.forEach((key) => { break
if (key in clientCollection.upload) {
delete clientCollection.upload[key]
}
})
if ('imageSizes' in clientCollection.upload && clientCollection.upload.imageSizes.length) {
clientCollection.upload.imageSizes = clientCollection.upload.imageSizes.map((size) => {
const sanitizedSize = { ...size }
if ('generateImageName' in sanitizedSize) {
delete sanitizedSize.generateImageName
} }
return sanitizedSize clientCollection.admin = {} as ClientCollectionConfig['admin']
}) for (const adminKey in collection.admin) {
if (serverOnlyCollectionAdminProperties.includes(adminKey as any)) {
continue
}
switch (adminKey) {
case 'description':
if (
typeof collection.admin.description === 'string' ||
typeof collection.admin.description === 'object'
) {
if (collection.admin.description) {
clientCollection.admin.description = collection.admin.description
}
} else if (typeof collection.admin.description === 'function') {
const description = collection.admin.description({ t: i18n.t })
if (description) {
clientCollection.admin.description = description
}
}
break
case 'livePreview':
clientCollection.admin.livePreview =
{} as ClientCollectionConfig['admin']['livePreview']
if (collection.admin.livePreview.breakpoints) {
clientCollection.admin.livePreview.breakpoints =
collection.admin.livePreview.breakpoints
}
break
case 'preview':
if (collection.admin.preview) {
clientCollection.admin.preview = true
}
break
default:
clientCollection.admin[adminKey] = collection.admin[adminKey]
}
}
break
case 'auth':
if (!collection.auth) {
break
}
clientCollection.auth = {} as { verify?: true } & SanitizedCollectionConfig['auth']
if (collection.auth.cookies) {
clientCollection.auth.cookies = collection.auth.cookies
}
if (collection.auth.depth !== undefined) {
// Check for undefined as it can be a number (0)
clientCollection.auth.depth = collection.auth.depth
}
if (collection.auth.disableLocalStrategy) {
clientCollection.auth.disableLocalStrategy = collection.auth.disableLocalStrategy
}
if (collection.auth.lockTime !== undefined) {
// Check for undefined as it can be a number (0)
clientCollection.auth.lockTime = collection.auth.lockTime
}
if (collection.auth.loginWithUsername) {
clientCollection.auth.loginWithUsername = collection.auth.loginWithUsername
}
if (collection.auth.maxLoginAttempts !== undefined) {
// Check for undefined as it can be a number (0)
clientCollection.auth.maxLoginAttempts = collection.auth.maxLoginAttempts
}
if (collection.auth.removeTokenFromResponses) {
clientCollection.auth.removeTokenFromResponses = collection.auth.removeTokenFromResponses
}
if (collection.auth.useAPIKey) {
clientCollection.auth.useAPIKey = collection.auth.useAPIKey
}
if (collection.auth.tokenExpiration) {
clientCollection.auth.tokenExpiration = collection.auth.tokenExpiration
}
if (collection.auth.verify) {
clientCollection.auth.verify = true
}
break
case 'fields':
clientCollection.fields = createClientFields({
defaultIDType,
fields: collection.fields,
i18n,
importMap,
})
break
case 'labels':
clientCollection.labels = {
plural:
typeof collection.labels.plural === 'function'
? collection.labels.plural({ t: i18n.t })
: collection.labels.plural,
singular:
typeof collection.labels.singular === 'function'
? collection.labels.singular({ t: i18n.t })
: collection.labels.singular,
}
break
case 'upload':
if (!collection.upload) {
break
}
clientCollection.upload = {} as SanitizedUploadConfig
for (const uploadKey in collection.upload) {
if (serverOnlyUploadProperties.includes(uploadKey as any)) {
continue
}
if (uploadKey === 'imageSizes') {
clientCollection.upload.imageSizes = collection.upload.imageSizes.map((size) => {
const sanitizedSize = { ...size }
if ('generateImageName' in sanitizedSize) {
delete sanitizedSize.generateImageName
}
return sanitizedSize
})
} else {
clientCollection.upload[uploadKey] = collection.upload[uploadKey]
}
}
break
break
default:
clientCollection[key] = collection[key]
} }
} }
if ('auth' in clientCollection && typeof clientCollection.auth === 'object') { return clientCollection as ClientCollectionConfig
delete clientCollection.auth.strategies
delete clientCollection.auth.forgotPassword
delete clientCollection.auth.verify
}
if (collection.labels) {
Object.entries(collection.labels).forEach(([labelType, collectionLabel]) => {
if (typeof collectionLabel === 'function') {
clientCollection.labels[labelType] = collectionLabel({ t: i18n.t })
}
})
}
if (!clientCollection.admin) {
clientCollection.admin = {} as ClientCollectionConfig['admin']
}
serverOnlyCollectionAdminProperties.forEach((key) => {
if (key in clientCollection.admin) {
delete clientCollection.admin[key]
}
})
if (collection.admin.preview) {
clientCollection.admin.preview = true
}
let description = undefined
if (collection.admin?.description) {
if (
typeof collection.admin?.description === 'string' ||
typeof collection.admin?.description === 'object'
) {
description = collection.admin.description
} else if (typeof collection.admin?.description === 'function') {
description = collection.admin?.description({ t: i18n.t })
}
}
if (description) {
clientCollection.admin.description = description
}
if (
'livePreview' in clientCollection.admin &&
clientCollection.admin.livePreview &&
'url' in clientCollection.admin.livePreview
) {
delete clientCollection.admin.livePreview.url
}
return clientCollection
} }
export const createClientCollectionConfigs = ({ export const createClientCollectionConfigs = ({

View File

@@ -91,6 +91,11 @@ export const findVersionByIDOperation = async <TData extends TypeWithID = any>(
return null return null
} }
if (!result.version) {
// Fallback if not selected
;(result as any).version = {}
}
// ///////////////////////////////////// // /////////////////////////////////////
// beforeRead - Collection // beforeRead - Collection
// ///////////////////////////////////// // /////////////////////////////////////

View File

@@ -93,6 +93,10 @@ export const findVersionsOperation = async <TData extends TypeWithVersion<TData>
docs: await Promise.all( docs: await Promise.all(
paginatedDocs.docs.map(async (doc) => { paginatedDocs.docs.map(async (doc) => {
const docRef = doc const docRef = doc
// Fallback if not selected
if (!docRef.version) {
;(docRef as any).version = {}
}
await collectionConfig.hooks.beforeRead.reduce(async (priorHook, hook) => { await collectionConfig.hooks.beforeRead.reduce(async (priorHook, hook) => {
await priorHook await priorHook

View File

@@ -1,4 +1,5 @@
import type { I18nClient } from '@payloadcms/translations' import type { I18nClient } from '@payloadcms/translations'
import type { DeepPartial } from 'ts-essentials'
import type { ImportMap } from '../bin/generateImportMap/index.js' import type { ImportMap } from '../bin/generateImportMap/index.js'
import type { import type {
@@ -12,7 +13,6 @@ import {
createClientCollectionConfigs, createClientCollectionConfigs,
} from '../collections/config/client.js' } from '../collections/config/client.js'
import { type ClientGlobalConfig, createClientGlobalConfigs } from '../globals/config/client.js' import { type ClientGlobalConfig, createClientGlobalConfigs } from '../globals/config/client.js'
import { deepCopyObjectSimple } from '../utilities/deepCopyObject.js'
export type ServerOnlyRootProperties = keyof Pick< export type ServerOnlyRootProperties = keyof Pick<
SanitizedConfig, SanitizedConfig,
@@ -39,7 +39,6 @@ export type ServerOnlyRootAdminProperties = keyof Pick<SanitizedConfig['admin'],
export type ClientConfig = { export type ClientConfig = {
admin: { admin: {
components: null
dependencies?: Record<string, React.ReactNode> dependencies?: Record<string, React.ReactNode>
livePreview?: Omit<LivePreviewConfig, ServerOnlyLivePreviewProperties> livePreview?: Omit<LivePreviewConfig, ServerOnlyLivePreviewProperties>
} & Omit<SanitizedConfig['admin'], 'components' | 'dependencies' | 'livePreview'> } & Omit<SanitizedConfig['admin'], 'components' | 'dependencies' | 'livePreview'>
@@ -81,56 +80,95 @@ export const createClientConfig = ({
i18n: I18nClient i18n: I18nClient
importMap: ImportMap importMap: ImportMap
}): ClientConfig => { }): ClientConfig => {
// We can use deepCopySimple here, as the clientConfig should be JSON serializable anyways, since it will be sent from server => client const clientConfig = {} as DeepPartial<ClientConfig>
const clientConfig = deepCopyObjectSimple(config, true) as unknown as ClientConfig
for (const key of serverOnlyConfigProperties) { for (const key in config) {
if (key in clientConfig) { if (serverOnlyConfigProperties.includes(key as any)) {
delete clientConfig[key] continue
}
switch (key) {
case 'admin':
clientConfig.admin = {
autoLogin: config.admin.autoLogin,
avatar: config.admin.avatar,
custom: config.admin.custom,
dateFormat: config.admin.dateFormat,
dependencies: config.admin.dependencies,
disable: config.admin.disable,
importMap: config.admin.importMap,
meta: config.admin.meta,
routes: config.admin.routes,
theme: config.admin.theme,
user: config.admin.user,
}
if (config.admin.livePreview) {
clientConfig.admin.livePreview = {}
if (config.admin.livePreview.breakpoints) {
clientConfig.admin.livePreview.breakpoints = config.admin.livePreview.breakpoints
}
}
break
case 'collections':
;(clientConfig.collections as ClientCollectionConfig[]) = createClientCollectionConfigs({
collections: config.collections,
defaultIDType: config.db.defaultIDType,
i18n,
importMap,
})
break
case 'globals':
;(clientConfig.globals as ClientGlobalConfig[]) = createClientGlobalConfigs({
defaultIDType: config.db.defaultIDType,
globals: config.globals,
i18n,
importMap,
})
break
case 'i18n':
clientConfig.i18n = {
fallbackLanguage: config.i18n.fallbackLanguage,
translations: config.i18n.translations,
}
break
case 'localization':
if (typeof config.localization === 'object' && config.localization) {
clientConfig.localization = {}
if (config.localization.defaultLocale) {
clientConfig.localization.defaultLocale = config.localization.defaultLocale
}
if (config.localization.fallback) {
clientConfig.localization.fallback = config.localization.fallback
}
if (config.localization.localeCodes) {
clientConfig.localization.localeCodes = config.localization.localeCodes
}
if (config.localization.locales) {
clientConfig.localization.locales = []
for (const locale of config.localization.locales) {
if (locale) {
const clientLocale: Partial<(typeof config.localization.locales)[0]> = {}
if (locale.code) {
clientLocale.code = locale.code
}
if (locale.fallbackLocale) {
clientLocale.fallbackLocale = locale.fallbackLocale
}
if (locale.label) {
clientLocale.label = locale.label
}
if (locale.rtl) {
clientLocale.rtl = locale.rtl
}
clientConfig.localization.locales.push(clientLocale)
}
}
}
}
break
default:
clientConfig[key] = config[key]
} }
} }
return clientConfig as ClientConfig
if ('localization' in clientConfig && clientConfig.localization) {
for (const locale of clientConfig.localization.locales) {
delete locale.toString
}
}
if (
'i18n' in clientConfig &&
'supportedLanguages' in clientConfig.i18n &&
clientConfig.i18n.supportedLanguages
) {
delete clientConfig.i18n.supportedLanguages
}
if (!clientConfig.admin) {
clientConfig.admin = {} as ClientConfig['admin']
}
clientConfig.admin.components = null
if (
'livePreview' in clientConfig.admin &&
clientConfig.admin.livePreview &&
'url' in clientConfig.admin.livePreview
) {
delete clientConfig.admin.livePreview.url
}
clientConfig.collections = createClientCollectionConfigs({
collections: config.collections,
defaultIDType: config.db.defaultIDType,
i18n,
importMap,
})
clientConfig.globals = createClientGlobalConfigs({
defaultIDType: config.db.defaultIDType,
globals: config.globals,
i18n,
importMap,
})
return clientConfig
} }

View File

@@ -39,61 +39,97 @@ export type ServerOnlyFieldProperties =
export type ServerOnlyFieldAdminProperties = keyof Pick<FieldBase['admin'], 'condition'> export type ServerOnlyFieldAdminProperties = keyof Pick<FieldBase['admin'], 'condition'>
const serverOnlyFieldProperties: Partial<ServerOnlyFieldProperties>[] = [
'hooks',
'access',
'validate',
'defaultValue',
'filterOptions', // This is a `relationship` and `upload` only property
'editor', // This is a `richText` only property
'custom',
'typescriptSchema',
'dbName', // can be a function
'enumName', // can be a function
// the following props are handled separately (see below):
// `label`
// `fields`
// `blocks`
// `tabs`
// `admin`
]
const serverOnlyFieldAdminProperties: Partial<ServerOnlyFieldAdminProperties>[] = ['condition']
type FieldWithDescription = {
admin: AdminClient
} & ClientField
export const createClientField = ({ export const createClientField = ({
clientField = {} as ClientField,
defaultIDType, defaultIDType,
field: incomingField, field: incomingField,
i18n, i18n,
importMap, importMap,
}: { }: {
clientField?: ClientField
defaultIDType: Payload['config']['db']['defaultIDType'] defaultIDType: Payload['config']['db']['defaultIDType']
field: Field field: Field
i18n: I18nClient i18n: I18nClient
importMap: ImportMap importMap: ImportMap
}): ClientField => { }): ClientField => {
const serverOnlyFieldProperties: Partial<ServerOnlyFieldProperties>[] = [ const clientField: ClientField = {} as ClientField
'hooks',
'access',
'validate',
'defaultValue',
'filterOptions', // This is a `relationship` and `upload` only property
'editor', // This is a `richText` only property
'custom',
'typescriptSchema',
'dbName', // can be a function
'enumName', // can be a function
// the following props are handled separately (see below):
// `label`
// `fields`
// `blocks`
// `tabs`
// `admin`
]
clientField.admin = clientField.admin || {}
// clientField.admin.readOnly = true
serverOnlyFieldProperties.forEach((key) => {
if (key in clientField) {
delete clientField[key]
}
})
const isHidden = 'hidden' in incomingField && incomingField?.hidden const isHidden = 'hidden' in incomingField && incomingField?.hidden
const disabledFromAdmin = const disabledFromAdmin =
incomingField?.admin && 'disabled' in incomingField.admin && incomingField.admin.disabled incomingField?.admin && 'disabled' in incomingField.admin && incomingField.admin.disabled
if (fieldAffectsData(clientField) && (isHidden || disabledFromAdmin)) { if (fieldAffectsData(incomingField) && (isHidden || disabledFromAdmin)) {
return null return null
} }
if ( for (const key in incomingField) {
'label' in clientField && if (serverOnlyFieldProperties.includes(key as any)) {
'label' in incomingField && continue
typeof incomingField.label === 'function' }
) { switch (key) {
clientField.label = incomingField.label({ t: i18n.t }) case 'admin':
if (!incomingField.admin) {
break
}
clientField.admin = {} as AdminClient
for (const adminKey in incomingField.admin) {
if (serverOnlyFieldAdminProperties.includes(adminKey as any)) {
continue
}
switch (adminKey) {
case 'description':
if ('description' in incomingField.admin) {
if (typeof incomingField.admin?.description !== 'function') {
;(clientField as FieldWithDescription).admin.description =
incomingField.admin.description
}
}
break
default:
clientField.admin[adminKey] = incomingField.admin[adminKey]
}
}
break
case 'blocks':
case 'fields':
case 'tabs':
// Skip - we handle sub-fields in the switch below
break
case 'label':
//@ts-expect-error - would need to type narrow
if (typeof incomingField.label === 'function') {
//@ts-expect-error - would need to type narrow
clientField.label = incomingField.label({ t: i18n.t })
} else {
//@ts-expect-error - would need to type narrow
clientField.label = incomingField.label
}
break
default:
clientField[key] = incomingField[key]
}
} }
switch (incomingField.type) { switch (incomingField.type) {
@@ -108,7 +144,6 @@ export const createClientField = ({
} }
field.fields = createClientFields({ field.fields = createClientFields({
clientFields: field.fields,
defaultIDType, defaultIDType,
disableAddingID: incomingField.type !== 'array', disableAddingID: incomingField.type !== 'array',
fields: incomingField.fields, fields: incomingField.fields,
@@ -167,7 +202,6 @@ export const createClientField = ({
} }
clientBlock.fields = createClientFields({ clientBlock.fields = createClientFields({
clientFields: clientBlock.fields,
defaultIDType, defaultIDType,
fields: block.fields, fields: block.fields,
i18n, i18n,
@@ -225,24 +259,29 @@ export const createClientField = ({
const field = clientField as unknown as TabsFieldClient const field = clientField as unknown as TabsFieldClient
if (incomingField.tabs?.length) { if (incomingField.tabs?.length) {
field.tabs = []
for (let i = 0; i < incomingField.tabs.length; i++) { for (let i = 0; i < incomingField.tabs.length; i++) {
const tab = incomingField.tabs[i] const tab = incomingField.tabs[i]
const clientTab = field.tabs[i] const clientTab = {} as unknown as TabsFieldClient['tabs'][0]
serverOnlyFieldProperties.forEach((key) => { for (const key in tab) {
if (key in clientTab) { if (serverOnlyFieldProperties.includes(key as any)) {
delete clientTab[key] continue
} }
}) if (key === 'fields') {
clientTab.fields = createClientFields({
clientTab.fields = createClientFields({ defaultIDType,
clientFields: clientTab.fields, disableAddingID: true,
defaultIDType, fields: tab.fields,
disableAddingID: true, i18n,
fields: tab.fields, importMap,
i18n, })
importMap, } else {
}) clientTab[key] = tab[key]
}
}
field.tabs[i] = clientTab
} }
} }
@@ -253,70 +292,43 @@ export const createClientField = ({
break break
} }
const serverOnlyFieldAdminProperties: Partial<ServerOnlyFieldAdminProperties>[] = ['condition']
if (!clientField.admin) {
clientField.admin = {} as AdminClient
}
serverOnlyFieldAdminProperties.forEach((key) => {
if (key in clientField.admin) {
delete clientField.admin[key]
}
})
type FieldWithDescription = {
admin: AdminClient
} & ClientField
if (incomingField.admin && 'description' in incomingField.admin) {
if (typeof incomingField.admin?.description === 'function') {
delete (clientField as FieldWithDescription).admin.description
} else {
;(clientField as FieldWithDescription).admin.description = incomingField.admin.description
}
}
return clientField return clientField
} }
export const createClientFields = ({ export const createClientFields = ({
clientFields,
defaultIDType, defaultIDType,
disableAddingID, disableAddingID,
fields, fields,
i18n, i18n,
importMap, importMap,
}: { }: {
clientFields: ClientField[]
defaultIDType: Payload['config']['db']['defaultIDType'] defaultIDType: Payload['config']['db']['defaultIDType']
disableAddingID?: boolean disableAddingID?: boolean
fields: Field[] fields: Field[]
i18n: I18nClient i18n: I18nClient
importMap: ImportMap importMap: ImportMap
}): ClientField[] => { }): ClientField[] => {
const newClientFields: ClientField[] = [] const clientFields: ClientField[] = []
for (let i = 0; i < fields.length; i++) { for (let i = 0; i < fields.length; i++) {
const field = fields[i] const field = fields[i]
const newField = createClientField({ const clientField = createClientField({
clientField: clientFields[i],
defaultIDType, defaultIDType,
field, field,
i18n, i18n,
importMap, importMap,
}) })
if (newField) { if (clientField) {
newClientFields.push(newField) clientFields.push(clientField)
} }
} }
const hasID = flattenTopLevelFields(fields).some((f) => fieldAffectsData(f) && f.name === 'id') const hasID = flattenTopLevelFields(fields).some((f) => fieldAffectsData(f) && f.name === 'id')
if (!disableAddingID && !hasID) { if (!disableAddingID && !hasID) {
newClientFields.push({ clientFields.push({
name: 'id', name: 'id',
type: defaultIDType, type: defaultIDType,
admin: { admin: {
@@ -330,5 +342,5 @@ export const createClientFields = ({
}) })
} }
return newClientFields return clientFields
} }

View File

@@ -1,9 +1,7 @@
import type { ClientField, Field, TabAsField } from './config/types.js' import type { ClientField, Field, TabAsField, TabAsFieldClient } from './config/types.js'
import { fieldAffectsData } from './config/types.js'
type Args = { type Args = {
field: ClientField | Field | TabAsField field: ClientField | Field | TabAsField | TabAsFieldClient
index: number index: number
parentIndexPath: string parentIndexPath: string
parentPath: string parentPath: string

View File

@@ -10,14 +10,16 @@ import type { Payload } from '../../types/index.js'
import type { SanitizedGlobalConfig } from './types.js' import type { SanitizedGlobalConfig } from './types.js'
import { type ClientField, createClientFields } from '../../fields/config/client.js' import { type ClientField, createClientFields } from '../../fields/config/client.js'
import { deepCopyObjectSimple } from '../../utilities/deepCopyObject.js'
export type ServerOnlyGlobalProperties = keyof Pick< export type ServerOnlyGlobalProperties = keyof Pick<
SanitizedGlobalConfig, SanitizedGlobalConfig,
'access' | 'admin' | 'custom' | 'endpoints' | 'fields' | 'flattenedFields' | 'hooks' 'access' | 'admin' | 'custom' | 'endpoints' | 'fields' | 'flattenedFields' | 'hooks'
> >
export type ServerOnlyGlobalAdminProperties = keyof Pick<SanitizedGlobalConfig['admin'], 'hidden'> export type ServerOnlyGlobalAdminProperties = keyof Pick<
SanitizedGlobalConfig['admin'],
'components' | 'hidden'
>
export type ClientGlobalConfig = { export type ClientGlobalConfig = {
admin: { admin: {
@@ -40,7 +42,10 @@ const serverOnlyProperties: Partial<ServerOnlyGlobalProperties>[] = [
// `admin` is handled separately // `admin` is handled separately
] ]
const serverOnlyGlobalAdminProperties: Partial<ServerOnlyGlobalAdminProperties>[] = ['hidden'] const serverOnlyGlobalAdminProperties: Partial<ServerOnlyGlobalAdminProperties>[] = [
'hidden',
'components',
]
export const createClientGlobalConfig = ({ export const createClientGlobalConfig = ({
defaultIDType, defaultIDType,
@@ -53,44 +58,55 @@ export const createClientGlobalConfig = ({
i18n: I18nClient i18n: I18nClient
importMap: ImportMap importMap: ImportMap
}): ClientGlobalConfig => { }): ClientGlobalConfig => {
const clientGlobal = deepCopyObjectSimple(global, true) as unknown as ClientGlobalConfig const clientGlobal = {} as ClientGlobalConfig
clientGlobal.fields = createClientFields({ for (const key in global) {
clientFields: clientGlobal?.fields || [], if (serverOnlyProperties.includes(key as any)) {
defaultIDType, continue
fields: global.fields,
i18n,
importMap,
})
serverOnlyProperties.forEach((key) => {
if (key in clientGlobal) {
delete clientGlobal[key]
} }
}) switch (key) {
case 'admin':
if (!clientGlobal.admin) { if (!global.admin) {
clientGlobal.admin = {} as ClientGlobalConfig['admin'] break
} }
clientGlobal.admin = {} as ClientGlobalConfig['admin']
serverOnlyGlobalAdminProperties.forEach((key) => { for (const adminKey in global.admin) {
if (key in clientGlobal.admin) { if (serverOnlyGlobalAdminProperties.includes(adminKey as any)) {
delete clientGlobal.admin[key] continue
}
switch (adminKey) {
case 'livePreview':
if (!global.admin.livePreview) {
break
}
clientGlobal.admin.livePreview = {}
if (global.admin.livePreview.breakpoints) {
clientGlobal.admin.livePreview.breakpoints = global.admin.livePreview.breakpoints
}
break
case 'preview':
if (global.admin.preview) {
clientGlobal.admin.preview = true
}
break
default:
clientGlobal.admin[adminKey] = global.admin[adminKey]
}
}
break
case 'fields':
clientGlobal.fields = createClientFields({
defaultIDType,
fields: global.fields,
i18n,
importMap,
})
break
default: {
clientGlobal[key] = global[key]
break
}
} }
})
if (global.admin.preview) {
clientGlobal.admin.preview = true
}
clientGlobal.admin.components = null
if (
'livePreview' in clientGlobal.admin &&
clientGlobal.admin.livePreview &&
'url' in clientGlobal.admin.livePreview
) {
delete clientGlobal.admin.livePreview.url
} }
return clientGlobal return clientGlobal

View File

@@ -90,6 +90,10 @@ export const findVersionByIDOperation = async <T extends TypeWithVersion<T> = an
// Clone the result - it may have come back memoized // Clone the result - it may have come back memoized
let result: any = deepCopyObjectSimple(results[0]) let result: any = deepCopyObjectSimple(results[0])
if (!result.version) {
result.version = {}
}
// Patch globalType onto version doc // Patch globalType onto version doc
result.version.globalType = globalConfig.slug result.version.globalType = globalConfig.slug

View File

@@ -90,6 +90,10 @@ export const findVersionsOperation = async <T extends TypeWithVersion<T>>(
...paginatedDocs, ...paginatedDocs,
docs: await Promise.all( docs: await Promise.all(
paginatedDocs.docs.map(async (data) => { paginatedDocs.docs.map(async (data) => {
if (!data.version) {
// Fallback if not selected
;(data as any).version = {}
}
return { return {
...data, ...data,
version: await afterRead<T>({ version: await afterRead<T>({

View File

@@ -555,7 +555,7 @@ export class BasePayload {
!checkedDependencies !checkedDependencies
) { ) {
checkedDependencies = true checkedDependencies = true
await checkPayloadDependencies() void checkPayloadDependencies()
} }
this.importMap = options.importMap this.importMap = options.importMap
@@ -782,6 +782,12 @@ export const reload = async (
if (payload.db.connect) { if (payload.db.connect) {
await payload.db.connect({ hotReload: true }) await payload.db.connect({ hotReload: true })
} }
global._payload_clientConfig = null
global._payload_schemaMap = null
global._payload_clientSchemaMap = null
global._payload_doNotCacheClientConfig = true // This will help refreshing the client config cache more reliably. If you remove this, please test HMR + client config refreshing (do new fields appear in the document?)
global._payload_doNotCacheSchemaMap = true
global._payload_doNotCacheClientSchemaMap = true
} }
export const getPayload = async ( export const getPayload = async (

View File

@@ -212,10 +212,8 @@ export const BlockComponent: React.FC<Props> = (props) => {
editor.update(() => { editor.update(() => {
const node = $getNodeByKey(nodeKey) const node = $getNodeByKey(nodeKey)
if (node && $isBlockNode(node)) { if (node && $isBlockNode(node)) {
const newData = { const newData = newFormStateData
...newFormStateData, newData.blockType = formData.blockType
blockType: formData.blockType,
}
node.setFields(newData) node.setFields(newData)
} }
}) })

View File

@@ -14,7 +14,6 @@ import type { JSX } from 'react'
import { DecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode.js' import { DecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode.js'
import ObjectID from 'bson-objectid' import ObjectID from 'bson-objectid'
import { deepCopyObjectSimple } from 'payload/shared'
type BaseBlockFields<TBlockFields extends JsonObject = JsonObject> = { type BaseBlockFields<TBlockFields extends JsonObject = JsonObject> = {
/** Block form data */ /** Block form data */
@@ -121,10 +120,8 @@ export class ServerBlockNode extends DecoratorBlockNode {
} }
setFields(fields: BlockFields): void { setFields(fields: BlockFields): void {
const fieldsCopy = deepCopyObjectSimple(fields)
const writable = this.getWritable() const writable = this.getWritable()
writable.__fields = fieldsCopy writable.__fields = fields
} }
} }

View File

@@ -13,7 +13,6 @@ import type { JSX } from 'react'
import ObjectID from 'bson-objectid' import ObjectID from 'bson-objectid'
import { DecoratorNode } from 'lexical' import { DecoratorNode } from 'lexical'
import { deepCopyObjectSimple } from 'payload/shared'
export type InlineBlockFields = { export type InlineBlockFields = {
/** Block form data */ /** Block form data */
@@ -108,10 +107,8 @@ export class ServerInlineBlockNode extends DecoratorNode<null | React.ReactEleme
} }
setFields(fields: InlineBlockFields): void { setFields(fields: InlineBlockFields): void {
const fieldsCopy = deepCopyObjectSimple(fields)
const writable = this.getWritable() const writable = this.getWritable()
writable.__fields = fieldsCopy writable.__fields = fields
} }
updateDOM(): boolean { updateDOM(): boolean {

View File

@@ -9,7 +9,6 @@ import type {
import escapeHTML from 'escape-html' import escapeHTML from 'escape-html'
import { sanitizeFields } from 'payload' import { sanitizeFields } from 'payload'
import { deepCopyObject } from 'payload/shared'
import type { ClientProps } from '../client/index.js' import type { ClientProps } from '../client/index.js'
@@ -78,7 +77,7 @@ export const LinkFeature = createServerFeature<
const validRelationships = _config.collections.map((c) => c.slug) || [] const validRelationships = _config.collections.map((c) => c.slug) || []
const _transformedFields = transformExtraFields( const _transformedFields = transformExtraFields(
props.fields ? deepCopyObject(props.fields) : null, props.fields ? props.fields : null,
_config, _config,
props.enabledCollections, props.enabledCollections,
props.disabledCollections, props.disabledCollections,
@@ -97,7 +96,7 @@ export const LinkFeature = createServerFeature<
// the text field is not included in the node data. // the text field is not included in the node data.
// Thus, for tasks like validation, we do not want to pass it a text field in the schema which will never have data. // Thus, for tasks like validation, we do not want to pass it a text field in the schema which will never have data.
// Otherwise, it will cause a validation error (field is required). // Otherwise, it will cause a validation error (field is required).
const sanitizedFieldsWithoutText = deepCopyObject(sanitizedFields).filter( const sanitizedFieldsWithoutText = sanitizedFields.filter(
(field) => !('name' in field) || field.name !== 'text', (field) => !('name' in field) || field.name !== 'text',
) )

View File

@@ -29,7 +29,9 @@ export const RscEntryLexicalField: React.FC<
const field: RichTextFieldType = args.field as RichTextFieldType const field: RichTextFieldType = args.field as RichTextFieldType
const path = args.path ?? (args.clientField as RichTextFieldClient).name const path = args.path ?? (args.clientField as RichTextFieldClient).name
const schemaPath = args.schemaPath ?? path const schemaPath = args.schemaPath ?? path
const { clientFeatures, featureClientSchemaMap } = initLexicalFeatures({ const { clientFeatures, featureClientSchemaMap } = initLexicalFeatures({
clientFieldSchemaMap: args.clientFieldSchemaMap,
fieldSchemaMap: args.fieldSchemaMap, fieldSchemaMap: args.fieldSchemaMap,
i18n: args.i18n, i18n: args.i18n,
path, path,
@@ -43,6 +45,7 @@ export const RscEntryLexicalField: React.FC<
initialLexicalFormState = await buildInitialState({ initialLexicalFormState = await buildInitialState({
context: { context: {
id: args.id, id: args.id,
clientFieldSchemaMap: args.clientFieldSchemaMap,
collectionSlug: args.collectionSlug, collectionSlug: args.collectionSlug,
field, field,
fieldSchemaMap: args.fieldSchemaMap, fieldSchemaMap: args.fieldSchemaMap,
@@ -60,7 +63,7 @@ export const RscEntryLexicalField: React.FC<
const props: LexicalRichTextFieldProps = { const props: LexicalRichTextFieldProps = {
admin: args.admin, admin: args.admin,
clientFeatures, clientFeatures,
featureClientSchemaMap, featureClientSchemaMap, // TODO: Does client need this? Why cant this just live in the server
field: args.clientField as RichTextFieldClient, field: args.clientField as RichTextFieldClient,
forceRender: args.forceRender, forceRender: args.forceRender,
initialLexicalFormState, initialLexicalFormState,

View File

@@ -0,0 +1,46 @@
import type { SanitizedConfig } from 'payload'
import { cache } from 'react'
import { type SanitizedServerEditorConfig } from './index.js'
import { defaultEditorConfig } from './lexical/config/server/default.js'
import { sanitizeServerEditorConfig } from './lexical/config/server/sanitize.js'
let cachedDefaultSanitizedServerEditorConfig:
| null
| Promise<SanitizedServerEditorConfig>
| SanitizedServerEditorConfig = (global as any)
._payload_lexical_defaultSanitizedServerEditorConfig
if (!cachedDefaultSanitizedServerEditorConfig) {
cachedDefaultSanitizedServerEditorConfig = (
global as any
)._payload_lexical_defaultSanitizedServerEditorConfig = null
}
export const getDefaultSanitizedEditorConfig = cache(
async (args: {
config: SanitizedConfig
parentIsLocalized: boolean
}): Promise<SanitizedServerEditorConfig> => {
const { config, parentIsLocalized } = args
if (cachedDefaultSanitizedServerEditorConfig) {
return await cachedDefaultSanitizedServerEditorConfig
}
cachedDefaultSanitizedServerEditorConfig = sanitizeServerEditorConfig(
defaultEditorConfig,
config,
parentIsLocalized,
)
;(global as any).payload_lexical_defaultSanitizedServerEditorConfig =
cachedDefaultSanitizedServerEditorConfig
cachedDefaultSanitizedServerEditorConfig = await cachedDefaultSanitizedServerEditorConfig
;(global as any).payload_lexical_defaultSanitizedServerEditorConfig =
cachedDefaultSanitizedServerEditorConfig
return cachedDefaultSanitizedServerEditorConfig
},
)

View File

@@ -7,8 +7,6 @@ import {
beforeChangeTraverseFields, beforeChangeTraverseFields,
beforeValidateTraverseFields, beforeValidateTraverseFields,
checkDependencies, checkDependencies,
deepCopyObject,
deepCopyObjectSimple,
withNullableJSONSchemaType, withNullableJSONSchemaType,
} from 'payload' } from 'payload'
@@ -21,89 +19,80 @@ import type {
LexicalRichTextAdapterProvider, LexicalRichTextAdapterProvider,
} from './types.js' } from './types.js'
import { getDefaultSanitizedEditorConfig } from './getDefaultSanitizedEditorConfig.js'
import { i18n } from './i18n.js' import { i18n } from './i18n.js'
import { defaultEditorConfig, defaultEditorFeatures } from './lexical/config/server/default.js' import { defaultEditorConfig, defaultEditorFeatures } from './lexical/config/server/default.js'
import { loadFeatures } from './lexical/config/server/loader.js' import { loadFeatures } from './lexical/config/server/loader.js'
import { import { sanitizeServerFeatures } from './lexical/config/server/sanitize.js'
sanitizeServerEditorConfig,
sanitizeServerFeatures,
} from './lexical/config/server/sanitize.js'
import { populateLexicalPopulationPromises } from './populateGraphQL/populateLexicalPopulationPromises.js' import { populateLexicalPopulationPromises } from './populateGraphQL/populateLexicalPopulationPromises.js'
import { getGenerateImportMap } from './utilities/generateImportMap.js' import { getGenerateImportMap } from './utilities/generateImportMap.js'
import { getGenerateSchemaMap } from './utilities/generateSchemaMap.js' import { getGenerateSchemaMap } from './utilities/generateSchemaMap.js'
import { recurseNodeTree } from './utilities/recurseNodeTree.js' import { recurseNodeTree } from './utilities/recurseNodeTree.js'
import { richTextValidateHOC } from './validate/index.js' import { richTextValidateHOC } from './validate/index.js'
let defaultSanitizedServerEditorConfig: null | SanitizedServerEditorConfig = null
let checkedDependencies = false let checkedDependencies = false
export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapterProvider { export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapterProvider {
if (
process.env.NODE_ENV !== 'production' &&
process.env.PAYLOAD_DISABLE_DEPENDENCY_CHECKER !== 'true' &&
!checkedDependencies
) {
checkedDependencies = true
void checkDependencies({
dependencyGroups: [
{
name: 'lexical',
dependencies: [
'lexical',
'@lexical/headless',
'@lexical/link',
'@lexical/list',
'@lexical/mark',
'@lexical/react',
'@lexical/rich-text',
'@lexical/selection',
'@lexical/utils',
],
targetVersion: '0.20.0',
},
],
})
}
return async ({ config, isRoot, parentIsLocalized }) => { return async ({ config, isRoot, parentIsLocalized }) => {
if (
process.env.NODE_ENV !== 'production' &&
process.env.PAYLOAD_DISABLE_DEPENDENCY_CHECKER !== 'true' &&
!checkedDependencies
) {
checkedDependencies = true
await checkDependencies({
dependencyGroups: [
{
name: 'lexical',
dependencies: [
'lexical',
'@lexical/headless',
'@lexical/link',
'@lexical/list',
'@lexical/mark',
'@lexical/react',
'@lexical/rich-text',
'@lexical/selection',
'@lexical/utils',
],
targetVersion: '0.20.0',
},
],
})
}
let features: FeatureProviderServer<unknown, unknown, unknown>[] = [] let features: FeatureProviderServer<unknown, unknown, unknown>[] = []
let resolvedFeatureMap: ResolvedServerFeatureMap let resolvedFeatureMap: ResolvedServerFeatureMap
let finalSanitizedEditorConfig: SanitizedServerEditorConfig // For server only let finalSanitizedEditorConfig: SanitizedServerEditorConfig // For server only
if (!props || (!props.features && !props.lexical)) { if (!props || (!props.features && !props.lexical)) {
if (!defaultSanitizedServerEditorConfig) { finalSanitizedEditorConfig = await getDefaultSanitizedEditorConfig({
defaultSanitizedServerEditorConfig = await sanitizeServerEditorConfig( config,
defaultEditorConfig, parentIsLocalized,
config, })
parentIsLocalized,
)
features = deepCopyObject(defaultEditorFeatures)
}
finalSanitizedEditorConfig = deepCopyObject(defaultSanitizedServerEditorConfig) features = defaultEditorFeatures
delete finalSanitizedEditorConfig.lexical // We don't want to send the default lexical editor config to the client
resolvedFeatureMap = finalSanitizedEditorConfig.resolvedFeatureMap resolvedFeatureMap = finalSanitizedEditorConfig.resolvedFeatureMap
} else { } else {
const rootEditor = config.editor if (props.features && typeof props.features === 'function') {
let rootEditorFeatures: FeatureProviderServer<unknown, unknown, unknown>[] = [] const rootEditor = config.editor
if (typeof rootEditor === 'object' && 'features' in rootEditor) { let rootEditorFeatures: FeatureProviderServer<unknown, unknown, unknown>[] = []
rootEditorFeatures = (rootEditor as LexicalRichTextAdapter).features if (typeof rootEditor === 'object' && 'features' in rootEditor) {
rootEditorFeatures = (rootEditor as LexicalRichTextAdapter).features
}
features = props.features({
defaultFeatures: defaultEditorFeatures,
rootFeatures: rootEditorFeatures,
})
} else {
features = props.features as FeatureProviderServer<unknown, unknown, unknown>[]
} }
features =
props.features && typeof props.features === 'function'
? props.features({
defaultFeatures: deepCopyObject(defaultEditorFeatures),
rootFeatures: rootEditorFeatures,
})
: (props.features as FeatureProviderServer<unknown, unknown, unknown>[])
if (!features) { if (!features) {
features = deepCopyObject(defaultEditorFeatures) features = defaultEditorFeatures
} }
const lexical = props.lexical ?? deepCopyObjectSimple(defaultEditorConfig.lexical)! const lexical = props.lexical ?? defaultEditorConfig.lexical
resolvedFeatureMap = await loadFeatures({ resolvedFeatureMap = await loadFeatures({
config, config,

View File

@@ -2,6 +2,7 @@ import type { SanitizedConfig } from 'payload'
import type { import type {
FeatureProviderServer, FeatureProviderServer,
ResolvedServerFeature,
ResolvedServerFeatureMap, ResolvedServerFeatureMap,
ServerFeatureProviderMap, ServerFeatureProviderMap,
} from '../../../features/typesServer.js' } from '../../../features/typesServer.js'
@@ -186,14 +187,21 @@ export async function loadFeatures({
unSanitizedEditorConfig, unSanitizedEditorConfig,
}) })
: featureProvider.feature : featureProvider.feature
resolvedFeatures.set(featureProvider.key, {
...feature, const resolvedFeature: ResolvedServerFeature<any, any> = feature as ResolvedServerFeature<
dependencies: featureProvider.dependencies!, any,
dependenciesPriority: featureProvider.dependenciesPriority!, any
dependenciesSoft: featureProvider.dependenciesSoft!, >
key: featureProvider.key,
order: loaded, // All these new properties would be added to the feature, as it's mutated. However, this does not cause any damage and allows
}) // us to prevent an unnecessary spread operation.
resolvedFeature.key = featureProvider.key
resolvedFeature.order = loaded
resolvedFeature.dependencies = featureProvider.dependencies!
resolvedFeature.dependenciesPriority = featureProvider.dependenciesPriority!
resolvedFeature.dependenciesSoft = featureProvider.dependenciesSoft!
resolvedFeatures.set(featureProvider.key, resolvedFeature)
loaded++ loaded++
} }

View File

@@ -80,10 +80,11 @@ export type LexicalRichTextAdapterProvider =
parentIsLocalized: boolean parentIsLocalized: boolean
}) => Promise<LexicalRichTextAdapter> }) => Promise<LexicalRichTextAdapter>
export type SingleFeatureClientSchemaMap = {
[key: string]: ClientField[]
}
export type FeatureClientSchemaMap = { export type FeatureClientSchemaMap = {
[featureKey: string]: { [featureKey: string]: SingleFeatureClientSchemaMap
[key: string]: ClientField[]
}
} }
export type LexicalRichTextFieldProps = { export type LexicalRichTextFieldProps = {

View File

@@ -1,5 +1,6 @@
import type { SerializedLexicalNode } from 'lexical' import type { SerializedLexicalNode } from 'lexical'
import type { import type {
ClientFieldSchemaMap,
DocumentPreferences, DocumentPreferences,
FieldSchemaMap, FieldSchemaMap,
FormState, FormState,
@@ -22,6 +23,7 @@ export type InitialLexicalFormState = {
type Props = { type Props = {
context: { context: {
clientFieldSchemaMap: ClientFieldSchemaMap
collectionSlug: string collectionSlug: string
field: RichTextField field: RichTextField
fieldSchemaMap: FieldSchemaMap fieldSchemaMap: FieldSchemaMap
@@ -68,6 +70,7 @@ export async function buildInitialState({
const formStateResult = await fieldSchemasToFormState({ const formStateResult = await fieldSchemasToFormState({
id: context.id, id: context.id,
clientFieldSchemaMap: context.clientFieldSchemaMap,
collectionSlug: context.collectionSlug, collectionSlug: context.collectionSlug,
data: blockNode.fields, data: blockNode.fields,
fields: (context.fieldSchemaMap.get(schemaFieldsPath) as any)?.fields, fields: (context.fieldSchemaMap.get(schemaFieldsPath) as any)?.fields,

View File

@@ -1,18 +1,13 @@
import type { I18nClient } from '@payloadcms/translations' import type { I18nClient } from '@payloadcms/translations'
import { import { type ClientFieldSchemaMap, type FieldSchemaMap, type Payload } from 'payload'
type ClientField,
createClientFields,
deepCopyObjectSimple,
type FieldSchemaMap,
type Payload,
} from 'payload'
import { getFromImportMap } from 'payload/shared' import { getFromImportMap } from 'payload/shared'
import type { FeatureProviderProviderClient } from '../features/typesClient.js' import type { FeatureProviderProviderClient } from '../features/typesClient.js'
import type { SanitizedServerEditorConfig } from '../lexical/config/types.js' import type { SanitizedServerEditorConfig } from '../lexical/config/types.js'
import type { FeatureClientSchemaMap, LexicalRichTextFieldProps } from '../types.js' import type { FeatureClientSchemaMap, LexicalRichTextFieldProps } from '../types.js'
type Args = { type Args = {
clientFieldSchemaMap: ClientFieldSchemaMap
fieldSchemaMap: FieldSchemaMap fieldSchemaMap: FieldSchemaMap
i18n: I18nClient i18n: I18nClient
path: string path: string
@@ -27,9 +22,6 @@ export function initLexicalFeatures(args: Args): {
} { } {
const clientFeatures: LexicalRichTextFieldProps['clientFeatures'] = {} const clientFeatures: LexicalRichTextFieldProps['clientFeatures'] = {}
const fieldSchemaMap = Object.fromEntries(new Map(args.fieldSchemaMap))
//&const value = deepCopyObjectSimple(args.fieldState.value)
// turn args.resolvedFeatureMap into an array of [key, value] pairs, ordered by value.order, lowest order first: // turn args.resolvedFeatureMap into an array of [key, value] pairs, ordered by value.order, lowest order first:
const resolvedFeatureMapArray = Array.from( const resolvedFeatureMapArray = Array.from(
args.sanitizedEditorConfig.resolvedFeatureMap.entries(), args.sanitizedEditorConfig.resolvedFeatureMap.entries(),
@@ -84,64 +76,14 @@ export function initLexicalFeatures(args: Args): {
featureKey, featureKey,
].join('.') ].join('.')
const featurePath = [...args.path.split('.'), 'lexical_internal_feature', featureKey].join(
'.',
)
// Like args.fieldSchemaMap, we only want to include the sub-fields of the current feature
const featureSchemaMap: typeof fieldSchemaMap = {}
for (const key in fieldSchemaMap) {
const state = fieldSchemaMap[key]
if (key.startsWith(featureSchemaPath)) {
featureSchemaMap[key] = state
}
}
featureClientSchemaMap[featureKey] = {} featureClientSchemaMap[featureKey] = {}
for (const key in featureSchemaMap) { // Like args.fieldSchemaMap, we only want to include the sub-fields of the current feature
const state = featureSchemaMap[key] for (const [key, entry] of args.clientFieldSchemaMap.entries()) {
if (key.startsWith(featureSchemaPath)) {
const clientFields = createClientFields({ featureClientSchemaMap[featureKey][key] = 'fields' in entry ? entry.fields : [entry]
clientFields: ('fields' in state
? deepCopyObjectSimple(state.fields)
: [deepCopyObjectSimple(state)]) as ClientField[],
defaultIDType: args.payload.config.db.defaultIDType,
disableAddingID: true,
fields: 'fields' in state ? state.fields : [state],
i18n: args.i18n,
importMap: args.payload.importMap,
})
featureClientSchemaMap[featureKey][key] = clientFields
}
/*
This is for providing an initial form state. Right now we only want to provide the clientfields though
const schemaMap: {
[key: string]: FieldState
} = {}
const lexicalDeepIterate = (editorState) => {
console.log('STATE', editorState)
if (
editorState &&
typeof editorState === 'object' &&
'children' in editorState &&
Array.isArray(editorState.children)
) {
for (const childKey in editorState.children) {
const childState = editorState.children[childKey]
if (childState && typeof childState === 'object') {
lexicalDeepIterate(childState)
}
}
} }
} }
lexicalDeepIterate(value.root)*/
} }
} }
return { return {

View File

@@ -8,7 +8,7 @@ import type {
} from 'payload' } from 'payload'
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent' import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
import { createClientFields, deepCopyObjectSimple } from 'payload' import { createClientFields } from 'payload'
import React from 'react' import React from 'react'
import type { AdapterArguments, RichTextCustomElement, RichTextCustomLeaf } from '../types.js' import type { AdapterArguments, RichTextCustomElement, RichTextCustomLeaf } from '../types.js'
@@ -134,11 +134,7 @@ export const RscEntrySlateField: React.FC<
switch (element.name) { switch (element.name) {
case 'link': { case 'link': {
let clientFields = deepCopyObjectSimple( const clientFields = createClientFields({
args.admin?.link?.fields,
) as unknown as ClientField[]
clientFields = createClientFields({
clientFields,
defaultIDType: payload.config.db.defaultIDType, defaultIDType: payload.config.db.defaultIDType,
fields: args.admin?.link?.fields as Field[], fields: args.admin?.link?.fields as Field[],
i18n, i18n,
@@ -166,11 +162,7 @@ export const RscEntrySlateField: React.FC<
uploadEnabledCollections.forEach((collection) => { uploadEnabledCollections.forEach((collection) => {
if (args?.admin?.upload?.collections[collection.slug]?.fields) { if (args?.admin?.upload?.collections[collection.slug]?.fields) {
let clientFields = deepCopyObjectSimple( const clientFields = createClientFields({
args?.admin?.upload?.collections[collection.slug]?.fields,
) as unknown as ClientField[]
clientFields = createClientFields({
clientFields,
defaultIDType: payload.config.db.defaultIDType, defaultIDType: payload.config.db.defaultIDType,
fields: args?.admin?.upload?.collections[collection.slug]?.fields, fields: args?.admin?.upload?.collections[collection.slug]?.fields,
i18n, i18n,

View File

@@ -53,6 +53,11 @@
"types": "./src/utilities/buildTableState.ts", "types": "./src/utilities/buildTableState.ts",
"default": "./src/utilities/buildTableState.ts" "default": "./src/utilities/buildTableState.ts"
}, },
"./utilities/getClientConfig": {
"import": "./src/utilities/getClientConfig.ts",
"types": "./src/utilities/getClientConfig.ts",
"default": "./src/utilities/getClientConfig.ts"
},
"./utilities/buildFieldSchemaMap/traverseFields": { "./utilities/buildFieldSchemaMap/traverseFields": {
"import": "./src/utilities/buildFieldSchemaMap/traverseFields.ts", "import": "./src/utilities/buildFieldSchemaMap/traverseFields.ts",
"types": "./src/utilities/buildFieldSchemaMap/traverseFields.ts", "types": "./src/utilities/buildFieldSchemaMap/traverseFields.ts",

View File

@@ -1,4 +1,5 @@
import type { import type {
ClientFieldSchemaMap,
Data, Data,
DocumentPreferences, DocumentPreferences,
Field, Field,
@@ -35,6 +36,7 @@ export type AddFieldStatePromiseArgs = {
* if all parents are localized, then the field is localized * if all parents are localized, then the field is localized
*/ */
anyParentLocalized?: boolean anyParentLocalized?: boolean
clientFieldSchemaMap?: ClientFieldSchemaMap
collectionSlug?: string collectionSlug?: string
data: Data data: Data
field: Field field: Field
@@ -93,6 +95,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
id, id,
addErrorPathToParent: addErrorPathToParentArg, addErrorPathToParent: addErrorPathToParentArg,
anyParentLocalized = false, anyParentLocalized = false,
clientFieldSchemaMap,
collectionSlug, collectionSlug,
data, data,
field, field,
@@ -119,6 +122,12 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
state, state,
} = args } = args
if (!args.clientFieldSchemaMap && args.renderFieldFn) {
console.warn(
'clientFieldSchemaMap is not passed to addFieldStatePromise - this will reduce performance',
)
}
const { indexPath, path, schemaPath } = getFieldPaths({ const { indexPath, path, schemaPath } = getFieldPaths({
field, field,
index: fieldIndex, index: fieldIndex,
@@ -237,6 +246,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
id, id,
addErrorPathToParent, addErrorPathToParent,
anyParentLocalized: field.localized || anyParentLocalized, anyParentLocalized: field.localized || anyParentLocalized,
clientFieldSchemaMap,
collectionSlug, collectionSlug,
data: row, data: row,
fields: field.fields, fields: field.fields,
@@ -370,6 +380,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
id, id,
addErrorPathToParent, addErrorPathToParent,
anyParentLocalized: field.localized || anyParentLocalized, anyParentLocalized: field.localized || anyParentLocalized,
clientFieldSchemaMap,
collectionSlug, collectionSlug,
data: row, data: row,
fields: block.fields, fields: block.fields,
@@ -460,6 +471,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
id, id,
addErrorPathToParent, addErrorPathToParent,
anyParentLocalized: field.localized || anyParentLocalized, anyParentLocalized: field.localized || anyParentLocalized,
clientFieldSchemaMap,
collectionSlug, collectionSlug,
data: data?.[field.name] || {}, data: data?.[field.name] || {},
fields: field.fields, fields: field.fields,
@@ -605,6 +617,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
// passthrough parent functionality // passthrough parent functionality
addErrorPathToParent: addErrorPathToParentArg, addErrorPathToParent: addErrorPathToParentArg,
anyParentLocalized: field.localized || anyParentLocalized, anyParentLocalized: field.localized || anyParentLocalized,
clientFieldSchemaMap,
collectionSlug, collectionSlug,
data, data,
fields: field.fields, fields: field.fields,
@@ -668,6 +681,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
id, id,
addErrorPathToParent: addErrorPathToParentArg, addErrorPathToParent: addErrorPathToParentArg,
anyParentLocalized: tab.localized || anyParentLocalized, anyParentLocalized: tab.localized || anyParentLocalized,
clientFieldSchemaMap,
collectionSlug, collectionSlug,
data: isNamedTab ? data?.[tab.name] || {} : data, data: isNamedTab ? data?.[tab.name] || {} : data,
fields: tab.fields, fields: tab.fields,
@@ -733,6 +747,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
renderFieldFn({ renderFieldFn({
id, id,
clientFieldSchemaMap,
collectionSlug, collectionSlug,
data: fullData, data: fullData,
fieldConfig: fieldConfig as Field, fieldConfig: fieldConfig as Field,

View File

@@ -1,4 +1,5 @@
import type { import type {
ClientFieldSchemaMap,
Data, Data,
DocumentPreferences, DocumentPreferences,
Field, Field,
@@ -15,6 +16,12 @@ import { calculateDefaultValues } from './calculateDefaultValues/index.js'
import { iterateFields } from './iterateFields.js' import { iterateFields } from './iterateFields.js'
type Args = { type Args = {
/**
* The client field schema map is required for field rendering.
* If fields should not be rendered (=> `renderFieldFn` is not provided),
* then the client field schema map is not required.
*/
clientFieldSchemaMap?: ClientFieldSchemaMap
collectionSlug?: string collectionSlug?: string
data?: Data data?: Data
fields: Field[] | undefined fields: Field[] | undefined
@@ -40,12 +47,19 @@ type Args = {
renderAllFields: boolean renderAllFields: boolean
renderFieldFn?: RenderFieldMethod renderFieldFn?: RenderFieldMethod
req: PayloadRequest req: PayloadRequest
schemaPath: string schemaPath: string
} }
export const fieldSchemasToFormState = async (args: Args): Promise<FormState> => { export const fieldSchemasToFormState = async (args: Args): Promise<FormState> => {
if (!args.clientFieldSchemaMap && args.renderFieldFn) {
console.warn(
'clientFieldSchemaMap is not passed to fieldSchemasToFormState - this will reduce performance',
)
}
const { const {
id, id,
clientFieldSchemaMap,
collectionSlug, collectionSlug,
data = {}, data = {},
fields, fields,
@@ -77,6 +91,7 @@ export const fieldSchemasToFormState = async (args: Args): Promise<FormState> =>
await iterateFields({ await iterateFields({
id, id,
addErrorPathToParent: null, addErrorPathToParent: null,
clientFieldSchemaMap,
collectionSlug, collectionSlug,
data: dataWithDefaultValues, data: dataWithDefaultValues,
fields, fields,

View File

@@ -1,4 +1,5 @@
import type { import type {
ClientFieldSchemaMap,
Data, Data,
DocumentPreferences, DocumentPreferences,
Field as FieldSchema, Field as FieldSchema,
@@ -20,6 +21,7 @@ type Args = {
* if any parents is localized, then the field is localized. @default false * if any parents is localized, then the field is localized. @default false
*/ */
anyParentLocalized?: boolean anyParentLocalized?: boolean
clientFieldSchemaMap?: ClientFieldSchemaMap
collectionSlug?: string collectionSlug?: string
data: Data data: Data
fields: FieldSchema[] fields: FieldSchema[]
@@ -71,6 +73,7 @@ export const iterateFields = async ({
id, id,
addErrorPathToParent: addErrorPathToParentArg, addErrorPathToParent: addErrorPathToParentArg,
anyParentLocalized = false, anyParentLocalized = false,
clientFieldSchemaMap,
collectionSlug, collectionSlug,
data, data,
fields, fields,
@@ -112,6 +115,7 @@ export const iterateFields = async ({
id, id,
addErrorPathToParent: addErrorPathToParentArg, addErrorPathToParent: addErrorPathToParentArg,
anyParentLocalized, anyParentLocalized,
clientFieldSchemaMap,
collectionSlug, collectionSlug,
data, data,
field, field,

View File

@@ -1,7 +1,7 @@
import type { ClientComponentProps, ClientField, FieldPaths, ServerComponentProps } from 'payload' import type { ClientComponentProps, ClientField, FieldPaths, ServerComponentProps } from 'payload'
import { getTranslation } from '@payloadcms/translations' import { getTranslation } from '@payloadcms/translations'
import { createClientField, deepCopyObjectSimple, MissingEditorProp } from 'payload' import { createClientField, MissingEditorProp } from 'payload'
import type { RenderFieldMethod } from './types.js' import type { RenderFieldMethod } from './types.js'
@@ -18,6 +18,7 @@ const defaultUIFieldComponentKeys: Array<'Cell' | 'Description' | 'Field' | 'Fil
export const renderField: RenderFieldMethod = ({ export const renderField: RenderFieldMethod = ({
id, id,
clientFieldSchemaMap,
collectionSlug, collectionSlug,
data, data,
fieldConfig, fieldConfig,
@@ -35,13 +36,14 @@ export const renderField: RenderFieldMethod = ({
schemaPath, schemaPath,
siblingData, siblingData,
}) => { }) => {
const clientField = createClientField({ const clientField = clientFieldSchemaMap
clientField: deepCopyObjectSimple(fieldConfig) as ClientField, ? (clientFieldSchemaMap.get(schemaPath) as ClientField)
defaultIDType: req.payload.config.db.defaultIDType, : createClientField({
field: fieldConfig, defaultIDType: req.payload.config.db.defaultIDType,
i18n: req.i18n, field: fieldConfig,
importMap: req.payload.importMap, i18n: req.i18n,
}) importMap: req.payload.importMap,
})
const clientProps: ClientComponentProps & Partial<FieldPaths> = { const clientProps: ClientComponentProps & Partial<FieldPaths> = {
customComponents: fieldState?.customComponents || {}, customComponents: fieldState?.customComponents || {},
@@ -61,6 +63,7 @@ export const renderField: RenderFieldMethod = ({
const serverProps: ServerComponentProps = { const serverProps: ServerComponentProps = {
id, id,
clientField, clientField,
clientFieldSchemaMap,
data, data,
field: fieldConfig, field: fieldConfig,
fieldSchemaMap, fieldSchemaMap,

View File

@@ -1,4 +1,5 @@
import type { import type {
ClientFieldSchemaMap,
Data, Data,
DocumentPreferences, DocumentPreferences,
Field, Field,
@@ -11,6 +12,7 @@ import type {
} from 'payload' } from 'payload'
export type RenderFieldArgs = { export type RenderFieldArgs = {
clientFieldSchemaMap?: ClientFieldSchemaMap
collectionSlug: string collectionSlug: string
data: Data data: Data
fieldConfig: Field fieldConfig: Field

View File

@@ -14,7 +14,7 @@ const LocaleContext = createContext({} as Locale)
export const LocaleProvider: React.FC<{ children?: React.ReactNode }> = ({ children }) => { export const LocaleProvider: React.FC<{ children?: React.ReactNode }> = ({ children }) => {
const { const {
config: { localization }, config: { localization = false },
} = useConfig() } = useConfig()
const { user } = useAuth() const { user } = useAuth()

View File

@@ -0,0 +1,94 @@
import type { I18n } from '@payloadcms/translations'
import type {
ClientConfig,
ClientField,
ClientFieldSchemaMap,
FieldSchemaMap,
Payload,
TextFieldClient,
} from 'payload'
import { traverseFields } from './traverseFields.js'
const baseAuthFields: ClientField[] = [
{
name: 'password',
type: 'text',
required: true,
},
{
name: 'confirm-password',
type: 'text',
required: true,
},
]
/**
* Flattens the config fields into a map of field schemas
*/
export const buildClientFieldSchemaMap = (args: {
collectionSlug?: string
config: ClientConfig
globalSlug?: string
i18n: I18n
payload: Payload
schemaMap: FieldSchemaMap
}): { clientFieldSchemaMap: ClientFieldSchemaMap } => {
const { collectionSlug, config, globalSlug, i18n, payload, schemaMap } = args
const clientSchemaMap: ClientFieldSchemaMap = new Map()
if (collectionSlug) {
const matchedCollection = config.collections.find(
(collection) => collection.slug === collectionSlug,
)
if (matchedCollection) {
if (matchedCollection.auth && !matchedCollection.auth.disableLocalStrategy) {
// register schema with auth schemaPath
;(baseAuthFields[0] as TextFieldClient).label = i18n.t('general:password')
;(baseAuthFields[1] as TextFieldClient).label = i18n.t('authentication:confirmPassword')
clientSchemaMap.set(`_${matchedCollection.slug}.auth`, {
fields: [...baseAuthFields, ...matchedCollection.fields],
})
}
clientSchemaMap.set(collectionSlug, {
fields: matchedCollection.fields,
})
traverseFields({
clientSchemaMap,
config,
fields: matchedCollection.fields,
i18n,
parentIndexPath: '',
parentSchemaPath: collectionSlug,
payload,
schemaMap,
})
}
} else if (globalSlug) {
const matchedGlobal = config.globals.find((global) => global.slug === globalSlug)
if (matchedGlobal) {
clientSchemaMap.set(globalSlug, {
fields: matchedGlobal.fields,
})
traverseFields({
clientSchemaMap,
config,
fields: matchedGlobal.fields,
i18n,
parentIndexPath: '',
parentSchemaPath: globalSlug,
payload,
schemaMap,
})
}
}
return { clientFieldSchemaMap: clientSchemaMap }
}

View File

@@ -0,0 +1,154 @@
import type { I18n } from '@payloadcms/translations'
import {
type ClientConfig,
type ClientField,
type ClientFieldSchemaMap,
createClientFields,
type FieldSchemaMap,
type Payload,
} from 'payload'
import { getFieldPaths, tabHasName } from 'payload/shared'
type Args = {
clientSchemaMap: ClientFieldSchemaMap
config: ClientConfig
fields: ClientField[]
i18n: I18n<any, any>
parentIndexPath: string
parentSchemaPath: string
payload: Payload
schemaMap: FieldSchemaMap
}
export const traverseFields = ({
clientSchemaMap,
config,
fields,
i18n,
parentIndexPath,
parentSchemaPath,
payload,
schemaMap,
}: Args) => {
for (const [index, field] of fields.entries()) {
const { indexPath, schemaPath } = getFieldPaths({
field,
index,
parentIndexPath: 'name' in field ? '' : parentIndexPath,
parentPath: '',
parentSchemaPath,
})
clientSchemaMap.set(schemaPath, field)
switch (field.type) {
case 'array':
case 'group':
traverseFields({
clientSchemaMap,
config,
fields: field.fields,
i18n,
parentIndexPath: '',
parentSchemaPath: schemaPath,
payload,
schemaMap,
})
break
case 'blocks':
field.blocks.map((block) => {
const blockSchemaPath = `${schemaPath}.${block.slug}`
clientSchemaMap.set(blockSchemaPath, block)
traverseFields({
clientSchemaMap,
config,
fields: block.fields,
i18n,
parentIndexPath: '',
parentSchemaPath: blockSchemaPath,
payload,
schemaMap,
})
})
break
case 'collapsible':
case 'row':
traverseFields({
clientSchemaMap,
config,
fields: field.fields,
i18n,
parentIndexPath: indexPath,
parentSchemaPath,
payload,
schemaMap,
})
break
case 'richText': {
// richText sub-fields are not part of the ClientConfig or the Config.
// They only exist in the field schema map.
// Thus, we need to
// 1. get them from the field schema map
// 2. convert them to client fields
// 3. add them to the client schema map
// So these would basically be all fields that are not part of the client config already
const richTextFieldSchemaMap: FieldSchemaMap = new Map()
for (const [path, subField] of schemaMap.entries()) {
if (path.startsWith(`${schemaPath}.`)) {
richTextFieldSchemaMap.set(path, subField)
}
}
// Now loop through them, convert each entry to a client field and add it to the client schema map
for (const [path, subField] of richTextFieldSchemaMap.entries()) {
const clientFields = createClientFields({
defaultIDType: payload.config.db.defaultIDType,
disableAddingID: true,
fields: 'fields' in subField ? subField.fields : [subField],
i18n,
importMap: payload.importMap,
})
clientSchemaMap.set(path, {
fields: clientFields,
})
}
break
}
case 'tabs':
field.tabs.map((tab, tabIndex) => {
const { indexPath: tabIndexPath, schemaPath: tabSchemaPath } = getFieldPaths({
field: {
...tab,
type: 'tab',
},
index: tabIndex,
parentIndexPath: indexPath,
parentPath: '',
parentSchemaPath,
})
clientSchemaMap.set(tabSchemaPath, tab)
traverseFields({
clientSchemaMap,
config,
fields: tab.fields,
i18n,
parentIndexPath: tabHasName(tab) ? '' : tabIndexPath,
parentSchemaPath: tabHasName(tab) ? tabSchemaPath : parentSchemaPath,
payload,
schemaMap,
})
})
break
}
}
}

View File

@@ -1,9 +1,25 @@
import type { I18n } from '@payloadcms/translations' import type { I18n } from '@payloadcms/translations'
import type { Field, FieldSchemaMap, SanitizedConfig } from 'payload' import type { Field, FieldSchemaMap, SanitizedConfig, TextField } from 'payload'
import { confirmPassword, password } from 'payload/shared' import { confirmPassword, password } from 'payload/shared'
import { traverseFields } from './traverseFields.js' import { traverseFields } from './traverseFields.js'
const baseAuthFields: Field[] = [
{
name: 'password',
type: 'text',
required: true,
validate: password,
},
{
name: 'confirm-password',
type: 'text',
required: true,
validate: confirmPassword,
},
]
/** /**
* Flattens the config fields into a map of field schemas * Flattens the config fields into a map of field schemas
*/ */
@@ -25,22 +41,8 @@ export const buildFieldSchemaMap = (args: {
if (matchedCollection) { if (matchedCollection) {
if (matchedCollection.auth && !matchedCollection.auth.disableLocalStrategy) { if (matchedCollection.auth && !matchedCollection.auth.disableLocalStrategy) {
// register schema with auth schemaPath // register schema with auth schemaPath
const baseAuthFields: Field[] = [ ;(baseAuthFields[0] as TextField).label = i18n.t('general:password')
{ ;(baseAuthFields[1] as TextField).label = i18n.t('authentication:confirmPassword')
name: 'password',
type: 'text',
label: i18n.t('general:password'),
required: true,
validate: password,
},
{
name: 'confirm-password',
type: 'text',
label: i18n.t('authentication:confirmPassword'),
required: true,
validate: confirmPassword,
},
]
schemaMap.set(`_${matchedCollection.slug}.auth`, { schemaMap.set(`_${matchedCollection.slug}.auth`, {
fields: [...baseAuthFields, ...matchedCollection.fields], fields: [...baseAuthFields, ...matchedCollection.fields],

View File

@@ -63,7 +63,6 @@ export const traverseFields = ({
break break
case 'collapsible': case 'collapsible':
case 'row': case 'row':
traverseFields({ traverseFields({
config, config,

View File

@@ -1,65 +1,15 @@
import type { I18n, I18nClient } from '@payloadcms/translations' import type { BuildFormStateArgs, ClientConfig, ClientUser, ErrorResult, FormState } from 'payload'
import type {
BuildFormStateArgs,
ClientConfig,
ClientUser,
ErrorResult,
FieldSchemaMap,
FormState,
SanitizedConfig,
} from 'payload'
import { formatErrors } from 'payload' import { formatErrors } from 'payload'
import { reduceFieldsToValues } from 'payload/shared' import { reduceFieldsToValues } from 'payload/shared'
import { fieldSchemasToFormState } from '../forms/fieldSchemasToFormState/index.js' import { fieldSchemasToFormState } from '../forms/fieldSchemasToFormState/index.js'
import { renderField } from '../forms/fieldSchemasToFormState/renderField.js' import { renderField } from '../forms/fieldSchemasToFormState/renderField.js'
import { buildFieldSchemaMap } from './buildFieldSchemaMap/index.js' import { getClientConfig } from './getClientConfig.js'
import { getClientSchemaMap } from './getClientSchemaMap.js'
import { getSchemaMap } from './getSchemaMap.js'
import { handleFormStateLocking } from './handleFormStateLocking.js' import { handleFormStateLocking } from './handleFormStateLocking.js'
let cachedFieldMap = global._payload_fieldMap
let cachedClientConfig = global._payload_clientConfig
if (!cachedFieldMap) {
cachedFieldMap = global._payload_fieldMap = null
}
if (!cachedClientConfig) {
cachedClientConfig = global._payload_clientConfig = null
}
export const getFieldSchemaMap = (args: {
collectionSlug?: string
config: SanitizedConfig
globalSlug?: string
i18n: I18nClient
}): FieldSchemaMap => {
const { collectionSlug, config, globalSlug, i18n } = args
if (process.env.NODE_ENV !== 'development') {
if (!cachedFieldMap) {
cachedFieldMap = new Map()
}
const cachedEntityFieldMap = cachedFieldMap.get(collectionSlug || globalSlug)
if (cachedEntityFieldMap) {
return cachedEntityFieldMap
}
}
const { fieldSchemaMap: entityFieldMap } = buildFieldSchemaMap({
collectionSlug,
config,
globalSlug,
i18n: i18n as I18n,
})
if (process.env.NODE_ENV !== 'development') {
cachedFieldMap.set(collectionSlug || globalSlug, entityFieldMap)
}
return entityFieldMap
}
type BuildFormStateSuccessResult = { type BuildFormStateSuccessResult = {
clientConfig?: ClientConfig clientConfig?: ClientConfig
errors?: never errors?: never
@@ -167,15 +117,24 @@ export const buildFormState = async (
throw new Error('Either collectionSlug or globalSlug must be provided') throw new Error('Either collectionSlug or globalSlug must be provided')
} }
const fieldSchemaMap = getFieldSchemaMap({ const schemaMap = getSchemaMap({
collectionSlug, collectionSlug,
config, config,
globalSlug, globalSlug,
i18n, i18n,
}) })
const clientSchemaMap = getClientSchemaMap({
collectionSlug,
config: getClientConfig({ config, i18n, importMap: req.payload.importMap }),
globalSlug,
i18n,
payload,
schemaMap,
})
const id = collectionSlug ? idFromArgs : undefined const id = collectionSlug ? idFromArgs : undefined
const fieldOrEntityConfig = fieldSchemaMap.get(schemaPath) const fieldOrEntityConfig = schemaMap.get(schemaPath)
if (!fieldOrEntityConfig) { if (!fieldOrEntityConfig) {
throw new Error(`Could not find "${schemaPath}" in the fieldSchemaMap`) throw new Error(`Could not find "${schemaPath}" in the fieldSchemaMap`)
@@ -216,10 +175,11 @@ export const buildFormState = async (
const formStateResult = await fieldSchemasToFormState({ const formStateResult = await fieldSchemasToFormState({
id, id,
clientFieldSchemaMap: clientSchemaMap,
collectionSlug, collectionSlug,
data, data,
fields, fields,
fieldSchemaMap, fieldSchemaMap: schemaMap,
operation, operation,
permissions: docPermissions?.fields || {}, permissions: docPermissions?.fields || {},
preferences: docPreferences || { fields: {} }, preferences: docPreferences || { fields: {} },

View File

@@ -1,49 +1,21 @@
import type { I18nClient } from '@payloadcms/translations'
import type { import type {
BuildTableStateArgs, BuildTableStateArgs,
ClientCollectionConfig, ClientCollectionConfig,
ClientConfig, ClientConfig,
ErrorResult, ErrorResult,
ImportMap,
PaginatedDocs, PaginatedDocs,
SanitizedCollectionConfig, SanitizedCollectionConfig,
SanitizedConfig,
} from 'payload' } from 'payload'
import { dequal } from 'dequal/lite' import { dequal } from 'dequal/lite'
import { createClientConfig, formatErrors } from 'payload' import { formatErrors } from 'payload'
import type { Column } from '../elements/Table/index.js' import type { Column } from '../elements/Table/index.js'
import type { ListPreferences } from '../elements/TableColumns/index.js' import type { ListPreferences } from '../elements/TableColumns/index.js'
import { getClientConfig } from './getClientConfig.js'
import { renderFilters, renderTable } from './renderTable.js' import { renderFilters, renderTable } from './renderTable.js'
let cachedClientConfig = global._payload_clientConfig
if (!cachedClientConfig) {
cachedClientConfig = global._payload_clientConfig = null
}
export const getClientConfig = (args: {
config: SanitizedConfig
i18n: I18nClient
importMap: ImportMap
}): ClientConfig => {
const { config, i18n, importMap } = args
if (cachedClientConfig && process.env.NODE_ENV !== 'development') {
return cachedClientConfig
}
cachedClientConfig = createClientConfig({
config,
i18n,
importMap,
})
return cachedClientConfig
}
type BuildTableStateSuccessResult = { type BuildTableStateSuccessResult = {
clientConfig?: ClientConfig clientConfig?: ClientConfig
data: PaginatedDocs data: PaginatedDocs

View File

@@ -0,0 +1,31 @@
import type { I18nClient } from '@payloadcms/translations'
import type { ClientConfig, ImportMap, SanitizedConfig } from 'payload'
import { createClientConfig } from 'payload'
import { cache } from 'react'
let cachedClientConfig = global._payload_clientConfig
if (!cachedClientConfig) {
cachedClientConfig = global._payload_clientConfig = null
}
export const getClientConfig = cache(
(args: { config: SanitizedConfig; i18n: I18nClient; importMap: ImportMap }): ClientConfig => {
if (cachedClientConfig && !global._payload_doNotCacheClientConfig) {
return cachedClientConfig
}
const { config, i18n, importMap } = args
cachedClientConfig = createClientConfig({
config,
i18n,
importMap,
})
global._payload_clientConfig = cachedClientConfig
global._payload_doNotCacheClientConfig = false
return cachedClientConfig
},
)

View File

@@ -0,0 +1,54 @@
import type { I18n, I18nClient } from '@payloadcms/translations'
import type { ClientConfig, ClientFieldSchemaMap, FieldSchemaMap, Payload } from 'payload'
import { cache } from 'react'
import { buildClientFieldSchemaMap } from './buildClientFieldSchemaMap/index.js'
let cachedClientSchemaMap = global._payload_clientSchemaMap
if (!cachedClientSchemaMap) {
cachedClientSchemaMap = global._payload_clientSchemaMap = null
}
export const getClientSchemaMap = cache(
(args: {
collectionSlug?: string
config: ClientConfig
globalSlug?: string
i18n: I18nClient
payload: Payload
schemaMap: FieldSchemaMap
}): ClientFieldSchemaMap => {
const { collectionSlug, config, globalSlug, i18n, payload, schemaMap } = args
if (!cachedClientSchemaMap || global._payload_doNotCacheClientSchemaMap) {
cachedClientSchemaMap = new Map()
}
let cachedEntityClientFieldMap = cachedClientSchemaMap.get(collectionSlug || globalSlug)
if (cachedEntityClientFieldMap) {
return cachedEntityClientFieldMap
}
cachedEntityClientFieldMap = new Map()
const { clientFieldSchemaMap: entityClientFieldMap } = buildClientFieldSchemaMap({
collectionSlug,
config,
globalSlug,
i18n: i18n as I18n,
payload,
schemaMap,
})
cachedClientSchemaMap.set(collectionSlug || globalSlug, entityClientFieldMap)
global._payload_clientSchemaMap = cachedClientSchemaMap
global._payload_doNotCacheClientSchemaMap = false
return entityClientFieldMap
},
)

View File

@@ -0,0 +1,50 @@
import type { I18n, I18nClient } from '@payloadcms/translations'
import type { FieldSchemaMap, SanitizedConfig } from 'payload'
import { cache } from 'react'
import { buildFieldSchemaMap } from './buildFieldSchemaMap/index.js'
let cachedSchemaMap = global._payload_schemaMap
if (!cachedSchemaMap) {
cachedSchemaMap = global._payload_schemaMap = null
}
export const getSchemaMap = cache(
(args: {
collectionSlug?: string
config: SanitizedConfig
globalSlug?: string
i18n: I18nClient
}): FieldSchemaMap => {
const { collectionSlug, config, globalSlug, i18n } = args
if (!cachedSchemaMap || global._payload_doNotCacheSchemaMap) {
cachedSchemaMap = new Map()
}
let cachedEntityFieldMap = cachedSchemaMap.get(collectionSlug || globalSlug)
if (cachedEntityFieldMap) {
return cachedEntityFieldMap
}
cachedEntityFieldMap = new Map()
const { fieldSchemaMap: entityFieldMap } = buildFieldSchemaMap({
collectionSlug,
config,
globalSlug,
i18n: i18n as I18n,
})
cachedSchemaMap.set(collectionSlug || globalSlug, entityFieldMap)
global._payload_schemaMap = cachedSchemaMap
global._payload_doNotCacheSchemaMap = false
return entityFieldMap
},
)

View File

@@ -1,4 +1,4 @@
import type { SanitizedCollectionConfig, VerifyConfig } from 'payload' import type { SanitizedCollectionConfig } from 'payload'
export type Props = { export type Props = {
className?: string className?: string
@@ -13,5 +13,5 @@ export type Props = {
setValidateBeforeSubmit: (validate: boolean) => void setValidateBeforeSubmit: (validate: boolean) => void
useAPIKey?: boolean useAPIKey?: boolean
username: string username: string
verify?: boolean | VerifyConfig verify?: boolean
} }

19
test/auth-basic/config.ts Normal file
View File

@@ -0,0 +1,19 @@
import { fileURLToPath } from 'node:url'
import path from 'path'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
// eslint-disable-next-line no-restricted-exports
export default buildConfigWithDefaults({
admin: {
autoLogin: false,
importMap: {
baseDir: path.resolve(dirname),
},
},
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
})

152
test/auth-basic/e2e.spec.ts Normal file
View File

@@ -0,0 +1,152 @@
import type { Page } from '@playwright/test'
import type { SanitizedConfig } from 'payload'
import { expect, test } from '@playwright/test'
import { devUser } from 'credentials.js'
import path from 'path'
import { fileURLToPath } from 'url'
import type { PayloadTestSDK } from '../helpers/sdk/index.js'
import type { Config } from './payload-types.js'
import { ensureCompilationIsDone, getRoutes, initPageConsoleErrorCatch } from '../helpers.js'
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
import { reInitializeDB } from '../helpers/reInitializeDB.js'
import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
let payload: PayloadTestSDK<Config>
const { beforeAll, beforeEach, describe } = test
const createFirstUser = async ({
page,
serverURL,
}: {
customAdminRoutes?: SanitizedConfig['admin']['routes']
customRoutes?: SanitizedConfig['routes']
page: Page
serverURL: string
}) => {
const {
admin: {
routes: { createFirstUser: createFirstUserRoute },
},
routes: { admin: adminRoute },
} = getRoutes({})
// wait for create first user route
await page.goto(serverURL + `${adminRoute}${createFirstUserRoute}`)
// forget to fill out confirm password
await page.locator('#field-email').fill(devUser.email)
await page.locator('#field-password').fill(devUser.password)
await page.locator('.form-submit > button').click()
await expect(page.locator('.field-type.confirm-password .field-error')).toHaveText(
'This field is required.',
)
// make them match, but does not pass password validation
await page.locator('#field-email').fill(devUser.email)
await page.locator('#field-password').fill('12')
await page.locator('#field-confirm-password').fill('12')
await page.locator('.form-submit > button').click()
await expect(page.locator('.field-type.password .field-error')).toHaveText(
'This value must be longer than the minimum length of 3 characters.',
)
await page.locator('#field-email').fill(devUser.email)
await page.locator('#field-password').fill(devUser.password)
await page.locator('#field-confirm-password').fill(devUser.password)
await page.locator('.form-submit > button').click()
await expect
.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT })
.not.toContain('create-first-user')
}
describe('auth-basic', () => {
let page: Page
let url: AdminUrlUtil
let serverURL: string
let apiURL: string
beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG)
;({ payload, serverURL } = await initPayloadE2ENoConfig<Config>({ dirname }))
apiURL = `${serverURL}/api`
url = new AdminUrlUtil(serverURL, 'users')
const context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)
await ensureCompilationIsDone({
page,
serverURL,
readyURL: `${serverURL}/admin/**`,
noAutoLogin: true,
})
// Undo onInit seeding, as we need to test this without having a user created, or testing create-first-user
await reInitializeDB({
serverURL,
snapshotKey: 'auth-basic',
deleteOnly: true,
})
await payload.delete({
collection: 'users',
where: {
id: {
exists: true,
},
},
})
await ensureCompilationIsDone({
page,
serverURL,
readyURL: `${serverURL}/admin/create-first-user`,
})
})
beforeEach(async () => {
await payload.delete({
collection: 'users',
where: {
id: {
exists: true,
},
},
})
})
describe('unauthenticated users', () => {
test('ensure create first user page only has 3 fields', async () => {
await page.goto(url.admin + '/create-first-user')
// Ensure there are only 2 elements with class field-type
await expect(page.locator('.field-type')).toHaveCount(3) // Email, Password, Confirm Password
})
test('ensure first user can be created', async () => {
await createFirstUser({ page, serverURL })
// use the api key in a fetch to assert that it is disabled
await expect(async () => {
const users = await payload.find({
collection: 'users',
})
expect(users.totalDocs).toBe(1)
expect(users.docs[0].email).toBe(devUser.email)
}).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
})
})
})

View File

@@ -0,0 +1,186 @@
/* tslint:disable */
/* eslint-disable */
/**
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/
export interface Config {
auth: {
users: UserAuthOperations;
};
collections: {
users: User;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
};
collectionsJoins: {};
collectionsSelect: {
users: UsersSelect<false> | UsersSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
};
db: {
defaultIDType: string;
};
globals: {};
globalsSelect: {};
locale: null;
user: User & {
collection: 'users';
};
jobs: {
tasks: unknown;
workflows: unknown;
};
}
export interface UserAuthOperations {
forgotPassword: {
email: string;
password: string;
};
login: {
email: string;
password: string;
};
registerFirstUser: {
email: string;
password: string;
};
unlock: {
email: string;
password: string;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
*/
export interface User {
id: string;
updatedAt: string;
createdAt: string;
email: string;
resetPasswordToken?: string | null;
resetPasswordExpiration?: string | null;
salt?: string | null;
hash?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
password?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: string;
document?: {
relationTo: 'users';
value: string | User;
} | null;
globalSlug?: string | null;
user: {
relationTo: 'users';
value: string | User;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: string;
user: {
relationTo: 'users';
value: string | User;
};
key?: string | null;
value?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: string;
name?: string | null;
batch?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users_select".
*/
export interface UsersSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
email?: T;
resetPasswordToken?: T;
resetPasswordExpiration?: T;
salt?: T;
hash?: T;
loginAttempts?: T;
lockUntil?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select".
*/
export interface PayloadLockedDocumentsSelect<T extends boolean = true> {
document?: T;
globalSlug?: T;
user?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences_select".
*/
export interface PayloadPreferencesSelect<T extends boolean = true> {
user?: T;
key?: T;
value?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations_select".
*/
export interface PayloadMigrationsSelect<T extends boolean = true> {
name?: T;
batch?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth".
*/
export interface Auth {
[k: string]: unknown;
}
declare module 'payload' {
// @ts-ignore
export interface GeneratedTypes extends Config {}
}

View File

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

View File

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

View File

@@ -64,11 +64,13 @@ export async function ensureCompilationIsDone({
page, page,
serverURL, serverURL,
noAutoLogin, noAutoLogin,
readyURL,
}: { }: {
customAdminRoutes?: Config['admin']['routes'] customAdminRoutes?: Config['admin']['routes']
customRoutes?: Config['routes'] customRoutes?: Config['routes']
noAutoLogin?: boolean noAutoLogin?: boolean
page: Page page: Page
readyURL?: string
serverURL: string serverURL: string
}): Promise<void> { }): Promise<void> {
const { const {
@@ -82,11 +84,16 @@ export async function ensureCompilationIsDone({
while (attempt <= maxAttempts) { while (attempt <= maxAttempts) {
try { try {
console.log(`Checking if compilation is done (attempt ${attempt}/${maxAttempts})...`) console.log(
`Checking if compilation is done (attempt ${attempt}/${maxAttempts})...`,
readyURL ??
(noAutoLogin ? `${adminURL + (adminURL.endsWith('/') ? '' : '/')}login` : adminURL),
)
await page.goto(adminURL) await page.goto(adminURL)
await page.waitForURL( await page.waitForURL(
noAutoLogin ? `${adminURL + (adminURL.endsWith('/') ? '' : '/')}login` : adminURL, readyURL ??
(noAutoLogin ? `${adminURL + (adminURL.endsWith('/') ? '' : '/')}login` : adminURL),
) )
console.log('Successfully compiled') console.log('Successfully compiled')