From fd0ff51296dbe686bfa9458864826e41894c3171 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Tue, 26 Nov 2024 14:31:14 -0700 Subject: [PATCH] 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. --- .github/workflows/main.yml | 1 + .../src/layouts/Root/checkDependencies.ts | 4 +- packages/next/src/layouts/Root/index.tsx | 8 +- .../next/src/utilities/getClientConfig.ts | 23 -- packages/next/src/views/Account/index.tsx | 1 + .../next/src/views/Document/getVersions.ts | 103 +++++--- .../views/Document/handleServerFunction.tsx | 41 +--- packages/next/src/views/Document/index.tsx | 9 +- .../src/views/List/handleServerFunction.tsx | 39 +-- packages/next/src/views/Root/index.tsx | 4 +- packages/payload/src/admin/forms/Field.ts | 2 + packages/payload/src/admin/types.ts | 20 +- .../payload/src/checkPayloadDependencies.ts | 4 +- .../payload/src/collections/config/client.ts | 231 +++++++++++------- .../collections/operations/findVersionByID.ts | 5 + .../collections/operations/findVersions.ts | 4 + packages/payload/src/config/client.ts | 140 +++++++---- packages/payload/src/fields/config/client.ts | 182 +++++++------- packages/payload/src/fields/getFieldPaths.ts | 6 +- packages/payload/src/globals/config/client.ts | 92 ++++--- .../src/globals/operations/findVersionByID.ts | 4 + .../src/globals/operations/findVersions.ts | 4 + packages/payload/src/index.ts | 8 +- .../blocks/client/component/index.tsx | 6 +- .../blocks/server/nodes/BlocksNode.tsx | 5 +- .../blocks/server/nodes/InlineBlocksNode.tsx | 5 +- .../src/features/link/server/index.ts | 5 +- .../richtext-lexical/src/field/rscEntry.tsx | 5 +- .../src/getDefaultSanitizedEditorConfig.ts | 46 ++++ packages/richtext-lexical/src/index.ts | 105 ++++---- .../src/lexical/config/server/loader.ts | 24 +- packages/richtext-lexical/src/types.ts | 7 +- .../src/utilities/buildInitialState.ts | 3 + .../src/utilities/initLexicalFeatures.ts | 70 +----- .../richtext-slate/src/field/rscEntry.tsx | 14 +- packages/ui/package.json | 5 + .../addFieldStatePromise.ts | 15 ++ .../forms/fieldSchemasToFormState/index.tsx | 15 ++ .../fieldSchemasToFormState/iterateFields.ts | 4 + .../fieldSchemasToFormState/renderField.tsx | 19 +- .../forms/fieldSchemasToFormState/types.ts | 2 + packages/ui/src/providers/Locale/index.tsx | 2 +- .../buildClientFieldSchemaMap/index.ts | 94 +++++++ .../traverseFields.ts | 154 ++++++++++++ .../utilities/buildFieldSchemaMap/index.ts | 36 +-- .../buildFieldSchemaMap/traverseFields.ts | 1 - packages/ui/src/utilities/buildFormState.ts | 74 ++---- packages/ui/src/utilities/buildTableState.ts | 32 +-- packages/ui/src/utilities/getClientConfig.ts | 31 +++ .../ui/src/utilities/getClientSchemaMap.ts | 54 ++++ packages/ui/src/utilities/getSchemaMap.ts | 50 ++++ packages/ui/src/views/Edit/Auth/types.ts | 4 +- test/auth-basic/config.ts | 19 ++ test/auth-basic/e2e.spec.ts | 152 ++++++++++++ test/auth-basic/payload-types.ts | 186 ++++++++++++++ test/auth-basic/tsconfig.eslint.json | 13 + test/auth-basic/tsconfig.json | 3 + test/helpers.ts | 11 +- 58 files changed, 1512 insertions(+), 694 deletions(-) delete mode 100644 packages/next/src/utilities/getClientConfig.ts create mode 100644 packages/richtext-lexical/src/getDefaultSanitizedEditorConfig.ts create mode 100644 packages/ui/src/utilities/buildClientFieldSchemaMap/index.ts create mode 100644 packages/ui/src/utilities/buildClientFieldSchemaMap/traverseFields.ts create mode 100644 packages/ui/src/utilities/getClientConfig.ts create mode 100644 packages/ui/src/utilities/getClientSchemaMap.ts create mode 100644 packages/ui/src/utilities/getSchemaMap.ts create mode 100644 test/auth-basic/config.ts create mode 100644 test/auth-basic/e2e.spec.ts create mode 100644 test/auth-basic/payload-types.ts create mode 100644 test/auth-basic/tsconfig.eslint.json create mode 100644 test/auth-basic/tsconfig.json diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index dc0fcada8..c45274613 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -296,6 +296,7 @@ jobs: - admin__e2e__3 - admin-root - auth + - auth-basic - field-error-states - fields-relationship - fields diff --git a/packages/next/src/layouts/Root/checkDependencies.ts b/packages/next/src/layouts/Root/checkDependencies.ts index de0cb1530..5a87975a0 100644 --- a/packages/next/src/layouts/Root/checkDependencies.ts +++ b/packages/next/src/layouts/Root/checkDependencies.ts @@ -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', diff --git a/packages/next/src/layouts/Root/index.tsx b/packages/next/src/layouts/Root/index.tsx index ba59cae91..39d89887d 100644 --- a/packages/next/src/layouts/Root/index.tsx +++ b/packages/next/src/layouts/Root/index.tsx @@ -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, diff --git a/packages/next/src/utilities/getClientConfig.ts b/packages/next/src/utilities/getClientConfig.ts deleted file mode 100644 index 1fde75460..000000000 --- a/packages/next/src/utilities/getClientConfig.ts +++ /dev/null @@ -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 => { - const { config, i18n, importMap } = args - - const clientConfig = createClientConfig({ - config, - i18n, - importMap, - }) - - return Promise.resolve(clientConfig) - }, -) diff --git a/packages/next/src/views/Account/index.tsx b/packages/next/src/views/Account/index.tsx index a57d9638d..9ef828674 100644 --- a/packages/next/src/views/Account/index.tsx +++ b/packages/next/src/views/Account/index.tsx @@ -102,6 +102,7 @@ export const Account: React.FC = async ({ await getVersions({ id: user.id, collectionConfig, + doc: data, docPermissions, locale: locale?.code, payload, diff --git a/packages/next/src/views/Document/getVersions.ts b/packages/next/src/views/Document/getVersions.ts index f18209f7e..44ef27edf 100644 --- a/packages/next/src/views/Document/getVersions.ts +++ b/packages/next/src/views/Document/getVersions.ts @@ -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 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,37 +79,49 @@ export const getVersions = async ({ } if (versionsConfig?.drafts) { - publishedQuery = await payload.find({ - collection: collectionConfig.slug, - depth: 0, - locale: locale || undefined, - user, - where: { - and: [ - { - or: [ + // 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: [ { - _status: { - equals: 'published', - }, + or: [ + { + _status: { + equals: 'published', + }, + }, + { + _status: { + exists: false, + }, + }, + ], }, { - _status: { - exists: false, + id: { + equals: id, }, }, ], }, - { - id: { - equals: id, - }, - }, - ], - }, - }) + }) + )?.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({ - slug: globalConfig.slug, - depth: 0, - locale, - user, - }) + 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, }, }, ], diff --git a/packages/next/src/views/Document/handleServerFunction.tsx b/packages/next/src/views/Document/handleServerFunction.tsx index 187e8674f..d4764b2f3 100644 --- a/packages/next/src/views/Document/handleServerFunction.tsx +++ b/packages/next/src/views/Document/handleServerFunction.tsx @@ -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 diff --git a/packages/next/src/views/Document/index.tsx b/packages/next/src/views/Document/index.tsx index d0d4742c0..ab8349b89 100644 --- a/packages/next/src/views/Document/index.tsx +++ b/packages/next/src/views/Document/index.tsx @@ -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, diff --git a/packages/next/src/views/List/handleServerFunction.tsx b/packages/next/src/views/List/handleServerFunction.tsx index afe897ec7..d363539ba 100644 --- a/packages/next/src/views/List/handleServerFunction.tsx +++ b/packages/next/src/views/List/handleServerFunction.tsx @@ -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 diff --git a/packages/next/src/views/Root/index.tsx b/packages/next/src/views/Root/index.tsx index 3efe20c58..24a0b6732 100644 --- a/packages/next/src/views/Root/index.tsx +++ b/packages/next/src/views/Root/index.tsx @@ -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, diff --git a/packages/payload/src/admin/forms/Field.ts b/packages/payload/src/admin/forms/Field.ts index 04f3e981e..1157f4d3e 100644 --- a/packages/payload/src/admin/forms/Field.ts +++ b/packages/payload/src/admin/forms/Field.ts @@ -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 diff --git a/packages/payload/src/admin/types.ts b/packages/payload/src/admin/types.ts index f6bb54878..0a158308a 100644 --- a/packages/payload/src/admin/types.ts +++ b/packages/payload/src/admin/types.ts @@ -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 +> diff --git a/packages/payload/src/checkPayloadDependencies.ts b/packages/payload/src/checkPayloadDependencies.ts index fbf4d7bd6..f58115089 100644 --- a/packages/payload/src/checkPayloadDependencies.ts +++ b/packages/payload/src/checkPayloadDependencies.ts @@ -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', diff --git a/packages/payload/src/collections/config/client.ts b/packages/payload/src/collections/config/client.ts index 666c0a8de..83316dce0 100644 --- a/packages/payload/src/collections/config/client.ts +++ b/packages/payload/src/collections/config/client.ts @@ -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 +} & Omit< + SanitizedCollectionConfig, + 'admin' | 'auth' | 'fields' | 'labels' | ServerOnlyCollectionProperties +> const serverOnlyCollectionProperties: Partial[] = [ '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 - clientCollection.fields = createClientFields({ - clientFields: clientCollection?.fields || [], - defaultIDType, - fields: collection.fields, - i18n, - importMap, - }) - - serverOnlyCollectionProperties.forEach((key) => { - if (key in clientCollection) { - delete clientCollection[key] + for (const key in collection) { + if (serverOnlyCollectionProperties.includes(key as any)) { + continue } - }) - - if ('upload' in clientCollection && typeof clientCollection.upload === 'object') { - serverOnlyUploadProperties.forEach((key) => { - 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 + switch (key) { + case 'admin': + if (!collection.admin) { + break } - 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') { - 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 = ({ diff --git a/packages/payload/src/collections/operations/findVersionByID.ts b/packages/payload/src/collections/operations/findVersionByID.ts index 0b65bce32..1393f8d06 100644 --- a/packages/payload/src/collections/operations/findVersionByID.ts +++ b/packages/payload/src/collections/operations/findVersionByID.ts @@ -91,6 +91,11 @@ export const findVersionByIDOperation = async ( return null } + if (!result.version) { + // Fallback if not selected + ;(result as any).version = {} + } + // ///////////////////////////////////// // beforeRead - Collection // ///////////////////////////////////// diff --git a/packages/payload/src/collections/operations/findVersions.ts b/packages/payload/src/collections/operations/findVersions.ts index 0a0849b8d..1eb3a7a75 100644 --- a/packages/payload/src/collections/operations/findVersions.ts +++ b/packages/payload/src/collections/operations/findVersions.ts @@ -93,6 +93,10 @@ export const findVersionsOperation = async 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 diff --git a/packages/payload/src/config/client.ts b/packages/payload/src/config/client.ts index a511fca50..f408823db 100644 --- a/packages/payload/src/config/client.ts +++ b/packages/payload/src/config/client.ts @@ -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 livePreview?: Omit } & Omit @@ -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 - 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 + } + } + 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] } } - - 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 + return clientConfig as ClientConfig } diff --git a/packages/payload/src/fields/config/client.ts b/packages/payload/src/fields/config/client.ts index be2de5426..0f7ab993d 100644 --- a/packages/payload/src/fields/config/client.ts +++ b/packages/payload/src/fields/config/client.ts @@ -39,61 +39,97 @@ export type ServerOnlyFieldProperties = export type ServerOnlyFieldAdminProperties = keyof Pick +const serverOnlyFieldProperties: Partial[] = [ + '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[] = ['condition'] +type FieldWithDescription = { + admin: AdminClient +} & ClientField + 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[] = [ - '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 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' - ) { - clientField.label = incomingField.label({ t: i18n.t }) + 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 } - }) - - clientTab.fields = createClientFields({ - clientFields: clientTab.fields, - defaultIDType, - disableAddingID: true, - fields: tab.fields, - i18n, - importMap, - }) + if (key === 'fields') { + clientTab.fields = createClientFields({ + 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[] = ['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 } diff --git a/packages/payload/src/fields/getFieldPaths.ts b/packages/payload/src/fields/getFieldPaths.ts index f780b3959..539e082d4 100644 --- a/packages/payload/src/fields/getFieldPaths.ts +++ b/packages/payload/src/fields/getFieldPaths.ts @@ -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 diff --git a/packages/payload/src/globals/config/client.ts b/packages/payload/src/globals/config/client.ts index 7a59eecdf..50f1493ca 100644 --- a/packages/payload/src/globals/config/client.ts +++ b/packages/payload/src/globals/config/client.ts @@ -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 +export type ServerOnlyGlobalAdminProperties = keyof Pick< + SanitizedGlobalConfig['admin'], + 'components' | 'hidden' +> export type ClientGlobalConfig = { admin: { @@ -40,7 +42,10 @@ const serverOnlyProperties: Partial[] = [ // `admin` is handled separately ] -const serverOnlyGlobalAdminProperties: Partial[] = ['hidden'] +const serverOnlyGlobalAdminProperties: Partial[] = [ + '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 - clientGlobal.fields = createClientFields({ - clientFields: clientGlobal?.fields || [], - defaultIDType, - fields: global.fields, - i18n, - importMap, - }) - - serverOnlyProperties.forEach((key) => { - if (key in clientGlobal) { - delete clientGlobal[key] + for (const key in global) { + if (serverOnlyProperties.includes(key as any)) { + continue } - }) - - if (!clientGlobal.admin) { - clientGlobal.admin = {} as ClientGlobalConfig['admin'] - } - - serverOnlyGlobalAdminProperties.forEach((key) => { - if (key in clientGlobal.admin) { - delete clientGlobal.admin[key] + 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({ + 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 diff --git a/packages/payload/src/globals/operations/findVersionByID.ts b/packages/payload/src/globals/operations/findVersionByID.ts index 3c3bf3ea3..81fae5f29 100644 --- a/packages/payload/src/globals/operations/findVersionByID.ts +++ b/packages/payload/src/globals/operations/findVersionByID.ts @@ -90,6 +90,10 @@ export const findVersionByIDOperation = async = 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 diff --git a/packages/payload/src/globals/operations/findVersions.ts b/packages/payload/src/globals/operations/findVersions.ts index e26f27669..ca7f83087 100644 --- a/packages/payload/src/globals/operations/findVersions.ts +++ b/packages/payload/src/globals/operations/findVersions.ts @@ -90,6 +90,10 @@ export const findVersionsOperation = async >( ...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({ diff --git a/packages/payload/src/index.ts b/packages/payload/src/index.ts index 71c82ad45..543560e49 100644 --- a/packages/payload/src/index.ts +++ b/packages/payload/src/index.ts @@ -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 ( diff --git a/packages/richtext-lexical/src/features/blocks/client/component/index.tsx b/packages/richtext-lexical/src/features/blocks/client/component/index.tsx index 934c086d1..9487a959d 100644 --- a/packages/richtext-lexical/src/features/blocks/client/component/index.tsx +++ b/packages/richtext-lexical/src/features/blocks/client/component/index.tsx @@ -212,10 +212,8 @@ export const BlockComponent: React.FC = (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) } }) diff --git a/packages/richtext-lexical/src/features/blocks/server/nodes/BlocksNode.tsx b/packages/richtext-lexical/src/features/blocks/server/nodes/BlocksNode.tsx index afcab5606..f95d49317 100644 --- a/packages/richtext-lexical/src/features/blocks/server/nodes/BlocksNode.tsx +++ b/packages/richtext-lexical/src/features/blocks/server/nodes/BlocksNode.tsx @@ -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 = { /** 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 } } diff --git a/packages/richtext-lexical/src/features/blocks/server/nodes/InlineBlocksNode.tsx b/packages/richtext-lexical/src/features/blocks/server/nodes/InlineBlocksNode.tsx index 1ca3f7887..834de23e7 100644 --- a/packages/richtext-lexical/src/features/blocks/server/nodes/InlineBlocksNode.tsx +++ b/packages/richtext-lexical/src/features/blocks/server/nodes/InlineBlocksNode.tsx @@ -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 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', ) diff --git a/packages/richtext-lexical/src/field/rscEntry.tsx b/packages/richtext-lexical/src/field/rscEntry.tsx index 3d3e983df..058c8dc34 100644 --- a/packages/richtext-lexical/src/field/rscEntry.tsx +++ b/packages/richtext-lexical/src/field/rscEntry.tsx @@ -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, diff --git a/packages/richtext-lexical/src/getDefaultSanitizedEditorConfig.ts b/packages/richtext-lexical/src/getDefaultSanitizedEditorConfig.ts new file mode 100644 index 000000000..19816b077 --- /dev/null +++ b/packages/richtext-lexical/src/getDefaultSanitizedEditorConfig.ts @@ -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 = (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 => { + 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 + }, +) diff --git a/packages/richtext-lexical/src/index.ts b/packages/richtext-lexical/src/index.ts index 9f8e68f1d..8d06cc189 100644 --- a/packages/richtext-lexical/src/index.ts +++ b/packages/richtext-lexical/src/index.ts @@ -7,8 +7,6 @@ import { beforeChangeTraverseFields, beforeValidateTraverseFields, checkDependencies, - deepCopyObject, - deepCopyObjectSimple, withNullableJSONSchemaType, } from 'payload' @@ -21,89 +19,80 @@ 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 { + 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 }) => { - 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[] = [] let resolvedFeatureMap: ResolvedServerFeatureMap let finalSanitizedEditorConfig: SanitizedServerEditorConfig // For server only if (!props || (!props.features && !props.lexical)) { - if (!defaultSanitizedServerEditorConfig) { - defaultSanitizedServerEditorConfig = await sanitizeServerEditorConfig( - defaultEditorConfig, - config, - parentIsLocalized, - ) - features = deepCopyObject(defaultEditorFeatures) - } + finalSanitizedEditorConfig = await getDefaultSanitizedEditorConfig({ + config, + parentIsLocalized, + }) - 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 { - const rootEditor = config.editor - let rootEditorFeatures: FeatureProviderServer[] = [] - if (typeof rootEditor === 'object' && 'features' in rootEditor) { - rootEditorFeatures = (rootEditor as LexicalRichTextAdapter).features + if (props.features && typeof props.features === 'function') { + const rootEditor = config.editor + let rootEditorFeatures: FeatureProviderServer[] = [] + 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[] } - features = - props.features && typeof props.features === 'function' - ? props.features({ - defaultFeatures: deepCopyObject(defaultEditorFeatures), - rootFeatures: rootEditorFeatures, - }) - : (props.features as FeatureProviderServer[]) if (!features) { - features = deepCopyObject(defaultEditorFeatures) + features = defaultEditorFeatures } - const lexical = props.lexical ?? deepCopyObjectSimple(defaultEditorConfig.lexical)! + const lexical = props.lexical ?? defaultEditorConfig.lexical resolvedFeatureMap = await loadFeatures({ config, diff --git a/packages/richtext-lexical/src/lexical/config/server/loader.ts b/packages/richtext-lexical/src/lexical/config/server/loader.ts index 2a837eca3..ed60682b8 100644 --- a/packages/richtext-lexical/src/lexical/config/server/loader.ts +++ b/packages/richtext-lexical/src/lexical/config/server/loader.ts @@ -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 = 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++ } diff --git a/packages/richtext-lexical/src/types.ts b/packages/richtext-lexical/src/types.ts index 9962d6a36..5ece7741e 100644 --- a/packages/richtext-lexical/src/types.ts +++ b/packages/richtext-lexical/src/types.ts @@ -80,10 +80,11 @@ export type LexicalRichTextAdapterProvider = parentIsLocalized: boolean }) => Promise +export type SingleFeatureClientSchemaMap = { + [key: string]: ClientField[] +} export type FeatureClientSchemaMap = { - [featureKey: string]: { - [key: string]: ClientField[] - } + [featureKey: string]: SingleFeatureClientSchemaMap } export type LexicalRichTextFieldProps = { diff --git a/packages/richtext-lexical/src/utilities/buildInitialState.ts b/packages/richtext-lexical/src/utilities/buildInitialState.ts index 66a529f95..d6614d2d8 100644 --- a/packages/richtext-lexical/src/utilities/buildInitialState.ts +++ b/packages/richtext-lexical/src/utilities/buildInitialState.ts @@ -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, diff --git a/packages/richtext-lexical/src/utilities/initLexicalFeatures.ts b/packages/richtext-lexical/src/utilities/initLexicalFeatures.ts index 58e9e2c05..04cca6c73 100644 --- a/packages/richtext-lexical/src/utilities/initLexicalFeatures.ts +++ b/packages/richtext-lexical/src/utilities/initLexicalFeatures.ts @@ -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,64 +76,14 @@ 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 { diff --git a/packages/richtext-slate/src/field/rscEntry.tsx b/packages/richtext-slate/src/field/rscEntry.tsx index 123ff3616..c2f3dd5be 100644 --- a/packages/richtext-slate/src/field/rscEntry.tsx +++ b/packages/richtext-slate/src/field/rscEntry.tsx @@ -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, diff --git a/packages/ui/package.json b/packages/ui/package.json index f2cdfac79..081bf1a80 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -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", diff --git a/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts b/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts index 15e27c8ec..b0ddc8ca1 100644 --- a/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts +++ b/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts @@ -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, diff --git a/packages/ui/src/forms/fieldSchemasToFormState/index.tsx b/packages/ui/src/forms/fieldSchemasToFormState/index.tsx index cc5bee53f..f1a836940 100644 --- a/packages/ui/src/forms/fieldSchemasToFormState/index.tsx +++ b/packages/ui/src/forms/fieldSchemasToFormState/index.tsx @@ -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 => { + 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 => await iterateFields({ id, addErrorPathToParent: null, + clientFieldSchemaMap, collectionSlug, data: dataWithDefaultValues, fields, diff --git a/packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts b/packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts index e218f9aa3..7a5e715b9 100644 --- a/packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts +++ b/packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts @@ -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, diff --git a/packages/ui/src/forms/fieldSchemasToFormState/renderField.tsx b/packages/ui/src/forms/fieldSchemasToFormState/renderField.tsx index 32d168460..94c6ec0f1 100644 --- a/packages/ui/src/forms/fieldSchemasToFormState/renderField.tsx +++ b/packages/ui/src/forms/fieldSchemasToFormState/renderField.tsx @@ -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,13 +36,14 @@ export const renderField: RenderFieldMethod = ({ schemaPath, siblingData, }) => { - const clientField = createClientField({ - clientField: deepCopyObjectSimple(fieldConfig) as ClientField, - defaultIDType: req.payload.config.db.defaultIDType, - field: fieldConfig, - i18n: req.i18n, - importMap: req.payload.importMap, - }) + const clientField = clientFieldSchemaMap + ? (clientFieldSchemaMap.get(schemaPath) as ClientField) + : createClientField({ + defaultIDType: req.payload.config.db.defaultIDType, + field: fieldConfig, + i18n: req.i18n, + importMap: req.payload.importMap, + }) const clientProps: ClientComponentProps & Partial = { customComponents: fieldState?.customComponents || {}, @@ -61,6 +63,7 @@ export const renderField: RenderFieldMethod = ({ const serverProps: ServerComponentProps = { id, clientField, + clientFieldSchemaMap, data, field: fieldConfig, fieldSchemaMap, diff --git a/packages/ui/src/forms/fieldSchemasToFormState/types.ts b/packages/ui/src/forms/fieldSchemasToFormState/types.ts index 9e98fede8..71f44370d 100644 --- a/packages/ui/src/forms/fieldSchemasToFormState/types.ts +++ b/packages/ui/src/forms/fieldSchemasToFormState/types.ts @@ -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 diff --git a/packages/ui/src/providers/Locale/index.tsx b/packages/ui/src/providers/Locale/index.tsx index 2ffaeb4eb..6248933cc 100644 --- a/packages/ui/src/providers/Locale/index.tsx +++ b/packages/ui/src/providers/Locale/index.tsx @@ -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() diff --git a/packages/ui/src/utilities/buildClientFieldSchemaMap/index.ts b/packages/ui/src/utilities/buildClientFieldSchemaMap/index.ts new file mode 100644 index 000000000..16375c22d --- /dev/null +++ b/packages/ui/src/utilities/buildClientFieldSchemaMap/index.ts @@ -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 } +} diff --git a/packages/ui/src/utilities/buildClientFieldSchemaMap/traverseFields.ts b/packages/ui/src/utilities/buildClientFieldSchemaMap/traverseFields.ts new file mode 100644 index 000000000..7bea4991e --- /dev/null +++ b/packages/ui/src/utilities/buildClientFieldSchemaMap/traverseFields.ts @@ -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 + 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 + } + } +} diff --git a/packages/ui/src/utilities/buildFieldSchemaMap/index.ts b/packages/ui/src/utilities/buildFieldSchemaMap/index.ts index a80c25ad5..97bd7ceca 100644 --- a/packages/ui/src/utilities/buildFieldSchemaMap/index.ts +++ b/packages/ui/src/utilities/buildFieldSchemaMap/index.ts @@ -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], diff --git a/packages/ui/src/utilities/buildFieldSchemaMap/traverseFields.ts b/packages/ui/src/utilities/buildFieldSchemaMap/traverseFields.ts index 3fd6ee115..37df3098e 100644 --- a/packages/ui/src/utilities/buildFieldSchemaMap/traverseFields.ts +++ b/packages/ui/src/utilities/buildFieldSchemaMap/traverseFields.ts @@ -63,7 +63,6 @@ export const traverseFields = ({ break case 'collapsible': - case 'row': traverseFields({ config, diff --git a/packages/ui/src/utilities/buildFormState.ts b/packages/ui/src/utilities/buildFormState.ts index 0726767ed..b0f0f2ab8 100644 --- a/packages/ui/src/utilities/buildFormState.ts +++ b/packages/ui/src/utilities/buildFormState.ts @@ -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: {} }, diff --git a/packages/ui/src/utilities/buildTableState.ts b/packages/ui/src/utilities/buildTableState.ts index 002f27924..c5d7c9140 100644 --- a/packages/ui/src/utilities/buildTableState.ts +++ b/packages/ui/src/utilities/buildTableState.ts @@ -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 diff --git a/packages/ui/src/utilities/getClientConfig.ts b/packages/ui/src/utilities/getClientConfig.ts new file mode 100644 index 000000000..a92314c5c --- /dev/null +++ b/packages/ui/src/utilities/getClientConfig.ts @@ -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 + }, +) diff --git a/packages/ui/src/utilities/getClientSchemaMap.ts b/packages/ui/src/utilities/getClientSchemaMap.ts new file mode 100644 index 000000000..d019aeea7 --- /dev/null +++ b/packages/ui/src/utilities/getClientSchemaMap.ts @@ -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 + }, +) diff --git a/packages/ui/src/utilities/getSchemaMap.ts b/packages/ui/src/utilities/getSchemaMap.ts new file mode 100644 index 000000000..cff509bdc --- /dev/null +++ b/packages/ui/src/utilities/getSchemaMap.ts @@ -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 + }, +) diff --git a/packages/ui/src/views/Edit/Auth/types.ts b/packages/ui/src/views/Edit/Auth/types.ts index a3fef3b59..766f8896b 100644 --- a/packages/ui/src/views/Edit/Auth/types.ts +++ b/packages/ui/src/views/Edit/Auth/types.ts @@ -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 } diff --git a/test/auth-basic/config.ts b/test/auth-basic/config.ts new file mode 100644 index 000000000..5ca434dd2 --- /dev/null +++ b/test/auth-basic/config.ts @@ -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'), + }, +}) diff --git a/test/auth-basic/e2e.spec.ts b/test/auth-basic/e2e.spec.ts new file mode 100644 index 000000000..2235bfaf0 --- /dev/null +++ b/test/auth-basic/e2e.spec.ts @@ -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 + +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({ 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, + }) + }) + }) +}) diff --git a/test/auth-basic/payload-types.ts b/test/auth-basic/payload-types.ts new file mode 100644 index 000000000..6b406d8f5 --- /dev/null +++ b/test/auth-basic/payload-types.ts @@ -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 | UsersSelect; + 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; + 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; + 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; + }; + 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 { + 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 { + 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 { + 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 { + 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 {} +} \ No newline at end of file diff --git a/test/auth-basic/tsconfig.eslint.json b/test/auth-basic/tsconfig.eslint.json new file mode 100644 index 000000000..b34cc7afb --- /dev/null +++ b/test/auth-basic/tsconfig.eslint.json @@ -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" + ] +} diff --git a/test/auth-basic/tsconfig.json b/test/auth-basic/tsconfig.json new file mode 100644 index 000000000..3c43903cf --- /dev/null +++ b/test/auth-basic/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../tsconfig.json" +} diff --git a/test/helpers.ts b/test/helpers.ts index c33265947..b2ed04440 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -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 { 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')