diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS deleted file mode 100644 index b3b3e4d608..0000000000 --- a/.github/CODEOWNERS +++ /dev/null @@ -1,37 +0,0 @@ -# Order matters. The last matching pattern takes precedence. - -### Package Exports - -**/exports/ @denolfe @jmikrut @DanRibbens - -### Packages - -/packages/plugin-cloud*/src/ @denolfe @jmikrut @DanRibbens -/packages/email-*/src/ @denolfe @jmikrut @DanRibbens -/packages/live-preview*/src/ @jacobsfletch -/packages/plugin-stripe/src/ @jacobsfletch -/packages/plugin-multi-tenant/src/ @JarrodMFlesch -/packages/richtext-*/src/ @AlessioGr -/packages/next/src/ @jmikrut @jacobsfletch @AlessioGr @JarrodMFlesch -/packages/ui/src/ @jmikrut @jacobsfletch @AlessioGr @JarrodMFlesch -/packages/storage-*/src/ @denolfe @jmikrut @DanRibbens -/packages/create-payload-app/src/ @denolfe @jmikrut @DanRibbens -/packages/eslint-*/ @denolfe @jmikrut @DanRibbens @AlessioGr - -### Templates - -/templates/_data/ @denolfe @jmikrut @DanRibbens -/templates/_template/ @denolfe @jmikrut @DanRibbens - -### Build Files - -**/tsconfig*.json @denolfe @jmikrut @DanRibbens @AlessioGr -**/jest.config.js @denolfe @jmikrut @DanRibbens @AlessioGr - -### Root - -/package.json @denolfe @jmikrut @DanRibbens -/tools/ @denolfe @jmikrut @DanRibbens -/.husky/ @denolfe @jmikrut @DanRibbens -/.vscode/ @denolfe @jmikrut @DanRibbens @AlessioGr -/.github/ @denolfe @jmikrut @DanRibbens diff --git a/docs/configuration/overview.mdx b/docs/configuration/overview.mdx index 74f4f00cea..c51de58e2b 100644 --- a/docs/configuration/overview.mdx +++ b/docs/configuration/overview.mdx @@ -63,41 +63,41 @@ export default buildConfig({ The following options are available: -| Option | Description | -| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **`admin`** | The configuration options for the Admin Panel, including Custom Components, Live Preview, etc. [More details](../admin/overview#admin-options). | -| **`bin`** | Register custom bin scripts for Payload to execute. | -| **`editor`** | The Rich Text Editor which will be used by `richText` fields. [More details](../rich-text/overview). | -| **`db`** * | The Database Adapter which will be used by Payload. [More details](../database/overview). | -| **`serverURL`** | A string used to define the absolute URL of your app. This includes the protocol, for example `https://example.com`. No paths allowed, only protocol, domain and (optionally) port. | -| **`collections`** | An array of Collections for Payload to manage. [More details](./collections). | -| **`compatibility`** | Compatibility flags for earlier versions of Payload. [More details](#compatibility-flags). | -| **`globals`** | An array of Globals for Payload to manage. [More details](./globals). | -| **`cors`** | Cross-origin resource sharing (CORS) is a mechanism that accept incoming requests from given domains. You can also customize the `Access-Control-Allow-Headers` header. [More details](#cors). | -| **`localization`** | Opt-in to translate your content into multiple locales. [More details](./localization). | -| **`logger`** | Logger options, logger options with a destination stream, or an instantiated logger instance. [More details](https://getpino.io/#/docs/api?id=options). | -| **`loggingLevels`** | An object to override the level to use in the logger for Payload's errors. | -| **`graphQL`** | Manage GraphQL-specific functionality, including custom queries and mutations, query complexity limits, etc. [More details](../graphql/overview#graphql-options). | -| **`cookiePrefix`** | A string that will be prefixed to all cookies that Payload sets. | -| **`csrf`** | A whitelist array of URLs to allow Payload to accept cookies from. [More details](../authentication/cookies#csrf-attacks). | -| **`defaultDepth`** | If a user does not specify `depth` while requesting a resource, this depth will be used. [More details](../queries/depth). | -| **`defaultMaxTextLength`** | The maximum allowed string length to be permitted application-wide. Helps to prevent malicious public document creation. | -| **`maxDepth`** | The maximum allowed depth to be permitted application-wide. This setting helps prevent against malicious queries. Defaults to `10`. [More details](../queries/depth). | -| **`indexSortableFields`** | Automatically index all sortable top-level fields in the database to improve sort performance and add database compatibility for Azure Cosmos and similar. | -| **`upload`** | Base Payload upload configuration. [More details](../upload/overview#payload-wide-upload-options). | -| **`routes`** | Control the routing structure that Payload binds itself to. [More details](../admin/overview#root-level-routes). | -| **`email`** | Configure the Email Adapter for Payload to use. [More details](../email/overview). | -| **`onInit`** | A function that is called immediately following startup that receives the Payload instance as its only argument. | -| **`debug`** | Enable to expose more detailed error information. | -| **`telemetry`** | Disable Payload telemetry by passing `false`. [More details](#telemetry). | -| **`hooks`** | An array of Root Hooks. [More details](../hooks/overview). | -| **`plugins`** | An array of Plugins. [More details](../plugins/overview). | -| **`endpoints`** | An array of Custom Endpoints added to the Payload router. [More details](../rest-api/overview#custom-endpoints). | -| **`custom`** | Extension point for adding custom data (e.g. for plugins). | -| **`i18n`** | Internationalization configuration. Pass all i18n languages you'd like the admin UI to support. Defaults to English-only. [More details](./i18n). | -| **`secret`** * | A secure, unguessable string that Payload will use for any encryption workflows - for example, password salt / hashing. | -| **`sharp`** | If you would like Payload to offer cropping, focal point selection, and automatic media resizing, install and pass the Sharp module to the config here. | -| **`typescript`** | Configure TypeScript settings here. [More details](#typescript). | +| Option | Description | +| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **`admin`** | The configuration options for the Admin Panel, including Custom Components, Live Preview, etc. [More details](../admin/overview#admin-options). | +| **`bin`** | Register custom bin scripts for Payload to execute. [More Details](#custom-bin-scripts). | +| **`editor`** | The Rich Text Editor which will be used by `richText` fields. [More details](../rich-text/overview). | +| **`db`** * | The Database Adapter which will be used by Payload. [More details](../database/overview). | +| **`serverURL`** | A string used to define the absolute URL of your app. This includes the protocol, for example `https://example.com`. No paths allowed, only protocol, domain and (optionally) port. | +| **`collections`** | An array of Collections for Payload to manage. [More details](./collections). | +| **`compatibility`** | Compatibility flags for earlier versions of Payload. [More details](#compatibility-flags). | +| **`globals`** | An array of Globals for Payload to manage. [More details](./globals). | +| **`cors`** | Cross-origin resource sharing (CORS) is a mechanism that accept incoming requests from given domains. You can also customize the `Access-Control-Allow-Headers` header. [More details](#cors). | +| **`localization`** | Opt-in to translate your content into multiple locales. [More details](./localization). | +| **`logger`** | Logger options, logger options with a destination stream, or an instantiated logger instance. [More details](https://getpino.io/#/docs/api?id=options). | +| **`loggingLevels`** | An object to override the level to use in the logger for Payload's errors. | +| **`graphQL`** | Manage GraphQL-specific functionality, including custom queries and mutations, query complexity limits, etc. [More details](../graphql/overview#graphql-options). | +| **`cookiePrefix`** | A string that will be prefixed to all cookies that Payload sets. | +| **`csrf`** | A whitelist array of URLs to allow Payload to accept cookies from. [More details](../authentication/cookies#csrf-attacks). | +| **`defaultDepth`** | If a user does not specify `depth` while requesting a resource, this depth will be used. [More details](../queries/depth). | +| **`defaultMaxTextLength`** | The maximum allowed string length to be permitted application-wide. Helps to prevent malicious public document creation. | +| **`maxDepth`** | The maximum allowed depth to be permitted application-wide. This setting helps prevent against malicious queries. Defaults to `10`. [More details](../queries/depth). | +| **`indexSortableFields`** | Automatically index all sortable top-level fields in the database to improve sort performance and add database compatibility for Azure Cosmos and similar. | +| **`upload`** | Base Payload upload configuration. [More details](../upload/overview#payload-wide-upload-options). | +| **`routes`** | Control the routing structure that Payload binds itself to. [More details](../admin/overview#root-level-routes). | +| **`email`** | Configure the Email Adapter for Payload to use. [More details](../email/overview). | +| **`onInit`** | A function that is called immediately following startup that receives the Payload instance as its only argument. | +| **`debug`** | Enable to expose more detailed error information. | +| **`telemetry`** | Disable Payload telemetry by passing `false`. [More details](#telemetry). | +| **`hooks`** | An array of Root Hooks. [More details](../hooks/overview). | +| **`plugins`** | An array of Plugins. [More details](../plugins/overview). | +| **`endpoints`** | An array of Custom Endpoints added to the Payload router. [More details](../rest-api/overview#custom-endpoints). | +| **`custom`** | Extension point for adding custom data (e.g. for plugins). | +| **`i18n`** | Internationalization configuration. Pass all i18n languages you'd like the admin UI to support. Defaults to English-only. [More details](./i18n). | +| **`secret`** * | A secure, unguessable string that Payload will use for any encryption workflows - for example, password salt / hashing. | +| **`sharp`** | If you would like Payload to offer cropping, focal point selection, and automatic media resizing, install and pass the Sharp module to the config here. | +| **`typescript`** | Configure TypeScript settings here. [More details](#typescript). | _* An asterisk denotes that a property is required._ @@ -265,3 +265,43 @@ The Payload Config can accept compatibility flags for running the newest version Payload localization works on a field-by-field basis. As you can nest fields within other fields, you could potentially nest a localized field within a localized field—but this would be redundant and unnecessary. There would be no reason to define a localized field within a localized parent field, given that the entire data structure from the parent field onward would be localized. By default, Payload will remove the `localized: true` property from sub-fields if a parent field is localized. Set this compatibility flag to `true` only if you have an existing Payload MongoDB database from pre-3.0, and you have nested localized fields that you would like to maintain without migrating. + + +## Custom bin scripts + +Using the `bin` configuration property, you can inject your own scripts to `npx payload`. +Example for `pnpm payload seed`: + + +Step 1: create `seed.ts` file in the same folder with `payload.config.ts` with: + +```ts +import type { SanitizedConfig } from 'payload' + +import payload from 'payload' + +// Script must define a "script" function export that accepts the sanitized config +export const script = async (config: SanitizedConfig) => { + await payload.init({ config }) + await payload.create({ collection: 'pages', data: { title: 'my title' } }) + payload.logger.info('Succesffully seeded!') + process.exit(0) +} +``` + +Step 2: add the `seed` script to `bin`: +```ts +export default buildConfig({ + bin: [ + { + scriptPath: path.resolve(dirname, 'seed.ts'), + key: 'seed', + }, + ], +}) +``` + +Now you can run the command using: +```sh +pnpm payload seed +``` \ No newline at end of file diff --git a/docs/custom-components/overview.mdx b/docs/custom-components/overview.mdx index 78731517f4..bf0c376d26 100644 --- a/docs/custom-components/overview.mdx +++ b/docs/custom-components/overview.mdx @@ -276,7 +276,7 @@ export default async function MyServerComponent({ But, the Payload Config is [non-serializable](https://react.dev/reference/rsc/use-client#serializable-types) by design. It is full of custom validation functions and more. This means that the Payload Config, in its entirety, cannot be passed directly to Client Components. -For this reason, Payload creates a Client Config and passes it into the Config Provider. This is a serializable version of the Payload Config that can be accessed from any Client Component via the [`useConfig`](../admin/hooks#useconfig) hook: +For this reason, Payload creates a Client Config and passes it into the Config Provider. This is a serializable version of the Payload Config that can be accessed from any Client Component via the [`useConfig`](../admin/react-hooks#useconfig) hook: ```tsx 'use client' @@ -375,7 +375,7 @@ export function MyClientComponent() { ``` - See the [Hooks](../admin/hooks) documentation for a full list of available hooks. + See the [Hooks](../admin/react-hooks) documentation for a full list of available hooks. ### Getting the Current Locale @@ -422,12 +422,12 @@ function Greeting() { ``` - See the [Hooks](../admin/hooks) documentation for a full list of available hooks. + See the [Hooks](../admin/react-hooks) documentation for a full list of available hooks. ### Using Hooks -To make it easier to [build your Custom Components](#building-custom-components), you can use [Payload's built-in React Hooks](../admin/hooks) in any Client Component. For example, you might want to interact with one of Payload's many React Contexts. To do this, you can use one of the many hooks available depending on your needs. +To make it easier to [build your Custom Components](#building-custom-components), you can use [Payload's built-in React Hooks](../admin/react-hooks) in any Client Component. For example, you might want to interact with one of Payload's many React Contexts. To do this, you can use one of the many hooks available depending on your needs. ```tsx 'use client' @@ -444,7 +444,7 @@ export function MyClientComponent() { ``` - See the [Hooks](../admin/hooks) documentation for a full list of available hooks. + See the [Hooks](../admin/react-hooks) documentation for a full list of available hooks. ### Adding Styles diff --git a/docs/fields/overview.mdx b/docs/fields/overview.mdx index 2faed7329f..77f9f94872 100644 --- a/docs/fields/overview.mdx +++ b/docs/fields/overview.mdx @@ -658,7 +658,7 @@ In addition to the above props, all Server Components will also receive the foll When swapping out the `Field` component, you are responsible for sending and receiving the field's `value` from the form itself. -To do so, import the [`useField`](../admin/hooks#usefield) hook from `@payloadcms/ui` and use it to manage the field's value: +To do so, import the [`useField`](../admin/react-hooks#usefield) hook from `@payloadcms/ui` and use it to manage the field's value: ```tsx 'use client' @@ -677,7 +677,7 @@ export const CustomTextField: React.FC = () => { ``` - For a complete list of all available React hooks, see the [Payload React Hooks](../admin/hooks) documentation. For additional help, see [Building Custom Components](../custom-components/overview#building-custom-components). + For a complete list of all available React hooks, see the [Payload React Hooks](../admin/react-hooks) documentation. For additional help, see [Building Custom Components](../custom-components/overview#building-custom-components). ##### TypeScript#field-component-types diff --git a/docs/hooks/overview.mdx b/docs/hooks/overview.mdx index 7d489c6bad..82533ec3f3 100644 --- a/docs/hooks/overview.mdx +++ b/docs/hooks/overview.mdx @@ -27,7 +27,7 @@ There are four main types of Hooks in Payload: **Reminder:** - Payload also ships a set of _React_ hooks that you can use in your frontend application. Although they share a common name, these are very different things and should not be confused. [More details](../admin/hooks). + Payload also ships a set of _React_ hooks that you can use in your frontend application. Although they share a common name, these are very different things and should not be confused. [More details](../admin/react-hooks). ## Root Hooks diff --git a/docs/migration-guide/overview.mdx b/docs/migration-guide/overview.mdx index 99e4bf9990..386cee9a5c 100644 --- a/docs/migration-guide/overview.mdx +++ b/docs/migration-guide/overview.mdx @@ -414,6 +414,15 @@ For more details, see the [Documentation](https://payloadcms.com/docs/getting-st ``` 1. The `./src/public` directory is now located directly at root level `./public` [see Next.js docs for details](https://nextjs.org/docs/pages/building-your-application/optimizing/static-assets) +1. Payload now automatically removes `localized: true` property from sub-fields if a parent is localized, as it's redunant and unnecessary. If you have some existing data in this structure and you want to disable that behavior, you need to enable `allowLocalizedWithinLocalized` flag in your payload.config [read more in documentation](https://payloadcms.com/docs/configuration/overview#compatibility-flags), or create a migration script that aligns your data. +Mongodb example for a link in a page layout. + + ```diff + - layout.columns.en.link.en.type.en + + layout.columns.en.link.type + ``` + + ## Custom Components 1. All Payload React components have been moved from the `payload` package to `@payloadcms/ui`. If you were previously importing components into your app from the `payload` package, for example to create Custom Components, you will need to change your import paths: diff --git a/docs/plugins/multi-tenant.mdx b/docs/plugins/multi-tenant.mdx index b982a0c5e2..743b8281ad 100644 --- a/docs/plugins/multi-tenant.mdx +++ b/docs/plugins/multi-tenant.mdx @@ -278,6 +278,50 @@ async rewrites() { } ``` +### React Hooks + +Below are the hooks exported from the plugin that you can import into your own custom components to consume. + +#### useTenantSelection + +You can import this like so: + +```tsx +import { useTenantSelection } from '@payloadcms/plugin-multi-tenant/client' + +... + +const tenantContext = useTenantSelection() +``` + +The hook returns the following context: + +```ts +type ContextType = { + /** + * Array of options to select from + */ + options: OptionObject[] + /** + * The currently selected tenant ID + */ + selectedTenantID: number | string | undefined + /** + * Prevents a refresh when the tenant is changed + * + * If not switching tenants while viewing a "global", set to true + */ + setPreventRefreshOnChange: React.Dispatch> + /** + * Sets the selected tenant ID + * + * @param args.id - The ID of the tenant to select + * @param args.refresh - Whether to refresh the page after changing the tenant + */ + setTenant: (args: { id: number | string | undefined; refresh?: boolean }) => void +} +``` + ## Examples diff --git a/docs/rich-text/overview.mdx b/docs/rich-text/overview.mdx index 02ff5ad8ac..5374950e46 100644 --- a/docs/rich-text/overview.mdx +++ b/docs/rich-text/overview.mdx @@ -145,13 +145,13 @@ Here's an overview of all the included features: | Feature Name | Included by default | Description | | --- | --- | --- | -| **`BoldTextFeature`** | Yes | Handles the bold text format | -| **`ItalicTextFeature`** | Yes | Handles the italic text format | -| **`UnderlineTextFeature`** | Yes | Handles the underline text format | -| **`StrikethroughTextFeature`** | Yes | Handles the strikethrough text format | -| **`SubscriptTextFeature`** | Yes | Handles the subscript text format | -| **`SuperscriptTextFeature`** | Yes | Handles the superscript text format | -| **`InlineCodeTextFeature`** | Yes | Handles the inline-code text format | +| **`BoldFeature`** | Yes | Handles the bold text format | +| **`ItalicFeature`** | Yes | Handles the italic text format | +| **`UnderlineFeature`** | Yes | Handles the underline text format | +| **`StrikethroughFeature`** | Yes | Handles the strikethrough text format | +| **`SubscriptFeature`** | Yes | Handles the subscript text format | +| **`SuperscriptFeature`** | Yes | Handles the superscript text format | +| **`InlineCodeFeature`** | Yes | Handles the inline-code text format | | **`ParagraphFeature`** | Yes | Handles paragraphs. Since they are already a key feature of lexical itself, this Feature mainly handles the Slash and Add-Block menu entries for paragraphs | | **`HeadingFeature`** | Yes | Adds Heading Nodes (by default, H1 - H6, but that can be customized) | | **`AlignFeature`** | Yes | Allows you to align text left, centered and right | diff --git a/packages/db-mongodb/src/create.ts b/packages/db-mongodb/src/create.ts index 49e549b7e4..3c99e79324 100644 --- a/packages/db-mongodb/src/create.ts +++ b/packages/db-mongodb/src/create.ts @@ -5,7 +5,7 @@ import type { MongooseAdapter } from './index.js' import { getSession } from './utilities/getSession.js' import { handleError } from './utilities/handleError.js' -import { sanitizeRelationshipIDs } from './utilities/sanitizeRelationshipIDs.js' +import { transform } from './utilities/transform.js' export const create: Create = async function create( this: MongooseAdapter, @@ -18,31 +18,31 @@ export const create: Create = async function create( let doc - const sanitizedData = sanitizeRelationshipIDs({ - config: this.payload.config, + transform({ + adapter: this, data, fields: this.payload.collections[collection].config.fields, + operation: 'write', }) if (this.payload.collections[collection].customIDType) { - sanitizedData._id = sanitizedData.id + data._id = data.id } try { - ;[doc] = await Model.create([sanitizedData], options) + ;[doc] = await Model.create([data], options) } catch (error) { handleError({ collection, error, req }) } - // doc.toJSON does not do stuff like converting ObjectIds to string, or date strings to date objects. That's why we use JSON.parse/stringify here - const result: Document = JSON.parse(JSON.stringify(doc)) - const verificationToken = doc._verificationToken + doc = doc.toObject() - // custom id type reset - result.id = result._id - if (verificationToken) { - result._verificationToken = verificationToken - } + transform({ + adapter: this, + data: doc, + fields: this.payload.collections[collection].config.fields, + operation: 'read', + }) - return result + return doc } diff --git a/packages/db-mongodb/src/createGlobal.ts b/packages/db-mongodb/src/createGlobal.ts index 969eeaed7c..28c10b39ca 100644 --- a/packages/db-mongodb/src/createGlobal.ts +++ b/packages/db-mongodb/src/createGlobal.ts @@ -4,8 +4,7 @@ import type { CreateGlobal } from 'payload' import type { MongooseAdapter } from './index.js' import { getSession } from './utilities/getSession.js' -import { sanitizeInternalFields } from './utilities/sanitizeInternalFields.js' -import { sanitizeRelationshipIDs } from './utilities/sanitizeRelationshipIDs.js' +import { transform } from './utilities/transform.js' export const createGlobal: CreateGlobal = async function createGlobal( this: MongooseAdapter, @@ -13,26 +12,28 @@ export const createGlobal: CreateGlobal = async function createGlobal( ) { const Model = this.globals - const global = sanitizeRelationshipIDs({ - config: this.payload.config, - data: { - globalType: slug, - ...data, - }, + transform({ + adapter: this, + data, fields: this.payload.config.globals.find((globalConfig) => globalConfig.slug === slug).fields, + globalSlug: slug, + operation: 'write', }) const options: CreateOptions = { session: await getSession(this, req), } - let [result] = (await Model.create([global], options)) as any + let [result] = (await Model.create([data], options)) as any - result = JSON.parse(JSON.stringify(result)) + result = result.toObject() - // custom id type reset - result.id = result._id - result = sanitizeInternalFields(result) + transform({ + adapter: this, + data: result, + fields: this.payload.config.globals.find((globalConfig) => globalConfig.slug === slug).fields, + operation: 'read', + }) return result } diff --git a/packages/db-mongodb/src/createGlobalVersion.ts b/packages/db-mongodb/src/createGlobalVersion.ts index a6fe7ccb5f..2137ca84aa 100644 --- a/packages/db-mongodb/src/createGlobalVersion.ts +++ b/packages/db-mongodb/src/createGlobalVersion.ts @@ -1,11 +1,11 @@ import type { CreateOptions } from 'mongoose' -import { buildVersionGlobalFields, type CreateGlobalVersion, type Document } from 'payload' +import { buildVersionGlobalFields, type CreateGlobalVersion } from 'payload' import type { MongooseAdapter } from './index.js' import { getSession } from './utilities/getSession.js' -import { sanitizeRelationshipIDs } from './utilities/sanitizeRelationshipIDs.js' +import { transform } from './utilities/transform.js' export const createGlobalVersion: CreateGlobalVersion = async function createGlobalVersion( this: MongooseAdapter, @@ -26,25 +26,30 @@ export const createGlobalVersion: CreateGlobalVersion = async function createGlo session: await getSession(this, req), } - const data = sanitizeRelationshipIDs({ - config: this.payload.config, - data: { - autosave, - createdAt, - latest: true, - parent, - publishedLocale, - snapshot, - updatedAt, - version: versionData, - }, - fields: buildVersionGlobalFields( - this.payload.config, - this.payload.config.globals.find((global) => global.slug === globalSlug), - ), + const data = { + autosave, + createdAt, + latest: true, + parent, + publishedLocale, + snapshot, + updatedAt, + version: versionData, + } + + const fields = buildVersionGlobalFields( + this.payload.config, + this.payload.config.globals.find((global) => global.slug === globalSlug), + ) + + transform({ + adapter: this, + data, + fields, + operation: 'write', }) - const [doc] = await VersionModel.create([data], options, req) + let [doc] = await VersionModel.create([data], options, req) await VersionModel.updateMany( { @@ -70,13 +75,14 @@ export const createGlobalVersion: CreateGlobalVersion = async function createGlo options, ) - const result: Document = JSON.parse(JSON.stringify(doc)) - const verificationToken = doc._verificationToken + doc = doc.toObject() - // custom id type reset - result.id = result._id - if (verificationToken) { - result._verificationToken = verificationToken - } - return result + transform({ + adapter: this, + data: doc, + fields, + operation: 'read', + }) + + return doc } diff --git a/packages/db-mongodb/src/createVersion.ts b/packages/db-mongodb/src/createVersion.ts index 3482345e24..a0894adc88 100644 --- a/packages/db-mongodb/src/createVersion.ts +++ b/packages/db-mongodb/src/createVersion.ts @@ -1,12 +1,11 @@ import type { CreateOptions } from 'mongoose' -import { Types } from 'mongoose' -import { buildVersionCollectionFields, type CreateVersion, type Document } from 'payload' +import { buildVersionCollectionFields, type CreateVersion } from 'payload' import type { MongooseAdapter } from './index.js' import { getSession } from './utilities/getSession.js' -import { sanitizeRelationshipIDs } from './utilities/sanitizeRelationshipIDs.js' +import { transform } from './utilities/transform.js' export const createVersion: CreateVersion = async function createVersion( this: MongooseAdapter, @@ -27,25 +26,30 @@ export const createVersion: CreateVersion = async function createVersion( session: await getSession(this, req), } - const data = sanitizeRelationshipIDs({ - config: this.payload.config, - data: { - autosave, - createdAt, - latest: true, - parent, - publishedLocale, - snapshot, - updatedAt, - version: versionData, - }, - fields: buildVersionCollectionFields( - this.payload.config, - this.payload.collections[collectionSlug].config, - ), + const data = { + autosave, + createdAt, + latest: true, + parent, + publishedLocale, + snapshot, + updatedAt, + version: versionData, + } + + const fields = buildVersionCollectionFields( + this.payload.config, + this.payload.collections[collectionSlug].config, + ) + + transform({ + adapter: this, + data, + fields, + operation: 'write', }) - const [doc] = await VersionModel.create([data], options, req) + let [doc] = await VersionModel.create([data], options, req) const parentQuery = { $or: [ @@ -56,13 +60,6 @@ export const createVersion: CreateVersion = async function createVersion( }, ], } - if (data.parent instanceof Types.ObjectId) { - parentQuery.$or.push({ - parent: { - $eq: data.parent.toString(), - }, - }) - } await VersionModel.updateMany( { @@ -89,13 +86,14 @@ export const createVersion: CreateVersion = async function createVersion( options, ) - const result: Document = JSON.parse(JSON.stringify(doc)) - const verificationToken = doc._verificationToken + doc = doc.toObject() - // custom id type reset - result.id = result._id - if (verificationToken) { - result._verificationToken = verificationToken - } - return result + transform({ + adapter: this, + data: doc, + fields, + operation: 'read', + }) + + return doc } diff --git a/packages/db-mongodb/src/deleteOne.ts b/packages/db-mongodb/src/deleteOne.ts index 1ba5c2b7fd..0ae3231531 100644 --- a/packages/db-mongodb/src/deleteOne.ts +++ b/packages/db-mongodb/src/deleteOne.ts @@ -1,12 +1,12 @@ import type { QueryOptions } from 'mongoose' -import type { DeleteOne, Document } from 'payload' +import type { DeleteOne } from 'payload' import type { MongooseAdapter } from './index.js' import { buildQuery } from './queries/buildQuery.js' import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js' import { getSession } from './utilities/getSession.js' -import { sanitizeInternalFields } from './utilities/sanitizeInternalFields.js' +import { transform } from './utilities/transform.js' export const deleteOne: DeleteOne = async function deleteOne( this: MongooseAdapter, @@ -35,11 +35,12 @@ export const deleteOne: DeleteOne = async function deleteOne( return null } - let result: Document = JSON.parse(JSON.stringify(doc)) + transform({ + adapter: this, + data: doc, + fields: this.payload.collections[collection].config.fields, + operation: 'read', + }) - // custom id type reset - result.id = result._id - result = sanitizeInternalFields(result) - - return result + return doc } diff --git a/packages/db-mongodb/src/find.ts b/packages/db-mongodb/src/find.ts index ab8733f87c..65f556ec33 100644 --- a/packages/db-mongodb/src/find.ts +++ b/packages/db-mongodb/src/find.ts @@ -10,7 +10,7 @@ import { buildSortParam } from './queries/buildSortParam.js' import { buildJoinAggregation } from './utilities/buildJoinAggregation.js' import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js' import { getSession } from './utilities/getSession.js' -import { sanitizeInternalFields } from './utilities/sanitizeInternalFields.js' +import { transform } from './utilities/transform.js' export const find: Find = async function find( this: MongooseAdapter, @@ -133,13 +133,12 @@ export const find: Find = async function find( result = await Model.paginate(query, paginationOptions) } - const docs = JSON.parse(JSON.stringify(result.docs)) + transform({ + adapter: this, + data: result.docs, + fields: this.payload.collections[collection].config.fields, + operation: 'read', + }) - return { - ...result, - docs: docs.map((doc) => { - doc.id = doc._id - return sanitizeInternalFields(doc) - }), - } + return result } diff --git a/packages/db-mongodb/src/findGlobal.ts b/packages/db-mongodb/src/findGlobal.ts index 61b61abdf7..dbefa258c8 100644 --- a/packages/db-mongodb/src/findGlobal.ts +++ b/packages/db-mongodb/src/findGlobal.ts @@ -8,14 +8,15 @@ import type { MongooseAdapter } from './index.js' import { buildQuery } from './queries/buildQuery.js' import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js' import { getSession } from './utilities/getSession.js' -import { sanitizeInternalFields } from './utilities/sanitizeInternalFields.js' +import { transform } from './utilities/transform.js' export const findGlobal: FindGlobal = async function findGlobal( this: MongooseAdapter, { slug, locale, req, select, where }, ) { const Model = this.globals - const fields = this.payload.globals.config.find((each) => each.slug === slug).flattenedFields + const globalConfig = this.payload.globals.config.find((each) => each.slug === slug) + const fields = globalConfig.flattenedFields const options: QueryOptions = { lean: true, select: buildProjectionFromSelect({ @@ -34,18 +35,18 @@ export const findGlobal: FindGlobal = async function findGlobal( where: combineQueries({ globalType: { equals: slug } }, where), }) - let doc = (await Model.findOne(query, {}, options)) as any + const doc = (await Model.findOne(query, {}, options)) as any if (!doc) { return null } - if (doc._id) { - doc.id = doc._id - delete doc._id - } - doc = JSON.parse(JSON.stringify(doc)) - doc = sanitizeInternalFields(doc) + transform({ + adapter: this, + data: doc, + fields: globalConfig.fields, + operation: 'read', + }) return doc } diff --git a/packages/db-mongodb/src/findGlobalVersions.ts b/packages/db-mongodb/src/findGlobalVersions.ts index b028a8d224..2ecd21edde 100644 --- a/packages/db-mongodb/src/findGlobalVersions.ts +++ b/packages/db-mongodb/src/findGlobalVersions.ts @@ -9,18 +9,15 @@ import { buildQuery } from './queries/buildQuery.js' import { buildSortParam } from './queries/buildSortParam.js' import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js' import { getSession } from './utilities/getSession.js' -import { sanitizeInternalFields } from './utilities/sanitizeInternalFields.js' +import { transform } from './utilities/transform.js' export const findGlobalVersions: FindGlobalVersions = async function findGlobalVersions( this: MongooseAdapter, { global, limit, locale, page, pagination, req, select, skip, sort: sortArg, where }, ) { + const globalConfig = this.payload.globals.config.find(({ slug }) => slug === global) const Model = this.versions[global] - const versionFields = buildVersionGlobalFields( - this.payload.config, - this.payload.globals.config.find(({ slug }) => slug === global), - true, - ) + const versionFields = buildVersionGlobalFields(this.payload.config, globalConfig, true) const session = await getSession(this, req) const options: QueryOptions = { @@ -103,13 +100,13 @@ export const findGlobalVersions: FindGlobalVersions = async function findGlobalV } const result = await Model.paginate(query, paginationOptions) - const docs = JSON.parse(JSON.stringify(result.docs)) - return { - ...result, - docs: docs.map((doc) => { - doc.id = doc._id - return sanitizeInternalFields(doc) - }), - } + transform({ + adapter: this, + data: result.docs, + fields: buildVersionGlobalFields(this.payload.config, globalConfig), + operation: 'read', + }) + + return result } diff --git a/packages/db-mongodb/src/findOne.ts b/packages/db-mongodb/src/findOne.ts index 8db28b3d59..7fec58d949 100644 --- a/packages/db-mongodb/src/findOne.ts +++ b/packages/db-mongodb/src/findOne.ts @@ -1,5 +1,5 @@ import type { AggregateOptions, QueryOptions } from 'mongoose' -import type { Document, FindOne } from 'payload' +import type { FindOne } from 'payload' import type { MongooseAdapter } from './index.js' @@ -7,7 +7,7 @@ import { buildQuery } from './queries/buildQuery.js' import { buildJoinAggregation } from './utilities/buildJoinAggregation.js' import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js' import { getSession } from './utilities/getSession.js' -import { sanitizeInternalFields } from './utilities/sanitizeInternalFields.js' +import { transform } from './utilities/transform.js' export const findOne: FindOne = async function findOne( this: MongooseAdapter, @@ -58,11 +58,7 @@ export const findOne: FindOne = async function findOne( return null } - let result: Document = JSON.parse(JSON.stringify(doc)) + transform({ adapter: this, data: doc, fields: collectionConfig.fields, operation: 'read' }) - // custom id type reset - result.id = result._id - result = sanitizeInternalFields(result) - - return result + return doc } diff --git a/packages/db-mongodb/src/findVersions.ts b/packages/db-mongodb/src/findVersions.ts index 0f123b1f23..0b5eb37459 100644 --- a/packages/db-mongodb/src/findVersions.ts +++ b/packages/db-mongodb/src/findVersions.ts @@ -9,7 +9,7 @@ import { buildQuery } from './queries/buildQuery.js' import { buildSortParam } from './queries/buildSortParam.js' import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js' import { getSession } from './utilities/getSession.js' -import { sanitizeInternalFields } from './utilities/sanitizeInternalFields.js' +import { transform } from './utilities/transform.js' export const findVersions: FindVersions = async function findVersions( this: MongooseAdapter, @@ -104,13 +104,13 @@ export const findVersions: FindVersions = async function findVersions( } const result = await Model.paginate(query, paginationOptions) - const docs = JSON.parse(JSON.stringify(result.docs)) - return { - ...result, - docs: docs.map((doc) => { - doc.id = doc._id - return sanitizeInternalFields(doc) - }), - } + transform({ + adapter: this, + data: result.docs, + fields: buildVersionCollectionFields(this.payload.config, collectionConfig), + operation: 'read', + }) + + return result } diff --git a/packages/db-mongodb/src/models/buildSchema.ts b/packages/db-mongodb/src/models/buildSchema.ts index af7f501585..2c6d5b69ed 100644 --- a/packages/db-mongodb/src/models/buildSchema.ts +++ b/packages/db-mongodb/src/models/buildSchema.ts @@ -476,6 +476,7 @@ const fieldToSchemaMap: Record = { if (fieldShouldBeLocalized({ field, parentIsLocalized }) && payload.config.localization) { schemaToReturn = { + _id: false, type: payload.config.localization.localeCodes.reduce((locales, locale) => { let localeSchema: { [key: string]: any } = {} @@ -698,6 +699,7 @@ const fieldToSchemaMap: Record = { if (fieldShouldBeLocalized({ field, parentIsLocalized }) && payload.config.localization) { schemaToReturn = { + _id: false, type: payload.config.localization.localeCodes.reduce((locales, locale) => { let localeSchema: { [key: string]: any } = {} diff --git a/packages/db-mongodb/src/predefinedMigrations/migrateRelationshipsV2_V3.ts b/packages/db-mongodb/src/predefinedMigrations/migrateRelationshipsV2_V3.ts index 11d5f4b4fc..7eabd67769 100644 --- a/packages/db-mongodb/src/predefinedMigrations/migrateRelationshipsV2_V3.ts +++ b/packages/db-mongodb/src/predefinedMigrations/migrateRelationshipsV2_V3.ts @@ -6,11 +6,12 @@ import { buildVersionCollectionFields, buildVersionGlobalFields } from 'payload' import type { MongooseAdapter } from '../index.js' import { getSession } from '../utilities/getSession.js' -import { sanitizeRelationshipIDs } from '../utilities/sanitizeRelationshipIDs.js' +import { transform } from '../utilities/transform.js' const migrateModelWithBatching = async ({ batchSize, config, + db, fields, Model, parentIsLocalized, @@ -18,6 +19,7 @@ const migrateModelWithBatching = async ({ }: { batchSize: number config: SanitizedConfig + db: MongooseAdapter fields: Field[] Model: Model parentIsLocalized: boolean @@ -49,7 +51,7 @@ const migrateModelWithBatching = async ({ } for (const doc of docs) { - sanitizeRelationshipIDs({ config, data: doc, fields, parentIsLocalized }) + transform({ adapter: db, data: doc, fields, operation: 'write', parentIsLocalized }) } await Model.collection.bulkWrite( @@ -124,6 +126,7 @@ export async function migrateRelationshipsV2_V3({ await migrateModelWithBatching({ batchSize, config, + db, fields: collection.fields, Model: db.collections[collection.slug], parentIsLocalized: false, @@ -139,6 +142,7 @@ export async function migrateRelationshipsV2_V3({ await migrateModelWithBatching({ batchSize, config, + db, fields: buildVersionCollectionFields(config, collection), Model: db.versions[collection.slug], parentIsLocalized: false, @@ -167,10 +171,11 @@ export async function migrateRelationshipsV2_V3({ // in case if the global doesn't exist in the database yet (not saved) if (doc) { - sanitizeRelationshipIDs({ - config, + transform({ + adapter: db, data: doc, fields: global.fields, + operation: 'write', }) await GlobalsModel.collection.updateOne( @@ -191,6 +196,7 @@ export async function migrateRelationshipsV2_V3({ await migrateModelWithBatching({ batchSize, config, + db, fields: buildVersionGlobalFields(config, global), Model: db.versions[global.slug], parentIsLocalized: false, diff --git a/packages/db-mongodb/src/queries/buildSearchParams.ts b/packages/db-mongodb/src/queries/buildSearchParams.ts index 736707e9e4..fbeefa0911 100644 --- a/packages/db-mongodb/src/queries/buildSearchParams.ts +++ b/packages/db-mongodb/src/queries/buildSearchParams.ts @@ -255,6 +255,25 @@ export async function buildSearchParam({ return result } + if (formattedOperator === 'not_like' && typeof formattedValue === 'string') { + const words = formattedValue.split(' ') + + const result = { + value: { + $and: words.map((word) => ({ + [path]: { + $not: { + $options: 'i', + $regex: word.replace(/[\\^$*+?.()|[\]{}]/g, '\\$&'), + }, + }, + })), + }, + } + + return result + } + // Some operators like 'near' need to define a full query // so if there is no operator key, just return the value if (!operatorKey) { diff --git a/packages/db-mongodb/src/queryDrafts.ts b/packages/db-mongodb/src/queryDrafts.ts index 0216d24fd0..1a8ba6269b 100644 --- a/packages/db-mongodb/src/queryDrafts.ts +++ b/packages/db-mongodb/src/queryDrafts.ts @@ -10,7 +10,7 @@ import { buildSortParam } from './queries/buildSortParam.js' import { buildJoinAggregation } from './utilities/buildJoinAggregation.js' import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js' import { getSession } from './utilities/getSession.js' -import { sanitizeInternalFields } from './utilities/sanitizeInternalFields.js' +import { transform } from './utilities/transform.js' export const queryDrafts: QueryDrafts = async function queryDrafts( this: MongooseAdapter, @@ -124,18 +124,18 @@ export const queryDrafts: QueryDrafts = async function queryDrafts( result = await VersionModel.paginate(versionQuery, paginationOptions) } - const docs = JSON.parse(JSON.stringify(result.docs)) + transform({ + adapter: this, + data: result.docs, + fields: buildVersionCollectionFields(this.payload.config, collectionConfig), + operation: 'read', + }) - return { - ...result, - docs: docs.map((doc) => { - doc = { - _id: doc.parent, - id: doc.parent, - ...doc.version, - } - - return sanitizeInternalFields(doc) - }), + for (let i = 0; i < result.docs.length; i++) { + const id = result.docs[i].parent + result.docs[i] = result.docs[i].version + result.docs[i].id = id } + + return result } diff --git a/packages/db-mongodb/src/updateGlobal.ts b/packages/db-mongodb/src/updateGlobal.ts index 86686064db..3ed8a04b3c 100644 --- a/packages/db-mongodb/src/updateGlobal.ts +++ b/packages/db-mongodb/src/updateGlobal.ts @@ -5,8 +5,7 @@ import type { MongooseAdapter } from './index.js' import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js' import { getSession } from './utilities/getSession.js' -import { sanitizeInternalFields } from './utilities/sanitizeInternalFields.js' -import { sanitizeRelationshipIDs } from './utilities/sanitizeRelationshipIDs.js' +import { transform } from './utilities/transform.js' export const updateGlobal: UpdateGlobal = async function updateGlobal( this: MongooseAdapter, @@ -27,25 +26,11 @@ export const updateGlobal: UpdateGlobal = async function updateGlobal( session: await getSession(this, req), } - let result + transform({ adapter: this, data, fields, globalSlug: slug, operation: 'write' }) - const sanitizedData = sanitizeRelationshipIDs({ - config: this.payload.config, - data, - fields, - }) + const result: any = await Model.findOneAndUpdate({ globalType: slug }, data, options) - result = await Model.findOneAndUpdate({ globalType: slug }, sanitizedData, options) - - if (!result) { - return null - } - - result = JSON.parse(JSON.stringify(result)) - - // custom id type reset - result.id = result._id - result = sanitizeInternalFields(result) + transform({ adapter: this, data: result, fields, globalSlug: slug, operation: 'read' }) return result } diff --git a/packages/db-mongodb/src/updateGlobalVersion.ts b/packages/db-mongodb/src/updateGlobalVersion.ts index 3337d63f5b..9bc4b592ea 100644 --- a/packages/db-mongodb/src/updateGlobalVersion.ts +++ b/packages/db-mongodb/src/updateGlobalVersion.ts @@ -7,7 +7,7 @@ import type { MongooseAdapter } from './index.js' import { buildQuery } from './queries/buildQuery.js' import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js' import { getSession } from './utilities/getSession.js' -import { sanitizeRelationshipIDs } from './utilities/sanitizeRelationshipIDs.js' +import { transform } from './utilities/transform.js' export async function updateGlobalVersion( this: MongooseAdapter, @@ -47,26 +47,15 @@ export async function updateGlobalVersion( where: whereToUse, }) - const sanitizedData = sanitizeRelationshipIDs({ - config: this.payload.config, - data: versionData, - fields, - }) + transform({ adapter: this, data: versionData, fields, operation: 'write' }) - const doc = await VersionModel.findOneAndUpdate(query, sanitizedData, options) + const doc = await VersionModel.findOneAndUpdate(query, versionData, options) if (!doc) { return null } - const result = JSON.parse(JSON.stringify(doc)) + transform({ adapter: this, data: doc, fields, operation: 'read' }) - const verificationToken = doc._verificationToken - - // custom id type reset - result.id = result._id - if (verificationToken) { - result._verificationToken = verificationToken - } - return result + return doc } diff --git a/packages/db-mongodb/src/updateOne.ts b/packages/db-mongodb/src/updateOne.ts index 569a36a83b..25dd47ba37 100644 --- a/packages/db-mongodb/src/updateOne.ts +++ b/packages/db-mongodb/src/updateOne.ts @@ -7,8 +7,7 @@ import { buildQuery } from './queries/buildQuery.js' import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js' import { getSession } from './utilities/getSession.js' import { handleError } from './utilities/handleError.js' -import { sanitizeInternalFields } from './utilities/sanitizeInternalFields.js' -import { sanitizeRelationshipIDs } from './utilities/sanitizeRelationshipIDs.js' +import { transform } from './utilities/transform.js' export const updateOne: UpdateOne = async function updateOne( this: MongooseAdapter, @@ -39,14 +38,10 @@ export const updateOne: UpdateOne = async function updateOne( let result - const sanitizedData = sanitizeRelationshipIDs({ - config: this.payload.config, - data, - fields, - }) + transform({ adapter: this, data, fields, operation: 'write' }) try { - result = await Model.findOneAndUpdate(query, sanitizedData, options) + result = await Model.findOneAndUpdate(query, data, options) } catch (error) { handleError({ collection, error, req }) } @@ -55,9 +50,7 @@ export const updateOne: UpdateOne = async function updateOne( return null } - result = JSON.parse(JSON.stringify(result)) - result.id = result._id - result = sanitizeInternalFields(result) + transform({ adapter: this, data: result, fields, operation: 'read' }) return result } diff --git a/packages/db-mongodb/src/updateVersion.ts b/packages/db-mongodb/src/updateVersion.ts index 4f9bd5d4c5..a2431388a4 100644 --- a/packages/db-mongodb/src/updateVersion.ts +++ b/packages/db-mongodb/src/updateVersion.ts @@ -7,7 +7,7 @@ import type { MongooseAdapter } from './index.js' import { buildQuery } from './queries/buildQuery.js' import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js' import { getSession } from './utilities/getSession.js' -import { sanitizeRelationshipIDs } from './utilities/sanitizeRelationshipIDs.js' +import { transform } from './utilities/transform.js' export const updateVersion: UpdateVersion = async function updateVersion( this: MongooseAdapter, @@ -45,26 +45,15 @@ export const updateVersion: UpdateVersion = async function updateVersion( where: whereToUse, }) - const sanitizedData = sanitizeRelationshipIDs({ - config: this.payload.config, - data: versionData, - fields, - }) + transform({ adapter: this, data: versionData, fields, operation: 'write' }) - const doc = await VersionModel.findOneAndUpdate(query, sanitizedData, options) + const doc = await VersionModel.findOneAndUpdate(query, versionData, options) if (!doc) { return null } - const result = JSON.parse(JSON.stringify(doc)) + transform({ adapter: this, data: doc, fields, operation: 'write' }) - const verificationToken = doc._verificationToken - - // custom id type reset - result.id = result._id - if (verificationToken) { - result._verificationToken = verificationToken - } - return result + return doc } diff --git a/packages/db-mongodb/src/utilities/buildJoinAggregation.ts b/packages/db-mongodb/src/utilities/buildJoinAggregation.ts index 4af139b5a7..0ffe07e6e1 100644 --- a/packages/db-mongodb/src/utilities/buildJoinAggregation.ts +++ b/packages/db-mongodb/src/utilities/buildJoinAggregation.ts @@ -195,17 +195,17 @@ export const buildJoinAggregation = async ({ const sliceValue = page ? [(page - 1) * limitJoin, limitJoin] : [limitJoin] aggregate.push({ - $set: { - [`${as}.docs`]: { - $slice: [`$${as}.docs`, ...sliceValue], + $addFields: { + [`${as}.hasNextPage`]: { + $gt: [{ $size: `$${as}.docs` }, limitJoin || Number.MAX_VALUE], }, }, }) aggregate.push({ - $addFields: { - [`${as}.hasNextPage`]: { - $gt: [{ $size: `$${as}.docs` }, limitJoin || Number.MAX_VALUE], + $set: { + [`${as}.docs`]: { + $slice: [`$${as}.docs`, ...sliceValue], }, }, }) diff --git a/packages/db-mongodb/src/utilities/sanitizeInternalFields.ts b/packages/db-mongodb/src/utilities/sanitizeInternalFields.ts deleted file mode 100644 index 14ab00da67..0000000000 --- a/packages/db-mongodb/src/utilities/sanitizeInternalFields.ts +++ /dev/null @@ -1,20 +0,0 @@ -const internalFields = ['__v'] - -export const sanitizeInternalFields = >(incomingDoc: T): T => - Object.entries(incomingDoc).reduce((newDoc, [key, val]): T => { - if (key === '_id') { - return { - ...newDoc, - id: val, - } - } - - if (internalFields.indexOf(key) > -1) { - return newDoc - } - - return { - ...newDoc, - [key]: val, - } - }, {} as T) diff --git a/packages/db-mongodb/src/utilities/sanitizeRelationshipIDs.ts b/packages/db-mongodb/src/utilities/sanitizeRelationshipIDs.ts deleted file mode 100644 index cb699cc294..0000000000 --- a/packages/db-mongodb/src/utilities/sanitizeRelationshipIDs.ts +++ /dev/null @@ -1,165 +0,0 @@ -import type { CollectionConfig, Field, SanitizedConfig, TraverseFieldsCallback } from 'payload' - -import { Types } from 'mongoose' -import { traverseFields } from 'payload' -import { fieldAffectsData, fieldShouldBeLocalized } from 'payload/shared' - -type Args = { - config: SanitizedConfig - data: Record - fields: Field[] - parentIsLocalized?: boolean -} - -interface RelationObject { - relationTo: string - value: number | string -} - -function isValidRelationObject(value: unknown): value is RelationObject { - return typeof value === 'object' && value !== null && 'relationTo' in value && 'value' in value -} - -const convertValue = ({ - relatedCollection, - value, -}: { - relatedCollection: CollectionConfig - value: number | string -}): number | string | Types.ObjectId => { - const customIDField = relatedCollection.fields.find( - (field) => fieldAffectsData(field) && field.name === 'id', - ) - - if (customIDField) { - return value - } - - try { - return new Types.ObjectId(value) - } catch { - return value - } -} - -const sanitizeRelationship = ({ config, field, locale, ref, value }) => { - let relatedCollection: CollectionConfig | undefined - let result = value - - const hasManyRelations = typeof field.relationTo !== 'string' - - if (!hasManyRelations) { - relatedCollection = config.collections?.find(({ slug }) => slug === field.relationTo) - } - - if (Array.isArray(value)) { - result = value.map((val) => { - // Handle has many - if (relatedCollection && val && (typeof val === 'string' || typeof val === 'number')) { - return convertValue({ - relatedCollection, - value: val, - }) - } - - // Handle has many - polymorphic - if (isValidRelationObject(val)) { - const relatedCollectionForSingleValue = config.collections?.find( - ({ slug }) => slug === val.relationTo, - ) - - if (relatedCollectionForSingleValue) { - return { - relationTo: val.relationTo, - value: convertValue({ - relatedCollection: relatedCollectionForSingleValue, - value: val.value, - }), - } - } - } - - return val - }) - } - - // Handle has one - polymorphic - if (isValidRelationObject(value)) { - relatedCollection = config.collections?.find(({ slug }) => slug === value.relationTo) - - if (relatedCollection) { - result = { - relationTo: value.relationTo, - value: convertValue({ relatedCollection, value: value.value }), - } - } - } - - // Handle has one - if (relatedCollection && value && (typeof value === 'string' || typeof value === 'number')) { - result = convertValue({ - relatedCollection, - value, - }) - } - if (locale) { - ref[locale] = result - } else { - ref[field.name] = result - } -} - -export const sanitizeRelationshipIDs = ({ - config, - data, - fields, - parentIsLocalized, -}: Args): Record => { - const sanitize: TraverseFieldsCallback = ({ field, ref }) => { - if (!ref || typeof ref !== 'object') { - return - } - - if (field.type === 'relationship' || field.type === 'upload') { - if (!ref[field.name]) { - return - } - - // handle localized relationships - if (config.localization && fieldShouldBeLocalized({ field, parentIsLocalized })) { - const locales = config.localization.locales - const fieldRef = ref[field.name] - if (typeof fieldRef !== 'object') { - return - } - - for (const { code } of locales) { - const value = ref[field.name][code] - if (value) { - sanitizeRelationship({ config, field, locale: code, ref: fieldRef, value }) - } - } - } else { - // handle non-localized relationships - sanitizeRelationship({ - config, - field, - locale: undefined, - ref, - value: ref[field.name], - }) - } - } - } - - traverseFields({ - callback: sanitize, - config, - fields, - fillEmpty: false, - parentIsLocalized, - ref: data, - }) - - return data -} diff --git a/packages/db-mongodb/src/utilities/sanitizeRelationshipIDs.spec.ts b/packages/db-mongodb/src/utilities/transform.spec.ts similarity index 95% rename from packages/db-mongodb/src/utilities/sanitizeRelationshipIDs.spec.ts rename to packages/db-mongodb/src/utilities/transform.spec.ts index 4bc8c3c64f..85b5bdb49e 100644 --- a/packages/db-mongodb/src/utilities/sanitizeRelationshipIDs.spec.ts +++ b/packages/db-mongodb/src/utilities/transform.spec.ts @@ -2,7 +2,8 @@ import { flattenAllFields, type Field, type SanitizedConfig } from 'payload' import { Types } from 'mongoose' -import { sanitizeRelationshipIDs } from './sanitizeRelationshipIDs.js' +import { transform } from './transform.js' +import type { MongooseAdapter } from '../index.js' const flattenRelationshipValues = (obj: Record, prefix = ''): Record => { return Object.keys(obj).reduce( @@ -297,7 +298,7 @@ const relsData = { }, } -describe('sanitizeRelationshipIDs', () => { +describe('transform', () => { it('should sanitize relationships', () => { const data = { ...relsData, @@ -382,7 +383,18 @@ describe('sanitizeRelationshipIDs', () => { } const flattenValuesBefore = Object.values(flattenRelationshipValues(data)) - sanitizeRelationshipIDs({ config, data, fields: config.collections[0].fields }) + const mockAdapter = { + payload: { + config, + }, + } as MongooseAdapter + + transform({ + adapter: mockAdapter, + operation: 'write', + data, + fields: config.collections[0].fields, + }) const flattenValuesAfter = Object.values(flattenRelationshipValues(data)) flattenValuesAfter.forEach((value, i) => { diff --git a/packages/db-mongodb/src/utilities/transform.ts b/packages/db-mongodb/src/utilities/transform.ts new file mode 100644 index 0000000000..02c7b63cdf --- /dev/null +++ b/packages/db-mongodb/src/utilities/transform.ts @@ -0,0 +1,347 @@ +import type { + CollectionConfig, + DateField, + Field, + JoinField, + RelationshipField, + SanitizedConfig, + TraverseFieldsCallback, + UploadField, +} from 'payload' + +import { Types } from 'mongoose' +import { traverseFields } from 'payload' +import { fieldAffectsData, fieldShouldBeLocalized } from 'payload/shared' + +import type { MongooseAdapter } from '../index.js' + +interface RelationObject { + relationTo: string + value: number | string +} + +function isValidRelationObject(value: unknown): value is RelationObject { + return typeof value === 'object' && value !== null && 'relationTo' in value && 'value' in value +} + +const convertRelationshipValue = ({ + operation, + relatedCollection, + validateRelationships, + value, +}: { + operation: Args['operation'] + relatedCollection: CollectionConfig + validateRelationships?: boolean + value: unknown +}) => { + const customIDField = relatedCollection.fields.find( + (field) => fieldAffectsData(field) && field.name === 'id', + ) + + if (operation === 'read') { + if (value instanceof Types.ObjectId) { + return value.toHexString() + } + + return value + } + + if (customIDField) { + return value + } + + if (typeof value === 'string') { + try { + return new Types.ObjectId(value) + } catch (e) { + if (validateRelationships) { + throw e + } + return value + } + } + + return value +} + +const sanitizeRelationship = ({ + config, + field, + locale, + operation, + ref, + validateRelationships, + value, +}: { + config: SanitizedConfig + field: JoinField | RelationshipField | UploadField + locale?: string + operation: Args['operation'] + ref: Record + validateRelationships?: boolean + value?: unknown +}) => { + if (field.type === 'join') { + if ( + operation === 'read' && + value && + typeof value === 'object' && + 'docs' in value && + Array.isArray(value.docs) + ) { + for (let i = 0; i < value.docs.length; i++) { + const item = value.docs[i] + + if (item instanceof Types.ObjectId) { + value.docs[i] = item.toHexString() + } else if (Array.isArray(field.collection) && item) { + // Fields here for polymorphic joins cannot be determinted, JSON.parse needed + value.docs[i] = JSON.parse(JSON.stringify(value.docs[i])) + } + } + } + + return value + } + let relatedCollection: CollectionConfig | undefined + let result = value + + const hasManyRelations = typeof field.relationTo !== 'string' + + if (!hasManyRelations) { + relatedCollection = config.collections?.find(({ slug }) => slug === field.relationTo) + } + + if (Array.isArray(value)) { + result = value.map((val) => { + // Handle has many - polymorphic + if (isValidRelationObject(val)) { + const relatedCollectionForSingleValue = config.collections?.find( + ({ slug }) => slug === val.relationTo, + ) + + if (relatedCollectionForSingleValue) { + return { + relationTo: val.relationTo, + value: convertRelationshipValue({ + operation, + relatedCollection: relatedCollectionForSingleValue, + validateRelationships, + value: val.value, + }), + } + } + } + + if (relatedCollection) { + return convertRelationshipValue({ + operation, + relatedCollection, + validateRelationships, + value: val, + }) + } + + return val + }) + } + // Handle has one - polymorphic + else if (isValidRelationObject(value)) { + relatedCollection = config.collections?.find(({ slug }) => slug === value.relationTo) + + if (relatedCollection) { + result = { + relationTo: value.relationTo, + value: convertRelationshipValue({ + operation, + relatedCollection, + validateRelationships, + value: value.value, + }), + } + } + } + // Handle has one + else if (relatedCollection) { + result = convertRelationshipValue({ + operation, + relatedCollection, + validateRelationships, + value, + }) + } + + if (locale) { + ref[locale] = result + } else { + ref[field.name] = result + } +} + +const sanitizeDate = ({ + field, + locale, + ref, + value, +}: { + field: DateField + locale?: string + ref: Record + value: unknown +}) => { + if (!value) { + return + } + + if (value instanceof Date) { + value = value.toISOString() + } + + if (locale) { + ref[locale] = value + } else { + ref[field.name] = value + } +} + +type Args = { + /** instance of the adapter */ + adapter: MongooseAdapter + /** data to transform, can be an array of documents or a single document */ + data: Record | Record[] + /** fields accossiated with the data */ + fields: Field[] + /** slug of the global, pass only when the operation is `write` */ + globalSlug?: string + /** + * Type of the operation + * read - sanitizes ObjectIDs, Date to strings. + * write - sanitizes string relationships to ObjectIDs. + */ + operation: 'read' | 'write' + parentIsLocalized?: boolean + /** + * Throw errors on invalid relationships + * @default true + */ + validateRelationships?: boolean +} + +export const transform = ({ + adapter, + data, + fields, + globalSlug, + operation, + parentIsLocalized, + validateRelationships = true, +}: Args) => { + if (Array.isArray(data)) { + for (let i = 0; i < data.length; i++) { + transform({ adapter, data: data[i], fields, globalSlug, operation, validateRelationships }) + } + return + } + + const { + payload: { config }, + } = adapter + + if (operation === 'read') { + delete data['__v'] + data.id = data._id + delete data['_id'] + + if (data.id instanceof Types.ObjectId) { + data.id = data.id.toHexString() + } + } + + if (operation === 'write' && globalSlug) { + data.globalType = globalSlug + } + + const sanitize: TraverseFieldsCallback = ({ field, ref }) => { + if (!ref || typeof ref !== 'object') { + return + } + + if (field.type === 'date' && operation === 'read' && ref[field.name]) { + if (config.localization && fieldShouldBeLocalized({ field, parentIsLocalized })) { + const fieldRef = ref[field.name] + if (!fieldRef || typeof fieldRef !== 'object') { + return + } + + for (const locale of config.localization.localeCodes) { + sanitizeDate({ + field, + ref: fieldRef, + value: fieldRef[locale], + }) + } + } else { + sanitizeDate({ + field, + ref: ref as Record, + value: ref[field.name], + }) + } + } + + if ( + field.type === 'relationship' || + field.type === 'upload' || + (operation === 'read' && field.type === 'join') + ) { + if (!ref[field.name]) { + return + } + + // handle localized relationships + if (config.localization && fieldShouldBeLocalized({ field, parentIsLocalized })) { + const locales = config.localization.locales + const fieldRef = ref[field.name] + if (typeof fieldRef !== 'object') { + return + } + + for (const { code } of locales) { + const value = ref[field.name][code] + if (value) { + sanitizeRelationship({ + config, + field, + locale: code, + operation, + ref: fieldRef, + validateRelationships, + value, + }) + } + } + } else { + // handle non-localized relationships + sanitizeRelationship({ + config, + field, + locale: undefined, + operation, + ref: ref as Record, + validateRelationships, + value: ref[field.name], + }) + } + } + } + + traverseFields({ + callback: sanitize, + config, + fields, + fillEmpty: false, + parentIsLocalized, + ref: data, + }) +} diff --git a/packages/db-sqlite/src/createJSONQuery/index.ts b/packages/db-sqlite/src/createJSONQuery/index.ts index 891503f401..376ea58eaa 100644 --- a/packages/db-sqlite/src/createJSONQuery/index.ts +++ b/packages/db-sqlite/src/createJSONQuery/index.ts @@ -50,10 +50,12 @@ const createConstraint = ({ const newAlias = `${pathSegments[0]}_alias_${pathSegments.length - 1}` let formattedValue = value let formattedOperator = operator - if (['contains', 'like'].includes(operator)) { formattedOperator = 'like' formattedValue = `%${value}%` + } else if (['not_like', 'notlike'].includes(operator)) { + formattedOperator = 'not like' + formattedValue = `%${value}%` } else if (operator === 'equals') { formattedOperator = '=' } @@ -61,7 +63,7 @@ const createConstraint = ({ return `EXISTS ( SELECT 1 FROM json_each(${alias}.value -> '${pathSegments[0]}') AS ${newAlias} - WHERE ${newAlias}.value ->> '${pathSegments[1]}' ${formattedOperator} '${formattedValue}' + WHERE COALESCE(${newAlias}.value ->> '${pathSegments[1]}', '') ${formattedOperator} '${formattedValue}' )` } diff --git a/packages/db-sqlite/src/index.ts b/packages/db-sqlite/src/index.ts index 030b504961..2bd3e0a674 100644 --- a/packages/db-sqlite/src/index.ts +++ b/packages/db-sqlite/src/index.ts @@ -37,7 +37,7 @@ import { updateOne, updateVersion, } from '@payloadcms/drizzle' -import { like } from 'drizzle-orm' +import { like, notLike } from 'drizzle-orm' import { createDatabaseAdapter, defaultBeginTransaction } from 'payload' import { fileURLToPath } from 'url' @@ -81,6 +81,7 @@ export function sqliteAdapter(args: Args): DatabaseAdapterObj { ...operatorMap, contains: like, like, + not_like: notLike, } as unknown as Operators return createDatabaseAdapter({ diff --git a/packages/drizzle/src/index.ts b/packages/drizzle/src/index.ts index f7036cfc0d..fd00c95d8e 100644 --- a/packages/drizzle/src/index.ts +++ b/packages/drizzle/src/index.ts @@ -31,9 +31,9 @@ export { buildRawSchema } from './schema/buildRawSchema.js' export { beginTransaction } from './transactions/beginTransaction.js' export { commitTransaction } from './transactions/commitTransaction.js' export { rollbackTransaction } from './transactions/rollbackTransaction.js' -export { updateOne } from './update.js' export { updateGlobal } from './updateGlobal.js' export { updateGlobalVersion } from './updateGlobalVersion.js' +export { updateOne } from './updateOne.js' export { updateVersion } from './updateVersion.js' export { upsertRow } from './upsertRow/index.js' export { buildCreateMigration } from './utilities/buildCreateMigration.js' diff --git a/packages/drizzle/src/postgres/createJSONQuery/index.ts b/packages/drizzle/src/postgres/createJSONQuery/index.ts index 00864aa239..88ac57b4ae 100644 --- a/packages/drizzle/src/postgres/createJSONQuery/index.ts +++ b/packages/drizzle/src/postgres/createJSONQuery/index.ts @@ -7,12 +7,13 @@ const operatorMap: Record = { like: 'like_regex', not_equals: '!=', not_in: 'in', + not_like: '!like_regex', } const sanitizeValue = (value: unknown, operator?: string) => { if (typeof value === 'string') { - // ignore casing with like - return `"${operator === 'like' ? '(?i)' : ''}${value}"` + // ignore casing with like or not_like + return `"${['like', 'not_like'].includes(operator) ? '(?i)' : ''}${value}"` } return value as string @@ -35,6 +36,10 @@ export const createJSONQuery = ({ column, operator, pathSegments, value }: Creat }) } else if (operator === 'exists') { sql = `${value === false ? 'NOT ' : ''}jsonb_path_exists(${columnName}, '$.${jsonPaths}')` + } else if (['not_like'].includes(operator)) { + const mappedOperator = operatorMap[operator] + + sql = `NOT jsonb_path_exists(${columnName}, '$.${jsonPaths} ? (@ ${mappedOperator.substring(1)} ${sanitizeValue(value, operator)})')` } else { sql = `jsonb_path_exists(${columnName}, '$.${jsonPaths} ? (@ ${operatorMap[operator]} ${sanitizeValue(value, operator)})')` } diff --git a/packages/drizzle/src/queries/operatorMap.ts b/packages/drizzle/src/queries/operatorMap.ts index 18a3ee7923..5fc24a2f39 100644 --- a/packages/drizzle/src/queries/operatorMap.ts +++ b/packages/drizzle/src/queries/operatorMap.ts @@ -11,6 +11,7 @@ import { lt, lte, ne, + notIlike, notInArray, or, type SQL, @@ -31,6 +32,7 @@ type OperatorKeys = | 'like' | 'not_equals' | 'not_in' + | 'not_like' | 'or' export type Operators = Record SQL> @@ -48,6 +50,7 @@ export const operatorMap: Operators = { less_than_equal: lte, like: ilike, not_equals: ne, + not_like: notIlike, // TODO: support this // all: all, not_in: notInArray, diff --git a/packages/drizzle/src/queries/parseParams.ts b/packages/drizzle/src/queries/parseParams.ts index a6182fb668..44c277f367 100644 --- a/packages/drizzle/src/queries/parseParams.ts +++ b/packages/drizzle/src/queries/parseParams.ts @@ -161,6 +161,7 @@ export function parseParams({ like: { operator: 'like', wildcard: '%' }, not_equals: { operator: '<>', wildcard: '' }, not_in: { operator: 'not in', wildcard: '' }, + not_like: { operator: 'not like', wildcard: '%' }, } let formattedValue = val @@ -175,11 +176,15 @@ export function parseParams({ formattedValue = '' } - constraints.push( - sql.raw( - `${table[columnName].name}${jsonQuery} ${operatorKeys[operator].operator} ${formattedValue}`, - ), - ) + let jsonQuerySelector = `${table[columnName].name}${jsonQuery}` + + if (adapter.name === 'sqlite' && operator === 'not_like') { + jsonQuerySelector = `COALESCE(${table[columnName].name}${jsonQuery}, '')` + } + + const rawSQLQuery = `${jsonQuerySelector} ${operatorKeys[operator].operator} ${formattedValue}` + + constraints.push(sql.raw(rawSQLQuery)) break } diff --git a/packages/drizzle/src/update.ts b/packages/drizzle/src/updateOne.ts similarity index 80% rename from packages/drizzle/src/update.ts rename to packages/drizzle/src/updateOne.ts index 935133fd71..3e682f4c5d 100644 --- a/packages/drizzle/src/update.ts +++ b/packages/drizzle/src/updateOne.ts @@ -1,10 +1,10 @@ +import type { LibSQLDatabase } from 'drizzle-orm/libsql' import type { UpdateOne } from 'payload' import toSnakeCase from 'to-snake-case' import type { DrizzleAdapter } from './types.js' -import { buildFindManyArgs } from './find/buildFindManyArgs.js' import buildQuery from './queries/buildQuery.js' import { selectDistinct } from './queries/selectDistinct.js' import { upsertRow } from './upsertRow/index.js' @@ -28,6 +28,7 @@ export const updateOne: UpdateOne = async function updateOne( where: whereToUse, }) + // selectDistinct will only return if there are joins const selectDistinctResult = await selectDistinct({ adapter: this, chainedMethods: [{ args: [1], method: 'limit' }], @@ -40,22 +41,18 @@ export const updateOne: UpdateOne = async function updateOne( if (selectDistinctResult?.[0]?.id) { idToUpdate = selectDistinctResult?.[0]?.id - // If id wasn't passed but `where` without any joins, retrieve it with findFirst } else if (whereArg && !joins.length) { - const findManyArgs = buildFindManyArgs({ - adapter: this, - depth: 0, - fields: collection.flattenedFields, - joinQuery: false, - select: {}, - tableName, - }) + const table = this.tables[tableName] - findManyArgs.where = where - - const docToUpdate = await db.query[tableName].findFirst(findManyArgs) - idToUpdate = docToUpdate?.id + const docsToUpdate = await (db as LibSQLDatabase) + .select({ + id: table.id, + }) + .from(table) + .where(where) + .limit(1) + idToUpdate = docsToUpdate?.[0]?.id } const result = await upsertRow({ diff --git a/packages/next/src/views/List/index.tsx b/packages/next/src/views/List/index.tsx index 585035bd9c..177fbde7fb 100644 --- a/packages/next/src/views/List/index.tsx +++ b/packages/next/src/views/List/index.tsx @@ -153,7 +153,7 @@ export const renderListView = async ( const renderedFilters = renderFilters(collectionConfig.fields, req.payload.importMap) const resolvedFilterOptions = await resolveAllFilterOptions({ - collectionConfig, + fields: collectionConfig.fields, req, }) diff --git a/packages/next/src/views/List/resolveAllFilterOptions.ts b/packages/next/src/views/List/resolveAllFilterOptions.ts index 743f913973..64c9fb9c0f 100644 --- a/packages/next/src/views/List/resolveAllFilterOptions.ts +++ b/packages/next/src/views/List/resolveAllFilterOptions.ts @@ -1,19 +1,21 @@ -import type { CollectionConfig, PayloadRequest, ResolvedFilterOptions } from 'payload' +import type { Field, PayloadRequest, ResolvedFilterOptions } from 'payload' import { resolveFilterOptions } from '@payloadcms/ui/rsc' -import { fieldIsHiddenOrDisabled } from 'payload/shared' +import { fieldHasSubFields, fieldIsHiddenOrDisabled } from 'payload/shared' export const resolveAllFilterOptions = async ({ - collectionConfig, + fields, req, + result, }: { - collectionConfig: CollectionConfig + fields: Field[] req: PayloadRequest + result?: Map }): Promise> => { - const resolvedFilterOptions = new Map() + const resolvedFilterOptions = !result ? new Map() : result await Promise.all( - collectionConfig.fields.map(async (field) => { + fields.map(async (field) => { if (fieldIsHiddenOrDisabled(field)) { return } @@ -28,8 +30,29 @@ export const resolveAllFilterOptions = async ({ siblingData: {}, // use empty object to prevent breaking queries when accessing properties of data user: req.user, }) + resolvedFilterOptions.set(field.name, options) } + + if (fieldHasSubFields(field)) { + await resolveAllFilterOptions({ + fields: field.fields, + req, + result: resolvedFilterOptions, + }) + } + + if (field.type === 'tabs') { + await Promise.all( + field.tabs.map((tab) => + resolveAllFilterOptions({ + fields: tab.fields, + req, + result: resolvedFilterOptions, + }), + ), + ) + } }), ) diff --git a/packages/next/src/withPayload.js b/packages/next/src/withPayload.js index 5476aa2a46..fff14cabe1 100644 --- a/packages/next/src/withPayload.js +++ b/packages/next/src/withPayload.js @@ -147,10 +147,6 @@ export const withPayload = (nextConfig = {}) => { toReturn.env.NEXT_BASE_PATH = nextConfig.basePath } - if (nextConfig.assetPrefix) { - toReturn.env.NEXT_ASSET_PREFIX = nextConfig.assetPrefix - } - return toReturn } diff --git a/packages/payload-cloud/src/utilities/getStorageClient.ts b/packages/payload-cloud/src/utilities/getStorageClient.ts index 0aa70331e4..7f02c559ad 100644 --- a/packages/payload-cloud/src/utilities/getStorageClient.ts +++ b/packages/payload-cloud/src/utilities/getStorageClient.ts @@ -1,19 +1,13 @@ +import type * as AWS from '@aws-sdk/client-s3' import type { CognitoUserSession } from 'amazon-cognito-identity-js' -import { CognitoIdentityClient } from '@aws-sdk/client-cognito-identity' -import * as AWS from '@aws-sdk/client-s3' -import { fromCognitoIdentityPool } from '@aws-sdk/credential-providers' +import type { GetStorageClient } from './refreshSession.js' -import { authAsCognitoUser } from './authAsCognitoUser.js' +import { refreshSession } from './refreshSession.js' -export type GetStorageClient = () => Promise<{ - identityID: string - storageClient: AWS.S3 -}> - -let storageClient: AWS.S3 | null = null -let session: CognitoUserSession | null = null -let identityID: string +export let storageClient: AWS.S3 | null = null +export let session: CognitoUserSession | null = null +export let identityID: string export const getStorageClient: GetStorageClient = async () => { if (storageClient && session?.isValid()) { @@ -23,6 +17,8 @@ export const getStorageClient: GetStorageClient = async () => { } } + ;({ identityID, session, storageClient } = await refreshSession()) + if (!process.env.PAYLOAD_CLOUD_PROJECT_ID) { throw new Error('PAYLOAD_CLOUD_PROJECT_ID is required') } @@ -33,34 +29,6 @@ export const getStorageClient: GetStorageClient = async () => { throw new Error('PAYLOAD_CLOUD_COGNITO_IDENTITY_POOL_ID is required') } - session = await authAsCognitoUser( - process.env.PAYLOAD_CLOUD_PROJECT_ID, - process.env.PAYLOAD_CLOUD_COGNITO_PASSWORD, - ) - - const cognitoIdentity = new CognitoIdentityClient({ - credentials: fromCognitoIdentityPool({ - clientConfig: { - region: 'us-east-1', - }, - identityPoolId: process.env.PAYLOAD_CLOUD_COGNITO_IDENTITY_POOL_ID, - logins: { - [`cognito-idp.us-east-1.amazonaws.com/${process.env.PAYLOAD_CLOUD_COGNITO_USER_POOL_ID}`]: - session.getIdToken().getJwtToken(), - }, - }), - }) - - const credentials = await cognitoIdentity.config.credentials() - - // @ts-expect-error - Incorrect AWS types - identityID = credentials.identityId - - storageClient = new AWS.S3({ - credentials, - region: process.env.PAYLOAD_CLOUD_BUCKET_REGION, - }) - return { identityID, storageClient, diff --git a/packages/payload-cloud/src/utilities/refreshSession.ts b/packages/payload-cloud/src/utilities/refreshSession.ts new file mode 100644 index 0000000000..a12ad42e21 --- /dev/null +++ b/packages/payload-cloud/src/utilities/refreshSession.ts @@ -0,0 +1,46 @@ +import { CognitoIdentityClient } from '@aws-sdk/client-cognito-identity' +import * as AWS from '@aws-sdk/client-s3' +import { fromCognitoIdentityPool } from '@aws-sdk/credential-providers' + +import { authAsCognitoUser } from './authAsCognitoUser.js' + +export type GetStorageClient = () => Promise<{ + identityID: string + storageClient: AWS.S3 +}> + +export const refreshSession = async () => { + const session = await authAsCognitoUser( + process.env.PAYLOAD_CLOUD_PROJECT_ID || '', + process.env.PAYLOAD_CLOUD_COGNITO_PASSWORD || '', + ) + + const cognitoIdentity = new CognitoIdentityClient({ + credentials: fromCognitoIdentityPool({ + clientConfig: { + region: 'us-east-1', + }, + identityPoolId: process.env.PAYLOAD_CLOUD_COGNITO_IDENTITY_POOL_ID || '', + logins: { + [`cognito-idp.us-east-1.amazonaws.com/${process.env.PAYLOAD_CLOUD_COGNITO_USER_POOL_ID}`]: + session.getIdToken().getJwtToken(), + }, + }), + }) + + const credentials = await cognitoIdentity.config.credentials() + + // @ts-expect-error - Incorrect AWS types + const identityID = credentials.identityId + + const storageClient = new AWS.S3({ + credentials, + region: process.env.PAYLOAD_CLOUD_BUCKET_REGION, + }) + + return { + identityID, + session, + storageClient, + } +} diff --git a/packages/payload/src/admin/RichText.ts b/packages/payload/src/admin/RichText.ts index 6812ba569b..f5cacf4c10 100644 --- a/packages/payload/src/admin/RichText.ts +++ b/packages/payload/src/admin/RichText.ts @@ -88,7 +88,7 @@ export type BeforeChangeRichTextHookArgs< errors?: ValidationFieldError[] /** Only available in `beforeChange` field hooks */ - mergeLocaleActions?: (() => Promise)[] + mergeLocaleActions?: (() => Promise | void)[] /** A string relating to which operation the field type is currently executing within. */ operation?: 'create' | 'delete' | 'read' | 'update' /** The sibling data of the document before changes being applied. */ diff --git a/packages/payload/src/admin/functions/index.ts b/packages/payload/src/admin/functions/index.ts index 6a127de063..94f930d466 100644 --- a/packages/payload/src/admin/functions/index.ts +++ b/packages/payload/src/admin/functions/index.ts @@ -1,7 +1,7 @@ import type { ImportMap } from '../../bin/generateImportMap/index.js' import type { SanitizedConfig } from '../../config/types.js' import type { PaginatedDocs } from '../../database/types.js' -import type { CollectionSlug } from '../../index.js' +import type { CollectionSlug, ColumnPreference } from '../../index.js' import type { PayloadRequest, Sort, Where } from '../../types/index.js' export type DefaultServerFunctionArgs = { @@ -50,7 +50,7 @@ export type ListQuery = { export type BuildTableStateArgs = { collectionSlug: string | string[] - columns?: { accessor: string; active: boolean }[] + columns?: ColumnPreference[] docs?: PaginatedDocs['docs'] enableRowSelections?: boolean parent?: { diff --git a/packages/payload/src/auth/executeAuthStrategies.ts b/packages/payload/src/auth/executeAuthStrategies.ts index bb07cad1d6..3eb41d9fa2 100644 --- a/packages/payload/src/auth/executeAuthStrategies.ts +++ b/packages/payload/src/auth/executeAuthStrategies.ts @@ -1,20 +1,19 @@ -// @ts-strict-ignore import type { AuthStrategyFunctionArgs, AuthStrategyResult } from './index.js' - export const executeAuthStrategies = async ( args: AuthStrategyFunctionArgs, ): Promise => { - return args.payload.authStrategies.reduce( - async (accumulatorPromise, strategy) => { - const result: AuthStrategyResult = await accumulatorPromise - if (!result.user) { - // add the configured AuthStrategy `name` to the strategy function args - args.strategyName = strategy.name + if (!args.payload.authStrategies?.length) { + return { user: null } + } - return strategy.authenticate(args) - } + for (const strategy of args.payload.authStrategies) { + // add the configured AuthStrategy `name` to the strategy function args + args.strategyName = strategy.name + + const result = await strategy.authenticate(args) + if (result.user) { return result - }, - Promise.resolve({ user: null }), - ) + } + } + return { user: null } } diff --git a/packages/payload/src/auth/operations/forgotPassword.ts b/packages/payload/src/auth/operations/forgotPassword.ts index 394f2e1998..f0ce36af7c 100644 --- a/packages/payload/src/auth/operations/forgotPassword.ts +++ b/packages/payload/src/auth/operations/forgotPassword.ts @@ -64,18 +64,18 @@ export const forgotPasswordOperation = async ( // beforeOperation - Collection // ///////////////////////////////////// - await args.collection.config.hooks.beforeOperation.reduce(async (priorHook, hook) => { - await priorHook - - args = - (await hook({ - args, - collection: args.collection?.config, - context: args.req.context, - operation: 'forgotPassword', - req: args.req, - })) || args - }, Promise.resolve()) + if (args.collection.config.hooks?.beforeOperation?.length) { + for (const hook of args.collection.config.hooks.beforeOperation) { + args = + (await hook({ + args, + collection: args.collection?.config, + context: args.req.context, + operation: 'forgotPassword', + req: args.req, + })) || args + } + } const { collection: { config: collectionConfig }, @@ -190,10 +190,11 @@ export const forgotPasswordOperation = async ( // afterForgotPassword - Collection // ///////////////////////////////////// - await collectionConfig.hooks.afterForgotPassword.reduce(async (priorHook, hook) => { - await priorHook - await hook({ args, collection: args.collection?.config, context: req.context }) - }, Promise.resolve()) + if (collectionConfig.hooks?.afterForgotPassword?.length) { + for (const hook of collectionConfig.hooks.afterForgotPassword) { + await hook({ args, collection: args.collection?.config, context: req.context }) + } + } // ///////////////////////////////////// // afterOperation - Collection diff --git a/packages/payload/src/auth/operations/login.ts b/packages/payload/src/auth/operations/login.ts index 04954ad01f..039a4e46a1 100644 --- a/packages/payload/src/auth/operations/login.ts +++ b/packages/payload/src/auth/operations/login.ts @@ -51,18 +51,18 @@ export const loginOperation = async ( // beforeOperation - Collection // ///////////////////////////////////// - await args.collection.config.hooks.beforeOperation.reduce(async (priorHook, hook) => { - await priorHook - - args = - (await hook({ - args, - collection: args.collection?.config, - context: args.req.context, - operation: 'login', - req: args.req, - })) || args - }, Promise.resolve()) + if (args.collection.config.hooks?.beforeOperation?.length) { + for (const hook of args.collection.config.hooks.beforeOperation) { + args = + (await hook({ + args, + collection: args.collection?.config, + context: args.req.context, + operation: 'login', + req: args.req, + })) || args + } + } const { collection: { config: collectionConfig }, @@ -227,17 +227,17 @@ export const loginOperation = async ( // beforeLogin - Collection // ///////////////////////////////////// - await collectionConfig.hooks.beforeLogin.reduce(async (priorHook, hook) => { - await priorHook - - user = - (await hook({ - collection: args.collection?.config, - context: args.req.context, - req: args.req, - user, - })) || user - }, Promise.resolve()) + if (collectionConfig.hooks?.beforeLogin?.length) { + for (const hook of collectionConfig.hooks.beforeLogin) { + user = + (await hook({ + collection: args.collection?.config, + context: args.req.context, + req: args.req, + user, + })) || user + } + } const { exp, token } = await jwtSign({ fieldsToSign, @@ -251,18 +251,18 @@ export const loginOperation = async ( // afterLogin - Collection // ///////////////////////////////////// - await collectionConfig.hooks.afterLogin.reduce(async (priorHook, hook) => { - await priorHook - - user = - (await hook({ - collection: args.collection?.config, - context: args.req.context, - req: args.req, - token, - user, - })) || user - }, Promise.resolve()) + if (collectionConfig.hooks?.afterLogin?.length) { + for (const hook of collectionConfig.hooks.afterLogin) { + user = + (await hook({ + collection: args.collection?.config, + context: args.req.context, + req: args.req, + token, + user, + })) || user + } + } // ///////////////////////////////////// // afterRead - Fields @@ -286,17 +286,17 @@ export const loginOperation = async ( // afterRead - Collection // ///////////////////////////////////// - await collectionConfig.hooks.afterRead.reduce(async (priorHook, hook) => { - await priorHook - - user = - (await hook({ - collection: args.collection?.config, - context: req.context, - doc: user, - req, - })) || user - }, Promise.resolve()) + if (collectionConfig.hooks?.afterRead?.length) { + for (const hook of collectionConfig.hooks.afterRead) { + user = + (await hook({ + collection: args.collection?.config, + context: req.context, + doc: user, + req, + })) || user + } + } let result: { user: DataFromCollectionSlug } & Result = { exp, diff --git a/packages/payload/src/auth/operations/logout.ts b/packages/payload/src/auth/operations/logout.ts index d5a640928d..57d5679b5b 100644 --- a/packages/payload/src/auth/operations/logout.ts +++ b/packages/payload/src/auth/operations/logout.ts @@ -25,16 +25,16 @@ export const logoutOperation = async (incomingArgs: Arguments): Promise throw new APIError('Incorrect collection', httpStatus.FORBIDDEN) } - await collectionConfig.hooks.afterLogout.reduce(async (priorHook, hook) => { - await priorHook - - args = - (await hook({ - collection: args.collection?.config, - context: req.context, - req, - })) || args - }, Promise.resolve()) + if (collectionConfig.hooks?.afterLogout?.length) { + for (const hook of collectionConfig.hooks.afterLogout) { + args = + (await hook({ + collection: args.collection?.config, + context: req.context, + req, + })) || args + } + } return true } diff --git a/packages/payload/src/auth/operations/me.ts b/packages/payload/src/auth/operations/me.ts index f8c21b5655..550bd05bbf 100644 --- a/packages/payload/src/auth/operations/me.ts +++ b/packages/payload/src/auth/operations/me.ts @@ -86,17 +86,17 @@ export const meOperation = async (args: Arguments): Promise = // After Me - Collection // ///////////////////////////////////// - await collection.config.hooks.afterMe.reduce(async (priorHook, hook) => { - await priorHook - - result = - (await hook({ - collection: collection?.config, - context: req.context, - req, - response: result, - })) || result - }, Promise.resolve()) + if (collection.config.hooks?.afterMe?.length) { + for (const hook of collection.config.hooks.afterMe) { + result = + (await hook({ + collection: collection?.config, + context: req.context, + req, + response: result, + })) || result + } + } return result } diff --git a/packages/payload/src/auth/operations/refresh.ts b/packages/payload/src/auth/operations/refresh.ts index ee8fed1bae..b250d505da 100644 --- a/packages/payload/src/auth/operations/refresh.ts +++ b/packages/payload/src/auth/operations/refresh.ts @@ -35,10 +35,8 @@ export const refreshOperation = async (incomingArgs: Arguments): Promise // beforeOperation - Collection // ///////////////////////////////////// - await args.collection.config.hooks.beforeOperation.reduce( - async (priorHook: BeforeOperationHook | Promise, hook: BeforeOperationHook) => { - await priorHook - + if (args.collection.config.hooks?.beforeOperation?.length) { + for (const hook of args.collection.config.hooks.beforeOperation) { args = (await hook({ args, @@ -47,9 +45,8 @@ export const refreshOperation = async (incomingArgs: Arguments): Promise operation: 'refresh', req: args.req, })) || args - }, - Promise.resolve(), - ) + } + } // ///////////////////////////////////// // Refresh @@ -122,18 +119,18 @@ export const refreshOperation = async (incomingArgs: Arguments): Promise // After Refresh - Collection // ///////////////////////////////////// - await collectionConfig.hooks.afterRefresh.reduce(async (priorHook, hook) => { - await priorHook - - result = - (await hook({ - collection: args.collection?.config, - context: args.req.context, - exp: result.exp, - req: args.req, - token: result.refreshedToken, - })) || result - }, Promise.resolve()) + if (collectionConfig.hooks?.afterRefresh?.length) { + for (const hook of collectionConfig.hooks.afterRefresh) { + result = + (await hook({ + collection: args.collection?.config, + context: args.req.context, + exp: result.exp, + req: args.req, + token: result.refreshedToken, + })) || result + } + } // ///////////////////////////////////// // afterOperation - Collection diff --git a/packages/payload/src/auth/operations/resetPassword.ts b/packages/payload/src/auth/operations/resetPassword.ts index 1b876a845e..237b68d23e 100644 --- a/packages/payload/src/auth/operations/resetPassword.ts +++ b/packages/payload/src/auth/operations/resetPassword.ts @@ -91,17 +91,17 @@ export const resetPasswordOperation = async (args: Arguments): Promise = // beforeValidate - Collection // ///////////////////////////////////// - await collectionConfig.hooks.beforeValidate.reduce(async (priorHook, hook) => { - await priorHook - - await hook({ - collection: args.collection?.config, - context: req.context, - data: user, - operation: 'update', - req, - }) - }, Promise.resolve()) + if (collectionConfig.hooks?.beforeValidate?.length) { + for (const hook of collectionConfig.hooks.beforeValidate) { + await hook({ + collection: args.collection?.config, + context: req.context, + data: user, + operation: 'update', + req, + }) + } + } // ///////////////////////////////////// // Update new password diff --git a/packages/payload/src/auth/strategies/jwt.ts b/packages/payload/src/auth/strategies/jwt.ts index 45fbc84db8..1cbb2b771e 100644 --- a/packages/payload/src/auth/strategies/jwt.ts +++ b/packages/payload/src/auth/strategies/jwt.ts @@ -54,6 +54,8 @@ async function autoLogin({ await payload.find({ collection: collection.config.slug, depth: isGraphQL ? 0 : collection.config.auth.depth, + limit: 1, + pagination: false, where, }) ).docs[0] diff --git a/packages/payload/src/bin/index.ts b/packages/payload/src/bin/index.ts index f9eb3714b9..2faa0697ad 100755 --- a/packages/payload/src/bin/index.ts +++ b/packages/payload/src/bin/index.ts @@ -76,8 +76,18 @@ export const bin = async () => { if (userBinScript) { try { - const script: BinScript = await import(pathToFileURL(userBinScript.scriptPath).toString()) - await script(config) + const module = await import(pathToFileURL(userBinScript.scriptPath).toString()) + + if (!module.script || typeof module.script !== 'function') { + console.error( + `Could not find "script" function export for script ${userBinScript.key} in ${userBinScript.scriptPath}`, + ) + } else { + await module.script(config).catch((err: unknown) => { + console.log(`Script ${userBinScript.key} failed, details:`) + console.error(err) + }) + } } catch (err) { console.log(`Could not find associated bin script for the ${userBinScript.key} command`) console.error(err) diff --git a/packages/payload/src/collections/dataloader.ts b/packages/payload/src/collections/dataloader.ts index cf41086485..3893487694 100644 --- a/packages/payload/src/collections/dataloader.ts +++ b/packages/payload/src/collections/dataloader.ts @@ -44,7 +44,9 @@ const batchAndLoadDocs = * **/ - const batchByFindArgs = keys.reduce((batches, key) => { + const batchByFindArgs = {} + + for (const key of keys) { const [ transactionID, collection, @@ -77,27 +79,16 @@ const batchAndLoadDocs = const batchKey = JSON.stringify(batchKeyArray) const idType = payload.collections?.[collection].customIDType || payload.db.defaultIDType - - let sanitizedID: number | string = id - - if (idType === 'number') { - sanitizedID = parseFloat(id) - } + const sanitizedID = idType === 'number' ? parseFloat(id) : id if (isValidID(sanitizedID, idType)) { - return { - ...batches, - [batchKey]: [...(batches[batchKey] || []), sanitizedID], - } + batchByFindArgs[batchKey] = [...(batchByFindArgs[batchKey] || []), sanitizedID] } - return batches - }, {}) + } // Run find requests one after another, so as to not hang transactions - await Object.entries(batchByFindArgs).reduce(async (priorFind, [batchKey, ids]) => { - await priorFind - + for (const [batchKey, ids] of Object.entries(batchByFindArgs)) { const [ transactionID, collection, @@ -137,8 +128,7 @@ const batchAndLoadDocs = // For each returned doc, find index in original keys // Inject doc within docs array if index exists - - result.docs.forEach((doc) => { + for (const doc of result.docs) { const docKey = createDataloaderCacheKey({ collectionSlug: collection, currentDepth, @@ -158,8 +148,8 @@ const batchAndLoadDocs = if (docsIndex > -1) { docs[docsIndex] = doc } - }) - }, Promise.resolve()) + } + } // Return docs array, // which has now been injected with all fetched docs diff --git a/packages/payload/src/collections/operations/count.ts b/packages/payload/src/collections/operations/count.ts index bcb2b9fd7c..d03faf5c43 100644 --- a/packages/payload/src/collections/operations/count.ts +++ b/packages/payload/src/collections/operations/count.ts @@ -28,18 +28,18 @@ export const countOperation = async ( // beforeOperation - Collection // ///////////////////////////////////// - await args.collection.config.hooks.beforeOperation.reduce(async (priorHook, hook) => { - await priorHook - - args = - (await hook({ - args, - collection: args.collection.config, - context: args.req.context, - operation: 'count', - req: args.req, - })) || args - }, Promise.resolve()) + if (args.collection.config.hooks?.beforeOperation?.length) { + for (const hook of args.collection.config.hooks.beforeOperation) { + args = + (await hook({ + args, + collection: args.collection.config, + context: args.req.context, + operation: 'count', + req: args.req, + })) || args + } + } const { collection: { config: collectionConfig }, diff --git a/packages/payload/src/collections/operations/countVersions.ts b/packages/payload/src/collections/operations/countVersions.ts index 70ddbe2962..4d0e0f3e1a 100644 --- a/packages/payload/src/collections/operations/countVersions.ts +++ b/packages/payload/src/collections/operations/countVersions.ts @@ -28,18 +28,18 @@ export const countVersionsOperation = async ( // beforeOperation - Collection // ///////////////////////////////////// - await args.collection.config.hooks.beforeOperation.reduce(async (priorHook, hook) => { - await priorHook - - args = - (await hook({ - args, - collection: args.collection.config, - context: args.req.context, - operation: 'countVersions', - req: args.req, - })) || args - }, Promise.resolve()) + if (args.collection.config.hooks.beforeOperation?.length) { + for (const hook of args.collection.config.hooks.beforeOperation) { + args = + (await hook({ + args, + collection: args.collection.config, + context: args.req.context, + operation: 'countVersions', + req: args.req, + })) || args + } + } const { collection: { config: collectionConfig }, diff --git a/packages/payload/src/collections/operations/create.ts b/packages/payload/src/collections/operations/create.ts index 940052429f..d3d0da6594 100644 --- a/packages/payload/src/collections/operations/create.ts +++ b/packages/payload/src/collections/operations/create.ts @@ -78,10 +78,8 @@ export const createOperation = async < // beforeOperation - Collection // ///////////////////////////////////// - await args.collection.config.hooks.beforeOperation.reduce( - async (priorHook: BeforeOperationHook | Promise, hook: BeforeOperationHook) => { - await priorHook - + if (args.collection.config.hooks.beforeOperation?.length) { + for (const hook of args.collection.config.hooks.beforeOperation) { args = (await hook({ args, @@ -90,9 +88,8 @@ export const createOperation = async < operation: 'create', req: args.req, })) || args - }, - Promise.resolve(), - ) + } + } const { autosave = false, @@ -183,10 +180,8 @@ export const createOperation = async < // beforeValidate - Collections // ///////////////////////////////////// - await collectionConfig.hooks.beforeValidate.reduce( - async (priorHook: BeforeValidateHook | Promise, hook: BeforeValidateHook) => { - await priorHook - + if (collectionConfig.hooks.beforeValidate?.length) { + for (const hook of collectionConfig.hooks.beforeValidate) { data = (await hook({ collection: collectionConfig, @@ -196,27 +191,26 @@ export const createOperation = async < originalDoc: duplicatedFromDoc, req, })) || data - }, - Promise.resolve(), - ) + } + } // ///////////////////////////////////// // beforeChange - Collection // ///////////////////////////////////// - await collectionConfig.hooks.beforeChange.reduce(async (priorHook, hook) => { - await priorHook - - data = - (await hook({ - collection: collectionConfig, - context: req.context, - data, - operation: 'create', - originalDoc: duplicatedFromDoc, - req, - })) || data - }, Promise.resolve()) + if (collectionConfig.hooks?.beforeChange?.length) { + for (const hook of collectionConfig.hooks.beforeChange) { + data = + (await hook({ + collection: collectionConfig, + context: req.context, + data, + operation: 'create', + originalDoc: duplicatedFromDoc, + req, + })) || data + } + } // ///////////////////////////////////// // beforeChange - Fields @@ -332,17 +326,17 @@ export const createOperation = async < // afterRead - Collection // ///////////////////////////////////// - await collectionConfig.hooks.afterRead.reduce(async (priorHook, hook) => { - await priorHook - - result = - (await hook({ - collection: collectionConfig, - context: req.context, - doc: result, - req, - })) || result - }, Promise.resolve()) + if (collectionConfig.hooks?.afterRead?.length) { + for (const hook of collectionConfig.hooks.afterRead) { + result = + (await hook({ + collection: collectionConfig, + context: req.context, + doc: result, + req, + })) || result + } + } // ///////////////////////////////////// // afterChange - Fields @@ -363,10 +357,8 @@ export const createOperation = async < // afterChange - Collection // ///////////////////////////////////// - await collectionConfig.hooks.afterChange.reduce( - async (priorHook: AfterChangeHook | Promise, hook: AfterChangeHook) => { - await priorHook - + if (collectionConfig.hooks?.afterChange?.length) { + for (const hook of collectionConfig.hooks.afterChange) { result = (await hook({ collection: collectionConfig, @@ -376,9 +368,8 @@ export const createOperation = async < previousDoc: {}, req: args.req, })) || result - }, - Promise.resolve(), - ) + } + } // ///////////////////////////////////// // afterOperation - Collection diff --git a/packages/payload/src/collections/operations/delete.ts b/packages/payload/src/collections/operations/delete.ts index 20f333590d..ff88fbf29f 100644 --- a/packages/payload/src/collections/operations/delete.ts +++ b/packages/payload/src/collections/operations/delete.ts @@ -54,10 +54,8 @@ export const deleteOperation = async < // beforeOperation - Collection // ///////////////////////////////////// - await args.collection.config.hooks.beforeOperation.reduce( - async (priorHook: BeforeOperationHook | Promise, hook: BeforeOperationHook) => { - await priorHook - + if (args.collection.config.hooks?.beforeOperation?.length) { + for (const hook of args.collection.config.hooks.beforeOperation) { args = (await hook({ args, @@ -66,9 +64,8 @@ export const deleteOperation = async < operation: 'delete', req: args.req, })) || args - }, - Promise.resolve(), - ) + } + } const { collection: { config: collectionConfig }, @@ -147,16 +144,16 @@ export const deleteOperation = async < // beforeDelete - Collection // ///////////////////////////////////// - await collectionConfig.hooks.beforeDelete.reduce(async (priorHook, hook) => { - await priorHook - - return hook({ - id, - collection: collectionConfig, - context: req.context, - req, - }) - }, Promise.resolve()) + if (collectionConfig.hooks?.beforeDelete?.length) { + for (const hook of collectionConfig.hooks.beforeDelete) { + await hook({ + id, + collection: collectionConfig, + context: req.context, + req, + }) + } + } await deleteAssociatedFiles({ collectionConfig, @@ -229,34 +226,34 @@ export const deleteOperation = async < // afterRead - Collection // ///////////////////////////////////// - await collectionConfig.hooks.afterRead.reduce(async (priorHook, hook) => { - await priorHook - - result = - (await hook({ - collection: collectionConfig, - context: req.context, - doc: result || doc, - req, - })) || result - }, Promise.resolve()) + if (collectionConfig.hooks?.afterRead?.length) { + for (const hook of collectionConfig.hooks.afterRead) { + result = + (await hook({ + collection: collectionConfig, + context: req.context, + doc: result || doc, + req, + })) || result + } + } // ///////////////////////////////////// // afterDelete - Collection // ///////////////////////////////////// - await collectionConfig.hooks.afterDelete.reduce(async (priorHook, hook) => { - await priorHook - - result = - (await hook({ - id, - collection: collectionConfig, - context: req.context, - doc: result, - req, - })) || result - }, Promise.resolve()) + if (collectionConfig.hooks?.afterDelete?.length) { + for (const hook of collectionConfig.hooks.afterDelete) { + result = + (await hook({ + id, + collection: collectionConfig, + context: req.context, + doc: result, + req, + })) || result + } + } // ///////////////////////////////////// // 8. Return results diff --git a/packages/payload/src/collections/operations/deleteByID.ts b/packages/payload/src/collections/operations/deleteByID.ts index 73de48188c..fc72780d4b 100644 --- a/packages/payload/src/collections/operations/deleteByID.ts +++ b/packages/payload/src/collections/operations/deleteByID.ts @@ -48,10 +48,8 @@ export const deleteByIDOperation = async , hook: BeforeOperationHook) => { - await priorHook - + if (args.collection.config.hooks?.beforeOperation?.length) { + for (const hook of args.collection.config.hooks.beforeOperation) { args = (await hook({ args, @@ -60,9 +58,8 @@ export const deleteByIDOperation = async { - await priorHook - - return hook({ - id, - collection: collectionConfig, - context: req.context, - req, - }) - }, Promise.resolve()) + if (collectionConfig.hooks?.beforeDelete?.length) { + for (const hook of collectionConfig.hooks.beforeDelete) { + await hook({ + id, + collection: collectionConfig, + context: req.context, + req, + }) + } + } // ///////////////////////////////////// // Retrieve document @@ -215,34 +212,34 @@ export const deleteByIDOperation = async { - await priorHook - - result = - (await hook({ - collection: collectionConfig, - context: req.context, - doc: result, - req, - })) || result - }, Promise.resolve()) + if (collectionConfig.hooks?.afterRead?.length) { + for (const hook of collectionConfig.hooks.afterRead) { + result = + (await hook({ + collection: collectionConfig, + context: req.context, + doc: result, + req, + })) || result + } + } // ///////////////////////////////////// // afterDelete - Collection // ///////////////////////////////////// - await collectionConfig.hooks.afterDelete.reduce(async (priorHook, hook) => { - await priorHook - - result = - (await hook({ - id, - collection: collectionConfig, - context: req.context, - doc: result, - req, - })) || result - }, Promise.resolve()) + if (collectionConfig.hooks?.afterDelete?.length) { + for (const hook of collectionConfig.hooks.afterDelete) { + result = + (await hook({ + id, + collection: collectionConfig, + context: req.context, + doc: result, + req, + })) || result + } + } // ///////////////////////////////////// // afterOperation - Collection diff --git a/packages/payload/src/collections/operations/find.ts b/packages/payload/src/collections/operations/find.ts index b7d5167ce3..0de5ba87a8 100644 --- a/packages/payload/src/collections/operations/find.ts +++ b/packages/payload/src/collections/operations/find.ts @@ -63,18 +63,18 @@ export const findOperation = async < // beforeOperation - Collection // ///////////////////////////////////// - await args.collection.config.hooks.beforeOperation.reduce(async (priorHook, hook) => { - await priorHook - - args = - (await hook({ - args, - collection: args.collection.config, - context: args.req.context, - operation: 'read', - req: args.req, - })) || args - }, Promise.resolve()) + if (args.collection.config.hooks?.beforeOperation?.length) { + for (const hook of args.collection.config.hooks.beforeOperation) { + args = + (await hook({ + args, + collection: args.collection.config, + context: args.req.context, + operation: 'read', + req: args.req, + })) || args + } + } const { collection: { config: collectionConfig }, @@ -257,9 +257,7 @@ export const findOperation = async < result.docs.map(async (doc) => { let docRef = doc - await collectionConfig.hooks.beforeRead.reduce(async (priorHook, hook) => { - await priorHook - + for (const hook of collectionConfig.hooks.beforeRead) { docRef = (await hook({ collection: collectionConfig, @@ -268,7 +266,7 @@ export const findOperation = async < query: fullWhere, req, })) || docRef - }, Promise.resolve()) + } return docRef }), @@ -310,9 +308,7 @@ export const findOperation = async < result.docs.map(async (doc) => { let docRef = doc - await collectionConfig.hooks.afterRead.reduce(async (priorHook, hook) => { - await priorHook - + for (const hook of collectionConfig.hooks.afterRead) { docRef = (await hook({ collection: collectionConfig, @@ -322,7 +318,7 @@ export const findOperation = async < query: fullWhere, req, })) || doc - }, Promise.resolve()) + } return docRef }), diff --git a/packages/payload/src/collections/operations/findByID.ts b/packages/payload/src/collections/operations/findByID.ts index c1d9d5e469..135b23d3dd 100644 --- a/packages/payload/src/collections/operations/findByID.ts +++ b/packages/payload/src/collections/operations/findByID.ts @@ -54,18 +54,18 @@ export const findByIDOperation = async < // beforeOperation - Collection // ///////////////////////////////////// - await args.collection.config.hooks.beforeOperation.reduce(async (priorHook, hook) => { - await priorHook - - args = - (await hook({ - args, - collection: args.collection.config, - context: args.req.context, - operation: 'read', - req: args.req, - })) || args - }, Promise.resolve()) + if (args.collection.config.hooks?.beforeOperation?.length) { + for (const hook of args.collection.config.hooks.beforeOperation) { + args = + (await hook({ + args, + collection: args.collection.config, + context: args.req.context, + operation: 'read', + req: args.req, + })) || args + } + } const { id, @@ -221,18 +221,18 @@ export const findByIDOperation = async < // beforeRead - Collection // ///////////////////////////////////// - await collectionConfig.hooks.beforeRead.reduce(async (priorHook, hook) => { - await priorHook - - result = - (await hook({ - collection: collectionConfig, - context: req.context, - doc: result, - query: findOneArgs.where, - req, - })) || result - }, Promise.resolve()) + if (collectionConfig.hooks?.beforeRead?.length) { + for (const hook of collectionConfig.hooks.beforeRead) { + result = + (await hook({ + collection: collectionConfig, + context: req.context, + doc: result, + query: findOneArgs.where, + req, + })) || result + } + } // ///////////////////////////////////// // afterRead - Fields @@ -259,18 +259,18 @@ export const findByIDOperation = async < // afterRead - Collection // ///////////////////////////////////// - await collectionConfig.hooks.afterRead.reduce(async (priorHook, hook) => { - await priorHook - - result = - (await hook({ - collection: collectionConfig, - context: req.context, - doc: result, - query: findOneArgs.where, - req, - })) || result - }, Promise.resolve()) + if (collectionConfig.hooks?.afterRead?.length) { + for (const hook of collectionConfig.hooks.afterRead) { + result = + (await hook({ + collection: collectionConfig, + context: req.context, + doc: result, + query: findOneArgs.where, + req, + })) || result + } + } // ///////////////////////////////////// // afterOperation - Collection diff --git a/packages/payload/src/collections/operations/findVersionByID.ts b/packages/payload/src/collections/operations/findVersionByID.ts index 82d9ccaf25..e22abe1985 100644 --- a/packages/payload/src/collections/operations/findVersionByID.ts +++ b/packages/payload/src/collections/operations/findVersionByID.ts @@ -101,18 +101,18 @@ export const findVersionByIDOperation = async ( // beforeRead - Collection // ///////////////////////////////////// - await collectionConfig.hooks.beforeRead.reduce(async (priorHook, hook) => { - await priorHook - - result.version = - (await hook({ - collection: collectionConfig, - context: req.context, - doc: result.version, - query: fullWhere, - req, - })) || result.version - }, Promise.resolve()) + if (collectionConfig.hooks?.beforeRead?.length) { + for (const hook of collectionConfig.hooks.beforeRead) { + result.version = + (await hook({ + collection: collectionConfig, + context: req.context, + doc: result.version, + query: fullWhere, + req, + })) || result.version + } + } // ///////////////////////////////////// // afterRead - Fields @@ -139,18 +139,18 @@ export const findVersionByIDOperation = async ( // afterRead - Collection // ///////////////////////////////////// - await collectionConfig.hooks.afterRead.reduce(async (priorHook, hook) => { - await priorHook - - result.version = - (await hook({ - collection: collectionConfig, - context: req.context, - doc: result.version, - query: fullWhere, - req, - })) || result.version - }, Promise.resolve()) + if (collectionConfig.hooks?.afterRead?.length) { + for (const hook of collectionConfig.hooks.afterRead) { + result.version = + (await hook({ + collection: collectionConfig, + context: req.context, + doc: result.version, + query: fullWhere, + req, + })) || result.version + } + } // ///////////////////////////////////// // Return results diff --git a/packages/payload/src/collections/operations/findVersions.ts b/packages/payload/src/collections/operations/findVersions.ts index bdb2d4e62f..e536c48cf3 100644 --- a/packages/payload/src/collections/operations/findVersions.ts +++ b/packages/payload/src/collections/operations/findVersions.ts @@ -96,18 +96,19 @@ export const findVersionsOperation = async if (!docRef.version) { ;(docRef as any).version = {} } - await collectionConfig.hooks.beforeRead.reduce(async (priorHook, hook) => { - await priorHook - docRef.version = - (await hook({ - collection: collectionConfig, - context: req.context, - doc: docRef.version, - query: fullWhere, - req, - })) || docRef.version - }, Promise.resolve()) + if (collectionConfig.hooks?.beforeRead?.length) { + for (const hook of collectionConfig.hooks.beforeRead) { + docRef.version = + (await hook({ + collection: collectionConfig, + context: req.context, + doc: docRef.version, + query: fullWhere, + req, + })) || docRef.version + } + } return docRef }), @@ -147,9 +148,7 @@ export const findVersionsOperation = async result.docs.map(async (doc) => { const docRef = doc - await collectionConfig.hooks.afterRead.reduce(async (priorHook, hook) => { - await priorHook - + for (const hook of collectionConfig.hooks.afterRead) { docRef.version = (await hook({ collection: collectionConfig, @@ -159,7 +158,7 @@ export const findVersionsOperation = async query: fullWhere, req, })) || doc.version - }, Promise.resolve()) + } return docRef }), diff --git a/packages/payload/src/collections/operations/restoreVersion.ts b/packages/payload/src/collections/operations/restoreVersion.ts index c763e6bd9f..2d61de8f15 100644 --- a/packages/payload/src/collections/operations/restoreVersion.ts +++ b/packages/payload/src/collections/operations/restoreVersion.ts @@ -165,17 +165,17 @@ export const restoreVersionOperation = async ( // afterRead - Collection // ///////////////////////////////////// - await collectionConfig.hooks.afterRead.reduce(async (priorHook, hook) => { - await priorHook - - result = - (await hook({ - collection: collectionConfig, - context: req.context, - doc: result, - req, - })) || result - }, Promise.resolve()) + if (collectionConfig.hooks?.afterRead?.length) { + for (const hook of collectionConfig.hooks.afterRead) { + result = + (await hook({ + collection: collectionConfig, + context: req.context, + doc: result, + req, + })) || result + } + } // ///////////////////////////////////// // afterChange - Fields @@ -196,19 +196,19 @@ export const restoreVersionOperation = async ( // afterChange - Collection // ///////////////////////////////////// - await collectionConfig.hooks.afterChange.reduce(async (priorHook, hook) => { - await priorHook - - result = - (await hook({ - collection: collectionConfig, - context: req.context, - doc: result, - operation: 'update', - previousDoc: prevDocWithLocales, - req, - })) || result - }, Promise.resolve()) + if (collectionConfig.hooks?.afterChange?.length) { + for (const hook of collectionConfig.hooks.afterChange) { + result = + (await hook({ + collection: collectionConfig, + context: req.context, + doc: result, + operation: 'update', + previousDoc: prevDocWithLocales, + req, + })) || result + } + } return result } catch (error: unknown) { diff --git a/packages/payload/src/collections/operations/update.ts b/packages/payload/src/collections/operations/update.ts index 3d8d75cc35..7e8cb6ec84 100644 --- a/packages/payload/src/collections/operations/update.ts +++ b/packages/payload/src/collections/operations/update.ts @@ -62,18 +62,18 @@ export const updateOperation = async < // beforeOperation - Collection // ///////////////////////////////////// - await args.collection.config.hooks.beforeOperation.reduce(async (priorHook, hook) => { - await priorHook - - args = - (await hook({ - args, - collection: args.collection.config, - context: args.req.context, - operation: 'update', - req: args.req, - })) || args - }, Promise.resolve()) + if (args.collection.config.hooks?.beforeOperation?.length) { + for (const hook of args.collection.config.hooks.beforeOperation) { + args = + (await hook({ + args, + collection: args.collection.config, + context: args.req.context, + operation: 'update', + req: args.req, + })) || args + } + } const { collection: { config: collectionConfig }, diff --git a/packages/payload/src/collections/operations/updateByID.ts b/packages/payload/src/collections/operations/updateByID.ts index 13463ca8a6..f019d3d20a 100644 --- a/packages/payload/src/collections/operations/updateByID.ts +++ b/packages/payload/src/collections/operations/updateByID.ts @@ -64,18 +64,18 @@ export const updateByIDOperation = async < // beforeOperation - Collection // ///////////////////////////////////// - await args.collection.config.hooks.beforeOperation.reduce(async (priorHook, hook) => { - await priorHook - - args = - (await hook({ - args, - collection: args.collection.config, - context: args.req.context, - operation: 'update', - req: args.req, - })) || args - }, Promise.resolve()) + if (args.collection.config.hooks?.beforeOperation?.length) { + for (const hook of args.collection.config.hooks.beforeOperation) { + args = + (await hook({ + args, + collection: args.collection.config, + context: args.req.context, + operation: 'update', + req: args.req, + })) || args + } + } if (args.publishSpecificLocale) { args.req.locale = args.publishSpecificLocale diff --git a/packages/payload/src/collections/operations/utilities/update.ts b/packages/payload/src/collections/operations/utilities/update.ts index 32b56cc39a..d554e81baf 100644 --- a/packages/payload/src/collections/operations/utilities/update.ts +++ b/packages/payload/src/collections/operations/utilities/update.ts @@ -171,19 +171,19 @@ export const updateDocument = async < // beforeValidate - Collection // ///////////////////////////////////// - await collectionConfig.hooks.beforeValidate.reduce(async (priorHook, hook) => { - await priorHook - - data = - (await hook({ - collection: collectionConfig, - context: req.context, - data, - operation: 'update', - originalDoc, - req, - })) || data - }, Promise.resolve()) + if (collectionConfig.hooks?.beforeValidate?.length) { + for (const hook of collectionConfig.hooks.beforeValidate) { + data = + (await hook({ + collection: collectionConfig, + context: req.context, + data, + operation: 'update', + originalDoc, + req, + })) || data + } + } // ///////////////////////////////////// // Write files to local storage @@ -197,19 +197,19 @@ export const updateDocument = async < // beforeChange - Collection // ///////////////////////////////////// - await collectionConfig.hooks.beforeChange.reduce(async (priorHook, hook) => { - await priorHook - - data = - (await hook({ - collection: collectionConfig, - context: req.context, - data, - operation: 'update', - originalDoc, - req, - })) || data - }, Promise.resolve()) + if (collectionConfig.hooks?.beforeChange?.length) { + for (const hook of collectionConfig.hooks.beforeChange) { + data = + (await hook({ + collection: collectionConfig, + context: req.context, + data, + operation: 'update', + originalDoc, + req, + })) || data + } + } // ///////////////////////////////////// // beforeChange - Fields @@ -338,17 +338,17 @@ export const updateDocument = async < // afterRead - Collection // ///////////////////////////////////// - await collectionConfig.hooks.afterRead.reduce(async (priorHook, hook) => { - await priorHook - - result = - (await hook({ - collection: collectionConfig, - context: req.context, - doc: result, - req, - })) || result - }, Promise.resolve()) + if (collectionConfig.hooks?.afterRead?.length) { + for (const hook of collectionConfig.hooks.afterRead) { + result = + (await hook({ + collection: collectionConfig, + context: req.context, + doc: result, + req, + })) || result + } + } // ///////////////////////////////////// // afterChange - Fields @@ -369,19 +369,19 @@ export const updateDocument = async < // afterChange - Collection // ///////////////////////////////////// - await collectionConfig.hooks.afterChange.reduce(async (priorHook, hook) => { - await priorHook - - result = - (await hook({ - collection: collectionConfig, - context: req.context, - doc: result, - operation: 'update', - previousDoc: originalDoc, - req, - })) || result - }, Promise.resolve()) + if (collectionConfig.hooks?.afterChange?.length) { + for (const hook of collectionConfig.hooks.afterChange) { + result = + (await hook({ + collection: collectionConfig, + context: req.context, + doc: result, + operation: 'update', + previousDoc: originalDoc, + req, + })) || result + } + } return result as TransformCollectionWithSelect } diff --git a/packages/payload/src/collections/operations/utils.ts b/packages/payload/src/collections/operations/utils.ts index 8b0d6dd9b5..f3e8c2c420 100644 --- a/packages/payload/src/collections/operations/utils.ts +++ b/packages/payload/src/collections/operations/utils.ts @@ -125,10 +125,8 @@ export const buildAfterOperation = async < let newResult = result as OperationResult - await args.collection.config.hooks.afterOperation.reduce( - async (priorHook, hook: AfterOperationHook) => { - await priorHook - + if (args.collection.config.hooks?.afterOperation?.length) { + for (const hook of args.collection.config.hooks.afterOperation) { const hookResult = await hook({ args, collection, @@ -140,9 +138,8 @@ export const buildAfterOperation = async < if (hookResult !== undefined) { newResult = hookResult as OperationResult } - }, - Promise.resolve(), - ) + } + } return newResult } diff --git a/packages/payload/src/config/build.ts b/packages/payload/src/config/build.ts index cfd1ee5d71..9ae99a98b2 100644 --- a/packages/payload/src/config/build.ts +++ b/packages/payload/src/config/build.ts @@ -9,11 +9,10 @@ import { sanitizeConfig } from './sanitize.js' */ export async function buildConfig(config: Config): Promise { if (Array.isArray(config.plugins)) { - const configAfterPlugins = await config.plugins.reduce(async (acc, plugin) => { - const configAfterPlugin = await acc - return plugin(configAfterPlugin) - }, Promise.resolve(config)) - + let configAfterPlugins = config + for (const plugin of config.plugins) { + configAfterPlugins = await plugin(configAfterPlugins) + } return await sanitizeConfig(configAfterPlugins) } diff --git a/packages/payload/src/config/sanitize.ts b/packages/payload/src/config/sanitize.ts index a2554e5e24..8168f3c073 100644 --- a/packages/payload/src/config/sanitize.ts +++ b/packages/payload/src/config/sanitize.ts @@ -179,10 +179,7 @@ export const sanitizeConfig = async (incomingConfig: Config): Promise { - locales.push(locale.code) - return locales - }, [] as string[]) + config.localization.localeCodes = config.localization.locales.map((locale) => locale.code) config.localization.locales = ( config.localization as LocalizationConfigWithLabels diff --git a/packages/payload/src/fields/hooks/afterChange/promise.ts b/packages/payload/src/fields/hooks/afterChange/promise.ts index 23b1959e65..dc18f307d0 100644 --- a/packages/payload/src/fields/hooks/afterChange/promise.ts +++ b/packages/payload/src/fields/hooks/afterChange/promise.ts @@ -75,10 +75,8 @@ export const promise = async ({ if (fieldAffectsData(field)) { // Execute hooks if (field.hooks?.afterChange) { - await field.hooks.afterChange.reduce(async (priorHook, currentHook) => { - await priorHook - - const hookedValue = await currentHook({ + for (const hook of field.hooks.afterChange) { + const hookedValue = await hook({ blockData, collection, context, @@ -102,7 +100,7 @@ export const promise = async ({ if (hookedValue !== undefined) { siblingDoc[field.name] = hookedValue } - }, Promise.resolve()) + } } } @@ -242,17 +240,15 @@ export const promise = async ({ throw new MissingEditorProp(field) // while we allow disabling editor functionality, you should not have any richText fields defined if you do not have an editor } - if (typeof field?.editor === 'function') { + if (typeof field.editor === 'function') { throw new Error('Attempted to access unsanitized rich text editor.') } - const editor: RichTextAdapter = field?.editor + const editor: RichTextAdapter = field.editor if (editor?.hooks?.afterChange?.length) { - await editor.hooks.afterChange.reduce(async (priorHook, currentHook) => { - await priorHook - - const hookedValue = await currentHook({ + for (const hook of editor.hooks.afterChange) { + const hookedValue = await hook({ collection, context, data, @@ -275,7 +271,7 @@ export const promise = async ({ if (hookedValue !== undefined) { siblingDoc[field.name] = hookedValue } - }, Promise.resolve()) + } } break } diff --git a/packages/payload/src/fields/hooks/afterRead/promise.ts b/packages/payload/src/fields/hooks/afterRead/promise.ts index df3da5dabc..bbc2787ee0 100644 --- a/packages/payload/src/fields/hooks/afterRead/promise.ts +++ b/packages/payload/src/fields/hooks/afterRead/promise.ts @@ -237,18 +237,17 @@ export const promise = async ({ if (fieldAffectsData(field)) { // Execute hooks if (triggerHooks && field.hooks?.afterRead) { - await field.hooks.afterRead.reduce(async (priorHook, currentHook) => { - await priorHook - + for (const hook of field.hooks.afterRead) { const shouldRunHookOnAllLocales = fieldShouldBeLocalized({ field, parentIsLocalized }) && (locale === 'all' || !flattenLocales) && typeof siblingDoc[field.name] === 'object' if (shouldRunHookOnAllLocales) { - const hookPromises = Object.entries(siblingDoc[field.name]).map(([locale, value]) => - (async () => { - const hookedValue = await currentHook({ + const localesAndValues = Object.entries(siblingDoc[field.name]) + await Promise.all( + localesAndValues.map(async ([localeKey, value]) => { + const hookedValue = await hook({ blockData, collection, context, @@ -273,14 +272,12 @@ export const promise = async ({ }) if (hookedValue !== undefined) { - siblingDoc[field.name][locale] = hookedValue + siblingDoc[field.name][localeKey] = hookedValue } - })(), + }), ) - - await Promise.all(hookPromises) } else { - const hookedValue = await currentHook({ + const hookedValue = await hook({ blockData, collection, context, @@ -308,7 +305,7 @@ export const promise = async ({ siblingDoc[field.name] = hookedValue } } - }, Promise.resolve()) + } } // Execute access control @@ -677,18 +674,18 @@ export const promise = async ({ const editor: RichTextAdapter = field?.editor if (editor?.hooks?.afterRead?.length) { - await editor.hooks.afterRead.reduce(async (priorHook, currentHook) => { - await priorHook - + for (const hook of editor.hooks.afterRead) { const shouldRunHookOnAllLocales = fieldShouldBeLocalized({ field, parentIsLocalized }) && (locale === 'all' || !flattenLocales) && typeof siblingDoc[field.name] === 'object' if (shouldRunHookOnAllLocales) { - const hookPromises = Object.entries(siblingDoc[field.name]).map(([locale, value]) => - (async () => { - const hookedValue = await currentHook({ + const localesAndValues = Object.entries(siblingDoc[field.name]) + + await Promise.all( + localesAndValues.map(async ([locale, value]) => { + const hookedValue = await hook({ collection, context, currentDepth, @@ -722,12 +719,10 @@ export const promise = async ({ if (hookedValue !== undefined) { siblingDoc[field.name][locale] = hookedValue } - })(), + }), ) - - await Promise.all(hookPromises) } else { - const hookedValue = await currentHook({ + const hookedValue = await hook({ collection, context, currentDepth, @@ -762,7 +757,7 @@ export const promise = async ({ siblingDoc[field.name] = hookedValue } } - }, Promise.resolve()) + } } break } diff --git a/packages/payload/src/fields/hooks/beforeChange/index.ts b/packages/payload/src/fields/hooks/beforeChange/index.ts index 3738984f14..4631d2b4e1 100644 --- a/packages/payload/src/fields/hooks/beforeChange/index.ts +++ b/packages/payload/src/fields/hooks/beforeChange/index.ts @@ -81,10 +81,9 @@ export const beforeChange = async ({ ) } - await mergeLocaleActions.reduce(async (priorAction, action) => { - await priorAction + for (const action of mergeLocaleActions) { await action() - }, Promise.resolve()) + } return data } diff --git a/packages/payload/src/fields/hooks/beforeChange/promise.ts b/packages/payload/src/fields/hooks/beforeChange/promise.ts index 01c00171a2..379a987ec9 100644 --- a/packages/payload/src/fields/hooks/beforeChange/promise.ts +++ b/packages/payload/src/fields/hooks/beforeChange/promise.ts @@ -32,7 +32,7 @@ type Args = { fieldIndex: number global: null | SanitizedGlobalConfig id?: number | string - mergeLocaleActions: (() => Promise)[] + mergeLocaleActions: (() => Promise | void)[] operation: Operation parentIndexPath: string parentIsLocalized: boolean @@ -109,10 +109,8 @@ export const promise = async ({ // Execute hooks if (field.hooks?.beforeChange) { - await field.hooks.beforeChange.reduce(async (priorHook, currentHook) => { - await priorHook - - const hookedValue = await currentHook({ + for (const hook of field.hooks.beforeChange) { + const hookedValue = await hook({ blockData, collection, context, @@ -136,7 +134,7 @@ export const promise = async ({ if (hookedValue !== undefined) { siblingData[field.name] = hookedValue } - }, Promise.resolve()) + } } // Validate @@ -193,28 +191,20 @@ export const promise = async ({ // Push merge locale action if applicable if (localization && fieldShouldBeLocalized({ field, parentIsLocalized })) { - mergeLocaleActions.push(async () => { - const localeData = await localization.localeCodes.reduce( - async (localizedValuesPromise: Promise, locale) => { - const localizedValues = await localizedValuesPromise - const fieldValue = - locale === req.locale - ? siblingData[field.name] - : siblingDocWithLocales?.[field.name]?.[locale] + mergeLocaleActions.push(() => { + const localeData = {} - // const result = await localizedValues - // update locale value if it's not undefined - if (typeof fieldValue !== 'undefined') { - return { - ...localizedValues, - [locale]: fieldValue, - } - } + for (const locale of localization.localeCodes) { + const fieldValue = + locale === req.locale + ? siblingData[field.name] + : siblingDocWithLocales?.[field.name]?.[locale] - return localizedValuesPromise - }, - Promise.resolve({}), - ) + // update locale value if it's not undefined + if (typeof fieldValue !== 'undefined') { + localeData[locale] = fieldValue + } + } // If there are locales with data, set the data if (Object.keys(localeData).length > 0) { @@ -424,10 +414,8 @@ export const promise = async ({ const editor: RichTextAdapter = field?.editor if (editor?.hooks?.beforeChange?.length) { - await editor.hooks.beforeChange.reduce(async (priorHook, currentHook) => { - await priorHook - - const hookedValue = await currentHook({ + for (const hook of editor.hooks.beforeChange) { + const hookedValue = await hook({ collection, context, data, @@ -454,7 +442,7 @@ export const promise = async ({ if (hookedValue !== undefined) { siblingData[field.name] = hookedValue } - }, Promise.resolve()) + } } break diff --git a/packages/payload/src/fields/hooks/beforeChange/traverseFields.ts b/packages/payload/src/fields/hooks/beforeChange/traverseFields.ts index abde171bd6..84c9eabf9a 100644 --- a/packages/payload/src/fields/hooks/beforeChange/traverseFields.ts +++ b/packages/payload/src/fields/hooks/beforeChange/traverseFields.ts @@ -28,7 +28,7 @@ type Args = { fields: (Field | TabAsField)[] global: null | SanitizedGlobalConfig id?: number | string - mergeLocaleActions: (() => Promise)[] + mergeLocaleActions: (() => Promise | void)[] operation: Operation parentIndexPath: string /** diff --git a/packages/payload/src/fields/hooks/beforeDuplicate/promise.ts b/packages/payload/src/fields/hooks/beforeDuplicate/promise.ts index b289167bc4..2daa2a91b4 100644 --- a/packages/payload/src/fields/hooks/beforeDuplicate/promise.ts +++ b/packages/payload/src/fields/hooks/beforeDuplicate/promise.ts @@ -6,7 +6,6 @@ import type { Block, Field, FieldHookArgs, TabAsField } from '../../config/types import { fieldAffectsData, fieldShouldBeLocalized } from '../../config/types.js' import { getFieldPathsModified as getFieldPaths } from '../../getFieldPaths.js' -import { runBeforeDuplicateHooks } from './runHook.js' import { traverseFields } from './traverseFields.js' type Args = { @@ -68,42 +67,37 @@ export const promise = async ({ // Run field beforeDuplicate hooks if (Array.isArray(field.hooks?.beforeDuplicate)) { if (fieldIsLocalized) { - const localeData = await localization.localeCodes.reduce( - async (localizedValuesPromise: Promise, locale) => { - const localizedValues = await localizedValuesPromise + const localeData: JsonObject = {} - const beforeDuplicateArgs: FieldHookArgs = { - blockData, - collection, - context, - data: doc, - field, - global: undefined, - indexPath: indexPathSegments, - path: pathSegments, - previousSiblingDoc: siblingDoc, - previousValue: siblingDoc[field.name]?.[locale], - req, - schemaPath: schemaPathSegments, - siblingData: siblingDoc, - siblingDocWithLocales: siblingDoc, - siblingFields, - value: siblingDoc[field.name]?.[locale], - } + for (const locale of localization.localeCodes) { + const beforeDuplicateArgs: FieldHookArgs = { + blockData, + collection, + context, + data: doc, + field, + global: undefined, + indexPath: indexPathSegments, + path: pathSegments, + previousSiblingDoc: siblingDoc, + previousValue: siblingDoc[field.name]?.[locale], + req, + schemaPath: schemaPathSegments, + siblingData: siblingDoc, + siblingDocWithLocales: siblingDoc, + siblingFields, + value: siblingDoc[field.name]?.[locale], + } - const hookResult = await runBeforeDuplicateHooks(beforeDuplicateArgs) + let hookResult + for (const hook of field.hooks.beforeDuplicate) { + hookResult = await hook(beforeDuplicateArgs) + } - if (typeof hookResult !== 'undefined') { - return { - ...localizedValues, - [locale]: hookResult, - } - } - - return localizedValuesPromise - }, - Promise.resolve({}), - ) + if (typeof hookResult !== 'undefined') { + localeData[locale] = hookResult + } + } siblingDoc[field.name] = localeData } else { @@ -126,7 +120,11 @@ export const promise = async ({ value: siblingDoc[field.name], } - const hookResult = await runBeforeDuplicateHooks(beforeDuplicateArgs) + let hookResult + for (const hook of field.hooks.beforeDuplicate) { + hookResult = await hook(beforeDuplicateArgs) + } + if (typeof hookResult !== 'undefined') { siblingDoc[field.name] = hookResult } diff --git a/packages/payload/src/fields/hooks/beforeDuplicate/runHook.ts b/packages/payload/src/fields/hooks/beforeDuplicate/runHook.ts deleted file mode 100644 index e6232ec4ce..0000000000 --- a/packages/payload/src/fields/hooks/beforeDuplicate/runHook.ts +++ /dev/null @@ -1,8 +0,0 @@ -// @ts-strict-ignore -import type { FieldHookArgs } from '../../config/types.js' - -export const runBeforeDuplicateHooks = async (args: FieldHookArgs) => - await args.field.hooks.beforeDuplicate.reduce(async (priorHook, currentHook) => { - await priorHook - return await currentHook(args) - }, Promise.resolve()) diff --git a/packages/payload/src/fields/hooks/beforeValidate/promise.ts b/packages/payload/src/fields/hooks/beforeValidate/promise.ts index 5917f6db85..59ba4a8a70 100644 --- a/packages/payload/src/fields/hooks/beforeValidate/promise.ts +++ b/packages/payload/src/fields/hooks/beforeValidate/promise.ts @@ -276,10 +276,8 @@ export const promise = async ({ // Execute hooks if (field.hooks?.beforeValidate) { - await field.hooks.beforeValidate.reduce(async (priorHook, currentHook) => { - await priorHook - - const hookedValue = await currentHook({ + for (const hook of field.hooks.beforeValidate) { + const hookedValue = await hook({ blockData, collection, context, @@ -303,7 +301,7 @@ export const promise = async ({ if (hookedValue !== undefined) { siblingData[field.name] = hookedValue } - }, Promise.resolve()) + } } // Execute access control @@ -493,10 +491,8 @@ export const promise = async ({ const editor: RichTextAdapter = field?.editor if (editor?.hooks?.beforeValidate?.length) { - await editor.hooks.beforeValidate.reduce(async (priorHook, currentHook) => { - await priorHook - - const hookedValue = await currentHook({ + for (const hook of editor.hooks.beforeValidate) { + const hookedValue = await hook({ collection, context, data, @@ -519,7 +515,7 @@ export const promise = async ({ if (hookedValue !== undefined) { siblingData[field.name] = hookedValue } - }, Promise.resolve()) + } } break } diff --git a/packages/payload/src/globals/operations/findOne.ts b/packages/payload/src/globals/operations/findOne.ts index 3a5517d7ef..48c989e977 100644 --- a/packages/payload/src/globals/operations/findOne.ts +++ b/packages/payload/src/globals/operations/findOne.ts @@ -133,17 +133,17 @@ export const findOneOperation = async >( // Execute before global hook // ///////////////////////////////////// - await globalConfig.hooks.beforeRead.reduce(async (priorHook, hook) => { - await priorHook - - doc = - (await hook({ - context: req.context, - doc, - global: globalConfig, - req, - })) || doc - }, Promise.resolve()) + if (globalConfig.hooks?.beforeRead?.length) { + for (const hook of globalConfig.hooks.beforeRead) { + doc = + (await hook({ + context: req.context, + doc, + global: globalConfig, + req, + })) || doc + } + } // ///////////////////////////////////// // Execute globalType field if not selected @@ -182,17 +182,17 @@ export const findOneOperation = async >( // Execute after global hook // ///////////////////////////////////// - await globalConfig.hooks.afterRead.reduce(async (priorHook, hook) => { - await priorHook - - doc = - (await hook({ - context: req.context, - doc, - global: globalConfig, - req, - })) || doc - }, Promise.resolve()) + if (globalConfig.hooks?.afterRead?.length) { + for (const hook of globalConfig.hooks.afterRead) { + doc = + (await hook({ + context: req.context, + doc, + global: globalConfig, + req, + })) || doc + } + } // ///////////////////////////////////// // Return results diff --git a/packages/payload/src/globals/operations/findVersionByID.ts b/packages/payload/src/globals/operations/findVersionByID.ts index 5e490421bb..47ddca86cb 100644 --- a/packages/payload/src/globals/operations/findVersionByID.ts +++ b/packages/payload/src/globals/operations/findVersionByID.ts @@ -102,17 +102,17 @@ export const findVersionByIDOperation = async = an // beforeRead - Collection // ///////////////////////////////////// - await globalConfig.hooks.beforeRead.reduce(async (priorHook, hook) => { - await priorHook - - result = - (await hook({ - context: req.context, - doc: result.version, - global: globalConfig, - req, - })) || result.version - }, Promise.resolve()) + if (globalConfig.hooks?.beforeRead?.length) { + for (const hook of globalConfig.hooks.beforeRead) { + result = + (await hook({ + context: req.context, + doc: result.version, + global: globalConfig, + req, + })) || result.version + } + } // ///////////////////////////////////// // afterRead - Fields @@ -139,18 +139,18 @@ export const findVersionByIDOperation = async = an // afterRead - Global // ///////////////////////////////////// - await globalConfig.hooks.afterRead.reduce(async (priorHook, hook) => { - await priorHook - - result.version = - (await hook({ - context: req.context, - doc: result.version, - global: globalConfig, - query: findGlobalVersionsArgs.where, - req, - })) || result.version - }, Promise.resolve()) + if (globalConfig.hooks?.afterRead?.length) { + for (const hook of globalConfig.hooks.afterRead) { + result.version = + (await hook({ + context: req.context, + doc: result.version, + global: globalConfig, + query: findGlobalVersionsArgs.where, + req, + })) || result.version + } + } return result } catch (error: unknown) { diff --git a/packages/payload/src/globals/operations/findVersions.ts b/packages/payload/src/globals/operations/findVersions.ts index 42b6365e39..426b4f7a24 100644 --- a/packages/payload/src/globals/operations/findVersions.ts +++ b/packages/payload/src/globals/operations/findVersions.ts @@ -126,15 +126,12 @@ export const findVersionsOperation = async >( // afterRead - Global // ///////////////////////////////////// - result = { - ...result, - docs: await Promise.all( + if (globalConfig.hooks?.afterRead?.length) { + result.docs = await Promise.all( result.docs.map(async (doc) => { const docRef = doc - await globalConfig.hooks.afterRead.reduce(async (priorHook, hook) => { - await priorHook - + for (const hook of globalConfig.hooks.afterRead) { docRef.version = (await hook({ context: req.context, @@ -144,11 +141,11 @@ export const findVersionsOperation = async >( query: fullWhere, req, })) || doc.version - }, Promise.resolve()) + } return docRef }), - ), + ) } // ///////////////////////////////////// diff --git a/packages/payload/src/globals/operations/restoreVersion.ts b/packages/payload/src/globals/operations/restoreVersion.ts index 0d46d43ccd..aa99ae7f02 100644 --- a/packages/payload/src/globals/operations/restoreVersion.ts +++ b/packages/payload/src/globals/operations/restoreVersion.ts @@ -143,17 +143,17 @@ export const restoreVersionOperation = async = any // afterRead - Global // ///////////////////////////////////// - await globalConfig.hooks.afterRead.reduce(async (priorHook, hook) => { - await priorHook - - result = - (await hook({ - context: req.context, - doc: result, - global: globalConfig, - req, - })) || result - }, Promise.resolve()) + if (globalConfig.hooks?.afterRead?.length) { + for (const hook of globalConfig.hooks.afterRead) { + result = + (await hook({ + context: req.context, + doc: result, + global: globalConfig, + req, + })) || result + } + } // ///////////////////////////////////// // afterChange - Fields @@ -174,18 +174,18 @@ export const restoreVersionOperation = async = any // afterChange - Global // ///////////////////////////////////// - await globalConfig.hooks.afterChange.reduce(async (priorHook, hook) => { - await priorHook - - result = - (await hook({ - context: req.context, - doc: result, - global: globalConfig, - previousDoc, - req, - })) || result - }, Promise.resolve()) + if (globalConfig.hooks?.afterChange?.length) { + for (const hook of globalConfig.hooks.afterChange) { + result = + (await hook({ + context: req.context, + doc: result, + global: globalConfig, + previousDoc, + req, + })) || result + } + } if (shouldCommit) { await commitTransaction(req) diff --git a/packages/payload/src/globals/operations/update.ts b/packages/payload/src/globals/operations/update.ts index cb6969aee8..7736129c96 100644 --- a/packages/payload/src/globals/operations/update.ts +++ b/packages/payload/src/globals/operations/update.ts @@ -168,35 +168,35 @@ export const updateOperation = async < // beforeValidate - Global // ///////////////////////////////////// - await globalConfig.hooks.beforeValidate.reduce(async (priorHook, hook) => { - await priorHook - - data = - (await hook({ - context: req.context, - data, - global: globalConfig, - originalDoc, - req, - })) || data - }, Promise.resolve()) + if (globalConfig.hooks?.beforeValidate?.length) { + for (const hook of globalConfig.hooks.beforeValidate) { + data = + (await hook({ + context: req.context, + data, + global: globalConfig, + originalDoc, + req, + })) || data + } + } // ///////////////////////////////////// // beforeChange - Global // ///////////////////////////////////// - await globalConfig.hooks.beforeChange.reduce(async (priorHook, hook) => { - await priorHook - - data = - (await hook({ - context: req.context, - data, - global: globalConfig, - originalDoc, - req, - })) || data - }, Promise.resolve()) + if (globalConfig.hooks?.beforeChange?.length) { + for (const hook of globalConfig.hooks.beforeChange) { + data = + (await hook({ + context: req.context, + data, + global: globalConfig, + originalDoc, + req, + })) || data + } + } // ///////////////////////////////////// // beforeChange - Fields @@ -326,17 +326,17 @@ export const updateOperation = async < // afterRead - Global // ///////////////////////////////////// - await globalConfig.hooks.afterRead.reduce(async (priorHook, hook) => { - await priorHook - - result = - (await hook({ - context: req.context, - doc: result, - global: globalConfig, - req, - })) || result - }, Promise.resolve()) + if (globalConfig.hooks?.afterRead?.length) { + for (const hook of globalConfig.hooks.afterRead) { + result = + (await hook({ + context: req.context, + doc: result, + global: globalConfig, + req, + })) || result + } + } // ///////////////////////////////////// // afterChange - Fields @@ -357,18 +357,18 @@ export const updateOperation = async < // afterChange - Global // ///////////////////////////////////// - await globalConfig.hooks.afterChange.reduce(async (priorHook, hook) => { - await priorHook - - result = - (await hook({ - context: req.context, - doc: result, - global: globalConfig, - previousDoc: originalDoc, - req, - })) || result - }, Promise.resolve()) + if (globalConfig.hooks?.afterChange?.length) { + for (const hook of globalConfig.hooks.afterChange) { + result = + (await hook({ + context: req.context, + doc: result, + global: globalConfig, + previousDoc: originalDoc, + req, + })) || result + } + } // ///////////////////////////////////// // Return results diff --git a/packages/payload/src/index.ts b/packages/payload/src/index.ts index 17dc214bf4..b2581d18d2 100644 --- a/packages/payload/src/index.ts +++ b/packages/payload/src/index.ts @@ -922,11 +922,13 @@ export const getPayload = async ( ) { try { const port = process.env.PORT || '3000' - const basePath = process.env.NEXT_BASE_PATH || '' - const assetPrefix = process.env.NEXT_ASSET_PREFIX || '' + + const path = '/_next/webpack-hmr' + // The __NEXT_ASSET_PREFIX env variable is set for both assetPrefix and basePath (tested in Next.js 15.1.6) + const prefix = process.env.__NEXT_ASSET_PREFIX ?? '' cached.ws = new WebSocket( - `ws://localhost:${port}${basePath}${assetPrefix}/_next/webpack-hmr`, + process.env.PAYLOAD_HMR_URL_OVERRIDE ?? `ws://localhost:${port}${prefix}${path}`, ) cached.ws.onmessage = (event) => { @@ -1372,6 +1374,7 @@ export { restoreVersionOperation as restoreVersionOperationGlobal } from './glob export { updateOperation as updateOperationGlobal } from './globals/operations/update.js' export type { CollapsedPreferences, + ColumnPreference, DocumentPreferences, FieldsPreferences, InsideFieldsPreferences, diff --git a/packages/payload/src/preferences/migrateColumns.ts b/packages/payload/src/preferences/migrateColumns.ts new file mode 100644 index 0000000000..bcf6727cd9 --- /dev/null +++ b/packages/payload/src/preferences/migrateColumns.ts @@ -0,0 +1,19 @@ +/** + * @todo remove this function and subsequent hooks in v4 + * They are used to transform the old shape of `columnPreferences` to new shape + * i.e. ({ accessor: string, active: boolean })[] to ({ [accessor: string]: boolean })[] + * In v4 can we use the new shape directly + */ +export const migrateColumns = (value: Record) => { + if (value && typeof value === 'object' && 'columns' in value && Array.isArray(value.columns)) { + value.columns = value.columns.map((col) => { + if ('accessor' in col) { + return { [col.accessor]: col.active } + } + + return col + }) + } + + return value +} diff --git a/packages/payload/src/preferences/preferencesCollection.ts b/packages/payload/src/preferences/preferencesCollection.ts index c6b0d4d5c0..a72389be46 100644 --- a/packages/payload/src/preferences/preferencesCollection.ts +++ b/packages/payload/src/preferences/preferencesCollection.ts @@ -2,6 +2,7 @@ import type { CollectionConfig } from '../collections/config/types.js' import type { Access, Config } from '../config/types.js' +import { migrateColumns } from './migrateColumns.js' import { deleteHandler } from './requestHandlers/delete.js' import { findByIDHandler } from './requestHandlers/findOne.js' import { updateHandler } from './requestHandlers/update.js' @@ -76,6 +77,14 @@ const getPreferencesCollection = (config: Config): CollectionConfig => ({ { name: 'value', type: 'json', + /** + * @todo remove these hooks in v4 + * See `migrateColumns` for more information + */ + hooks: { + afterRead: [({ value }) => migrateColumns(value)], + beforeValidate: [({ value }) => migrateColumns(value)], + }, validate: (value) => { if (value) { try { diff --git a/packages/payload/src/preferences/types.ts b/packages/payload/src/preferences/types.ts index 1405841675..c3686006bd 100644 --- a/packages/payload/src/preferences/types.ts +++ b/packages/payload/src/preferences/types.ts @@ -28,8 +28,12 @@ export type DocumentPreferences = { fields: FieldsPreferences } +export type ColumnPreference = { + [key: string]: boolean +} + export type ListPreferences = { - columns?: { accessor: string; active: boolean }[] + columns?: ColumnPreference[] limit?: number sort?: string } diff --git a/packages/payload/src/types/constants.ts b/packages/payload/src/types/constants.ts index 6c0eb6a2d4..7cbbaeb634 100644 --- a/packages/payload/src/types/constants.ts +++ b/packages/payload/src/types/constants.ts @@ -11,6 +11,7 @@ export const validOperators = [ 'less_than', 'less_than_equal', 'like', + 'not_like', 'within', 'intersects', 'near', diff --git a/packages/payload/src/utilities/addDataAndFileToRequest.ts b/packages/payload/src/utilities/addDataAndFileToRequest.ts index 3fff110a57..c07656953e 100644 --- a/packages/payload/src/utilities/addDataAndFileToRequest.ts +++ b/packages/payload/src/utilities/addDataAndFileToRequest.ts @@ -19,7 +19,8 @@ export const addDataAndFileToRequest: AddDataAndFileToRequest = async (req) => { if (contentType === 'application/json') { let data = {} try { - data = await req.json() + const text = await req.text() + data = text ? JSON.parse(text) : {} } catch (error) { req.payload.logger.error(error) } finally { diff --git a/packages/payload/src/utilities/configToJSONSchema.ts b/packages/payload/src/utilities/configToJSONSchema.ts index aeb55398c9..d17d39a869 100644 --- a/packages/payload/src/utilities/configToJSONSchema.ts +++ b/packages/payload/src/utilities/configToJSONSchema.ts @@ -15,7 +15,6 @@ import type { SanitizedGlobalConfig } from '../globals/config/types.js' import { MissingEditorProp } from '../errors/MissingEditorProp.js' import { fieldAffectsData } from '../fields/config/types.js' import { generateJobsJSONSchemas } from '../queues/config/generateJobsJSONSchemas.js' -import { deepCopyObject } from './deepCopyObject.js' import { toWords } from './formatLabels.js' import { getCollectionIDFieldTypes } from './getCollectionIDFieldTypes.js' @@ -719,7 +718,7 @@ export function fieldsToJSONSchema( // This function is part of the public API and is exported through payload/utilities export function entityToJSONSchema( config: SanitizedConfig, - incomingEntity: SanitizedCollectionConfig | SanitizedGlobalConfig, + entity: SanitizedCollectionConfig | SanitizedGlobalConfig, interfaceNameDefinitions: Map, defaultIDType: 'number' | 'text', collectionIDFieldTypes?: { [key: string]: 'number' | 'string' }, @@ -729,25 +728,30 @@ export function entityToJSONSchema( collectionIDFieldTypes = getCollectionIDFieldTypes({ config, defaultIDType }) } - const entity: SanitizedCollectionConfig | SanitizedGlobalConfig = deepCopyObject(incomingEntity) const title = entity.typescript?.interface ? entity.typescript.interface : singular(toWords(entity.slug, true)) + let mutableFields = [...entity.flattenedFields] + const idField: FieldAffectingData = { name: 'id', type: defaultIDType as 'text', required: true } - const customIdField = entity.flattenedFields.find( - (field) => field.name === 'id', - ) as FieldAffectingData + const customIdField = mutableFields.find((field) => field.name === 'id') as FieldAffectingData if (customIdField && customIdField.type !== 'group' && customIdField.type !== 'tab') { - customIdField.required = true + mutableFields = mutableFields.map((field) => { + if (field === customIdField) { + return { ...field, required: true } + } + + return field + }) } else { - entity.flattenedFields.unshift(idField) + mutableFields.unshift(idField) } // mark timestamp fields required if ('timestamps' in entity && entity.timestamps !== false) { - entity.flattenedFields = entity.flattenedFields.map((field) => { + mutableFields = mutableFields.map((field) => { if (field.name === 'createdAt' || field.name === 'updatedAt') { return { ...field, @@ -765,7 +769,7 @@ export function entityToJSONSchema( (typeof entity.auth?.disableLocalStrategy === 'object' && entity.auth.disableLocalStrategy.enableFields)) ) { - entity.flattenedFields.push({ + mutableFields.push({ name: 'password', type: 'text', }) @@ -777,7 +781,7 @@ export function entityToJSONSchema( title, ...fieldsToJSONSchema( collectionIDFieldTypes, - entity.flattenedFields, + mutableFields, interfaceNameDefinitions, config, i18n, @@ -1152,40 +1156,42 @@ export function configToJSONSchema( ) : {} - const blocksDefinition: JSONSchema4 = { + const blocksDefinition: JSONSchema4 | undefined = { type: 'object', additionalProperties: false, properties: {}, required: [], } - for (const block of config.blocks) { - const blockFieldSchemas = fieldsToJSONSchema( - collectionIDFieldTypes, - block.flattenedFields, - interfaceNameDefinitions, - config, - i18n, - ) + if (config?.blocks?.length) { + for (const block of config.blocks) { + const blockFieldSchemas = fieldsToJSONSchema( + collectionIDFieldTypes, + block.flattenedFields, + interfaceNameDefinitions, + config, + i18n, + ) - const blockSchema: JSONSchema4 = { - type: 'object', - additionalProperties: false, - properties: { - ...blockFieldSchemas.properties, - blockType: { - const: block.slug, + const blockSchema: JSONSchema4 = { + type: 'object', + additionalProperties: false, + properties: { + ...blockFieldSchemas.properties, + blockType: { + const: block.slug, + }, }, - }, - required: ['blockType', ...blockFieldSchemas.required], - } + required: ['blockType', ...blockFieldSchemas.required], + } - const interfaceName = block.interfaceName ?? block.slug - interfaceNameDefinitions.set(interfaceName, blockSchema) + const interfaceName = block.interfaceName ?? block.slug + interfaceNameDefinitions.set(interfaceName, blockSchema) - blocksDefinition.properties[block.slug] = { - $ref: `#/definitions/${interfaceName}`, + blocksDefinition.properties[block.slug] = { + $ref: `#/definitions/${interfaceName}`, + } + ;(blocksDefinition.required as string[]).push(block.slug) } - ;(blocksDefinition.required as string[]).push(block.slug) } let jsonSchema: JSONSchema4 = { @@ -1225,6 +1231,7 @@ export function configToJSONSchema( ], title: 'Config', } + if (jobsSchemas.definitions?.size) { for (const [key, value] of jobsSchemas.definitions) { jsonSchema.definitions[key] = value diff --git a/packages/plugin-multi-tenant/src/exports/client.ts b/packages/plugin-multi-tenant/src/exports/client.ts index 0abd2ea795..cd6fb65b94 100644 --- a/packages/plugin-multi-tenant/src/exports/client.ts +++ b/packages/plugin-multi-tenant/src/exports/client.ts @@ -1,2 +1,3 @@ export { TenantField } from '../components/TenantField/index.client.js' export { TenantSelector } from '../components/TenantSelector/index.js' +export { useTenantSelection } from '../providers/TenantSelectionProvider/index.client.js' diff --git a/packages/plugin-multi-tenant/src/hooks/afterTenantDelete.ts b/packages/plugin-multi-tenant/src/hooks/afterTenantDelete.ts index 8deb27942d..626bb3014d 100644 --- a/packages/plugin-multi-tenant/src/hooks/afterTenantDelete.ts +++ b/packages/plugin-multi-tenant/src/hooks/afterTenantDelete.ts @@ -115,7 +115,13 @@ export const afterTenantDelete = id: user.id, collection: usersSlug, data: { - tenants: (user.tenants || []).filter(({ tenant: tenantID }) => tenantID !== id), + [usersTenantsArrayFieldName]: (user[usersTenantsArrayFieldName] || []).filter( + (row: Record) => { + if (row[usersTenantsArrayTenantFieldName]) { + return row[usersTenantsArrayTenantFieldName] !== id + } + }, + ), }, }), ) diff --git a/packages/plugin-multi-tenant/src/index.ts b/packages/plugin-multi-tenant/src/index.ts index 96d254a938..a94da8d059 100644 --- a/packages/plugin-multi-tenant/src/index.ts +++ b/packages/plugin-multi-tenant/src/index.ts @@ -92,6 +92,8 @@ export const multiTenantPlugin = addCollectionAccess({ collection: adminUsersCollection, fieldName: `${tenantsArrayFieldName}.${tenantsArrayTenantFieldName}`, + tenantsArrayFieldName, + tenantsArrayTenantFieldName, userHasAccessToAllTenants, }) @@ -130,6 +132,8 @@ export const multiTenantPlugin = addCollectionAccess({ collection, fieldName: 'id', + tenantsArrayFieldName, + tenantsArrayTenantFieldName, userHasAccessToAllTenants, }) } @@ -205,6 +209,8 @@ export const multiTenantPlugin = addCollectionAccess({ collection, fieldName: tenantFieldName, + tenantsArrayFieldName, + tenantsArrayTenantFieldName, userHasAccessToAllTenants, }) } diff --git a/packages/plugin-multi-tenant/src/providers/TenantSelectionProvider/index.client.tsx b/packages/plugin-multi-tenant/src/providers/TenantSelectionProvider/index.client.tsx index 5587303648..eacf66cb29 100644 --- a/packages/plugin-multi-tenant/src/providers/TenantSelectionProvider/index.client.tsx +++ b/packages/plugin-multi-tenant/src/providers/TenantSelectionProvider/index.client.tsx @@ -9,9 +9,26 @@ import React, { createContext } from 'react' import { SELECT_ALL } from '../../constants.js' type ContextType = { + /** + * Array of options to select from + */ options: OptionObject[] + /** + * The currently selected tenant ID + */ selectedTenantID: number | string | undefined + /** + * Prevents a refresh when the tenant is changed + * + * If not switching tenants while viewing a "global", set to true + */ setPreventRefreshOnChange: React.Dispatch> + /** + * Sets the selected tenant ID + * + * @param args.id - The ID of the tenant to select + * @param args.refresh - Whether to refresh the page after changing the tenant + */ setTenant: (args: { id: number | string | undefined; refresh?: boolean }) => void } diff --git a/packages/plugin-multi-tenant/src/utilities/addCollectionAccess.ts b/packages/plugin-multi-tenant/src/utilities/addCollectionAccess.ts index 65d41b12ef..e70d45965f 100644 --- a/packages/plugin-multi-tenant/src/utilities/addCollectionAccess.ts +++ b/packages/plugin-multi-tenant/src/utilities/addCollectionAccess.ts @@ -20,6 +20,8 @@ const collectionAccessKeys: AllAccessKeys< type Args = { collection: CollectionConfig fieldName: string + tenantsArrayFieldName?: string + tenantsArrayTenantFieldName?: string userHasAccessToAllTenants: Required< MultiTenantPluginConfig >['userHasAccessToAllTenants'] @@ -32,6 +34,8 @@ type Args = { export const addCollectionAccess = ({ collection, fieldName, + tenantsArrayFieldName, + tenantsArrayTenantFieldName, userHasAccessToAllTenants, }: Args): void => { collectionAccessKeys.forEach((key) => { @@ -40,7 +44,11 @@ export const addCollectionAccess = ({ } collection.access[key] = withTenantAccess({ accessFunction: collection.access?.[key], + collection, fieldName: key === 'readVersions' ? `version.${fieldName}` : fieldName, + operation: key, + tenantsArrayFieldName, + tenantsArrayTenantFieldName, userHasAccessToAllTenants, }) }) diff --git a/packages/plugin-multi-tenant/src/utilities/getGlobalViewRedirect.ts b/packages/plugin-multi-tenant/src/utilities/getGlobalViewRedirect.ts index 562264848b..6cb79b7c15 100644 --- a/packages/plugin-multi-tenant/src/utilities/getGlobalViewRedirect.ts +++ b/packages/plugin-multi-tenant/src/utilities/getGlobalViewRedirect.ts @@ -52,6 +52,7 @@ export async function getGlobalViewRedirect({ depth: 0, limit: 1, overrideAccess: false, + pagination: false, user, where: { [tenantFieldName]: { diff --git a/packages/plugin-multi-tenant/src/utilities/getTenantAccess.ts b/packages/plugin-multi-tenant/src/utilities/getTenantAccess.ts index 7749fb37e0..b7cd2dc683 100644 --- a/packages/plugin-multi-tenant/src/utilities/getTenantAccess.ts +++ b/packages/plugin-multi-tenant/src/utilities/getTenantAccess.ts @@ -2,14 +2,25 @@ import type { Where } from 'payload' import type { UserWithTenantsField } from '../types.js' +import { defaults } from '../defaults.js' import { getUserTenantIDs } from './getUserTenantIDs.js' type Args = { fieldName: string + tenantsArrayFieldName?: string + tenantsArrayTenantFieldName?: string user: UserWithTenantsField } -export function getTenantAccess({ fieldName, user }: Args): Where { - const userAssignedTenantIDs = getUserTenantIDs(user) +export function getTenantAccess({ + fieldName, + tenantsArrayFieldName = defaults.tenantsArrayFieldName, + tenantsArrayTenantFieldName = defaults.tenantsArrayTenantFieldName, + user, +}: Args): Where { + const userAssignedTenantIDs = getUserTenantIDs(user, { + tenantsArrayFieldName, + tenantsArrayTenantFieldName, + }) return { [fieldName]: { diff --git a/packages/plugin-multi-tenant/src/utilities/getUserTenantIDs.ts b/packages/plugin-multi-tenant/src/utilities/getUserTenantIDs.ts index fce80f19e6..01ebd6a979 100644 --- a/packages/plugin-multi-tenant/src/utilities/getUserTenantIDs.ts +++ b/packages/plugin-multi-tenant/src/utilities/getUserTenantIDs.ts @@ -1,5 +1,6 @@ import type { Tenant, UserWithTenantsField } from '../types.js' +import { defaults } from '../defaults.js' import { extractID } from './extractID.js' /** @@ -9,15 +10,26 @@ import { extractID } from './extractID.js' */ export const getUserTenantIDs = ( user: null | UserWithTenantsField, + options?: { + tenantsArrayFieldName?: string + tenantsArrayTenantFieldName?: string + }, ): IDType[] => { if (!user) { return [] } + const { + tenantsArrayFieldName = defaults.tenantsArrayFieldName, + tenantsArrayTenantFieldName = defaults.tenantsArrayTenantFieldName, + } = options || {} + return ( - user?.tenants?.reduce((acc, { tenant }) => { - if (tenant) { - acc.push(extractID(tenant as Tenant)) + (Array.isArray(user[tenantsArrayFieldName]) ? user[tenantsArrayFieldName] : [])?.reduce< + IDType[] + >((acc, row) => { + if (row[tenantsArrayTenantFieldName]) { + acc.push(extractID(row[tenantsArrayTenantFieldName] as Tenant)) } return acc diff --git a/packages/plugin-multi-tenant/src/utilities/withTenantAccess.ts b/packages/plugin-multi-tenant/src/utilities/withTenantAccess.ts index 003a8ee8aa..78fb779650 100644 --- a/packages/plugin-multi-tenant/src/utilities/withTenantAccess.ts +++ b/packages/plugin-multi-tenant/src/utilities/withTenantAccess.ts @@ -1,4 +1,12 @@ -import type { Access, AccessArgs, AccessResult, User } from 'payload' +import type { + Access, + AccessArgs, + AccessResult, + AllOperations, + CollectionConfig, + User, + Where, +} from 'payload' import type { MultiTenantPluginConfig, UserWithTenantsField } from '../types.js' @@ -7,15 +15,26 @@ import { getTenantAccess } from './getTenantAccess.js' type Args = { accessFunction?: Access + collection: CollectionConfig fieldName: string + operation: AllOperations + tenantsArrayFieldName?: string + tenantsArrayTenantFieldName?: string userHasAccessToAllTenants: Required< MultiTenantPluginConfig >['userHasAccessToAllTenants'] } export const withTenantAccess = - ({ accessFunction, fieldName, userHasAccessToAllTenants }: Args) => + ({ + accessFunction, + collection, + fieldName, + tenantsArrayFieldName, + tenantsArrayTenantFieldName, + userHasAccessToAllTenants, + }: Args) => async (args: AccessArgs): Promise => { - const constraints = [] + const constraints: Where[] = [] const accessFn = typeof accessFunction === 'function' ? accessFunction @@ -34,12 +53,26 @@ export const withTenantAccess = args.req.user as ConfigType extends { user: unknown } ? ConfigType['user'] : User, ) ) { - constraints.push( - getTenantAccess({ - fieldName, - user: args.req.user as UserWithTenantsField, - }), - ) + const tenantConstraint = getTenantAccess({ + fieldName, + tenantsArrayFieldName, + tenantsArrayTenantFieldName, + user: args.req.user as UserWithTenantsField, + }) + if (collection.slug === args.req.user.collection) { + constraints.push({ + or: [ + { + id: { + equals: args.req.user.id, + }, + }, + tenantConstraint, + ], + }) + } else { + constraints.push(tenantConstraint) + } return combineWhereConstraints(constraints) } diff --git a/packages/plugin-nested-docs/src/hooks/resaveChildren.ts b/packages/plugin-nested-docs/src/hooks/resaveChildren.ts index 783cde5bab..bac3467a9e 100644 --- a/packages/plugin-nested-docs/src/hooks/resaveChildren.ts +++ b/packages/plugin-nested-docs/src/hooks/resaveChildren.ts @@ -81,10 +81,7 @@ const resave = async ({ collection, doc, draft, pluginConfig, req }: ResaveArgs) await req.payload.update({ id: child.id, collection: collection.slug, - data: { - ...child, - [breadcrumbSlug]: await populateBreadcrumbs(req, pluginConfig, collection, child), - }, + data: populateBreadcrumbs(req, pluginConfig, collection, child), depth: 0, draft: isDraft, locale: req.locale, diff --git a/packages/plugin-nested-docs/src/utilities/formatBreadcrumb.ts b/packages/plugin-nested-docs/src/utilities/formatBreadcrumb.ts index b7ee116e9f..92acac71ea 100644 --- a/packages/plugin-nested-docs/src/utilities/formatBreadcrumb.ts +++ b/packages/plugin-nested-docs/src/utilities/formatBreadcrumb.ts @@ -19,8 +19,9 @@ export const formatBreadcrumb = ( if (typeof pluginConfig?.generateLabel === 'function') { label = pluginConfig.generateLabel(docs, lastDoc) } else { - const useAsTitle = collection?.admin?.useAsTitle || 'id' - label = lastDoc[useAsTitle] as string + const title = lastDoc[collection.admin.useAsTitle] + + label = typeof title === 'string' || typeof title === 'number' ? String(title) : '' } return { diff --git a/packages/plugin-search/src/Search/hooks/deleteFromSearch.ts b/packages/plugin-search/src/Search/hooks/deleteFromSearch.ts index 761534eed9..04a395598c 100644 --- a/packages/plugin-search/src/Search/hooks/deleteFromSearch.ts +++ b/packages/plugin-search/src/Search/hooks/deleteFromSearch.ts @@ -12,6 +12,8 @@ export const deleteFromSearch: DeleteFromSearch = async ({ const searchDocQuery = await payload.find({ collection: searchSlug, depth: 0, + limit: 1, + pagination: false, req, where: { doc: { diff --git a/packages/plugin-search/src/utilities/syncDocAsSearchIndex.ts b/packages/plugin-search/src/utilities/syncDocAsSearchIndex.ts index 199a6c3b16..bb8734439f 100644 --- a/packages/plugin-search/src/utilities/syncDocAsSearchIndex.ts +++ b/packages/plugin-search/src/utilities/syncDocAsSearchIndex.ts @@ -149,7 +149,9 @@ export const syncDocAsSearchIndex = async ({ } = await payload.find({ collection, draft: false, + limit: 1, locale: syncLocale, + pagination: false, req, where: { and: [ diff --git a/packages/plugin-stripe/src/webhooks/handleCreatedOrUpdated.ts b/packages/plugin-stripe/src/webhooks/handleCreatedOrUpdated.ts index bf9c773e1f..16db3affcd 100644 --- a/packages/plugin-stripe/src/webhooks/handleCreatedOrUpdated.ts +++ b/packages/plugin-stripe/src/webhooks/handleCreatedOrUpdated.ts @@ -54,6 +54,8 @@ export const handleCreatedOrUpdated: HandleCreatedOrUpdated = async (args) => { // First, search for an existing document in Payload const payloadQuery = await payload.find({ collection: collectionSlug, + limit: 1, + pagination: false, where: { stripeID: { equals: stripeID, @@ -95,6 +97,8 @@ export const handleCreatedOrUpdated: HandleCreatedOrUpdated = async (args) => { if (stripeDoc?.email) { const authQuery = await payload.find({ collection: collectionSlug, + limit: 1, + pagination: false, where: { email: { equals: stripeDoc.email, diff --git a/packages/plugin-stripe/src/webhooks/handleDeleted.ts b/packages/plugin-stripe/src/webhooks/handleDeleted.ts index 75d0f55311..f74f6cbab2 100644 --- a/packages/plugin-stripe/src/webhooks/handleDeleted.ts +++ b/packages/plugin-stripe/src/webhooks/handleDeleted.ts @@ -40,6 +40,8 @@ export const handleDeleted: HandleDeleted = async (args) => { try { const payloadQuery = await payload.find({ collection: collectionSlug, + limit: 1, + pagination: false, where: { stripeID: { equals: stripeID, diff --git a/packages/richtext-lexical/src/features/relationship/client/drawer/index.tsx b/packages/richtext-lexical/src/features/relationship/client/drawer/index.tsx index 5f60d1667d..11b0b9c444 100644 --- a/packages/richtext-lexical/src/features/relationship/client/drawer/index.tsx +++ b/packages/richtext-lexical/src/features/relationship/client/drawer/index.tsx @@ -1,4 +1,5 @@ 'use client' +import type { ListDrawerProps } from '@payloadcms/ui' import type { LexicalEditor } from 'lexical' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext.js' @@ -67,13 +68,13 @@ const RelationshipDrawerComponent: React.FC = ({ enabledCollectionSlugs } ) }, [editor, openListDrawer]) - const onSelect = useCallback( - ({ collectionSlug, docID }: { collectionSlug: string; docID: number | string }) => { + const onSelect = useCallback>( + ({ collectionSlug, doc }) => { insertRelationship({ editor, relationTo: collectionSlug, replaceNodeKey, - value: docID, + value: doc.id, }) closeListDrawer() }, diff --git a/packages/richtext-lexical/src/features/typesServer.ts b/packages/richtext-lexical/src/features/typesServer.ts index 35ee04c646..96505597f8 100644 --- a/packages/richtext-lexical/src/features/typesServer.ts +++ b/packages/richtext-lexical/src/features/typesServer.ts @@ -176,7 +176,7 @@ export type BeforeChangeNodeHookArgs = { * Only available in `beforeChange` hooks. */ errors: ValidationFieldError[] - mergeLocaleActions: (() => Promise)[] + mergeLocaleActions: (() => Promise | void)[] /** A string relating to which operation the field type is currently executing within. Useful within beforeValidate, beforeChange, and afterChange hooks to differentiate between create and update operations. */ operation: 'create' | 'delete' | 'read' | 'update' /** The value of the node before any changes. Not available in afterRead hooks */ diff --git a/packages/richtext-lexical/src/features/upload/client/drawer/index.tsx b/packages/richtext-lexical/src/features/upload/client/drawer/index.tsx index b3b7645f5a..d384c0adb5 100644 --- a/packages/richtext-lexical/src/features/upload/client/drawer/index.tsx +++ b/packages/richtext-lexical/src/features/upload/client/drawer/index.tsx @@ -1,4 +1,5 @@ 'use client' +import type { ListDrawerProps } from '@payloadcms/ui' import type { LexicalEditor } from 'lexical' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext.js' @@ -77,14 +78,14 @@ const UploadDrawerComponent: React.FC = ({ enabledCollectionSlugs }) => { ) }, [editor, openListDrawer]) - const onSelect = useCallback( - ({ collectionSlug, docID }: { collectionSlug: string; docID: number | string }) => { + const onSelect = useCallback>( + ({ collectionSlug, doc }) => { closeListDrawer() insertUpload({ editor, relationTo: collectionSlug, replaceNodeKey, - value: docID, + value: doc.id, }) }, [editor, closeListDrawer, replaceNodeKey], diff --git a/packages/richtext-slate/src/field/elements/relationship/Button/index.tsx b/packages/richtext-slate/src/field/elements/relationship/Button/index.tsx index 103b221a18..b851227f06 100644 --- a/packages/richtext-slate/src/field/elements/relationship/Button/index.tsx +++ b/packages/richtext-slate/src/field/elements/relationship/Button/index.tsx @@ -1,7 +1,8 @@ 'use client' +import type { ListDrawerProps } from '@payloadcms/ui' import { useListDrawer, useTranslation } from '@payloadcms/ui' -import React, { Fragment, useCallback, useEffect, useState } from 'react' +import React, { Fragment, useCallback, useState } from 'react' import { ReactEditor, useSlate } from 'slate-react' import { RelationshipIcon } from '../../../icons/Relationship/index.js' @@ -34,15 +35,13 @@ type Props = { const RelationshipButtonComponent: React.FC = ({ enabledCollectionSlugs }) => { const { t } = useTranslation() const editor = useSlate() - const [selectedCollectionSlug, setSelectedCollectionSlug] = useState( - () => enabledCollectionSlugs[0], - ) - const [ListDrawer, ListDrawerToggler, { closeDrawer, isDrawerOpen }] = useListDrawer({ + const [selectedCollectionSlug] = useState(() => enabledCollectionSlugs[0]) + const [ListDrawer, ListDrawerToggler, { closeDrawer }] = useListDrawer({ collectionSlugs: enabledCollectionSlugs, selectedCollection: selectedCollectionSlug, }) - const onSelect = useCallback( + const onSelect = useCallback>( ({ collectionSlug, docID }) => { insertRelationship(editor, { relationTo: collectionSlug, diff --git a/packages/richtext-slate/src/field/elements/relationship/Element/index.tsx b/packages/richtext-slate/src/field/elements/relationship/Element/index.tsx index 321ae45a12..d31cb5e420 100644 --- a/packages/richtext-slate/src/field/elements/relationship/Element/index.tsx +++ b/packages/richtext-slate/src/field/elements/relationship/Element/index.tsx @@ -1,5 +1,7 @@ 'use client' +import type { ListDrawerProps } from '@payloadcms/ui' + import { getTranslation } from '@payloadcms/translations' import { Button, @@ -103,8 +105,8 @@ const RelationshipElementComponent: React.FC = () => { [editor, element, relatedCollection, cacheBust, setParams, closeDrawer], ) - const swapRelationship = React.useCallback( - ({ collectionSlug, docID }) => { + const swapRelationship = useCallback>( + ({ collectionSlug, doc }) => { const elementPath = ReactEditor.findPath(editor, element) Transforms.setNodes( @@ -113,7 +115,7 @@ const RelationshipElementComponent: React.FC = () => { type: 'relationship', children: [{ text: ' ' }], relationTo: collectionSlug, - value: { id: docID }, + value: { id: doc.id }, }, { at: elementPath }, ) diff --git a/packages/richtext-slate/src/field/elements/upload/Button/index.tsx b/packages/richtext-slate/src/field/elements/upload/Button/index.tsx index 7d6d9cfe8d..fd92669287 100644 --- a/packages/richtext-slate/src/field/elements/upload/Button/index.tsx +++ b/packages/richtext-slate/src/field/elements/upload/Button/index.tsx @@ -1,5 +1,7 @@ 'use client' +import type { ListDrawerProps } from '@payloadcms/ui' + import { useListDrawer, useTranslation } from '@payloadcms/ui' import React, { Fragment, useCallback } from 'react' import { ReactEditor, useSlate } from 'slate-react' @@ -41,12 +43,12 @@ const UploadButton: React.FC = ({ enabledCollectionSlugs }) => { uploads: true, }) - const onSelect = useCallback( - ({ collectionSlug, docID }) => { + const onSelect = useCallback>( + ({ collectionSlug, doc }) => { insertUpload(editor, { relationTo: collectionSlug, value: { - id: docID, + id: doc.id, }, }) closeDrawer() diff --git a/packages/richtext-slate/src/field/elements/upload/Element/index.tsx b/packages/richtext-slate/src/field/elements/upload/Element/index.tsx index 6f2671cb05..9f1d63b0df 100644 --- a/packages/richtext-slate/src/field/elements/upload/Element/index.tsx +++ b/packages/richtext-slate/src/field/elements/upload/Element/index.tsx @@ -1,5 +1,6 @@ 'use client' +import type { ListDrawerProps } from '@payloadcms/ui' import type { ClientCollectionConfig } from 'payload' import { getTranslation } from '@payloadcms/translations' @@ -23,8 +24,8 @@ import type { UploadElementType } from '../types.js' import { useElement } from '../../../providers/ElementProvider.js' import { EnabledRelationshipsCondition } from '../../EnabledRelationshipsCondition.js' import { uploadFieldsSchemaPath, uploadName } from '../shared.js' -import './index.scss' import { UploadDrawer } from './UploadDrawer/index.js' +import './index.scss' const baseClass = 'rich-text-upload' @@ -110,13 +111,13 @@ const UploadElementComponent: React.FC<{ enabledCollectionSlugs?: string[] }> = [editor, element, setParams, cacheBust, closeDrawer], ) - const swapUpload = React.useCallback( - ({ collectionSlug, docID }) => { + const swapUpload = useCallback>( + ({ collectionSlug, doc }) => { const newNode = { type: uploadName, children: [{ text: ' ' }], relationTo: collectionSlug, - value: { id: docID }, + value: { id: doc.id }, } const elementPath = ReactEditor.findPath(editor, element) diff --git a/packages/translations/src/clientKeys.ts b/packages/translations/src/clientKeys.ts index fe4e69ec33..cf16b4f237 100644 --- a/packages/translations/src/clientKeys.ts +++ b/packages/translations/src/clientKeys.ts @@ -301,6 +301,7 @@ export const clientTranslationKeys = createClientTranslationKeys([ 'operators:isIn', 'operators:contains', 'operators:isLike', + 'operators:isNotLike', 'operators:isNotEqualTo', 'operators:near', 'operators:isGreaterThan', diff --git a/packages/translations/src/languages/ar.ts b/packages/translations/src/languages/ar.ts index 2786e15495..5b17f2403b 100644 --- a/packages/translations/src/languages/ar.ts +++ b/packages/translations/src/languages/ar.ts @@ -370,6 +370,7 @@ export const arTranslations: DefaultTranslationsObject = { isLike: 'هو مثل', isNotEqualTo: 'لا يساوي', isNotIn: 'غير موجود في', + isNotLike: 'ليس مثل', near: 'قريب من', within: 'في غضون', }, diff --git a/packages/translations/src/languages/az.ts b/packages/translations/src/languages/az.ts index 5274fd8723..9801df92a8 100644 --- a/packages/translations/src/languages/az.ts +++ b/packages/translations/src/languages/az.ts @@ -375,6 +375,7 @@ export const azTranslations: DefaultTranslationsObject = { isLike: 'kimi', isNotEqualTo: 'bərabər deyil', isNotIn: 'daxil deyil', + isNotLike: 'deyil kimi', near: 'yaxın', within: 'daxilinde', }, diff --git a/packages/translations/src/languages/bg.ts b/packages/translations/src/languages/bg.ts index bc5efa25b1..5e1275d710 100644 --- a/packages/translations/src/languages/bg.ts +++ b/packages/translations/src/languages/bg.ts @@ -373,6 +373,7 @@ export const bgTranslations: DefaultTranslationsObject = { isLike: 'е като', isNotEqualTo: 'не е равно на', isNotIn: 'не е в', + isNotLike: 'не е като', near: 'близко', within: 'в рамките на', }, diff --git a/packages/translations/src/languages/ca.ts b/packages/translations/src/languages/ca.ts index b77c40589a..a355699a00 100644 --- a/packages/translations/src/languages/ca.ts +++ b/packages/translations/src/languages/ca.ts @@ -374,6 +374,7 @@ export const caTranslations: DefaultTranslationsObject = { isLike: 'és semblant a', isNotEqualTo: 'no és igual a', isNotIn: 'no està en', + isNotLike: 'no és com', near: 'a prop de', within: 'dins de', }, diff --git a/packages/translations/src/languages/cs.ts b/packages/translations/src/languages/cs.ts index d6202264d0..072a477ef3 100644 --- a/packages/translations/src/languages/cs.ts +++ b/packages/translations/src/languages/cs.ts @@ -371,6 +371,7 @@ export const csTranslations: DefaultTranslationsObject = { isLike: 'je jako', isNotEqualTo: 'není rovno', isNotIn: 'není v', + isNotLike: 'není jako', near: 'blízko', within: 'uvnitř', }, diff --git a/packages/translations/src/languages/da.ts b/packages/translations/src/languages/da.ts index c24a6418d0..52aa59d898 100644 --- a/packages/translations/src/languages/da.ts +++ b/packages/translations/src/languages/da.ts @@ -372,6 +372,7 @@ export const daTranslations: DefaultTranslationsObject = { isLike: 'Ligner', isNotEqualTo: 'Er ikke lig med', isNotIn: 'Er ikke i', + isNotLike: 'er ikke som', near: 'Tæt på', within: 'Inden for', }, diff --git a/packages/translations/src/languages/de.ts b/packages/translations/src/languages/de.ts index 136cae63bd..d5e400f55b 100644 --- a/packages/translations/src/languages/de.ts +++ b/packages/translations/src/languages/de.ts @@ -379,6 +379,7 @@ export const deTranslations: DefaultTranslationsObject = { isLike: 'ist wie', isNotEqualTo: 'ist nicht gleich', isNotIn: 'ist nicht drin', + isNotLike: 'ist nicht wie', near: 'in der Nähe', within: 'innerhalb', }, diff --git a/packages/translations/src/languages/en.ts b/packages/translations/src/languages/en.ts index c6885dfd4e..99944915a1 100644 --- a/packages/translations/src/languages/en.ts +++ b/packages/translations/src/languages/en.ts @@ -374,6 +374,7 @@ export const enTranslations = { isLike: 'is like', isNotEqualTo: 'is not equal to', isNotIn: 'is not in', + isNotLike: 'is not like', near: 'near', within: 'within', }, diff --git a/packages/translations/src/languages/es.ts b/packages/translations/src/languages/es.ts index 13f163f506..e52f71cd1d 100644 --- a/packages/translations/src/languages/es.ts +++ b/packages/translations/src/languages/es.ts @@ -378,6 +378,7 @@ export const esTranslations: DefaultTranslationsObject = { isLike: 'es como', isNotEqualTo: 'no es igual a', isNotIn: 'no está en', + isNotLike: 'no es como', near: 'cerca', within: 'dentro de', }, diff --git a/packages/translations/src/languages/et.ts b/packages/translations/src/languages/et.ts index 7db8dd89ce..cbc2122a00 100644 --- a/packages/translations/src/languages/et.ts +++ b/packages/translations/src/languages/et.ts @@ -370,6 +370,7 @@ export const etTranslations: DefaultTranslationsObject = { isLike: 'on sarnane', isNotEqualTo: 'ei võrdu', isNotIn: 'ei ole hulgas', + isNotLike: 'ei ole nagu', near: 'lähedal', within: 'vahemikus', }, diff --git a/packages/translations/src/languages/fa.ts b/packages/translations/src/languages/fa.ts index 912ae3818e..37e2edd85b 100644 --- a/packages/translations/src/languages/fa.ts +++ b/packages/translations/src/languages/fa.ts @@ -371,6 +371,7 @@ export const faTranslations: DefaultTranslationsObject = { isLike: 'مانند این است', isNotEqualTo: 'برابر نیست', isNotIn: 'در این نیست', + isNotLike: 'مانند نیست', near: 'نزدیک', within: 'در داخل', }, diff --git a/packages/translations/src/languages/fr.ts b/packages/translations/src/languages/fr.ts index 06a6e0bdeb..0436b817d8 100644 --- a/packages/translations/src/languages/fr.ts +++ b/packages/translations/src/languages/fr.ts @@ -383,6 +383,7 @@ export const frTranslations: DefaultTranslationsObject = { isLike: 'est comme', isNotEqualTo: 'n’est pas égal à', isNotIn: 'n’est pas dans', + isNotLike: "n'est pas comme", near: 'proche', within: 'dans', }, diff --git a/packages/translations/src/languages/he.ts b/packages/translations/src/languages/he.ts index f1024d68e4..3acd9855d3 100644 --- a/packages/translations/src/languages/he.ts +++ b/packages/translations/src/languages/he.ts @@ -366,6 +366,7 @@ export const heTranslations: DefaultTranslationsObject = { isLike: 'דומה ל', isNotEqualTo: 'לא שווה ל', isNotIn: 'לא נמצא ב', + isNotLike: 'אינו דומה', near: 'קרוב ל', within: 'בתוך', }, diff --git a/packages/translations/src/languages/hr.ts b/packages/translations/src/languages/hr.ts index 918a43f2f3..980b4670d5 100644 --- a/packages/translations/src/languages/hr.ts +++ b/packages/translations/src/languages/hr.ts @@ -373,6 +373,7 @@ export const hrTranslations: DefaultTranslationsObject = { isLike: 'je kao', isNotEqualTo: 'nije jednako', isNotIn: 'nije unutra', + isNotLike: 'nije kao', near: 'blizu', within: 'unutar', }, diff --git a/packages/translations/src/languages/hu.ts b/packages/translations/src/languages/hu.ts index 547ae0a385..2275076e99 100644 --- a/packages/translations/src/languages/hu.ts +++ b/packages/translations/src/languages/hu.ts @@ -376,6 +376,7 @@ export const huTranslations: DefaultTranslationsObject = { isLike: 'olyan, mint', isNotEqualTo: 'nem egyenlő', isNotIn: 'nincs benne', + isNotLike: 'nem olyan mint', near: 'közel', within: 'belül', }, diff --git a/packages/translations/src/languages/it.ts b/packages/translations/src/languages/it.ts index c432de1d77..35200dcee4 100644 --- a/packages/translations/src/languages/it.ts +++ b/packages/translations/src/languages/it.ts @@ -377,6 +377,7 @@ export const itTranslations: DefaultTranslationsObject = { isLike: 'è come', isNotEqualTo: 'non è uguale a', isNotIn: 'non è in', + isNotLike: 'non è come', near: 'vicino', within: "all'interno", }, diff --git a/packages/translations/src/languages/ja.ts b/packages/translations/src/languages/ja.ts index 555e0ba200..505adf587b 100644 --- a/packages/translations/src/languages/ja.ts +++ b/packages/translations/src/languages/ja.ts @@ -373,6 +373,7 @@ export const jaTranslations: DefaultTranslationsObject = { isLike: 'のような', isNotEqualTo: '等しくない', isNotIn: '入っていません', + isNotLike: 'ではない', near: '近く', within: '内で', }, diff --git a/packages/translations/src/languages/ko.ts b/packages/translations/src/languages/ko.ts index 709e42ea78..48bacf66af 100644 --- a/packages/translations/src/languages/ko.ts +++ b/packages/translations/src/languages/ko.ts @@ -371,6 +371,7 @@ export const koTranslations: DefaultTranslationsObject = { isLike: '유사', isNotEqualTo: '같지 않음', isNotIn: '포함되지 않음', + isNotLike: '같지 않다', near: '근처', within: '내에서', }, diff --git a/packages/translations/src/languages/lt.ts b/packages/translations/src/languages/lt.ts index c885697018..1fa7524aa2 100644 --- a/packages/translations/src/languages/lt.ts +++ b/packages/translations/src/languages/lt.ts @@ -375,6 +375,7 @@ export const ltTranslations: DefaultTranslationsObject = { isLike: 'yra panašu', isNotEqualTo: 'nelygu', isNotIn: 'nėra', + isNotLike: 'nėra panašus', near: 'šalia', within: 'viduje', }, diff --git a/packages/translations/src/languages/my.ts b/packages/translations/src/languages/my.ts index 97a13dac1e..b6dfb9af2d 100644 --- a/packages/translations/src/languages/my.ts +++ b/packages/translations/src/languages/my.ts @@ -378,6 +378,7 @@ export const myTranslations: DefaultTranslationsObject = { isLike: 'တူသည်', isNotEqualTo: 'ညီမျှသည်', isNotIn: 'မဝင်ပါ', + isNotLike: 'မဟုတ်ကဲ့သို့', near: 'နီး', within: 'အတွင်း', }, diff --git a/packages/translations/src/languages/nb.ts b/packages/translations/src/languages/nb.ts index 5e38ffd8fa..50e3336b38 100644 --- a/packages/translations/src/languages/nb.ts +++ b/packages/translations/src/languages/nb.ts @@ -374,6 +374,7 @@ export const nbTranslations: DefaultTranslationsObject = { isLike: 'er som', isNotEqualTo: 'er ikke lik', isNotIn: 'er ikke med', + isNotLike: 'er ikke lik', near: 'nær', within: 'innen', }, diff --git a/packages/translations/src/languages/nl.ts b/packages/translations/src/languages/nl.ts index 9255f319da..41fe48868e 100644 --- a/packages/translations/src/languages/nl.ts +++ b/packages/translations/src/languages/nl.ts @@ -377,6 +377,7 @@ export const nlTranslations: DefaultTranslationsObject = { isLike: 'is als', isNotEqualTo: 'is niet gelijk aan', isNotIn: 'zit er niet in', + isNotLike: 'is niet zoals', near: 'nabij', within: 'binnen', }, diff --git a/packages/translations/src/languages/pl.ts b/packages/translations/src/languages/pl.ts index d2ae7dd2f9..2f9bfba12c 100644 --- a/packages/translations/src/languages/pl.ts +++ b/packages/translations/src/languages/pl.ts @@ -373,6 +373,7 @@ export const plTranslations: DefaultTranslationsObject = { isLike: 'jest jak', isNotEqualTo: 'nie jest równe', isNotIn: 'nie ma go w', + isNotLike: 'nie jest jak', near: 'blisko', within: 'w ciągu', }, diff --git a/packages/translations/src/languages/pt.ts b/packages/translations/src/languages/pt.ts index f225801e5d..2164112e04 100644 --- a/packages/translations/src/languages/pt.ts +++ b/packages/translations/src/languages/pt.ts @@ -374,6 +374,7 @@ export const ptTranslations: DefaultTranslationsObject = { isLike: 'é como', isNotEqualTo: 'não é igual a', isNotIn: 'não está em', + isNotLike: 'não é como', near: 'perto', within: 'dentro', }, diff --git a/packages/translations/src/languages/ro.ts b/packages/translations/src/languages/ro.ts index a3132bd95c..ea9e1d6bd2 100644 --- a/packages/translations/src/languages/ro.ts +++ b/packages/translations/src/languages/ro.ts @@ -377,6 +377,7 @@ export const roTranslations: DefaultTranslationsObject = { isLike: 'este ca', isNotEqualTo: 'nu este egal cu', isNotIn: 'nu este în', + isNotLike: 'nu este ca', near: 'în apropiere de', within: 'înăuntru', }, diff --git a/packages/translations/src/languages/rs.ts b/packages/translations/src/languages/rs.ts index e468878dd9..38b76d92a1 100644 --- a/packages/translations/src/languages/rs.ts +++ b/packages/translations/src/languages/rs.ts @@ -373,6 +373,7 @@ export const rsTranslations: DefaultTranslationsObject = { isLike: 'је као', isNotEqualTo: 'није једнако', isNotIn: 'није у', + isNotLike: 'nije kao', near: 'близу', within: 'unutar', }, diff --git a/packages/translations/src/languages/rsLatin.ts b/packages/translations/src/languages/rsLatin.ts index 2f087bdecb..cba4d51d0d 100644 --- a/packages/translations/src/languages/rsLatin.ts +++ b/packages/translations/src/languages/rsLatin.ts @@ -374,6 +374,7 @@ export const rsLatinTranslations: DefaultTranslationsObject = { isLike: 'je kao', isNotEqualTo: 'nije jednako', isNotIn: 'nije unutra', + isNotLike: 'nije kao', near: 'blizu', within: 'unutar', }, diff --git a/packages/translations/src/languages/ru.ts b/packages/translations/src/languages/ru.ts index 65dafd1829..acb5828cd9 100644 --- a/packages/translations/src/languages/ru.ts +++ b/packages/translations/src/languages/ru.ts @@ -377,6 +377,7 @@ export const ruTranslations: DefaultTranslationsObject = { isLike: 'похоже', isNotEqualTo: 'не равно', isNotIn: 'нет в', + isNotLike: 'не похож', near: 'рядом', within: 'в пределах', }, diff --git a/packages/translations/src/languages/sk.ts b/packages/translations/src/languages/sk.ts index 8036611839..85a3d03a62 100644 --- a/packages/translations/src/languages/sk.ts +++ b/packages/translations/src/languages/sk.ts @@ -374,6 +374,7 @@ export const skTranslations: DefaultTranslationsObject = { isLike: 'je ako', isNotEqualTo: 'nie je rovné', isNotIn: 'nie je v', + isNotLike: 'nie je ako', near: 'blízko', within: 'vnútri', }, diff --git a/packages/translations/src/languages/sl.ts b/packages/translations/src/languages/sl.ts index cce83e554b..6cc87b28cb 100644 --- a/packages/translations/src/languages/sl.ts +++ b/packages/translations/src/languages/sl.ts @@ -372,6 +372,7 @@ export const slTranslations: DefaultTranslationsObject = { isLike: 'je podobno', isNotEqualTo: 'ni enako', isNotIn: 'ni v', + isNotLike: 'ni podobno', near: 'blizu', within: 'znotraj', }, diff --git a/packages/translations/src/languages/sv.ts b/packages/translations/src/languages/sv.ts index f6f8731681..03eac52039 100644 --- a/packages/translations/src/languages/sv.ts +++ b/packages/translations/src/languages/sv.ts @@ -374,6 +374,7 @@ export const svTranslations: DefaultTranslationsObject = { isLike: 'är som', isNotEqualTo: 'är inte lika med', isNotIn: 'är inte med', + isNotLike: 'är inte som', near: 'nära', within: 'inom', }, diff --git a/packages/translations/src/languages/th.ts b/packages/translations/src/languages/th.ts index 4195d5e70e..79b5f19b21 100644 --- a/packages/translations/src/languages/th.ts +++ b/packages/translations/src/languages/th.ts @@ -369,6 +369,7 @@ export const thTranslations: DefaultTranslationsObject = { isLike: 'เหมือน', isNotEqualTo: 'ไม่เท่ากับ', isNotIn: 'ไม่ได้อยู่ใน', + isNotLike: 'ไม่เหมือน', near: 'ใกล้', within: 'ภายใน', }, diff --git a/packages/translations/src/languages/tr.ts b/packages/translations/src/languages/tr.ts index 5743ea19b0..87e2243110 100644 --- a/packages/translations/src/languages/tr.ts +++ b/packages/translations/src/languages/tr.ts @@ -378,6 +378,7 @@ export const trTranslations: DefaultTranslationsObject = { isLike: 'gibidir', isNotEqualTo: 'eşit değildir', isNotIn: 'içinde değil', + isNotLike: 'gibi değil', near: 'yakın', within: 'içinde', }, diff --git a/packages/translations/src/languages/uk.ts b/packages/translations/src/languages/uk.ts index 277113116e..3ceb4b5ae2 100644 --- a/packages/translations/src/languages/uk.ts +++ b/packages/translations/src/languages/uk.ts @@ -372,6 +372,7 @@ export const ukTranslations: DefaultTranslationsObject = { isLike: 'схоже', isNotEqualTo: 'не дорівнює', isNotIn: 'не в', + isNotLike: 'не такий як', near: 'поруч', within: 'в межах', }, diff --git a/packages/translations/src/languages/vi.ts b/packages/translations/src/languages/vi.ts index 405f7a7a05..b11716d869 100644 --- a/packages/translations/src/languages/vi.ts +++ b/packages/translations/src/languages/vi.ts @@ -372,6 +372,7 @@ export const viTranslations: DefaultTranslationsObject = { isLike: 'gần giống', isNotEqualTo: 'không bằng', isNotIn: 'không có trong', + isNotLike: 'không giống như', near: 'gần', within: 'trong', }, diff --git a/packages/translations/src/languages/zh.ts b/packages/translations/src/languages/zh.ts index e4ac7491e6..8a66c5ef40 100644 --- a/packages/translations/src/languages/zh.ts +++ b/packages/translations/src/languages/zh.ts @@ -362,6 +362,7 @@ export const zhTranslations: DefaultTranslationsObject = { isLike: '就像', isNotEqualTo: '不等于', isNotIn: '不在', + isNotLike: '不像', near: '附近', within: '在...之内', }, diff --git a/packages/translations/src/languages/zhTw.ts b/packages/translations/src/languages/zhTw.ts index 3929675dc3..e365afc0ef 100644 --- a/packages/translations/src/languages/zhTw.ts +++ b/packages/translations/src/languages/zhTw.ts @@ -362,6 +362,7 @@ export const zhTwTranslations: DefaultTranslationsObject = { isLike: '就像', isNotEqualTo: '不等於', isNotIn: '不在', + isNotLike: '不像', near: '附近', within: '在...之內', }, diff --git a/packages/ui/src/elements/BulkUpload/DiscardWithoutSaving/index.tsx b/packages/ui/src/elements/BulkUpload/DiscardWithoutSaving/index.tsx index 3ecee89e65..4c74ae4ba7 100644 --- a/packages/ui/src/elements/BulkUpload/DiscardWithoutSaving/index.tsx +++ b/packages/ui/src/elements/BulkUpload/DiscardWithoutSaving/index.tsx @@ -4,12 +4,9 @@ import { useModal } from '@faceless-ui/modal' import React from 'react' import { useTranslation } from '../../../providers/Translation/index.js' -import { Button } from '../../Button/index.js' -import { FullscreenModal } from '../../FullscreenModal/index.js' +import { ConfirmationModal } from '../../ConfirmationModal/index.js' import { useBulkUpload } from '../index.js' - export const discardBulkUploadModalSlug = 'bulk-upload--discard-without-saving' -const baseClass = 'leave-without-saving' export function DiscardWithoutSaving() { const { t } = useTranslation() @@ -26,21 +23,14 @@ export function DiscardWithoutSaving() { }, [closeModal, drawerSlug]) return ( - -
-
-

{t('general:leaveWithoutSaving')}

-

{t('general:changesNotSaved')}

-
-
- - -
-
-
+ ) } diff --git a/packages/ui/src/elements/BulkUpload/FormsManager/index.tsx b/packages/ui/src/elements/BulkUpload/FormsManager/index.tsx index 762b49493e..965d90478a 100644 --- a/packages/ui/src/elements/BulkUpload/FormsManager/index.tsx +++ b/packages/ui/src/elements/BulkUpload/FormsManager/index.tsx @@ -3,6 +3,7 @@ import type { Data, DocumentSlots, FormState, SanitizedDocumentPermissions } from 'payload' import { useModal } from '@faceless-ui/modal' +import { isImage } from 'payload/shared' import * as qs from 'qs-esm' import React from 'react' import { toast } from 'sonner' @@ -122,7 +123,7 @@ export function FormsManagerProvider({ children }: FormsManagerProps) { const file = formsRef.current[i].formState.file.value as File // Skip if already processed - if (processedFiles.current.has(file) || !file) { + if (processedFiles.current.has(file) || !file || !isImage(file.type)) { continue } processedFiles.current.add(file) diff --git a/packages/ui/src/elements/DeleteMany/index.scss b/packages/ui/src/elements/DeleteMany/index.scss index 4e3cd18744..414949b10a 100644 --- a/packages/ui/src/elements/DeleteMany/index.scss +++ b/packages/ui/src/elements/DeleteMany/index.scss @@ -7,5 +7,21 @@ align-items: center; justify-content: center; height: 100%; + + &__toggle { + font-size: inherit; + line-height: inherit; + display: inline-flex; + background: transparent; + color: var(--theme-elevation-800); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + border: 0; + padding: 0; + align-items: center; + cursor: pointer; + text-decoration: underline; + } } } diff --git a/packages/ui/src/elements/DeleteMany/index.tsx b/packages/ui/src/elements/DeleteMany/index.tsx index bd27581c58..a7fc4451dc 100644 --- a/packages/ui/src/elements/DeleteMany/index.tsx +++ b/packages/ui/src/elements/DeleteMany/index.tsx @@ -16,7 +16,6 @@ import { useTranslation } from '../../providers/Translation/index.js' import { requests } from '../../utilities/api.js' import { mergeListSearchAndWhere } from '../../utilities/mergeListSearchAndWhere.js' import { ConfirmationModal } from '../ConfirmationModal/index.js' -import { Pill } from '../Pill/index.js' import './index.scss' const baseClass = 'delete-documents' @@ -140,14 +139,15 @@ export const DeleteMany: React.FC = (props) => { return ( - { openModal(modalSlug) }} + type="button" > {t('general:delete')} - + = (props) => { } = props const { permissions } = useAuth() + const { openModal } = useModal() const { selectAll } = useSelection() const { t } = useTranslation() @@ -39,16 +41,17 @@ export const EditMany: React.FC = (props) => { return (
- { + openModal(drawerSlug) setSelected([]) }} - slug={drawerSlug} + type="button" > {t('general:edit')} - + [0]> = ({ +type Props = { + /** + * Disable the e.preventDefault() call on click if you want to handle it yourself via onClick + * + * @default true + */ + preventDefault?: boolean +} & Parameters[0] + +export const Link: React.FC = ({ children, href, onClick, + preventDefault = true, ref, replace, scroll, @@ -47,6 +57,12 @@ export const Link: React.FC[0]> = ({ onClick(e) } + // We need a preventDefault here so that a clicked link doesn't trigger twice, + // once for default browser navigation and once for startRouteTransition + if (preventDefault) { + e.preventDefault() + } + startRouteTransition(() => { const url = typeof href === 'string' ? href : formatUrl(href) diff --git a/packages/ui/src/elements/ListControls/index.tsx b/packages/ui/src/elements/ListControls/index.tsx index bb92a1e686..2deb787076 100644 --- a/packages/ui/src/elements/ListControls/index.tsx +++ b/packages/ui/src/elements/ListControls/index.tsx @@ -3,7 +3,7 @@ import type { ClientCollectionConfig, ResolvedFilterOptions, Where } from 'paylo import { useWindowInfo } from '@faceless-ui/window-info' import { getTranslation } from '@payloadcms/translations' -import React, { Fragment, useEffect, useRef, useState } from 'react' +import React, { useEffect, useRef, useState } from 'react' import { Popup, PopupList } from '../../elements/Popup/index.js' import { useUseTitleField } from '../../hooks/useUseAsTitle.js' @@ -14,12 +14,8 @@ import { useListQuery } from '../../providers/ListQuery/index.js' import { useTranslation } from '../../providers/Translation/index.js' import { AnimateHeight } from '../AnimateHeight/index.js' import { ColumnSelector } from '../ColumnSelector/index.js' -import { DeleteMany } from '../DeleteMany/index.js' -import { EditMany } from '../EditMany/index.js' import { Pill } from '../Pill/index.js' -import { PublishMany } from '../PublishMany/index.js' import { SearchFilter } from '../SearchFilter/index.js' -import { UnpublishMany } from '../UnpublishMany/index.js' import { WhereBuilder } from '../WhereBuilder/index.js' import validateWhereQuery from '../WhereBuilder/validateWhereQuery.js' import { getTextFieldsToBeSearched } from './getTextFieldsToBeSearched.js' @@ -31,7 +27,15 @@ export type ListControlsProps = { readonly beforeActions?: React.ReactNode[] readonly collectionConfig: ClientCollectionConfig readonly collectionSlug: string + /** + * @deprecated + * These are now handled by the `ListSelection` component + */ readonly disableBulkDelete?: boolean + /** + * @deprecated + * These are now handled by the `ListSelection` component + */ readonly disableBulkEdit?: boolean readonly enableColumns?: boolean readonly enableSort?: boolean @@ -53,8 +57,6 @@ export const ListControls: React.FC = (props) => { beforeActions, collectionConfig, collectionSlug, - disableBulkDelete, - disableBulkEdit, enableColumns = true, enableSort = false, listMenuItems, @@ -71,7 +73,8 @@ export const ListControls: React.FC = (props) => { const searchLabel = (titleField && getTranslation( - 'label' in titleField && typeof titleField.label === 'string' + 'label' in titleField && + (typeof titleField.label === 'string' || typeof titleField.label === 'object') ? titleField.label : 'name' in titleField ? titleField.name @@ -148,19 +151,7 @@ export const ListControls: React.FC = (props) => { />
- {!smallBreak && ( - - {beforeActions && beforeActions} - {!disableBulkEdit && ( - - - - - - )} - {!disableBulkDelete && } - - )} + {!smallBreak && {beforeActions && beforeActions}} {enableColumns && ( = ({ [ serverFunction, closeModal, + allowCreate, drawerSlug, isOpen, enableRowSelections, @@ -130,6 +131,7 @@ export const ListDrawerContent: React.FC = ({ if (typeof onSelect === 'function') { onSelect({ collectionSlug: selectedOption.value, + doc, docID: doc.id, }) } diff --git a/packages/ui/src/elements/ListDrawer/Provider.tsx b/packages/ui/src/elements/ListDrawer/Provider.tsx index 71cd4d1e59..c66f009680 100644 --- a/packages/ui/src/elements/ListDrawer/Provider.tsx +++ b/packages/ui/src/elements/ListDrawer/Provider.tsx @@ -1,4 +1,4 @@ -import type { CollectionSlug, ListQuery } from 'payload' +import type { CollectionSlug, Data, ListQuery } from 'payload' import { createContext, useContext } from 'react' @@ -14,7 +14,16 @@ export type ListDrawerContextProps = { readonly enabledCollections?: CollectionSlug[] readonly onBulkSelect?: (selected: ReturnType['selected']) => void readonly onQueryChange?: (query: ListQuery) => void - readonly onSelect?: (args: { collectionSlug: CollectionSlug; docID: string }) => void + readonly onSelect?: (args: { + collectionSlug: CollectionSlug + doc: Data + /** + * @deprecated + * The `docID` property is deprecated and will be removed in the next major version of Payload. + * Use `doc.id` instead. + */ + docID: string + }) => void readonly selectedOption?: Option readonly setSelectedOption?: (option: Option) => void } diff --git a/packages/ui/src/elements/ListSelection/index.scss b/packages/ui/src/elements/ListSelection/index.scss index d2dd8b8675..f3f496d708 100644 --- a/packages/ui/src/elements/ListSelection/index.scss +++ b/packages/ui/src/elements/ListSelection/index.scss @@ -2,8 +2,10 @@ @layer payload-default { .list-selection { + display: flex; margin-left: auto; color: var(--theme-elevation-500); + gap: 0.5em; &__button { color: var(--theme-elevation-500); @@ -11,6 +13,8 @@ border: none; text-decoration: underline; cursor: pointer; + padding: 0; + font-size: inherit; } @include small-break { diff --git a/packages/ui/src/elements/ListSelection/index.tsx b/packages/ui/src/elements/ListSelection/index.tsx index f72b645b83..f38d9f65cc 100644 --- a/packages/ui/src/elements/ListSelection/index.tsx +++ b/packages/ui/src/elements/ListSelection/index.tsx @@ -1,17 +1,31 @@ 'use client' +import type { ClientCollectionConfig } from 'payload' + import React, { Fragment } from 'react' import { SelectAllStatus, useSelection } from '../../providers/Selection/index.js' import { useTranslation } from '../../providers/Translation/index.js' +import { DeleteMany } from '../DeleteMany/index.js' +import { EditMany } from '../EditMany/index.js' +import { PublishMany } from '../PublishMany/index.js' +import { UnpublishMany } from '../UnpublishMany/index.js' import './index.scss' const baseClass = 'list-selection' export type ListSelectionProps = { + collectionConfig?: ClientCollectionConfig + disableBulkDelete?: boolean + disableBulkEdit?: boolean label: string } -export const ListSelection: React.FC = ({ label }) => { +export const ListSelection: React.FC = ({ + collectionConfig, + disableBulkDelete, + disableBulkEdit, + label, +}) => { const { count, selectAll, toggleAll, totalDocs } = useSelection() const { t } = useTranslation() @@ -21,21 +35,27 @@ export const ListSelection: React.FC = ({ label }) => { return (
- {t('general:selectedCount', { count, label })} - {selectAll !== SelectAllStatus.AllAvailable && ( + {t('general:selectedCount', { count, label: '' })} + {selectAll !== SelectAllStatus.AllAvailable && count < totalDocs && ( + + )} + {!disableBulkEdit && !disableBulkDelete && } + {!disableBulkEdit && ( - {' '} - — - + + + )} + {!disableBulkDelete && }
) } diff --git a/packages/ui/src/elements/PublishMany/index.scss b/packages/ui/src/elements/PublishMany/index.scss new file mode 100644 index 0000000000..d310ebdf22 --- /dev/null +++ b/packages/ui/src/elements/PublishMany/index.scss @@ -0,0 +1,21 @@ +@import '../../scss/styles.scss'; + +@layer payload-default { + .publish-many { + &__toggle { + font-size: inherit; + line-height: inherit; + display: inline-flex; + background: transparent; + color: var(--theme-elevation-800); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + border: 0; + padding: 0; + align-items: center; + cursor: pointer; + text-decoration: underline; + } + } +} diff --git a/packages/ui/src/elements/PublishMany/index.tsx b/packages/ui/src/elements/PublishMany/index.tsx index 8bae422581..2585ce7bd4 100644 --- a/packages/ui/src/elements/PublishMany/index.tsx +++ b/packages/ui/src/elements/PublishMany/index.tsx @@ -16,7 +16,7 @@ import { useTranslation } from '../../providers/Translation/index.js' import { requests } from '../../utilities/api.js' import { parseSearchParams } from '../../utilities/parseSearchParams.js' import { ConfirmationModal } from '../ConfirmationModal/index.js' -import { Pill } from '../Pill/index.js' +import './index.scss' export type PublishManyProps = { collection: ClientCollectionConfig @@ -133,14 +133,15 @@ export const PublishMany: React.FC = (props) => { return ( - { openModal(modalSlug) }} + type="button" > {t('version:publish')} - + = (pro newQuery.where = hoistQueryParamsToAnd(newQuery.where, filterOptions) } - // map columns from string[] to ListPreferences['columns'] - const defaultColumns = field.admin.defaultColumns + // map columns from string[] to ColumnPreference[] + const defaultColumns: ColumnPreference[] = field.admin.defaultColumns ? field.admin.defaultColumns.map((accessor) => ({ - accessor, - active: true, + [accessor]: true, })) : undefined diff --git a/packages/ui/src/elements/TableColumns/RenderDefaultCell/index.tsx b/packages/ui/src/elements/TableColumns/RenderDefaultCell/index.tsx index 1ccb1b4f0c..ee52e1fb7b 100644 --- a/packages/ui/src/elements/TableColumns/RenderDefaultCell/index.tsx +++ b/packages/ui/src/elements/TableColumns/RenderDefaultCell/index.tsx @@ -36,6 +36,7 @@ export const RenderDefaultCell: React.FC<{ if (typeof onSelect === 'function') { onSelect({ collectionSlug: rowColl, + doc: rowData, docID: rowData.id as string, }) } diff --git a/packages/ui/src/elements/TableColumns/buildColumnState.tsx b/packages/ui/src/elements/TableColumns/buildColumnState.tsx index 8eaacb507d..909490fdc5 100644 --- a/packages/ui/src/elements/TableColumns/buildColumnState.tsx +++ b/packages/ui/src/elements/TableColumns/buildColumnState.tsx @@ -4,10 +4,10 @@ import type { ClientComponentProps, ClientField, Column, + ColumnPreference, DefaultCellComponentProps, DefaultServerCellComponentProps, Field, - ListPreferences, PaginatedDocs, Payload, SanitizedCollectionConfig, @@ -39,8 +39,8 @@ type Args = { beforeRows?: Column[] clientCollectionConfig: ClientCollectionConfig collectionConfig: SanitizedCollectionConfig - columnPreferences: ListPreferences['columns'] - columns?: ListPreferences['columns'] + columnPreferences: ColumnPreference[] + columns?: ColumnPreference[] customCellProps: DefaultCellComponentProps['customCellProps'] docs: PaginatedDocs['docs'] enableRowSelections: boolean @@ -99,10 +99,10 @@ export const buildColumnState = (args: Args): Column[] => { const sortTo = columnPreferences || columns - const sortFieldMap = (fieldMap, sortTo) => + const sortFieldMap = (fieldMap, sortTo: ColumnPreference[]) => fieldMap?.sort((a, b) => { - const aIndex = sortTo.findIndex((column) => 'name' in a && column.accessor === a.name) - const bIndex = sortTo.findIndex((column) => 'name' in b && column.accessor === b.name) + const aIndex = sortTo.findIndex((column) => 'name' in a && a.name in column) + const bIndex = sortTo.findIndex((column) => 'name' in b && b.name in column) if (aIndex === -1 && bIndex === -1) { return 0 @@ -136,18 +136,12 @@ export const buildColumnState = (args: Args): Column[] => { (f) => 'name' in field && 'name' in f && f.name === field.name, ) - const columnPreference = columnPreferences?.find( - (preference) => field && 'name' in field && preference.accessor === field.name, - ) - let active = false - if (columnPreference) { - active = columnPreference.active + if (columnPreferences) { + active = 'name' in field && columnPreferences?.some((col) => col?.[field.name]) } else if (columns && Array.isArray(columns) && columns.length > 0) { - active = columns.find( - (column) => field && 'name' in field && column.accessor === field.name, - )?.active + active = 'name' in field && columns.some((col) => col?.[field.name]) } else if (activeColumnsIndices.length < 4) { active = true } diff --git a/packages/ui/src/elements/TableColumns/buildPolymorphicColumnState.tsx b/packages/ui/src/elements/TableColumns/buildPolymorphicColumnState.tsx index 916ce7836f..471549b96a 100644 --- a/packages/ui/src/elements/TableColumns/buildPolymorphicColumnState.tsx +++ b/packages/ui/src/elements/TableColumns/buildPolymorphicColumnState.tsx @@ -4,10 +4,10 @@ import type { I18nClient } from '@payloadcms/translations' import type { ClientField, Column, + ColumnPreference, DefaultCellComponentProps, DefaultServerCellComponentProps, Field, - ListPreferences, PaginatedDocs, Payload, SanitizedCollectionConfig, @@ -36,8 +36,8 @@ import { filterFields } from './filterFields.js' type Args = { beforeRows?: Column[] - columnPreferences: ListPreferences['columns'] - columns?: ListPreferences['columns'] + columnPreferences: ColumnPreference[] + columns?: ColumnPreference[] customCellProps: DefaultCellComponentProps['customCellProps'] docs: PaginatedDocs['docs'] enableRowSelections: boolean @@ -92,8 +92,8 @@ export const buildPolymorphicColumnState = (args: Args): Column[] => { const sortFieldMap = (fieldMap, sortTo) => fieldMap?.sort((a, b) => { - const aIndex = sortTo.findIndex((column) => 'name' in a && column.accessor === a.name) - const bIndex = sortTo.findIndex((column) => 'name' in b && column.accessor === b.name) + const aIndex = sortTo.findIndex((column) => 'name' in a && a.name in column) + const bIndex = sortTo.findIndex((column) => 'name' in b && b.name in column) if (aIndex === -1 && bIndex === -1) { return 0 @@ -127,18 +127,12 @@ export const buildPolymorphicColumnState = (args: Args): Column[] => { (f) => 'name' in field && 'name' in f && f.name === field.name, ) - const columnPreference = columnPreferences?.find( - (preference) => field && 'name' in field && preference.accessor === field.name, - ) - let active = false - if (columnPreference) { - active = columnPreference.active + if (columnPreferences) { + active = 'name' in field && columnPreferences?.some((col) => col?.[field.name]) } else if (columns && Array.isArray(columns) && columns.length > 0) { - active = columns.find( - (column) => field && 'name' in field && column.accessor === field.name, - )?.active + active = 'name' in field && columns.some((col) => col?.[field.name]) } else if (activeColumnsIndices.length < 4) { active = true } diff --git a/packages/ui/src/elements/TableColumns/getInitialColumns.ts b/packages/ui/src/elements/TableColumns/getInitialColumns.ts index 02e797ec38..304a11cba3 100644 --- a/packages/ui/src/elements/TableColumns/getInitialColumns.ts +++ b/packages/ui/src/elements/TableColumns/getInitialColumns.ts @@ -1,11 +1,11 @@ -import type { ClientField, CollectionConfig, Field, ListPreferences } from 'payload' +import type { ClientField, CollectionConfig, ColumnPreference, Field } from 'payload' import { fieldAffectsData } from 'payload/shared' const getRemainingColumns = ( fields: T, useAsTitle: string, -): ListPreferences['columns'] => +): ColumnPreference[] => fields?.reduce((remaining, field) => { if (fieldAffectsData(field) && field.name === useAsTitle) { return remaining @@ -40,7 +40,7 @@ export const getInitialColumns = ( fields: T, useAsTitle: CollectionConfig['admin']['useAsTitle'], defaultColumns: CollectionConfig['admin']['defaultColumns'], -): ListPreferences['columns'] => { +): ColumnPreference[] => { let initialColumns = [] if (Array.isArray(defaultColumns) && defaultColumns.length >= 1) { @@ -57,7 +57,6 @@ export const getInitialColumns = ( } return initialColumns.map((column) => ({ - accessor: column, - active: true, + [column]: true, })) } diff --git a/packages/ui/src/elements/TableColumns/index.tsx b/packages/ui/src/elements/TableColumns/index.tsx index 5d7120e7a0..9dcab9db9e 100644 --- a/packages/ui/src/elements/TableColumns/index.tsx +++ b/packages/ui/src/elements/TableColumns/index.tsx @@ -1,5 +1,5 @@ 'use client' -import type { Column, ListPreferences, SanitizedCollectionConfig } from 'payload' +import type { Column, ColumnPreference, ListPreferences, SanitizedCollectionConfig } from 'payload' import React, { createContext, useCallback, useContext, useEffect } from 'react' @@ -39,12 +39,10 @@ type Props = { } // strip out Heading, Label, and renderedCells properties, they cannot be sent to the server -const sanitizeColumns = (columns: Column[]) => { - return columns.map(({ accessor, active }) => ({ - accessor, - active, +const formatColumnPreferences = (columns: Column[]): ColumnPreference[] => + columns.map(({ accessor, active }) => ({ + [accessor]: active, })) -} export const TableColumnsProvider: React.FC = ({ children, @@ -90,7 +88,7 @@ export const TableColumnsProvider: React.FC = ({ const result = await getTableState({ collectionSlug, - columns: sanitizeColumns(withMovedColumn), + columns: formatColumnPreferences(withMovedColumn), docs, enableRowSelections, renderRowTypes, @@ -123,7 +121,7 @@ export const TableColumnsProvider: React.FC = ({ const { newColumnState, toggledColumns } = tableColumns.reduce<{ newColumnState: Column[] - toggledColumns: Pick[] + toggledColumns: ColumnPreference[] }>( (acc, col) => { if (col.accessor === column) { @@ -133,14 +131,12 @@ export const TableColumnsProvider: React.FC = ({ active: !col.active, }) acc.toggledColumns.push({ - accessor: col.accessor, - active: !col.active, + [col.accessor]: !col.active, }) } else { acc.newColumnState.push(col) acc.toggledColumns.push({ - accessor: col.accessor, - active: col.active, + [col.accessor]: col.active, }) } @@ -182,14 +178,8 @@ export const TableColumnsProvider: React.FC = ({ const setActiveColumns = React.useCallback( async (activeColumnAccessors: string[]) => { - const activeColumns: Pick[] = tableColumns - .map((col) => { - return { - accessor: col.accessor, - active: activeColumnAccessors.includes(col.accessor), - } - }) - .sort((first, second) => { + const activeColumns: ColumnPreference[] = formatColumnPreferences( + tableColumns.sort((first, second) => { const indexOfFirst = activeColumnAccessors.indexOf(first.accessor) const indexOfSecond = activeColumnAccessors.indexOf(second.accessor) @@ -198,7 +188,8 @@ export const TableColumnsProvider: React.FC = ({ } return indexOfFirst > indexOfSecond ? 1 : -1 - }) + }), + ) const { state: columnState, Table } = await getTableState({ collectionSlug, @@ -239,7 +230,7 @@ export const TableColumnsProvider: React.FC = ({ if (collectionHasChanged || !listPreferences) { const currentPreferences = await getPreference<{ - columns: ListPreferences['columns'] + columns: ColumnPreference[] }>(preferenceKey) prevCollection.current = defaultCollection diff --git a/packages/ui/src/elements/UnpublishMany/index.scss b/packages/ui/src/elements/UnpublishMany/index.scss new file mode 100644 index 0000000000..6164cb79d9 --- /dev/null +++ b/packages/ui/src/elements/UnpublishMany/index.scss @@ -0,0 +1,21 @@ +@import '../../scss/styles.scss'; + +@layer payload-default { + .unpublish-many { + &__toggle { + font-size: inherit; + line-height: inherit; + display: inline-flex; + background: transparent; + color: var(--theme-elevation-800); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + border: 0; + padding: 0; + align-items: center; + cursor: pointer; + text-decoration: underline; + } + } +} diff --git a/packages/ui/src/elements/UnpublishMany/index.tsx b/packages/ui/src/elements/UnpublishMany/index.tsx index 1775a3c30a..2b362eb8cc 100644 --- a/packages/ui/src/elements/UnpublishMany/index.tsx +++ b/packages/ui/src/elements/UnpublishMany/index.tsx @@ -16,7 +16,7 @@ import { useTranslation } from '../../providers/Translation/index.js' import { requests } from '../../utilities/api.js' import { parseSearchParams } from '../../utilities/parseSearchParams.js' import { ConfirmationModal } from '../ConfirmationModal/index.js' -import { Pill } from '../Pill/index.js' +import './index.scss' export type UnpublishManyProps = { collection: ClientCollectionConfig @@ -130,14 +130,15 @@ export const UnpublishMany: React.FC = (props) => { return ( - { toggleModal(modalSlug) }} + type="button" > {t('version:unpublish')} - + { readOnly, validate, } = props - - const [stringValue, setStringValue] = useState() const [jsonError, setJsonError] = useState() - const [hasLoadedValue, setHasLoadedValue] = useState(false) + const inputChangeFromRef = React.useRef<'system' | 'user'>('system') + const [editorKey, setEditorKey] = useState('') const memoizedValidate = useCallback( (value, options) => { @@ -56,6 +55,12 @@ const JSONFieldComponent: JSONFieldClientComponent = (props) => { validate: memoizedValidate, }) + const [initialStringValue, setInitialStringValue] = useState(() => + (value || initialValue) !== undefined + ? JSON.stringify(value ?? initialValue, null, 2) + : undefined, + ) + const handleMount = useCallback( (editor, monaco) => { if (!jsonSchema) { @@ -88,7 +93,7 @@ const JSONFieldComponent: JSONFieldClientComponent = (props) => { if (readOnly) { return } - setStringValue(val) + inputChangeFromRef.current = 'user' try { setValue(val ? JSON.parse(val) : null) @@ -98,20 +103,21 @@ const JSONFieldComponent: JSONFieldClientComponent = (props) => { setJsonError(e) } }, - [readOnly, setValue, setStringValue], + [readOnly, setValue], ) useEffect(() => { - if (hasLoadedValue || value === undefined) { - return + if (inputChangeFromRef.current === 'system') { + setInitialStringValue( + (value || initialValue) !== undefined + ? JSON.stringify(value ?? initialValue, null, 2) + : undefined, + ) + setEditorKey(new Date().toString()) } - setStringValue( - value || initialValue ? JSON.stringify(value ? value : initialValue, null, 2) : '', - ) - - setHasLoadedValue(true) - }, [initialValue, value, hasLoadedValue]) + inputChangeFromRef.current = 'system' + }, [initialValue, value]) const styles = useMemo(() => mergeFieldStyles(field), [field]) @@ -142,12 +148,16 @@ const JSONFieldComponent: JSONFieldClientComponent = (props) => { {BeforeInput} {AfterInput}
diff --git a/packages/ui/src/fields/Upload/Input.tsx b/packages/ui/src/fields/Upload/Input.tsx index 57458cae8d..878e8e755a 100644 --- a/packages/ui/src/fields/Upload/Input.tsx +++ b/packages/ui/src/fields/Upload/Input.tsx @@ -350,9 +350,9 @@ export function UploadInput(props: UploadInputProps) { [closeCreateDocDrawer, activeRelationTo, onChange], ) - const onListSelect = React.useCallback>( - async ({ collectionSlug, docID }) => { - const loadedDocs = await populateDocs([docID], collectionSlug) + const onListSelect = useCallback>( + async ({ collectionSlug, doc }) => { + const loadedDocs = await populateDocs([doc.id], collectionSlug) const selectedDoc = loadedDocs ? loadedDocs.docs?.[0] : null setPopulatedDocs((currentDocs) => { if (selectedDoc) { @@ -375,9 +375,9 @@ export function UploadInput(props: UploadInputProps) { return currentDocs }) if (hasMany) { - onChange([...(Array.isArray(value) ? value : []), docID]) + onChange([...(Array.isArray(value) ? value : []), doc.id]) } else { - onChange(docID) + onChange(doc.id) } closeListDrawer() }, diff --git a/packages/ui/src/forms/Form/context.ts b/packages/ui/src/forms/Form/context.ts index b630fb3887..6c520d93ae 100644 --- a/packages/ui/src/forms/Form/context.ts +++ b/packages/ui/src/forms/Form/context.ts @@ -24,7 +24,7 @@ export type RenderedFieldSlots = Map /** * Get the state of the form, can be used to submit & validate the form. * - * @see https://payloadcms.com/docs/admin/hooks#useform + * @see https://payloadcms.com/docs/admin/react-hooks#useform */ const useForm = (): Context => useContext(FormContext) /** @@ -42,7 +42,7 @@ const useFormInitializing = (): boolean => useContext(InitializingContext) /** * Get and set the value of a form field based on a selector * - * @see https://payloadcms.com/docs/admin/hooks#useformfields + * @see https://payloadcms.com/docs/admin/react-hooks#useformfields */ const useFormFields = ( selector: (context: FormFieldsContextType) => Value, @@ -51,7 +51,7 @@ const useFormFields = ( /** * Get the state of all form fields. * - * @see https://payloadcms.com/docs/admin/hooks#useallformfields + * @see https://payloadcms.com/docs/admin/react-hooks#useallformfields */ const useAllFormFields = (): FormFieldsContextType => useFullContext(FormFieldsContext) diff --git a/packages/ui/src/forms/Form/fieldReducer.ts b/packages/ui/src/forms/Form/fieldReducer.ts index e5b4029c7f..642925cf7e 100644 --- a/packages/ui/src/forms/Form/fieldReducer.ts +++ b/packages/ui/src/forms/Form/fieldReducer.ts @@ -180,31 +180,52 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState { case 'MOVE_ROW': { const { moveFromIndex, moveToIndex, path } = action - const { remainingFields, rows } = separateRows(path, state) - // copy the row to move - const copyOfMovingRow = rows[moveFromIndex] - // delete the row by index - rows.splice(moveFromIndex, 1) - // insert row copyOfMovingRow back in - rows.splice(moveToIndex, 0, copyOfMovingRow) + // Handle moving rows on the top-level, i.e. `array.0.text` -> `array.1.text` + const { remainingFields, rows: topLevelRows } = separateRows(path, state) + const copyOfMovingRow = topLevelRows[moveFromIndex] + topLevelRows.splice(moveFromIndex, 1) + topLevelRows.splice(moveToIndex, 0, copyOfMovingRow) // modify array/block internal row state (i.e. collapsed, blockType) - const rowStateCopy = [...(state[path]?.rows || [])] - const movingRowState = { ...rowStateCopy[moveFromIndex] } - rowStateCopy.splice(moveFromIndex, 1) - rowStateCopy.splice(moveToIndex, 0, movingRowState) + const rowsWithinField = [...(state[path]?.rows || [])] + const copyOfMovingRow2 = { ...rowsWithinField[moveFromIndex] } + rowsWithinField.splice(moveFromIndex, 1) + rowsWithinField.splice(moveToIndex, 0, copyOfMovingRow2) const newState = { ...remainingFields, - ...flattenRows(path, rows), + ...flattenRows(path, topLevelRows), [path]: { ...state[path], requiresRender: true, - rows: rowStateCopy, + rows: rowsWithinField, }, } + // Do the same for custom components, i.e. `array.customComponents.RowLabels[0]` -> `array.customComponents.RowLabels[1]` + // Do this _after_ initializing `newState` to avoid adding the `customComponents` key to the state if it doesn't exist + if (newState[path]?.customComponents?.RowLabels) { + const customComponents = { + ...newState[path].customComponents, + RowLabels: [...newState[path].customComponents.RowLabels], + } + + // Ensure the array grows if necessary + if (moveToIndex >= customComponents.RowLabels.length) { + customComponents.RowLabels.length = moveToIndex + 1 + } + + const copyOfMovingLabel = customComponents.RowLabels[moveFromIndex] + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + customComponents.RowLabels.splice(moveFromIndex, 1) + // eslint-disable-next-line @typescript-eslint/no-floating-promises + customComponents.RowLabels.splice(moveToIndex, 0, copyOfMovingLabel) + + newState[path].customComponents = customComponents + } + return newState } diff --git a/packages/ui/src/forms/useField/index.tsx b/packages/ui/src/forms/useField/index.tsx index 17ae9fd55c..e30b4cd6ee 100644 --- a/packages/ui/src/forms/useField/index.tsx +++ b/packages/ui/src/forms/useField/index.tsx @@ -27,7 +27,7 @@ import { /** * Get and set the value of a form field. * - * @see https://payloadcms.com/docs/admin/hooks#usefield + * @see https://payloadcms.com/docs/admin/react-hooks#usefield */ export const useField = (options: Options): FieldType => { const { disableFormData = false, hasRows, path, validate } = options diff --git a/packages/ui/src/utilities/renderTable.tsx b/packages/ui/src/utilities/renderTable.tsx index 912b9dfeb6..637ede7ed2 100644 --- a/packages/ui/src/utilities/renderTable.tsx +++ b/packages/ui/src/utilities/renderTable.tsx @@ -3,9 +3,10 @@ import type { ClientConfig, ClientField, CollectionConfig, + Column, + ColumnPreference, Field, ImportMap, - ListPreferences, PaginatedDocs, Payload, SanitizedCollectionConfig, @@ -14,9 +15,6 @@ import type { import { getTranslation, type I18nClient } from '@payloadcms/translations' import { fieldAffectsData, fieldIsHiddenOrDisabled, flattenTopLevelFields } from 'payload/shared' -// eslint-disable-next-line payload/no-imports-from-exports-dir -import type { Column } from '../exports/client/index.js' - import { RenderServerComponent } from '../elements/RenderServerComponent/index.js' import { buildColumnState } from '../elements/TableColumns/buildColumnState.js' import { buildPolymorphicColumnState } from '../elements/TableColumns/buildPolymorphicColumnState.js' @@ -71,8 +69,8 @@ export const renderTable = ({ clientConfig?: ClientConfig collectionConfig?: SanitizedCollectionConfig collections?: string[] - columnPreferences: ListPreferences['columns'] - columns?: ListPreferences['columns'] + columnPreferences: ColumnPreference[] + columns?: ColumnPreference[] customCellProps?: Record docs: PaginatedDocs['docs'] drawerSlug?: string @@ -109,7 +107,7 @@ export const renderTable = ({ const columns = columnsFromArgs ? columnsFromArgs?.filter((column) => flattenTopLevelFields(fields, true)?.some( - (field) => 'name' in field && field.name === column.accessor, + (field) => 'name' in field && column[field.name], ), ) : getInitialColumns(fields, useAsTitle, []) @@ -130,7 +128,7 @@ export const renderTable = ({ const columns = columnsFromArgs ? columnsFromArgs?.filter((column) => flattenTopLevelFields(clientCollectionConfig.fields, true)?.some( - (field) => 'name' in field && field.name === column.accessor, + (field) => 'name' in field && field.name in column, ), ) : getInitialColumns( diff --git a/packages/ui/src/views/List/ListHeader/index.tsx b/packages/ui/src/views/List/ListHeader/index.tsx index 2b528b67d8..1a476c8a28 100644 --- a/packages/ui/src/views/List/ListHeader/index.tsx +++ b/packages/ui/src/views/List/ListHeader/index.tsx @@ -22,6 +22,8 @@ export type ListHeaderProps = { className?: string collectionConfig: ClientCollectionConfig Description?: React.ReactNode + disableBulkDelete?: boolean + disableBulkEdit?: boolean hasCreatePermission: boolean i18n: I18nClient isBulkUploadEnabled: boolean @@ -35,6 +37,8 @@ const DefaultListHeader: React.FC = ({ className, collectionConfig, Description, + disableBulkDelete, + disableBulkEdit, hasCreatePermission, i18n, isBulkUploadEnabled, @@ -72,7 +76,12 @@ const DefaultListHeader: React.FC = ({ )} {!smallBreak && ( - + )} {Description} diff --git a/packages/ui/src/views/List/index.tsx b/packages/ui/src/views/List/index.tsx index 07dbf69a49..bd9be45dbb 100644 --- a/packages/ui/src/views/List/index.tsx +++ b/packages/ui/src/views/List/index.tsx @@ -9,8 +9,6 @@ import React, { Fragment, useEffect, useState } from 'react' import { useBulkUpload } from '../../elements/BulkUpload/index.js' import { Button } from '../../elements/Button/index.js' -import { DeleteMany } from '../../elements/DeleteMany/index.js' -import { EditMany } from '../../elements/EditMany/index.js' import { Gutter } from '../../elements/Gutter/index.js' import { ListControls } from '../../elements/ListControls/index.js' import { useListDrawerContext } from '../../elements/ListDrawer/Provider.js' @@ -18,13 +16,11 @@ import { ListSelection } from '../../elements/ListSelection/index.js' import { useModal } from '../../elements/Modal/index.js' import { Pagination } from '../../elements/Pagination/index.js' import { PerPage } from '../../elements/PerPage/index.js' -import { PublishMany } from '../../elements/PublishMany/index.js' import { RenderCustomComponent } from '../../elements/RenderCustomComponent/index.js' import { SelectMany } from '../../elements/SelectMany/index.js' import { useStepNav } from '../../elements/StepNav/index.js' import { RelationshipProvider } from '../../elements/Table/RelationshipProvider/index.js' import { TableColumnsProvider } from '../../elements/TableColumns/index.js' -import { UnpublishMany } from '../../elements/UnpublishMany/index.js' import { ViewDescription } from '../../elements/ViewDescription/index.js' import { useAuth } from '../../providers/Auth/index.js' import { useConfig } from '../../providers/Config/index.js' @@ -155,6 +151,7 @@ export function DefaultListView(props: ListViewClientProps) { ]) } }, [setStepNav, labels, drawerDepth]) + return (
} + disableBulkDelete={disableBulkDelete} + disableBulkEdit={disableBulkEdit} hasCreatePermission={hasCreatePermission} i18n={i18n} isBulkUploadEnabled={isBulkUploadEnabled && !upload.hideFileInputOnCreate} @@ -203,8 +202,6 @@ export function DefaultListView(props: ListViewClientProps) { } collectionConfig={collectionConfig} collectionSlug={collectionSlug} - disableBulkDelete={disableBulkDelete} - disableBulkEdit={disableBulkEdit} listMenuItems={listMenuItems} renderedFilters={renderedFilters} resolvedFilterOptions={resolvedFilterOptions} @@ -267,6 +264,9 @@ export function DefaultListView(props: ListViewClientProps) { {smallBreak && (
@@ -278,14 +278,6 @@ export function DefaultListView(props: ListViewClientProps) { ] : [] : beforeActions} - {!disableBulkEdit && ( - - - - - - )} - {!disableBulkDelete && }
)} diff --git a/templates/with-postgres/docker-compose.yml b/templates/with-postgres/docker-compose.yml index 3aba7cc7e2..40e4acb761 100644 --- a/templates/with-postgres/docker-compose.yml +++ b/templates/with-postgres/docker-compose.yml @@ -2,42 +2,46 @@ version: '3' services: payload: - image: node:18-alpine + image: node:20-alpine ports: - '3000:3000' volumes: - .:/home/node/app - node_modules:/home/node/app/node_modules working_dir: /home/node/app/ - command: sh -c "corepack enable && corepack prepare pnpm@latest --activate && pnpm install && pnpm dev" + command: sh -c "corepack enable && corepack prepare pnpm@9.0.0 --activate && pnpm install && pnpm dev" depends_on: - - mongo - # - postgres + - postgres + # - mongo env_file: - .env - # Ensure your DATABASE_URI uses 'mongo' as the hostname ie. mongodb://mongo/my-db-name - mongo: - image: mongo:latest - ports: - - '27017:27017' - command: - - --storageEngine=wiredTiger + # Ensure your DATABASE_URI uses 'postgresql' as the hostname ie. postgresql://127.0.0.1:5432/your-database-name + postgres: + restart: always + image: postgres:latest volumes: - - data:/data/db - logging: - driver: none + - pgdata:/var/lib/postgresql/data + ports: + - '5432:5432' + environment: + POSTGRES_USER: postgres + POSTGRES_DB: your-database-name # THIS MUST MATCH YOUR DB NAME IN .env + POSTGRES_HOST_AUTH_METHOD: trust - # Uncomment the following to use postgres - # postgres: - # restart: always - # image: postgres:latest - # volumes: - # - pgdata:/var/lib/postgresql/data + # Uncomment the following to use mongodb + # mongo: + # image: mongo:latest # ports: - # - "5432:5432" + # - '27017:27017' + # command: + # - --storageEngine=wiredTiger + # volumes: + # - data:/data/db + # logging: + # driver: none volumes: - data: - # pgdata: + pgdata: + # data: node_modules: diff --git a/templates/with-vercel-postgres/docker-compose.yml b/templates/with-vercel-postgres/docker-compose.yml index 3aba7cc7e2..40e4acb761 100644 --- a/templates/with-vercel-postgres/docker-compose.yml +++ b/templates/with-vercel-postgres/docker-compose.yml @@ -2,42 +2,46 @@ version: '3' services: payload: - image: node:18-alpine + image: node:20-alpine ports: - '3000:3000' volumes: - .:/home/node/app - node_modules:/home/node/app/node_modules working_dir: /home/node/app/ - command: sh -c "corepack enable && corepack prepare pnpm@latest --activate && pnpm install && pnpm dev" + command: sh -c "corepack enable && corepack prepare pnpm@9.0.0 --activate && pnpm install && pnpm dev" depends_on: - - mongo - # - postgres + - postgres + # - mongo env_file: - .env - # Ensure your DATABASE_URI uses 'mongo' as the hostname ie. mongodb://mongo/my-db-name - mongo: - image: mongo:latest - ports: - - '27017:27017' - command: - - --storageEngine=wiredTiger + # Ensure your DATABASE_URI uses 'postgresql' as the hostname ie. postgresql://127.0.0.1:5432/your-database-name + postgres: + restart: always + image: postgres:latest volumes: - - data:/data/db - logging: - driver: none + - pgdata:/var/lib/postgresql/data + ports: + - '5432:5432' + environment: + POSTGRES_USER: postgres + POSTGRES_DB: your-database-name # THIS MUST MATCH YOUR DB NAME IN .env + POSTGRES_HOST_AUTH_METHOD: trust - # Uncomment the following to use postgres - # postgres: - # restart: always - # image: postgres:latest - # volumes: - # - pgdata:/var/lib/postgresql/data + # Uncomment the following to use mongodb + # mongo: + # image: mongo:latest # ports: - # - "5432:5432" + # - '27017:27017' + # command: + # - --storageEngine=wiredTiger + # volumes: + # - data:/data/db + # logging: + # driver: none volumes: - data: - # pgdata: + pgdata: + # data: node_modules: diff --git a/test/admin/e2e/general/e2e.spec.ts b/test/admin/e2e/general/e2e.spec.ts index 8942df2c55..ec77d11e64 100644 --- a/test/admin/e2e/general/e2e.spec.ts +++ b/test/admin/e2e/general/e2e.spec.ts @@ -12,6 +12,7 @@ import { openNav, saveDocAndAssert, saveDocHotkeyAndAssert, + // throttleTest, } from '../../../helpers.js' import { AdminUrlUtil } from '../../../helpers/adminUrlUtil.js' import { initPayloadE2ENoConfig } from '../../../helpers/initPayloadE2ENoConfig.js' @@ -100,6 +101,12 @@ describe('General', () => { }) beforeEach(async () => { + // await throttleTest({ + // page, + // context, + // delay: 'Fast 4G', + // }) + await reInitializeDB({ serverURL, snapshotKey: 'adminTests', @@ -721,25 +728,32 @@ describe('General', () => { 'Deleted 3 Posts successfully.', ) - await expect(page.locator('.collection-list__no-results')).toBeVisible() + // Poll until router has refreshed + await expect.poll(() => page.locator('.collection-list__no-results').isVisible()).toBeTruthy() }) test('should bulk delete with filters and across pages', async () => { await deleteAllPosts() - await Promise.all([createPost({ title: 'Post 1' }), createPost({ title: 'Post 2' })]) + + Array.from({ length: 6 }).forEach(async (_, i) => { + await createPost({ title: `Post ${i + 1}` }) + }) + await page.goto(postsUrl.list) - await page.locator('#search-filter-input').fill('Post 1') - await expect(page.locator('.table table > tbody > tr')).toHaveCount(1) + await page.locator('#search-filter-input').fill('Post') + await page.waitForURL(/search=Post/) + await expect(page.locator('.table table > tbody > tr')).toHaveCount(5) await page.locator('input#select-all').check() - await page.locator('button.list-selection__button').click() + await page.locator('button#select-all-across-pages').click() await page.locator('.delete-documents__toggle').click() await page.locator('#delete-posts #confirm-action').click() await expect(page.locator('.payload-toast-container .toast-success')).toHaveText( - 'Deleted 1 Post successfully.', + 'Deleted 6 Posts successfully.', ) - await expect(page.locator('.table table > tbody > tr')).toHaveCount(1) + // Poll until router has refreshed + await expect.poll(() => page.locator('.table table > tbody > tr').count()).toBe(0) }) test('should bulk update', async () => { @@ -835,17 +849,30 @@ describe('General', () => { expect(updatedPost.docs[0].defaultValueField).toBe('not the default value') }) + test('should not show "select all across pages" button if already selected all', async () => { + await deleteAllPosts() + await createPost({ title: `Post 1` }) + await page.goto(postsUrl.list) + await page.locator('input#select-all').check() + await expect(page.locator('button#select-all-across-pages')).toBeHidden() + }) + test('should bulk update with filters and across pages', async () => { // First, delete all posts created by the seed await deleteAllPosts() - const post1Title = 'Post 1' - await Promise.all([createPost({ title: post1Title }), createPost({ title: 'Post 2' })]) - const updatedPostTitle = `${post1Title} (Updated)` + + Array.from({ length: 6 }).forEach(async (_, i) => { + await createPost({ title: `Post ${i + 1}` }) + }) + await page.goto(postsUrl.list) - await page.locator('#search-filter-input').fill('Post 1') - await expect(page.locator('.table table > tbody > tr')).toHaveCount(1) + await page.locator('#search-filter-input').fill('Post') + await page.waitForURL(/search=Post/) + await expect(page.locator('.table table > tbody > tr')).toHaveCount(5) + await page.locator('input#select-all').check() - await page.locator('button.list-selection__button').click() + await page.locator('button#select-all-across-pages').click() + await page.locator('.edit-many__toggle').click() await page.locator('.field-select .rs__control').click() @@ -857,23 +884,29 @@ describe('General', () => { await titleOption.click() const titleInput = page.locator('#field-title') await expect(titleInput).toBeVisible() - await titleInput.fill(updatedPostTitle) + const updatedTitle = `Post (Updated)` + await titleInput.fill(updatedTitle) await page.locator('.form-submit button[type="submit"].edit-many__publish').click() await expect(page.locator('.payload-toast-container .toast-success')).toContainText( - 'Updated 1 Post successfully.', + 'Updated 6 Posts successfully.', ) - await expect(page.locator('.table table > tbody > tr')).toHaveCount(1) - await expect(page.locator('.row-1 .cell-title')).toContainText(updatedPostTitle) + // Poll until router has refreshed + await expect.poll(() => page.locator('.table table > tbody > tr').count()).toBe(5) + await expect(page.locator('.row-1 .cell-title')).toContainText(updatedTitle) }) test('should update selection state after deselecting item following select all', async () => { await deleteAllPosts() - await createPost({ title: 'Post 1' }) + + Array.from({ length: 6 }).forEach(async (_, i) => { + await createPost({ title: `Post ${i + 1}` }) + }) + await page.goto(postsUrl.list) await page.locator('input#select-all').check() - await page.locator('button.list-selection__button').click() + await page.locator('button#select-all-across-pages').click() // Deselect the first row await page.locator('.row-1 input').click() diff --git a/test/config/bin.ts b/test/config/bin.ts new file mode 100644 index 0000000000..0ab75bdeb8 --- /dev/null +++ b/test/config/bin.ts @@ -0,0 +1,8 @@ +import path from 'path' +import { fileURLToPath } from 'url' + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) +const { bin } = await import(path.resolve(dirname, '../../packages/payload/src/bin/index.js')) + +await bin() diff --git a/test/config/config.ts b/test/config/config.ts index c777de4113..957b7c7996 100644 --- a/test/config/config.ts +++ b/test/config/config.ts @@ -80,6 +80,12 @@ export default buildConfigWithDefaults({ path: '/config', }, ], + bin: [ + { + scriptPath: path.resolve(dirname, 'customScript.ts'), + key: 'start-server', + }, + ], globals: [ { slug: 'my-global', @@ -107,13 +113,17 @@ export default buildConfigWithDefaults({ }, ], onInit: async (payload) => { - await payload.create({ - collection: 'users', - data: { - email: devUser.email, - password: devUser.password, - }, - }) + const { totalDocs } = await payload.count({ collection: 'users' }) + + if (totalDocs === 0) { + await payload.create({ + collection: 'users', + data: { + email: devUser.email, + password: devUser.password, + }, + }) + } }, typescript: { outputFile: path.resolve(dirname, 'payload-types.ts'), diff --git a/test/config/customScript.ts b/test/config/customScript.ts new file mode 100644 index 0000000000..24989e5791 --- /dev/null +++ b/test/config/customScript.ts @@ -0,0 +1,13 @@ +import type { SanitizedConfig } from 'payload' + +import { writeFileSync } from 'fs' +import payload from 'payload' + +import { testFilePath } from './testFilePath.js' + +export const script = async (config: SanitizedConfig) => { + await payload.init({ config }) + const data = await payload.find({ collection: 'users' }) + writeFileSync(testFilePath, JSON.stringify(data), 'utf-8') + process.exit(0) +} diff --git a/test/config/int.spec.ts b/test/config/int.spec.ts index c6750ad690..c3a843cc00 100644 --- a/test/config/int.spec.ts +++ b/test/config/int.spec.ts @@ -1,11 +1,14 @@ import type { BlockField, Payload } from 'payload' +import { execSync } from 'child_process' +import { existsSync, readFileSync, rmSync } from 'fs' import path from 'path' import { fileURLToPath } from 'url' import type { NextRESTClient } from '../helpers/NextRESTClient.js' import { initPayloadInt } from '../helpers/initPayloadInt.js' +import { testFilePath } from './testFilePath.js' let restClient: NextRESTClient let payload: Payload @@ -106,4 +109,31 @@ describe('Config', () => { expect(response.headers.get('Access-Control-Allow-Headers')).toContain('x-custom-header') }) }) + + describe('bin config', () => { + const executeCLI = (command: string) => { + execSync(`pnpm tsx "${path.resolve(dirname, 'bin.ts')}" ${command}`, { + env: { + ...process.env, + PAYLOAD_CONFIG_PATH: path.resolve(dirname, 'config.ts'), + PAYLOAD_DROP_DATABASE: 'false', + }, + stdio: 'inherit', + cwd: path.resolve(dirname, '../..'), // from root + }) + } + + const deleteTestFile = () => { + if (existsSync(testFilePath)) { + rmSync(testFilePath) + } + } + + it('should execute a custom script', () => { + deleteTestFile() + executeCLI('start-server') + expect(JSON.parse(readFileSync(testFilePath, 'utf-8')).docs).toHaveLength(1) + deleteTestFile() + }) + }) }) diff --git a/test/config/testFilePath.ts b/test/config/testFilePath.ts new file mode 100644 index 0000000000..a65672e109 --- /dev/null +++ b/test/config/testFilePath.ts @@ -0,0 +1,7 @@ +import path from 'path' +import { fileURLToPath } from 'url' + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) + +export const testFilePath = path.resolve(dirname, '_data.json') diff --git a/test/fields-relationship/collections/Relationship/index.ts b/test/fields-relationship/collections/Relationship/index.ts index c4c5f6c43d..c07374b9af 100644 --- a/test/fields-relationship/collections/Relationship/index.ts +++ b/test/fields-relationship/collections/Relationship/index.ts @@ -88,6 +88,28 @@ export const Relationship: CollectionConfig = { relationTo: slug, type: 'relationship', }, + { + type: 'collapsible', + label: 'Collapsible', + fields: [ + { + name: 'nestedRelationshipFilteredByField', + filterOptions: () => { + return { + filter: { + equals: 'Include me', + }, + } + }, + admin: { + description: + 'This will filter the relationship options if the filter field in this document is set to "Include me"', + }, + relationTo: slug, + type: 'relationship', + }, + ], + }, { name: 'relationshipFilteredAsync', filterOptions: (args: FilterOptionsProps) => { diff --git a/test/fields-relationship/e2e.spec.ts b/test/fields-relationship/e2e.spec.ts index 15e46ace31..56f1802a6b 100644 --- a/test/fields-relationship/e2e.spec.ts +++ b/test/fields-relationship/e2e.spec.ts @@ -351,6 +351,41 @@ describe('Relationship Field', () => { await expect(valueOptions.locator(`text=${idToInclude}`)).toBeVisible() }) + test('should apply filter options of nested fields to list view filter controls', async () => { + const { id: idToInclude } = await payload.create({ + collection: slug, + data: { + filter: 'Include me', + }, + }) + + // first ensure that filter options are applied in the edit view + await page.goto(url.edit(idToInclude)) + const field = page.locator('#field-nestedRelationshipFilteredByField') + await field.click({ delay: 100 }) + const options = field.locator('.rs__option') + await expect(options).toHaveCount(1) + await expect(options).toContainText(idToInclude) + + // now ensure that the same filter options are applied in the list view + await page.goto(url.list) + + const whereBuilder = await addListFilter({ + page, + fieldLabel: 'Collapsible > Nested Relationship Filtered By Field', + operatorLabel: 'equals', + skipValueInput: true, + }) + + const valueInput = page.locator('.condition__value input') + await valueInput.click() + const valueOptions = whereBuilder.locator('.condition__value .rs__option') + + await expect(valueOptions).toHaveCount(2) + await expect(valueOptions.locator(`text=None`)).toBeVisible() + await expect(valueOptions.locator(`text=${idToInclude}`)).toBeVisible() + }) + test('should allow usage of relationTo in filterOptions', async () => { const { id: include } = (await payload.create({ collection: relationOneSlug, diff --git a/test/fields-relationship/payload-types.ts b/test/fields-relationship/payload-types.ts index b6be2560d2..be922eacb3 100644 --- a/test/fields-relationship/payload-types.ts +++ b/test/fields-relationship/payload-types.ts @@ -177,6 +177,10 @@ export interface FieldsRelationship { * This will filter the relationship options if the filter field in this document is set to "Include me" */ relationshipFilteredByField?: (string | null) | FieldsRelationship; + /** + * This will filter the relationship options if the filter field in this document is set to "Include me" + */ + nestedRelationshipFilteredByField?: (string | null) | FieldsRelationship; relationshipFilteredAsync?: (string | null) | RelationOne; relationshipManyFiltered?: | ( @@ -506,6 +510,7 @@ export interface FieldsRelationshipSelect { relationshipWithTitle?: T; relationshipFilteredByID?: T; relationshipFilteredByField?: T; + nestedRelationshipFilteredByField?: T; relationshipFilteredAsync?: T; relationshipManyFiltered?: T; filter?: T; diff --git a/test/fields/collections/Blocks/e2e.spec.ts b/test/fields/collections/Blocks/e2e.spec.ts index ef4a63cf4b..e6a8c8216c 100644 --- a/test/fields/collections/Blocks/e2e.spec.ts +++ b/test/fields/collections/Blocks/e2e.spec.ts @@ -3,6 +3,7 @@ import type { BrowserContext, Page } from '@playwright/test' import { expect, test } from '@playwright/test' import { addBlock } from 'helpers/e2e/addBlock.js' import { openBlocksDrawer } from 'helpers/e2e/openBlocksDrawer.js' +import { reorderBlocks } from 'helpers/e2e/reorderBlocks.js' import path from 'path' import { fileURLToPath } from 'url' @@ -289,6 +290,39 @@ describe('Block fields', () => { }) describe('row manipulation', () => { + test('moving rows should immediately move custom row labels', async () => { + await page.goto(url.create) + + // first ensure that the first block has the custom header, and that the second block doesn't + + await expect( + page.locator('#field-blocks #blocks-row-0 .blocks-field__block-header'), + ).toHaveText('Custom Block Label: Content 01') + + const secondBlockHeader = page.locator( + '#field-blocks #blocks-row-1 .blocks-field__block-header', + ) + + await expect(secondBlockHeader.locator('.blocks-field__block-pill')).toHaveText('Number') + + await expect(secondBlockHeader.locator('input[id="blocks.1.blockName"]')).toHaveValue( + 'Second block', + ) + + await reorderBlocks({ + page, + fieldName: 'blocks', + fromBlockIndex: 0, + toBlockIndex: 1, + }) + + // Important: do _not_ poll here, use `textContent()` instead of `toHaveText()` + // This will prevent Playwright from polling for the change to the DOM + expect( + await page.locator('#field-blocks #blocks-row-1 .blocks-field__block-header').textContent(), + ).toMatch(/^Custom Block Label: Content/) + }) + describe('react hooks', () => { test('should add 2 new block rows', async () => { await page.goto(url.create) diff --git a/test/fields/collections/JSON/AfterField.tsx b/test/fields/collections/JSON/AfterField.tsx new file mode 100644 index 0000000000..be7bfd8db0 --- /dev/null +++ b/test/fields/collections/JSON/AfterField.tsx @@ -0,0 +1,38 @@ +'use client' + +import { useField } from '@payloadcms/ui' + +export function AfterField() { + const { setValue } = useField({ path: 'customJSON' }) + + return ( + + ) +} diff --git a/test/fields/collections/JSON/e2e.spec.ts b/test/fields/collections/JSON/e2e.spec.ts index 8cee112ae6..feafa0e053 100644 --- a/test/fields/collections/JSON/e2e.spec.ts +++ b/test/fields/collections/JSON/e2e.spec.ts @@ -103,4 +103,24 @@ describe('JSON', () => { '"foo.with.periods": "bar"', ) }) + + test('should update', async () => { + const createdDoc = await payload.create({ + collection: 'json-fields', + data: { + customJSON: { + default: 'value', + }, + }, + }) + + await page.goto(url.edit(createdDoc.id)) + const jsonField = page.locator('.json-field #field-customJSON') + await expect(jsonField).toContainText('"default": "value"') + + const originalHeight = (await page.locator('#field-customJSON').boundingBox())?.height || 0 + await page.locator('#set-custom-json').click() + const newHeight = (await page.locator('#field-customJSON').boundingBox())?.height || 0 + expect(newHeight).toBeGreaterThan(originalHeight) + }) }) diff --git a/test/fields/collections/JSON/index.tsx b/test/fields/collections/JSON/index.tsx index 8a05ee77e3..5952f4f4c4 100644 --- a/test/fields/collections/JSON/index.tsx +++ b/test/fields/collections/JSON/index.tsx @@ -67,6 +67,16 @@ const JSON: CollectionConfig = { }, ], }, + { + name: 'customJSON', + type: 'json', + admin: { + components: { + afterInput: ['./collections/JSON/AfterField#AfterField'], + }, + }, + label: 'Custom Json', + }, ], versions: { maxPerDoc: 1, diff --git a/test/fields/collections/Text/e2e.spec.ts b/test/fields/collections/Text/e2e.spec.ts index ea23a70c14..d975b10ac2 100644 --- a/test/fields/collections/Text/e2e.spec.ts +++ b/test/fields/collections/Text/e2e.spec.ts @@ -170,12 +170,7 @@ describe('Text', () => { user: client.user, key: 'text-fields-list', value: { - columns: [ - { - accessor: 'disableListColumnText', - active: true, - }, - ], + columns: [{ disableListColumnText: true }], }, }) diff --git a/test/fields/int.spec.ts b/test/fields/int.spec.ts index c937123679..3b36ffacf5 100644 --- a/test/fields/int.spec.ts +++ b/test/fields/int.spec.ts @@ -165,6 +165,68 @@ describe('Fields', () => { expect(missResult).toBeFalsy() }) + it('should query like on value', async () => { + const miss = await payload.create({ + collection: 'text-fields', + data: { + text: 'dog', + }, + }) + + const hit = await payload.create({ + collection: 'text-fields', + data: { + text: 'cat', + }, + }) + + const { docs } = await payload.find({ + collection: 'text-fields', + where: { + text: { + like: 'cat', + }, + }, + }) + + const hitResult = docs.find(({ id: findID }) => hit.id === findID) + const missResult = docs.find(({ id: findID }) => miss.id === findID) + + expect(hitResult).toBeDefined() + expect(missResult).toBeFalsy() + }) + + it('should query not_like on value', async () => { + const hit = await payload.create({ + collection: 'text-fields', + data: { + text: 'dog', + }, + }) + + const miss = await payload.create({ + collection: 'text-fields', + data: { + text: 'cat', + }, + }) + + const { docs } = await payload.find({ + collection: 'text-fields', + where: { + text: { + not_like: 'cat', + }, + }, + }) + + const hitResult = docs.find(({ id: findID }) => hit.id === findID) + const missResult = docs.find(({ id: findID }) => miss.id === findID) + + expect(hitResult).toBeDefined() + expect(missResult).toBeFalsy() + }) + it('should query hasMany within an array', async () => { const docFirst = await payload.create({ collection: 'text-fields', @@ -2713,6 +2775,20 @@ describe('Fields', () => { expect(docIDs).not.toContain(bazBar.id) }) + it('should query nested properties - not_like', async () => { + const { docs } = await payload.find({ + collection: 'json-fields', + where: { + 'json.baz': { not_like: 'bar' }, + }, + }) + + const docIDs = docs.map(({ id }) => id) + + expect(docIDs).toContain(fooBar.id) + expect(docIDs).not.toContain(bazBar.id) + }) + it('should query nested properties - equals', async () => { const { docs } = await payload.find({ collection: 'json-fields', diff --git a/test/fields/payload-types.ts b/test/fields/payload-types.ts index 61d47b8f75..5023784986 100644 --- a/test/fields/payload-types.ts +++ b/test/fields/payload-types.ts @@ -1474,6 +1474,15 @@ export interface JsonField { | boolean | null; }; + customJSON?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; updatedAt: string; createdAt: string; } @@ -3165,6 +3174,7 @@ export interface JsonFieldsSelect { | { jsonWithinGroup?: T; }; + customJSON?: T; updatedAt?: T; createdAt?: T; } diff --git a/test/helpers.ts b/test/helpers.ts index 53b9b09c1c..11e4967404 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -391,9 +391,13 @@ export async function switchTab(page: Page, selector: string) { * * Useful to prevent the e2e test from passing when, for example, there are react missing key prop errors * @param page + * @param options */ export function initPageConsoleErrorCatch(page: Page, options?: { ignoreCORS?: boolean }) { const { ignoreCORS = false } = options || {} // Default to not ignoring CORS errors + const consoleErrors: string[] = [] + + let shouldCollectErrors = false page.on('console', (msg) => { if ( @@ -435,6 +439,21 @@ export function initPageConsoleErrorCatch(page: Page, options?: { ignoreCORS?: b console.log(`Ignoring expected network error: ${msg.text()}`) } }) + + // Capture uncaught errors that do not appear in the console + page.on('pageerror', (error) => { + if (shouldCollectErrors) { + consoleErrors.push(`Page error: ${error.message}`) + } else { + throw new Error(`Page error: ${error.message}`) + } + }) + + return { + consoleErrors, + collectErrors: () => (shouldCollectErrors = true), // Enable collection of errors for specific tests + stopCollectingErrors: () => (shouldCollectErrors = false), // Disable collection of errors after the test + } } export function describeIfInCIOrHasLocalstack(): jest.Describe { diff --git a/test/joins/int.spec.ts b/test/joins/int.spec.ts index 12b535e01c..f3a8812d67 100644 --- a/test/joins/int.spec.ts +++ b/test/joins/int.spec.ts @@ -175,7 +175,10 @@ describe('Joins Field', () => { collection: categoriesSlug, }) - expect(Object.keys(categoryWithPosts)).toStrictEqual(['id', 'group']) + expect(categoryWithPosts).toStrictEqual({ + id: categoryWithPosts.id, + group: categoryWithPosts.group, + }) expect(categoryWithPosts.group.relatedPosts.docs).toHaveLength(10) expect(categoryWithPosts.group.relatedPosts.docs[0]).toHaveProperty('id') @@ -1202,6 +1205,37 @@ describe('Joins Field', () => { expect(parent.children.docs[1]?.value.id).toBe(child_1.id) expect(parent.children.docs[1]?.relationTo).toBe('multiple-collections-1') + // Pagination across collections + parent = await payload.findByID({ + collection: 'multiple-collections-parents', + id: parent.id, + depth: 1, + joins: { + children: { + limit: 1, + sort: 'title', + }, + }, + }) + + expect(parent.children.docs).toHaveLength(1) + expect(parent.children?.hasNextPage).toBe(true) + + parent = await payload.findByID({ + collection: 'multiple-collections-parents', + id: parent.id, + depth: 1, + joins: { + children: { + limit: 2, + sort: 'title', + }, + }, + }) + + expect(parent.children.docs).toHaveLength(2) + expect(parent.children?.hasNextPage).toBe(false) + // Sorting across collections parent = await payload.findByID({ collection: 'multiple-collections-parents', diff --git a/test/localization/config.ts b/test/localization/config.ts index bed2f48948..b91b0eb0ae 100644 --- a/test/localization/config.ts +++ b/test/localization/config.ts @@ -64,8 +64,16 @@ export default buildConfigWithDefaults({ NestedArray, NestedFields, { + admin: { + listSearchableFields: 'name', + }, auth: true, fields: [ + { + name: 'name', + label: { en: 'Full name' }, + type: 'text', + }, { name: 'relation', relationTo: localizedPostsSlug, @@ -83,6 +91,7 @@ export default buildConfigWithDefaults({ fields: [ { name: 'title', + label: { en: 'Full title' }, index: true, localized: true, type: 'text', diff --git a/test/localization/e2e.spec.ts b/test/localization/e2e.spec.ts index cbe9c14b6c..c2403318b2 100644 --- a/test/localization/e2e.spec.ts +++ b/test/localization/e2e.spec.ts @@ -1,7 +1,11 @@ import type { BrowserContext, Page } from '@playwright/test' +import type { GeneratedTypes } from 'helpers/sdk/types.js' import { expect, test } from '@playwright/test' +import { navigateToDoc } from 'helpers/e2e/navigateToDoc.js' import { openDocControls } from 'helpers/e2e/openDocControls.js' +import { upsertPrefs } from 'helpers/e2e/upsertPrefs.js' +import { RESTClient } from 'helpers/rest.js' import path from 'path' import { fileURLToPath } from 'url' @@ -31,11 +35,6 @@ import { spanishLocale, withRequiredLocalizedFields, } from './shared.js' -import { navigateToDoc } from 'helpers/e2e/navigateToDoc.js' - -import { upsertPrefs } from 'helpers/e2e/upsertPrefs.js' -import { RESTClient } from 'helpers/rest.js' -import { GeneratedTypes } from 'helpers/sdk/types.js' const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) @@ -119,16 +118,16 @@ describe('Localization', () => { await expect(page.locator('.localizer .popup')).toHaveClass(/popup--active/) - const activeOption = await page.locator( + const activeOption = page.locator( `.localizer .popup.popup--active .popup-button-list__button--selected`, ) await expect(activeOption).toBeVisible() const tagName = await activeOption.evaluate((node) => node.tagName) - await expect(tagName).not.toBe('A') + expect(tagName).not.toBe('A') await expect(activeOption).not.toHaveAttribute('href') - await expect(tagName).not.toBe('BUTTON') - await expect(tagName).toBe('DIV') + expect(tagName).not.toBe('BUTTON') + expect(tagName).toBe('DIV') }) }) @@ -140,7 +139,7 @@ describe('Localization', () => { const createNewButtonLocator = '.collection-list a[href="/admin/collections/cannot-create-default-locale/create"]' - await expect(page.locator(createNewButtonLocator)).not.toBeVisible() + await expect(page.locator(createNewButtonLocator)).toBeHidden() await changeLocale(page, spanishLocale) await expect(page.locator(createNewButtonLocator).first()).toBeVisible() await page.goto(urlCannotCreateDefaultLocale.create) @@ -330,11 +329,11 @@ describe('Localization', () => { await page.goto(url.list) - const localeLabel = await page + const localeLabel = page .locator('.localizer.app-header__localizer .localizer-button__current-label') - .innerText() + - expect(localeLabel).not.toEqual('English') + await expect(localeLabel).not.toHaveText('English') }) }) @@ -351,7 +350,7 @@ describe('Localization', () => { await navigateToDoc(page, urlRelationshipLocalized) const drawerToggler = '#field-relationMultiRelationTo .relationship--single-value__drawer-toggler' - expect(page.locator(drawerToggler)).toBeEnabled() + await expect(page.locator(drawerToggler)).toBeEnabled() await openDocDrawer(page, drawerToggler) await expect(page.locator('.doc-drawer__header-text')).toContainText('spanish-relation2') await page.locator('.doc-drawer__header-close').click() @@ -518,7 +517,7 @@ describe('Localization', () => { // only throttle test after initial load to avoid timeouts const cdpSession = await throttleTest({ - page: page, + page, context, delay: 'Fast 4G', }) @@ -541,6 +540,13 @@ describe('Localization', () => { await cdpSession.detach() }) }) + + test('should use label in search filter when string or object', async () => { + await page.goto(url.list) + const searchInput = page.locator('.search-filter__input') + await expect(searchInput).toBeVisible() + await expect(searchInput).toHaveAttribute('placeholder', 'Search by Full title') + }) }) async function fillValues(data: Partial) { diff --git a/test/localization/payload-types.ts b/test/localization/payload-types.ts index 320471a4a4..cfc3e07d4c 100644 --- a/test/localization/payload-types.ts +++ b/test/localization/payload-types.ts @@ -64,7 +64,6 @@ export interface Config { auth: { users: UserAuthOperations; }; - blocks: {}; collections: { richText: RichText; 'blocks-fields': BlocksField; @@ -322,6 +321,7 @@ export interface NestedFieldTable { */ export interface User { id: string; + name?: string | null; relation?: (string | null) | LocalizedPost; updatedAt: string; createdAt: string; @@ -928,6 +928,7 @@ export interface NestedFieldTablesSelect { * via the `definition` "users_select". */ export interface UsersSelect { + name?: T; relation?: T; updatedAt?: T; createdAt?: T; diff --git a/test/plugin-multi-tenant/int.spec.ts b/test/plugin-multi-tenant/int.spec.ts index 100e117205..512ea44000 100644 --- a/test/plugin-multi-tenant/int.spec.ts +++ b/test/plugin-multi-tenant/int.spec.ts @@ -7,6 +7,7 @@ import type { NextRESTClient } from '../helpers/NextRESTClient.js' import { devUser } from '../credentials.js' import { initPayloadInt } from '../helpers/initPayloadInt.js' +import { tenantsSlug } from './shared.js' let payload: Payload let restClient: NextRESTClient @@ -40,7 +41,7 @@ describe('@payloadcms/plugin-multi-tenant', () => { describe('tenants', () => { it('should create a tenant', async () => { const tenant1 = await payload.create({ - collection: 'tenants', + collection: tenantsSlug, data: { name: 'tenant1', domain: 'tenant1.com', diff --git a/test/plugin-multi-tenant/seed/index.ts b/test/plugin-multi-tenant/seed/index.ts index fe7efeae5a..4e37f2a1ec 100644 --- a/test/plugin-multi-tenant/seed/index.ts +++ b/test/plugin-multi-tenant/seed/index.ts @@ -1,19 +1,19 @@ import type { Config } from 'payload' import { devUser } from '../../credentials.js' -import { menuItemsSlug, menuSlug, usersSlug } from '../shared.js' +import { menuItemsSlug, menuSlug, tenantsSlug, usersSlug } from '../shared.js' export const seed: Config['onInit'] = async (payload) => { // create tenants const blueDogTenant = await payload.create({ - collection: 'tenants', + collection: tenantsSlug, data: { name: 'Blue Dog', domain: 'bluedog.com', }, }) const steelCatTenant = await payload.create({ - collection: 'tenants', + collection: tenantsSlug, data: { name: 'Steel Cat', domain: 'steelcat.com', diff --git a/test/plugin-nested-docs/int.spec.ts b/test/plugin-nested-docs/int.spec.ts index 6c7359cd9b..90d9262c6e 100644 --- a/test/plugin-nested-docs/int.spec.ts +++ b/test/plugin-nested-docs/int.spec.ts @@ -76,7 +76,6 @@ describe('@payloadcms/plugin-nested-docs', () => { }, }) } - // update parent doc await payload.update({ collection: 'pages', @@ -110,6 +109,91 @@ describe('@payloadcms/plugin-nested-docs', () => { // @ts-ignore expect(lastUpdatedChildBreadcrumbs[0].url).toStrictEqual('/11-children-updated') }) + + it('should return breadcrumbs as an array of objects', async () => { + const parentDoc = await payload.create({ + collection: 'pages', + data: { + title: 'parent doc', + slug: 'parent-doc', + _status: 'published', + }, + }) + + const childDoc = await payload.create({ + collection: 'pages', + data: { + title: 'child doc', + slug: 'child-doc', + parent: parentDoc.id, + _status: 'published', + }, + }) + + // expect breadcrumbs to be an array + expect(childDoc.breadcrumbs).toBeInstanceOf(Array) + expect(childDoc.breadcrumbs).toBeDefined() + + // expect each to be objects + childDoc.breadcrumbs?.map((breadcrumb) => { + expect(breadcrumb).toBeInstanceOf(Object) + }) + }) + + it('should update child doc breadcrumb without affecting any other data', async () => { + const parentDoc = await payload.create({ + collection: 'pages', + data: { + title: 'parent doc', + slug: 'parent', + }, + }) + + const childDoc = await payload.create({ + collection: 'pages', + data: { + title: 'child doc', + slug: 'child', + parent: parentDoc.id, + _status: 'published', + }, + }) + + await payload.update({ + collection: 'pages', + id: parentDoc.id, + data: { + title: 'parent updated', + slug: 'parent-updated', + _status: 'published', + }, + }) + + const updatedChild = await payload + .find({ + collection: 'pages', + where: { + id: { + equals: childDoc.id, + }, + }, + }) + .then(({ docs }) => docs[0]) + + if (!updatedChild) { + return + } + + // breadcrumbs should be updated + expect(updatedChild.breadcrumbs).toHaveLength(2) + + expect(updatedChild.breadcrumbs?.[0]?.url).toStrictEqual('/parent-updated') + expect(updatedChild.breadcrumbs?.[1]?.url).toStrictEqual('/parent-updated/child') + + // no other data should be affected + expect(updatedChild.title).toEqual('child doc') + expect(updatedChild.slug).toEqual('child') + }) }) describe('overrides', () => { diff --git a/test/select/int.spec.ts b/test/select/int.spec.ts index a106a8d2cb..dd8cf4f2c1 100644 --- a/test/select/int.spec.ts +++ b/test/select/int.spec.ts @@ -1648,7 +1648,10 @@ describe('Select', () => { }, }) - expect(Object.keys(res)).toStrictEqual(['id', 'text']) + expect(res).toStrictEqual({ + id: res.id, + text: res.text, + }) }) it('should apply select with updateByID', async () => { @@ -1661,7 +1664,10 @@ describe('Select', () => { select: { text: true }, }) - expect(Object.keys(res)).toStrictEqual(['id', 'text']) + expect(res).toStrictEqual({ + id: res.id, + text: res.text, + }) }) it('should apply select with updateBulk', async () => { @@ -1680,7 +1686,10 @@ describe('Select', () => { assert(res.docs[0]) - expect(Object.keys(res.docs[0])).toStrictEqual(['id', 'text']) + expect(res.docs[0]).toStrictEqual({ + id: res.docs[0].id, + text: res.docs[0].text, + }) }) it('should apply select with deleteByID', async () => { @@ -1692,7 +1701,10 @@ describe('Select', () => { select: { text: true }, }) - expect(Object.keys(res)).toStrictEqual(['id', 'text']) + expect(res).toStrictEqual({ + id: res.id, + text: res.text, + }) }) it('should apply select with deleteBulk', async () => { @@ -1710,7 +1722,10 @@ describe('Select', () => { assert(res.docs[0]) - expect(Object.keys(res.docs[0])).toStrictEqual(['id', 'text']) + expect(res.docs[0]).toStrictEqual({ + id: res.docs[0].id, + text: res.docs[0].text, + }) }) it('should apply select with duplicate', async () => { @@ -1722,7 +1737,10 @@ describe('Select', () => { select: { text: true }, }) - expect(Object.keys(res)).toStrictEqual(['id', 'text']) + expect(res).toStrictEqual({ + id: res.id, + text: res.text, + }) }) }) diff --git a/test/uploads/collections/Upload1/index.ts b/test/uploads/collections/Upload1/index.ts index 6cf7ebf1c3..bc6580611e 100644 --- a/test/uploads/collections/Upload1/index.ts +++ b/test/uploads/collections/Upload1/index.ts @@ -26,7 +26,7 @@ export const Uploads1: CollectionConfig = { relationTo: 'uploads-2', filterOptions: { mimeType: { - equals: 'image/png', + in: ['image/png', 'application/pdf'], }, }, hasMany: true, diff --git a/test/uploads/e2e.spec.ts b/test/uploads/e2e.spec.ts index 191e3cbd3e..66e54f0e90 100644 --- a/test/uploads/e2e.spec.ts +++ b/test/uploads/e2e.spec.ts @@ -65,6 +65,9 @@ let uploadsOne: AdminUrlUtil let uploadsTwo: AdminUrlUtil let customUploadFieldURL: AdminUrlUtil let hideFileInputOnCreateURL: AdminUrlUtil +let consoleErrorsFromPage: string[] = [] +let collectErrorsFromPage: () => boolean +let stopCollectingErrorsFromPage: () => boolean describe('Uploads', () => { let page: Page @@ -99,7 +102,14 @@ describe('Uploads', () => { const context = await browser.newContext() page = await context.newPage() - initPageConsoleErrorCatch(page, { ignoreCORS: true }) + const { consoleErrors, collectErrors, stopCollectingErrors } = initPageConsoleErrorCatch(page, { + ignoreCORS: true, + }) + + consoleErrorsFromPage = consoleErrors + collectErrorsFromPage = collectErrors + stopCollectingErrorsFromPage = stopCollectingErrors + await ensureCompilationIsDone({ page, serverURL }) }) @@ -744,6 +754,55 @@ describe('Uploads', () => { await saveDocAndAssert(page) }) + test('should bulk upload non-image files without page errors', async () => { + // Enable collection ONLY for this test + collectErrorsFromPage() + + // Navigate to the upload creation page + await page.goto(uploadsOne.create) + await page.waitForURL(uploadsOne.create) + + // Upload single file + await page.setInputFiles( + '.file-field input[type="file"]', + path.resolve(dirname, './image.png'), + ) + const filename = page.locator('.file-field__filename') + await expect(filename).toHaveValue('image.png') + + const bulkUploadButton = page.locator('#field-hasManyUpload button', { + hasText: exactText('Create New'), + }) + await bulkUploadButton.click() + + const bulkUploadModal = page.locator('#bulk-upload-drawer-slug-1') + await expect(bulkUploadModal).toBeVisible() + + await page.setInputFiles('#bulk-upload-drawer-slug-1 .dropzone input[type="file"]', [ + path.resolve(dirname, './test-pdf.pdf'), + ]) + + await page + .locator('.bulk-upload--file-manager .render-fields #field-prefix') + .fill('prefix-one') + const saveButton = page.locator('.bulk-upload--actions-bar__saveButtons button') + await saveButton.click() + + await page.waitForSelector('#field-hasManyUpload .upload--has-many__dragItem') + const itemCount = await page + .locator('#field-hasManyUpload .upload--has-many__dragItem') + .count() + expect(itemCount).toEqual(1) + + await saveDocAndAssert(page) + + // Assert no console errors occurred for this test only + expect(consoleErrorsFromPage).toEqual([]) + + // Reset global behavior for other tests + stopCollectingErrorsFromPage() + }) + test('should apply field value to all bulk upload files after edit many', async () => { // Navigate to the upload creation page await page.goto(uploadsOne.create) diff --git a/test/uploads/payload-types.ts b/test/uploads/payload-types.ts index f75bd6fcd4..038b568d30 100644 --- a/test/uploads/payload-types.ts +++ b/test/uploads/payload-types.ts @@ -64,7 +64,6 @@ export interface Config { auth: { users: UserAuthOperations; }; - blocks: {}; collections: { relation: Relation; audio: Audio; diff --git a/test/uploads/test-pdf.pdf b/test/uploads/test-pdf.pdf new file mode 100644 index 0000000000..845e38c540 Binary files /dev/null and b/test/uploads/test-pdf.pdf differ diff --git a/test/versions/e2e.spec.ts b/test/versions/e2e.spec.ts index 0cd34eb850..d7d44c0842 100644 --- a/test/versions/e2e.spec.ts +++ b/test/versions/e2e.spec.ts @@ -438,6 +438,47 @@ describe('Versions', () => { await expect(drawer.locator('.id-label')).toBeVisible() }) + test('collection - autosave - should not create duplicates when clicking Create new', async () => { + // This test checks that when we click "Create new" in the list view, it only creates 1 extra document and not more + const { totalDocs: initialDocsCount } = await payload.find({ + collection: autosaveCollectionSlug, + draft: true, + }) + + await page.goto(autosaveURL.create) + await page.locator('#field-title').fill('autosave title') + await waitForAutoSaveToRunAndComplete(page) + await expect(page.locator('#field-title')).toHaveValue('autosave title') + + const { totalDocs: updatedDocsCount } = await payload.find({ + collection: autosaveCollectionSlug, + draft: true, + }) + + await expect(() => { + expect(updatedDocsCount).toBe(initialDocsCount + 1) + }).toPass({ timeout: POLL_TOPASS_TIMEOUT, intervals: [100] }) + + await page.goto(autosaveURL.list) + const createNewButton = page.locator('.list-header .btn:has-text("Create New")') + await createNewButton.click() + + await page.waitForURL(`**/${autosaveCollectionSlug}/**`) + + await page.locator('#field-title').fill('autosave title') + await waitForAutoSaveToRunAndComplete(page) + await expect(page.locator('#field-title')).toHaveValue('autosave title') + + const { totalDocs: latestDocsCount } = await payload.find({ + collection: autosaveCollectionSlug, + draft: true, + }) + + await expect(() => { + expect(latestDocsCount).toBe(updatedDocsCount + 1) + }).toPass({ timeout: POLL_TOPASS_TIMEOUT, intervals: [100] }) + }) + test('collection - should update updatedAt', async () => { await page.goto(url.create) await page.waitForURL(`**/${url.create}`) @@ -757,7 +798,7 @@ describe('Versions', () => { // schedule publish should not be available before document has been saved await page.locator('#action-save-popup').click() - await expect(page.locator('#schedule-publish')).not.toBeVisible() + await expect(page.locator('#schedule-publish')).toBeHidden() // save draft then try to schedule publish await saveDocAndAssert(page) diff --git a/tools/scripts/src/generate-template-variations.ts b/tools/scripts/src/generate-template-variations.ts index e385edbaec..c38049a4ab 100644 --- a/tools/scripts/src/generate-template-variations.ts +++ b/tools/scripts/src/generate-template-variations.ts @@ -68,6 +68,7 @@ async function main() { dbUri: 'POSTGRES_URL', }, sharp: false, + skipDockerCompose: true, storage: 'vercelBlobStorage', vercelDeployButtonLink: `https://vercel.com/new/clone?repository-url=` + @@ -107,6 +108,7 @@ async function main() { db: 'postgres', dirname: 'with-postgres', sharp: true, + skipDockerCompose: true, storage: 'localDisk', }, { diff --git a/tsconfig.base.json b/tsconfig.base.json index 35007f8ac1..ffd7ec771c 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -31,7 +31,7 @@ } ], "paths": { - "@payload-config": ["./test/versions/config.ts"], + "@payload-config": ["./test/_community/config.ts"], "@payloadcms/live-preview": ["./packages/live-preview/src"], "@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"], "@payloadcms/live-preview-vue": ["./packages/live-preview-vue/src/index.ts"],