From 034a26754f4bd1ffb1cd69d72fefb40328257fbf Mon Sep 17 00:00:00 2001 From: Said Akhrarov <36972061+akhrarovsaid@users.noreply.github.com> Date: Wed, 23 Apr 2025 19:55:42 -0400 Subject: [PATCH 01/34] docs: fix query preset config link (#12156) ### What? This PR fixes a link to the Payload config in the query presets docs, and adjusts the links to the edit view components in the collections and global config pages. ### Why? To direct users to the correct location. ### How? Changes to a few docs. Fixes #12199 --- docs/configuration/collections.mdx | 14 +++++----- docs/configuration/globals.mdx | 12 ++++---- docs/query-presets/overview.mdx | 44 ++++++++++++++++-------------- 3 files changed, 36 insertions(+), 34 deletions(-) diff --git a/docs/configuration/collections.mdx b/docs/configuration/collections.mdx index 9dcebc92cc..e00043c9dc 100644 --- a/docs/configuration/collections.mdx +++ b/docs/configuration/collections.mdx @@ -193,13 +193,13 @@ export const MyCollection: CollectionConfig = { The following options are available: -| Option | Description | -| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `SaveButton` | Replace the default Save Button within the Edit View. [Drafts](../versions/drafts) must be disabled. [More details](../custom-components/edit-view#save-button). | -| `SaveDraftButton` | Replace the default Save Draft Button within the Edit View. [Drafts](../versions/drafts) must be enabled and autosave must be disabled. [More details](../custom-components/edit-view#save-draft-button). | -| `PublishButton` | Replace the default Publish Button within the Edit View. [Drafts](../versions/drafts) must be enabled. [More details](../custom-components/edit-view#publish-button). | -| `PreviewButton` | Replace the default Preview Button within the Edit View. [Preview](../admin/preview) must be enabled. [More details](../custom-components/edit-view#preview-button). | -| `Upload` | Replace the default Upload component within the Edit View. [Upload](../upload/overview) must be enabled. [More details](../custom-components/edit-view#upload). | +| Option | Description | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `SaveButton` | Replace the default Save Button within the Edit View. [Drafts](../versions/drafts) must be disabled. [More details](../custom-components/edit-view#savebutton). | +| `SaveDraftButton` | Replace the default Save Draft Button within the Edit View. [Drafts](../versions/drafts) must be enabled and autosave must be disabled. [More details](../custom-components/edit-view#savedraftbutton). | +| `PublishButton` | Replace the default Publish Button within the Edit View. [Drafts](../versions/drafts) must be enabled. [More details](../custom-components/edit-view#publishbutton). | +| `PreviewButton` | Replace the default Preview Button within the Edit View. [Preview](../admin/preview) must be enabled. [More details](../custom-components/edit-view#previewbutton). | +| `Upload` | Replace the default Upload component within the Edit View. [Upload](../upload/overview) must be enabled. [More details](../custom-components/edit-view#upload). | **Note:** For details on how to build Custom Components, see [Building Custom diff --git a/docs/configuration/globals.mdx b/docs/configuration/globals.mdx index c833bb0d2c..9339262100 100644 --- a/docs/configuration/globals.mdx +++ b/docs/configuration/globals.mdx @@ -179,12 +179,12 @@ export const MyGlobal: SanitizedGlobalConfig = { The following options are available: -| Option | Description | -| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `SaveButton` | Replace the default Save Button with a Custom Component. [Drafts](../versions/drafts) must be disabled. [More details](../custom-components/edit-view#save-button). | -| `SaveDraftButton` | Replace the default Save Draft Button with a Custom Component. [Drafts](../versions/drafts) must be enabled and autosave must be disabled. [More details](../custom-components/edit-view#save-draft-button). | -| `PublishButton` | Replace the default Publish Button with a Custom Component. [Drafts](../versions/drafts) must be enabled. [More details](../custom-components/edit-view#publish-button). | -| `PreviewButton` | Replace the default Preview Button with a Custom Component. [Preview](../admin/preview) must be enabled. [More details](../custom-components/edit-view#preview-button). | +| Option | Description | +| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `SaveButton` | Replace the default Save Button with a Custom Component. [Drafts](../versions/drafts) must be disabled. [More details](../custom-components/edit-view#savebutton). | +| `SaveDraftButton` | Replace the default Save Draft Button with a Custom Component. [Drafts](../versions/drafts) must be enabled and autosave must be disabled. [More details](../custom-components/edit-view#savedraftbutton). | +| `PublishButton` | Replace the default Publish Button with a Custom Component. [Drafts](../versions/drafts) must be enabled. [More details](../custom-components/edit-view#publishbutton). | +| `PreviewButton` | Replace the default Preview Button with a Custom Component. [Preview](../admin/preview) must be enabled. [More details](../custom-components/edit-view#previewbutton). | **Note:** For details on how to build Custom Components, see [Building Custom diff --git a/docs/query-presets/overview.mdx b/docs/query-presets/overview.mdx index abc5852279..a5ca006833 100644 --- a/docs/query-presets/overview.mdx +++ b/docs/query-presets/overview.mdx @@ -117,7 +117,7 @@ Adding custom access control rules requires: 2. A set of fields to conditionally render when that option is selected 3. A function that returns the access control rules for that option -To do this, use the `queryPresets.constraints` property in your [Payload Config](../configuration/payload-config). +To do this, use the `queryPresets.constraints` property in your [Payload Config](../configuration/overview). ```ts import { buildConfig } from 'payload' @@ -128,26 +128,28 @@ const config = buildConfig({ // ... // highlight-start constraints: { - read: { - label: 'Specific Roles', - value: 'specificRoles', - fields: [ - { - name: 'roles', - type: 'select', - hasMany: true, - options: [ - { label: 'Admin', value: 'admin' }, - { label: 'User', value: 'user' }, - ], - }, - ], - access: ({ req: { user } }) => ({ - 'access.read.roles': { - in: [user?.roles], - }, - }), - }, + read: [ + { + label: 'Specific Roles', + value: 'specificRoles', + fields: [ + { + name: 'roles', + type: 'select', + hasMany: true, + options: [ + { label: 'Admin', value: 'admin' }, + { label: 'User', value: 'user' }, + ], + }, + ], + access: ({ req: { user } }) => ({ + 'access.read.roles': { + in: [user?.roles], + }, + }), + }, + ], // highlight-end }, }, From 2157450805498c7889c1763a92c8d37b45f0c8df Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Mon, 28 Apr 2025 13:04:33 -0400 Subject: [PATCH 02/34] fix(next): pg-cloudflare build issue (#12242) Fixes next build issue related to `cloudflare:sockets`. Related Next.js discussion thread here: https://github.com/vercel/next.js/discussions/50177 Commands to recreate issue locally ```sh pnpm run script:pack --dest templates/with-postgres && \ pnpm run script:build-template-with-local-pkgs with-postgres postgresql://localhost:5432/payloadtests ``` **Build Error:** ``` Failed to compile. cloudflare:sockets Module build failed: UnhandledSchemeError: Reading from "cloudflare:sockets" is not handled by plugins (Unhandled scheme). Webpack supports "data:" and "file:" URIs by default. You may need an additional plugin to handle "cloudflare:" URIs. at /home/runner/work/payload/payload/templates/with-postgres/node_modules/.pnpm/next@15.3.0_react-dom@19.1.0_react@19.1.0__react@19.1.0_sass@1.77.4/node_modules/next/dist/compiled/webpack/bundle5.js:29:408376 at Hook.eval [as callAsync] (eval at create (/home/runner/work/payload/payload/templates/with-postgres/node_modules/.pnpm/next@15.3.0_react-dom@19.1.0_react@19.1.0__react@19.1.0_sass@1.77.4/node_modules/next/dist/compiled/webpack/bundle5.js:14:9224), :6:1) at Hook.CALL_ASYNC_DELEGATE [as _callAsync] (/home/runner/work/payload/payload/templates/with-postgres/node_modules/.pnpm/next@15.3.0_react-dom@19.1.0_react@19.1.0__react@19.1.0_sass@1.77.4/node_modules/next/dist/compiled/webpack/bundle5.js:14:6378) at Object.processResource (/home/runner/work/payload/payload/templates/with-postgres/node_modules/.pnpm/next@15.3.0_react-dom@19.1.0_react@19.1.0__react@19.1.0_sass@1.77.4/node_modules/next/dist/compiled/webpack/bundle5.js:29:408301) at processResource (/home/runner/work/payload/payload/templates/with-postgres/node_modules/.pnpm/next@15.3.0_react-dom@19.1.0_react@19.1.0__react@19.1.0_sass@1.77.4/node_modules/next/dist/compiled/loader-runner/LoaderRunner.js:1:5308) at iteratePitchingLoaders (/home/runner/work/payload/payload/templates/with-postgres/node_modules/.pnpm/next@15.3.0_react-dom@19.1.0_react@19.1.0__react@19.1.0_sass@1.77.4/node_modules/next/dist/compiled/loader-runner/LoaderRunner.js:1:4667) at runLoaders (/home/runner/work/payload/payload/templates/with-postgres/node_modules/.pnpm/next@15.3.0_react-dom@19.1.0_react@19.1.0__react@19.1.0_sass@1.77.4/node_modules/next/dist/compiled/loader-runner/LoaderRunner.js:1:8590) at NormalModule._doBuild (/home/runner/work/payload/payload/templates/with-postgres/node_modules/.pnpm/next@15.3.0_react-dom@19.1.0_react@19.1.0__react@19.1.0_sass@1.77.4/node_modules/next/dist/compiled/webpack/bundle5.js:29:408163) at NormalModule.build (/home/runner/work/payload/payload/templates/with-postgres/node_modules/.pnpm/next@15.3.0_react-dom@19.1.0_react@19.1.0__react@19.1.0_sass@1.77.4/node_modules/next/dist/compiled/webpack/bundle5.js:29:410176) at /home/runner/work/payload/payload/templates/with-postgres/node_modules/.pnpm/next@15.3.0_react-dom@19.1.0_react@19.1.0__react@19.1.0_sass@1.77.4/node_modules/next/dist/compiled/webpack/bundle5.js:29:82494 ``` --- packages/next/src/withPayload.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/next/src/withPayload.js b/packages/next/src/withPayload.js index 328ccdc952..eabc23de14 100644 --- a/packages/next/src/withPayload.js +++ b/packages/next/src/withPayload.js @@ -140,6 +140,13 @@ export const withPayload = (nextConfig = {}, options = {}) => { { module: /node_modules\/mongodb\/lib\/bson\.js/ }, { file: /node_modules\/mongodb\/lib\/bson\.js/ }, ], + plugins: [ + ...(incomingWebpackConfig?.plugins || []), + // Fix cloudflare:sockets error: https://github.com/vercel/next.js/discussions/50177 + new webpackOptions.webpack.IgnorePlugin({ + resourceRegExp: /^pg-native$|^cloudflare:sockets$/, + }), + ], resolve: { ...(incomingWebpackConfig?.resolve || {}), alias: { From ab03f4f3051372db556fb838a44520652be0612b Mon Sep 17 00:00:00 2001 From: Tylan Davis <89618855+tylandavis@users.noreply.github.com> Date: Mon, 28 Apr 2025 13:18:09 -0400 Subject: [PATCH 03/34] fix(examples): incorrect documentation links for Live Preview example (#12233) Fixes a couple links in the Live Preview example that were pointing to `/docs/live-preview` instead of `/docs/live-preview/overview`. --- examples/live-preview/README.md | 2 +- examples/live-preview/src/migrations/seed.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/live-preview/README.md b/examples/live-preview/README.md index 800e4de0d2..42aad30b7c 100644 --- a/examples/live-preview/README.md +++ b/examples/live-preview/README.md @@ -58,7 +58,7 @@ See the [Collections](https://payloadcms.com/docs/configuration/collections) doc } ``` - For more details on how to extend this functionality, see the [Live Preview](https://payloadcms.com/docs/live-preview) docs. + For more details on how to extend this functionality, see the [Live Preview](https://payloadcms.com/docs/live-preview/overview) docs. ## Front-end diff --git a/examples/live-preview/src/migrations/seed.ts b/examples/live-preview/src/migrations/seed.ts index 91b0a5323f..98eb2164ce 100644 --- a/examples/live-preview/src/migrations/seed.ts +++ b/examples/live-preview/src/migrations/seed.ts @@ -36,7 +36,7 @@ export const home: Partial = { type: 'link', children: [{ text: 'Live Preview' }], newTab: true, - url: 'https://payloadcms.com/docs/live-preview', + url: 'https://payloadcms.com/docs/live-preview/overview', }, { text: ' you can edit this page in the admin panel and see the changes reflected here in real time.', From c85fb808b96deccb0c83fcecf4a966c76cc5644b Mon Sep 17 00:00:00 2001 From: Adrian Maj Date: Mon, 28 Apr 2025 20:49:43 +0200 Subject: [PATCH 04/34] fix: user validation error inside the forgotPassword operation in the cases where user had localised fields (#12034) ### What? So, while resetting the password using the Local API, I encountered a validation error for localized fields. I jumped into the Payload repository, and saw that `payload.update` is being used in the process, with no locale specified/supported. This causes errors if the user has localized fields, but specifying a locale for the password reset operation would be silly, so I suggest turning this into a db operation, just like the user fetching operation before. ### How? I replaced this: ```TS user = await payload.update({ id: user.id, collection: collectionConfig.slug, data: user, req, }) ``` With this: ```TS user = await payload.db.updateOne({ id: user.id, collection: collectionConfig.slug, data: user, req, }) ``` So the validation of other fields would be skipped in this operation. ### Why? This is the error I encountered while trying to reset password, it blocks my project to go further :) ```bash Error [ValidationError]: The following field is invalid: Data > Name at async sendOfferEmail (src/collections/Offers/components/SendEmailButton/index.tsx:18:20) 16 | try { 17 | const payload = await getPayload({ config }); > 18 | const token = await payload.forgotPassword({ | ^ 19 | collection: "offers", 20 | data: { { data: [Object], isOperational: true, isPublic: false, status: 400, [cause]: [Object] } cause: { id: '67f4c1df8aa60189df9bdf5c', collection: 'offers', errors: [ { label: 'Data > Name', message: 'This field is required.', path: 'name' } ], global: undefined } ``` P.S The name field is totally fine, it is required and filled with values in both locales I use, in admin panel I can edit and save everything without any issues. --- .../src/auth/operations/forgotPassword.ts | 8 +- payload-types.ts | 246 ++++++++++++++++++ test/auth/forgot-password-localized/config.ts | 51 ++++ .../forgot-password-localized/int.spec.ts | 78 ++++++ .../payload-types.ts | 246 ++++++++++++++++++ 5 files changed, 626 insertions(+), 3 deletions(-) create mode 100644 payload-types.ts create mode 100644 test/auth/forgot-password-localized/config.ts create mode 100644 test/auth/forgot-password-localized/int.spec.ts create mode 100644 test/auth/forgot-password-localized/payload-types.ts diff --git a/packages/payload/src/auth/operations/forgotPassword.ts b/packages/payload/src/auth/operations/forgotPassword.ts index a51f947b32..dad7df932d 100644 --- a/packages/payload/src/auth/operations/forgotPassword.ts +++ b/packages/payload/src/auth/operations/forgotPassword.ts @@ -138,15 +138,17 @@ export const forgotPasswordOperation = async ( return null } - user.resetPasswordToken = token - user.resetPasswordExpiration = new Date( + const resetPasswordExpiration = new Date( Date.now() + (collectionConfig.auth?.forgotPassword?.expiration ?? expiration ?? 3600000), ).toISOString() user = await payload.update({ id: user.id, collection: collectionConfig.slug, - data: user, + data: { + resetPasswordExpiration, + resetPasswordToken: token, + }, req, }) diff --git a/payload-types.ts b/payload-types.ts new file mode 100644 index 0000000000..1b22b04b70 --- /dev/null +++ b/payload-types.ts @@ -0,0 +1,246 @@ +/* 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. + */ + +/** + * Supported timezones in IANA format. + * + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "supportedTimezones". + */ +export type SupportedTimezones = + | 'Pacific/Midway' + | 'Pacific/Niue' + | 'Pacific/Honolulu' + | 'Pacific/Rarotonga' + | 'America/Anchorage' + | 'Pacific/Gambier' + | 'America/Los_Angeles' + | 'America/Tijuana' + | 'America/Denver' + | 'America/Phoenix' + | 'America/Chicago' + | 'America/Guatemala' + | 'America/New_York' + | 'America/Bogota' + | 'America/Caracas' + | 'America/Santiago' + | 'America/Buenos_Aires' + | 'America/Sao_Paulo' + | 'Atlantic/South_Georgia' + | 'Atlantic/Azores' + | 'Atlantic/Cape_Verde' + | 'Europe/London' + | 'Europe/Berlin' + | 'Africa/Lagos' + | 'Europe/Athens' + | 'Africa/Cairo' + | 'Europe/Moscow' + | 'Asia/Riyadh' + | 'Asia/Dubai' + | 'Asia/Baku' + | 'Asia/Karachi' + | 'Asia/Tashkent' + | 'Asia/Calcutta' + | 'Asia/Dhaka' + | 'Asia/Almaty' + | 'Asia/Jakarta' + | 'Asia/Bangkok' + | 'Asia/Shanghai' + | 'Asia/Singapore' + | 'Asia/Tokyo' + | 'Asia/Seoul' + | 'Australia/Brisbane' + | 'Australia/Sydney' + | 'Pacific/Guam' + | 'Pacific/Noumea' + | 'Pacific/Auckland' + | 'Pacific/Fiji'; + +export interface Config { + auth: { + users: UserAuthOperations; + }; + blocks: {}; + 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: 'en' | 'pl'; + 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; + localizedField: string; + roles: ('admin' | 'editor' | 'moderator' | 'user' | 'viewer')[]; + 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 { + localizedField?: T; + roles?: T; + 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/forgot-password-localized/config.ts b/test/auth/forgot-password-localized/config.ts new file mode 100644 index 0000000000..7a6cdec9bf --- /dev/null +++ b/test/auth/forgot-password-localized/config.ts @@ -0,0 +1,51 @@ +import { fileURLToPath } from 'node:url' +import path from 'path' + +import { buildConfigWithDefaults } from '../../buildConfigWithDefaults.js' + +export const collectionSlug = 'users' + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) + +export default buildConfigWithDefaults({ + admin: { + user: collectionSlug, + importMap: { + baseDir: path.resolve(dirname), + }, + }, + localization: { + locales: ['en', 'pl'], + defaultLocale: 'en', + }, + collections: [ + { + slug: collectionSlug, + auth: { + forgotPassword: { + // Default options + }, + }, + fields: [ + { + name: 'localizedField', + type: 'text', + localized: true, // This field is localized and will require locale during validation + required: true, + }, + { + name: 'roles', + type: 'select', + defaultValue: ['user'], + hasMany: true, + label: 'Role', + options: ['admin', 'editor', 'moderator', 'user', 'viewer'], + required: true, + saveToJWT: true, + }, + ], + }, + ], + debug: true, +}) diff --git a/test/auth/forgot-password-localized/int.spec.ts b/test/auth/forgot-password-localized/int.spec.ts new file mode 100644 index 0000000000..0e5eaa308e --- /dev/null +++ b/test/auth/forgot-password-localized/int.spec.ts @@ -0,0 +1,78 @@ +import type { Payload } from 'payload' + +import path from 'path' +import { fileURLToPath } from 'url' + +import type { NextRESTClient } from '../../helpers/NextRESTClient.js' + +import { devUser } from '../../credentials.js' +import { initPayloadInt } from '../../helpers/initPayloadInt.js' +import { collectionSlug } from './config.js' + +let restClient: NextRESTClient | undefined +let payload: Payload | undefined + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) + +describe('Forgot password operation with localized fields', () => { + beforeAll(async () => { + ;({ payload, restClient } = await initPayloadInt(dirname, 'auth/forgot-password-localized')) + + // Register a user with additional localized field + const res = await restClient?.POST(`/${collectionSlug}/first-register?locale=en`, { + body: JSON.stringify({ + ...devUser, + 'confirm-password': devUser.password, + localizedField: 'English content', + }), + }) + + if (!res) { + throw new Error('Failed to register user') + } + + const { user } = await res.json() + + // @ts-expect-error - Localized field is not in the general Payload type, but it is in mocked collection in this case. + await payload?.update({ + collection: collectionSlug, + id: user.id as string, + locale: 'pl', + data: { + localizedField: 'Polish content', + }, + }) + }) + + afterAll(async () => { + if (typeof payload?.db.destroy === 'function') { + await payload?.db.destroy() + } + }) + + it('should successfully process forgotPassword operation with localized fields', async () => { + // Attempt to trigger forgotPassword operation + const token = await payload?.forgotPassword({ + collection: collectionSlug, + data: { email: devUser.email }, + disableEmail: true, + }) + + // Verify token was generated successfully + expect(token).toBeDefined() + expect(typeof token).toBe('string') + expect(token?.length).toBeGreaterThan(0) + }) + + it('should not throw validation errors for localized fields', async () => { + // We expect this not to throw an error + await expect( + payload?.forgotPassword({ + collection: collectionSlug, + data: { email: devUser.email }, + disableEmail: true, + }), + ).resolves.not.toThrow() + }) +}) diff --git a/test/auth/forgot-password-localized/payload-types.ts b/test/auth/forgot-password-localized/payload-types.ts new file mode 100644 index 0000000000..1b22b04b70 --- /dev/null +++ b/test/auth/forgot-password-localized/payload-types.ts @@ -0,0 +1,246 @@ +/* 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. + */ + +/** + * Supported timezones in IANA format. + * + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "supportedTimezones". + */ +export type SupportedTimezones = + | 'Pacific/Midway' + | 'Pacific/Niue' + | 'Pacific/Honolulu' + | 'Pacific/Rarotonga' + | 'America/Anchorage' + | 'Pacific/Gambier' + | 'America/Los_Angeles' + | 'America/Tijuana' + | 'America/Denver' + | 'America/Phoenix' + | 'America/Chicago' + | 'America/Guatemala' + | 'America/New_York' + | 'America/Bogota' + | 'America/Caracas' + | 'America/Santiago' + | 'America/Buenos_Aires' + | 'America/Sao_Paulo' + | 'Atlantic/South_Georgia' + | 'Atlantic/Azores' + | 'Atlantic/Cape_Verde' + | 'Europe/London' + | 'Europe/Berlin' + | 'Africa/Lagos' + | 'Europe/Athens' + | 'Africa/Cairo' + | 'Europe/Moscow' + | 'Asia/Riyadh' + | 'Asia/Dubai' + | 'Asia/Baku' + | 'Asia/Karachi' + | 'Asia/Tashkent' + | 'Asia/Calcutta' + | 'Asia/Dhaka' + | 'Asia/Almaty' + | 'Asia/Jakarta' + | 'Asia/Bangkok' + | 'Asia/Shanghai' + | 'Asia/Singapore' + | 'Asia/Tokyo' + | 'Asia/Seoul' + | 'Australia/Brisbane' + | 'Australia/Sydney' + | 'Pacific/Guam' + | 'Pacific/Noumea' + | 'Pacific/Auckland' + | 'Pacific/Fiji'; + +export interface Config { + auth: { + users: UserAuthOperations; + }; + blocks: {}; + 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: 'en' | 'pl'; + 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; + localizedField: string; + roles: ('admin' | 'editor' | 'moderator' | 'user' | 'viewer')[]; + 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 { + localizedField?: T; + roles?: T; + 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 From 5bd852c9b59e053629d49f5cbae9fdc38987e5e6 Mon Sep 17 00:00:00 2001 From: Sam Wheeler <104921112+swheeler7@users.noreply.github.com> Date: Mon, 28 Apr 2025 13:38:50 -0700 Subject: [PATCH 05/34] fix(ui): relationship using list drawer correctly updates when hasMany is true (#12176) ### What? This fixes an issue raised by @maximseshuk in this PR #11553. Here is the text of the original comment: If the field has the property hasMany: true and you select one item, it shows up in the select field, but any additional selected items won't be visible in the select field, even though the data is actually there and can be saved. After refreshing the page, they appear. In addition I added a fix to an issue where the filterOptions weren't being passed in to the useListDrawer hook properly in polymorphic relationships ### How? Instead of using the push method to update the value state, a new array is created and directly set using useState. I think the issue was because using push mutates the original array. --- packages/ui/src/fields/Relationship/index.tsx | 45 ++++++++++++------ .../collections/Relationship/e2e.spec.ts | 46 ++++++++++++++++++- test/fields/collections/Relationship/index.ts | 2 +- 3 files changed, 78 insertions(+), 15 deletions(-) diff --git a/packages/ui/src/fields/Relationship/index.tsx b/packages/ui/src/fields/Relationship/index.tsx index 449c1e1374..a572b6d393 100644 --- a/packages/ui/src/fields/Relationship/index.tsx +++ b/packages/ui/src/fields/Relationship/index.tsx @@ -136,18 +136,38 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) => let newFilterOptions = filterOptions if (value) { - ;(Array.isArray(value) ? value : [value]).forEach((val) => { - ;(Array.isArray(relationTo) ? relationTo : [relationTo]).forEach((relationTo) => { - newFilterOptions = { - ...(filterOptions || {}), - [relationTo]: { - ...(typeof filterOptions?.[relationTo] === 'object' ? filterOptions[relationTo] : {}), - id: { - not_in: [typeof val === 'object' ? val.value : val], - }, - }, + const valuesByRelation = (Array.isArray(value) ? value : [value]).reduce((acc, val) => { + if (typeof val === 'object' && val.relationTo) { + if (!acc[val.relationTo]) { + acc[val.relationTo] = [] } - }) + acc[val.relationTo].push(val.value) + } else if (val) { + const relation = Array.isArray(relationTo) ? undefined : relationTo + if (relation) { + if (!acc[relation]) { + acc[relation] = [] + } + acc[relation].push(val) + } + } + return acc + }, {}) + + ;(Array.isArray(relationTo) ? relationTo : [relationTo]).forEach((relation) => { + newFilterOptions = { + ...(newFilterOptions || {}), + [relation]: { + ...(typeof filterOptions?.[relation] === 'object' ? filterOptions[relation] : {}), + ...(valuesByRelation[relation] + ? { + id: { + not_in: valuesByRelation[relation], + }, + } + : {}), + }, + } }) } @@ -174,8 +194,7 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) => if (hasMany) { const withSelection = Array.isArray(value) ? value : [] - withSelection.push(formattedSelection) - setValue(withSelection) + setValue([...withSelection, formattedSelection]) } else { setValue(formattedSelection) } diff --git a/test/fields/collections/Relationship/e2e.spec.ts b/test/fields/collections/Relationship/e2e.spec.ts index 5fd1c5a110..492a40b686 100644 --- a/test/fields/collections/Relationship/e2e.spec.ts +++ b/test/fields/collections/Relationship/e2e.spec.ts @@ -742,7 +742,15 @@ describe('relationship', () => { await expect(listDrawerContent).toBeHidden() const selectedValue = relationshipField.locator('.relationship--multi-value-label__text') - await expect(selectedValue).toBeVisible() + await expect(selectedValue).toHaveCount(1) + + await relationshipField.click() + await expect(listDrawerContent).toBeVisible() + await button.click() + await expect(listDrawerContent).toBeHidden() + + const selectedValues = relationshipField.locator('.relationship--multi-value-label__text') + await expect(selectedValues).toHaveCount(2) }) test('should handle `hasMany` polymorphic relationship when `appearance: "drawer"`', async () => { @@ -807,6 +815,42 @@ describe('relationship', () => { await expect(newRows).toHaveCount(1) await expect(listDrawerContent.getByText('Seeded text document')).toHaveCount(0) }) + + test('should filter out existing values from polymorphic relationship list drawer', async () => { + await page.goto(url.create) + const relationshipField = page.locator('#field-polymorphicRelationshipDrawer') + await relationshipField.click() + const listDrawerContent = page.locator('.list-drawer').locator('.drawer__content') + await expect(listDrawerContent).toBeVisible() + + const relationToSelector = page.locator('.list-header__select-collection') + await expect(relationToSelector).toBeVisible() + + await relationToSelector.locator('.rs__control').click() + const option = relationToSelector.locator('.rs__option').nth(1) + await option.click() + const rows = listDrawerContent.locator('table tbody tr') + await expect(rows).toHaveCount(2) + const firstRow = rows.first() + const button = firstRow.locator('button') + await button.click() + await expect(listDrawerContent).toBeHidden() + + const selectedValue = relationshipField.locator('.relationship--single-value__text') + await expect(selectedValue).toBeVisible() + + await relationshipField.click() + await expect(listDrawerContent).toBeVisible() + await expect(relationToSelector).toBeVisible() + await relationToSelector.locator('.rs__control').click() + await option.click() + const newRows = listDrawerContent.locator('table tbody tr') + await expect(newRows).toHaveCount(1) + const newFirstRow = newRows.first() + const newButton = newFirstRow.locator('button') + await newButton.click() + await expect(listDrawerContent).toBeHidden() + }) }) async function createTextFieldDoc(overrides?: Partial): Promise { diff --git a/test/fields/collections/Relationship/index.ts b/test/fields/collections/Relationship/index.ts index 1a8f6ff924..ec09f8cbcd 100644 --- a/test/fields/collections/Relationship/index.ts +++ b/test/fields/collections/Relationship/index.ts @@ -158,7 +158,7 @@ const RelationshipFields: CollectionConfig = { }, { name: 'relationshipDrawerHasManyPolymorphic', - relationTo: ['text-fields'], + relationTo: ['text-fields', 'array-fields'], admin: { appearance: 'drawer', }, From 6b83086c6cf02590452d032be63b466e5dc1ab41 Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Mon, 28 Apr 2025 19:25:14 -0700 Subject: [PATCH 06/34] perf(graphql): skip count query for join field using simple pagination (#12223) GraphQL requests with join fields result in a lot of extra count rows queries that aren't necessary. This turns off pagination and uses limit+1 and slice instead. --- packages/graphql/src/schema/fieldToSchemaMap.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/graphql/src/schema/fieldToSchemaMap.ts b/packages/graphql/src/schema/fieldToSchemaMap.ts index fefd598393..70ffc75abd 100644 --- a/packages/graphql/src/schema/fieldToSchemaMap.ts +++ b/packages/graphql/src/schema/fieldToSchemaMap.ts @@ -393,19 +393,32 @@ export const fieldToSchemaMap: FieldToSchemaMap = { throw new Error('GraphQL with array of join.field.collection is not implemented') } - return await req.payload.find({ + const { docs } = await req.payload.find({ collection, depth: 0, draft, fallbackLocale: req.fallbackLocale, - limit, + // Fetch one extra document to determine if there are more documents beyond the requested limit (used for hasNextPage calculation). + limit: typeof limit === 'number' && limit > 0 ? limit + 1 : 0, locale: req.locale, overrideAccess: false, page, + pagination: false, req, sort, where: fullWhere, }) + + let shouldSlice = false + + if (typeof limit === 'number' && limit !== 0 && limit < docs.length) { + shouldSlice = true + } + + return { + docs: shouldSlice ? docs.slice(0, -1) : docs, + hasNextPage: limit === 0 ? false : limit < docs.length, + } }, } From 2f21d46de64cde83224b31ebc109cadc9212e2d2 Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Mon, 28 Apr 2025 19:25:53 -0700 Subject: [PATCH 07/34] perf(plugin-nested-docs): remove extra find call (#12224) Reduce query by combining find and update into one local api call. --- .../src/hooks/resaveChildren.ts | 1 - .../src/hooks/resaveSelfAfterCreate.ts | 58 ++++++++----------- 2 files changed, 25 insertions(+), 34 deletions(-) diff --git a/packages/plugin-nested-docs/src/hooks/resaveChildren.ts b/packages/plugin-nested-docs/src/hooks/resaveChildren.ts index dca94c7285..becaf3ad4a 100644 --- a/packages/plugin-nested-docs/src/hooks/resaveChildren.ts +++ b/packages/plugin-nested-docs/src/hooks/resaveChildren.ts @@ -22,7 +22,6 @@ type ResaveArgs = { const resave = async ({ collection, doc, draft, pluginConfig, req }: ResaveArgs) => { const parentSlug = pluginConfig?.parentFieldSlug || 'parent' - const breadcrumbSlug = pluginConfig.breadcrumbsFieldSlug || 'breadcrumbs' if (draft) { // If the parent is a draft, don't resave children diff --git a/packages/plugin-nested-docs/src/hooks/resaveSelfAfterCreate.ts b/packages/plugin-nested-docs/src/hooks/resaveSelfAfterCreate.ts index 14368b248d..facc5464cf 100644 --- a/packages/plugin-nested-docs/src/hooks/resaveSelfAfterCreate.ts +++ b/packages/plugin-nested-docs/src/hooks/resaveSelfAfterCreate.ts @@ -8,47 +8,39 @@ import type { Breadcrumb, NestedDocsPluginConfig } from '../types.js' export const resaveSelfAfterCreate = (pluginConfig: NestedDocsPluginConfig, collection: CollectionConfig): CollectionAfterChangeHook => async ({ doc, operation, req }) => { + if (operation !== 'create') { + return undefined + } + const { locale, payload } = req const breadcrumbSlug = pluginConfig.breadcrumbsFieldSlug || 'breadcrumbs' const breadcrumbs = doc[breadcrumbSlug] as unknown as Breadcrumb[] - if (operation === 'create') { - const originalDocWithDepth0 = await payload.findByID({ + const updateAsDraft = + typeof collection.versions === 'object' && + collection.versions.drafts && + doc._status !== 'published' + + try { + await payload.update({ id: doc.id, collection: collection.slug, + data: { + [breadcrumbSlug]: + breadcrumbs?.map((crumb, i) => ({ + ...crumb, + doc: breadcrumbs.length === i + 1 ? doc.id : crumb.doc, + })) || [], + }, depth: 0, + draft: updateAsDraft, + locale, req, }) - - const updateAsDraft = - typeof collection.versions === 'object' && - collection.versions.drafts && - doc._status !== 'published' - - try { - await payload.update({ - id: doc.id, - collection: collection.slug, - data: { - ...originalDocWithDepth0, - [breadcrumbSlug]: - breadcrumbs?.map((crumb, i) => ({ - ...crumb, - doc: breadcrumbs.length === i + 1 ? doc.id : crumb.doc, - })) || [], - }, - depth: 0, - draft: updateAsDraft, - locale, - req, - }) - } catch (err: unknown) { - payload.logger.error( - `Nested Docs plugin has had an error while adding breadcrumbs during document creation.`, - ) - payload.logger.error(err) - } + } catch (err: unknown) { + payload.logger.error( + `Nested Docs plugin has had an error while adding breadcrumbs during document creation.`, + ) + payload.logger.error(err) } - - return undefined } From caae5986f5d6eda2a052204931cf65f6b768ecb6 Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Mon, 28 Apr 2025 19:32:26 -0700 Subject: [PATCH 08/34] perf(plugin-search): reduce query depth in hooks (#12225) Perf improvements and reliability of document reindexing and synchronization of plugin-search functions. ## What Reindex Handler (generateReindexHandler.ts): - Replaced `Promise.all` with sequential `await` to prevent transaction issues. - Added `depth: 0` to payload.find for lighter queries. Sync Operations (syncDocAsSearchIndex.ts): - Standardized depth: 0 across create, delete, update, and find API calls. - Streamlined conditionals for create operations. ## Why Improved performance with reduced query overhead. Enhanced transaction safety by avoiding parallel database operations. --- .../src/utilities/generateReindexHandler.ts | 12 +++----- .../src/utilities/syncDocAsSearchIndex.ts | 28 +++++++++++-------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/packages/plugin-search/src/utilities/generateReindexHandler.ts b/packages/plugin-search/src/utilities/generateReindexHandler.ts index b14184228c..929d04e843 100644 --- a/packages/plugin-search/src/utilities/generateReindexHandler.ts +++ b/packages/plugin-search/src/utilities/generateReindexHandler.ts @@ -124,14 +124,15 @@ export const generateReindexHandler = for (let i = 0; i < totalBatches; i++) { const { docs } = await payload.find({ collection, + depth: 0, limit: batchSize, locale: localeToSync, page: i + 1, ...defaultLocalApiProps, }) - const promises = docs.map((doc) => - syncDocAsSearchIndex({ + for (const doc of docs) { + await syncDocAsSearchIndex({ collection, doc, locale: localeToSync, @@ -139,12 +140,7 @@ export const generateReindexHandler = operation, pluginConfig, req, - }), - ) - - // Sequentially await promises to avoid transaction issues - for (const promise of promises) { - await promise + }) } } } diff --git a/packages/plugin-search/src/utilities/syncDocAsSearchIndex.ts b/packages/plugin-search/src/utilities/syncDocAsSearchIndex.ts index 2f2159815f..433f044683 100644 --- a/packages/plugin-search/src/utilities/syncDocAsSearchIndex.ts +++ b/packages/plugin-search/src/utilities/syncDocAsSearchIndex.ts @@ -64,18 +64,17 @@ export const syncDocAsSearchIndex = async ({ const doSync = syncDrafts || (!syncDrafts && status !== 'draft') try { - if (operation === 'create') { - if (doSync) { - await payload.create({ - collection: searchSlug, - data: { - ...dataToSave, - priority: defaultPriority, - }, - locale: syncLocale, - req, - }) - } + if (operation === 'create' && doSync) { + await payload.create({ + collection: searchSlug, + data: { + ...dataToSave, + priority: defaultPriority, + }, + depth: 0, + locale: syncLocale, + req, + }) } if (operation === 'update') { @@ -110,6 +109,7 @@ export const syncDocAsSearchIndex = async ({ const duplicativeDocIDs = duplicativeDocs.map(({ id }) => id) await payload.delete({ collection: searchSlug, + depth: 0, req, where: { id: { in: duplicativeDocIDs } }, }) @@ -134,6 +134,7 @@ export const syncDocAsSearchIndex = async ({ ...dataToSave, priority: foundDoc.priority || defaultPriority, }, + depth: 0, locale: syncLocale, req, }) @@ -148,6 +149,7 @@ export const syncDocAsSearchIndex = async ({ docs: [docWithPublish], } = await payload.find({ collection, + depth: 0, draft: false, limit: 1, locale: syncLocale, @@ -175,6 +177,7 @@ export const syncDocAsSearchIndex = async ({ await payload.delete({ id: searchDocID, collection: searchSlug, + depth: 0, req, }) } catch (err: unknown) { @@ -190,6 +193,7 @@ export const syncDocAsSearchIndex = async ({ ...dataToSave, priority: defaultPriority, }, + depth: 0, locale: syncLocale, req, }) From 34ead72c8527ed2d8bb53d126ad29e0dede8bccf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Nyborg?= Date: Tue, 29 Apr 2025 10:23:40 +0200 Subject: [PATCH 09/34] fix(ui): copyToLocale should not pass any id's to avoid duplicates (#11887) ### What? Using the `Copy To Locale` function causes validation errors on content with `id` fields in postgres, since these should be unique. ``` key not found: error:valueMustBeUnique key not found: error:followingFieldsInvalid [13:11:29] ERROR: There was an error copying data from "en" to "de" err: { "type": "ValidationError", "message": "error:followingFieldsInvalid id", "stack": ValidationError: error:followingFieldsInvalid id ``` ### Why? In `packages/ui/src/utilities/copyDataFromLocale.ts` we are passing all data from `fromLocaleData` including the `id` fields, which causes duplicates on fields with unique id's like `Blocks` and `Arrays`. ### How? To resolve this i implemented a function that recursively remove any `id` field on the passed data. ### Fixes - https://github.com/payloadcms/payload/issues/10684 - https://discord.com/channels/967097582721572934/1351497930984521800 --------- Co-authored-by: Jessica Chowdhury --- .../ui/src/utilities/copyDataFromLocale.ts | 22 ++++++++++++---- test/localization/e2e.spec.ts | 25 +++++++++++++++++++ 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/utilities/copyDataFromLocale.ts b/packages/ui/src/utilities/copyDataFromLocale.ts index c934b7f0c1..5bf9711c38 100644 --- a/packages/ui/src/utilities/copyDataFromLocale.ts +++ b/packages/ui/src/utilities/copyDataFromLocale.ts @@ -183,6 +183,17 @@ function mergeData( return toLocaleData } +function removeIds(data: Data): Data { + if (Array.isArray(data)) { + return data.map(removeIds) + } + if (typeof data === 'object' && data !== null) { + const { id: _id, ...rest } = data + return Object.fromEntries(Object.entries(rest).map(([key, value]) => [key, removeIds(value)])) + } + return data +} + export const copyDataFromLocaleHandler = async (args: CopyDataFromLocaleArgs) => { const { req } = args @@ -288,7 +299,8 @@ export const copyDataFromLocale = async (args: CopyDataFromLocaleArgs) => { throw new Error(`Error fetching data from locale "${toLocale}"`) } - const { id, ...fromLocaleDataWithoutID } = fromLocaleData.value + const fromLocaleDataWithoutID = removeIds(fromLocaleData.value) + const toLocaleDataWithoutID = removeIds(toLocaleData.value) return globalSlug ? await payload.updateGlobal({ @@ -296,8 +308,8 @@ export const copyDataFromLocale = async (args: CopyDataFromLocaleArgs) => { data: overrideData ? fromLocaleDataWithoutID : mergeData( - fromLocaleData.value, - toLocaleData.value, + fromLocaleDataWithoutID, + toLocaleDataWithoutID, globals[globalSlug].config.fields, req, false, @@ -313,8 +325,8 @@ export const copyDataFromLocale = async (args: CopyDataFromLocaleArgs) => { data: overrideData ? fromLocaleDataWithoutID : mergeData( - fromLocaleData.value, - toLocaleData.value, + fromLocaleDataWithoutID, + toLocaleDataWithoutID, collections[collectionSlug].config.fields, req, false, diff --git a/test/localization/e2e.spec.ts b/test/localization/e2e.spec.ts index 1916677181..b1f1884301 100644 --- a/test/localization/e2e.spec.ts +++ b/test/localization/e2e.spec.ts @@ -26,6 +26,7 @@ import { import { AdminUrlUtil } from '../helpers/adminUrlUtil.js' import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js' import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.js' +import { blocksCollectionSlug } from './collections/Blocks/index.js' import { nestedToArrayAndBlockCollectionSlug } from './collections/NestedToArrayAndBlock/index.js' import { richTextSlug } from './collections/RichText/index.js' import { @@ -427,6 +428,30 @@ describe('Localization', () => { await expect(arrayField).toHaveValue(sampleText) }) + test('should copy block to locale', async () => { + const sampleText = 'Copy this text' + const blocksCollection = new AdminUrlUtil(serverURL, blocksCollectionSlug) + await page.goto(blocksCollection.create) + await changeLocale(page, 'pt') + const addBlock = page.locator('.blocks-field__drawer-toggler') + await addBlock.click() + const selectBlock = page.locator('.blocks-drawer__block button') + await selectBlock.click() + const addContentButton = page.locator('#field-content__0__content button') + await addContentButton.click() + await selectBlock.click() + const textField = page.locator('#field-content__0__content__0__text') + await expect(textField).toBeVisible() + await textField.fill(sampleText) + await saveDocAndAssert(page) + + await openCopyToLocaleDrawer(page) + await setToLocale(page, 'English') + await runCopy(page) + + await expect(textField).toHaveValue(sampleText) + }) + test('should default source locale to current locale', async () => { await changeLocale(page, spanishLocale) await createAndSaveDoc(page, url, { title }) From b7ae4ee60a822b977b9b13856abd1e53b4d50eb9 Mon Sep 17 00:00:00 2001 From: Jessica Rynkar <67977755+jessrynkar@users.noreply.github.com> Date: Tue, 29 Apr 2025 13:23:49 +0100 Subject: [PATCH 10/34] docs: adds warning about handling different environments with migrations (#12249) ### What? Migrating configs that include environment specific options can cause issues and confusion for users. ### How? Adds new section to `database/migrations` docs to highlight potential issues with environment-specific settings when generating and running migrations and includes some recommendations for addressing these issues. Closes #12241 --- docs/database/migrations.mdx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/database/migrations.mdx b/docs/database/migrations.mdx index 2281412015..a0d96d72e5 100644 --- a/docs/database/migrations.mdx +++ b/docs/database/migrations.mdx @@ -298,3 +298,15 @@ Passing your migrations as shown above will tell Payload, in production only, to may slow down serverless cold starts on platforms such as Vercel. Generally, this option should only be used for long-running servers / containers. + +## Environment-Specific Configurations and Migrations + +Your configuration may include environment-specific settings (e.g., enabling a plugin only in production). If you generate migrations without considering the environment, it can lead to discrepancies and issues. When running migrations locally, Payload uses the development environment, which might miss production-specific configurations. Similarly, running migrations in production could miss development-specific entities. + +This is an easy oversight, so be mindful of any environment-specific logic in your config when handling migrations. + +**Ways to address this:** + +- Manually update your migration file after it is generated to include any environment-specific configurations. +- Temporarily enable any required production environment variables in your local setup when generating the migration to capture the necessary updates. +- Use separate migration files for each environment to ensure the correct migration is executed in the corresponding environment. From 9948040ad22627a98206c58c116694931b8522ce Mon Sep 17 00:00:00 2001 From: Tobias Odendahl Date: Tue, 29 Apr 2025 17:50:00 +0200 Subject: [PATCH 11/34] perf(ui): only select necessary data for relationship options (#12251) ### What? Improve performance of the relationship select options by reducing the fetched documents to only necessary data. ### Why? The relationship select only requires an ID and title. Fetching the whole document instead leads to slow performance on collections with large documents. ### How? Add a select parameter to the query, the same way it is done in the [WhereBuilder](https://github.com/payloadcms/payload/blob/main/packages/ui/src/elements/WhereBuilder/Condition/Relationship/index.tsx#L105-L107) already. --- packages/ui/src/fields/Relationship/index.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/ui/src/fields/Relationship/index.tsx b/packages/ui/src/fields/Relationship/index.tsx index a572b6d393..a1fd1a80ed 100644 --- a/packages/ui/src/fields/Relationship/index.tsx +++ b/packages/ui/src/fields/Relationship/index.tsx @@ -271,6 +271,9 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) => limit: maxResultsPerRequest, locale, page: lastLoadedPageToUse, + select: { + [fieldToSearch]: true, + }, sort: fieldToSort, where: { and: [ From 5492542c1ae14a88e6e744e843296ce4b6ee0050 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Jablo=C3=B1ski?= <43938777+GermanJablo@users.noreply.github.com> Date: Tue, 29 Apr 2025 12:57:46 -0300 Subject: [PATCH 12/34] fix(richtext-lexical): prevent extra paragraph when inserting blocks or uploadNodes. Add preemptive selection normalization (#12077) Fixes #11628 PR #6389 caused bug #11628, which is a regression, as it had already been fixed in #4441 It is likely that some things have changed because [Lexical had recently made improvements](https://github.com/facebook/lexical/pull/7046) to address selection normalization. Although it wasn't necessary to resolve the issue, I added a `NormalizeSelectionPlugin` to the editor, which makes selection handling in the editor more robust. I'm also adding a new collection to the Lexical test suite, intending it to be used by default for most tests going forward. I've left an explanatory comment on the dashboard. ___ Looking at #11628's video, it seems users also want to be able to prevent the first paragraph from being empty. This makes sense to me, so I think in another PR we could add a button at the top, just [like we did at the bottom of the editor](https://github.com/payloadcms/payload/pull/10530). --- .../features/blocks/client/plugin/index.tsx | 19 ++---- .../relationship/client/plugins/index.tsx | 18 ++--- .../features/upload/client/plugin/index.tsx | 14 ++-- .../src/lexical/LexicalEditor.tsx | 10 +-- .../plugins/NormalizeSelection/index.tsx | 35 ++++++++++ test/lexical/baseConfig.ts | 10 +++ .../Lexical/e2e/blocks/e2e.spec.ts | 10 +-- .../collections/Lexical/e2e/main/e2e.spec.ts | 14 ++-- .../_LexicalFullyFeatured/e2e.spec.ts | 68 +++++++++++++++++++ .../_LexicalFullyFeatured/index.ts | 57 ++++++++++++++++ .../_LexicalFullyFeatured/utils.ts | 49 +++++++++++++ .../components/CollectionsExplained.tsx | 22 ++++++ test/lexical/payload-types.ts | 39 +++++++++++ test/lexical/slugs.ts | 1 + 14 files changed, 313 insertions(+), 53 deletions(-) create mode 100644 packages/richtext-lexical/src/lexical/plugins/NormalizeSelection/index.tsx create mode 100644 test/lexical/collections/_LexicalFullyFeatured/e2e.spec.ts create mode 100644 test/lexical/collections/_LexicalFullyFeatured/index.ts create mode 100644 test/lexical/collections/_LexicalFullyFeatured/utils.ts create mode 100644 test/lexical/components/CollectionsExplained.tsx diff --git a/packages/richtext-lexical/src/features/blocks/client/plugin/index.tsx b/packages/richtext-lexical/src/features/blocks/client/plugin/index.tsx index ed8e0c1adf..fa9539852e 100644 --- a/packages/richtext-lexical/src/features/blocks/client/plugin/index.tsx +++ b/packages/richtext-lexical/src/features/blocks/client/plugin/index.tsx @@ -56,22 +56,15 @@ export const BlocksPlugin: PluginComponent = () => { if ($isRangeSelection(selection)) { const blockNode = $createBlockNode(payload) + + // we need to get the focus node before inserting the block node, as $insertNodeToNearestRoot can change the focus node + const { focus } = selection + const focusNode = focus.getNode() // Insert blocks node BEFORE potentially removing focusNode, as $insertNodeToNearestRoot errors if the focusNode doesn't exist $insertNodeToNearestRoot(blockNode) - const { focus } = selection - const focusNode = focus.getNode() - - // First, delete currently selected node if it's an empty paragraph and if there are sufficient - // paragraph nodes (more than 1) left in the parent node, so that we don't "trap" the user - if ( - $isParagraphNode(focusNode) && - focusNode.getTextContentSize() === 0 && - focusNode - .getParentOrThrow() - .getChildren() - .filter((node) => $isParagraphNode(node)).length > 1 - ) { + // Delete the node it it's an empty paragraph + if ($isParagraphNode(focusNode) && !focusNode.__first) { focusNode.remove() } } diff --git a/packages/richtext-lexical/src/features/relationship/client/plugins/index.tsx b/packages/richtext-lexical/src/features/relationship/client/plugins/index.tsx index 84affa0ed5..bd581620ae 100644 --- a/packages/richtext-lexical/src/features/relationship/client/plugins/index.tsx +++ b/packages/richtext-lexical/src/features/relationship/client/plugins/index.tsx @@ -53,22 +53,14 @@ export const RelationshipPlugin: PluginComponent = ({ if ($isRangeSelection(selection)) { const relationshipNode = $createRelationshipNode(payload) + // we need to get the focus node before inserting the block node, as $insertNodeToNearestRoot can change the focus node + const { focus } = selection + const focusNode = focus.getNode() // Insert relationship node BEFORE potentially removing focusNode, as $insertNodeToNearestRoot errors if the focusNode doesn't exist $insertNodeToNearestRoot(relationshipNode) - const { focus } = selection - const focusNode = focus.getNode() - - // First, delete currently selected node if it's an empty paragraph and if there are sufficient - // paragraph nodes (more than 1) left in the parent node, so that we don't "trap" the user - if ( - $isParagraphNode(focusNode) && - focusNode.getTextContentSize() === 0 && - focusNode - .getParentOrThrow() - .getChildren() - .filter((node) => $isParagraphNode(node)).length > 1 - ) { + // Delete the node it it's an empty paragraph + if ($isParagraphNode(focusNode) && !focusNode.__first) { focusNode.remove() } } diff --git a/packages/richtext-lexical/src/features/upload/client/plugin/index.tsx b/packages/richtext-lexical/src/features/upload/client/plugin/index.tsx index 7d0884433e..5ee652610e 100644 --- a/packages/richtext-lexical/src/features/upload/client/plugin/index.tsx +++ b/packages/richtext-lexical/src/features/upload/client/plugin/index.tsx @@ -53,18 +53,14 @@ export const UploadPlugin: PluginComponent = ({ client value: payload.value, }, }) + // we need to get the focus node before inserting the block node, as $insertNodeToNearestRoot can change the focus node + const { focus } = selection + const focusNode = focus.getNode() // Insert upload node BEFORE potentially removing focusNode, as $insertNodeToNearestRoot errors if the focusNode doesn't exist $insertNodeToNearestRoot(uploadNode) - const { focus } = selection - const focusNode = focus.getNode() - - // Delete the node it it's an empty paragraph and it has at least one sibling, so that we don't "trap" the user - if ( - $isParagraphNode(focusNode) && - !focusNode.__first && - (focusNode.__prev || focusNode.__next) - ) { + // Delete the node it it's an empty paragraph + if ($isParagraphNode(focusNode) && !focusNode.__first) { focusNode.remove() } } diff --git a/packages/richtext-lexical/src/lexical/LexicalEditor.tsx b/packages/richtext-lexical/src/lexical/LexicalEditor.tsx index ead987a743..aa3a5f3d28 100644 --- a/packages/richtext-lexical/src/lexical/LexicalEditor.tsx +++ b/packages/richtext-lexical/src/lexical/LexicalEditor.tsx @@ -4,13 +4,7 @@ import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary.js' import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin.js' import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin.js' import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin.js' -import { - $createParagraphNode, - $getRoot, - BLUR_COMMAND, - COMMAND_PRIORITY_LOW, - FOCUS_COMMAND, -} from 'lexical' +import { BLUR_COMMAND, COMMAND_PRIORITY_LOW, FOCUS_COMMAND } from 'lexical' import * as React from 'react' import { useEffect, useState } from 'react' @@ -24,6 +18,7 @@ import { AddBlockHandlePlugin } from './plugins/handles/AddBlockHandlePlugin/ind import { DraggableBlockPlugin } from './plugins/handles/DraggableBlockPlugin/index.js' import { InsertParagraphAtEndPlugin } from './plugins/InsertParagraphAtEnd/index.js' import { MarkdownShortcutPlugin } from './plugins/MarkdownShortcut/index.js' +import { NormalizeSelectionPlugin } from './plugins/NormalizeSelection/index.js' import { SlashMenuPlugin } from './plugins/SlashMenu/index.js' import { TextPlugin } from './plugins/TextPlugin/index.js' import { LexicalContentEditable } from './ui/ContentEditable.js' @@ -112,6 +107,7 @@ export const LexicalEditor: React.FC< } ErrorBoundary={LexicalErrorBoundary} /> + diff --git a/packages/richtext-lexical/src/lexical/plugins/NormalizeSelection/index.tsx b/packages/richtext-lexical/src/lexical/plugins/NormalizeSelection/index.tsx new file mode 100644 index 0000000000..93c954790e --- /dev/null +++ b/packages/richtext-lexical/src/lexical/plugins/NormalizeSelection/index.tsx @@ -0,0 +1,35 @@ +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { $getSelection, $isRangeSelection, RootNode } from 'lexical' +import { useEffect } from 'react' + +/** + * By default, Lexical throws an error if the selection ends in deleted nodes. + * This is very aggressive considering there are reasons why this can happen + * outside of Payload's control (custom features or conflicting features, for example). + * In the case of selections on nonexistent nodes, this plugin moves the selection to + * the end of the editor and displays a warning instead of an error. + */ +export function NormalizeSelectionPlugin() { + const [editor] = useLexicalComposerContext() + + useEffect(() => { + return editor.registerNodeTransform(RootNode, (root) => { + const selection = $getSelection() + if ($isRangeSelection(selection)) { + const anchorNode = selection.anchor.getNode() + const focusNode = selection.focus.getNode() + if (!anchorNode.isAttached() || !focusNode.isAttached()) { + root.selectEnd() + // eslint-disable-next-line no-console + console.warn( + 'updateEditor: selection has been moved to the end of the editor because the previously selected nodes have been removed and ' + + "selection wasn't moved to another node. Ensure selection changes after removing/replacing a selected node.", + ) + } + } + return false + }) + }, [editor]) + + return null +} diff --git a/test/lexical/baseConfig.ts b/test/lexical/baseConfig.ts index ca48a213b0..f163abbd30 100644 --- a/test/lexical/baseConfig.ts +++ b/test/lexical/baseConfig.ts @@ -2,6 +2,7 @@ import { fileURLToPath } from 'node:url' import path from 'path' import { type Config } from 'payload' +import { LexicalFullyFeatured } from './collections/_LexicalFullyFeatured/index.js' import ArrayFields from './collections/Array/index.js' import { getLexicalFieldsCollection, @@ -26,6 +27,7 @@ const dirname = path.dirname(filename) export const baseConfig: Partial = { // ...extend config here collections: [ + LexicalFullyFeatured, getLexicalFieldsCollection({ blocks: lexicalBlocks, inlineBlocks: lexicalInlineBlocks, @@ -42,10 +44,18 @@ export const baseConfig: Partial = { ArrayFields, ], globals: [TabsWithRichText], + admin: { importMap: { baseDir: path.resolve(dirname), }, + components: { + beforeDashboard: [ + { + path: './components/CollectionsExplained.tsx#CollectionsExplained', + }, + ], + }, }, onInit: async (payload) => { if (process.env.SEED_IN_CONFIG_ONINIT !== 'false') { diff --git a/test/lexical/collections/Lexical/e2e/blocks/e2e.spec.ts b/test/lexical/collections/Lexical/e2e/blocks/e2e.spec.ts index 9a910b0002..b1c5ddc986 100644 --- a/test/lexical/collections/Lexical/e2e/blocks/e2e.spec.ts +++ b/test/lexical/collections/Lexical/e2e/blocks/e2e.spec.ts @@ -302,7 +302,7 @@ describe('lexicalBlocks', () => { await assertLexicalDoc({ fn: ({ lexicalWithBlocks }) => { const rscBlock: SerializedBlockNode = lexicalWithBlocks.root - .children[14] as SerializedBlockNode + .children[13] as SerializedBlockNode const paragraphNode: SerializedParagraphNode = lexicalWithBlocks.root .children[12] as SerializedParagraphNode @@ -1133,9 +1133,9 @@ describe('lexicalBlocks', () => { ).docs[0] as never const richTextBlock: SerializedBlockNode = lexicalWithBlocks.root - .children[13] as SerializedBlockNode + .children[12] as SerializedBlockNode const subRichTextBlock: SerializedBlockNode = richTextBlock.fields.richTextField.root - .children[1] as SerializedBlockNode // index 0 and 2 are paragraphs created by default around the block node when a new block is added via slash command + .children[0] as SerializedBlockNode // index 0 and 2 are paragraphs created by default around the block node when a new block is added via slash command const subSubRichTextField = subRichTextBlock.fields.subRichTextField const subSubUploadField = subRichTextBlock.fields.subUploadField @@ -1163,9 +1163,9 @@ describe('lexicalBlocks', () => { ).docs[0] as never const richTextBlock2: SerializedBlockNode = lexicalWithBlocks.root - .children[13] as SerializedBlockNode + .children[12] as SerializedBlockNode const subRichTextBlock2: SerializedBlockNode = richTextBlock2.fields.richTextField.root - .children[1] as SerializedBlockNode // index 0 and 2 are paragraphs created by default around the block node when a new block is added via slash command + .children[0] as SerializedBlockNode // index 0 and 2 are paragraphs created by default around the block node when a new block is added via slash command const subSubRichTextField2 = subRichTextBlock2.fields.subRichTextField const subSubUploadField2 = subRichTextBlock2.fields.subUploadField diff --git a/test/lexical/collections/Lexical/e2e/main/e2e.spec.ts b/test/lexical/collections/Lexical/e2e/main/e2e.spec.ts index 44df6e68b4..d337cd37f9 100644 --- a/test/lexical/collections/Lexical/e2e/main/e2e.spec.ts +++ b/test/lexical/collections/Lexical/e2e/main/e2e.spec.ts @@ -728,7 +728,8 @@ describe('lexicalMain', () => { await expect(relationshipListDrawer).toBeVisible() await wait(500) - await expect(relationshipListDrawer.locator('.rs__single-value')).toHaveText('Lexical Field') + await relationshipListDrawer.locator('.rs__input').first().click() + await relationshipListDrawer.locator('.rs__menu').getByText('Lexical Field').click() await relationshipListDrawer.locator('button').getByText('Rich Text').first().click() await expect(relationshipListDrawer).toBeHidden() @@ -1203,10 +1204,11 @@ describe('lexicalMain', () => { await expect(newUploadNode.locator('.lexical-upload__bottomRow')).toContainText('payload.png') await page.keyboard.press('Enter') // floating toolbar needs to appear with enough distance to the upload node, otherwise clicking may fail + await page.keyboard.press('Enter') await page.keyboard.press('ArrowLeft') await page.keyboard.press('ArrowLeft') // Select "there" by pressing shift + arrow left - for (let i = 0; i < 4; i++) { + for (let i = 0; i < 5; i++) { await page.keyboard.press('Shift+ArrowLeft') } @@ -1258,10 +1260,10 @@ describe('lexicalMain', () => { const firstParagraph: SerializedParagraphNode = lexicalField.root .children[0] as SerializedParagraphNode const secondParagraph: SerializedParagraphNode = lexicalField.root - .children[1] as SerializedParagraphNode - const thirdParagraph: SerializedParagraphNode = lexicalField.root .children[2] as SerializedParagraphNode - const uploadNode: SerializedUploadNode = lexicalField.root.children[3] as SerializedUploadNode + const thirdParagraph: SerializedParagraphNode = lexicalField.root + .children[3] as SerializedParagraphNode + const uploadNode: SerializedUploadNode = lexicalField.root.children[1] as SerializedUploadNode expect(firstParagraph.children).toHaveLength(2) expect((firstParagraph.children[0] as SerializedTextNode).text).toBe('Some ') @@ -1391,7 +1393,7 @@ describe('lexicalMain', () => { const lexicalField: SerializedEditorState = lexicalDoc.lexicalRootEditor // @ts-expect-error no need to type this - expect(lexicalField?.root?.children[1].fields.someTextRequired).toEqual('test') + expect(lexicalField?.root?.children[0].fields.someTextRequired).toEqual('test') }).toPass({ timeout: POLL_TOPASS_TIMEOUT, }) diff --git a/test/lexical/collections/_LexicalFullyFeatured/e2e.spec.ts b/test/lexical/collections/_LexicalFullyFeatured/e2e.spec.ts new file mode 100644 index 0000000000..ac986a998d --- /dev/null +++ b/test/lexical/collections/_LexicalFullyFeatured/e2e.spec.ts @@ -0,0 +1,68 @@ +import { expect, test } from '@playwright/test' +import { AdminUrlUtil } from 'helpers/adminUrlUtil.js' +import { reInitializeDB } from 'helpers/reInitializeDB.js' +import { lexicalFullyFeaturedSlug } from 'lexical/slugs.js' +import path from 'path' +import { fileURLToPath } from 'url' + +import { ensureCompilationIsDone } from '../../../helpers.js' +import { initPayloadE2ENoConfig } from '../../../helpers/initPayloadE2ENoConfig.js' +import { TEST_TIMEOUT_LONG } from '../../../playwright.config.js' +import { LexicalHelpers } from './utils.js' +const filename = fileURLToPath(import.meta.url) +const currentFolder = path.dirname(filename) +const dirname = path.resolve(currentFolder, '../../') + +const { beforeAll, beforeEach, describe } = test + +// Unlike the other suites, this one runs in parallel, as they run on the `lexical-fully-featured/create` URL and are "pure" tests +test.describe.configure({ mode: 'parallel' }) + +const { serverURL } = await initPayloadE2ENoConfig({ + dirname, +}) + +describe('Lexical Fully Featured', () => { + beforeAll(async ({ browser }, testInfo) => { + testInfo.setTimeout(TEST_TIMEOUT_LONG) + process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit + const page = await browser.newPage() + await ensureCompilationIsDone({ page, serverURL }) + await page.close() + }) + beforeEach(async ({ page }) => { + await reInitializeDB({ + serverURL, + snapshotKey: 'fieldsTest', + uploadsDir: [ + path.resolve(dirname, './collections/Upload/uploads'), + path.resolve(dirname, './collections/Upload2/uploads2'), + ], + }) + const url = new AdminUrlUtil(serverURL, lexicalFullyFeaturedSlug) + const lexical = new LexicalHelpers(page) + await page.goto(url.create) + await lexical.editor.first().focus() + }) + test('prevent extra paragraph when inserting decorator blocks like blocks or upload node', async ({ + page, + }) => { + const lexical = new LexicalHelpers(page) + await lexical.slashCommand('block') + await lexical.slashCommand('relationship') + await lexical.drawer.locator('.list-drawer__header').getByText('Create New').click() + await lexical.save('drawer') + await expect(lexical.decorator).toHaveCount(2) + await lexical.slashCommand('upload') + await lexical.drawer.locator('.list-drawer__header').getByText('Create New').click() + await lexical.drawer.getByText('Paste URL').click() + await lexical.drawer + .locator('.file-field__remote-file') + .fill('https://payloadcms.com/images/universal-truth.jpg') + await lexical.drawer.getByText('Add file').click() + await lexical.save('drawer') + await expect(lexical.decorator).toHaveCount(3) + const paragraph = lexical.editor.locator('> p') + await expect(paragraph).toHaveText('') + }) +}) diff --git a/test/lexical/collections/_LexicalFullyFeatured/index.ts b/test/lexical/collections/_LexicalFullyFeatured/index.ts new file mode 100644 index 0000000000..f8d8156e8f --- /dev/null +++ b/test/lexical/collections/_LexicalFullyFeatured/index.ts @@ -0,0 +1,57 @@ +import type { CollectionConfig } from 'payload' + +import { + BlocksFeature, + EXPERIMENTAL_TableFeature, + FixedToolbarFeature, + lexicalEditor, + TreeViewFeature, +} from '@payloadcms/richtext-lexical' + +import { lexicalFullyFeaturedSlug } from '../../slugs.js' + +export const LexicalFullyFeatured: CollectionConfig = { + slug: lexicalFullyFeaturedSlug, + labels: { + singular: 'Lexical Fully Featured', + plural: 'Lexical Fully Featured', + }, + fields: [ + { + name: 'richText', + type: 'richText', + editor: lexicalEditor({ + features: ({ defaultFeatures }) => [ + ...defaultFeatures, + TreeViewFeature(), + FixedToolbarFeature(), + EXPERIMENTAL_TableFeature(), + BlocksFeature({ + blocks: [ + { + slug: 'myBlock', + fields: [ + { + name: 'someText', + type: 'text', + }, + ], + }, + ], + inlineBlocks: [ + { + slug: 'myInlineBlock', + fields: [ + { + name: 'someText', + type: 'text', + }, + ], + }, + ], + }), + ], + }), + }, + ], +} diff --git a/test/lexical/collections/_LexicalFullyFeatured/utils.ts b/test/lexical/collections/_LexicalFullyFeatured/utils.ts new file mode 100644 index 0000000000..95030f7fee --- /dev/null +++ b/test/lexical/collections/_LexicalFullyFeatured/utils.ts @@ -0,0 +1,49 @@ +import type { Page } from 'playwright' + +import { expect } from '@playwright/test' + +export class LexicalHelpers { + page: Page + constructor(page: Page) { + this.page = page + } + + async save(container: 'document' | 'drawer') { + if (container === 'drawer') { + await this.drawer.getByText('Save').click() + } else { + throw new Error('Not implemented') + } + await this.page.waitForTimeout(1000) + } + + async slashCommand( + // prettier-ignore + command: 'block' | 'check' | 'code' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' |'h6' | 'inline' + | 'link' | 'ordered' | 'paragraph' | 'quote' | 'relationship' | 'unordered' | 'upload', + ) { + await this.page.keyboard.press(`/`) + + const slashMenuPopover = this.page.locator('#slash-menu .slash-menu-popup') + await expect(slashMenuPopover).toBeVisible() + await this.page.keyboard.type(command) + await this.page.keyboard.press(`Enter`) + await expect(slashMenuPopover).toBeHidden() + } + + get decorator() { + return this.editor.locator('[data-lexical-decorator="true"]') + } + + get drawer() { + return this.page.locator('.drawer__content') + } + + get editor() { + return this.page.locator('[data-lexical-editor="true"]') + } + + get paragraph() { + return this.editor.locator('p') + } +} diff --git a/test/lexical/components/CollectionsExplained.tsx b/test/lexical/components/CollectionsExplained.tsx new file mode 100644 index 0000000000..10f27bd787 --- /dev/null +++ b/test/lexical/components/CollectionsExplained.tsx @@ -0,0 +1,22 @@ +import React from 'react' + +export function CollectionsExplained() { + return ( +
+

Which collection should I use for my tests?

+ +

+ By default and as a rule of thumb: "Lexical Fully Featured". This collection has all our + features, but it does NOT have (and will never have): +

+
    +
  • Relationships or dependencies to other collections
  • +
  • Seeded documents
  • +
  • Features with custom props (except for a block and an inline block included)
  • +
  • Multiple richtext fields or other fields
  • +
+ +

If you need any of these features, use another collection or create a new one.

+
+ ) +} diff --git a/test/lexical/payload-types.ts b/test/lexical/payload-types.ts index 73373c85c9..1964b49ffb 100644 --- a/test/lexical/payload-types.ts +++ b/test/lexical/payload-types.ts @@ -83,6 +83,7 @@ export interface Config { }; blocks: {}; collections: { + 'lexical-fully-featured': LexicalFullyFeatured; 'lexical-fields': LexicalField; 'lexical-migrate-fields': LexicalMigrateField; 'lexical-localized-fields': LexicalLocalizedField; @@ -101,6 +102,7 @@ export interface Config { }; collectionsJoins: {}; collectionsSelect: { + 'lexical-fully-featured': LexicalFullyFeaturedSelect | LexicalFullyFeaturedSelect; 'lexical-fields': LexicalFieldsSelect | LexicalFieldsSelect; 'lexical-migrate-fields': LexicalMigrateFieldsSelect | LexicalMigrateFieldsSelect; 'lexical-localized-fields': LexicalLocalizedFieldsSelect | LexicalLocalizedFieldsSelect; @@ -153,6 +155,30 @@ export interface UserAuthOperations { password: string; }; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "lexical-fully-featured". + */ +export interface LexicalFullyFeatured { + id: string; + richText?: { + root: { + type: string; + children: { + type: string; + version: number; + [k: string]: unknown; + }[]; + direction: ('ltr' | 'rtl') | null; + format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; + indent: number; + version: number; + }; + [k: string]: unknown; + } | null; + updatedAt: string; + createdAt: string; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "lexical-fields". @@ -774,6 +800,10 @@ export interface User { export interface PayloadLockedDocument { id: string; document?: + | ({ + relationTo: 'lexical-fully-featured'; + value: string | LexicalFullyFeatured; + } | null) | ({ relationTo: 'lexical-fields'; value: string | LexicalField; @@ -864,6 +894,15 @@ export interface PayloadMigration { updatedAt: string; createdAt: string; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "lexical-fully-featured_select". + */ +export interface LexicalFullyFeaturedSelect { + richText?: T; + updatedAt?: T; + createdAt?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "lexical-fields_select". diff --git a/test/lexical/slugs.ts b/test/lexical/slugs.ts index 1f782022a4..73cf167101 100644 --- a/test/lexical/slugs.ts +++ b/test/lexical/slugs.ts @@ -1,5 +1,6 @@ export const usersSlug = 'users' +export const lexicalFullyFeaturedSlug = 'lexical-fully-featured' export const lexicalFieldsSlug = 'lexical-fields' export const lexicalLocalizedFieldsSlug = 'lexical-localized-fields' export const lexicalMigrateFieldsSlug = 'lexical-migrate-fields' From 3df1329e1981a25816e2f05afe83f308a913f403 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Tue, 29 Apr 2025 12:36:58 -0400 Subject: [PATCH 13/34] chore(release): v3.36.0 [skip ci] --- package.json | 2 +- packages/admin-bar/package.json | 2 +- packages/create-payload-app/package.json | 2 +- packages/db-mongodb/package.json | 2 +- packages/db-postgres/package.json | 2 +- packages/db-sqlite/package.json | 2 +- packages/db-vercel-postgres/package.json | 2 +- packages/drizzle/package.json | 2 +- packages/email-nodemailer/package.json | 2 +- packages/email-resend/package.json | 2 +- packages/graphql/package.json | 2 +- packages/live-preview-react/package.json | 2 +- packages/live-preview-vue/package.json | 2 +- packages/live-preview/package.json | 2 +- packages/next/package.json | 2 +- packages/payload-cloud/package.json | 2 +- packages/payload/package.json | 2 +- packages/plugin-cloud-storage/package.json | 2 +- packages/plugin-form-builder/package.json | 2 +- packages/plugin-import-export/package.json | 2 +- packages/plugin-multi-tenant/package.json | 2 +- packages/plugin-nested-docs/package.json | 2 +- packages/plugin-redirects/package.json | 2 +- packages/plugin-search/package.json | 2 +- packages/plugin-sentry/package.json | 2 +- packages/plugin-seo/package.json | 2 +- packages/plugin-stripe/package.json | 2 +- packages/richtext-lexical/package.json | 2 +- packages/richtext-slate/package.json | 2 +- packages/storage-azure/package.json | 2 +- packages/storage-gcs/package.json | 2 +- packages/storage-s3/package.json | 2 +- packages/storage-uploadthing/package.json | 2 +- packages/storage-vercel-blob/package.json | 2 +- packages/translations/package.json | 2 +- packages/ui/package.json | 2 +- 36 files changed, 36 insertions(+), 36 deletions(-) diff --git a/package.json b/package.json index 026c32f976..7895f6be1c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "payload-monorepo", - "version": "3.35.1", + "version": "3.36.0", "private": true, "type": "module", "scripts": { diff --git a/packages/admin-bar/package.json b/packages/admin-bar/package.json index 6292ace878..15de281654 100644 --- a/packages/admin-bar/package.json +++ b/packages/admin-bar/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/admin-bar", - "version": "3.35.1", + "version": "3.36.0", "description": "An admin bar for React apps using Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/create-payload-app/package.json b/packages/create-payload-app/package.json index d0a1244a45..04eff67ead 100644 --- a/packages/create-payload-app/package.json +++ b/packages/create-payload-app/package.json @@ -1,6 +1,6 @@ { "name": "create-payload-app", - "version": "3.35.1", + "version": "3.36.0", "homepage": "https://payloadcms.com", "repository": { "type": "git", diff --git a/packages/db-mongodb/package.json b/packages/db-mongodb/package.json index b433605a93..5327c2bf3b 100644 --- a/packages/db-mongodb/package.json +++ b/packages/db-mongodb/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/db-mongodb", - "version": "3.35.1", + "version": "3.36.0", "description": "The officially supported MongoDB database adapter for Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/db-postgres/package.json b/packages/db-postgres/package.json index b6017ea73c..e8b0f3a6c5 100644 --- a/packages/db-postgres/package.json +++ b/packages/db-postgres/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/db-postgres", - "version": "3.35.1", + "version": "3.36.0", "description": "The officially supported Postgres database adapter for Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/db-sqlite/package.json b/packages/db-sqlite/package.json index 12390b6c5c..b286230331 100644 --- a/packages/db-sqlite/package.json +++ b/packages/db-sqlite/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/db-sqlite", - "version": "3.35.1", + "version": "3.36.0", "description": "The officially supported SQLite database adapter for Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/db-vercel-postgres/package.json b/packages/db-vercel-postgres/package.json index f33b6c80b6..be9ff4df75 100644 --- a/packages/db-vercel-postgres/package.json +++ b/packages/db-vercel-postgres/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/db-vercel-postgres", - "version": "3.35.1", + "version": "3.36.0", "description": "Vercel Postgres adapter for Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/drizzle/package.json b/packages/drizzle/package.json index 866f2477b2..54e97c46e1 100644 --- a/packages/drizzle/package.json +++ b/packages/drizzle/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/drizzle", - "version": "3.35.1", + "version": "3.36.0", "description": "A library of shared functions used by different payload database adapters", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/email-nodemailer/package.json b/packages/email-nodemailer/package.json index 1cdc4983a8..ba8be1c41c 100644 --- a/packages/email-nodemailer/package.json +++ b/packages/email-nodemailer/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/email-nodemailer", - "version": "3.35.1", + "version": "3.36.0", "description": "Payload Nodemailer Email Adapter", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/email-resend/package.json b/packages/email-resend/package.json index 338b35c5af..b4bf1caa1a 100644 --- a/packages/email-resend/package.json +++ b/packages/email-resend/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/email-resend", - "version": "3.35.1", + "version": "3.36.0", "description": "Payload Resend Email Adapter", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/graphql/package.json b/packages/graphql/package.json index db700269b7..c2c7cd6d9c 100644 --- a/packages/graphql/package.json +++ b/packages/graphql/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/graphql", - "version": "3.35.1", + "version": "3.36.0", "homepage": "https://payloadcms.com", "repository": { "type": "git", diff --git a/packages/live-preview-react/package.json b/packages/live-preview-react/package.json index 5ae4fd75a6..3ef930daaa 100644 --- a/packages/live-preview-react/package.json +++ b/packages/live-preview-react/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/live-preview-react", - "version": "3.35.1", + "version": "3.36.0", "description": "The official React SDK for Payload Live Preview", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/live-preview-vue/package.json b/packages/live-preview-vue/package.json index c6d2c35823..15e02daa1d 100644 --- a/packages/live-preview-vue/package.json +++ b/packages/live-preview-vue/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/live-preview-vue", - "version": "3.35.1", + "version": "3.36.0", "description": "The official Vue SDK for Payload Live Preview", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/live-preview/package.json b/packages/live-preview/package.json index 338ffa0c2e..fc8a95caa7 100644 --- a/packages/live-preview/package.json +++ b/packages/live-preview/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/live-preview", - "version": "3.35.1", + "version": "3.36.0", "description": "The official live preview JavaScript SDK for Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/next/package.json b/packages/next/package.json index f4ae1af419..879c2f8674 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/next", - "version": "3.35.1", + "version": "3.36.0", "homepage": "https://payloadcms.com", "repository": { "type": "git", diff --git a/packages/payload-cloud/package.json b/packages/payload-cloud/package.json index bbaf0361da..e74897aed0 100644 --- a/packages/payload-cloud/package.json +++ b/packages/payload-cloud/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/payload-cloud", - "version": "3.35.1", + "version": "3.36.0", "description": "The official Payload Cloud plugin", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/payload/package.json b/packages/payload/package.json index e294512b71..0885f3c983 100644 --- a/packages/payload/package.json +++ b/packages/payload/package.json @@ -1,6 +1,6 @@ { "name": "payload", - "version": "3.35.1", + "version": "3.36.0", "description": "Node, React, Headless CMS and Application Framework built on Next.js", "keywords": [ "admin panel", diff --git a/packages/plugin-cloud-storage/package.json b/packages/plugin-cloud-storage/package.json index 47dab5d7bd..326d5d271d 100644 --- a/packages/plugin-cloud-storage/package.json +++ b/packages/plugin-cloud-storage/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-cloud-storage", - "version": "3.35.1", + "version": "3.36.0", "description": "The official cloud storage plugin for Payload CMS", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/plugin-form-builder/package.json b/packages/plugin-form-builder/package.json index 2ef45b15b1..5dea0f2bcc 100644 --- a/packages/plugin-form-builder/package.json +++ b/packages/plugin-form-builder/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-form-builder", - "version": "3.35.1", + "version": "3.36.0", "description": "Form builder plugin for Payload CMS", "keywords": [ "payload", diff --git a/packages/plugin-import-export/package.json b/packages/plugin-import-export/package.json index ce73cf23fb..93418181d2 100644 --- a/packages/plugin-import-export/package.json +++ b/packages/plugin-import-export/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-import-export", - "version": "3.35.1", + "version": "3.36.0", "description": "Import-Export plugin for Payload", "keywords": [ "payload", diff --git a/packages/plugin-multi-tenant/package.json b/packages/plugin-multi-tenant/package.json index 37f1898379..edba2c559a 100644 --- a/packages/plugin-multi-tenant/package.json +++ b/packages/plugin-multi-tenant/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-multi-tenant", - "version": "3.35.1", + "version": "3.36.0", "description": "Multi Tenant plugin for Payload", "keywords": [ "payload", diff --git a/packages/plugin-nested-docs/package.json b/packages/plugin-nested-docs/package.json index a04087888d..883a72b2a6 100644 --- a/packages/plugin-nested-docs/package.json +++ b/packages/plugin-nested-docs/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-nested-docs", - "version": "3.35.1", + "version": "3.36.0", "description": "The official Nested Docs plugin for Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/plugin-redirects/package.json b/packages/plugin-redirects/package.json index 65c605e62a..4d309d3d62 100644 --- a/packages/plugin-redirects/package.json +++ b/packages/plugin-redirects/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-redirects", - "version": "3.35.1", + "version": "3.36.0", "description": "Redirects plugin for Payload", "keywords": [ "payload", diff --git a/packages/plugin-search/package.json b/packages/plugin-search/package.json index 295d36315e..6e3ee3aef7 100644 --- a/packages/plugin-search/package.json +++ b/packages/plugin-search/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-search", - "version": "3.35.1", + "version": "3.36.0", "description": "Search plugin for Payload", "keywords": [ "payload", diff --git a/packages/plugin-sentry/package.json b/packages/plugin-sentry/package.json index 9b1218a7b7..a4c59563b2 100644 --- a/packages/plugin-sentry/package.json +++ b/packages/plugin-sentry/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-sentry", - "version": "3.35.1", + "version": "3.36.0", "description": "Sentry plugin for Payload", "keywords": [ "payload", diff --git a/packages/plugin-seo/package.json b/packages/plugin-seo/package.json index 4f13a39f91..7c11f3f706 100644 --- a/packages/plugin-seo/package.json +++ b/packages/plugin-seo/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-seo", - "version": "3.35.1", + "version": "3.36.0", "description": "SEO plugin for Payload", "keywords": [ "payload", diff --git a/packages/plugin-stripe/package.json b/packages/plugin-stripe/package.json index e45211aaa1..9e2564adc4 100644 --- a/packages/plugin-stripe/package.json +++ b/packages/plugin-stripe/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-stripe", - "version": "3.35.1", + "version": "3.36.0", "description": "Stripe plugin for Payload", "keywords": [ "payload", diff --git a/packages/richtext-lexical/package.json b/packages/richtext-lexical/package.json index f6e2057f6b..0b0eef81b6 100644 --- a/packages/richtext-lexical/package.json +++ b/packages/richtext-lexical/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/richtext-lexical", - "version": "3.35.1", + "version": "3.36.0", "description": "The officially supported Lexical richtext adapter for Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/richtext-slate/package.json b/packages/richtext-slate/package.json index af889d5f00..9e1b091f17 100644 --- a/packages/richtext-slate/package.json +++ b/packages/richtext-slate/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/richtext-slate", - "version": "3.35.1", + "version": "3.36.0", "description": "The officially supported Slate richtext adapter for Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/storage-azure/package.json b/packages/storage-azure/package.json index b342dbb4c8..a700d036db 100644 --- a/packages/storage-azure/package.json +++ b/packages/storage-azure/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/storage-azure", - "version": "3.35.1", + "version": "3.36.0", "description": "Payload storage adapter for Azure Blob Storage", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/storage-gcs/package.json b/packages/storage-gcs/package.json index 625022b32e..73ad059c84 100644 --- a/packages/storage-gcs/package.json +++ b/packages/storage-gcs/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/storage-gcs", - "version": "3.35.1", + "version": "3.36.0", "description": "Payload storage adapter for Google Cloud Storage", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/storage-s3/package.json b/packages/storage-s3/package.json index 0e65946913..8818a84852 100644 --- a/packages/storage-s3/package.json +++ b/packages/storage-s3/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/storage-s3", - "version": "3.35.1", + "version": "3.36.0", "description": "Payload storage adapter for Amazon S3", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/storage-uploadthing/package.json b/packages/storage-uploadthing/package.json index 56f1ba3c99..4943396c9a 100644 --- a/packages/storage-uploadthing/package.json +++ b/packages/storage-uploadthing/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/storage-uploadthing", - "version": "3.35.1", + "version": "3.36.0", "description": "Payload storage adapter for uploadthing", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/storage-vercel-blob/package.json b/packages/storage-vercel-blob/package.json index 84af5e067f..f1db8bade4 100644 --- a/packages/storage-vercel-blob/package.json +++ b/packages/storage-vercel-blob/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/storage-vercel-blob", - "version": "3.35.1", + "version": "3.36.0", "description": "Payload storage adapter for Vercel Blob Storage", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/translations/package.json b/packages/translations/package.json index 2608d3cb10..c72bde26c9 100644 --- a/packages/translations/package.json +++ b/packages/translations/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/translations", - "version": "3.35.1", + "version": "3.36.0", "homepage": "https://payloadcms.com", "repository": { "type": "git", diff --git a/packages/ui/package.json b/packages/ui/package.json index 360012acd2..93243e3495 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/ui", - "version": "3.35.1", + "version": "3.36.0", "homepage": "https://payloadcms.com", "repository": { "type": "git", From 1b17df9e0ba43978c52e3f05fdab44c97bd29896 Mon Sep 17 00:00:00 2001 From: Tobias Odendahl Date: Tue, 29 Apr 2025 18:54:06 +0200 Subject: [PATCH 14/34] fix(richtext-lexical): ensure state is up-to-date on inline-block restore (#12128) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### What? Ensures that the initial state on inline blocks gets updated when an inline block gets restored from lexical history. ### Why? If an inline block got edited, removed, and restored (via lexical undo), the state of the inline block was taken from an outdated initial state and did not reflect the current form state, see screencast https://github.com/user-attachments/assets/6f55ded3-57bc-4de0-8ac1-e49331674d5f ### How? We now ensure that the initial state gets re-initialized after the component got unmounted, resulting in the expected behavior: https://github.com/user-attachments/assets/4e97eeb2-6dc4-49b1-91ca-35b59a93a348 --------- Co-authored-by: Germán Jabloñski <43938777+GermanJablo@users.noreply.github.com> --- .../blocks/client/componentInline/index.tsx | 14 +++++- .../Lexical/e2e/blocks/e2e.spec.ts | 50 +++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/packages/richtext-lexical/src/features/blocks/client/componentInline/index.tsx b/packages/richtext-lexical/src/features/blocks/client/componentInline/index.tsx index 9df01e47f9..8cae0bdd1b 100644 --- a/packages/richtext-lexical/src/features/blocks/client/componentInline/index.tsx +++ b/packages/richtext-lexical/src/features/blocks/client/componentInline/index.tsx @@ -284,10 +284,22 @@ export const InlineBlockComponent: React.FC = (props) => { ) // cleanup effect useEffect(() => { + const isStateOutOfSync = (formData: InlineBlockFields, initialState: FormState) => { + return Object.keys(initialState).some( + (key) => initialState[key] && formData[key] !== initialState[key].value, + ) + } + return () => { + // If the component is unmounted (either via removeInlineBlock or via lexical itself) and the form state got changed before, + // we need to reset the initial state to force a re-fetch of the initial state when it gets mounted again (e.g. via lexical history undo). + // Otherwise it would use an outdated initial state. + if (initialState && isStateOutOfSync(formData, initialState)) { + setInitialState(false) + } abortAndIgnore(onChangeAbortControllerRef.current) } - }, []) + }, [formData, initialState]) /** * HANDLE FORM SUBMIT diff --git a/test/lexical/collections/Lexical/e2e/blocks/e2e.spec.ts b/test/lexical/collections/Lexical/e2e/blocks/e2e.spec.ts index b1c5ddc986..eac8657da0 100644 --- a/test/lexical/collections/Lexical/e2e/blocks/e2e.spec.ts +++ b/test/lexical/collections/Lexical/e2e/blocks/e2e.spec.ts @@ -1666,5 +1666,55 @@ describe('lexicalBlocks', () => { }, }) }) + + test('ensure inline blocks restore their state after undoing a removal', async () => { + await page.goto('http://localhost:3000/admin/collections/LexicalInBlock?limit=10') + + await page.locator('.cell-id a').first().click() + await page.waitForURL(`**/collections/LexicalInBlock/**`) + + // Wait for the page to be fully loaded and elements to be stable + await page.waitForLoadState('domcontentloaded') + + // Wait for the specific row to be visible and have its content loaded + const row2 = page.locator('#blocks-row-2') + await expect(row2).toBeVisible() + + // Get initial count and ensure it's stable + const inlineBlocks = page.locator('#blocks-row-2 .inline-block-container') + const inlineBlockCount = await inlineBlocks.count() + await expect(() => { + expect(inlineBlockCount).toBeGreaterThan(0) + }).toPass() + + const inlineBlockElement = inlineBlocks.first() + await inlineBlockElement.locator('.inline-block__editButton').first().click() + + await page.locator('.drawer--is-open #field-text').fill('value1') + await page.locator('.drawer--is-open button[type="submit"]').first().click() + + // remove inline block + await inlineBlockElement.click() + await page.keyboard.press('Backspace') + + // Check both that this specific element is removed and the total count decreased + await expect(inlineBlocks).toHaveCount(inlineBlockCount - 1) + + await page.keyboard.press('Escape') + + await inlineBlockElement.click() + + // Undo the removal using keyboard shortcut + await page.keyboard.press('ControlOrMeta+Z') + + // Wait for the block to be restored + await expect(inlineBlocks).toHaveCount(inlineBlockCount) + + // Open the drawer again + await inlineBlockElement.locator('.inline-block__editButton').first().click() + + // Check if the text field still contains 'value1' + await expect(page.locator('.drawer--is-open #field-text')).toHaveValue('value1') + }) }) }) From 8fee0163b5efccd126ef593a6b04875c9a9fad81 Mon Sep 17 00:00:00 2001 From: Mattias Grenhall Date: Tue, 29 Apr 2025 19:43:24 +0200 Subject: [PATCH 15/34] fix: update email regex to support special characters (#12181) ### What? It's impossible to create a user with special characters in their email in Payload CMS 3.35.0. The issue is that currently the regex looks like this: ...payload/packages/payload/src/fields/validations.ts (line 202-203): const emailRegex = /^(?!.*\.\.)[\w.%+-]+@[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*\.[a-z]{2,}$/i This allows users that have the following characters in their email to be created: %, ., +, - The regex needs to get updated to the following: const emailRegex = /^(?!.*\.\.)[\w!#$%&'*+/=?^{|}~.-]+@a-z0-9?(?:.a-z0-9?)*.[a-z]{2,}$/i` This way all special characters `!#$%&'*+/=?^_{|}~.-`` are hereby OK to have in the email. I've added more test-cases to cover a couple of more scenarios in the forked repo. ### Why? The regex is missing some special characters that are allowed according to standards. ### How? * Go to the admin ui and try to create a user with any of the newly added special characters meaning (!#$%&'*+/=?^_{|}~.-`) * You should get a validation error. However with the addition of the above code it should all check out. Fixes # https://github.com/payloadcms/payload/issues/12180 --------- Co-authored-by: Mattias Grenhall --- packages/payload/src/fields/validations.ts | 2 +- test/auth/int.spec.ts | 7 +++++++ tsconfig.base.json | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/payload/src/fields/validations.ts b/packages/payload/src/fields/validations.ts index a22e5cd5d8..5018948c51 100644 --- a/packages/payload/src/fields/validations.ts +++ b/packages/payload/src/fields/validations.ts @@ -200,7 +200,7 @@ export const email: EmailFieldValidation = ( * Supports multiple subdomains (e.g., user@sub.domain.example.com) */ const emailRegex = - /^(?!.*\.\.)[\w.%+-]+@[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*\.[a-z]{2,}$/i + /^(?!.*\.\.)[\w!#$%&'*+/=?^`{|}~-](?:[\w!#$%&'*+/=?^`{|}~.-]*[\w!#$%&'*+/=?^`{|}~-])?@[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*\.[a-z]{2,}$/i if ((value && !emailRegex.test(value)) || (!value && required)) { return t('validation:emailAddress') diff --git a/test/auth/int.spec.ts b/test/auth/int.spec.ts index 77b75798c6..ec6e6c0e53 100644 --- a/test/auth/int.spec.ts +++ b/test/auth/int.spec.ts @@ -1016,6 +1016,7 @@ describe('Auth', () => { expect(emailValidation('user.name+alias@example.co.uk', mockContext)).toBe(true) expect(emailValidation('user-name@example.org', mockContext)).toBe(true) expect(emailValidation('user@ex--ample.com', mockContext)).toBe(true) + expect(emailValidation("user'payload@example.org", mockContext)).toBe(true) }) it('should not allow emails with double quotes', () => { @@ -1045,5 +1046,11 @@ describe('Auth', () => { expect(emailValidation('user@-example.com', mockContext)).toBe('validation:emailAddress') expect(emailValidation('user@example-.com', mockContext)).toBe('validation:emailAddress') }) + it('should not allow emails that start with dot', () => { + expect(emailValidation('.user@example.com', mockContext)).toBe('validation:emailAddress') + }) + it('should not allow emails that have a comma', () => { + expect(emailValidation('user,name@example.com', mockContext)).toBe('validation:emailAddress') + }) }) }) diff --git a/tsconfig.base.json b/tsconfig.base.json index daa36c7211..c9793d25c6 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -31,7 +31,7 @@ } ], "paths": { - "@payload-config": ["./test/query-presets/config.ts"], + "@payload-config": ["./test/_community/config.ts"], "@payloadcms/admin-bar": ["./packages/admin-bar/src"], "@payloadcms/live-preview": ["./packages/live-preview/src"], "@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"], From 47a1eee765e554db29a9370927bf90f0fb4947f2 Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Tue, 29 Apr 2025 12:28:16 -0700 Subject: [PATCH 16/34] fix(plugin-import-export): csv export column order (#12258) ### What? The order of fields, when specified for the create export function was not used for constructing the data. Now the fields order will be used. ### Why? This is important to building CSV data for consumption in other systems. ### How? Adds logic to handle ordering the field values assigned to the export data prior to building the CSV. --- .../src/export/createExport.ts | 4 +- .../src/export/flattenObject.ts | 70 ++++++++++++++----- test/plugin-import-export/int.spec.ts | 34 +++++++++ 3 files changed, 90 insertions(+), 18 deletions(-) diff --git a/packages/plugin-import-export/src/export/createExport.ts b/packages/plugin-import-export/src/export/createExport.ts index 76021900a4..cee0c80f1c 100644 --- a/packages/plugin-import-export/src/export/createExport.ts +++ b/packages/plugin-import-export/src/export/createExport.ts @@ -87,7 +87,7 @@ export const createExport = async (args: CreateExportArgs) => { let isFirstBatch = true while (result.docs.length > 0) { - const csvInput = result.docs.map((doc) => flattenObject(doc)) + const csvInput = result.docs.map((doc) => flattenObject({ doc, fields })) const csvString = stringify(csvInput, { header: isFirstBatch }) this.push(encoder.encode(csvString)) isFirstBatch = false @@ -119,7 +119,7 @@ export const createExport = async (args: CreateExportArgs) => { result = await payload.find(findArgs) if (isCSV) { - const csvInput = result.docs.map((doc) => flattenObject(doc)) + const csvInput = result.docs.map((doc) => flattenObject({ doc, fields })) outputData.push(stringify(csvInput, { header: isFirstBatch })) isFirstBatch = false } else { diff --git a/packages/plugin-import-export/src/export/flattenObject.ts b/packages/plugin-import-export/src/export/flattenObject.ts index 8fe2c83f23..ccc2de988c 100644 --- a/packages/plugin-import-export/src/export/flattenObject.ts +++ b/packages/plugin-import-export/src/export/flattenObject.ts @@ -1,23 +1,61 @@ -export const flattenObject = (obj: any, prefix: string = ''): Record => { +import type { Document } from 'payload' + +type Args = { + doc: Document + fields?: string[] + prefix?: string +} + +export const flattenObject = ({ doc, fields, prefix }: Args): Record => { const result: Record = {} - Object.entries(obj).forEach(([key, value]) => { - const newKey = prefix ? `${prefix}_${key}` : key + const flatten = (doc: Document, prefix?: string) => { + Object.entries(doc).forEach(([key, value]) => { + const newKey = prefix ? `${prefix}_${key}` : key - if (Array.isArray(value)) { - value.forEach((item, index) => { - if (typeof item === 'object' && item !== null) { - Object.assign(result, flattenObject(item, `${newKey}_${index}`)) - } else { - result[`${newKey}_${index}`] = item - } - }) - } else if (typeof value === 'object' && value !== null) { - Object.assign(result, flattenObject(value, newKey)) - } else { - result[newKey] = value + if (Array.isArray(value)) { + value.forEach((item, index) => { + if (typeof item === 'object' && item !== null) { + flatten(item, `${newKey}_${index}`) + } else { + result[`${newKey}_${index}`] = item + } + }) + } else if (typeof value === 'object' && value !== null) { + flatten(value, newKey) + } else { + result[newKey] = value + } + }) + } + + flatten(doc, prefix) + + if (fields) { + const orderedResult: Record = {} + + const fieldToRegex = (field: string): RegExp => { + const parts = field.split('.').map((part) => `${part}(?:_\\d+)?`) + const pattern = `^${parts.join('_')}` + return new RegExp(pattern) } - }) + + fields.forEach((field) => { + if (result[field.replace(/\./g, '_')]) { + const sanitizedField = field.replace(/\./g, '_') + orderedResult[sanitizedField] = result[sanitizedField] + } else { + const regex = fieldToRegex(field) + Object.keys(result).forEach((key) => { + if (regex.test(key)) { + orderedResult[key] = result[key] + } + }) + } + }) + + return orderedResult + } return result } diff --git a/test/plugin-import-export/int.spec.ts b/test/plugin-import-export/int.spec.ts index 6bf5f6f14d..b0f27f80d1 100644 --- a/test/plugin-import-export/int.spec.ts +++ b/test/plugin-import-export/int.spec.ts @@ -1,5 +1,6 @@ import type { CollectionSlug, Payload } from 'payload' +import fs from 'fs' import path from 'path' import { fileURLToPath } from 'url' @@ -221,6 +222,39 @@ describe('@payloadcms/plugin-import-export', () => { expect(data[0].array_1_field2).toStrictEqual('baz') }) + it('should create a CSV file with columns matching the order of the fields array', async () => { + const fields = ['id', 'group.value', 'group.array.field1', 'title', 'createdAt', 'updatedAt'] + const doc = await payload.create({ + collection: 'exports', + user, + data: { + collectionSlug: 'pages', + fields, + format: 'csv', + where: { + title: { contains: 'Title ' }, + }, + }, + }) + + const exportDoc = await payload.findByID({ + collection: 'exports', + id: doc.id, + }) + + expect(exportDoc.filename).toBeDefined() + const expectedPath = path.join(dirname, './uploads', exportDoc.filename as string) + const buffer = fs.readFileSync(expectedPath) + const str = buffer.toString() + + // Assert that the header row matches the fields array + expect(str.indexOf('id')).toBeLessThan(str.indexOf('title')) + expect(str.indexOf('group_value')).toBeLessThan(str.indexOf('title')) + expect(str.indexOf('group_value')).toBeLessThan(str.indexOf('group_array')) + expect(str.indexOf('title')).toBeLessThan(str.indexOf('createdAt')) + expect(str.indexOf('createdAt')).toBeLessThan(str.indexOf('updatedAt')) + }) + it('should create a file for collection csv from array.subfield', async () => { let doc = await payload.create({ collection: 'exports', From 564fdb0e179d6db11d693ba1ec0fb58a6fe89bbf Mon Sep 17 00:00:00 2001 From: Sasha <64744993+r1tsuu@users.noreply.github.com> Date: Wed, 30 Apr 2025 19:27:04 +0300 Subject: [PATCH 17/34] fix: virtual relationship fields with `select` (#12266) Continuation of https://github.com/payloadcms/payload/pull/12265. Currently, using `select` on new relationship virtual fields: ``` const doc = await payload.findByID({ collection: 'virtual-relations', depth: 0, id, select: { postTitle: true }, }) ``` doesn't work, because in order to calculate `post.title`, the `post` field must be selected as well. This PR adds logic that sanitizes the incoming `select` to include those relationships into `select` (that are related to selected virtual fields) --------- Co-authored-by: Dan Ribbens --- .../src/collections/operations/create.ts | 1 + .../src/collections/operations/delete.ts | 1 + .../src/collections/operations/deleteByID.ts | 1 + .../src/collections/operations/find.ts | 1 + .../src/collections/operations/findByID.ts | 1 + .../collections/operations/findVersionByID.ts | 3 + .../collections/operations/findVersions.ts | 2 + .../collections/operations/restoreVersion.ts | 1 + .../src/collections/operations/update.ts | 1 + .../src/collections/operations/updateByID.ts | 1 + .../payload/src/globals/operations/findOne.ts | 1 + .../src/globals/operations/findVersionByID.ts | 4 + .../src/globals/operations/findVersions.ts | 2 + .../payload/src/globals/operations/update.ts | 1 + .../payload/src/utilities/sanitizeSelect.ts | 149 +++++++++++++++++- test/database/int.spec.ts | 37 +++++ .../plugin-import-export/collections/Pages.ts | 13 ++ .../plugin-import-export/collections/Users.ts | 4 + test/plugin-import-export/int.spec.ts | 29 ++++ test/plugin-import-export/payload-types.ts | 6 + test/plugin-import-export/seed/index.ts | 13 +- 21 files changed, 268 insertions(+), 4 deletions(-) diff --git a/packages/payload/src/collections/operations/create.ts b/packages/payload/src/collections/operations/create.ts index 908d138a94..9f3e7f5028 100644 --- a/packages/payload/src/collections/operations/create.ts +++ b/packages/payload/src/collections/operations/create.ts @@ -247,6 +247,7 @@ export const createOperation = async < let doc const select = sanitizeSelect({ + fields: collectionConfig.flattenedFields, forceSelect: collectionConfig.forceSelect, select: incomingSelect, }) diff --git a/packages/payload/src/collections/operations/delete.ts b/packages/payload/src/collections/operations/delete.ts index 824c5a7af6..68a9fecbc3 100644 --- a/packages/payload/src/collections/operations/delete.ts +++ b/packages/payload/src/collections/operations/delete.ts @@ -110,6 +110,7 @@ export const deleteOperation = async < const fullWhere = combineQueries(where, accessResult) const select = sanitizeSelect({ + fields: collectionConfig.flattenedFields, forceSelect: collectionConfig.forceSelect, select: incomingSelect, }) diff --git a/packages/payload/src/collections/operations/deleteByID.ts b/packages/payload/src/collections/operations/deleteByID.ts index 6225700e42..add2bd8445 100644 --- a/packages/payload/src/collections/operations/deleteByID.ts +++ b/packages/payload/src/collections/operations/deleteByID.ts @@ -168,6 +168,7 @@ export const deleteByIDOperation = async ( // ///////////////////////////////////// const select = sanitizeSelect({ + fields: buildVersionCollectionFields(payload.config, collectionConfig, true), forceSelect: getQueryDraftsSelect({ select: collectionConfig.forceSelect }), select: incomingSelect, + versions: true, }) const versionsQuery = await payload.db.findVersions({ diff --git a/packages/payload/src/collections/operations/findVersions.ts b/packages/payload/src/collections/operations/findVersions.ts index fb1e59cdf3..029af417f1 100644 --- a/packages/payload/src/collections/operations/findVersions.ts +++ b/packages/payload/src/collections/operations/findVersions.ts @@ -72,8 +72,10 @@ export const findVersionsOperation = async const fullWhere = combineQueries(where, accessResults) const select = sanitizeSelect({ + fields: buildVersionCollectionFields(payload.config, collectionConfig, true), forceSelect: getQueryDraftsSelect({ select: collectionConfig.forceSelect }), select: incomingSelect, + versions: true, }) // ///////////////////////////////////// diff --git a/packages/payload/src/collections/operations/restoreVersion.ts b/packages/payload/src/collections/operations/restoreVersion.ts index f43e56e943..b671e30300 100644 --- a/packages/payload/src/collections/operations/restoreVersion.ts +++ b/packages/payload/src/collections/operations/restoreVersion.ts @@ -117,6 +117,7 @@ export const restoreVersionOperation = async ( // ///////////////////////////////////// const select = sanitizeSelect({ + fields: collectionConfig.flattenedFields, forceSelect: collectionConfig.forceSelect, select: incomingSelect, }) diff --git a/packages/payload/src/collections/operations/update.ts b/packages/payload/src/collections/operations/update.ts index ab2e2308fa..31e412e436 100644 --- a/packages/payload/src/collections/operations/update.ts +++ b/packages/payload/src/collections/operations/update.ts @@ -201,6 +201,7 @@ export const updateOperation = async < try { const select = sanitizeSelect({ + fields: collectionConfig.flattenedFields, forceSelect: collectionConfig.forceSelect, select: incomingSelect, }) diff --git a/packages/payload/src/collections/operations/updateByID.ts b/packages/payload/src/collections/operations/updateByID.ts index a340ea307f..c80487686c 100644 --- a/packages/payload/src/collections/operations/updateByID.ts +++ b/packages/payload/src/collections/operations/updateByID.ts @@ -161,6 +161,7 @@ export const updateByIDOperation = async < }) const select = sanitizeSelect({ + fields: collectionConfig.flattenedFields, forceSelect: collectionConfig.forceSelect, select: incomingSelect, }) diff --git a/packages/payload/src/globals/operations/findOne.ts b/packages/payload/src/globals/operations/findOne.ts index b076fc5ae3..f341cc019d 100644 --- a/packages/payload/src/globals/operations/findOne.ts +++ b/packages/payload/src/globals/operations/findOne.ts @@ -53,6 +53,7 @@ export const findOneOperation = async >( } const select = sanitizeSelect({ + fields: globalConfig.flattenedFields, forceSelect: globalConfig.forceSelect, select: incomingSelect, }) diff --git a/packages/payload/src/globals/operations/findVersionByID.ts b/packages/payload/src/globals/operations/findVersionByID.ts index 06a00c12e6..37b457551a 100644 --- a/packages/payload/src/globals/operations/findVersionByID.ts +++ b/packages/payload/src/globals/operations/findVersionByID.ts @@ -11,6 +11,8 @@ import { afterRead } from '../../fields/hooks/afterRead/index.js' import { deepCopyObjectSimple } from '../../utilities/deepCopyObject.js' import { killTransaction } from '../../utilities/killTransaction.js' import { sanitizeSelect } from '../../utilities/sanitizeSelect.js' +import { buildVersionCollectionFields } from '../../versions/buildCollectionFields.js' +import { buildVersionGlobalFields } from '../../versions/buildGlobalFields.js' import { getQueryDraftsSelect } from '../../versions/drafts/getQueryDraftsSelect.js' export type Arguments = { @@ -60,8 +62,10 @@ export const findVersionByIDOperation = async = an const hasWhereAccess = typeof accessResults === 'object' const select = sanitizeSelect({ + fields: buildVersionGlobalFields(payload.config, globalConfig, true), forceSelect: getQueryDraftsSelect({ select: globalConfig.forceSelect }), select: incomingSelect, + versions: true, }) const findGlobalVersionsArgs: FindGlobalVersionsArgs = { diff --git a/packages/payload/src/globals/operations/findVersions.ts b/packages/payload/src/globals/operations/findVersions.ts index 2f59b44097..57bcbf7099 100644 --- a/packages/payload/src/globals/operations/findVersions.ts +++ b/packages/payload/src/globals/operations/findVersions.ts @@ -70,8 +70,10 @@ export const findVersionsOperation = async >( const fullWhere = combineQueries(where, accessResults) const select = sanitizeSelect({ + fields: buildVersionGlobalFields(payload.config, globalConfig, true), forceSelect: getQueryDraftsSelect({ select: globalConfig.forceSelect }), select: incomingSelect, + versions: true, }) // ///////////////////////////////////// diff --git a/packages/payload/src/globals/operations/update.ts b/packages/payload/src/globals/operations/update.ts index 859a04342f..c1f32a7b1d 100644 --- a/packages/payload/src/globals/operations/update.ts +++ b/packages/payload/src/globals/operations/update.ts @@ -246,6 +246,7 @@ export const updateOperation = async < // ///////////////////////////////////// const select = sanitizeSelect({ + fields: globalConfig.flattenedFields, forceSelect: globalConfig.forceSelect, select: incomingSelect, }) diff --git a/packages/payload/src/utilities/sanitizeSelect.ts b/packages/payload/src/utilities/sanitizeSelect.ts index 9d18bcbb51..3e7bf9d56c 100644 --- a/packages/payload/src/utilities/sanitizeSelect.ts +++ b/packages/payload/src/utilities/sanitizeSelect.ts @@ -1,17 +1,129 @@ import { deepMergeSimple } from '@payloadcms/translations/utilities' -import type { SelectType } from '../types/index.js' +import type { FlattenedField } from '../fields/config/types.js' +import type { SelectIncludeType, SelectType } from '../types/index.js' import { getSelectMode } from './getSelectMode.js' +// Transform post.title -> post, post.category.title -> post +const stripVirtualPathToCurrentCollection = ({ + fields, + path, + versions, +}: { + fields: FlattenedField[] + path: string + versions: boolean +}) => { + const resultSegments: string[] = [] + + if (versions) { + resultSegments.push('version') + const versionField = fields.find((each) => each.name === 'version') + + if (versionField && versionField.type === 'group') { + fields = versionField.flattenedFields + } + } + + for (const segment of path.split('.')) { + const field = fields.find((each) => each.name === segment) + + if (!field) { + continue + } + + resultSegments.push(segment) + + if (field.type === 'relationship' || field.type === 'upload') { + return resultSegments.join('.') + } + } + + return resultSegments.join('.') +} + +const getAllVirtualRelations = ({ fields }: { fields: FlattenedField[] }) => { + const result: string[] = [] + + for (const field of fields) { + if ('virtual' in field && typeof field.virtual === 'string') { + result.push(field.virtual) + } else if (field.type === 'group' || field.type === 'tab') { + const nestedResult = getAllVirtualRelations({ fields: field.flattenedFields }) + + for (const nestedItem of nestedResult) { + result.push(nestedItem) + } + } + } + + return result +} + +const resolveVirtualRelationsToSelect = ({ + fields, + selectValue, + topLevelFields, + versions, +}: { + fields: FlattenedField[] + selectValue: SelectIncludeType | true + topLevelFields: FlattenedField[] + versions: boolean +}) => { + const result: string[] = [] + if (selectValue === true) { + for (const item of getAllVirtualRelations({ fields })) { + result.push( + stripVirtualPathToCurrentCollection({ fields: topLevelFields, path: item, versions }), + ) + } + } else { + for (const fieldName in selectValue) { + const field = fields.find((each) => each.name === fieldName) + if (!field) { + continue + } + + if ('virtual' in field && typeof field.virtual === 'string') { + result.push( + stripVirtualPathToCurrentCollection({ + fields: topLevelFields, + path: field.virtual, + versions, + }), + ) + } else if (field.type === 'group' || field.type === 'tab') { + for (const item of resolveVirtualRelationsToSelect({ + fields: field.flattenedFields, + selectValue: selectValue[fieldName], + topLevelFields, + versions, + })) { + result.push( + stripVirtualPathToCurrentCollection({ fields: topLevelFields, path: item, versions }), + ) + } + } + } + } + + return result +} + export const sanitizeSelect = ({ + fields, forceSelect, select, + versions, }: { + fields: FlattenedField[] forceSelect?: SelectType select?: SelectType + versions?: boolean }): SelectType | undefined => { - if (!forceSelect || !select) { + if (!select) { return select } @@ -21,5 +133,36 @@ export const sanitizeSelect = ({ return select } - return deepMergeSimple(select, forceSelect) + if (forceSelect) { + select = deepMergeSimple(select, forceSelect) + } + + if (select) { + const virtualRelations = resolveVirtualRelationsToSelect({ + fields, + selectValue: select as SelectIncludeType, + topLevelFields: fields, + versions: versions ?? false, + }) + + for (const path of virtualRelations) { + let currentRef = select + const segments = path.split('.') + for (let i = 0; i < segments.length; i++) { + const isLast = segments.length - 1 === i + const segment = segments[i] + + if (isLast) { + currentRef[segment] = true + } else { + if (!(segment in currentRef)) { + currentRef[segment] = {} + currentRef = currentRef[segment] + } + } + } + } + } + + return select } diff --git a/test/database/int.spec.ts b/test/database/int.spec.ts index ad45a88f40..f6ff086f6a 100644 --- a/test/database/int.spec.ts +++ b/test/database/int.spec.ts @@ -1997,6 +1997,23 @@ describe('database', () => { expect(draft.docs[0]?.postTitle).toBe('my-title') }) + it('should not break when using select', async () => { + const post = await payload.create({ collection: 'posts', data: { title: 'my-title-10' } }) + const { id } = await payload.create({ + collection: 'virtual-relations', + depth: 0, + data: { post: post.id }, + }) + + const doc = await payload.findByID({ + collection: 'virtual-relations', + depth: 0, + id, + select: { postTitle: true }, + }) + expect(doc.postTitle).toBe('my-title-10') + }) + it('should allow virtual field as reference to ID', async () => { const post = await payload.create({ collection: 'posts', data: { title: 'my-title' } }) const { id } = await payload.create({ @@ -2129,6 +2146,26 @@ describe('database', () => { expect(doc.postCategoryTitle).toBe('1-category') }) + it('should not break when using select 2x deep', async () => { + const category = await payload.create({ + collection: 'categories', + data: { title: '3-category' }, + }) + const post = await payload.create({ + collection: 'posts', + data: { title: '3-post', category: category.id }, + }) + const doc = await payload.create({ collection: 'virtual-relations', data: { post: post.id } }) + + const docWithSelect = await payload.findByID({ + collection: 'virtual-relations', + depth: 0, + id: doc.id, + select: { postCategoryTitle: true }, + }) + expect(docWithSelect.postCategoryTitle).toBe('3-category') + }) + it('should allow to query by virtual field 2x deep', async () => { const category = await payload.create({ collection: 'categories', diff --git a/test/plugin-import-export/collections/Pages.ts b/test/plugin-import-export/collections/Pages.ts index 3f3aa112d5..0c4865dbec 100644 --- a/test/plugin-import-export/collections/Pages.ts +++ b/test/plugin-import-export/collections/Pages.ts @@ -98,6 +98,19 @@ export const Pages: CollectionConfig = { type: 'relationship', relationTo: 'users', }, + { + name: 'virtualRelationship', + type: 'text', + virtual: 'author.name', + }, + { + name: 'virtual', + type: 'text', + virtual: true, + hooks: { + afterRead: [() => 'virtual value'], + }, + }, { name: 'hasManyNumber', type: 'number', diff --git a/test/plugin-import-export/collections/Users.ts b/test/plugin-import-export/collections/Users.ts index b29c9debff..d50cca6cd6 100644 --- a/test/plugin-import-export/collections/Users.ts +++ b/test/plugin-import-export/collections/Users.ts @@ -10,6 +10,10 @@ export const Users: CollectionConfig = { read: () => true, }, fields: [ + { + name: 'name', + type: 'text', + }, // Email added by default // Add more fields as needed ], diff --git a/test/plugin-import-export/int.spec.ts b/test/plugin-import-export/int.spec.ts index b0f27f80d1..c61ee38831 100644 --- a/test/plugin-import-export/int.spec.ts +++ b/test/plugin-import-export/int.spec.ts @@ -255,6 +255,35 @@ describe('@payloadcms/plugin-import-export', () => { expect(str.indexOf('createdAt')).toBeLessThan(str.indexOf('updatedAt')) }) + it('should create a CSV file with virtual fields', async () => { + const fields = ['id', 'virtual', 'virtualRelationship'] + const doc = await payload.create({ + collection: 'exports', + user, + data: { + collectionSlug: 'pages', + fields, + format: 'csv', + where: { + title: { contains: 'Virtual ' }, + }, + }, + }) + + const exportDoc = await payload.findByID({ + collection: 'exports', + id: doc.id, + }) + + expect(exportDoc.filename).toBeDefined() + const expectedPath = path.join(dirname, './uploads', exportDoc.filename as string) + const data = await readCSV(expectedPath) + + // Assert that the csv file contains the expected virtual fields + expect(data[0].virtual).toStrictEqual('virtual value') + expect(data[0].virtualRelationship).toStrictEqual('name value') + }) + it('should create a file for collection csv from array.subfield', async () => { let doc = await payload.create({ collection: 'exports', diff --git a/test/plugin-import-export/payload-types.ts b/test/plugin-import-export/payload-types.ts index 740160e7db..fbfe1504cc 100644 --- a/test/plugin-import-export/payload-types.ts +++ b/test/plugin-import-export/payload-types.ts @@ -131,6 +131,7 @@ export interface UserAuthOperations { */ export interface User { id: string; + name?: string | null; updatedAt: string; createdAt: string; email: string; @@ -199,6 +200,8 @@ export interface Page { )[] | null; author?: (string | null) | User; + virtualRelationship?: string | null; + virtual?: string | null; hasManyNumber?: number[] | null; relationship?: (string | null) | User; excerpt?: string | null; @@ -444,6 +447,7 @@ export interface PayloadMigration { * via the `definition` "users_select". */ export interface UsersSelect { + name?: T; updatedAt?: T; createdAt?: T; email?: T; @@ -500,6 +504,8 @@ export interface PagesSelect { }; }; author?: T; + virtualRelationship?: T; + virtual?: T; hasManyNumber?: T; relationship?: T; excerpt?: T; diff --git a/test/plugin-import-export/seed/index.ts b/test/plugin-import-export/seed/index.ts index bbc8dd3222..652af40a0f 100644 --- a/test/plugin-import-export/seed/index.ts +++ b/test/plugin-import-export/seed/index.ts @@ -6,11 +6,12 @@ import { richTextData } from './richTextData.js' export const seed = async (payload: Payload): Promise => { payload.logger.info('Seeding data...') try { - await payload.create({ + const user = await payload.create({ collection: 'users', data: { email: devUser.email, password: devUser.password, + name: 'name value', }, }) // create pages @@ -80,6 +81,16 @@ export const seed = async (payload: Payload): Promise => { }) } + for (let i = 0; i < 5; i++) { + await payload.create({ + collection: 'pages', + data: { + author: user.id, + title: `Virtual ${i}`, + }, + }) + } + for (let i = 0; i < 5; i++) { await payload.create({ collection: 'pages', From 27d644f2f914216332d58b6403c7454f1bf08f67 Mon Sep 17 00:00:00 2001 From: Sasha <64744993+r1tsuu@users.noreply.github.com> Date: Wed, 30 Apr 2025 19:31:04 +0300 Subject: [PATCH 18/34] perf(db-postgres): skip pagination overhead if `limit: 0` is passed (#12261) This improves performance when querying data in Postgers / SQLite with `limit: 0`. Before, unless you additionally passed `pagination: false` we executed additional count query to calculate the pagination. Now we skip this as this is unnecessary since we can retrieve the count just from `rows.length`. This logic already existed in `db-mongodb` - https://github.com/payloadcms/payload/blob/1b17df9e0ba43978c52e3f05fdab44c97bd29896/packages/db-mongodb/src/find.ts#L114-L124 --- packages/drizzle/src/find/findMany.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/drizzle/src/find/findMany.ts b/packages/drizzle/src/find/findMany.ts index 2c0304617d..6b429f38ca 100644 --- a/packages/drizzle/src/find/findMany.ts +++ b/packages/drizzle/src/find/findMany.ts @@ -46,6 +46,7 @@ export const findMany = async function find({ const offset = skip || (page - 1) * limit if (limit === 0) { + pagination = false limit = undefined } From 4a56597b92702b144c1ea2b8a836a883c9404f8d Mon Sep 17 00:00:00 2001 From: Sasha <64744993+r1tsuu@users.noreply.github.com> Date: Wed, 30 Apr 2025 19:53:36 +0300 Subject: [PATCH 19/34] fix(db-postgres): `count` crashes when query contains subqueries and doesn't return any rows (#12273) Fixes https://github.com/payloadcms/payload/issues/12264 Uses safe object access in `countDistinct`, fallbacks to `0` --- packages/db-sqlite/src/countDistinct.ts | 4 +-- .../drizzle/src/postgres/countDistinct.ts | 5 +-- test/database/int.spec.ts | 33 +++++++++++++++++++ 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/packages/db-sqlite/src/countDistinct.ts b/packages/db-sqlite/src/countDistinct.ts index de0af7a995..ae729138f0 100644 --- a/packages/db-sqlite/src/countDistinct.ts +++ b/packages/db-sqlite/src/countDistinct.ts @@ -16,7 +16,7 @@ export const countDistinct: CountDistinct = async function countDistinct( }) .from(this.tables[tableName]) .where(where) - return Number(countResult[0]?.count) + return Number(countResult?.[0]?.count ?? 0) } let query: SQLiteSelect = db @@ -39,5 +39,5 @@ export const countDistinct: CountDistinct = async function countDistinct( // Instead, COUNT (GROUP BY id) can be used which is still slower than COUNT(*) but acceptable. const countResult = await query - return Number(countResult[0]?.count) + return Number(countResult?.[0]?.count ?? 0) } diff --git a/packages/drizzle/src/postgres/countDistinct.ts b/packages/drizzle/src/postgres/countDistinct.ts index 16c2f576c9..04d7559fcf 100644 --- a/packages/drizzle/src/postgres/countDistinct.ts +++ b/packages/drizzle/src/postgres/countDistinct.ts @@ -16,7 +16,8 @@ export const countDistinct: CountDistinct = async function countDistinct( }) .from(this.tables[tableName]) .where(where) - return Number(countResult[0].count) + + return Number(countResult?.[0]?.count ?? 0) } let query = db @@ -39,5 +40,5 @@ export const countDistinct: CountDistinct = async function countDistinct( // Instead, COUNT (GROUP BY id) can be used which is still slower than COUNT(*) but acceptable. const countResult = await query - return Number(countResult[0].count) + return Number(countResult?.[0]?.count ?? 0) } diff --git a/test/database/int.spec.ts b/test/database/int.spec.ts index f6ff086f6a..efad1d825d 100644 --- a/test/database/int.spec.ts +++ b/test/database/int.spec.ts @@ -2451,4 +2451,37 @@ describe('database', () => { expect(res.docs[0].id).toBe(customID.id) }) + + it('should count with a query that contains subqueries', async () => { + const category = await payload.create({ + collection: 'categories', + data: { title: 'new-category' }, + }) + const post = await payload.create({ + collection: 'posts', + data: { title: 'new-post', category: category.id }, + }) + + const result_1 = await payload.count({ + collection: 'posts', + where: { + 'category.title': { + equals: 'new-category', + }, + }, + }) + + expect(result_1.totalDocs).toBe(1) + + const result_2 = await payload.count({ + collection: 'posts', + where: { + 'category.title': { + equals: 'non-existing-category', + }, + }, + }) + + expect(result_2.totalDocs).toBe(0) + }) }) From 710fe0949becbcfee88664f4be872aac85e9d757 Mon Sep 17 00:00:00 2001 From: Sasha <64744993+r1tsuu@users.noreply.github.com> Date: Wed, 30 Apr 2025 20:28:13 +0300 Subject: [PATCH 20/34] fix: duplicate with orderable (#12274) Previously, duplication with orderable collections worked incorrectly, for example Document 1 is created - `_order: 'a5'` Document 2 is duplicated from 1, - `_order: 'a5 - copy'` (result from https://github.com/payloadcms/payload/blob/47a1eee765e554db29a9370927bf90f0fb4947f2/packages/payload/src/fields/setDefaultBeforeDuplicate.ts#L6) Now, the `_order` value is re-calculated properly. --- .../payload/src/config/orderable/index.ts | 8 ++++ test/sort/int.spec.ts | 37 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/packages/payload/src/config/orderable/index.ts b/packages/payload/src/config/orderable/index.ts index c09de82a12..26b22f7cd9 100644 --- a/packages/payload/src/config/orderable/index.ts +++ b/packages/payload/src/config/orderable/index.ts @@ -83,6 +83,13 @@ export const addOrderableFieldsAndHook = ( hidden: true, readOnly: true, }, + hooks: { + beforeDuplicate: [ + ({ siblingData }) => { + delete siblingData[orderableFieldName] + }, + ], + }, index: true, required: true, // override the schema to make order fields optional for payload.create() @@ -275,5 +282,6 @@ export const addOrderableEndpoint = (config: SanitizedConfig) => { if (!config.endpoints) { config.endpoints = [] } + config.endpoints.push(reorderEndpoint) } diff --git a/test/sort/int.spec.ts b/test/sort/int.spec.ts index 0fea47ee92..7064181cac 100644 --- a/test/sort/int.spec.ts +++ b/test/sort/int.spec.ts @@ -444,6 +444,43 @@ describe('Sort', () => { parseInt(ordered.docs[1]._order, 16), ) }) + + it('should allow to duplicate with reordable', async () => { + const doc = await payload.create({ + collection: 'orderable', + data: { title: 'new document' }, + }) + + const docDuplicated = await payload.create({ + duplicateFromID: doc.id, + collection: 'orderable', + data: {}, + }) + expect(docDuplicated.title).toBe('new document') + expect(parseInt(doc._order!, 16)).toBeLessThan(parseInt(docDuplicated._order!, 16)) + + await restClient.POST('/reorder', { + body: JSON.stringify({ + collectionSlug: orderableSlug, + docsToMove: [doc.id], + newKeyWillBe: 'greater', + orderableFieldName: '_order', + target: { + id: docDuplicated.id, + key: docDuplicated._order, + }, + }), + }) + + const docAfterReorder = await payload.findByID({ collection: 'orderable', id: doc.id }) + const docDuplicatedAfterReorder = await payload.findByID({ + collection: 'orderable', + id: docDuplicated.id, + }) + expect(parseInt(docAfterReorder._order!, 16)).toBeGreaterThan( + parseInt(docDuplicatedAfterReorder._order!, 16), + ) + }) }) describe('Orderable join', () => { From 6133a1d1838fe0d5f15608fad0e76aca3c405f68 Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Wed, 30 Apr 2025 11:26:28 -0700 Subject: [PATCH 21/34] perf: optimize file access promises (#12275) Improves performance in local strategy uploads by reading the file and metadata info synchronously. This change uses `promise.all` for three separately awaited calls. This improves the perf by making all calls in a non-blocking way. --- .../payload/src/uploads/generateFileData.ts | 6 +++- packages/payload/src/uploads/getFileByPath.ts | 36 +++++++++---------- 2 files changed, 23 insertions(+), 19 deletions(-) diff --git a/packages/payload/src/uploads/generateFileData.ts b/packages/payload/src/uploads/generateFileData.ts index 860be72713..bf5c86d7f0 100644 --- a/packages/payload/src/uploads/generateFileData.ts +++ b/packages/payload/src/uploads/generateFileData.ts @@ -10,7 +10,7 @@ import type { SanitizedConfig } from '../config/types.js' import type { PayloadRequest } from '../types/index.js' import type { FileData, FileToSave, ProbedImageSize, UploadEdits } from './types.js' -import { FileRetrievalError, FileUploadError, MissingFile } from '../errors/index.js' +import { FileRetrievalError, FileUploadError, Forbidden, MissingFile } from '../errors/index.js' import { canResizeImage } from './canResizeImage.js' import { cropImage } from './cropImage.js' import { getExternalFile } from './getExternalFile.js' @@ -85,6 +85,10 @@ export const generateFileData = async ({ if (!file && uploadEdits && incomingFileData) { const { filename, url } = incomingFileData as FileData + if (filename && (filename.includes('../') || filename.includes('..\\'))) { + throw new Forbidden(req.t) + } + try { if (url && url.startsWith('/') && !disableLocalStorage) { const filePath = `${staticPath}/${filename}` diff --git a/packages/payload/src/uploads/getFileByPath.ts b/packages/payload/src/uploads/getFileByPath.ts index 53ce791196..2f0a1f1526 100644 --- a/packages/payload/src/uploads/getFileByPath.ts +++ b/packages/payload/src/uploads/getFileByPath.ts @@ -5,28 +5,28 @@ import path from 'path' import type { PayloadRequest } from '../types/index.js' -const mimeTypeEstimate = { +const mimeTypeEstimate: Record = { svg: 'image/svg+xml', } export const getFileByPath = async (filePath: string): Promise => { - if (typeof filePath === 'string') { - const data = await fs.readFile(filePath) - const mimetype = fileTypeFromFile(filePath) - const { size } = await fs.stat(filePath) - - const name = path.basename(filePath) - const ext = path.extname(filePath).slice(1) - - const mime = (await mimetype)?.mime || mimeTypeEstimate[ext] - - return { - name, - data, - mimetype: mime, - size, - } + if (typeof filePath !== 'string') { + return undefined } - return undefined + const name = path.basename(filePath) + const ext = path.extname(filePath).slice(1) + + const [data, stat, type] = await Promise.all([ + fs.readFile(filePath), + fs.stat(filePath), + fileTypeFromFile(filePath), + ]) + + return { + name, + data, + mimetype: type?.mime || mimeTypeEstimate[ext], + size: stat.size, + } } From cfe8c97ab782e33a4f2d53566fbbe5e853ce0ed2 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Wed, 30 Apr 2025 14:52:46 -0400 Subject: [PATCH 22/34] chore(release): v3.36.1 [skip ci] --- package.json | 2 +- packages/admin-bar/package.json | 2 +- packages/create-payload-app/package.json | 2 +- packages/db-mongodb/package.json | 2 +- packages/db-postgres/package.json | 2 +- packages/db-sqlite/package.json | 2 +- packages/db-vercel-postgres/package.json | 2 +- packages/drizzle/package.json | 2 +- packages/email-nodemailer/package.json | 2 +- packages/email-resend/package.json | 2 +- packages/graphql/package.json | 2 +- packages/live-preview-react/package.json | 2 +- packages/live-preview-vue/package.json | 2 +- packages/live-preview/package.json | 2 +- packages/next/package.json | 2 +- packages/payload-cloud/package.json | 2 +- packages/payload/package.json | 2 +- packages/plugin-cloud-storage/package.json | 2 +- packages/plugin-form-builder/package.json | 2 +- packages/plugin-import-export/package.json | 2 +- packages/plugin-multi-tenant/package.json | 2 +- packages/plugin-nested-docs/package.json | 2 +- packages/plugin-redirects/package.json | 2 +- packages/plugin-search/package.json | 2 +- packages/plugin-sentry/package.json | 2 +- packages/plugin-seo/package.json | 2 +- packages/plugin-stripe/package.json | 2 +- packages/richtext-lexical/package.json | 2 +- packages/richtext-slate/package.json | 2 +- packages/storage-azure/package.json | 2 +- packages/storage-gcs/package.json | 2 +- packages/storage-s3/package.json | 2 +- packages/storage-uploadthing/package.json | 2 +- packages/storage-vercel-blob/package.json | 2 +- packages/translations/package.json | 2 +- packages/ui/package.json | 2 +- 36 files changed, 36 insertions(+), 36 deletions(-) diff --git a/package.json b/package.json index 7895f6be1c..a25e17adc3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "payload-monorepo", - "version": "3.36.0", + "version": "3.36.1", "private": true, "type": "module", "scripts": { diff --git a/packages/admin-bar/package.json b/packages/admin-bar/package.json index 15de281654..c4a3947edf 100644 --- a/packages/admin-bar/package.json +++ b/packages/admin-bar/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/admin-bar", - "version": "3.36.0", + "version": "3.36.1", "description": "An admin bar for React apps using Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/create-payload-app/package.json b/packages/create-payload-app/package.json index 04eff67ead..cec49d40be 100644 --- a/packages/create-payload-app/package.json +++ b/packages/create-payload-app/package.json @@ -1,6 +1,6 @@ { "name": "create-payload-app", - "version": "3.36.0", + "version": "3.36.1", "homepage": "https://payloadcms.com", "repository": { "type": "git", diff --git a/packages/db-mongodb/package.json b/packages/db-mongodb/package.json index 5327c2bf3b..bba9c0dd54 100644 --- a/packages/db-mongodb/package.json +++ b/packages/db-mongodb/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/db-mongodb", - "version": "3.36.0", + "version": "3.36.1", "description": "The officially supported MongoDB database adapter for Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/db-postgres/package.json b/packages/db-postgres/package.json index e8b0f3a6c5..51a933ad4c 100644 --- a/packages/db-postgres/package.json +++ b/packages/db-postgres/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/db-postgres", - "version": "3.36.0", + "version": "3.36.1", "description": "The officially supported Postgres database adapter for Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/db-sqlite/package.json b/packages/db-sqlite/package.json index b286230331..87db6a8f0e 100644 --- a/packages/db-sqlite/package.json +++ b/packages/db-sqlite/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/db-sqlite", - "version": "3.36.0", + "version": "3.36.1", "description": "The officially supported SQLite database adapter for Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/db-vercel-postgres/package.json b/packages/db-vercel-postgres/package.json index be9ff4df75..1268e2c95c 100644 --- a/packages/db-vercel-postgres/package.json +++ b/packages/db-vercel-postgres/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/db-vercel-postgres", - "version": "3.36.0", + "version": "3.36.1", "description": "Vercel Postgres adapter for Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/drizzle/package.json b/packages/drizzle/package.json index 54e97c46e1..254394b989 100644 --- a/packages/drizzle/package.json +++ b/packages/drizzle/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/drizzle", - "version": "3.36.0", + "version": "3.36.1", "description": "A library of shared functions used by different payload database adapters", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/email-nodemailer/package.json b/packages/email-nodemailer/package.json index ba8be1c41c..0d36ea0b00 100644 --- a/packages/email-nodemailer/package.json +++ b/packages/email-nodemailer/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/email-nodemailer", - "version": "3.36.0", + "version": "3.36.1", "description": "Payload Nodemailer Email Adapter", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/email-resend/package.json b/packages/email-resend/package.json index b4bf1caa1a..c986fa70d4 100644 --- a/packages/email-resend/package.json +++ b/packages/email-resend/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/email-resend", - "version": "3.36.0", + "version": "3.36.1", "description": "Payload Resend Email Adapter", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/graphql/package.json b/packages/graphql/package.json index c2c7cd6d9c..10f27f1383 100644 --- a/packages/graphql/package.json +++ b/packages/graphql/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/graphql", - "version": "3.36.0", + "version": "3.36.1", "homepage": "https://payloadcms.com", "repository": { "type": "git", diff --git a/packages/live-preview-react/package.json b/packages/live-preview-react/package.json index 3ef930daaa..70fb80fcb7 100644 --- a/packages/live-preview-react/package.json +++ b/packages/live-preview-react/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/live-preview-react", - "version": "3.36.0", + "version": "3.36.1", "description": "The official React SDK for Payload Live Preview", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/live-preview-vue/package.json b/packages/live-preview-vue/package.json index 15e02daa1d..219c04d332 100644 --- a/packages/live-preview-vue/package.json +++ b/packages/live-preview-vue/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/live-preview-vue", - "version": "3.36.0", + "version": "3.36.1", "description": "The official Vue SDK for Payload Live Preview", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/live-preview/package.json b/packages/live-preview/package.json index fc8a95caa7..8caac4e6aa 100644 --- a/packages/live-preview/package.json +++ b/packages/live-preview/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/live-preview", - "version": "3.36.0", + "version": "3.36.1", "description": "The official live preview JavaScript SDK for Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/next/package.json b/packages/next/package.json index 879c2f8674..9102fc40ca 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/next", - "version": "3.36.0", + "version": "3.36.1", "homepage": "https://payloadcms.com", "repository": { "type": "git", diff --git a/packages/payload-cloud/package.json b/packages/payload-cloud/package.json index e74897aed0..9e7ce8217e 100644 --- a/packages/payload-cloud/package.json +++ b/packages/payload-cloud/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/payload-cloud", - "version": "3.36.0", + "version": "3.36.1", "description": "The official Payload Cloud plugin", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/payload/package.json b/packages/payload/package.json index 0885f3c983..0b62938b55 100644 --- a/packages/payload/package.json +++ b/packages/payload/package.json @@ -1,6 +1,6 @@ { "name": "payload", - "version": "3.36.0", + "version": "3.36.1", "description": "Node, React, Headless CMS and Application Framework built on Next.js", "keywords": [ "admin panel", diff --git a/packages/plugin-cloud-storage/package.json b/packages/plugin-cloud-storage/package.json index 326d5d271d..8b5d9020f9 100644 --- a/packages/plugin-cloud-storage/package.json +++ b/packages/plugin-cloud-storage/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-cloud-storage", - "version": "3.36.0", + "version": "3.36.1", "description": "The official cloud storage plugin for Payload CMS", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/plugin-form-builder/package.json b/packages/plugin-form-builder/package.json index 5dea0f2bcc..bf5de206ac 100644 --- a/packages/plugin-form-builder/package.json +++ b/packages/plugin-form-builder/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-form-builder", - "version": "3.36.0", + "version": "3.36.1", "description": "Form builder plugin for Payload CMS", "keywords": [ "payload", diff --git a/packages/plugin-import-export/package.json b/packages/plugin-import-export/package.json index 93418181d2..0317874ffc 100644 --- a/packages/plugin-import-export/package.json +++ b/packages/plugin-import-export/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-import-export", - "version": "3.36.0", + "version": "3.36.1", "description": "Import-Export plugin for Payload", "keywords": [ "payload", diff --git a/packages/plugin-multi-tenant/package.json b/packages/plugin-multi-tenant/package.json index edba2c559a..077b46d5b1 100644 --- a/packages/plugin-multi-tenant/package.json +++ b/packages/plugin-multi-tenant/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-multi-tenant", - "version": "3.36.0", + "version": "3.36.1", "description": "Multi Tenant plugin for Payload", "keywords": [ "payload", diff --git a/packages/plugin-nested-docs/package.json b/packages/plugin-nested-docs/package.json index 883a72b2a6..daf8954e84 100644 --- a/packages/plugin-nested-docs/package.json +++ b/packages/plugin-nested-docs/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-nested-docs", - "version": "3.36.0", + "version": "3.36.1", "description": "The official Nested Docs plugin for Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/plugin-redirects/package.json b/packages/plugin-redirects/package.json index 4d309d3d62..2aa329ad26 100644 --- a/packages/plugin-redirects/package.json +++ b/packages/plugin-redirects/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-redirects", - "version": "3.36.0", + "version": "3.36.1", "description": "Redirects plugin for Payload", "keywords": [ "payload", diff --git a/packages/plugin-search/package.json b/packages/plugin-search/package.json index 6e3ee3aef7..430bef0eb2 100644 --- a/packages/plugin-search/package.json +++ b/packages/plugin-search/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-search", - "version": "3.36.0", + "version": "3.36.1", "description": "Search plugin for Payload", "keywords": [ "payload", diff --git a/packages/plugin-sentry/package.json b/packages/plugin-sentry/package.json index a4c59563b2..02bc00753d 100644 --- a/packages/plugin-sentry/package.json +++ b/packages/plugin-sentry/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-sentry", - "version": "3.36.0", + "version": "3.36.1", "description": "Sentry plugin for Payload", "keywords": [ "payload", diff --git a/packages/plugin-seo/package.json b/packages/plugin-seo/package.json index 7c11f3f706..769554c198 100644 --- a/packages/plugin-seo/package.json +++ b/packages/plugin-seo/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-seo", - "version": "3.36.0", + "version": "3.36.1", "description": "SEO plugin for Payload", "keywords": [ "payload", diff --git a/packages/plugin-stripe/package.json b/packages/plugin-stripe/package.json index 9e2564adc4..c75b99fd7f 100644 --- a/packages/plugin-stripe/package.json +++ b/packages/plugin-stripe/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-stripe", - "version": "3.36.0", + "version": "3.36.1", "description": "Stripe plugin for Payload", "keywords": [ "payload", diff --git a/packages/richtext-lexical/package.json b/packages/richtext-lexical/package.json index 0b0eef81b6..466ec7f03e 100644 --- a/packages/richtext-lexical/package.json +++ b/packages/richtext-lexical/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/richtext-lexical", - "version": "3.36.0", + "version": "3.36.1", "description": "The officially supported Lexical richtext adapter for Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/richtext-slate/package.json b/packages/richtext-slate/package.json index 9e1b091f17..fd720101e9 100644 --- a/packages/richtext-slate/package.json +++ b/packages/richtext-slate/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/richtext-slate", - "version": "3.36.0", + "version": "3.36.1", "description": "The officially supported Slate richtext adapter for Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/storage-azure/package.json b/packages/storage-azure/package.json index a700d036db..52ca8d300d 100644 --- a/packages/storage-azure/package.json +++ b/packages/storage-azure/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/storage-azure", - "version": "3.36.0", + "version": "3.36.1", "description": "Payload storage adapter for Azure Blob Storage", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/storage-gcs/package.json b/packages/storage-gcs/package.json index 73ad059c84..22f2efbcc4 100644 --- a/packages/storage-gcs/package.json +++ b/packages/storage-gcs/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/storage-gcs", - "version": "3.36.0", + "version": "3.36.1", "description": "Payload storage adapter for Google Cloud Storage", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/storage-s3/package.json b/packages/storage-s3/package.json index 8818a84852..3c274d09cf 100644 --- a/packages/storage-s3/package.json +++ b/packages/storage-s3/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/storage-s3", - "version": "3.36.0", + "version": "3.36.1", "description": "Payload storage adapter for Amazon S3", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/storage-uploadthing/package.json b/packages/storage-uploadthing/package.json index 4943396c9a..828a00d6ed 100644 --- a/packages/storage-uploadthing/package.json +++ b/packages/storage-uploadthing/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/storage-uploadthing", - "version": "3.36.0", + "version": "3.36.1", "description": "Payload storage adapter for uploadthing", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/storage-vercel-blob/package.json b/packages/storage-vercel-blob/package.json index f1db8bade4..3d7ae0b645 100644 --- a/packages/storage-vercel-blob/package.json +++ b/packages/storage-vercel-blob/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/storage-vercel-blob", - "version": "3.36.0", + "version": "3.36.1", "description": "Payload storage adapter for Vercel Blob Storage", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/translations/package.json b/packages/translations/package.json index c72bde26c9..7c4c9d9747 100644 --- a/packages/translations/package.json +++ b/packages/translations/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/translations", - "version": "3.36.0", + "version": "3.36.1", "homepage": "https://payloadcms.com", "repository": { "type": "git", diff --git a/packages/ui/package.json b/packages/ui/package.json index 93243e3495..1e41fd10f9 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/ui", - "version": "3.36.0", + "version": "3.36.1", "homepage": "https://payloadcms.com", "repository": { "type": "git", From 35c04048175b9a241fe4bfab8291c7e8010a7bd9 Mon Sep 17 00:00:00 2001 From: Janus Reith Date: Wed, 30 Apr 2025 21:08:53 +0200 Subject: [PATCH 23/34] feat(live-preview): expose requestHandler to subscribe.ts (#10947) ### What? As described in https://github.com/payloadcms/payload/discussions/10946, allow passing a custom `collectionPopulationRequestHandler` function to `subscribe`, which passes it along to `handleMessage` and `mergeData` ### Why? `mergeData` already supports a custom function for this, that functionality however isn't exposed. My use case so far was passing along custom Authorization headers. ### How? Move the functions type defined in `mergeData` to a dedicated `CollectionPopulationRequestHandler` type, reuse it across `subscribe`, `handleMessage` and `mergeData`. --------- Co-authored-by: Jacob Fletcher --- packages/live-preview/src/handleMessage.ts | 6 +- packages/live-preview/src/mergeData.ts | 19 +++---- packages/live-preview/src/subscribe.ts | 6 +- packages/live-preview/src/types.ts | 10 ++++ test/live-preview/int.spec.ts | 64 +++++++++++----------- 5 files changed, 59 insertions(+), 46 deletions(-) diff --git a/packages/live-preview/src/handleMessage.ts b/packages/live-preview/src/handleMessage.ts index ed7ebeb47f..45f267684b 100644 --- a/packages/live-preview/src/handleMessage.ts +++ b/packages/live-preview/src/handleMessage.ts @@ -1,6 +1,6 @@ import type { FieldSchemaJSON } from 'payload' -import type { LivePreviewMessageEvent } from './types.js' +import type { CollectionPopulationRequestHandler, LivePreviewMessageEvent } from './types.js' import { isLivePreviewEvent } from './isLivePreviewEvent.js' import { mergeData } from './mergeData.js' @@ -29,9 +29,10 @@ export const handleMessage = async >(args: { depth?: number event: LivePreviewMessageEvent initialData: T + requestHandler?: CollectionPopulationRequestHandler serverURL: string }): Promise => { - const { apiRoute, depth, event, initialData, serverURL } = args + const { apiRoute, depth, event, initialData, requestHandler, serverURL } = args if (isLivePreviewEvent(event, serverURL)) { const { data, externallyUpdatedRelationship, fieldSchemaJSON, locale } = event.data @@ -57,6 +58,7 @@ export const handleMessage = async >(args: { incomingData: data, initialData: _payloadLivePreview?.previousData || initialData, locale, + requestHandler, serverURL, }) diff --git a/packages/live-preview/src/mergeData.ts b/packages/live-preview/src/mergeData.ts index 1448377475..60f497fb20 100644 --- a/packages/live-preview/src/mergeData.ts +++ b/packages/live-preview/src/mergeData.ts @@ -1,6 +1,6 @@ import type { DocumentEvent, FieldSchemaJSON, PaginatedDocs } from 'payload' -import type { PopulationsByCollection } from './types.js' +import type { CollectionPopulationRequestHandler, PopulationsByCollection } from './types.js' import { traverseFields } from './traverseFields.js' @@ -29,21 +29,17 @@ let prevLocale: string | undefined export const mergeData = async >(args: { apiRoute?: string - collectionPopulationRequestHandler?: ({ - apiPath, - endpoint, - serverURL, - }: { - apiPath: string - endpoint: string - serverURL: string - }) => Promise + /** + * @deprecated Use `requestHandler` instead + */ + collectionPopulationRequestHandler?: CollectionPopulationRequestHandler depth?: number externallyUpdatedRelationship?: DocumentEvent fieldSchema: FieldSchemaJSON incomingData: Partial initialData: T locale?: string + requestHandler?: CollectionPopulationRequestHandler returnNumberOfRequests?: boolean serverURL: string }): Promise< @@ -81,7 +77,8 @@ export const mergeData = async >(args: { let res: PaginatedDocs const ids = new Set(populations.map(({ id }) => id)) - const requestHandler = args.collectionPopulationRequestHandler || defaultRequestHandler + const requestHandler = + args.collectionPopulationRequestHandler || args.requestHandler || defaultRequestHandler try { res = await requestHandler({ diff --git a/packages/live-preview/src/subscribe.ts b/packages/live-preview/src/subscribe.ts index 6cbe1bf4e7..f6c27e9843 100644 --- a/packages/live-preview/src/subscribe.ts +++ b/packages/live-preview/src/subscribe.ts @@ -1,3 +1,5 @@ +import type { CollectionPopulationRequestHandler } from './types.js' + import { handleMessage } from './handleMessage.js' export const subscribe = >(args: { @@ -5,9 +7,10 @@ export const subscribe = >(args: { callback: (data: T) => void depth?: number initialData: T + requestHandler?: CollectionPopulationRequestHandler serverURL: string }): ((event: MessageEvent) => Promise | void) => { - const { apiRoute, callback, depth, initialData, serverURL } = args + const { apiRoute, callback, depth, initialData, requestHandler, serverURL } = args const onMessage = async (event: MessageEvent) => { const mergedData = await handleMessage({ @@ -15,6 +18,7 @@ export const subscribe = >(args: { depth, event, initialData, + requestHandler, serverURL, }) diff --git a/packages/live-preview/src/types.ts b/packages/live-preview/src/types.ts index 94ae0ba447..4128d23720 100644 --- a/packages/live-preview/src/types.ts +++ b/packages/live-preview/src/types.ts @@ -1,5 +1,15 @@ import type { DocumentEvent, FieldSchemaJSON } from 'payload' +export type CollectionPopulationRequestHandler = ({ + apiPath, + endpoint, + serverURL, +}: { + apiPath: string + endpoint: string + serverURL: string +}) => Promise + export type LivePreviewArgs = {} export type LivePreview = void diff --git a/test/live-preview/int.spec.ts b/test/live-preview/int.spec.ts index 5fe0109cce..0e93bc2844 100644 --- a/test/live-preview/int.spec.ts +++ b/test/live-preview/int.spec.ts @@ -13,9 +13,9 @@ import { fileURLToPath } from 'url' import type { NextRESTClient } from '../helpers/NextRESTClient.js' import type { Media, Page, Post, Tenant } from './payload-types.js' -import config from './config.js' import { Pages } from './collections/Pages.js' +import config from './config.js' import { postsSlug, tenantsSlug } from './shared.js' const filename = fileURLToPath(import.meta.url) @@ -28,7 +28,7 @@ let restClient: NextRESTClient import { initPayloadInt } from '../helpers/initPayloadInt.js' -function collectionPopulationRequestHandler({ endpoint }: { endpoint: string }) { +function requestHandler({ endpoint }: { endpoint: string }) { return restClient.GET(`/${endpoint}`) } @@ -170,7 +170,7 @@ describe('Collections - Live Preview', () => { initialData, serverURL, returnNumberOfRequests: true, - collectionPopulationRequestHandler, + requestHandler, }) expect(mergedData.title).toEqual('Test Page (Changed)') @@ -198,7 +198,7 @@ describe('Collections - Live Preview', () => { initialData, serverURL, returnNumberOfRequests: true, - collectionPopulationRequestHandler, + requestHandler, }) expect(mergedData.arrayOfRelationships).toEqual([]) @@ -217,7 +217,7 @@ describe('Collections - Live Preview', () => { initialData, serverURL, returnNumberOfRequests: true, - collectionPopulationRequestHandler, + requestHandler, }) expect(mergedData2.arrayOfRelationships).toEqual([]) @@ -243,7 +243,7 @@ describe('Collections - Live Preview', () => { initialData, serverURL, returnNumberOfRequests: true, - collectionPopulationRequestHandler, + requestHandler, }) expect(mergedData.hero.media).toMatchObject(media) @@ -262,7 +262,7 @@ describe('Collections - Live Preview', () => { }, initialData: mergedData, serverURL, - collectionPopulationRequestHandler, + requestHandler, }) expect(mergedDataWithoutUpload.hero.media).toBeFalsy() @@ -290,7 +290,7 @@ describe('Collections - Live Preview', () => { initialData, serverURL, returnNumberOfRequests: true, - collectionPopulationRequestHandler, + requestHandler, }) expect(merge1.richTextSlate).toHaveLength(1) @@ -317,7 +317,7 @@ describe('Collections - Live Preview', () => { initialData, serverURL, returnNumberOfRequests: true, - collectionPopulationRequestHandler, + requestHandler, }) expect(merge2.richTextSlate).toHaveLength(1) @@ -377,7 +377,7 @@ describe('Collections - Live Preview', () => { initialData, serverURL, returnNumberOfRequests: true, - collectionPopulationRequestHandler, + requestHandler, }) expect(merge1.richTextLexical.root.children).toHaveLength(2) @@ -423,7 +423,7 @@ describe('Collections - Live Preview', () => { initialData, serverURL, returnNumberOfRequests: true, - collectionPopulationRequestHandler, + requestHandler, }) expect(merge2.richTextLexical.root.children).toHaveLength(1) @@ -446,7 +446,7 @@ describe('Collections - Live Preview', () => { initialData, serverURL, returnNumberOfRequests: true, - collectionPopulationRequestHandler, + requestHandler, }) expect(merge1._numberOfRequests).toEqual(1) @@ -468,7 +468,7 @@ describe('Collections - Live Preview', () => { initialData, serverURL, returnNumberOfRequests: true, - collectionPopulationRequestHandler, + requestHandler, }) expect(merge1._numberOfRequests).toEqual(1) @@ -490,7 +490,7 @@ describe('Collections - Live Preview', () => { initialData, serverURL, returnNumberOfRequests: true, - collectionPopulationRequestHandler, + requestHandler, }) expect(merge1._numberOfRequests).toEqual(1) @@ -515,7 +515,7 @@ describe('Collections - Live Preview', () => { initialData, serverURL, returnNumberOfRequests: true, - collectionPopulationRequestHandler, + requestHandler, }) expect(merge1._numberOfRequests).toEqual(1) @@ -545,7 +545,7 @@ describe('Collections - Live Preview', () => { initialData, serverURL, returnNumberOfRequests: true, - collectionPopulationRequestHandler, + requestHandler, }) expect(merge2._numberOfRequests).toEqual(0) @@ -571,7 +571,7 @@ describe('Collections - Live Preview', () => { }, initialData, serverURL, - collectionPopulationRequestHandler, + requestHandler, }) expect(merge1.tab.relationshipInTab).toMatchObject(testPost) @@ -608,7 +608,7 @@ describe('Collections - Live Preview', () => { initialData, serverURL, returnNumberOfRequests: true, - collectionPopulationRequestHandler, + requestHandler, }) expect(merge1._numberOfRequests).toEqual(1) @@ -665,7 +665,7 @@ describe('Collections - Live Preview', () => { initialData: merge1, serverURL, returnNumberOfRequests: true, - collectionPopulationRequestHandler, + requestHandler, }) expect(merge2._numberOfRequests).toEqual(1) @@ -741,7 +741,7 @@ describe('Collections - Live Preview', () => { initialData, serverURL, returnNumberOfRequests: true, - collectionPopulationRequestHandler, + requestHandler, }) expect(merge1._numberOfRequests).toEqual(2) @@ -804,7 +804,7 @@ describe('Collections - Live Preview', () => { initialData: merge1, serverURL, returnNumberOfRequests: true, - collectionPopulationRequestHandler, + requestHandler, }) expect(merge2._numberOfRequests).toEqual(1) @@ -870,7 +870,7 @@ describe('Collections - Live Preview', () => { initialData, serverURL, returnNumberOfRequests: true, - collectionPopulationRequestHandler, + requestHandler, }) expect(merge1._numberOfRequests).toEqual(2) @@ -937,7 +937,7 @@ describe('Collections - Live Preview', () => { initialData: merge1, serverURL, returnNumberOfRequests: true, - collectionPopulationRequestHandler, + requestHandler, }) expect(merge2._numberOfRequests).toEqual(1) @@ -991,7 +991,7 @@ describe('Collections - Live Preview', () => { initialData, serverURL, returnNumberOfRequests: true, - collectionPopulationRequestHandler, + requestHandler, }) expect(merge1._numberOfRequests).toEqual(0) @@ -1051,7 +1051,7 @@ describe('Collections - Live Preview', () => { initialData, serverURL, returnNumberOfRequests: true, - collectionPopulationRequestHandler, + requestHandler, }) // Check that the relationship on the first has been removed @@ -1080,7 +1080,7 @@ describe('Collections - Live Preview', () => { initialData, serverURL, returnNumberOfRequests: true, - collectionPopulationRequestHandler, + requestHandler, }) expect(merge1._numberOfRequests).toEqual(1) @@ -1126,7 +1126,7 @@ describe('Collections - Live Preview', () => { externallyUpdatedRelationship, serverURL, returnNumberOfRequests: true, - collectionPopulationRequestHandler, + requestHandler, }) expect(merge2._numberOfRequests).toEqual(1) @@ -1183,7 +1183,7 @@ describe('Collections - Live Preview', () => { initialData, serverURL, returnNumberOfRequests: true, - collectionPopulationRequestHandler, + requestHandler, locale: 'es', }) @@ -1332,7 +1332,7 @@ describe('Collections - Live Preview', () => { initialData, serverURL, returnNumberOfRequests: true, - collectionPopulationRequestHandler, + requestHandler, }) // Check that the blocks have been reordered @@ -1365,7 +1365,7 @@ describe('Collections - Live Preview', () => { initialData, serverURL, returnNumberOfRequests: true, - collectionPopulationRequestHandler, + requestHandler, }) // Check that the block has been removed @@ -1385,7 +1385,7 @@ describe('Collections - Live Preview', () => { initialData, serverURL, returnNumberOfRequests: true, - collectionPopulationRequestHandler, + requestHandler, }) // Check that the block has been removed @@ -1445,7 +1445,7 @@ describe('Collections - Live Preview', () => { initialData, serverURL, returnNumberOfRequests: true, - collectionPopulationRequestHandler: customRequestHandler, + requestHandler: customRequestHandler, }) expect(mergedData.relationshipPolyHasMany).toMatchObject([ From e5b28c98dcd5ec89e4f14a7b3e6b99d9920302c0 Mon Sep 17 00:00:00 2001 From: Jessica Rynkar <67977755+jessrynkar@users.noreply.github.com> Date: Thu, 1 May 2025 17:03:07 +0100 Subject: [PATCH 24/34] fix(cpa): overwrites existing env variables (#10636) ### What? Using `create-payload-app` to initialize Payload in an existing Next.js app **that does not already have Payload installed** overwrites any existing data in the `.env` and `.env.example` files. The desired behavior is for Payload variables to get added with no client data lost. ### How? Updates `manageEnvFiles` to check for existing `.env / .env.example` file and appends or creates as necessary. Adds tests to `packages/create-payload-app/src/lib/create-project.spec.ts`. #### Fixes https://github.com/payloadcms/payload/issues/10355 --- .../src/lib/create-project.spec.ts | 71 +++++++++ .../src/lib/manage-env-files.ts | 144 ++++++++++-------- 2 files changed, 152 insertions(+), 63 deletions(-) diff --git a/packages/create-payload-app/src/lib/create-project.spec.ts b/packages/create-payload-app/src/lib/create-project.spec.ts index 93503c1e6f..578ebbb6e7 100644 --- a/packages/create-payload-app/src/lib/create-project.spec.ts +++ b/packages/create-payload-app/src/lib/create-project.spec.ts @@ -10,6 +10,7 @@ import type { CliArgs, DbType, ProjectExample, ProjectTemplate } from '../types. import { createProject } from './create-project.js' import { dbReplacements } from './replacements.js' import { getValidTemplates } from './templates.js' +import { manageEnvFiles } from './manage-env-files.js' describe('createProject', () => { let projectDir: string @@ -154,5 +155,75 @@ describe('createProject', () => { expect(content).toContain(dbReplacement.configReplacement().join('\n')) }) }) + describe('managing env files', () => { + it('updates .env files without overwriting existing data', async () => { + const envFilePath = path.join(projectDir, '.env') + const envExampleFilePath = path.join(projectDir, '.env.example') + + fse.ensureDirSync(projectDir) + fse.ensureFileSync(envFilePath) + fse.ensureFileSync(envExampleFilePath) + + const initialEnvContent = `CUSTOM_VAR=custom-value\nDATABASE_URI=old-connection\n` + const initialEnvExampleContent = `CUSTOM_VAR=custom-value\nDATABASE_URI=old-connection\nPAYLOAD_SECRET=YOUR_SECRET_HERE\n` + + fse.writeFileSync(envFilePath, initialEnvContent) + fse.writeFileSync(envExampleFilePath, initialEnvExampleContent) + + await manageEnvFiles({ + cliArgs: { + '--debug': true, + } as CliArgs, + databaseType: 'mongodb', + databaseUri: 'mongodb://localhost:27017/test', + payloadSecret: 'test-secret', + projectDir, + template: undefined, + }) + + const updatedEnvContent = fse.readFileSync(envFilePath, 'utf-8') + + expect(updatedEnvContent).toContain('CUSTOM_VAR=custom-value') + expect(updatedEnvContent).toContain('DATABASE_URI=mongodb://localhost:27017/test') + expect(updatedEnvContent).toContain('PAYLOAD_SECRET=test-secret') + + const updatedEnvExampleContent = fse.readFileSync(envExampleFilePath, 'utf-8') + + expect(updatedEnvExampleContent).toContain('CUSTOM_VAR=custom-value') + expect(updatedEnvContent).toContain('DATABASE_URI=mongodb://localhost:27017/test') + expect(updatedEnvContent).toContain('PAYLOAD_SECRET=test-secret') + }) + + it('creates .env and .env.example if they do not exist', async () => { + const envFilePath = path.join(projectDir, '.env') + const envExampleFilePath = path.join(projectDir, '.env.example') + + fse.ensureDirSync(projectDir) + + if (fse.existsSync(envFilePath)) fse.removeSync(envFilePath) + if (fse.existsSync(envExampleFilePath)) fse.removeSync(envExampleFilePath) + + await manageEnvFiles({ + cliArgs: { + '--debug': true, + } as CliArgs, + databaseUri: '', + payloadSecret: '', + projectDir, + template: undefined, + }) + + expect(fse.existsSync(envFilePath)).toBe(true) + expect(fse.existsSync(envExampleFilePath)).toBe(true) + + const updatedEnvContent = fse.readFileSync(envFilePath, 'utf-8') + expect(updatedEnvContent).toContain('DATABASE_URI=your-connection-string-here') + expect(updatedEnvContent).toContain('PAYLOAD_SECRET=YOUR_SECRET_HERE') + + const updatedEnvExampleContent = fse.readFileSync(envExampleFilePath, 'utf-8') + expect(updatedEnvExampleContent).toContain('DATABASE_URI=your-connection-string-here') + expect(updatedEnvExampleContent).toContain('PAYLOAD_SECRET=YOUR_SECRET_HERE') + }) + }) }) }) diff --git a/packages/create-payload-app/src/lib/manage-env-files.ts b/packages/create-payload-app/src/lib/manage-env-files.ts index b2454c8833..6ea4ba364b 100644 --- a/packages/create-payload-app/src/lib/manage-env-files.ts +++ b/packages/create-payload-app/src/lib/manage-env-files.ts @@ -6,66 +6,55 @@ import type { CliArgs, DbType, ProjectTemplate } from '../types.js' import { debug, error } from '../utils/log.js' import { dbChoiceRecord } from './select-db.js' -const updateEnvExampleVariables = (contents: string, databaseType: DbType | undefined): string => { - return contents +const updateEnvExampleVariables = ( + contents: string, + databaseType: DbType | undefined, + payloadSecret?: string, + databaseUri?: string, +): string => { + const seenKeys = new Set() + const updatedEnv = contents .split('\n') .map((line) => { if (line.startsWith('#') || !line.includes('=')) { - return line // Preserve comments and unrelated lines + return line } const [key] = line.split('=') + if (!key) {return} + if (key === 'DATABASE_URI' || key === 'POSTGRES_URL' || key === 'MONGODB_URI') { const dbChoice = databaseType ? dbChoiceRecord[databaseType] : null - if (dbChoice) { - const placeholderUri = `${dbChoice.dbConnectionPrefix}your-database-name${ - dbChoice.dbConnectionSuffix || '' - }` - return databaseType === 'vercel-postgres' - ? `POSTGRES_URL=${placeholderUri}` - : `DATABASE_URI=${placeholderUri}` + const placeholderUri = databaseUri + ? databaseUri + : `${dbChoice.dbConnectionPrefix}your-database-name${dbChoice.dbConnectionSuffix || ''}` + line = + databaseType === 'vercel-postgres' + ? `POSTGRES_URL=${placeholderUri}` + : `DATABASE_URI=${placeholderUri}` } - - return `DATABASE_URI=your-database-connection-here` // Fallback } if (key === 'PAYLOAD_SECRET' || key === 'PAYLOAD_SECRET_KEY') { - return `PAYLOAD_SECRET=YOUR_SECRET_HERE` + line = `PAYLOAD_SECRET=${payloadSecret || 'YOUR_SECRET_HERE'}` } + // handles dupes + if (seenKeys.has(key)) { + return null + } + + seenKeys.add(key) + return line }) + .filter(Boolean) + .reverse() .join('\n') -} -const generateEnvContent = ( - existingEnv: string, - databaseType: DbType | undefined, - databaseUri: string, - payloadSecret: string, -): string => { - const dbKey = databaseType === 'vercel-postgres' ? 'POSTGRES_URL' : 'DATABASE_URI' - - const envVars: Record = {} - existingEnv - .split('\n') - .filter((line) => line.includes('=') && !line.startsWith('#')) - .forEach((line) => { - const [key, value] = line.split('=') - // @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve - envVars[key] = value - }) - - // Override specific keys - envVars[dbKey] = databaseUri - envVars['PAYLOAD_SECRET'] = payloadSecret - - // Rebuild content - return Object.entries(envVars) - .map(([key, value]) => `${key}=${value}`) - .join('\n') + return updatedEnv } /** Parse and swap .env.example values and write .env */ @@ -88,42 +77,71 @@ export async function manageEnvFiles(args: { const envExamplePath = path.join(projectDir, '.env.example') const envPath = path.join(projectDir, '.env') - + const emptyEnvContent = `# Added by Payload\nDATABASE_URI=your-connection-string-here\nPAYLOAD_SECRET=YOUR_SECRET_HERE\n` try { let updatedExampleContents: string - // Update .env.example - if (template?.type === 'starter') { - if (!fs.existsSync(envExamplePath)) { - error(`.env.example file not found at ${envExamplePath}`) - process.exit(1) + if (template?.type === 'plugin') { + if (debugFlag) { + debug(`plugin template detected - no .env added .env.example added`) } + return + } + if (!fs.existsSync(envExamplePath)) { + updatedExampleContents = updateEnvExampleVariables( + emptyEnvContent, + databaseType, + payloadSecret, + databaseUri, + ) + + await fs.writeFile(envExamplePath, updatedExampleContents) + if (debugFlag) { + debug(`.env.example file successfully created`) + } + } else { const envExampleContents = await fs.readFile(envExamplePath, 'utf8') - updatedExampleContents = updateEnvExampleVariables(envExampleContents, databaseType) - - await fs.writeFile(envExamplePath, updatedExampleContents.trimEnd() + '\n') + const mergedEnvs = envExampleContents + '\n' + emptyEnvContent + updatedExampleContents = updateEnvExampleVariables( + mergedEnvs, + databaseType, + payloadSecret, + databaseUri, + ) + await fs.writeFile(envExamplePath, updatedExampleContents) if (debugFlag) { debug(`.env.example file successfully updated`) } - } else { - updatedExampleContents = `# Added by Payload\nDATABASE_URI=your-connection-string-here\nPAYLOAD_SECRET=YOUR_SECRET_HERE\n` - await fs.writeFile(envExamplePath, updatedExampleContents.trimEnd() + '\n') } - // Merge existing variables and create or update .env - const envExampleContents = await fs.readFile(envExamplePath, 'utf8') - const envContent = generateEnvContent( - envExampleContents, - databaseType, - databaseUri, - payloadSecret, - ) - await fs.writeFile(envPath, `# Added by Payload\n${envContent.trimEnd()}\n`) + if (!fs.existsSync(envPath)) { + const envContent = updateEnvExampleVariables( + emptyEnvContent, + databaseType, + payloadSecret, + databaseUri, + ) + await fs.writeFile(envPath, envContent) - if (debugFlag) { - debug(`.env file successfully created or updated`) + if (debugFlag) { + debug(`.env file successfully created`) + } + } else { + const envContents = await fs.readFile(envPath, 'utf8') + const mergedEnvs = envContents + '\n' + emptyEnvContent + const updatedEnvContents = updateEnvExampleVariables( + mergedEnvs, + databaseType, + payloadSecret, + databaseUri, + ) + + await fs.writeFile(envPath, updatedEnvContents) + if (debugFlag) { + debug(`.env file successfully updated`) + } } } catch (err: unknown) { error('Unable to manage environment files') From b9868c4a3be313f7e30247e899fc5a7ab7f4adf0 Mon Sep 17 00:00:00 2001 From: Samuel Gabriel <144083942+sam-gab@users.noreply.github.com> Date: Fri, 2 May 2025 01:58:51 +0800 Subject: [PATCH 25/34] fix: allow custom admin user collection in query presets constraints (#12202) Query preset "Specific User" constraints is currently fixed to `users` collection. However, this will fail if one has a custom admin user collection. --- packages/payload/src/query-presets/constraints.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/payload/src/query-presets/constraints.ts b/packages/payload/src/query-presets/constraints.ts index 8e9cec4f5d..33603d2f1d 100644 --- a/packages/payload/src/query-presets/constraints.ts +++ b/packages/payload/src/query-presets/constraints.ts @@ -74,7 +74,7 @@ export const getConstraints = (config: Config): Field => ({ }, ], }, - relationTo: 'users', + relationTo: config.admin?.user ?? 'users', // TODO: remove this fallback when the args are properly typed as `SanitizedConfig` }, ...(config?.queryPresets?.constraints?.[operation]?.reduce( (acc: Field[], option: QueryPresetConstraint) => { From c08c7071ee66bd62cb30130d0aeef539d58924ef Mon Sep 17 00:00:00 2001 From: Sasha <64744993+r1tsuu@users.noreply.github.com> Date: Thu, 1 May 2025 21:04:42 +0300 Subject: [PATCH 26/34] fix(graphql): population of joins that target relationship fields that have `relationTo` as an array (#12289) Fixes population of joins that target relationship fields that have `relationTo` as an array, for example: ```ts // Posts collection { name: 'polymorphic', type: 'relationship', relationTo: ['categories', 'users'], }, // Categories collection { name: 'polymorphic', type: 'join', collection: 'posts', on: 'polymorphic', } ``` Thanks @jaycetde for the integration test https://github.com/payloadcms/payload/pull/12278! --------- Co-authored-by: Jayce Pulsipher --- .../graphql/src/schema/buildObjectType.ts | 3 + .../graphql/src/schema/fieldToSchemaMap.ts | 24 ++++- .../src/schema/fieldToWhereInputSchemaMap.ts | 1 + .../graphql/src/schema/initCollections.ts | 2 + test/joins/int.spec.ts | 88 +++++++++++++++++++ 5 files changed, 114 insertions(+), 4 deletions(-) diff --git a/packages/graphql/src/schema/buildObjectType.ts b/packages/graphql/src/schema/buildObjectType.ts index 0ce88092e7..f368f663b0 100644 --- a/packages/graphql/src/schema/buildObjectType.ts +++ b/packages/graphql/src/schema/buildObjectType.ts @@ -11,6 +11,7 @@ export type ObjectTypeConfig = { type Args = { baseFields?: ObjectTypeConfig + collectionSlug?: string config: SanitizedConfig fields: Field[] forceNullable?: boolean @@ -23,6 +24,7 @@ type Args = { export function buildObjectType({ name, baseFields = {}, + collectionSlug, config, fields, forceNullable, @@ -43,6 +45,7 @@ export function buildObjectType({ return { ...objectTypeConfig, ...fieldSchema({ + collectionSlug, config, field, forceNullable, diff --git a/packages/graphql/src/schema/fieldToSchemaMap.ts b/packages/graphql/src/schema/fieldToSchemaMap.ts index 70ffc75abd..fc5750add9 100644 --- a/packages/graphql/src/schema/fieldToSchemaMap.ts +++ b/packages/graphql/src/schema/fieldToSchemaMap.ts @@ -8,6 +8,7 @@ import type { DateField, EmailField, Field, + FlattenedJoinField, GraphQLInfo, GroupField, JoinField, @@ -68,6 +69,7 @@ function formattedNameResolver({ } type SharedArgs = { + collectionSlug?: string config: SanitizedConfig forceNullable?: boolean graphqlResult: GraphQLInfo @@ -340,7 +342,7 @@ export const fieldToSchemaMap: FieldToSchemaMap = { }, } }, - join: ({ field, graphqlResult, objectTypeConfig, parentName }) => { + join: ({ collectionSlug, field, graphqlResult, objectTypeConfig, parentName }) => { const joinName = combineParentName(parentName, toWords(field.name, true)) const joinType = { @@ -385,9 +387,23 @@ export const fieldToSchemaMap: FieldToSchemaMap = { const draft = Boolean(args.draft ?? context.req.query?.draft) - const fullWhere = combineQueries(where, { - [field.on]: { equals: parent._id ?? parent.id }, - }) + const targetField = (field as FlattenedJoinField).targetField + + const fullWhere = combineQueries( + where, + Array.isArray(targetField.relationTo) + ? { + [field.on]: { + equals: { + relationTo: collectionSlug, + value: parent._id ?? parent.id, + }, + }, + } + : { + [field.on]: { equals: parent._id ?? parent.id }, + }, + ) if (Array.isArray(collection)) { throw new Error('GraphQL with array of join.field.collection is not implemented') diff --git a/packages/graphql/src/schema/fieldToWhereInputSchemaMap.ts b/packages/graphql/src/schema/fieldToWhereInputSchemaMap.ts index db3f0e1f39..ab77eff030 100644 --- a/packages/graphql/src/schema/fieldToWhereInputSchemaMap.ts +++ b/packages/graphql/src/schema/fieldToWhereInputSchemaMap.ts @@ -29,6 +29,7 @@ import { recursivelyBuildNestedPaths } from './recursivelyBuildNestedPaths.js' import { withOperators } from './withOperators.js' type Args = { + collectionSlug?: string nestedFieldName?: string parentName: string } diff --git a/packages/graphql/src/schema/initCollections.ts b/packages/graphql/src/schema/initCollections.ts index 626a2a1202..7dda78057d 100644 --- a/packages/graphql/src/schema/initCollections.ts +++ b/packages/graphql/src/schema/initCollections.ts @@ -111,6 +111,7 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ collection.graphQL.type = buildObjectType({ name: singularName, baseFields, + collectionSlug: collectionConfig.slug, config, fields, forceNullable: forceNullableObjectType, @@ -339,6 +340,7 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ collection.graphQL.versionType = buildObjectType({ name: `${singularName}Version`, + collectionSlug: collectionConfig.slug, config, fields: versionCollectionFields, forceNullable: forceNullableObjectType, diff --git a/test/joins/int.spec.ts b/test/joins/int.spec.ts index f382483880..85b9bc76e5 100644 --- a/test/joins/int.spec.ts +++ b/test/joins/int.spec.ts @@ -940,6 +940,94 @@ describe('Joins Field', () => { ) }) + it('should have simple paginate with page for joins polymorphic', async () => { + let queryWithLimit = `query { + Categories(where: { + name: { equals: "paginate example" } + }) { + docs { + polymorphic( + sort: "createdAt", + limit: 2 + ) { + docs { + title + } + hasNextPage + } + } + } + }` + let pageWithLimit = await restClient + .GRAPHQL_POST({ body: JSON.stringify({ query: queryWithLimit }) }) + .then((res) => res.json()) + + const queryUnlimited = `query { + Categories( + where: { + name: { equals: "paginate example" } + } + ) { + docs { + polymorphic( + sort: "createdAt", + limit: 0 + ) { + docs { + title + createdAt + } + hasNextPage + } + } + } + }` + + const unlimited = await restClient + .GRAPHQL_POST({ body: JSON.stringify({ query: queryUnlimited }) }) + .then((res) => res.json()) + + expect(pageWithLimit.data.Categories.docs[0].polymorphic.docs).toHaveLength(2) + expect(pageWithLimit.data.Categories.docs[0].polymorphic.docs[0].id).toStrictEqual( + unlimited.data.Categories.docs[0].polymorphic.docs[0].id, + ) + expect(pageWithLimit.data.Categories.docs[0].polymorphic.docs[1].id).toStrictEqual( + unlimited.data.Categories.docs[0].polymorphic.docs[1].id, + ) + + expect(pageWithLimit.data.Categories.docs[0].polymorphic.hasNextPage).toStrictEqual(true) + + queryWithLimit = `query { + Categories(where: { + name: { equals: "paginate example" } + }) { + docs { + polymorphic( + sort: "createdAt", + limit: 2, + page: 2, + ) { + docs { + title + } + hasNextPage + } + } + } + }` + + pageWithLimit = await restClient + .GRAPHQL_POST({ body: JSON.stringify({ query: queryWithLimit }) }) + .then((res) => res.json()) + + expect(pageWithLimit.data.Categories.docs[0].polymorphic.docs[0].id).toStrictEqual( + unlimited.data.Categories.docs[0].polymorphic.docs[2].id, + ) + expect(pageWithLimit.data.Categories.docs[0].polymorphic.docs[1].id).toStrictEqual( + unlimited.data.Categories.docs[0].polymorphic.docs[3].id, + ) + }) + it('should populate joins with hasMany when on both sides documents are in draft', async () => { const category = await payload.create({ collection: 'categories-versions', From 78d3af7dc96a802217706778a5477e53e3067381 Mon Sep 17 00:00:00 2001 From: Tobias Odendahl Date: Thu, 1 May 2025 20:19:43 +0200 Subject: [PATCH 27/34] feat(ui): allow array fields to be filtered in list view (#11925) ### What? Allows array fields to be filtered in the list view. ### Why? Array fields were not filterable in the list view although all other field types were filterable already. ### How? Adds handling for array fields as filter option. ![image](https://github.com/user-attachments/assets/6df1a113-1d9f-4d50-92f7-d1fceed294d0) --- .../elements/WhereBuilder/reduceFields.tsx | 2 +- test/admin/collections/Array.ts | 19 ++++++++++ test/admin/config.ts | 2 + test/admin/e2e/list-view/e2e.spec.ts | 38 +++++++++++++++++++ test/admin/payload-types.ts | 35 +++++++++++++++++ test/admin/slugs.ts | 2 + 6 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 test/admin/collections/Array.ts diff --git a/packages/ui/src/elements/WhereBuilder/reduceFields.tsx b/packages/ui/src/elements/WhereBuilder/reduceFields.tsx index 4822f5ce77..de2b079569 100644 --- a/packages/ui/src/elements/WhereBuilder/reduceFields.tsx +++ b/packages/ui/src/elements/WhereBuilder/reduceFields.tsx @@ -99,7 +99,7 @@ export const reduceFields = ({ return reduced } - if (field.type === 'group' && 'fields' in field) { + if ((field.type === 'group' || field.type === 'array') && 'fields' in field) { const translatedLabel = getTranslation(field.label || '', i18n) const labelWithPrefix = labelPrefix diff --git a/test/admin/collections/Array.ts b/test/admin/collections/Array.ts new file mode 100644 index 0000000000..a7e054b57f --- /dev/null +++ b/test/admin/collections/Array.ts @@ -0,0 +1,19 @@ +import type { CollectionConfig } from 'payload' + +import { arrayCollectionSlug } from '../slugs.js' + +export const Array: CollectionConfig = { + slug: arrayCollectionSlug, + fields: [ + { + name: 'array', + type: 'array', + fields: [ + { + name: 'text', + type: 'text', + }, + ], + }, + ], +} diff --git a/test/admin/config.ts b/test/admin/config.ts index 7821de2ba4..10a5c6c8d5 100644 --- a/test/admin/config.ts +++ b/test/admin/config.ts @@ -3,6 +3,7 @@ import path from 'path' const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' +import { Array } from './collections/Array.js' import { BaseListFilter } from './collections/BaseListFilter.js' import { CustomFields } from './collections/CustomFields/index.js' import { CustomViews1 } from './collections/CustomViews1.js' @@ -158,6 +159,7 @@ export default buildConfigWithDefaults({ CollectionGroup2A, CollectionGroup2B, Geo, + Array, DisableDuplicate, DisableCopyToLocale, BaseListFilter, diff --git a/test/admin/e2e/list-view/e2e.spec.ts b/test/admin/e2e/list-view/e2e.spec.ts index 125cbda93d..7e155f8492 100644 --- a/test/admin/e2e/list-view/e2e.spec.ts +++ b/test/admin/e2e/list-view/e2e.spec.ts @@ -17,6 +17,7 @@ import { AdminUrlUtil } from '../../../helpers/adminUrlUtil.js' import { initPayloadE2ENoConfig } from '../../../helpers/initPayloadE2ENoConfig.js' import { customAdminRoutes } from '../../shared.js' import { + arrayCollectionSlug, customViews1CollectionSlug, geoCollectionSlug, listDrawerSlug, @@ -57,6 +58,7 @@ const dirname = path.resolve(currentFolder, '../../') describe('List View', () => { let page: Page let geoUrl: AdminUrlUtil + let arrayUrl: AdminUrlUtil let postsUrl: AdminUrlUtil let baseListFiltersUrl: AdminUrlUtil let customViewsUrl: AdminUrlUtil @@ -79,6 +81,7 @@ describe('List View', () => { })) geoUrl = new AdminUrlUtil(serverURL, geoCollectionSlug) + arrayUrl = new AdminUrlUtil(serverURL, arrayCollectionSlug) postsUrl = new AdminUrlUtil(serverURL, postsCollectionSlug) with300DocumentsUrl = new AdminUrlUtil(serverURL, with300DocumentsSlug) baseListFiltersUrl = new AdminUrlUtil(serverURL, 'base-list-filters') @@ -389,6 +392,32 @@ describe('List View', () => { await expect(page.locator(tableRowLocator)).toHaveCount(2) }) + test('should allow to filter in array field', async () => { + await createArray() + + await page.goto(arrayUrl.list) + await expect(page.locator(tableRowLocator)).toHaveCount(1) + + await addListFilter({ + page, + fieldLabel: 'Array > Text', + operatorLabel: 'equals', + value: 'test', + }) + + await expect(page.locator(tableRowLocator)).toHaveCount(1) + + await page.locator('.condition__actions .btn.condition__actions-remove').click() + await addListFilter({ + page, + fieldLabel: 'Array > Text', + operatorLabel: 'equals', + value: 'not-matching', + }) + + await expect(page.locator(tableRowLocator)).toHaveCount(0) + }) + test('should reset filter value when a different field is selected', async () => { const id = (await page.locator('.cell-id').first().innerText()).replace('ID: ', '') @@ -1405,3 +1434,12 @@ async function createGeo(overrides?: Partial): Promise { }, }) as unknown as Promise } + +async function createArray() { + return payload.create({ + collection: arrayCollectionSlug, + data: { + array: [{ text: 'test' }], + }, + }) +} diff --git a/test/admin/payload-types.ts b/test/admin/payload-types.ts index d8566f15bc..4d312ccf22 100644 --- a/test/admin/payload-types.ts +++ b/test/admin/payload-types.ts @@ -82,6 +82,7 @@ export interface Config { 'group-two-collection-ones': GroupTwoCollectionOne; 'group-two-collection-twos': GroupTwoCollectionTwo; geo: Geo; + array: Array; 'disable-duplicate': DisableDuplicate; 'disable-copy-to-locale': DisableCopyToLocale; 'base-list-filters': BaseListFilter; @@ -108,6 +109,7 @@ export interface Config { 'group-two-collection-ones': GroupTwoCollectionOnesSelect | GroupTwoCollectionOnesSelect; 'group-two-collection-twos': GroupTwoCollectionTwosSelect | GroupTwoCollectionTwosSelect; geo: GeoSelect | GeoSelect; + array: ArraySelect | ArraySelect; 'disable-duplicate': DisableDuplicateSelect | DisableDuplicateSelect; 'disable-copy-to-locale': DisableCopyToLocaleSelect | DisableCopyToLocaleSelect; 'base-list-filters': BaseListFiltersSelect | BaseListFiltersSelect; @@ -414,6 +416,21 @@ export interface Geo { updatedAt: string; createdAt: string; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "array". + */ +export interface Array { + id: string; + array?: + | { + text?: string | null; + id?: string | null; + }[] + | null; + updatedAt: string; + createdAt: string; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "disable-duplicate". @@ -534,6 +551,10 @@ export interface PayloadLockedDocument { relationTo: 'geo'; value: string | Geo; } | null) + | ({ + relationTo: 'array'; + value: string | Array; + } | null) | ({ relationTo: 'disable-duplicate'; value: string | DisableDuplicate; @@ -818,6 +839,20 @@ export interface GeoSelect { updatedAt?: T; createdAt?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "array_select". + */ +export interface ArraySelect { + array?: + | T + | { + text?: T; + id?: T; + }; + updatedAt?: T; + createdAt?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "disable-duplicate_select". diff --git a/test/admin/slugs.ts b/test/admin/slugs.ts index 19cf43e229..a64f1085fa 100644 --- a/test/admin/slugs.ts +++ b/test/admin/slugs.ts @@ -2,6 +2,7 @@ export const usersCollectionSlug = 'users' export const customViews1CollectionSlug = 'custom-views-one' export const customViews2CollectionSlug = 'custom-views-two' export const geoCollectionSlug = 'geo' +export const arrayCollectionSlug = 'array' export const postsCollectionSlug = 'posts' export const group1Collection1Slug = 'group-one-collection-ones' export const group1Collection2Slug = 'group-one-collection-twos' @@ -23,6 +24,7 @@ export const collectionSlugs = [ customViews1CollectionSlug, customViews2CollectionSlug, geoCollectionSlug, + arrayCollectionSlug, postsCollectionSlug, group1Collection1Slug, group1Collection2Slug, From e5683913b4a323cf84b2d92be381f3ab29447be8 Mon Sep 17 00:00:00 2001 From: Tobias Odendahl Date: Thu, 1 May 2025 21:17:47 +0200 Subject: [PATCH 28/34] feat(ui): make select and relationship field placeholder configurable (#12253) ### What? Allows to overwrite the default placeholder text of select and relationship fields. ### Why? The default placeholder text is generic. In some scenarios a custom placeholder can guide the user better. ### How? Adds a new property `admin.placeholder` to relationship and select field which allows to define an alternative text or translation function for the placeholder. The placeholder is used in the form fields as well as in the filter options. ![Screenshot 2025-04-29 at 15 28 54](https://github.com/user-attachments/assets/d83d60c8-d4f6-41b7-951c-9f21c238afd8) ![Screenshot 2025-04-29 at 15 28 19](https://github.com/user-attachments/assets/d2263cf1-6042-4072-b5a9-e10af5f380bb) --------- Co-authored-by: Dan Ribbens Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Dan Ribbens --- docs/fields/relationship.mdx | 1 + docs/fields/select.mdx | 1 + packages/payload/src/fields/config/types.ts | 6 +- .../ui/src/elements/ReactSelect/index.tsx | 4 +- packages/ui/src/elements/ReactSelect/types.ts | 3 +- .../Condition/Relationship/index.tsx | 4 +- .../WhereBuilder/Condition/Select/index.tsx | 4 ++ .../WhereBuilder/Condition/Select/types.ts | 3 +- packages/ui/src/fields/Relationship/index.tsx | 4 +- packages/ui/src/fields/Select/Input.tsx | 5 +- packages/ui/src/fields/Select/index.tsx | 2 + test/admin/collections/Placeholder.ts | 41 ++++++++++++ test/admin/config.ts | 8 ++- test/admin/e2e/list-view/e2e.spec.ts | 67 ++++++++++++++++++- test/admin/slugs.ts | 1 + 15 files changed, 138 insertions(+), 16 deletions(-) create mode 100644 test/admin/collections/Placeholder.ts diff --git a/docs/fields/relationship.mdx b/docs/fields/relationship.mdx index 10d652a2c2..96f3b1ebe2 100644 --- a/docs/fields/relationship.mdx +++ b/docs/fields/relationship.mdx @@ -94,6 +94,7 @@ The Relationship Field inherits all of the default options from the base [Field | **`allowCreate`** | Set to `false` if you'd like to disable the ability to create new documents from within the relationship field. | | **`allowEdit`** | Set to `false` if you'd like to disable the ability to edit documents from within the relationship field. | | **`sortOptions`** | Define a default sorting order for the options within a Relationship field's dropdown. [More](#sort-options) | +| **`placeholder`** | Define a custom text or function to replace the generic default placeholder | | **`appearance`** | Set to `drawer` or `select` to change the behavior of the field. Defaults to `select`. | ### Sort Options diff --git a/docs/fields/select.mdx b/docs/fields/select.mdx index c8328a8d8a..cf5ed27b54 100644 --- a/docs/fields/select.mdx +++ b/docs/fields/select.mdx @@ -89,6 +89,7 @@ The Select Field inherits all of the default options from the base [Field Admin | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | **`isClearable`** | Set to `true` if you'd like this field to be clearable within the Admin UI. | | **`isSortable`** | Set to `true` if you'd like this field to be sortable within the Admin UI using drag and drop. (Only works when `hasMany` is set to `true`) | +| **`placeholder`** | Define a custom text or function to replace the generic default placeholder | ## Example diff --git a/packages/payload/src/fields/config/types.ts b/packages/payload/src/fields/config/types.ts index 3050e6250c..a8aec98c94 100644 --- a/packages/payload/src/fields/config/types.ts +++ b/packages/payload/src/fields/config/types.ts @@ -1061,6 +1061,7 @@ export type SelectField = { } & Admin['components'] isClearable?: boolean isSortable?: boolean + placeholder?: LabelFunction | string } & Admin /** * Customize the SQL table name @@ -1093,7 +1094,7 @@ export type SelectField = { Omit export type SelectFieldClient = { - admin?: AdminClient & Pick + admin?: AdminClient & Pick } & FieldBaseClient & Pick @@ -1160,10 +1161,11 @@ type RelationshipAdmin = { > } & Admin['components'] isSortable?: boolean + placeholder?: LabelFunction | string } & Admin type RelationshipAdminClient = AdminClient & - Pick + Pick export type PolymorphicRelationshipField = { admin?: { diff --git a/packages/ui/src/elements/ReactSelect/index.tsx b/packages/ui/src/elements/ReactSelect/index.tsx index 8319e77146..01c2f8f21d 100644 --- a/packages/ui/src/elements/ReactSelect/index.tsx +++ b/packages/ui/src/elements/ReactSelect/index.tsx @@ -84,7 +84,6 @@ const SelectAdapter: React.FC = (props) => { captureMenuScroll customProps={customProps} isLoading={isLoading} - placeholder={getTranslation(placeholder, i18n)} {...props} className={classes} classNamePrefix="rs" @@ -113,6 +112,7 @@ const SelectAdapter: React.FC = (props) => { onMenuClose={onMenuClose} onMenuOpen={onMenuOpen} options={options} + placeholder={getTranslation(placeholder, i18n)} styles={styles} unstyled={true} value={value} @@ -160,7 +160,6 @@ const SelectAdapter: React.FC = (props) => { = (props) => { onMenuClose={onMenuClose} onMenuOpen={onMenuOpen} options={options} + placeholder={getTranslation(placeholder, i18n)} styles={styles} unstyled={true} value={value} diff --git a/packages/ui/src/elements/ReactSelect/types.ts b/packages/ui/src/elements/ReactSelect/types.ts index 4520327b08..2f4b8592d6 100644 --- a/packages/ui/src/elements/ReactSelect/types.ts +++ b/packages/ui/src/elements/ReactSelect/types.ts @@ -1,3 +1,4 @@ +import type { LabelFunction } from 'payload' import type { CommonProps, GroupBase, Props as ReactSelectStateManagerProps } from 'react-select' import type { DocumentDrawerProps, UseDocumentDrawer } from '../DocumentDrawer/types.js' @@ -101,7 +102,7 @@ export type ReactSelectAdapterProps = { onMenuOpen?: () => void onMenuScrollToBottom?: () => void options: Option[] | OptionGroup[] - placeholder?: string + placeholder?: LabelFunction | string showError?: boolean value?: Option | Option[] } diff --git a/packages/ui/src/elements/WhereBuilder/Condition/Relationship/index.tsx b/packages/ui/src/elements/WhereBuilder/Condition/Relationship/index.tsx index cd5e1ea22d..825e0144db 100644 --- a/packages/ui/src/elements/WhereBuilder/Condition/Relationship/index.tsx +++ b/packages/ui/src/elements/WhereBuilder/Condition/Relationship/index.tsx @@ -23,7 +23,7 @@ const maxResultsPerRequest = 10 export const RelationshipFilter: React.FC = (props) => { const { disabled, - field: { admin: { isSortable } = {}, hasMany, relationTo }, + field: { admin: { isSortable, placeholder } = {}, hasMany, relationTo }, filterOptions, onChange, value, @@ -412,7 +412,7 @@ export const RelationshipFilter: React.FC = (props) => { onInputChange={handleInputChange} onMenuScrollToBottom={handleScrollToBottom} options={options} - placeholder={t('general:selectValue')} + placeholder={placeholder} value={valueToRender} /> )} diff --git a/packages/ui/src/elements/WhereBuilder/Condition/Select/index.tsx b/packages/ui/src/elements/WhereBuilder/Condition/Select/index.tsx index c9b1ebf0bc..43f7b509a0 100644 --- a/packages/ui/src/elements/WhereBuilder/Condition/Select/index.tsx +++ b/packages/ui/src/elements/WhereBuilder/Condition/Select/index.tsx @@ -11,6 +11,9 @@ import { formatOptions } from './formatOptions.js' export const Select: React.FC = ({ disabled, + field: { + admin: { placeholder }, + }, isClearable, onChange, operator, @@ -77,6 +80,7 @@ export const Select: React.FC = ({ isMulti={isMulti} onChange={onSelect} options={options.map((option) => ({ ...option, label: getTranslation(option.label, i18n) }))} + placeholder={placeholder} value={valueToRender} /> ) diff --git a/packages/ui/src/elements/WhereBuilder/Condition/Select/types.ts b/packages/ui/src/elements/WhereBuilder/Condition/Select/types.ts index 27c34b3f59..186f42228e 100644 --- a/packages/ui/src/elements/WhereBuilder/Condition/Select/types.ts +++ b/packages/ui/src/elements/WhereBuilder/Condition/Select/types.ts @@ -1,4 +1,4 @@ -import type { Option, SelectFieldClient } from 'payload' +import type { LabelFunction, Option, SelectFieldClient } from 'payload' import type { DefaultFilterProps } from '../types.js' @@ -7,5 +7,6 @@ export type SelectFilterProps = { readonly isClearable?: boolean readonly onChange: (val: string) => void readonly options: Option[] + readonly placeholder?: LabelFunction | string readonly value: string } & DefaultFilterProps diff --git a/packages/ui/src/fields/Relationship/index.tsx b/packages/ui/src/fields/Relationship/index.tsx index a1fd1a80ed..3d093c0c66 100644 --- a/packages/ui/src/fields/Relationship/index.tsx +++ b/packages/ui/src/fields/Relationship/index.tsx @@ -9,7 +9,7 @@ import type { import { dequal } from 'dequal/lite' import { wordBoundariesRegex } from 'payload/shared' import * as qs from 'qs-esm' -import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react' import type { DocumentDrawerProps } from '../../elements/DocumentDrawer/types.js' import type { ListDrawerProps } from '../../elements/ListDrawer/types.js' @@ -56,6 +56,7 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) => className, description, isSortable = true, + placeholder, sortOptions, } = {}, hasMany, @@ -779,6 +780,7 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) => }) }} options={options} + placeholder={placeholder} showError={showError} value={valueToRender ?? null} /> diff --git a/packages/ui/src/fields/Select/Input.tsx b/packages/ui/src/fields/Select/Input.tsx index d51d0148e9..da6d007a9a 100644 --- a/packages/ui/src/fields/Select/Input.tsx +++ b/packages/ui/src/fields/Select/Input.tsx @@ -1,5 +1,5 @@ 'use client' -import type { OptionObject, StaticDescription, StaticLabel } from 'payload' +import type { LabelFunction, OptionObject, StaticDescription, StaticLabel } from 'payload' import { getTranslation } from '@payloadcms/translations' import React from 'react' @@ -33,6 +33,7 @@ export type SelectInputProps = { readonly onInputChange?: ReactSelectAdapterProps['onInputChange'] readonly options?: OptionObject[] readonly path: string + readonly placeholder?: LabelFunction | string readonly readOnly?: boolean readonly required?: boolean readonly showError?: boolean @@ -58,6 +59,7 @@ export const SelectInput: React.FC = (props) => { onInputChange, options, path, + placeholder, readOnly, required, showError, @@ -125,6 +127,7 @@ export const SelectInput: React.FC = (props) => { ...option, label: getTranslation(option.label, i18n), }))} + placeholder={placeholder} showError={showError} value={valueToRender as OptionObject} /> diff --git a/packages/ui/src/fields/Select/index.tsx b/packages/ui/src/fields/Select/index.tsx index b8b67af5f9..d9b4f65084 100644 --- a/packages/ui/src/fields/Select/index.tsx +++ b/packages/ui/src/fields/Select/index.tsx @@ -38,6 +38,7 @@ const SelectFieldComponent: SelectFieldClientComponent = (props) => { description, isClearable = true, isSortable = true, + placeholder, } = {} as SelectFieldClientProps['field']['admin'], hasMany = false, label, @@ -118,6 +119,7 @@ const SelectFieldComponent: SelectFieldClientComponent = (props) => { onChange={onChange} options={options} path={path} + placeholder={placeholder} readOnly={readOnly || disabled} required={required} showError={showError} diff --git a/test/admin/collections/Placeholder.ts b/test/admin/collections/Placeholder.ts new file mode 100644 index 0000000000..5e47103c0d --- /dev/null +++ b/test/admin/collections/Placeholder.ts @@ -0,0 +1,41 @@ +import type { CollectionConfig } from 'payload' + +import { placeholderCollectionSlug } from '../slugs.js' + +export const Placeholder: CollectionConfig = { + slug: placeholderCollectionSlug, + fields: [ + { + name: 'defaultSelect', + type: 'select', + options: [ + { + label: 'Option 1', + value: 'option1', + }, + ], + }, + { + name: 'placeholderSelect', + type: 'select', + options: [{ label: 'Option 1', value: 'option1' }], + admin: { + placeholder: 'Custom placeholder', + }, + }, + { + name: 'defaultRelationship', + type: 'relationship', + relationTo: 'posts', + }, + { + name: 'placeholderRelationship', + type: 'relationship', + relationTo: 'posts', + admin: { + placeholder: 'Custom placeholder', + }, + }, + ], + versions: true, +} diff --git a/test/admin/config.ts b/test/admin/config.ts index 10a5c6c8d5..80f0de525f 100644 --- a/test/admin/config.ts +++ b/test/admin/config.ts @@ -1,7 +1,6 @@ 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' import { Array } from './collections/Array.js' import { BaseListFilter } from './collections/BaseListFilter.js' @@ -19,6 +18,7 @@ import { CollectionHidden } from './collections/Hidden.js' import { ListDrawer } from './collections/ListDrawer.js' import { CollectionNoApiView } from './collections/NoApiView.js' import { CollectionNotInView } from './collections/NotInView.js' +import { Placeholder } from './collections/Placeholder.js' import { Posts } from './collections/Posts.js' import { UploadCollection } from './collections/Upload.js' import { UploadTwoCollection } from './collections/UploadTwo.js' @@ -43,7 +43,8 @@ import { protectedCustomNestedViewPath, publicCustomViewPath, } from './shared.js' - +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) export default buildConfigWithDefaults({ admin: { importMap: { @@ -165,6 +166,7 @@ export default buildConfigWithDefaults({ BaseListFilter, with300Documents, ListDrawer, + Placeholder, ], globals: [ GlobalHidden, diff --git a/test/admin/e2e/list-view/e2e.spec.ts b/test/admin/e2e/list-view/e2e.spec.ts index 7e155f8492..d9bf0c149e 100644 --- a/test/admin/e2e/list-view/e2e.spec.ts +++ b/test/admin/e2e/list-view/e2e.spec.ts @@ -1,11 +1,10 @@ import type { Page } from '@playwright/test' -import type { User as PayloadUser } from 'payload' import { expect, test } from '@playwright/test' import { mapAsync } from 'payload' import * as qs from 'qs-esm' -import type { Config, Geo, Post, User } from '../../payload-types.js' +import type { Config, Geo, Post } from '../../payload-types.js' import { ensureCompilationIsDone, @@ -21,6 +20,7 @@ import { customViews1CollectionSlug, geoCollectionSlug, listDrawerSlug, + placeholderCollectionSlug, postsCollectionSlug, with300DocumentsSlug, } from '../../slugs.js' @@ -64,6 +64,7 @@ describe('List View', () => { let customViewsUrl: AdminUrlUtil let with300DocumentsUrl: AdminUrlUtil let withListViewUrl: AdminUrlUtil + let placeholderUrl: AdminUrlUtil let user: any let serverURL: string @@ -87,7 +88,7 @@ describe('List View', () => { baseListFiltersUrl = new AdminUrlUtil(serverURL, 'base-list-filters') customViewsUrl = new AdminUrlUtil(serverURL, customViews1CollectionSlug) withListViewUrl = new AdminUrlUtil(serverURL, listDrawerSlug) - + placeholderUrl = new AdminUrlUtil(serverURL, placeholderCollectionSlug) const context = await browser.newContext() page = await context.newPage() initPageConsoleErrorCatch(page) @@ -1408,6 +1409,66 @@ describe('List View', () => { ).toHaveText('Title') }) }) + + describe('placeholder', () => { + test('should display placeholder in filter options', async () => { + await page.goto( + `${placeholderUrl.list}${qs.stringify( + { + where: { + or: [ + { + and: [ + { + defaultSelect: { + equals: '', + }, + }, + { + placeholderSelect: { + equals: '', + }, + }, + { + defaultRelationship: { + equals: '', + }, + }, + { + placeholderRelationship: { + equals: '', + }, + }, + ], + }, + ], + }, + }, + { addQueryPrefix: true }, + )}`, + ) + + const conditionValueSelects = page.locator('#list-controls-where .condition__value') + await expect(conditionValueSelects.nth(0)).toHaveText('Select a value') + await expect(conditionValueSelects.nth(1)).toHaveText('Custom placeholder') + await expect(conditionValueSelects.nth(2)).toHaveText('Select a value') + await expect(conditionValueSelects.nth(3)).toHaveText('Custom placeholder') + }) + }) + test('should display placeholder in edit view', async () => { + await page.goto(placeholderUrl.create) + + await expect(page.locator('#field-defaultSelect .rs__placeholder')).toHaveText('Select a value') + await expect(page.locator('#field-placeholderSelect .rs__placeholder')).toHaveText( + 'Custom placeholder', + ) + await expect(page.locator('#field-defaultRelationship .rs__placeholder')).toHaveText( + 'Select a value', + ) + await expect(page.locator('#field-placeholderRelationship .rs__placeholder')).toHaveText( + 'Custom placeholder', + ) + }) }) async function createPost(overrides?: Partial): Promise { diff --git a/test/admin/slugs.ts b/test/admin/slugs.ts index a64f1085fa..93f0739321 100644 --- a/test/admin/slugs.ts +++ b/test/admin/slugs.ts @@ -14,6 +14,7 @@ export const noApiViewCollectionSlug = 'collection-no-api-view' export const disableDuplicateSlug = 'disable-duplicate' export const disableCopyToLocale = 'disable-copy-to-locale' export const uploadCollectionSlug = 'uploads' +export const placeholderCollectionSlug = 'placeholder' export const uploadTwoCollectionSlug = 'uploads-two' export const customFieldsSlug = 'custom-fields' From 5365d4f1c2e9137b8835a74a26bd47d27dcdc5d3 Mon Sep 17 00:00:00 2001 From: qoheleth-tech Date: Thu, 1 May 2025 12:52:23 -0700 Subject: [PATCH 29/34] docs: repair blank template markdown link in installation docs (#12297) ### What? Fix link to "Blank Template" in installation.mdx so that it displays correctly on the web. ### Why? Text of broken md link looks bad. ### How? Remove angle brackets. ### Fixes: ![2025-05-01 12 26 01 payloadcms com aa355d5f4756](https://github.com/user-attachments/assets/6da465e9-49ba-4784-bdd9-37ead6ba374b) --- docs/getting-started/installation.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/getting-started/installation.mdx b/docs/getting-started/installation.mdx index 2f507862a6..e85f6d5469 100644 --- a/docs/getting-started/installation.mdx +++ b/docs/getting-started/installation.mdx @@ -81,7 +81,7 @@ To install a Database Adapter, you can run **one** of the following commands: #### 2. Copy Payload files into your Next.js app folder -Payload installs directly in your Next.js `/app` folder, and you'll need to place some files into that folder for Payload to run. You can copy these files from the [Blank Template]() on GitHub. Once you have the required Payload files in place in your `/app` folder, you should have something like this: +Payload installs directly in your Next.js `/app` folder, and you'll need to place some files into that folder for Payload to run. You can copy these files from the [Blank Template](https://github.com/payloadcms/payload/tree/main/templates/blank/src/app/(payload)) on GitHub. Once you have the required Payload files in place in your `/app` folder, you should have something like this: ```plaintext app/ From b6b02ac97c4dce9fed8fd1f51bbd68ef418f5a4d Mon Sep 17 00:00:00 2001 From: Tobias Odendahl Date: Fri, 2 May 2025 15:21:02 +0200 Subject: [PATCH 30/34] fix(ui): fix version list status for unpublished documents (#11983) ### What? Fixes the label for documents which were the current published document but got unpublished in the version view. ### Why? If the most recent published document was unpublished, it remained displayed as "Currently published version" in the version list. ### How? Checks whether the document has a currently published version instead of only looking at the latest published version when determining the label in the versions view. Fixes https://github.com/payloadcms/payload/issues/10838 --------- Co-authored-by: Alessio Gravili Co-authored-by: Dan Ribbens --- .../views/Version/SelectComparison/index.tsx | 16 ++- packages/next/src/views/Versions/index.tsx | 33 +++++- test/versions/e2e.spec.ts | 110 ++++++++++++++++++ 3 files changed, 151 insertions(+), 8 deletions(-) diff --git a/packages/next/src/views/Version/SelectComparison/index.tsx b/packages/next/src/views/Version/SelectComparison/index.tsx index 82f8ef2958..37c50742e1 100644 --- a/packages/next/src/views/Version/SelectComparison/index.tsx +++ b/packages/next/src/views/Version/SelectComparison/index.tsx @@ -2,7 +2,14 @@ import type { PaginatedDocs, Where } from 'payload' -import { fieldBaseClass, Pill, ReactSelect, useConfig, useTranslation } from '@payloadcms/ui' +import { + fieldBaseClass, + Pill, + ReactSelect, + useConfig, + useDocumentInfo, + useTranslation, +} from '@payloadcms/ui' import { formatDate } from '@payloadcms/ui/shared' import { stringify } from 'qs-esm' import React, { useCallback, useEffect, useState } from 'react' @@ -37,6 +44,8 @@ export const SelectComparison: React.FC = (props) => { }, } = useConfig() + const { hasPublishedDoc } = useDocumentInfo() + const [options, setOptions] = useState< { label: React.ReactNode | string @@ -109,7 +118,10 @@ export const SelectComparison: React.FC = (props) => { }, published: { currentLabel: t('version:currentPublishedVersion'), - latestVersion: latestPublishedVersion, + // The latest published version does not necessarily equal the current published version, + // because the latest published version might have been unpublished in the meantime. + // Hence, we should only use the latest published version if there is a published document. + latestVersion: hasPublishedDoc ? latestPublishedVersion : undefined, pillStyle: 'success', previousLabel: t('version:previouslyPublished'), }, diff --git a/packages/next/src/views/Versions/index.tsx b/packages/next/src/views/Versions/index.tsx index 54e05e0088..744ddfd08b 100644 --- a/packages/next/src/views/Versions/index.tsx +++ b/packages/next/src/views/Versions/index.tsx @@ -85,13 +85,34 @@ export async function VersionsView(props: DocumentViewServerProps) { payload, status: 'draft', }) - latestPublishedVersion = await getLatestVersion({ - slug: collectionSlug, - type: 'collection', - parentID: id, - payload, - status: 'published', + const publishedDoc = await payload.count({ + collection: collectionSlug, + depth: 0, + overrideAccess: true, + req, + where: { + id: { + equals: id, + }, + _status: { + equals: 'published', + }, + }, }) + + // If we pass a latestPublishedVersion to buildVersionColumns, + // this will be used to display it as the "current published version". + // However, the latest published version might have been unpublished in the meantime. + // Hence, we should only pass the latest published version if there is a published document. + latestPublishedVersion = + publishedDoc.totalDocs > 0 && + (await getLatestVersion({ + slug: collectionSlug, + type: 'collection', + parentID: id, + payload, + status: 'published', + })) } } catch (err) { logError({ err, payload }) diff --git a/test/versions/e2e.spec.ts b/test/versions/e2e.spec.ts index 2798fb52cd..f22e7fd5e1 100644 --- a/test/versions/e2e.spec.ts +++ b/test/versions/e2e.spec.ts @@ -205,6 +205,116 @@ describe('Versions', () => { await expect(page.locator('#field-title')).toHaveValue('v1') }) + test('should show currently published version status in versions view', async () => { + const publishedDoc = await payload.create({ + collection: draftCollectionSlug, + data: { + _status: 'published', + title: 'title', + description: 'description', + }, + overrideAccess: true, + }) + + await page.goto(`${url.edit(publishedDoc.id)}/versions`) + await expect(page.locator('main.versions')).toContainText('Current Published Version') + }) + + test('should show unpublished version status in versions view', async () => { + const publishedDoc = await payload.create({ + collection: draftCollectionSlug, + data: { + _status: 'published', + title: 'title', + description: 'description', + }, + overrideAccess: true, + }) + + // Unpublish the document + await payload.update({ + collection: draftCollectionSlug, + id: publishedDoc.id, + data: { + _status: 'draft', + }, + draft: false, + }) + + await page.goto(`${url.edit(publishedDoc.id)}/versions`) + await expect(page.locator('main.versions')).toContainText('Previously Published') + }) + + test('should show global versions view level action in globals versions view', async () => { + const global = new AdminUrlUtil(serverURL, draftGlobalSlug) + await page.goto(`${global.global(draftGlobalSlug)}/versions`) + await expect(page.locator('.app-header .global-versions-button')).toHaveCount(1) + }) + + test('global — has versions tab', async () => { + const global = new AdminUrlUtil(serverURL, draftGlobalSlug) + await page.goto(global.global(draftGlobalSlug)) + + const docURL = page.url() + const pathname = new URL(docURL).pathname + + const versionsTab = page.locator('.doc-tab', { + hasText: 'Versions', + }) + await versionsTab.waitFor({ state: 'visible' }) + + expect(versionsTab).toBeTruthy() + const href = versionsTab.locator('a').first() + await expect(href).toHaveAttribute('href', `${pathname}/versions`) + }) + + test('global — respects max number of versions', async () => { + await payload.updateGlobal({ + slug: draftWithMaxGlobalSlug, + data: { + title: 'initial title', + }, + }) + + const global = new AdminUrlUtil(serverURL, draftWithMaxGlobalSlug) + await page.goto(global.global(draftWithMaxGlobalSlug)) + + const titleFieldInitial = page.locator('#field-title') + await titleFieldInitial.fill('updated title') + await saveDocAndAssert(page, '#action-save-draft') + await expect(titleFieldInitial).toHaveValue('updated title') + + const versionsTab = page.locator('.doc-tab', { + hasText: '1', + }) + + await versionsTab.waitFor({ state: 'visible' }) + + expect(versionsTab).toBeTruthy() + + const titleFieldUpdated = page.locator('#field-title') + await titleFieldUpdated.fill('latest title') + await saveDocAndAssert(page, '#action-save-draft') + await expect(titleFieldUpdated).toHaveValue('latest title') + + const versionsTabUpdated = page.locator('.doc-tab', { + hasText: '1', + }) + + await versionsTabUpdated.waitFor({ state: 'visible' }) + + expect(versionsTabUpdated).toBeTruthy() + }) + + test('global — has versions route', async () => { + const global = new AdminUrlUtil(serverURL, autoSaveGlobalSlug) + const versionsURL = `${global.global(autoSaveGlobalSlug)}/versions` + await page.goto(versionsURL) + await expect(() => { + expect(page.url()).toMatch(/\/versions/) + }).toPass({ timeout: 10000, intervals: [100] }) + }) + test('collection - should autosave', async () => { await page.goto(autosaveURL.create) await page.locator('#field-title').fill('autosave title') From a62cdc89d887780b33568fde9ca45c047ffd489f Mon Sep 17 00:00:00 2001 From: Philipp Schneider <47689073+philipp-tailor@users.noreply.github.com> Date: Fri, 2 May 2025 16:18:11 +0200 Subject: [PATCH 31/34] fix(ui): blockType ignored when merging server form state (#12207) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In this case, the `blockType` property is created on the server, but - prior to this fix - was discarded on the client in [`fieldReducer.ts`](https://github.com/payloadcms/payload/blob/main/packages/ui/src/forms/Form/fieldReducer.ts#L186-L198) via [`mergerServerFormState.ts`](https://github.com/payloadcms/payload/blob/b9832f40e41fff0a5a5b7bf7b08032aa2d6e8b4e/packages/ui/src/forms/Form/mergeServerFormState.ts#L29-L31), because the field's path neither existed in the client's form state, nor was it marked as `addedByServer`. This caused later calls to POST requests to form state to send without the `blockType` key for block rows, which in turn caused `addFieldStatePromise.ts` to throw the following error: ``` Block with type "undefined" was found in block data, but no block with that type is defined in the config for field with schema path ${schemaPath}. ``` This prevented the client side form state update from completing, and if the form state was saved, broke the document. This is a follow-up to #12131, which treated the symptom, but not the cause. The original issue seems to have been introduced in https://github.com/payloadcms/payload/releases/tag/v3.34.0. It's unclear to me whether this issue is connected to block E2E tests having been disabled in the same release in https://github.com/payloadcms/payload/pull/11988. ## How to reproduce ### Collection configuration ```ts const RICH_TEXT_BLOCK_TYPE = 'richTextBlockType' const RichTextBlock: Block = { slug: RICH_TEXT_BLOCK_TYPE, interfaceName: 'RichTextBlock', fields: [ { name: 'richTextBlockField', label: 'Rich Text Field in Block Field', type: 'richText', editor: lexicalEditor({}), required: true, }, ], } const MyCollection: CollectionConfig = { slug: 'my-collection-slug, fields: [ { name: 'arrayField', label: 'Array Field', type: 'array', fields: [ { name: 'blockField', type: 'blocks', blocks: [RichTextBlock], required: true, }, ], }, ] } export default MyCollection ``` ### Steps - Press "Add Array Field" --> ✅ 1st block with rich text is added - Press "Add Array Field" a 2nd time ### Result - 🛑 2nd block is indefinitely in loading state (side-note: the form UI should preferably explicitly indicate the error). - 🛑 If saving the document, it is corrupted and will only show a blank page (also not indicating any error). Client side: Untitled API error: image Client side, when saving and re-opening document (API error of `GET /admin/collections/${myCollection}/${documentId}` is the same (arguably the HTTP response status code shouldn't be `200`)): image ### Result after fix - `blockType` is sent from the client to the server. - ✅ 2nd block with rich text is added. - ✅ Document does not break when saving & re-opening. Untitled --------- Co-authored-by: Jacob Fletcher --- .../forms/fieldSchemasToFormState/addFieldStatePromise.ts | 4 ++++ test/form-state/int.spec.ts | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts b/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts index b3896560dc..56035e4d1e 100644 --- a/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts +++ b/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts @@ -456,6 +456,10 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom value: row.blockType, } + if (addedByServer) { + state[fieldKey].addedByServer = addedByServer + } + if (includeSchema) { state[fieldKey].fieldSchema = block.fields.find( (blockField) => 'name' in blockField && blockField.name === 'blockType', diff --git a/test/form-state/int.spec.ts b/test/form-state/int.spec.ts index 525e99e419..d81d4ae73f 100644 --- a/test/form-state/int.spec.ts +++ b/test/form-state/int.spec.ts @@ -228,6 +228,12 @@ describe('Form State', () => { collection: postsSlug, data: { title: 'Test Post', + blocks: [ + { + blockType: 'text', + text: 'Test block', + }, + ], }, }) @@ -248,6 +254,7 @@ describe('Form State', () => { }) expect(state.title?.addedByServer).toBe(true) + expect(state['blocks.0.blockType']?.addedByServer).toBe(true) // Ensure that `addedByServer` is removed after being received by the client const newState = mergeServerFormState({ From 055a263af343fb2c86ca79f1355d7b92280d37a3 Mon Sep 17 00:00:00 2001 From: Bamsi <99365485+francescoamici@users.noreply.github.com> Date: Fri, 2 May 2025 18:39:45 +0200 Subject: [PATCH 32/34] docs: fix typo in fields/relationship.mdx (#12306) --- docs/fields/relationship.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/fields/relationship.mdx b/docs/fields/relationship.mdx index 96f3b1ebe2..12e95b2896 100644 --- a/docs/fields/relationship.mdx +++ b/docs/fields/relationship.mdx @@ -150,7 +150,7 @@ The `filterOptions` property can either be a `Where` query, or a function return | `id` | The `id` of the current document being edited. Will be `undefined` during the `create` operation or when called on a `Filter` component within the list view. | | `relationTo` | The collection `slug` to filter against, limited to this field's `relationTo` property. | | `req` | The Payload Request, which contains references to `payload`, `user`, `locale`, and more. | -| `siblingData` | An object containing document data that is scoped to only fields within the same parent of this field. Will be an emprt object when called on a `Filter` component within the list view. | +| `siblingData` | An object containing document data that is scoped to only fields within the same parent of this field. Will be an empty object when called on a `Filter` component within the list view. | | `user` | An object containing the currently authenticated user. | ## Example From 1ef1c5564d1b4b48db87678321eb3fbd4f53e8cc Mon Sep 17 00:00:00 2001 From: Tobias Odendahl Date: Fri, 2 May 2025 19:03:51 +0200 Subject: [PATCH 33/34] feat(ui): add option to open related documents in a new tab (#11939) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### What? Selected documents in a relationship field can be opened in a new tab. ### Why? Related documents can be edited using the edit icon which opens the document in a drawer. Sometimes users would like to open the document in a new tab instead to e.g. modify the related document at a later point in time. This currently requires users to find the related document via the list view and open it there. There is no easy way to find and open a related document. ### How? Adds custom handling to the relationship edit button to support opening it in a new tab via middle-click, Ctrl+click, or right-click → 'Open in new tab'. --------- Co-authored-by: Jacob Fletcher --- packages/ui/src/elements/ReactSelect/types.ts | 6 +-- packages/ui/src/fields/Relationship/index.tsx | 39 ++++++++++++------ .../MultiValueLabel/index.tsx | 7 ++-- .../select-components/SingleValue/index.tsx | 7 ++-- .../collections/Relationship/e2e.spec.ts | 40 +++++++++++++++++++ test/helpers/e2e/toggleDocDrawer.ts | 8 +++- tsconfig.base.json | 2 +- 7 files changed, 85 insertions(+), 24 deletions(-) diff --git a/packages/ui/src/elements/ReactSelect/types.ts b/packages/ui/src/elements/ReactSelect/types.ts index 2f4b8592d6..58e324b1ba 100644 --- a/packages/ui/src/elements/ReactSelect/types.ts +++ b/packages/ui/src/elements/ReactSelect/types.ts @@ -1,12 +1,11 @@ import type { LabelFunction } from 'payload' import type { CommonProps, GroupBase, Props as ReactSelectStateManagerProps } from 'react-select' -import type { DocumentDrawerProps, UseDocumentDrawer } from '../DocumentDrawer/types.js' +import type { DocumentDrawerProps } from '../DocumentDrawer/types.js' type CustomSelectProps = { disableKeyDown?: boolean disableMouseDown?: boolean - DocumentDrawerToggler?: ReturnType[1] draggableProps?: any droppableRef?: React.RefObject editableProps?: ( @@ -15,10 +14,11 @@ type CustomSelectProps = { selectProps: ReactSelectStateManagerProps, ) => any onDelete?: DocumentDrawerProps['onDelete'] - onDocumentDrawerOpen?: (args: { + onDocumentOpen?: (args: { collectionSlug: string hasReadPermission: boolean id: number | string + openInNewTab?: boolean }) => void onDuplicate?: DocumentDrawerProps['onSave'] onSave?: DocumentDrawerProps['onSave'] diff --git a/packages/ui/src/fields/Relationship/index.tsx b/packages/ui/src/fields/Relationship/index.tsx index 3d093c0c66..21cbaa4299 100644 --- a/packages/ui/src/fields/Relationship/index.tsx +++ b/packages/ui/src/fields/Relationship/index.tsx @@ -7,7 +7,7 @@ import type { } from 'payload' import { dequal } from 'dequal/lite' -import { wordBoundariesRegex } from 'payload/shared' +import { formatAdminURL, wordBoundariesRegex } from 'payload/shared' import * as qs from 'qs-esm' import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react' @@ -83,7 +83,7 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) => const hasMultipleRelations = Array.isArray(relationTo) const [currentlyOpenRelationship, setCurrentlyOpenRelationship] = useState< - Parameters[0] + Parameters[0] >({ id: undefined, collectionSlug: undefined, @@ -631,16 +631,29 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) => return r.test(labelString.slice(-breakApartThreshold)) }, []) - const onDocumentDrawerOpen = useCallback< - ReactSelectAdapterProps['customProps']['onDocumentDrawerOpen'] - >(({ id, collectionSlug, hasReadPermission }) => { - openDrawerWhenRelationChanges.current = true - setCurrentlyOpenRelationship({ - id, - collectionSlug, - hasReadPermission, - }) - }, []) + const onDocumentOpen = useCallback( + ({ id, collectionSlug, hasReadPermission, openInNewTab }) => { + if (openInNewTab) { + if (hasReadPermission && id && collectionSlug) { + const docUrl = formatAdminURL({ + adminRoute: config.routes.admin, + path: `/collections/${collectionSlug}/${id}`, + }) + + window.open(docUrl, '_blank') + } + } else { + openDrawerWhenRelationChanges.current = true + + setCurrentlyOpenRelationship({ + id, + collectionSlug, + hasReadPermission, + }) + } + }, + [setCurrentlyOpenRelationship, config.routes.admin], + ) useEffect(() => { if (openDrawerWhenRelationChanges.current) { @@ -697,7 +710,7 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) => customProps={{ disableKeyDown: isDrawerOpen || isListDrawerOpen, disableMouseDown: isDrawerOpen || isListDrawerOpen, - onDocumentDrawerOpen, + onDocumentOpen, onSave, }} disabled={readOnly || disabled || isDrawerOpen || isListDrawerOpen} diff --git a/packages/ui/src/fields/Relationship/select-components/MultiValueLabel/index.tsx b/packages/ui/src/fields/Relationship/select-components/MultiValueLabel/index.tsx index 03cb247a53..adc01e11d4 100644 --- a/packages/ui/src/fields/Relationship/select-components/MultiValueLabel/index.tsx +++ b/packages/ui/src/fields/Relationship/select-components/MultiValueLabel/index.tsx @@ -25,7 +25,7 @@ export const MultiValueLabel: React.FC< > = (props) => { const { data: { allowEdit, label, relationTo, value }, - selectProps: { customProps: { draggableProps, onDocumentDrawerOpen } = {} } = {}, + selectProps: { customProps: { draggableProps, onDocumentOpen } = {} } = {}, } = props const { permissions } = useAuth() @@ -49,12 +49,13 @@ export const MultiValueLabel: React.FC<