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-root
- auth
- auth-basic
- field-error-states
- fields-relationship
- fields

View File

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

View File

@@ -3,12 +3,12 @@ import type { ImportMap, SanitizedConfig, ServerFunctionClient } from 'payload'
import { rtlLanguages } from '@payloadcms/translations'
import { RootProvider } from '@payloadcms/ui'
import { getClientConfig } from '@payloadcms/ui/utilities/getClientConfig'
import { headers as getHeaders, cookies as nextCookies } from 'next/headers.js'
import { getPayload, parseCookies } from 'payload'
import React from 'react'
import { getNavPrefs } from '../../elements/Nav/getNavPrefs.js'
import { getClientConfig } from '../../utilities/getClientConfig.js'
import { getRequestLanguage } from '../../utilities/getRequestLanguage.js'
import { getRequestTheme } from '../../utilities/getRequestTheme.js'
import { initReq } from '../../utilities/initReq.js'
@@ -33,7 +33,7 @@ export const RootLayout = async ({
readonly importMap: ImportMap
readonly serverFunction: ServerFunctionClient
}) => {
await checkDependencies()
checkDependencies()
const config = await configPromise
@@ -54,7 +54,7 @@ export const RootLayout = async ({
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)
? 'RTL'
@@ -86,7 +86,7 @@ export const RootLayout = async ({
const navPrefs = await getNavPrefs({ payload, user })
const clientConfig = await getClientConfig({
const clientConfig = getClientConfig({
config,
i18n,
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({
id: user.id,
collectionConfig,
doc: data,
docPermissions,
locale: locale?.code,
payload,

View File

@@ -10,6 +10,13 @@ import { sanitizeID } from '@payloadcms/ui/shared'
type Args = {
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
globalConfig?: SanitizedGlobalConfig
id?: number | string
@@ -27,9 +34,11 @@ type Result = Promise<{
// TODO: in the future, we can parallelize some of these queries
// 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 ({
id: idArg,
collectionConfig,
doc,
docPermissions,
globalConfig,
locale,
@@ -37,7 +46,7 @@ export const getVersions = async ({
user,
}: Args): Result => {
const id = sanitizeID(idArg)
let publishedQuery
let publishedDoc
let hasPublishedDoc = false
let mostRecentVersionIsAutosaved = false
let unpublishedVersionCount = 0
@@ -70,10 +79,20 @@ export const getVersions = async ({
}
if (versionsConfig?.drafts) {
publishedQuery = await payload.find({
// Find out if a published document exists
if (doc?._status === 'published') {
publishedDoc = doc
} else {
publishedDoc = (
await payload.find({
collection: collectionConfig.slug,
depth: 0,
limit: 1,
locale: locale || undefined,
pagination: false,
select: {
updatedAt: true,
},
user,
where: {
and: [
@@ -99,8 +118,10 @@ export const getVersions = async ({
],
},
})
)?.docs?.[0]
}
if (publishedQuery.docs?.[0]) {
if (publishedDoc) {
hasPublishedDoc = true
}
@@ -109,6 +130,9 @@ export const getVersions = async ({
collection: collectionConfig.slug,
depth: 0,
limit: 1,
select: {
autosave: true,
},
user,
where: {
and: [
@@ -130,7 +154,7 @@ export const getVersions = async ({
}
}
if (publishedQuery.docs?.[0]?.updatedAt) {
if (publishedDoc?.updatedAt) {
;({ totalDocs: unpublishedVersionCount } = await payload.countVersions({
collection: collectionConfig.slug,
user,
@@ -148,7 +172,7 @@ export const getVersions = async ({
},
{
updatedAt: {
greater_than: publishedQuery.docs[0].updatedAt,
greater_than: publishedDoc.updatedAt,
},
},
],
@@ -159,6 +183,7 @@ export const getVersions = async ({
;({ totalDocs: versionCount } = await payload.countVersions({
collection: collectionConfig.slug,
depth: 0,
user,
where: {
and: [
@@ -173,15 +198,23 @@ export const getVersions = async ({
}
if (globalConfig) {
// Find out if a published document exists
if (versionsConfig?.drafts) {
publishedQuery = await payload.findGlobal({
if (doc?._status === 'published') {
publishedDoc = doc
} else {
publishedDoc = await payload.findGlobal({
slug: globalConfig.slug,
depth: 0,
locale,
select: {
updatedAt: true,
},
user,
})
}
if (publishedQuery?._status === 'published') {
if (publishedDoc?._status === 'published') {
hasPublishedDoc = true
}
@@ -204,7 +237,7 @@ export const getVersions = async ({
}
}
if (publishedQuery?.updatedAt) {
if (publishedDoc?.updatedAt) {
;({ totalDocs: unpublishedVersionCount } = await payload.countGlobalVersions({
depth: 0,
global: globalConfig.slug,
@@ -218,7 +251,7 @@ export const getVersions = async ({
},
{
updatedAt: {
greater_than: publishedQuery.updatedAt,
greater_than: publishedDoc.updatedAt,
},
},
],

View File

@@ -1,46 +1,11 @@
import type { I18nClient } from '@payloadcms/translations'
import type {
ClientConfig,
Data,
DocumentPreferences,
FormState,
ImportMap,
PayloadRequest,
SanitizedConfig,
VisibleEntities,
} from 'payload'
import type { Data, DocumentPreferences, FormState, PayloadRequest, VisibleEntities } from 'payload'
import { getClientConfig } from '@payloadcms/ui/utilities/getClientConfig'
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'
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 = {
data: any
Document: React.ReactNode

View File

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

View File

@@ -1,45 +1,12 @@
import type { I18nClient } from '@payloadcms/translations'
import type { ListPreferences } from '@payloadcms/ui'
import type {
ClientConfig,
ImportMap,
ListQuery,
PayloadRequest,
SanitizedConfig,
VisibleEntities,
} from 'payload'
import type { ListQuery, PayloadRequest, VisibleEntities } from 'payload'
import { getClientConfig } from '@payloadcms/ui/utilities/getClientConfig'
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'
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 = {
List: React.ReactNode
preferences: ListPreferences

View File

@@ -4,12 +4,12 @@ import type { ImportMap, SanitizedConfig } from 'payload'
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
import { formatAdminURL } from '@payloadcms/ui/shared'
import { getClientConfig } from '@payloadcms/ui/utilities/getClientConfig'
import { notFound, redirect } from 'next/navigation.js'
import React, { Fragment } from 'react'
import { DefaultTemplate } from '../../templates/Default/index.js'
import { MinimalTemplate } from '../../templates/Minimal/index.js'
import { getClientConfig } from '../../utilities/getClientConfig.js'
import { initPage } from '../../utilities/initPage/index.js'
import { getViewFromConfig } from './getViewFromConfig.js'
@@ -115,7 +115,7 @@ export const RootPage = async ({
redirect(adminRoute)
}
const clientConfig = await getClientConfig({
const clientConfig = getClientConfig({
config,
i18n: initPageResult?.req.i18n,
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 { Operation, Payload, PayloadRequest } from '../../types/index.js'
import type {
ClientFieldSchemaMap,
ClientTab,
Data,
FieldSchemaMap,
@@ -67,6 +68,7 @@ export type FieldPaths = {
export type ServerComponentProps = {
clientField: ClientFieldWithOptionalType
clientFieldSchemaMap: ClientFieldSchemaMap
collectionSlug: string
data: Data
field: Field

View File

@@ -3,8 +3,16 @@ import type React from 'react'
import type { ImportMap } from '../bin/generateImportMap/index.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 { ClientTab } from './fields/Tabs.js'
import type {
BuildFormStateArgs,
Data,
@@ -489,3 +497,13 @@ export type FieldSchemaMap = Map<
| Field
| 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 { PAYLOAD_PACKAGE_LIST } from './versions/payloadPackageList.js'
export async function checkPayloadDependencies() {
export function checkPayloadDependencies() {
const dependencies = [...PAYLOAD_PACKAGE_LIST]
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
await checkDependencies({
void checkDependencies({
dependencyGroups: [
{
name: 'payload',

View File

@@ -9,10 +9,10 @@ import type {
} from '../../config/types.js'
import type { ClientField } from '../../fields/config/client.js'
import type { Payload } from '../../types/index.js'
import type { SanitizedUploadConfig } from '../../uploads/types.js'
import type { SanitizedCollectionConfig } from './types.js'
import { createClientFields } from '../../fields/config/client.js'
import { deepCopyObjectSimple } from '../../utilities/deepCopyObject.js'
export type ServerOnlyCollectionProperties = keyof Pick<
SanitizedCollectionConfig,
@@ -47,12 +47,19 @@ export type ClientCollectionConfig = {
| 'preview'
| ServerOnlyCollectionAdminProperties
>
auth?: { verify?: true } & Omit<
SanitizedCollectionConfig['auth'],
'forgotPassword' | 'strategies' | 'verify'
>
fields: ClientField[]
labels: {
plural: StaticLabel
singular: StaticLabel
}
} & Omit<SanitizedCollectionConfig, 'admin' | 'fields' | 'labels' | ServerOnlyCollectionProperties>
} & Omit<
SanitizedCollectionConfig,
'admin' | 'auth' | 'fields' | 'labels' | ServerOnlyCollectionProperties
>
const serverOnlyCollectionProperties: Partial<ServerOnlyCollectionProperties>[] = [
'hooks',
@@ -93,97 +100,147 @@ export const createClientCollectionConfig = ({
i18n: I18nClient
importMap: ImportMap
}): ClientCollectionConfig => {
const clientCollection = deepCopyObjectSimple(
collection,
true,
) as unknown as ClientCollectionConfig
const clientCollection = {} as Partial<ClientCollectionConfig>
for (const key in collection) {
if (serverOnlyCollectionProperties.includes(key as any)) {
continue
}
switch (key) {
case 'admin':
if (!collection.admin) {
break
}
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({
clientFields: clientCollection?.fields || [],
defaultIDType,
fields: collection.fields,
i18n,
importMap,
})
serverOnlyCollectionProperties.forEach((key) => {
if (key in clientCollection) {
delete clientCollection[key]
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,
}
})
if ('upload' in clientCollection && typeof clientCollection.upload === 'object') {
serverOnlyUploadProperties.forEach((key) => {
if (key in clientCollection.upload) {
delete clientCollection.upload[key]
break
case 'upload':
if (!collection.upload) {
break
}
})
if ('imageSizes' in clientCollection.upload && clientCollection.upload.imageSizes.length) {
clientCollection.upload.imageSizes = clientCollection.upload.imageSizes.map((size) => {
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') {
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
return clientCollection as ClientCollectionConfig
}
export const createClientCollectionConfigs = ({

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import type { I18nClient } from '@payloadcms/translations'
import type { DeepPartial } from 'ts-essentials'
import type { ImportMap } from '../bin/generateImportMap/index.js'
import type {
@@ -12,7 +13,6 @@ import {
createClientCollectionConfigs,
} from '../collections/config/client.js'
import { type ClientGlobalConfig, createClientGlobalConfigs } from '../globals/config/client.js'
import { deepCopyObjectSimple } from '../utilities/deepCopyObject.js'
export type ServerOnlyRootProperties = keyof Pick<
SanitizedConfig,
@@ -39,7 +39,6 @@ export type ServerOnlyRootAdminProperties = keyof Pick<SanitizedConfig['admin'],
export type ClientConfig = {
admin: {
components: null
dependencies?: Record<string, React.ReactNode>
livePreview?: Omit<LivePreviewConfig, ServerOnlyLivePreviewProperties>
} & Omit<SanitizedConfig['admin'], 'components' | 'dependencies' | 'livePreview'>
@@ -81,56 +80,95 @@ export const createClientConfig = ({
i18n: I18nClient
importMap: ImportMap
}): ClientConfig => {
// We can use deepCopySimple here, as the clientConfig should be JSON serializable anyways, since it will be sent from server => client
const clientConfig = deepCopyObjectSimple(config, true) as unknown as ClientConfig
const clientConfig = {} as DeepPartial<ClientConfig>
for (const key of serverOnlyConfigProperties) {
if (key in clientConfig) {
delete clientConfig[key]
for (const key in config) {
if (serverOnlyConfigProperties.includes(key as any)) {
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
}
}
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({
break
case 'collections':
;(clientConfig.collections as ClientCollectionConfig[]) = createClientCollectionConfigs({
collections: config.collections,
defaultIDType: config.db.defaultIDType,
i18n,
importMap,
})
clientConfig.globals = createClientGlobalConfigs({
break
case 'globals':
;(clientConfig.globals as ClientGlobalConfig[]) = createClientGlobalConfigs({
defaultIDType: config.db.defaultIDType,
globals: config.globals,
i18n,
importMap,
})
return clientConfig
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
}

View File

@@ -39,19 +39,6 @@ export type ServerOnlyFieldProperties =
export type ServerOnlyFieldAdminProperties = keyof Pick<FieldBase['admin'], 'condition'>
export const createClientField = ({
clientField = {} as ClientField,
defaultIDType,
field: incomingField,
i18n,
importMap,
}: {
clientField?: ClientField
defaultIDType: Payload['config']['db']['defaultIDType']
field: Field
i18n: I18nClient
importMap: ImportMap
}): ClientField => {
const serverOnlyFieldProperties: Partial<ServerOnlyFieldProperties>[] = [
'hooks',
'access',
@@ -70,30 +57,79 @@ export const createClientField = ({
// `tabs`
// `admin`
]
const serverOnlyFieldAdminProperties: Partial<ServerOnlyFieldAdminProperties>[] = ['condition']
type FieldWithDescription = {
admin: AdminClient
} & ClientField
clientField.admin = clientField.admin || {}
// clientField.admin.readOnly = true
serverOnlyFieldProperties.forEach((key) => {
if (key in clientField) {
delete clientField[key]
}
})
export const createClientField = ({
defaultIDType,
field: incomingField,
i18n,
importMap,
}: {
defaultIDType: Payload['config']['db']['defaultIDType']
field: Field
i18n: I18nClient
importMap: ImportMap
}): ClientField => {
const clientField: ClientField = {} as ClientField
const isHidden = 'hidden' in incomingField && incomingField?.hidden
const disabledFromAdmin =
incomingField?.admin && 'disabled' in incomingField.admin && incomingField.admin.disabled
if (fieldAffectsData(clientField) && (isHidden || disabledFromAdmin)) {
if (fieldAffectsData(incomingField) && (isHidden || disabledFromAdmin)) {
return null
}
if (
'label' in clientField &&
'label' in incomingField &&
typeof incomingField.label === 'function'
) {
for (const key in incomingField) {
if (serverOnlyFieldProperties.includes(key as any)) {
continue
}
switch (key) {
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) {
@@ -108,7 +144,6 @@ export const createClientField = ({
}
field.fields = createClientFields({
clientFields: field.fields,
defaultIDType,
disableAddingID: incomingField.type !== 'array',
fields: incomingField.fields,
@@ -167,7 +202,6 @@ export const createClientField = ({
}
clientBlock.fields = createClientFields({
clientFields: clientBlock.fields,
defaultIDType,
fields: block.fields,
i18n,
@@ -225,24 +259,29 @@ export const createClientField = ({
const field = clientField as unknown as TabsFieldClient
if (incomingField.tabs?.length) {
field.tabs = []
for (let i = 0; i < incomingField.tabs.length; i++) {
const tab = incomingField.tabs[i]
const clientTab = field.tabs[i]
const clientTab = {} as unknown as TabsFieldClient['tabs'][0]
serverOnlyFieldProperties.forEach((key) => {
if (key in clientTab) {
delete clientTab[key]
for (const key in tab) {
if (serverOnlyFieldProperties.includes(key as any)) {
continue
}
})
if (key === 'fields') {
clientTab.fields = createClientFields({
clientFields: clientTab.fields,
defaultIDType,
disableAddingID: true,
fields: tab.fields,
i18n,
importMap,
})
} else {
clientTab[key] = tab[key]
}
}
field.tabs[i] = clientTab
}
}
@@ -253,70 +292,43 @@ export const createClientField = ({
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
}
export const createClientFields = ({
clientFields,
defaultIDType,
disableAddingID,
fields,
i18n,
importMap,
}: {
clientFields: ClientField[]
defaultIDType: Payload['config']['db']['defaultIDType']
disableAddingID?: boolean
fields: Field[]
i18n: I18nClient
importMap: ImportMap
}): ClientField[] => {
const newClientFields: ClientField[] = []
const clientFields: ClientField[] = []
for (let i = 0; i < fields.length; i++) {
const field = fields[i]
const newField = createClientField({
clientField: clientFields[i],
const clientField = createClientField({
defaultIDType,
field,
i18n,
importMap,
})
if (newField) {
newClientFields.push(newField)
if (clientField) {
clientFields.push(clientField)
}
}
const hasID = flattenTopLevelFields(fields).some((f) => fieldAffectsData(f) && f.name === 'id')
if (!disableAddingID && !hasID) {
newClientFields.push({
clientFields.push({
name: 'id',
type: defaultIDType,
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 { fieldAffectsData } from './config/types.js'
import type { ClientField, Field, TabAsField, TabAsFieldClient } from './config/types.js'
type Args = {
field: ClientField | Field | TabAsField
field: ClientField | Field | TabAsField | TabAsFieldClient
index: number
parentIndexPath: string
parentPath: string

View File

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

View File

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

View File

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

View File

@@ -555,7 +555,7 @@ export class BasePayload {
!checkedDependencies
) {
checkedDependencies = true
await checkPayloadDependencies()
void checkPayloadDependencies()
}
this.importMap = options.importMap
@@ -782,6 +782,12 @@ export const reload = async (
if (payload.db.connect) {
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 (

View File

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

View File

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

View File

@@ -9,7 +9,6 @@ import type {
import escapeHTML from 'escape-html'
import { sanitizeFields } from 'payload'
import { deepCopyObject } from 'payload/shared'
import type { ClientProps } from '../client/index.js'
@@ -78,7 +77,7 @@ export const LinkFeature = createServerFeature<
const validRelationships = _config.collections.map((c) => c.slug) || []
const _transformedFields = transformExtraFields(
props.fields ? deepCopyObject(props.fields) : null,
props.fields ? props.fields : null,
_config,
props.enabledCollections,
props.disabledCollections,
@@ -97,7 +96,7 @@ export const LinkFeature = createServerFeature<
// 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.
// 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',
)

View File

@@ -29,7 +29,9 @@ export const RscEntryLexicalField: React.FC<
const field: RichTextFieldType = args.field as RichTextFieldType
const path = args.path ?? (args.clientField as RichTextFieldClient).name
const schemaPath = args.schemaPath ?? path
const { clientFeatures, featureClientSchemaMap } = initLexicalFeatures({
clientFieldSchemaMap: args.clientFieldSchemaMap,
fieldSchemaMap: args.fieldSchemaMap,
i18n: args.i18n,
path,
@@ -43,6 +45,7 @@ export const RscEntryLexicalField: React.FC<
initialLexicalFormState = await buildInitialState({
context: {
id: args.id,
clientFieldSchemaMap: args.clientFieldSchemaMap,
collectionSlug: args.collectionSlug,
field,
fieldSchemaMap: args.fieldSchemaMap,
@@ -60,7 +63,7 @@ export const RscEntryLexicalField: React.FC<
const props: LexicalRichTextFieldProps = {
admin: args.admin,
clientFeatures,
featureClientSchemaMap,
featureClientSchemaMap, // TODO: Does client need this? Why cant this just live in the server
field: args.clientField as RichTextFieldClient,
forceRender: args.forceRender,
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,
beforeValidateTraverseFields,
checkDependencies,
deepCopyObject,
deepCopyObjectSimple,
withNullableJSONSchemaType,
} from 'payload'
@@ -21,31 +19,27 @@ import type {
LexicalRichTextAdapterProvider,
} from './types.js'
import { getDefaultSanitizedEditorConfig } from './getDefaultSanitizedEditorConfig.js'
import { i18n } from './i18n.js'
import { defaultEditorConfig, defaultEditorFeatures } from './lexical/config/server/default.js'
import { loadFeatures } from './lexical/config/server/loader.js'
import {
sanitizeServerEditorConfig,
sanitizeServerFeatures,
} from './lexical/config/server/sanitize.js'
import { sanitizeServerFeatures } from './lexical/config/server/sanitize.js'
import { populateLexicalPopulationPromises } from './populateGraphQL/populateLexicalPopulationPromises.js'
import { getGenerateImportMap } from './utilities/generateImportMap.js'
import { getGenerateSchemaMap } from './utilities/generateSchemaMap.js'
import { recurseNodeTree } from './utilities/recurseNodeTree.js'
import { richTextValidateHOC } from './validate/index.js'
let defaultSanitizedServerEditorConfig: null | SanitizedServerEditorConfig = null
let checkedDependencies = false
export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapterProvider {
return async ({ config, isRoot, parentIsLocalized }) => {
if (
process.env.NODE_ENV !== 'production' &&
process.env.PAYLOAD_DISABLE_DEPENDENCY_CHECKER !== 'true' &&
!checkedDependencies
) {
checkedDependencies = true
await checkDependencies({
void checkDependencies({
dependencyGroups: [
{
name: 'lexical',
@@ -65,45 +59,40 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte
],
})
}
return async ({ config, isRoot, parentIsLocalized }) => {
let features: FeatureProviderServer<unknown, unknown, unknown>[] = []
let resolvedFeatureMap: ResolvedServerFeatureMap
let finalSanitizedEditorConfig: SanitizedServerEditorConfig // For server only
if (!props || (!props.features && !props.lexical)) {
if (!defaultSanitizedServerEditorConfig) {
defaultSanitizedServerEditorConfig = await sanitizeServerEditorConfig(
defaultEditorConfig,
finalSanitizedEditorConfig = await getDefaultSanitizedEditorConfig({
config,
parentIsLocalized,
)
features = deepCopyObject(defaultEditorFeatures)
}
})
finalSanitizedEditorConfig = deepCopyObject(defaultSanitizedServerEditorConfig)
delete finalSanitizedEditorConfig.lexical // We don't want to send the default lexical editor config to the client
features = defaultEditorFeatures
resolvedFeatureMap = finalSanitizedEditorConfig.resolvedFeatureMap
} else {
if (props.features && typeof props.features === 'function') {
const rootEditor = config.editor
let rootEditorFeatures: FeatureProviderServer<unknown, unknown, unknown>[] = []
if (typeof rootEditor === 'object' && 'features' in rootEditor) {
rootEditorFeatures = (rootEditor as LexicalRichTextAdapter).features
}
features =
props.features && typeof props.features === 'function'
? props.features({
defaultFeatures: deepCopyObject(defaultEditorFeatures),
features = props.features({
defaultFeatures: defaultEditorFeatures,
rootFeatures: rootEditorFeatures,
})
: (props.features as FeatureProviderServer<unknown, unknown, unknown>[])
if (!features) {
features = deepCopyObject(defaultEditorFeatures)
} else {
features = props.features as FeatureProviderServer<unknown, unknown, unknown>[]
}
const lexical = props.lexical ?? deepCopyObjectSimple(defaultEditorConfig.lexical)!
if (!features) {
features = defaultEditorFeatures
}
const lexical = props.lexical ?? defaultEditorConfig.lexical
resolvedFeatureMap = await loadFeatures({
config,

View File

@@ -2,6 +2,7 @@ import type { SanitizedConfig } from 'payload'
import type {
FeatureProviderServer,
ResolvedServerFeature,
ResolvedServerFeatureMap,
ServerFeatureProviderMap,
} from '../../../features/typesServer.js'
@@ -186,14 +187,21 @@ export async function loadFeatures({
unSanitizedEditorConfig,
})
: featureProvider.feature
resolvedFeatures.set(featureProvider.key, {
...feature,
dependencies: featureProvider.dependencies!,
dependenciesPriority: featureProvider.dependenciesPriority!,
dependenciesSoft: featureProvider.dependenciesSoft!,
key: featureProvider.key,
order: loaded,
})
const resolvedFeature: ResolvedServerFeature<any, any> = feature as ResolvedServerFeature<
any,
any
>
// 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++
}

View File

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

View File

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

View File

@@ -1,18 +1,13 @@
import type { I18nClient } from '@payloadcms/translations'
import {
type ClientField,
createClientFields,
deepCopyObjectSimple,
type FieldSchemaMap,
type Payload,
} from 'payload'
import { type ClientFieldSchemaMap, type FieldSchemaMap, type Payload } from 'payload'
import { getFromImportMap } from 'payload/shared'
import type { FeatureProviderProviderClient } from '../features/typesClient.js'
import type { SanitizedServerEditorConfig } from '../lexical/config/types.js'
import type { FeatureClientSchemaMap, LexicalRichTextFieldProps } from '../types.js'
type Args = {
clientFieldSchemaMap: ClientFieldSchemaMap
fieldSchemaMap: FieldSchemaMap
i18n: I18nClient
path: string
@@ -27,9 +22,6 @@ export function initLexicalFeatures(args: Args): {
} {
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:
const resolvedFeatureMapArray = Array.from(
args.sanitizedEditorConfig.resolvedFeatureMap.entries(),
@@ -84,66 +76,16 @@ export function initLexicalFeatures(args: Args): {
featureKey,
].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] = {}
for (const key in featureSchemaMap) {
const state = featureSchemaMap[key]
const clientFields = createClientFields({
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)
// Like args.fieldSchemaMap, we only want to include the sub-fields of the current feature
for (const [key, entry] of args.clientFieldSchemaMap.entries()) {
if (key.startsWith(featureSchemaPath)) {
featureClientSchemaMap[featureKey][key] = 'fields' in entry ? entry.fields : [entry]
}
}
}
}
lexicalDeepIterate(value.root)*/
}
}
return {
clientFeatures,
featureClientSchemaMap,

View File

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

View File

@@ -53,6 +53,11 @@
"types": "./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": {
"import": "./src/utilities/buildFieldSchemaMap/traverseFields.ts",
"types": "./src/utilities/buildFieldSchemaMap/traverseFields.ts",

View File

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

View File

@@ -1,4 +1,5 @@
import type {
ClientFieldSchemaMap,
Data,
DocumentPreferences,
Field,
@@ -15,6 +16,12 @@ import { calculateDefaultValues } from './calculateDefaultValues/index.js'
import { iterateFields } from './iterateFields.js'
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
data?: Data
fields: Field[] | undefined
@@ -40,12 +47,19 @@ type Args = {
renderAllFields: boolean
renderFieldFn?: RenderFieldMethod
req: PayloadRequest
schemaPath: string
}
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 {
id,
clientFieldSchemaMap,
collectionSlug,
data = {},
fields,
@@ -77,6 +91,7 @@ export const fieldSchemasToFormState = async (args: Args): Promise<FormState> =>
await iterateFields({
id,
addErrorPathToParent: null,
clientFieldSchemaMap,
collectionSlug,
data: dataWithDefaultValues,
fields,

View File

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

View File

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

View File

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

View File

@@ -14,7 +14,7 @@ const LocaleContext = createContext({} as Locale)
export const LocaleProvider: React.FC<{ children?: React.ReactNode }> = ({ children }) => {
const {
config: { localization },
config: { localization = false },
} = useConfig()
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 { Field, FieldSchemaMap, SanitizedConfig } from 'payload'
import type { Field, FieldSchemaMap, SanitizedConfig, TextField } from 'payload'
import { confirmPassword, password } from 'payload/shared'
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
*/
@@ -25,22 +41,8 @@ export const buildFieldSchemaMap = (args: {
if (matchedCollection) {
if (matchedCollection.auth && !matchedCollection.auth.disableLocalStrategy) {
// register schema with auth schemaPath
const baseAuthFields: Field[] = [
{
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,
},
]
;(baseAuthFields[0] as TextField).label = i18n.t('general:password')
;(baseAuthFields[1] as TextField).label = i18n.t('authentication:confirmPassword')
schemaMap.set(`_${matchedCollection.slug}.auth`, {
fields: [...baseAuthFields, ...matchedCollection.fields],

View File

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

View File

@@ -1,65 +1,15 @@
import type { I18n, I18nClient } from '@payloadcms/translations'
import type {
BuildFormStateArgs,
ClientConfig,
ClientUser,
ErrorResult,
FieldSchemaMap,
FormState,
SanitizedConfig,
} from 'payload'
import type { BuildFormStateArgs, ClientConfig, ClientUser, ErrorResult, FormState } from 'payload'
import { formatErrors } from 'payload'
import { reduceFieldsToValues } from 'payload/shared'
import { fieldSchemasToFormState } from '../forms/fieldSchemasToFormState/index.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'
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 = {
clientConfig?: ClientConfig
errors?: never
@@ -167,15 +117,24 @@ export const buildFormState = async (
throw new Error('Either collectionSlug or globalSlug must be provided')
}
const fieldSchemaMap = getFieldSchemaMap({
const schemaMap = getSchemaMap({
collectionSlug,
config,
globalSlug,
i18n,
})
const clientSchemaMap = getClientSchemaMap({
collectionSlug,
config: getClientConfig({ config, i18n, importMap: req.payload.importMap }),
globalSlug,
i18n,
payload,
schemaMap,
})
const id = collectionSlug ? idFromArgs : undefined
const fieldOrEntityConfig = fieldSchemaMap.get(schemaPath)
const fieldOrEntityConfig = schemaMap.get(schemaPath)
if (!fieldOrEntityConfig) {
throw new Error(`Could not find "${schemaPath}" in the fieldSchemaMap`)
@@ -216,10 +175,11 @@ export const buildFormState = async (
const formStateResult = await fieldSchemasToFormState({
id,
clientFieldSchemaMap: clientSchemaMap,
collectionSlug,
data,
fields,
fieldSchemaMap,
fieldSchemaMap: schemaMap,
operation,
permissions: docPermissions?.fields || {},
preferences: docPreferences || { fields: {} },

View File

@@ -1,49 +1,21 @@
import type { I18nClient } from '@payloadcms/translations'
import type {
BuildTableStateArgs,
ClientCollectionConfig,
ClientConfig,
ErrorResult,
ImportMap,
PaginatedDocs,
SanitizedCollectionConfig,
SanitizedConfig,
} from 'payload'
import { dequal } from 'dequal/lite'
import { createClientConfig, formatErrors } from 'payload'
import { formatErrors } from 'payload'
import type { Column } from '../elements/Table/index.js'
import type { ListPreferences } from '../elements/TableColumns/index.js'
import { getClientConfig } from './getClientConfig.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 = {
clientConfig?: ClientConfig
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 = {
className?: string
@@ -13,5 +13,5 @@ export type Props = {
setValidateBeforeSubmit: (validate: boolean) => void
useAPIKey?: boolean
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,
serverURL,
noAutoLogin,
readyURL,
}: {
customAdminRoutes?: Config['admin']['routes']
customRoutes?: Config['routes']
noAutoLogin?: boolean
page: Page
readyURL?: string
serverURL: string
}): Promise<void> {
const {
@@ -82,11 +84,16 @@ export async function ensureCompilationIsDone({
while (attempt <= maxAttempts) {
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.waitForURL(
noAutoLogin ? `${adminURL + (adminURL.endsWith('/') ? '' : '/')}login` : adminURL,
readyURL ??
(noAutoLogin ? `${adminURL + (adminURL.endsWith('/') ? '' : '/')}login` : adminURL),
)
console.log('Successfully compiled')