diff --git a/docs/access-control/fields.mdx b/docs/access-control/fields.mdx
index 8c60a6db653..575fef04c59 100644
--- a/docs/access-control/fields.mdx
+++ b/docs/access-control/fields.mdx
@@ -23,7 +23,7 @@ export const FieldWithAccessControl: Field = {
```
- **Note:** Field Access Controls does not support returning
+ **Note:** Field Access Control does not support returning
[Query](../queries/overview) constraints like [Collection Access
Control](./collections) does.
diff --git a/docs/admin/metadata.mdx b/docs/admin/metadata.mdx
index adca913f504..1f36af2ce80 100644
--- a/docs/admin/metadata.mdx
+++ b/docs/admin/metadata.mdx
@@ -10,7 +10,7 @@ Every page within the Admin Panel automatically receives dynamic, auto-generated
Metadata is fully configurable at the root level and cascades down to individual collections, documents, and custom views. This allows for the ability to control metadata on any page with high precision, while also providing sensible defaults.
-All metadata is injected into Next.js' [`generateMetadata`](https://nextjs.org/docs/app/api-reference/functions/generate-metadata) function. This used to generate the `
` of pages within the Admin Panel. All metadata options that are available in Next.js are exposed by Payload.
+All metadata is injected into Next.js' [`generateMetadata`](https://nextjs.org/docs/app/api-reference/functions/generate-metadata) function. This is used to generate the `` of pages within the Admin Panel. All metadata options that are available in Next.js are exposed by Payload.
Within the Admin Panel, metadata can be customized at the following levels:
diff --git a/docs/admin/overview.mdx b/docs/admin/overview.mdx
index 8a959e2ebdb..1e38f39e39c 100644
--- a/docs/admin/overview.mdx
+++ b/docs/admin/overview.mdx
@@ -56,7 +56,7 @@ app
As shown above, all Payload routes are nested within the `(payload)` route group. This creates a boundary between the Admin Panel and the rest of your application by scoping all layouts and styles. The `layout.tsx` file within this directory, for example, is where Payload manages the `html` tag of the document to set proper [`lang`](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/lang) and [`dir`](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/dir) attributes, etc.
-The `admin` directory contains all the _pages_ related to the interface itself, whereas the `api` and `graphql` directories contains all the _routes_ related to the [REST API](../rest-api/overview) and [GraphQL API](../graphql/overview). All admin routes are [easily configurable](#customizing-routes) to meet your application's exact requirements.
+The `admin` directory contains all the _pages_ related to the interface itself, whereas the `api` and `graphql` directories contain all the _routes_ related to the [REST API](../rest-api/overview) and [GraphQL API](../graphql/overview). All admin routes are [easily configurable](#customizing-routes) to meet your application's exact requirements.
**Note:** If you don't intend to use the Admin Panel, [REST
diff --git a/docs/admin/preview.mdx b/docs/admin/preview.mdx
index 3abfd20bfce..6f5984c594f 100644
--- a/docs/admin/preview.mdx
+++ b/docs/admin/preview.mdx
@@ -53,7 +53,7 @@ The `options` object contains the following properties:
| ------------ | ----------------------------------------------------- |
| **`locale`** | The current locale of the Document being edited. |
| **`req`** | The Payload Request object. |
-| **`token`** | The JWT token of the currently authenticated in user. |
+| **`token`** | The JWT token of the currently authenticated user. |
If your application requires a fully qualified URL, such as within deploying to Vercel Preview Deployments, you can use the `req` property to build this URL:
@@ -225,7 +225,7 @@ export default async function Page({ params: paramsPromise }) {
```
- **Note:** For fully working example of this, check of the official [Draft
+ **Note:** For fully working example of this, check out the official [Draft
Preview
Example](https://github.com/payloadcms/payload/tree/main/examples/draft-preview)
in the [Examples
diff --git a/docs/authentication/email.mdx b/docs/authentication/email.mdx
index 15b49cc09b0..b54dc971e4e 100644
--- a/docs/authentication/email.mdx
+++ b/docs/authentication/email.mdx
@@ -2,7 +2,7 @@
title: Authentication Emails
label: Email Verification
order: 30
-desc: Email Verification allows users to verify their email address before they're account is fully activated. Email Verification ties directly into the Email functionality that Payload provides.
+desc: Email Verification allows users to verify their email address before their account is fully activated. Email Verification ties directly into the Email functionality that Payload provides.
keywords: authentication, email, config, configuration, documentation, Content Management System, cms, headless, javascript, node, react, nextjs
---
diff --git a/docs/custom-components/document-views.mdx b/docs/custom-components/document-views.mdx
index f85ed93c037..76d637a987e 100644
--- a/docs/custom-components/document-views.mdx
+++ b/docs/custom-components/document-views.mdx
@@ -6,7 +6,7 @@ desc:
keywords:
---
-Document Views consist of multiple, individual views that together represent any single [Collection](../configuration/collections) or [Global](../configuration/globals) Document. All Document Views and are scoped under the `/collections/:collectionSlug/:id` or the `/globals/:globalSlug` route, respectively.
+Document Views consist of multiple, individual views that together represent any single [Collection](../configuration/collections) or [Global](../configuration/globals) Document. All Document Views are scoped under the `/collections/:collectionSlug/:id` or the `/globals/:globalSlug` route, respectively.
There are a number of default Document Views, such as the [Edit View](./edit-view) and API View, but you can also create [entirely new views](./custom-views#adding-new-views) as needed. All Document Views share a layout and can be given their own tab-based navigation, if desired.
diff --git a/docs/custom-components/edit-view.mdx b/docs/custom-components/edit-view.mdx
index af7f371ce51..0640957bc25 100644
--- a/docs/custom-components/edit-view.mdx
+++ b/docs/custom-components/edit-view.mdx
@@ -6,14 +6,14 @@ desc:
keywords: admin, components, custom, documentation, Content Management System, cms, headless, javascript, node, react, nextjs
---
-The Edit View is where users interact with individual [Collection](../configuration/collections) and [Global](../configuration/globals) Documents within the [Admin Panel](../admin/overview). The Edit View contains the actual form in which submits the data to the server. This is where they can view, edit, and save their content. It contains controls for saving, publishing, and previewing the document, all of which can be customized to a high degree.
+The Edit View is where users interact with individual [Collection](../configuration/collections) and [Global](../configuration/globals) Documents within the [Admin Panel](../admin/overview). The Edit View contains the actual form that submits the data to the server. This is where they can view, edit, and save their content. It contains controls for saving, publishing, and previewing the document, all of which can be customized to a high degree.
The Edit View can be swapped out in its entirety for a Custom View, or it can be injected with a number of Custom Components to add additional functionality or presentational elements without replacing the entire view.
**Note:** The Edit View is one of many [Document Views](./document-views) in
the Payload Admin Panel. Each Document View is responsible for a different
- aspect of the interacting with a single Document.
+ aspect of interacting with a single Document.
## Custom Edit View
@@ -74,7 +74,7 @@ In addition to swapping out the entire Edit View with a [Custom View](./custom-v
**Important:** Collection and Globals are keyed to a different property in the
- `admin.components` object have slightly different options. Be sure to use the
+ `admin.components` object and have slightly different options. Be sure to use the
correct key for the entity you are working with.
@@ -199,7 +199,7 @@ export function MySaveButton(props: SaveButtonClientProps) {
The `beforeDocumentControls` property allows you to render custom components just before the default document action buttons (like Save, Publish, or Preview). This is useful for injecting custom buttons, status indicators, or any other UI elements before the built-in controls.
-To add `beforeDocumentControls` components, use the `components.edit.beforeDocumentControls` property in you [Collection Config](../configuration/collections) or `components.elements.beforeDocumentControls` in your [Global Config](../configuration/globals):
+To add `beforeDocumentControls` components, use the `components.edit.beforeDocumentControls` property in your [Collection Config](../configuration/collections) or `components.elements.beforeDocumentControls` in your [Global Config](../configuration/globals):
#### Collections
diff --git a/docs/custom-components/root-components.mdx b/docs/custom-components/root-components.mdx
index e6b66a1d05c..70bcc489970 100644
--- a/docs/custom-components/root-components.mdx
+++ b/docs/custom-components/root-components.mdx
@@ -52,7 +52,7 @@ The following options are available:
_For details on how to build Custom Components, see [Building Custom Components](./overview#building-custom-components)._
- **Note:** You can also use set [Collection
+ **Note:** You can also set [Collection
Components](../configuration/collections#custom-components) and [Global
Components](../configuration/globals#custom-components) in their respective
configs.
diff --git a/docs/database/mongodb.mdx b/docs/database/mongodb.mdx
index f258ae92d21..cf74e0dd0a2 100644
--- a/docs/database/mongodb.mdx
+++ b/docs/database/mongodb.mdx
@@ -77,4 +77,4 @@ export default buildConfig({
We export compatibility options for [DocumentDB](https://aws.amazon.com/documentdb/), [Azure Cosmos DB](https://azure.microsoft.com/en-us/products/cosmos-db) and [Firestore](https://cloud.google.com/firestore/mongodb-compatibility/docs/overview). Known limitations:
- Azure Cosmos DB does not support transactions that update two or more documents in different collections, which is a common case when using Payload (via hooks).
-- Azure Cosmos DB the root config property `indexSortableFields` must be set to `true`.
+- Azure Cosmos DB requires the root config property `indexSortableFields` to be set to `true`.
diff --git a/docs/database/postgres.mdx b/docs/database/postgres.mdx
index 99e39aca072..3f2c8c1382e 100644
--- a/docs/database/postgres.mdx
+++ b/docs/database/postgres.mdx
@@ -51,7 +51,7 @@ export default buildConfig({
```
- **Note:** If you're using `vercelPostgresAdapter` your
+ **Note:** If you're using `vercelPostgresAdapter` and your
`process.env.POSTGRES_URL` or `pool.connectionString` points to a local
database (e.g hostname has `localhost` or `127.0.0.1`) we use the `pg` module
for pooling instead of `@vercel/postgres`. This is because `@vercel/postgres`
diff --git a/docs/ecommerce/advanced.mdx b/docs/ecommerce/advanced.mdx
index 77f7b47a06a..f916d3b8871 100644
--- a/docs/ecommerce/advanced.mdx
+++ b/docs/ecommerce/advanced.mdx
@@ -17,7 +17,7 @@ You can import the collections directly from the plugin and add them to your Pay
| `createAddressesCollection` | `addresses` | Used for customer addresses (like shipping and billing). [More](#createAddressesCollection) |
| `createCartsCollection` | `carts` | Carts can be used by customers, guests and once purchased are kept for records and analytics. [More](#createCartsCollection) |
| `createOrdersCollection` | `orders` | Orders are used to store customer-side information and are related to at least one transaction. [More](#createOrdersCollection) |
-| `createTransactionsCollection` | `transactions` | Handles payment information accessable by admins only, related to Orders. [More](#createTransactionsCollection) |
+| `createTransactionsCollection` | `transactions` | Handles payment information accessible by admins only, related to Orders. [More](#createTransactionsCollection) |
| `createProductsCollection` | `products` | All the product information lives here, contains prices, relations to Variant Types and joins to Variants. [More](#createProductsCollection) |
| `createVariantsCollection` | `variants` | Product variants, unique purchasable items that are linked to a product and Variant Options. [More](#createVariantsCollection) |
| `createVariantTypesCollection` | `variantTypes` | A taxonomy used by Products to relate Variant Options together. An example of a Variant Type is "size". [More](#createVariantTypesCollection) |
diff --git a/docs/ecommerce/frontend.mdx b/docs/ecommerce/frontend.mdx
index e66dad295f8..ca4218957e7 100644
--- a/docs/ecommerce/frontend.mdx
+++ b/docs/ecommerce/frontend.mdx
@@ -14,7 +14,7 @@ The following hooks and components are available:
| ------------------- | ------------------------------------------------------------------------------ |
| `EcommerceProvider` | A context provider to wrap your application and provide the ecommerce context. |
| `useCart` | A hook to manage the cart state and actions. |
-| `useAddresses` | A hook to fetch and manage products. |
+| `useAddresses` | A hook to fetch and manage addresses. |
| `usePayments` | A hook to manage the checkout process. |
| `useCurrency` | A hook to format prices based on the selected currency. |
| `useEcommerce` | A hook that encompasses all of the above in one. |
diff --git a/docs/ecommerce/overview.mdx b/docs/ecommerce/overview.mdx
index 951b952e741..92fb0a17183 100644
--- a/docs/ecommerce/overview.mdx
+++ b/docs/ecommerce/overview.mdx
@@ -13,7 +13,7 @@ keywords: plugins, ecommerce, stripe, plugin, payload, cms, shop, payments
releases.
-Payload provides an Ecommerce Plugin that allows you to add ecommerce functionality to your app. It comes a set of utilities and collections to manage products, orders, and payments. It also integrates with popular payment gateways like Stripe to handle transactions.
+Payload provides an Ecommerce Plugin that allows you to add ecommerce functionality to your app. It comes with a set of utilities and collections to manage products, orders, and payments. It also integrates with popular payment gateways like Stripe to handle transactions.
This plugin is completely open-source and the [source code can be found
@@ -97,7 +97,7 @@ Each Variant Type can contain a set of Variant Options. For example, a T-Shirt p
**Carts**
-Carts are linked to Customers or they're left entirely public for guests users and can contain multiple Products and Variants. Carts are stored in the database and can be retrieved at any time. Carts are automatically created for Customers when they add a product to their cart for the first time.
+Carts are linked to Customers or they're left entirely public for guest users and can contain multiple Products and Variants. Carts are stored in the database and can be retrieved at any time. Carts are automatically created for Customers when they add a product to their cart for the first time.
**Transactions and Orders**
diff --git a/docs/ecommerce/payments.mdx b/docs/ecommerce/payments.mdx
index 27ae5e19a82..4d47c85cb43 100644
--- a/docs/ecommerce/payments.mdx
+++ b/docs/ecommerce/payments.mdx
@@ -156,12 +156,12 @@ The arguments can be extended but should always include the `PaymentAdapterArgs`
| Property | Type | Description |
| ---------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------- |
-| `label` | `string` | (Optional) Allow overriding the default UI label for this adaper. |
+| `label` | `string` | (Optional) Allow overriding the default UI label for this adapter. |
| `groupOverrides` | `FieldsOverride` | (Optional) Allow overriding the default fields of the payment group. See [Payment Fields](#payment-fields) for more details. |
#### initiatePayment
-The `initiatePayment` function is called when a payment is initiated. At this step the transaction is created with a status "Processing", an abandoned purchaase will leave this transaction in this state. It receives an object with the following properties:
+The `initiatePayment` function is called when a payment is initiated. At this step the transaction is created with a status "Processing", an abandoned purchase will leave this transaction in this state. It receives an object with the following properties:
| Property | Type | Description |
| ------------------ | ---------------- | --------------------------------------------- |
@@ -407,7 +407,7 @@ And for the args use the `PaymentAdapterClientArgs` type:
| Property | Type | Description |
| -------- | -------- | ----------------------------------------------------------------- |
-| `label` | `string` | (Optional) Allow overriding the default UI label for this adaper. |
+| `label` | `string` | (Optional) Allow overriding the default UI label for this adapter. |
## Best Practices
diff --git a/docs/fields/group.mdx b/docs/fields/group.mdx
index 1772851f63f..693be20ca2c 100644
--- a/docs/fields/group.mdx
+++ b/docs/fields/group.mdx
@@ -111,7 +111,7 @@ export const ExampleCollection: CollectionConfig = {
## Presentational group fields
-You can also use the Group field to only visually group fields without affecting the data structure. Not defining a label will render just the grouped fields.
+You can also use the Group field to only visually group fields without affecting the data structure. Not defining a `name` will render just the grouped fields (no nested object is created). If you want the group to appear as a titled section in the Admin UI, set a `label`.
```ts
import type { CollectionConfig } from 'payload'
@@ -120,6 +120,39 @@ export const ExampleCollection: CollectionConfig = {
slug: 'example-collection',
fields: [
{
+ label: 'Page meta', // label only → presentational
+ type: 'group', // required
+ fields: [
+ {
+ name: 'title',
+ type: 'text',
+ required: true,
+ minLength: 20,
+ maxLength: 100,
+ },
+ {
+ name: 'description',
+ type: 'textarea',
+ required: true,
+ minLength: 40,
+ maxLength: 160,
+ },
+ ],
+ },
+ ],
+}
+```
+
+## Named group
+
+```ts
+import type { CollectionConfig } from 'payload'
+
+export const ExampleCollection: CollectionConfig = {
+ slug: 'example-collection',
+ fields: [
+ {
+ name: 'pageMeta', // name → nested object in data
label: 'Page meta',
type: 'group', // required
fields: [
diff --git a/docs/fields/overview.mdx b/docs/fields/overview.mdx
index e7be07ef9b6..3a81ab5b9ad 100644
--- a/docs/fields/overview.mdx
+++ b/docs/fields/overview.mdx
@@ -96,7 +96,7 @@ Here are the available Presentational Fields:
- [Collapsible](../fields/collapsible) - nests fields within a collapsible component
- [Row](../fields/row) - aligns fields horizontally
- [Tabs (Unnamed)](../fields/tabs) - nests fields within a tabbed layout. It is not presentational if the tab has a name.
-- [Group (Unnamed)](../fields/group) - nests fields within a keyed object It is not presentational if the group has a name.
+- [Group (Unnamed)](../fields/group) - nests fields within a keyed object. It is not presentational if the group has a name.
- [UI](../fields/ui) - blank field for custom UI components
### Virtual Fields
diff --git a/docs/hooks/fields.mdx b/docs/hooks/fields.mdx
index 1c8e91ced0c..d37c2cf0d2a 100644
--- a/docs/hooks/fields.mdx
+++ b/docs/hooks/fields.mdx
@@ -24,7 +24,7 @@ export const FieldWithHooks: Field = {
## Config Options
-All Field Hooks accept an array of synchronous or asynchronous functions. These functions can optionally modify the return value of the field before the operation continues. All Field Hooks are formatted to accept the same arguments, although some arguments may be `undefined` based the specific hook type.
+All Field Hooks accept an array of synchronous or asynchronous functions. These functions can optionally modify the return value of the field before the operation continues. All Field Hooks are formatted to accept the same arguments, although some arguments may be `undefined` based on the specific hook type.
**Important:** Due to GraphQL's typed nature, changing the type of data that
diff --git a/docs/hooks/globals.mdx b/docs/hooks/globals.mdx
index d6c650d3ab7..9b6c7d7e397 100644
--- a/docs/hooks/globals.mdx
+++ b/docs/hooks/globals.mdx
@@ -195,7 +195,7 @@ const afterReadHook: GlobalAfterReadHook = async ({
}) => {...}
```
-The following arguments are provided to the `beforeRead` hook:
+The following arguments are provided to the `afterRead` hook:
| Option | Description |
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
diff --git a/docs/hooks/overview.mdx b/docs/hooks/overview.mdx
index 1d1afb49232..761b0b9ebca 100644
--- a/docs/hooks/overview.mdx
+++ b/docs/hooks/overview.mdx
@@ -161,7 +161,7 @@ Instead, you might want to use a `beforeChange` or `afterChange` hook, which onl
### Using Hook Context
-Use [Hook Context](./context) avoid prevent infinite loops or avoid repeating expensive operations across multiple hooks in the same request.
+Use [Hook Context](./context) to avoid infinite loops or to prevent repeating expensive operations across multiple hooks in the same request.
```ts
{
@@ -181,7 +181,7 @@ To learn more, see the [Hook Context documentation](./context).
### Offloading to the jobs queue
-If your hooks perform any long-running tasks that don't direct affect request lifecycle, consider offloading them to the [jobs queue](../jobs-queue/overview). This will free up the request to continue processing without waiting for the task to complete.
+If your hooks perform any long-running tasks that don't directly affect the request lifecycle, consider offloading them to the [jobs queue](../jobs-queue/overview). This will free up the request to continue processing without waiting for the task to complete.
```ts
{
diff --git a/docs/integrations/vercel-content-link.mdx b/docs/integrations/vercel-content-link.mdx
index 29356b4ec7d..52879954a72 100644
--- a/docs/integrations/vercel-content-link.mdx
+++ b/docs/integrations/vercel-content-link.mdx
@@ -2,7 +2,7 @@
title: Vercel Content Link
label: Vercel Content Link
order: 10
-desc: Payload + Vercel Content Link allows yours editors to navigate directly from the content rendered on your front-end to the fields in Payload that control it.
+desc: Payload + Vercel Content Link allows your editors to navigate directly from the content rendered on your front-end to the fields in Payload that control it.
keywords: vercel, vercel content link, content link, visual editing, content source maps, Content Management System, cms, headless, javascript, node, react, nextjs
---
diff --git a/docs/jobs-queue/tasks.mdx b/docs/jobs-queue/tasks.mdx
index 32eb23e9620..0f1e69fbf0a 100644
--- a/docs/jobs-queue/tasks.mdx
+++ b/docs/jobs-queue/tasks.mdx
@@ -13,7 +13,7 @@ keywords: jobs queue, application framework, typescript, node, react, nextjs
You can register Tasks on the Payload config, and then create [Jobs](/docs/jobs-queue/jobs) or [Workflows](/docs/jobs-queue/workflows) that use them. Think of Tasks like tidy, isolated "functions that do one specific thing".
-Payload Tasks can be configured to automatically retried if they fail, which makes them valuable for "durable" workflows like AI applications where LLMs can return non-deterministic results, and might need to be retried.
+Payload Tasks can be configured to be automatically retried if they fail, which makes them valuable for "durable" workflows like AI applications where LLMs can return non-deterministic results, and might need to be retried.
Tasks can either be defined within the `jobs.tasks` array in your Payload config, or they can be defined inline within a workflow.
@@ -157,7 +157,7 @@ You can configure this behavior through the `retries.shouldRestore` property. Th
If `shouldRestore` is set to true, the task will only be re-run if it previously failed. This is the default behavior.
-If `shouldRestore` this is set to false, the task will be re-run even if it previously succeeded, ignoring the maximum number of retries.
+If `shouldRestore` is set to false, the task will be re-run even if it previously succeeded, ignoring the maximum number of retries.
If `shouldRestore` is a function, the return value of the function will determine whether the task should be re-run. This can be used for more complex restore logic, e.g you may want to re-run a task up to X amount of times and then restore it for consecutive runs, or only re-run a task if the input has changed.
diff --git a/docs/migration-guide/overview.mdx b/docs/migration-guide/overview.mdx
index b17b01cd8b6..a7d31bb20ea 100644
--- a/docs/migration-guide/overview.mdx
+++ b/docs/migration-guide/overview.mdx
@@ -416,7 +416,7 @@ 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.
+1. Payload now automatically removes `localized: true` property from sub-fields if a parent is localized, as it's redundant 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
diff --git a/docs/plugins/build-your-own.mdx b/docs/plugins/build-your-own.mdx
index 31ee685a1ac..c02f56a01e0 100644
--- a/docs/plugins/build-your-own.mdx
+++ b/docs/plugins/build-your-own.mdx
@@ -71,7 +71,7 @@ In the root folder, you will see various files related to the configuration of t
### The dev folder
-The purpose of the **dev** folder is to provide a sanitized local Payload project. so you can run and test your plugin while you are actively developing it.
+The purpose of the **dev** folder is to provide a sanitized local Payload project so you can run and test your plugin while you are actively developing it.
Do **not** store any of the plugin functionality in this folder - it is purely an environment to _assist_ you with developing the plugin.
diff --git a/docs/plugins/nested-docs.mdx b/docs/plugins/nested-docs.mdx
index f9ffef26d75..f33c1fb624d 100644
--- a/docs/plugins/nested-docs.mdx
+++ b/docs/plugins/nested-docs.mdx
@@ -115,7 +115,7 @@ An array of collections slugs to enable nested docs.
#### `generateLabel`
Each `breadcrumb` has a required `label` field. By default, its value will be set to the collection's `admin.useAsTitle`
-or fallback the the `ID` of the document.
+or fallback to the `ID` of the document.
You can also pass a function to dynamically set the `label` of your breadcrumb.
diff --git a/docs/plugins/overview.mdx b/docs/plugins/overview.mdx
index 9038f30391a..f7e0eec8dad 100644
--- a/docs/plugins/overview.mdx
+++ b/docs/plugins/overview.mdx
@@ -8,7 +8,7 @@ keywords: plugins, config, configuration, extensions, custom, documentation, Con
Payload Plugins take full advantage of the modularity of the [Payload Config](../configuration/overview), allowing developers to easily inject custom—sometimes complex—functionality into Payload apps from a very small touch-point. This is especially useful for sharing your work across multiple projects or with the greater Payload community.
-There are many [Official Plugins](#official-plugins) available that solve for some of the most common uses cases, such as the [Form Builder Plugin](./form-builder) or [SEO Plugin](./seo). There are also [Community Plugins](#community-plugins) available, maintained entirely by contributing members. To extend Payload's functionality in some other way, you can easily [build your own plugin](./build-your-own).
+There are many [Official Plugins](#official-plugins) available that solve for some of the most common use cases, such as the [Form Builder Plugin](./form-builder) or [SEO Plugin](./seo). There are also [Community Plugins](#community-plugins) available, maintained entirely by contributing members. To extend Payload's functionality in some other way, you can easily [build your own plugin](./build-your-own).
To configure Plugins, use the `plugins` property in your [Payload Config](../configuration/overview):
@@ -29,7 +29,7 @@ Writing Plugins is no more complex than writing regular JavaScript. If you know
Because we rely on a simple config-based structure, Payload Plugins simply
- take in an existing config and returns a _modified_ config with new fields,
+ take in an existing config and return a _modified_ config with new fields,
hooks, collections, admin views, or anything else you can think of.
@@ -154,5 +154,5 @@ export const addLastModified: Plugin = (incomingConfig: Config): Config => {
**Reminder:** See [how to build your own plugin](./build-your-own) for a more
- in-depth explication on how create your own Payload Plugin.
+ in-depth explication on how to create your own Payload Plugin.
diff --git a/docs/plugins/search.mdx b/docs/plugins/search.mdx
index 8eee4073aaf..faf873c4336 100644
--- a/docs/plugins/search.mdx
+++ b/docs/plugins/search.mdx
@@ -10,9 +10,9 @@ keywords: plugins, search, search plugin, search engine, search index, search re
This plugin generates records of your documents that are extremely fast to search on. It does so by creating a new `search` collection that is indexed in the database then saving a static copy of each of your documents using only search-critical data. Search records are automatically created, synced, and deleted behind-the-scenes as you manage your application's documents.
-For example, if you have a posts collection that is extremely large and complex, this would allow you to sync just the title, excerpt, and slug of each post so you can query on _that_ instead of the original post directly. Search records are static, so querying them also has the significant advantage of bypassing any hooks that may present be on the original documents. You define exactly what data is synced, and you can even modify or fallback this data before it is saved on a per-document basis.
+For example, if you have a posts collection that is extremely large and complex, this would allow you to sync just the title, excerpt, and slug of each post so you can query on _that_ instead of the original post directly. Search records are static, so querying them also has the significant advantage of bypassing any hooks that may be present on the original documents. You define exactly what data is synced, and you can even modify or fallback this data before it is saved on a per-document basis.
-To query search results, use all the existing Payload APIs that you are already familiar with. You can also prioritize search results by setting a custom priority for each collection. For example, you may want to list blog posts before pages. Or you may want one specific post to always take appear first. Search records are given a `priority` field that can be used as the `?sort=` parameter in your queries.
+To query search results, use all the existing Payload APIs that you are already familiar with. You can also prioritize search results by setting a custom priority for each collection. For example, you may want to list blog posts before pages. Or you may want one specific post to always appear first. Search records are given a `priority` field that can be used as the `?sort=` parameter in your queries.
This plugin is a great way to implement a fast, immersive search experience such as a search bar in a front-end application. Many applications may not need the power and complexity of a third-party service like Algolia or ElasticSearch. This plugin provides a first-party alternative that is easy to set up and runs entirely on your own database.
diff --git a/docs/plugins/seo.mdx b/docs/plugins/seo.mdx
index b22e01c8296..b1b95b6f3fe 100644
--- a/docs/plugins/seo.mdx
+++ b/docs/plugins/seo.mdx
@@ -28,7 +28,7 @@ To help you visualize what your page might look like in a search engine, a previ
- Adds a `meta` field group to every SEO-enabled collection or global
- Allows you to define custom functions to auto-generate metadata
-- Displays hints and indicators to help content editor write effective meta
+- Displays hints and indicators to help content editors write effective meta
- Renders a snippet of what a search engine might display
- Extendable so you can define custom fields like `og:title` or `json-ld`
- Soon will support dynamic variable injection
diff --git a/docs/production/preventing-abuse.mdx b/docs/production/preventing-abuse.mdx
index 32282d03c1a..4f090b73d6c 100644
--- a/docs/production/preventing-abuse.mdx
+++ b/docs/production/preventing-abuse.mdx
@@ -16,7 +16,7 @@ Set the max number of failed login attempts before a user account is locked out
## Max Depth
-Querying a collection and automatically including related documents via `depth` incurs a performance cost. Also, it's possible that your configs may have circular relationships, meaning scenarios where an infinite amount of relationships might populate back and forth until your server times out and crashes. You can prevent any potential of depth-related issues by setting a `maxDepth` property on your Payload Config.. The maximum allowed depth should be as small as possible without interrupting dev experience, and it defaults to `10`.
+Querying a collection and automatically including related documents via `depth` incurs a performance cost. Also, it's possible that your configs may have circular relationships, meaning scenarios where an infinite amount of relationships might populate back and forth until your server times out and crashes. You can prevent any potential of depth-related issues by setting a `maxDepth` property on your Payload Config. The maximum allowed depth should be as small as possible without interrupting dev experience, and it defaults to `10`.
## Cross-Site Request Forgery (CSRF)
@@ -32,7 +32,7 @@ Because GraphQL gives the power of query writing outside a server's control, som
Any GraphQL request that is calculated to be too expensive is rejected. On the Payload Config, in `graphQL` you can set the `maxComplexity` value as an integer. For reference, the default complexity value for each added field is 1, and all `relationship` and `upload` fields are assigned a value of 10.
-If you do not need GraphQL it is advised that you disable it altogether with the Payload Config by setting `graphQL.disable: true`. Should you wish to enable GraphQL again, you can remove this property or set it `false`, any time. By turning it off, Payload will bypass creating schemas from your collections and will not register the route.
+If you do not need GraphQL it is advised that you disable it altogether with the Payload Config by setting `graphQL.disable: true`. Should you wish to enable GraphQL again, you can remove this property or set it to `false` any time. By turning it off, Payload will bypass creating schemas from your collections and will not register the route.
## Malicious File Uploads
diff --git a/docs/queries/overview.mdx b/docs/queries/overview.mdx
index cd39f6d1744..53296406e59 100644
--- a/docs/queries/overview.mdx
+++ b/docs/queries/overview.mdx
@@ -103,7 +103,7 @@ Written in plain English, if the above query were passed to a `find` operation,
### Nested properties
-When working with nested properties, which can happen when using relational fields, it is possible to use the dot notation to access the nested property. For example, when working with a `Song` collection that has a `artists` field which is related to an `Artists` collection using the `name: 'artists'`. You can access a property within the collection `Artists` like so:
+When working with nested properties, which can happen when using relational fields, it is possible to use the dot notation to access the nested property. For example, when working with a `Song` collection that has an `artists` field which is related to an `Artists` collection using the `name: 'artists'`. You can access a property within the collection `Artists` like so:
```js
import type { Where } from 'payload'
diff --git a/docs/rest-api/overview.mdx b/docs/rest-api/overview.mdx
index 53ae5b10c1a..97f188a7cee 100644
--- a/docs/rest-api/overview.mdx
+++ b/docs/rest-api/overview.mdx
@@ -21,8 +21,8 @@ To enhance DX, you can use [Payload SDK](#payload-rest-api-sdk) to query your RE
- [depth](../queries/depth) - automatically populates relationships and uploads
- [locale](/docs/configuration/localization#retrieving-localized-docs) - retrieves document(s) in a specific locale
- [fallback-locale](/docs/configuration/localization#retrieving-localized-docs) - specifies a fallback locale if no locale value exists
-- [select](../queries/select) - specifies which fields to include to the result
-- [populate](../queries/select#populate) - specifies which fields to include to the result from populated documents
+- [select](../queries/select) - specifies which fields to include in the result
+- [populate](../queries/select#populate) - specifies which fields to include in the result from populated documents
- [limit](../queries/pagination#pagination-controls) - limits the number of documents returned
- [page](../queries/pagination#pagination-controls) - specifies which page to get documents from when used with a limit
- [sort](../queries/sort#rest-api) - specifies the field(s) to use to sort the returned documents by
diff --git a/docs/rich-text/custom-features.mdx b/docs/rich-text/custom-features.mdx
index 217819ec030..f25fd327e78 100644
--- a/docs/rich-text/custom-features.mdx
+++ b/docs/rich-text/custom-features.mdx
@@ -849,7 +849,7 @@ export function mwnSlashMenuGroupWithItems(
}
```
-By creating a helper function like this, you can easily re-use it and add items to it. All Slash Menu groups with the same keys will have their items merged together.
+By creating a helper function like this, you can easily reuse it and add items to it. All Slash Menu groups with the same keys will have their items merged together.
| Option | Description |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------- |
diff --git a/docs/rich-text/overview.mdx b/docs/rich-text/overview.mdx
index 680f1f245a2..0591213f145 100644
--- a/docs/rich-text/overview.mdx
+++ b/docs/rich-text/overview.mdx
@@ -121,7 +121,7 @@ import { CallToAction } from '../blocks/CallToAction'
},
},
}),
- // This is incredibly powerful. You can re-use your Payload blocks
+ // This is incredibly powerful. You can reuse your Payload blocks
// directly in the Lexical editor as follows:
BlocksFeature({
blocks: [Banner, CallToAction],
diff --git a/docs/troubleshooting/troubleshooting.mdx b/docs/troubleshooting/troubleshooting.mdx
index 7cf91980ee6..b4527b9f9fc 100644
--- a/docs/troubleshooting/troubleshooting.mdx
+++ b/docs/troubleshooting/troubleshooting.mdx
@@ -53,7 +53,7 @@ Perform the same two checks for react and react-dom; a second copy of React can
Any other deep import such as `@payloadcms/ui/elements/Button` should **only** be used in your own frontend, outside of the Payload Admin Panel. Those deep entries are published un-bundled to help you tree-shake and ship a smaller client bundle if you only need a few components from `@payloadcms/ui`.
-### Fixing depedendency issues
+### Fixing dependency issues
These steps assume `pnpm`, which the Payload team recommends and uses internally. The principles apply to other package managers like npm and yarn as well. Do note that yarn 1.x is not supported by Payload.
diff --git a/docs/upload/overview.mdx b/docs/upload/overview.mdx
index e74c757c861..665477e7541 100644
--- a/docs/upload/overview.mdx
+++ b/docs/upload/overview.mdx
@@ -95,7 +95,7 @@ _An asterisk denotes that an option is required._
| **`adminThumbnail`** | Set the way that the [Admin Panel](../admin/overview) will display thumbnails for this Collection. [More](#admin-thumbnails) |
| **`bulkUpload`** | Allow users to upload in bulk from the list view, default is true |
| **`cacheTags`** | Set to `false` to disable the cache tag set in the UI for the admin thumbnail component. Useful for when CDNs don't allow certain cache queries. |
-| **`constructorOptions`** | An object passed to the the Sharp image library that accepts any Constructor options and applies them to the upload file. [More](https://sharp.pixelplumbing.com/api-constructor/) |
+| **`constructorOptions`** | An object passed to the Sharp image library that accepts any Constructor options and applies them to the upload file. [More](https://sharp.pixelplumbing.com/api-constructor/) |
| **`crop`** | Set to `false` to disable the cropping tool in the [Admin Panel](../admin/overview). Crop is enabled by default. [More](#crop-and-focal-point-selector) |
| **`disableLocalStorage`** | Completely disable uploading files to disk locally. [More](#disabling-local-upload-storage) |
| **`displayPreview`** | Enable displaying preview of the uploaded file in Upload fields related to this Collection. Can be locally overridden by `displayPreview` option in Upload field. [More](/docs/fields/upload#config-options). |
@@ -108,11 +108,11 @@ _An asterisk denotes that an option is required._
| **`imageSizes`** | If specified, image uploads will be automatically resized in accordance to these image sizes. [More](#image-sizes) |
| **`mimeTypes`** | Restrict mimeTypes in the file picker. Array of valid mimetypes or mimetype wildcards [More](#mimetypes) |
| **`pasteURL`** | Controls whether files can be uploaded from remote URLs by pasting them into the Upload field. **Enabled by default.** Accepts `false` to disable or an object with an `allowList` of valid remote URLs. [More](#uploading-files-from-remote-urls) |
-| **`resizeOptions`** | An object passed to the the Sharp image library to resize the uploaded file. [More](https://sharp.pixelplumbing.com/api-resize) |
+| **`resizeOptions`** | An object passed to the Sharp image library to resize the uploaded file. [More](https://sharp.pixelplumbing.com/api-resize) |
| **`skipSafeFetch`** | Set to an `allowList` to skip the safe fetch check when fetching external files. Set to `true` to skip the safe fetch for all documents in this collection. Defaults to `false`. |
| **`allowRestrictedFileTypes`** | Set to `true` to allow restricted file types. If your Collection has defined [mimeTypes](#mimetypes), restricted file verification will be skipped. Defaults to `false`. [More](#restricted-file-types) |
| **`staticDir`** | The folder directory to use to store media in. Can be either an absolute path or relative to the directory that contains your config. Defaults to your collection slug |
-| **`trimOptions`** | An object passed to the the Sharp image library to trim the uploaded file. [More](https://sharp.pixelplumbing.com/api-resize#trim) |
+| **`trimOptions`** | An object passed to the Sharp image library to trim the uploaded file. [More](https://sharp.pixelplumbing.com/api-resize#trim) |
| **`withMetadata`** | If specified, appends metadata to the output image file. Accepts a boolean or a function that receives `metadata` and `req`, returning a boolean. |
| **`hideFileInputOnCreate`** | Set to `true` to prevent the admin UI from showing file inputs during document creation, useful for programmatic file generation. |
| **`hideRemoveFile`** | Set to `true` to prevent the admin UI having a way to remove an existing file while editing. |
@@ -219,7 +219,7 @@ This is useful for hiding large or rarely used image sizes from the list view UI
#### Accessing the resized images in hooks
-All auto-resized images are exposed to be re-used in hooks and similar via an object that is bound to `req.payloadUploadSizes`.
+All auto-resized images are exposed to be reused in hooks and similar via an object that is bound to `req.payloadUploadSizes`.
The object will have keys for each size generated, and each key will be set equal to a buffer containing the file data.
diff --git a/docs/upload/storage-adapters.mdx b/docs/upload/storage-adapters.mdx
index 362a692f091..b7c93df881d 100644
--- a/docs/upload/storage-adapters.mdx
+++ b/docs/upload/storage-adapters.mdx
@@ -126,7 +126,7 @@ export default buildConfig({
### Configuration Options#s3-configuration
-See the the [AWS SDK Package](https://github.com/aws/aws-sdk-js-v3) and [`S3ClientConfig`](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/s3) object for guidance on AWS S3 configuration.
+See the [AWS SDK Package](https://github.com/aws/aws-sdk-js-v3) and [`S3ClientConfig`](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/s3) object for guidance on AWS S3 configuration.
## Azure Blob Storage
@@ -299,7 +299,7 @@ pnpm add @payloadcms/storage-r2
- Configure the `collections` object to specify which collections should use r2. The slug _must_ match one of your existing collection slugs and be an `upload` type.
- Pass in the R2 bucket binding to the `bucket` option, this should be done in the environment where Payload is running (e.g. Cloudflare Worker).
-- You can conditionally datamine whether or not to enable the plugin with the `enabled` option.
+- You can conditionally determine whether or not to enable the plugin with the `enabled` option.
```ts
export default buildConfig({
diff --git a/package.json b/package.json
index b06541b4765..82761f2e9f1 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "payload-monorepo",
- "version": "3.61.0",
+ "version": "3.61.1",
"private": true,
"type": "module",
"workspaces": [
diff --git a/packages/admin-bar/package.json b/packages/admin-bar/package.json
index b84c4c8c773..14f95c2adaf 100644
--- a/packages/admin-bar/package.json
+++ b/packages/admin-bar/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/admin-bar",
- "version": "3.61.0",
+ "version": "3.61.1",
"description": "An admin bar for React apps using Payload",
"homepage": "https://payloadcms.com",
"repository": {
diff --git a/packages/create-payload-app/package.json b/packages/create-payload-app/package.json
index 56abccb7143..8c8f3e2cff6 100644
--- a/packages/create-payload-app/package.json
+++ b/packages/create-payload-app/package.json
@@ -1,6 +1,6 @@
{
"name": "create-payload-app",
- "version": "3.61.0",
+ "version": "3.61.1",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",
diff --git a/packages/db-d1-sqlite/package.json b/packages/db-d1-sqlite/package.json
index aafdcb401ea..d4609f8a070 100644
--- a/packages/db-d1-sqlite/package.json
+++ b/packages/db-d1-sqlite/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-d1-sqlite",
- "version": "3.61.0",
+ "version": "3.61.1",
"description": "The officially supported D1 SQLite database adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {
diff --git a/packages/db-mongodb/package.json b/packages/db-mongodb/package.json
index d6bcc8b7742..eab6210d42c 100644
--- a/packages/db-mongodb/package.json
+++ b/packages/db-mongodb/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-mongodb",
- "version": "3.61.0",
+ "version": "3.61.1",
"description": "The officially supported MongoDB database adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {
diff --git a/packages/db-mongodb/src/queries/buildSearchParams.ts b/packages/db-mongodb/src/queries/buildSearchParams.ts
index a04587ecadc..b7f669051cd 100644
--- a/packages/db-mongodb/src/queries/buildSearchParams.ts
+++ b/packages/db-mongodb/src/queries/buildSearchParams.ts
@@ -114,7 +114,7 @@ export async function buildSearchParam({
const { operator: formattedOperator, rawQuery, val: formattedValue } = sanitizedQueryValue
- if (rawQuery) {
+ if (rawQuery && paths.length === 1) {
return { value: rawQuery }
}
diff --git a/packages/db-postgres/package.json b/packages/db-postgres/package.json
index 03b218186f7..812a1eab324 100644
--- a/packages/db-postgres/package.json
+++ b/packages/db-postgres/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-postgres",
- "version": "3.61.0",
+ "version": "3.61.1",
"description": "The officially supported Postgres database adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {
diff --git a/packages/db-sqlite/package.json b/packages/db-sqlite/package.json
index 6569a6f0f0b..731eafe45a7 100644
--- a/packages/db-sqlite/package.json
+++ b/packages/db-sqlite/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-sqlite",
- "version": "3.61.0",
+ "version": "3.61.1",
"description": "The officially supported SQLite database adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {
diff --git a/packages/db-vercel-postgres/package.json b/packages/db-vercel-postgres/package.json
index 5636e71bccc..e816605e2b4 100644
--- a/packages/db-vercel-postgres/package.json
+++ b/packages/db-vercel-postgres/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-vercel-postgres",
- "version": "3.61.0",
+ "version": "3.61.1",
"description": "Vercel Postgres adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {
diff --git a/packages/drizzle/package.json b/packages/drizzle/package.json
index 2090beea92d..dc47ceee6b2 100644
--- a/packages/drizzle/package.json
+++ b/packages/drizzle/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/drizzle",
- "version": "3.61.0",
+ "version": "3.61.1",
"description": "A library of shared functions used by different payload database adapters",
"homepage": "https://payloadcms.com",
"repository": {
diff --git a/packages/drizzle/src/queries/getTableColumnFromPath.ts b/packages/drizzle/src/queries/getTableColumnFromPath.ts
index 1a51104dffc..92056e23487 100644
--- a/packages/drizzle/src/queries/getTableColumnFromPath.ts
+++ b/packages/drizzle/src/queries/getTableColumnFromPath.ts
@@ -1,4 +1,4 @@
-import type { SQL } from 'drizzle-orm'
+import type { SQL, Table } from 'drizzle-orm'
import type { SQLiteTableWithColumns } from 'drizzle-orm/sqlite-core'
import type {
FlattenedBlock,
@@ -8,7 +8,7 @@ import type {
TextField,
} from 'payload'
-import { and, eq, getTableName, like, or, sql, Table } from 'drizzle-orm'
+import { and, eq, getTableName, like, or, sql } from 'drizzle-orm'
import { type PgTableWithColumns } from 'drizzle-orm/pg-core'
import { APIError, getFieldByPath } from 'payload'
import { fieldShouldBeLocalized, tabHasName } from 'payload/shared'
@@ -368,10 +368,16 @@ export const getTableColumnFromPath = ({
if (field.hasMany) {
const relationTableName = `${adapter.tableNameMap.get(toSnakeCase(field.collection))}${adapter.relationshipsSuffix}`
- const { newAliasTable: aliasRelationshipTable } = getTableAlias({
- adapter,
- tableName: relationTableName,
- })
+
+ const existingTable = joins.find(
+ (e) => e.queryPath === `${constraintPath}${field.name}._rels`,
+ )
+
+ const aliasRelationshipTable = (existingTable?.table ??
+ getTableAlias({
+ adapter,
+ tableName: relationTableName,
+ }).newAliasTable) as PgTableWithColumns
const relationshipField = getFieldByPath({
fields: adapter.payload.collections[field.collection].config.flattenedFields,
@@ -381,20 +387,22 @@ export const getTableColumnFromPath = ({
throw new APIError('Relationship was not found')
}
- addJoinTable({
- condition: and(
- eq(
- adapter.tables[rootTableName].id,
- aliasRelationshipTable[
- `${(relationshipField.field as RelationshipField).relationTo as string}ID`
- ],
+ if (!existingTable) {
+ addJoinTable({
+ condition: and(
+ eq(
+ adapter.tables[rootTableName].id,
+ aliasRelationshipTable[
+ `${(relationshipField.field as RelationshipField).relationTo as string}ID`
+ ],
+ ),
+ like(aliasRelationshipTable.path, field.on),
),
- like(aliasRelationshipTable.path, field.on),
- ),
- joins,
- queryPath: field.on,
- table: aliasRelationshipTable,
- })
+ joins,
+ queryPath: `${constraintPath}${field.name}._rels`,
+ table: aliasRelationshipTable,
+ })
+ }
if (newCollectionPath === 'id') {
return {
@@ -416,15 +424,23 @@ export const getTableColumnFromPath = ({
// parent to relationship join table
const relationshipFields = relationshipConfig.flattenedFields
- const { newAliasTable: relationshipTable } = getTableAlias({
- adapter,
- tableName: relationshipTableName,
- })
+ const existingMainTable = joins.find(
+ (e) => e.queryPath === `${constraintPath}${field.name}`,
+ )
- joins.push({
- condition: eq(aliasRelationshipTable.parent, relationshipTable.id),
- table: relationshipTable,
- })
+ const relationshipTable = (existingMainTable?.table ??
+ getTableAlias({
+ adapter,
+ tableName: relationshipTableName,
+ }).newAliasTable) as PgTableWithColumns
+
+ if (!existingMainTable) {
+ joins.push({
+ condition: eq(aliasRelationshipTable.parent, relationshipTable.id),
+ queryPath: `${constraintPath}${field.name}`,
+ table: relationshipTable,
+ })
+ }
return getTableColumnFromPath({
adapter,
@@ -448,15 +464,23 @@ export const getTableColumnFromPath = ({
const newTableName = adapter.tableNameMap.get(
toSnakeCase(adapter.payload.collections[field.collection].config.slug),
)
- const { newAliasTable } = getTableAlias({ adapter, tableName: newTableName })
-
- joins.push({
- condition: eq(
- newAliasTable[field.on.replaceAll('.', '_')],
- aliasTable ? aliasTable.id : adapter.tables[tableName].id,
- ),
- table: newAliasTable,
- })
+
+ const existingTable = joins.find(
+ (e) => e.queryPath === `${constraintPath}${field.name}`,
+ )?.table
+ const newAliasTable =
+ existingTable || getTableAlias({ adapter, tableName: newTableName }).newAliasTable
+
+ if (!existingTable) {
+ joins.push({
+ condition: eq(
+ newAliasTable[field.on.replaceAll('.', '_')],
+ aliasTable ? aliasTable.id : adapter.tables[tableName].id,
+ ),
+ queryPath: `${constraintPath}${field.name}`,
+ table: newAliasTable,
+ })
+ }
if (newCollectionPath === 'id') {
return {
diff --git a/packages/drizzle/src/schema/build.ts b/packages/drizzle/src/schema/build.ts
index 4a94013dc2d..64dcbb2b1f7 100644
--- a/packages/drizzle/src/schema/build.ts
+++ b/packages/drizzle/src/schema/build.ts
@@ -600,7 +600,7 @@ export const buildTable = ({
const relationshipForeignKeys: Record = {
parentFk: {
- name: buildIndexName({ name: `${relationshipsTableName}_parent`, adapter }),
+ name: buildForeignKeyName({ name: `${relationshipsTableName}_parent`, adapter }),
columns: ['parent'],
foreignColumns: [
{
diff --git a/packages/email-nodemailer/package.json b/packages/email-nodemailer/package.json
index 8a461e0d824..fb8af773c6d 100644
--- a/packages/email-nodemailer/package.json
+++ b/packages/email-nodemailer/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/email-nodemailer",
- "version": "3.61.0",
+ "version": "3.61.1",
"description": "Payload Nodemailer Email Adapter",
"homepage": "https://payloadcms.com",
"repository": {
diff --git a/packages/email-resend/package.json b/packages/email-resend/package.json
index 77e00e0bfa9..9647cfc09a8 100644
--- a/packages/email-resend/package.json
+++ b/packages/email-resend/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/email-resend",
- "version": "3.61.0",
+ "version": "3.61.1",
"description": "Payload Resend Email Adapter",
"homepage": "https://payloadcms.com",
"repository": {
diff --git a/packages/graphql/package.json b/packages/graphql/package.json
index adc88553638..8ba00510fe1 100644
--- a/packages/graphql/package.json
+++ b/packages/graphql/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/graphql",
- "version": "3.61.0",
+ "version": "3.61.1",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",
diff --git a/packages/live-preview-react/package.json b/packages/live-preview-react/package.json
index cd52c0f8080..6d1f3bce4a7 100644
--- a/packages/live-preview-react/package.json
+++ b/packages/live-preview-react/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/live-preview-react",
- "version": "3.61.0",
+ "version": "3.61.1",
"description": "The official React SDK for Payload Live Preview",
"homepage": "https://payloadcms.com",
"repository": {
diff --git a/packages/live-preview-vue/package.json b/packages/live-preview-vue/package.json
index be1cce60a26..1e067464ade 100644
--- a/packages/live-preview-vue/package.json
+++ b/packages/live-preview-vue/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/live-preview-vue",
- "version": "3.61.0",
+ "version": "3.61.1",
"description": "The official Vue SDK for Payload Live Preview",
"homepage": "https://payloadcms.com",
"repository": {
diff --git a/packages/live-preview/package.json b/packages/live-preview/package.json
index e9ad4a02a6f..6d70eb0baed 100644
--- a/packages/live-preview/package.json
+++ b/packages/live-preview/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/live-preview",
- "version": "3.61.0",
+ "version": "3.61.1",
"description": "The official live preview JavaScript SDK for Payload",
"homepage": "https://payloadcms.com",
"repository": {
diff --git a/packages/next/package.json b/packages/next/package.json
index 9741a94c6d1..72217e4f441 100644
--- a/packages/next/package.json
+++ b/packages/next/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/next",
- "version": "3.61.0",
+ "version": "3.61.1",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",
diff --git a/packages/next/src/layouts/Root/index.tsx b/packages/next/src/layouts/Root/index.tsx
index 7a0b58fedc5..397038ddee7 100644
--- a/packages/next/src/layouts/Root/index.tsx
+++ b/packages/next/src/layouts/Root/index.tsx
@@ -5,6 +5,7 @@ import { rtlLanguages } from '@payloadcms/translations'
import { ProgressBar, RootProvider } from '@payloadcms/ui'
import { getClientConfig } from '@payloadcms/ui/utilities/getClientConfig'
import { cookies as nextCookies } from 'next/headers.js'
+import { applyLocaleFiltering } from 'payload/shared'
import React from 'react'
import { getNavPrefs } from '../../elements/Nav/getNavPrefs.js'
@@ -87,20 +88,7 @@ export const RootLayout = async ({
importMap,
user: req.user,
})
-
- if (
- clientConfig.localization &&
- config.localization &&
- typeof config.localization.filterAvailableLocales === 'function'
- ) {
- clientConfig.localization.locales = (
- await config.localization.filterAvailableLocales({
- locales: config.localization.locales,
- req,
- })
- ).map(({ toString, ...rest }) => rest)
- clientConfig.localization.localeCodes = config.localization.locales.map(({ code }) => code)
- }
+ await applyLocaleFiltering({ clientConfig, config, req })
return (
`${collectionSlug}-${id}`,
),
items,
+ noResults: !Array.isArray(items) || items.length === 0,
parentFieldName: '_parentDoc',
search,
TreeViewComponent,
diff --git a/packages/next/src/views/Document/handleServerFunction.tsx b/packages/next/src/views/Document/handleServerFunction.tsx
index 2da9aa5259d..85aec01539e 100644
--- a/packages/next/src/views/Document/handleServerFunction.tsx
+++ b/packages/next/src/views/Document/handleServerFunction.tsx
@@ -4,6 +4,7 @@ import type { DocumentPreferences, VisibleEntities } from 'payload'
import { getClientConfig } from '@payloadcms/ui/utilities/getClientConfig'
import { headers as getHeaders } from 'next/headers.js'
import { canAccessAdmin, getAccessResults, isEntityHidden, parseCookies } from 'payload'
+import { applyLocaleFiltering } from 'payload/shared'
import { renderDocument } from './index.js'
@@ -43,6 +44,7 @@ export const renderDocumentHandler: RenderDocumentServerFunction = async (args)
importMap: req.payload.importMap,
user,
})
+ await applyLocaleFiltering({ clientConfig, config, req })
let preferences: DocumentPreferences
diff --git a/packages/next/src/views/List/handleServerFunction.tsx b/packages/next/src/views/List/handleServerFunction.tsx
index 27fb8e654c3..d30e332a5a7 100644
--- a/packages/next/src/views/List/handleServerFunction.tsx
+++ b/packages/next/src/views/List/handleServerFunction.tsx
@@ -3,6 +3,7 @@ import type { CollectionPreferences, ListQuery, ServerFunction, VisibleEntities
import { getClientConfig } from '@payloadcms/ui/utilities/getClientConfig'
import { headers as getHeaders } from 'next/headers.js'
import { canAccessAdmin, getAccessResults, isEntityHidden, parseCookies } from 'payload'
+import { applyLocaleFiltering } from 'payload/shared'
import { renderListView } from './index.js'
@@ -61,6 +62,7 @@ export const renderListHandler: ServerFunction<
importMap: payload.importMap,
user,
})
+ await applyLocaleFiltering({ clientConfig, config, req })
const preferencesKey = `collection-${collectionSlug}`
diff --git a/packages/next/src/views/Root/index.tsx b/packages/next/src/views/Root/index.tsx
index ccba83f5648..2d9ae8a33a6 100644
--- a/packages/next/src/views/Root/index.tsx
+++ b/packages/next/src/views/Root/index.tsx
@@ -14,7 +14,7 @@ import { PageConfigProvider } from '@payloadcms/ui'
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
import { getClientConfig } from '@payloadcms/ui/utilities/getClientConfig'
import { notFound, redirect } from 'next/navigation.js'
-import { formatAdminURL } from 'payload/shared'
+import { applyLocaleFiltering, formatAdminURL } from 'payload/shared'
import * as qs from 'qs-esm'
import React from 'react'
@@ -253,6 +253,7 @@ export const RootPage = async ({
importMap,
user: viewType === 'createFirstUser' ? true : req.user,
})
+ await applyLocaleFiltering({ clientConfig, config, req })
const visibleEntities = getVisibleEntities({ req })
diff --git a/packages/next/src/views/Version/index.tsx b/packages/next/src/views/Version/index.tsx
index fdc0446b57e..693300e2698 100644
--- a/packages/next/src/views/Version/index.tsx
+++ b/packages/next/src/views/Version/index.tsx
@@ -213,7 +213,12 @@ export async function VersionView(props: DocumentViewServerProps) {
const clientSchemaMap = getClientSchemaMap({
collectionSlug,
- config: getClientConfig({ config: payload.config, i18n, importMap: payload.importMap, user }),
+ config: getClientConfig({
+ config: payload.config,
+ i18n,
+ importMap: payload.importMap,
+ user,
+ }),
globalSlug,
i18n,
payload,
diff --git a/packages/payload-cloud/package.json b/packages/payload-cloud/package.json
index a53b448114b..140895e3c6c 100644
--- a/packages/payload-cloud/package.json
+++ b/packages/payload-cloud/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/payload-cloud",
- "version": "3.61.0",
+ "version": "3.61.1",
"description": "The official Payload Cloud plugin",
"homepage": "https://payloadcms.com",
"repository": {
diff --git a/packages/payload/package.json b/packages/payload/package.json
index d65e876c298..16619a40b01 100644
--- a/packages/payload/package.json
+++ b/packages/payload/package.json
@@ -1,6 +1,6 @@
{
"name": "payload",
- "version": "3.61.0",
+ "version": "3.61.1",
"description": "Node, React, Headless CMS and Application Framework built on Next.js",
"keywords": [
"admin panel",
diff --git a/packages/payload/src/config/client.ts b/packages/payload/src/config/client.ts
index 961076a3161..05d4327e38d 100644
--- a/packages/payload/src/config/client.ts
+++ b/packages/payload/src/config/client.ts
@@ -4,6 +4,7 @@ import type { DeepPartial } from 'ts-essentials'
import type { ImportMap } from '../bin/generateImportMap/index.js'
import type { ClientBlock } from '../fields/config/types.js'
import type { BlockSlug, TypedUser } from '../index.js'
+import type { PayloadRequest } from '../types/index.js'
import type {
RootLivePreviewConfig,
SanitizedConfig,
diff --git a/packages/payload/src/exports/shared.ts b/packages/payload/src/exports/shared.ts
index 7031d2c20f1..95f6fd10d25 100644
--- a/packages/payload/src/exports/shared.ts
+++ b/packages/payload/src/exports/shared.ts
@@ -39,8 +39,8 @@ export {
} from '../fields/config/types.js'
export { getFieldPaths } from '../fields/getFieldPaths.js'
-export * from '../fields/validations.js'
+export * from '../fields/validations.js'
export type {
FolderBreadcrumb,
FolderDocumentItemKey,
@@ -52,15 +52,17 @@ export type {
} from '../folders/types.js'
export { buildFolderWhereConstraints } from '../folders/utils/buildFolderWhereConstraints.js'
+
export { formatFolderOrDocumentItem } from '../folders/utils/formatFolderOrDocumentItem.js'
export type { TreeViewItem, TreeViewItemKey } from '../treeView/types.js'
export { validOperators, validOperatorSet } from '../types/constants.js'
-
export { formatFilesize } from '../uploads/formatFilesize.js'
export { isImage } from '../uploads/isImage.js'
+
export { appendUploadSelectFields } from '../utilities/appendUploadSelectFields.js'
+export { applyLocaleFiltering } from '../utilities/applyLocaleFiltering.js'
export { combineWhereConstraints } from '../utilities/combineWhereConstraints.js'
export {
diff --git a/packages/payload/src/utilities/applyLocaleFiltering.ts b/packages/payload/src/utilities/applyLocaleFiltering.ts
new file mode 100644
index 00000000000..74c59eb4627
--- /dev/null
+++ b/packages/payload/src/utilities/applyLocaleFiltering.ts
@@ -0,0 +1,31 @@
+import type { ClientConfig } from '../config/client.js'
+import type { SanitizedConfig } from '../config/types.js'
+import type { PayloadRequest } from '../types/index.js'
+
+export async function applyLocaleFiltering({
+ clientConfig,
+ config,
+ req,
+}: {
+ clientConfig: ClientConfig
+ config: SanitizedConfig
+ req: PayloadRequest
+}): Promise {
+ if (
+ !clientConfig.localization ||
+ !config.localization ||
+ typeof config.localization.filterAvailableLocales !== 'function'
+ ) {
+ return
+ }
+
+ const filteredLocales = (
+ await config.localization.filterAvailableLocales({
+ locales: config.localization.locales,
+ req,
+ })
+ ).map(({ toString, ...rest }) => rest)
+
+ clientConfig.localization.localeCodes = filteredLocales.map(({ code }) => code)
+ clientConfig.localization.locales = filteredLocales
+}
diff --git a/packages/plugin-cloud-storage/package.json b/packages/plugin-cloud-storage/package.json
index 59745c7052e..a4e6f9c34ce 100644
--- a/packages/plugin-cloud-storage/package.json
+++ b/packages/plugin-cloud-storage/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-cloud-storage",
- "version": "3.61.0",
+ "version": "3.61.1",
"description": "The official cloud storage plugin for Payload CMS",
"homepage": "https://payloadcms.com",
"repository": {
diff --git a/packages/plugin-ecommerce/package.json b/packages/plugin-ecommerce/package.json
index da9a80a99fa..a2806820940 100644
--- a/packages/plugin-ecommerce/package.json
+++ b/packages/plugin-ecommerce/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-ecommerce",
- "version": "3.61.0",
+ "version": "3.61.1",
"description": "Ecommerce plugin for Payload",
"keywords": [
"payload",
diff --git a/packages/plugin-form-builder/package.json b/packages/plugin-form-builder/package.json
index cabda3cfbfb..5f8fee6fc75 100644
--- a/packages/plugin-form-builder/package.json
+++ b/packages/plugin-form-builder/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-form-builder",
- "version": "3.61.0",
+ "version": "3.61.1",
"description": "Form builder plugin for Payload CMS",
"keywords": [
"payload",
diff --git a/packages/plugin-import-export/package.json b/packages/plugin-import-export/package.json
index 1de3ada173d..5aeb999a224 100644
--- a/packages/plugin-import-export/package.json
+++ b/packages/plugin-import-export/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-import-export",
- "version": "3.61.0",
+ "version": "3.61.1",
"description": "Import-Export plugin for Payload",
"keywords": [
"payload",
diff --git a/packages/plugin-mcp/package.json b/packages/plugin-mcp/package.json
index 34791d700b2..49c8239971e 100644
--- a/packages/plugin-mcp/package.json
+++ b/packages/plugin-mcp/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-mcp",
- "version": "3.61.0",
+ "version": "3.61.1",
"description": "MCP (Model Context Protocol) capabilities with Payload",
"keywords": [
"plugin",
diff --git a/packages/plugin-multi-tenant/package.json b/packages/plugin-multi-tenant/package.json
index 825d75bac82..a9bc0d41f3e 100644
--- a/packages/plugin-multi-tenant/package.json
+++ b/packages/plugin-multi-tenant/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-multi-tenant",
- "version": "3.61.0",
+ "version": "3.61.1",
"description": "Multi Tenant plugin for Payload",
"keywords": [
"payload",
diff --git a/packages/plugin-nested-docs/package.json b/packages/plugin-nested-docs/package.json
index dbc7720a0d7..5fa06da0cba 100644
--- a/packages/plugin-nested-docs/package.json
+++ b/packages/plugin-nested-docs/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-nested-docs",
- "version": "3.61.0",
+ "version": "3.61.1",
"description": "The official Nested Docs plugin for Payload",
"homepage": "https://payloadcms.com",
"repository": {
diff --git a/packages/plugin-redirects/package.json b/packages/plugin-redirects/package.json
index 37248aefea9..dc348ea5762 100644
--- a/packages/plugin-redirects/package.json
+++ b/packages/plugin-redirects/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-redirects",
- "version": "3.61.0",
+ "version": "3.61.1",
"description": "Redirects plugin for Payload",
"keywords": [
"payload",
diff --git a/packages/plugin-search/package.json b/packages/plugin-search/package.json
index ae0097798c8..b4e6b949867 100644
--- a/packages/plugin-search/package.json
+++ b/packages/plugin-search/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-search",
- "version": "3.61.0",
+ "version": "3.61.1",
"description": "Search plugin for Payload",
"keywords": [
"payload",
diff --git a/packages/plugin-sentry/package.json b/packages/plugin-sentry/package.json
index 645df597491..106d03c25e5 100644
--- a/packages/plugin-sentry/package.json
+++ b/packages/plugin-sentry/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-sentry",
- "version": "3.61.0",
+ "version": "3.61.1",
"description": "Sentry plugin for Payload",
"keywords": [
"payload",
diff --git a/packages/plugin-seo/package.json b/packages/plugin-seo/package.json
index 6fb6380281e..a9484e9e459 100644
--- a/packages/plugin-seo/package.json
+++ b/packages/plugin-seo/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-seo",
- "version": "3.61.0",
+ "version": "3.61.1",
"description": "SEO plugin for Payload",
"keywords": [
"payload",
diff --git a/packages/plugin-stripe/package.json b/packages/plugin-stripe/package.json
index a98f4f2567a..c8e71961979 100644
--- a/packages/plugin-stripe/package.json
+++ b/packages/plugin-stripe/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-stripe",
- "version": "3.61.0",
+ "version": "3.61.1",
"description": "Stripe plugin for Payload",
"keywords": [
"payload",
diff --git a/packages/richtext-lexical/package.json b/packages/richtext-lexical/package.json
index 852cbb2b11d..3932f09a627 100644
--- a/packages/richtext-lexical/package.json
+++ b/packages/richtext-lexical/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/richtext-lexical",
- "version": "3.61.0",
+ "version": "3.61.1",
"description": "The officially supported Lexical richtext adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {
diff --git a/packages/richtext-slate/package.json b/packages/richtext-slate/package.json
index cfc9f845e2a..612c8d4fe1f 100644
--- a/packages/richtext-slate/package.json
+++ b/packages/richtext-slate/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/richtext-slate",
- "version": "3.61.0",
+ "version": "3.61.1",
"description": "The officially supported Slate richtext adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {
diff --git a/packages/sdk/package.json b/packages/sdk/package.json
index 99949c8db70..dc94d86664a 100644
--- a/packages/sdk/package.json
+++ b/packages/sdk/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/sdk",
- "version": "3.61.0",
+ "version": "3.61.1",
"description": "The official Payload REST API SDK",
"homepage": "https://payloadcms.com",
"repository": {
diff --git a/packages/storage-azure/package.json b/packages/storage-azure/package.json
index 9fc7387d743..da98ff49164 100644
--- a/packages/storage-azure/package.json
+++ b/packages/storage-azure/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/storage-azure",
- "version": "3.61.0",
+ "version": "3.61.1",
"description": "Payload storage adapter for Azure Blob Storage",
"homepage": "https://payloadcms.com",
"repository": {
diff --git a/packages/storage-gcs/package.json b/packages/storage-gcs/package.json
index c60c9727548..31842e87b75 100644
--- a/packages/storage-gcs/package.json
+++ b/packages/storage-gcs/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/storage-gcs",
- "version": "3.61.0",
+ "version": "3.61.1",
"description": "Payload storage adapter for Google Cloud Storage",
"homepage": "https://payloadcms.com",
"repository": {
diff --git a/packages/storage-r2/package.json b/packages/storage-r2/package.json
index c397441200f..f0bf8500252 100644
--- a/packages/storage-r2/package.json
+++ b/packages/storage-r2/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/storage-r2",
- "version": "3.61.0",
+ "version": "3.61.1",
"description": "Payload storage adapter for Cloudflare R2",
"homepage": "https://payloadcms.com",
"repository": {
diff --git a/packages/storage-s3/package.json b/packages/storage-s3/package.json
index 3518839938a..416050a17a9 100644
--- a/packages/storage-s3/package.json
+++ b/packages/storage-s3/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/storage-s3",
- "version": "3.61.0",
+ "version": "3.61.1",
"description": "Payload storage adapter for Amazon S3",
"homepage": "https://payloadcms.com",
"repository": {
diff --git a/packages/storage-uploadthing/package.json b/packages/storage-uploadthing/package.json
index db3826f5d95..03363f5b539 100644
--- a/packages/storage-uploadthing/package.json
+++ b/packages/storage-uploadthing/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/storage-uploadthing",
- "version": "3.61.0",
+ "version": "3.61.1",
"description": "Payload storage adapter for uploadthing",
"homepage": "https://payloadcms.com",
"repository": {
diff --git a/packages/storage-vercel-blob/package.json b/packages/storage-vercel-blob/package.json
index ba6fb4aff85..21046926582 100644
--- a/packages/storage-vercel-blob/package.json
+++ b/packages/storage-vercel-blob/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/storage-vercel-blob",
- "version": "3.61.0",
+ "version": "3.61.1",
"description": "Payload storage adapter for Vercel Blob Storage",
"homepage": "https://payloadcms.com",
"repository": {
diff --git a/packages/translations/package.json b/packages/translations/package.json
index ef6c384e559..548e7648860 100644
--- a/packages/translations/package.json
+++ b/packages/translations/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/translations",
- "version": "3.61.0",
+ "version": "3.61.1",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 978092ff5ed..326b809fc13 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -1,6 +1,6 @@
{
"name": "@payloadcms/ui",
- "version": "3.61.0",
+ "version": "3.61.1",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",
diff --git a/packages/ui/src/elements/DocumentDrawer/DrawerHeader/index.tsx b/packages/ui/src/elements/DocumentDrawer/DrawerHeader/index.tsx
index b8c620c2530..d2bd9042a39 100644
--- a/packages/ui/src/elements/DocumentDrawer/DrawerHeader/index.tsx
+++ b/packages/ui/src/elements/DocumentDrawer/DrawerHeader/index.tsx
@@ -1,23 +1,38 @@
'use client'
+import { useCallback } from 'react'
+
import { Gutter } from '../../../elements/Gutter/index.js'
import { useModal } from '../../../elements/Modal/index.js'
import { RenderTitle } from '../../../elements/RenderTitle/index.js'
+import { useFormModified } from '../../../forms/Form/index.js'
import { XIcon } from '../../../icons/X/index.js'
import { useDocumentInfo } from '../../../providers/DocumentInfo/index.js'
import { useDocumentTitle } from '../../../providers/DocumentTitle/index.js'
import { useTranslation } from '../../../providers/Translation/index.js'
import { IDLabel } from '../../IDLabel/index.js'
+import { LeaveWithoutSavingModal } from '../../LeaveWithoutSaving/index.js'
import { documentDrawerBaseClass } from '../index.js'
import './index.scss'
+const leaveWithoutSavingModalSlug = 'leave-without-saving-doc-drawer'
+
export const DocumentDrawerHeader: React.FC<{
AfterHeader?: React.ReactNode
drawerSlug: string
showDocumentID?: boolean
}> = ({ AfterHeader, drawerSlug, showDocumentID = true }) => {
- const { closeModal } = useModal()
+ const { closeModal, openModal } = useModal()
const { t } = useTranslation()
+ const isModified = useFormModified()
+
+ const handleOnClose = useCallback(() => {
+ if (isModified) {
+ openModal(leaveWithoutSavingModalSlug)
+ } else {
+ closeModal(drawerSlug)
+ }
+ }, [isModified, openModal, closeModal, drawerSlug])
return (
@@ -28,7 +43,7 @@ export const DocumentDrawerHeader: React.FC<{
)
}
diff --git a/packages/ui/src/elements/LeaveWithoutSaving/index.tsx b/packages/ui/src/elements/LeaveWithoutSaving/index.tsx
index 017cec5c388..e75ec730f36 100644
--- a/packages/ui/src/elements/LeaveWithoutSaving/index.tsx
+++ b/packages/ui/src/elements/LeaveWithoutSaving/index.tsx
@@ -10,22 +10,28 @@ import { ConfirmationModal } from '../ConfirmationModal/index.js'
import { useModal } from '../Modal/index.js'
import { usePreventLeave } from './usePreventLeave.js'
-const modalSlug = 'leave-without-saving'
-
type LeaveWithoutSavingProps = {
+ disablePreventLeave?: boolean
+ modalSlug?: string
onConfirm?: () => Promise | void
onPrevent?: (nextHref: null | string) => void
}
-export const LeaveWithoutSaving: React.FC = ({ onConfirm, onPrevent }) => {
+const leaveWithoutSavingModalSlug = 'leave-without-saving'
+
+export const LeaveWithoutSaving: React.FC = ({
+ disablePreventLeave = false,
+ onConfirm,
+ onPrevent,
+}) => {
+ const modalSlug = leaveWithoutSavingModalSlug
const { closeModal, openModal } = useModal()
const modified = useFormModified()
const { isValid } = useForm()
const { user } = useAuth()
const [hasAccepted, setHasAccepted] = React.useState(false)
- const { t } = useTranslation()
- const prevent = Boolean((modified || !isValid) && user)
+ const prevent = !disablePreventLeave && Boolean((modified || !isValid) && user)
const handlePrevent = useCallback(() => {
const activeHref = (document.activeElement as HTMLAnchorElement)?.href || null
@@ -33,17 +39,17 @@ export const LeaveWithoutSaving: React.FC = ({ onConfir
onPrevent(activeHref)
}
openModal(modalSlug)
- }, [openModal, onPrevent])
+ }, [openModal, onPrevent, modalSlug])
const handleAccept = useCallback(() => {
closeModal(modalSlug)
- }, [closeModal])
+ }, [closeModal, modalSlug])
usePreventLeave({ hasAccepted, onAccept: handleAccept, onPrevent: handlePrevent, prevent })
const onCancel: OnCancel = useCallback(() => {
closeModal(modalSlug)
- }, [closeModal])
+ }, [closeModal, modalSlug])
const handleConfirm = useCallback(async () => {
if (onConfirm) {
@@ -56,6 +62,22 @@ export const LeaveWithoutSaving: React.FC = ({ onConfir
setHasAccepted(true)
}, [onConfirm])
+ return (
+
+ )
+}
+
+export const LeaveWithoutSavingModal = ({
+ modalSlug,
+ onCancel,
+ onConfirm,
+}: {
+ modalSlug: string
+ onCancel?: OnCancel
+ onConfirm: () => Promise | void
+}) => {
+ const { t } = useTranslation()
+
return (
= ({ onConfir
heading={t('general:leaveWithoutSaving')}
modalSlug={modalSlug}
onCancel={onCancel}
- onConfirm={handleConfirm}
+ onConfirm={onConfirm}
/>
)
}
diff --git a/packages/ui/src/elements/LeaveWithoutSaving/usePreventLeave.tsx b/packages/ui/src/elements/LeaveWithoutSaving/usePreventLeave.tsx
index f8c029741df..f2924abb43d 100644
--- a/packages/ui/src/elements/LeaveWithoutSaving/usePreventLeave.tsx
+++ b/packages/ui/src/elements/LeaveWithoutSaving/usePreventLeave.tsx
@@ -143,8 +143,10 @@ export const usePreventLeave = ({
}
}
- // Add the global click event listener
- document.addEventListener('click', handleClick, true)
+ if (prevent) {
+ // Add the global click event listener
+ document.addEventListener('click', handleClick, true)
+ }
// Clean up the global click event listener when the component is unmounted
return () => {
diff --git a/packages/ui/src/elements/QueryPresets/cells/WhereCell/index.tsx b/packages/ui/src/elements/QueryPresets/cells/WhereCell/index.tsx
index d7c875aee1e..d06f81d9c95 100644
--- a/packages/ui/src/elements/QueryPresets/cells/WhereCell/index.tsx
+++ b/packages/ui/src/elements/QueryPresets/cells/WhereCell/index.tsx
@@ -22,7 +22,11 @@ const transformWhereToNaturalLanguage = (where: Where): string => {
const operator = Object.keys(andQuery[key])[0]
const value = andQuery[key][operator]
- return `${toWords(key)} ${operator} ${toWords(value)}`
+ if (typeof value === 'string') {
+ return `${toWords(key)} ${operator} ${toWords(value)}`
+ } else if (Array.isArray(value)) {
+ return `${toWords(key)} ${operator} ${value.map((val) => toWords(val)).join(' or ')}`
+ }
}
return ''
diff --git a/packages/ui/src/elements/TreeView/NestedSectionsTable/NestedItems/index.tsx b/packages/ui/src/elements/TreeView/NestedSectionsTable/NestedItems/index.tsx
index 356df3113ad..09da56fcaf2 100644
--- a/packages/ui/src/elements/TreeView/NestedSectionsTable/NestedItems/index.tsx
+++ b/packages/ui/src/elements/TreeView/NestedSectionsTable/NestedItems/index.tsx
@@ -10,7 +10,7 @@ interface ItemWithChildrenProps {
firstCellRef?: React.RefObject
firstCellWidth: number
firstCellXOffset: number
- focusedItemIndex?: number
+ focusedItemKey?: ItemKey | null
hasSelectedAncestor?: boolean
hoveredItemKey: ItemKey | null
isDragging: boolean
@@ -23,10 +23,8 @@ interface ItemWithChildrenProps {
placement?: string
targetItem: null | SectionItem
}) => void
- onFocusChange: (focusedIndex: number) => void
- onItemDrag: (params: { event: PointerEvent; item: null | SectionItem }) => void
- onItemKeyPress: (params: { event: React.KeyboardEvent; item: SectionItem }) => void
- onSelectionChange: ({
+ onFocusChange: (indexPath: number[]) => void
+ onItemClick: ({
itemKey,
options,
}: {
@@ -37,8 +35,11 @@ interface ItemWithChildrenProps {
shiftKey: boolean
}
}) => void
+ onItemDrag: (params: { event: PointerEvent; item: null | SectionItem }) => void
+ onItemKeyDown: (params: { event: React.KeyboardEvent; item: SectionItem }) => void
openItemKeys?: Set
parentIndex?: number
+ parentIndexPath: number[]
parentItems?: SectionItem[]
segmentWidth: number
selectedItemKeys?: Set
@@ -52,7 +53,7 @@ export const NestedItems: React.FC = ({
firstCellRef,
firstCellWidth,
firstCellXOffset,
- focusedItemIndex,
+ focusedItemKey,
hasSelectedAncestor = false,
hoveredItemKey,
isDragging,
@@ -62,11 +63,12 @@ export const NestedItems: React.FC = ({
loadingItemKeys,
onDroppableHover,
onFocusChange,
+ onItemClick,
onItemDrag,
- onItemKeyPress,
- onSelectionChange,
+ onItemKeyDown,
openItemKeys,
parentIndex = 0,
+ parentIndexPath = [],
parentItems = [],
segmentWidth,
selectedItemKeys = new Set(),
@@ -97,15 +99,14 @@ export const NestedItems: React.FC = ({
return (
<>
{items.map((sectionItem, sectionItemIndex: number) => {
+ const itemIndexPath = [...parentIndexPath, sectionItemIndex]
const absoluteItemIndex = getAbsoluteItemIndex(sectionItemIndex)
const isLastSiblingItem = items.length - 1 === sectionItemIndex
const hasNestedItems =
Boolean(sectionItem?.rows?.length) && openItemKeys?.has(sectionItem.itemKey)
const isItemSelected = selectedItemKeys.has(sectionItem.itemKey)
- const isInvalidTarget = hasSelectedAncestor || isItemSelected
const isItemAtRootLevel = level === 0 || (isLastSiblingItem && isLastItemOfRoot)
- const isFirstItemAtRootLevel = level === 0 && sectionItemIndex === 0
// Calculate drop target items based on position in hierarchy
let targetItems: (null | SectionItem)[] = []
@@ -144,20 +145,20 @@ export const NestedItems: React.FC = ({
firstCellWidth={firstCellWidth}
firstCellXOffset={firstCellXOffset}
hasSelectedAncestor={hasSelectedAncestor}
+ indexPath={itemIndexPath}
isDragging={isDragging}
- isFirstRootItem={isFirstItemAtRootLevel}
- isFocused={focusedItemIndex !== undefined && focusedItemIndex === absoluteItemIndex}
+ isFocused={focusedItemKey !== undefined && focusedItemKey === sectionItem.itemKey}
isHovered={hoveredItemKey === sectionItem.itemKey}
- isInvalidTarget={isInvalidTarget}
+ isInvalidTarget={hasSelectedAncestor || isItemSelected}
isSelected={isItemSelected}
item={sectionItem}
level={level}
loadingItemKeys={loadingItemKeys}
- onClick={onSelectionChange}
+ onClick={onItemClick}
onDrag={onItemDrag}
onDroppableHover={onDroppableHover}
onFocusChange={onFocusChange}
- onKeyPress={onItemKeyPress}
+ onKeyDown={onItemKeyDown}
openItemKeys={openItemKeys}
segmentWidth={segmentWidth}
selectedItemKeys={selectedItemKeys}
@@ -165,6 +166,8 @@ export const NestedItems: React.FC = ({
targetItems={targetItems}
targetParentItemKey={targetParentItemKey}
toggleExpand={toggleItemExpand}
+ // isFirstSiblingItem={isFirstSiblingItem}
+ // isLastSiblingItem={isLastSiblingItem}
/>
{hasNestedItems && sectionItem.rows && (
@@ -173,7 +176,7 @@ export const NestedItems: React.FC = ({
dropContextName={dropContextName}
firstCellWidth={firstCellWidth}
firstCellXOffset={firstCellXOffset}
- focusedItemIndex={focusedItemIndex}
+ focusedItemKey={focusedItemKey}
hasSelectedAncestor={hasSelectedAncestor || isItemSelected}
hoveredItemKey={hoveredItemKey}
isDragging={isDragging}
@@ -183,11 +186,12 @@ export const NestedItems: React.FC = ({
loadingItemKeys={loadingItemKeys}
onDroppableHover={onDroppableHover}
onFocusChange={onFocusChange}
+ onItemClick={onItemClick}
onItemDrag={onItemDrag}
- onItemKeyPress={onItemKeyPress}
- onSelectionChange={onSelectionChange}
+ onItemKeyDown={onItemKeyDown}
openItemKeys={openItemKeys}
parentIndex={absoluteItemIndex + 1}
+ parentIndexPath={itemIndexPath}
parentItems={[...parentItems, sectionItem]}
segmentWidth={segmentWidth}
selectedItemKeys={selectedItemKeys}
diff --git a/packages/ui/src/elements/TreeView/NestedSectionsTable/Row/index.tsx b/packages/ui/src/elements/TreeView/NestedSectionsTable/Row/index.tsx
index a8bec7275ef..04b2b5f212e 100644
--- a/packages/ui/src/elements/TreeView/NestedSectionsTable/Row/index.tsx
+++ b/packages/ui/src/elements/TreeView/NestedSectionsTable/Row/index.tsx
@@ -26,8 +26,8 @@ interface DivTableRowProps {
firstCellWidth: number
firstCellXOffset: number
hasSelectedAncestor: boolean
+ indexPath: number[]
isDragging: boolean
- isFirstRootItem: boolean
isFocused: boolean
isHovered: boolean
isInvalidTarget: boolean
@@ -45,8 +45,12 @@ interface DivTableRowProps {
}) => void
onDrag: (params: { event: PointerEvent; item: null | SectionItem }) => void
onDroppableHover: (params: { hoveredItemKey?: ItemKey; targetItem: null | SectionItem }) => void
- onFocusChange: (focusedIndex: number) => void
- onKeyPress: (params: { event: React.KeyboardEvent; item: SectionItem }) => void
+ onFocusChange: (indexPath: number[]) => void
+ onKeyDown: (params: {
+ event: React.KeyboardEvent
+ indexPath: number[]
+ item: SectionItem
+ }) => void
openItemKeys: Set
segmentWidth: number
selectedItemKeys: Set
@@ -64,8 +68,8 @@ export const Row: React.FC = ({
firstCellWidth,
firstCellXOffset,
hasSelectedAncestor,
+ indexPath,
isDragging,
- isFirstRootItem,
isFocused,
isHovered,
isInvalidTarget,
@@ -77,7 +81,7 @@ export const Row: React.FC = ({
onDrag,
onDroppableHover,
onFocusChange,
- onKeyPress,
+ onKeyDown,
openItemKeys,
segmentWidth,
selectedItemKeys,
@@ -131,12 +135,12 @@ export const Row: React.FC = ({
onClick={handleClick}
onFocus={(e) => {
if (e.target === e.currentTarget && !isFocused) {
- onFocusChange(absoluteIndex)
+ onFocusChange(indexPath)
}
}}
onKeyDown={(event) => {
if (event.target === event.currentTarget) {
- onKeyPress({ event, item })
+ onKeyDown({ event, indexPath, item })
}
}}
onMouseDown={(e) => {
@@ -235,7 +239,7 @@ export const Row: React.FC = ({
{/* Add split-top drop area for first root-level row */}
- {isFirstRootItem && (
+ {absoluteIndex === 0 && (
= ({
- canFocusItem,
className = '',
columns = [{ name: 'name', label: 'Name' }],
dropContextName,
@@ -36,74 +41,17 @@ export const NestedSectionsTable: React.FC = ({
const [firstCellWidth, setFirstCellWidth] = React.useState(0)
const firstCellRef = React.useRef(null)
- const totalVisibleItems = React.useMemo(() => {
- if (!rootItems) {
- return 0
- }
-
- const countVisibleItems = (items: SectionItem[]): number => {
- let count = 0
- for (const item of items) {
- count++
- if (item.rows && openItemKeys?.has(item.itemKey)) {
- count += countVisibleItems(item.rows)
- }
- }
- return count
- }
-
- return countVisibleItems(rootItems)
- }, [rootItems, openItemKeys])
-
- // Get row at a specific visible index
- const getItemFromIndex = React.useCallback(
- (targetIndex: number): SectionItem | undefined => {
- if (!rootItems) {
- return undefined
- }
-
- const findItemAtIndex = ({
- currentIndex,
- items,
- targetIndex,
- }: {
- currentIndex: number
- items: SectionItem[]
- targetIndex: number
- }): SectionItem | undefined => {
- for (const item of items) {
- if (currentIndex === targetIndex) {
- return item
- }
- currentIndex++
- if (item.rows && openItemKeys?.has(item.itemKey)) {
- const found = findItemAtIndex({ currentIndex, items: item.rows, targetIndex })
- if (found) {
- return found
- }
- }
- }
- return undefined
- }
-
- return findItemAtIndex({ currentIndex: 0, items: rootItems, targetIndex })
- },
- [rootItems, openItemKeys],
- )
-
- // Get visual index from row ID
const getIndexFromItemKey = React.useCallback(
(itemKey: ItemKey | null): number => {
if (itemKey === null) {
return -1
}
+ let currentIndex = 0
const findIndex = ({
- currentIndex,
items,
targetItemKey,
}: {
- currentIndex: number
items: SectionItem[]
targetItemKey: ItemKey | null
}): number => {
@@ -113,7 +61,7 @@ export const NestedSectionsTable: React.FC = ({
}
currentIndex++
if (item.rows && openItemKeys?.has(item.itemKey)) {
- const found = findIndex({ currentIndex, items: item.rows, targetItemKey })
+ const found = findIndex({ items: item.rows, targetItemKey })
if (found !== -1) {
return found
}
@@ -122,9 +70,7 @@ export const NestedSectionsTable: React.FC = ({
return -1
}
- return rootItems
- ? findIndex({ currentIndex: 0, items: rootItems, targetItemKey: itemKey })
- : -1
+ return rootItems ? findIndex({ items: rootItems, targetItemKey: itemKey }) : -1
},
[rootItems, openItemKeys],
)
@@ -135,7 +81,7 @@ export const NestedSectionsTable: React.FC = ({
[focusedItemKey, getIndexFromItemKey],
)
- const onSelectionChange = React.useCallback(
+ const onItemClick = React.useCallback(
({
itemKey,
options,
@@ -152,65 +98,42 @@ export const NestedSectionsTable: React.FC = ({
if (shiftKey) {
// Shift selection: select range from anchor to current item
// Set anchor if not set
- if (selectionAnchorItemKey === null) {
- setSelectionAnchorItemKey(itemKey)
- // Just select this item as the starting point
- updateSelections({ itemKeys: [itemKey] })
- return
+ const anchorItemKey = selectionAnchorItemKey || itemKey
+ if (!selectionAnchorItemKey) {
+ setSelectionAnchorItemKey(anchorItemKey)
}
- // Find indexes for anchor and current item
- const anchorIndex = getIndexFromItemKey(selectionAnchorItemKey)
- const currentIndex = getIndexFromItemKey(itemKey)
-
- if (anchorIndex === -1 || currentIndex === -1) {
- // Fallback to simple selection if indexes not found
- updateSelections({ itemKeys: [itemKey] })
- return
- }
-
- // Collect all focusable items in the range
- const startIndex = Math.min(anchorIndex, currentIndex)
- const endIndex = Math.max(anchorIndex, currentIndex)
- const rangeItemKeys: Array<`${string}-${number | string}`> = []
-
- for (let i = startIndex; i <= endIndex; i++) {
- const item = getItemFromIndex(i)
- if (item && canFocusItem(item)) {
- rangeItemKeys.push(item.itemKey)
- }
- }
+ const itemKeysInRange = getItemKeysBetween({
+ itemKeyA: itemKey,
+ itemKeyB: anchorItemKey,
+ openItemKeys,
+ rootItems,
+ })
- updateSelections({ itemKeys: rangeItemKeys })
+ updateSelections({ itemKeys: itemKeysInRange })
} else {
// Normal selection: toggle single item
// Reset anchor for next shift selection
setSelectionAnchorItemKey(itemKey)
-
- const isCurrentlySelected = selectedItemKeys.has(itemKey)
- if (isCurrentlySelected) {
- const newItemKeys = new Set(selectedItemKeys)
- newItemKeys.delete(itemKey)
- updateSelections({ itemKeys: newItemKeys })
- } else {
- updateSelections({ itemKeys: [itemKey] })
- }
+ setFocusedItemKey(itemKey)
+ updateSelections({ itemKeys: selectedItemKeys.has(itemKey) ? [] : [itemKey] })
}
},
- [
- updateSelections,
- selectedItemKeys,
- selectionAnchorItemKey,
- getIndexFromItemKey,
- getItemFromIndex,
- canFocusItem,
- ],
+ [selectionAnchorItemKey, rootItems, openItemKeys, updateSelections, selectedItemKeys],
)
// Handle keyboard navigation
- const handleRowKeyPress = React.useCallback(
- ({ event, item }: { event: React.KeyboardEvent; item: SectionItem }) => {
- const { code, ctrlKey, metaKey, shiftKey } = event
+ const onItemKeyDown = React.useCallback(
+ ({
+ event,
+ indexPath,
+ item,
+ }: {
+ event: React.KeyboardEvent
+ indexPath: number[]
+ item: SectionItem
+ }) => {
+ const { code, ctrlKey, metaKey, shiftKey: isShiftPressed } = event
const isCtrlPressed = ctrlKey || metaKey
switch (code) {
@@ -218,31 +141,39 @@ export const NestedSectionsTable: React.FC = ({
case 'ArrowUp': {
event.preventDefault()
- const direction: -1 | 1 = code === 'ArrowUp' ? -1 : 1
- const currentIndex = getIndexFromItemKey(item.itemKey)
- let nextIndex = currentIndex + direction
-
- // Find next focusable row
- while (nextIndex >= 0 && nextIndex < totalVisibleItems) {
- const nextItem = getItemFromIndex(nextIndex)
- if (nextItem && canFocusItem(nextItem)) {
- setFocusedItemKey(nextItem.itemKey)
-
- // Handle shift+arrow for range selection
- if (shiftKey) {
- // Pass the selection event with shiftKey to the next row
- // The parent's selection logic should handle range selection
- onSelectionChange({
- itemKey: nextItem.itemKey,
- options: { ctrlKey, metaKey, shiftKey },
- })
+ const nextItem =
+ code === 'ArrowDown'
+ ? getNextVisibleItem({ indexPath, openItemKeys, rootItems })
+ : getPreviousVisibleItem({ indexPath, openItemKeys, rootItems })
+
+ if (nextItem) {
+ setFocusedItemKey(nextItem.itemKey)
+
+ // Handle selection with shift key (incremental selection)
+ if (isShiftPressed) {
+ if (!selectionAnchorItemKey) {
+ setSelectionAnchorItemKey(nextItem.itemKey)
}
- break
+ // Check if next item is already selected
+ const isTargetSelected = selectedItemKeys.has(nextItem.itemKey)
+ const newSelections = new Set(selectedItemKeys)
+
+ if (isTargetSelected) {
+ // Remove from selection (contracting)
+ newSelections.delete(item.itemKey)
+ } else {
+ if (!selectedItemKeys.has(item.itemKey)) {
+ newSelections.add(item.itemKey)
+ }
+ newSelections.add(nextItem.itemKey)
+ }
+
+ updateSelections({ itemKeys: newSelections })
+ } else {
+ setSelectionAnchorItemKey(nextItem.itemKey)
}
- nextIndex += direction
}
-
break
}
@@ -265,6 +196,8 @@ export const NestedSectionsTable: React.FC = ({
case 'Escape': {
event.preventDefault()
setFocusedItemKey(null)
+ setHoveredItemKey(null)
+ setSelectionAnchorItemKey(null)
onEscape()
break
}
@@ -279,24 +212,28 @@ export const NestedSectionsTable: React.FC = ({
case 'Space': {
event.preventDefault()
- onSelectionChange({
- itemKey: item.itemKey,
- options: { ctrlKey, metaKey, shiftKey },
- })
+ const newSelections = new Set(selectedItemKeys)
+
+ if (selectedItemKeys.has(item.itemKey)) {
+ newSelections.delete(item.itemKey)
+ } else {
+ newSelections.add(item.itemKey)
+ }
+
+ updateSelections({ itemKeys: newSelections })
break
}
}
},
[
- getIndexFromItemKey,
- totalVisibleItems,
- getItemFromIndex,
- canFocusItem,
- // onEnter,
onEscape,
onSelectAll,
+ openItemKeys,
+ rootItems,
+ selectedItemKeys,
+ selectionAnchorItemKey,
toggleItemExpand,
- onSelectionChange,
+ updateSelections,
],
)
@@ -345,7 +282,7 @@ export const NestedSectionsTable: React.FC = ({
setIsDragging(false)
setHoveredItemKey(null)
setTargetParentItemKey(null)
- // eslint-disable-next-line react-compiler/react-compiler
+
document.body.style.cursor = ''
},
onDragEnd(event) {
@@ -412,24 +349,28 @@ export const NestedSectionsTable: React.FC = ({
firstCellRef={firstCellRef}
firstCellWidth={firstCellWidth}
firstCellXOffset={firstCellXOffset}
- focusedItemIndex={focusedRowIndex}
+ focusedItemKey={focusedItemKey}
hoveredItemKey={hoveredItemKey}
isDragging={isDragging}
items={rootItems}
loadingItemKeys={loadingItemKeys}
onDroppableHover={onDroppableHover}
- onFocusChange={(index) => {
+ onFocusChange={(indexPath: number[]) => {
// Convert index back to row ID
- const row = getItemFromIndex(index)
+ const row = getItemByPath({
+ indexPath,
+ rootItems,
+ })
if (row) {
setFocusedItemKey(row.itemKey)
}
}}
+ onItemClick={onItemClick}
onItemDrag={onItemDrag}
- onItemKeyPress={handleRowKeyPress}
- onSelectionChange={onSelectionChange}
+ onItemKeyDown={onItemKeyDown}
openItemKeys={openItemKeys}
parentIndex={0}
+ parentIndexPath={[]}
parentItems={[]}
segmentWidth={segmentWidth}
selectedItemKeys={selectedItemKeys}
diff --git a/packages/ui/src/elements/TreeView/NestedSectionsTable/navigationUtils.ts b/packages/ui/src/elements/TreeView/NestedSectionsTable/navigationUtils.ts
new file mode 100644
index 00000000000..c9f6a180a1d
--- /dev/null
+++ b/packages/ui/src/elements/TreeView/NestedSectionsTable/navigationUtils.ts
@@ -0,0 +1,304 @@
+import type { ItemKey, SectionItem } from './types.js'
+
+export interface GetItemByPathArgs {
+ indexPath: number[]
+ rootItems: SectionItem[]
+}
+
+export interface GetNextVisibleItemArgs {
+ indexPath: number[]
+ openItemKeys: Set
+ rootItems: SectionItem[]
+}
+
+export interface GetPreviousVisibleItemArgs {
+ indexPath: number[]
+ openItemKeys: Set
+ rootItems: SectionItem[]
+}
+
+export interface GetItemKeysBetweenArgs {
+ filterFn?: (item: SectionItem) => boolean
+ itemKeyA: ItemKey
+ itemKeyB: ItemKey
+ openItemKeys: Set
+ rootItems: SectionItem[]
+}
+
+export interface FindItemPathArgs {
+ itemKey: ItemKey
+ rootItems: SectionItem[]
+}
+
+/**
+ * Retrieves an item from the tree structure by following the specified index path.
+ *
+ * @param args - Configuration object
+ * @param args.indexPath - Array of indices representing the path to the item (e.g., [0, 2, 1] for first item -> third child -> second grandchild)
+ * @param args.rootItems - The root-level items of the tree
+ * @returns The item at the specified path, or undefined if not found
+ *
+ * @example
+ * const item = getItemByPath({ indexPath: [0, 2], allItems: rootItems })
+ */
+export const getItemByPath = ({
+ indexPath,
+ rootItems: allItems,
+}: GetItemByPathArgs): SectionItem | undefined => {
+ let current = allItems
+ for (let i = 0; i < indexPath.length; i++) {
+ const item = current?.[indexPath[i]]
+ if (!item) {
+ return undefined
+ }
+ if (i === indexPath.length - 1) {
+ return item
+ }
+ current = item.rows
+ }
+ return undefined
+}
+
+/**
+ * Finds the next visible item in the tree, respecting the current expand/collapse state.
+ * Navigation follows depth-first order: children first, then siblings, then parent's siblings.
+ *
+ * @param args - Configuration object
+ * @param args.indexPath - Array of indices representing the current item's path
+ * @param args.allItems - The root-level items of the tree
+ * @param args.openItemKeys - Set of item keys that are currently expanded
+ * @returns The next visible item, or undefined if there is no next item
+ *
+ * @example
+ * const nextItem = getNextVisibleItem({ indexPath: [0, 1], allItems: rootItems, openItemKeys })
+ */
+export const getNextVisibleItem = ({
+ indexPath,
+ openItemKeys,
+ rootItems: rootItems,
+}: GetNextVisibleItemArgs): SectionItem | undefined => {
+ if (indexPath.length === 0) {
+ return undefined
+ }
+
+ const currentItem = getItemByPath({ indexPath, rootItems })
+ // If current item has visible children, navigate to first child
+ if (currentItem?.rows?.length && openItemKeys?.has(currentItem.itemKey)) {
+ return currentItem.rows[0]
+ }
+
+ // Otherwise, navigate to next sibling or parent's next sibling
+ let searchPath = [...indexPath]
+ while (searchPath.length > 0) {
+ const lastIndex = searchPath[searchPath.length - 1]
+ const parentPath = searchPath.slice(0, -1)
+ const siblings =
+ parentPath.length === 0
+ ? rootItems
+ : getItemByPath({ indexPath: parentPath, rootItems })?.rows || []
+
+ // Check if there's a next sibling
+ if (lastIndex + 1 < siblings.length) {
+ return siblings[lastIndex + 1]
+ }
+
+ // Move up to parent and try again
+ searchPath = parentPath
+ }
+
+ return undefined
+}
+
+/**
+ * Finds the previous visible item in the tree, respecting the current expand/collapse state.
+ * Navigation follows reverse depth-first order: previous sibling's deepest visible child, then parent.
+ *
+ * @param args - Configuration object
+ * @param args.indexPath - Array of indices representing the current item's path
+ * @param args.allItems - The root-level items of the tree
+ * @param args.openItemKeys - Set of item keys that are currently expanded
+ * @returns The previous visible item, or undefined if there is no previous item
+ *
+ * @example
+ * const prevItem = getPreviousVisibleItem({ indexPath: [0, 1], allItems: rootItems, openItemKeys })
+ */
+export const getPreviousVisibleItem = ({
+ indexPath,
+ openItemKeys,
+ rootItems: allItems,
+}: GetPreviousVisibleItemArgs): SectionItem | undefined => {
+ if (indexPath.length === 0) {
+ return undefined
+ }
+
+ const lastIndex = indexPath[indexPath.length - 1]
+
+ // If there's a previous sibling, navigate to its deepest visible child
+ if (lastIndex > 0) {
+ const parentPath = indexPath.slice(0, -1)
+ const siblings =
+ parentPath.length === 0
+ ? allItems
+ : getItemByPath({ indexPath: parentPath, rootItems: allItems })?.rows || []
+ let targetItem = siblings[lastIndex - 1]
+
+ // Navigate to the deepest visible child
+ while (targetItem?.rows?.length && openItemKeys?.has(targetItem.itemKey)) {
+ targetItem = targetItem.rows[targetItem.rows.length - 1]
+ }
+
+ return targetItem
+ }
+
+ // Otherwise, navigate to parent
+ if (indexPath.length > 1) {
+ return getItemByPath({ indexPath: indexPath.slice(0, -1), rootItems: allItems })
+ }
+
+ return undefined
+}
+
+/**
+ * Finds the index path of an item by its itemKey.
+ *
+ * @param args - Configuration object
+ * @param args.itemKey - The itemKey to search for
+ * @param args.rootItems - The root-level items of the tree
+ * @returns The index path to the item, or undefined if not found
+ *
+ * @example
+ * const path = findItemPath({ itemKey: 'section-123', rootItems })
+ * // Returns [0, 2, 1] if the item is at rootItems[0].rows[2].rows[1]
+ */
+export const findItemPath = ({ itemKey, rootItems }: FindItemPathArgs): number[] | undefined => {
+ const search = (items: SectionItem[], currentPath: number[]): number[] | undefined => {
+ for (let i = 0; i < items.length; i++) {
+ const item = items[i]
+ const newPath = [...currentPath, i]
+
+ if (item.itemKey === itemKey) {
+ return newPath
+ }
+
+ if (item.rows?.length) {
+ const found = search(item.rows, newPath)
+ if (found) {
+ return found
+ }
+ }
+ }
+ return undefined
+ }
+
+ return search(rootItems, [])
+}
+
+/**
+ * Compares two index paths to determine their order in the tree.
+ *
+ * @param pathA - First index path
+ * @param pathB - Second index path
+ * @returns Negative if pathA comes before pathB, positive if after, 0 if equal
+ */
+const compareIndexPaths = (pathA: number[], pathB: number[]): number => {
+ const minLength = Math.min(pathA.length, pathB.length)
+
+ for (let i = 0; i < minLength; i++) {
+ if (pathA[i] !== pathB[i]) {
+ return pathA[i] - pathB[i]
+ }
+ }
+
+ // If all compared indices are equal, the shorter path comes first
+ return pathA.length - pathB.length
+}
+
+/**
+ * Gathers all itemKeys between two items in the tree, inclusive of both items.
+ * Only includes visible items (respecting the current expand/collapse state).
+ * Automatically determines which item comes first and handles the range accordingly.
+ *
+ * @param args - Configuration object
+ * @param args.itemKeyA - The itemKey of one end of the range
+ * @param args.itemKeyB - The itemKey of the other end of the range
+ * @param args.rootItems - The root-level items of the tree
+ * @param args.openItemKeys - Set of item keys that are currently expanded
+ * @param args.filterFn - Optional filter function to exclude certain items from the result
+ * @returns Array of itemKeys in the range (in order), or empty array if either item is not found
+ *
+ * @example
+ * // Works regardless of which item comes first in the tree
+ * const keys = getItemKeysBetween({
+ * itemKeyA: 'section-5',
+ * itemKeyB: 'section-1',
+ * rootItems,
+ * openItemKeys,
+ * filterFn: (item) => item.isEnabled // optional filter
+ * })
+ */
+export const getItemKeysBetween = ({
+ filterFn,
+ itemKeyA,
+ itemKeyB,
+ openItemKeys,
+ rootItems,
+}: GetItemKeysBetweenArgs): ItemKey[] => {
+ const pathA = findItemPath({ itemKey: itemKeyA, rootItems })
+ const pathB = findItemPath({ itemKey: itemKeyB, rootItems })
+
+ if (!pathA || !pathB) {
+ return []
+ }
+
+ // Determine which path comes first
+ const comparison = compareIndexPaths(pathA, pathB)
+
+ // If the paths are the same, return just that one item
+ if (comparison === 0) {
+ const item = getItemByPath({ indexPath: pathA, rootItems })
+ if (item && (!filterFn || filterFn(item))) {
+ return [item.itemKey]
+ }
+ return []
+ }
+
+ // Swap if necessary so firstPath always comes before lastPath
+ const [firstPath, _lastPath, lastKey] =
+ comparison < 0 ? [pathA, pathB, itemKeyB] : [pathB, pathA, itemKeyA]
+
+ const result: ItemKey[] = []
+ let currentPath = firstPath
+
+ // Collect items from first to last
+ while (currentPath) {
+ const currentItem = getItemByPath({ indexPath: currentPath, rootItems })
+
+ if (currentItem) {
+ // Apply filter if provided
+ if (!filterFn || filterFn(currentItem)) {
+ result.push(currentItem.itemKey)
+ }
+
+ // Stop if we've reached the last item
+ if (currentItem.itemKey === lastKey) {
+ break
+ }
+ }
+
+ // Move to the next visible item
+ const nextItem = getNextVisibleItem({ indexPath: currentPath, openItemKeys, rootItems })
+ if (!nextItem) {
+ break
+ }
+
+ // Find the path of the next item
+ const nextPath = findItemPath({ itemKey: nextItem.itemKey, rootItems })
+ if (!nextPath) {
+ break
+ }
+
+ currentPath = nextPath
+ }
+
+ return result
+}
diff --git a/packages/ui/src/elements/TreeView/NestedSectionsTable/types.ts b/packages/ui/src/elements/TreeView/NestedSectionsTable/types.ts
index 89eaaa05645..0a39a77bdf4 100644
--- a/packages/ui/src/elements/TreeView/NestedSectionsTable/types.ts
+++ b/packages/ui/src/elements/TreeView/NestedSectionsTable/types.ts
@@ -11,7 +11,6 @@ export type Column = {
}
export type NestedSectionsTableProps = {
- canFocusItem: (item: SectionItem) => boolean
className?: string
columns?: Column[]
dropContextName: string
diff --git a/packages/ui/src/elements/TreeView/TreeViewTable/index.tsx b/packages/ui/src/elements/TreeView/TreeViewTable/index.tsx
index c06c17ef325..b50c454b743 100644
--- a/packages/ui/src/elements/TreeView/TreeViewTable/index.tsx
+++ b/packages/ui/src/elements/TreeView/TreeViewTable/index.tsx
@@ -12,7 +12,6 @@ const dropContextName = 'tree-view-table'
export function TreeViewTable() {
const {
- canFocusItem,
clearSelections,
collectionSlug,
loadingItemKeys,
@@ -31,7 +30,6 @@ export function TreeViewTable() {
{/* TODO: remove this button */}
boolean
clearSelections: () => void
collectionSlug: CollectionSlug
loadingItemKeys: Set
@@ -52,7 +51,6 @@ export type TreeViewContextValue = {
}
const Context = React.createContext({
- canFocusItem: () => true,
clearSelections: () => {},
collectionSlug: '' as CollectionSlug,
loadingItemKeys: new Set(),
@@ -379,14 +377,6 @@ export function TreeViewProvider({
[moveItems, getSelectedItems, items, t],
)
- const canFocusItem = React.useCallback(
- (item: SectionItem) => {
- const unfocusableIDs = getAllDescendantIDs({ itemKeys: selectedItemKeys, items })
- return !unfocusableIDs.has(item.itemKey)
- },
- [selectedItemKeys, items],
- )
-
const rootItems: TreeViewContextValue['rootItems'] = React.useMemo(
() => buildItemHierarchy({ i18nLanguage: i18n.language, items }),
[items, i18n.language],
@@ -401,7 +391,6 @@ export function TreeViewProvider({
return (
diff --git a/packages/ui/src/views/Edit/index.tsx b/packages/ui/src/views/Edit/index.tsx
index 914ac029cc7..c0a5c950f47 100644
--- a/packages/ui/src/views/Edit/index.tsx
+++ b/packages/ui/src/views/Edit/index.tsx
@@ -1,8 +1,8 @@
-/* eslint-disable react-compiler/react-compiler -- TODO: fix */
'use client'
import type { ClientUser, DocumentViewClientProps } from 'payload'
+import { useModal } from '@faceless-ui/modal'
import { useRouter, useSearchParams } from 'next/navigation.js'
import { formatAdminURL } from 'payload/shared'
import React, { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
@@ -112,6 +112,7 @@ export function DefaultEditView({
onRestore,
onSave: onSaveFromContext,
} = useDocumentDrawerContext()
+ const { closeModal } = useModal()
const isInDrawer = Boolean(drawerSlug)
@@ -175,18 +176,20 @@ export function DefaultEditView({
(globalConfig?.versions?.drafts && globalConfig?.versions?.drafts?.autosave),
)
- const preventLeaveWithoutSaving =
- typeof disableLeaveWithoutSaving !== 'undefined' ? !disableLeaveWithoutSaving : !autosaveEnabled
-
const [isReadOnlyForIncomingUser, setIsReadOnlyForIncomingUser] = useState(false)
const [showTakeOverModal, setShowTakeOverModal] = useState(false)
const [editSessionStartTime, setEditSessionStartTime] = useState(Date.now())
const lockExpiryTime = lastUpdateTime + lockDurationInMilliseconds
-
const isLockExpired = Date.now() > lockExpiryTime
+ const preventLeaveWithoutSaving =
+ !isReadOnlyForIncomingUser &&
+ (typeof disableLeaveWithoutSaving !== 'undefined'
+ ? !disableLeaveWithoutSaving
+ : !autosaveEnabled)
+
const schemaPathSegments = useMemo(() => [entitySlug], [entitySlug])
const [validateBeforeSubmit, setValidateBeforeSubmit] = useState(() => {
@@ -253,16 +256,14 @@ export function DefaultEditView({
nextPath.includes(path),
)
- // Only retain the lock if the user is still viewing the document
- if (!isInternalView) {
- if (isLockOwnedByCurrentUser) {
- try {
- await unlockDocument(id, collectionSlug ?? globalSlug)
- setDocumentIsLocked(false)
- setCurrentEditor(null)
- } catch (err) {
- console.error('Failed to unlock before leave', err) // eslint-disable-line no-console
- }
+ // Remove the lock if the user is navigating away from the document view they have locked
+ if (isLockOwnedByCurrentUser && !isInternalView) {
+ try {
+ await unlockDocument(id, collectionSlug ?? globalSlug)
+ setDocumentIsLocked(false)
+ setCurrentEditor(null)
+ } catch (err) {
+ console.error('Failed to unlock before leave', err) // eslint-disable-line no-console
}
}
}
@@ -577,7 +578,7 @@ export function DefaultEditView({
}}
/>
)}
- {!isReadOnlyForIncomingUser && preventLeaveWithoutSaving && (
+ {preventLeaveWithoutSaving && (
)}
{!isInDrawer && (
@@ -675,6 +676,7 @@ export function DefaultEditView({
readOnly={!hasSavePermission}
requirePassword={!id}
setValidateBeforeSubmit={setValidateBeforeSubmit}
+ // eslint-disable-next-line react-compiler/react-compiler
useAPIKey={auth.useAPIKey}
username={data?.username}
verify={auth.verify}
diff --git a/test/admin/e2e/document-view/e2e.spec.ts b/test/admin/e2e/document-view/e2e.spec.ts
index 249899f1e57..077f26e34f9 100644
--- a/test/admin/e2e/document-view/e2e.spec.ts
+++ b/test/admin/e2e/document-view/e2e.spec.ts
@@ -49,13 +49,15 @@ const description = 'Description'
let payload: PayloadTestSDK
-import { navigateToDoc } from 'helpers/e2e/navigateToDoc.js'
-import { openNav } from 'helpers/e2e/toggleNav.js'
import path from 'path'
import { fileURLToPath } from 'url'
import type { PayloadTestSDK } from '../../../helpers/sdk/index.js'
+import { navigateToDoc } from '../../../helpers/e2e/navigateToDoc.js'
+import { selectInput } from '../../../helpers/e2e/selectInput.js'
+import { openDocDrawer } from '../../../helpers/e2e/toggleDocDrawer.js'
+import { openNav } from '../../../helpers/e2e/toggleNav.js'
import { reInitializeDB } from '../../../helpers/reInitializeDB.js'
import { TEST_TIMEOUT_LONG } from '../../../playwright.config.js'
const filename = fileURLToPath(import.meta.url)
@@ -805,6 +807,43 @@ describe('Document View', () => {
await expect(customEditMenuItem).toHaveAttribute('href', `/custom-action?id=${docId}`)
})
})
+
+ describe('save before leaving modal', () => {
+ test('should prompt in drawer with edits', async () => {
+ await page.goto(postsUrl.create)
+ await page.locator('#field-title').fill('sean')
+ await saveDocAndAssert(page)
+
+ await page.goto(postsUrl.create)
+ await page.locator('#field-title').fill('heros')
+ await selectInput({
+ multiSelect: false,
+ option: 'sean',
+ filter: 'sean',
+ selectLocator: page.locator('#field-relationship'),
+ selectType: 'relationship',
+ })
+ await saveDocAndAssert(page)
+ await openDocDrawer({
+ page,
+ selector: '#field-relationship button.relationship--single-value__drawer-toggler',
+ })
+ const editModal = page.locator('.drawer--is-open .collection-edit')
+ await editModal.locator('#field-title').fill('new sean')
+
+ // Attempt to close the drawer
+ const closeButton = editModal.locator('button.doc-drawer__header-close')
+ await closeButton.click()
+
+ const leaveModal = page.locator('#leave-without-saving-doc-drawer')
+ await expect(leaveModal).toBeVisible()
+ await leaveModal.locator('#confirm-cancel').click()
+ await expect(editModal).toBeVisible()
+ await closeButton.click()
+ await leaveModal.locator('#confirm-action').click()
+ await expect(editModal).toBeHidden()
+ })
+ })
})
async function createPost(overrides?: Partial): Promise {
diff --git a/test/helpers/e2e/selectInput.ts b/test/helpers/e2e/selectInput.ts
index b219e1bc758..5e86eb643e9 100644
--- a/test/helpers/e2e/selectInput.ts
+++ b/test/helpers/e2e/selectInput.ts
@@ -1,9 +1,11 @@
-import type { Locator, Page } from '@playwright/test'
+import type { Locator } from '@playwright/test'
-import { exactText } from 'helpers.js'
+import { exactText } from '../../helpers.js'
type SelectReactOptionsParams = {
+ filter?: string // Optional filter text to narrow down options
selectLocator: Locator // Locator for the react-select component
+ selectType?: 'relationship' | 'select'
} & (
| {
clear?: boolean // Whether to clear the selection before selecting new options
@@ -19,13 +21,35 @@ type SelectReactOptionsParams = {
}
)
+const selectors = {
+ hasMany: {
+ relationship: '.relationship--multi-value-label__text',
+ select: '.multi-value-label__text',
+ },
+ hasOne: {
+ relationship: '.relationship--single-value__text',
+ select: '.react-select--single-value',
+ },
+}
+
export async function selectInput({
selectLocator,
options,
option,
multiSelect = true,
clear = true,
+ filter,
+ selectType = 'select',
}: SelectReactOptionsParams) {
+ if (filter) {
+ // Open the select menu to access the input field
+ await openSelectMenu({ selectLocator })
+
+ // Type the filter text into the input field
+ const inputLocator = selectLocator.locator('.rs__input[type="text"]')
+ await inputLocator.fill(filter)
+ }
+
if (multiSelect && options) {
if (clear) {
await clearSelectInput({
@@ -36,7 +60,7 @@ export async function selectInput({
for (const optionText of options) {
// Check if the option is already selected
const alreadySelected = await selectLocator
- .locator('.multi-value-label__text', {
+ .locator(selectors.hasMany[selectType], {
hasText: optionText,
})
.count()
@@ -51,7 +75,7 @@ export async function selectInput({
} else if (option) {
// For single selection, ensure only one option is selected
const alreadySelected = await selectLocator
- .locator('.react-select--single-value', {
+ .locator(selectors.hasOne[selectType], {
hasText: option,
})
.count()
@@ -98,26 +122,25 @@ async function selectOption({
type GetSelectInputValueFunction = (args: {
multiSelect: TMultiSelect
selectLocator: Locator
+ selectType?: 'relationship' | 'select'
valueLabelClass?: string
}) => Promise
export const getSelectInputValue: GetSelectInputValueFunction = async ({
selectLocator,
multiSelect = false,
- valueLabelClass,
+ selectType = 'select',
}) => {
if (multiSelect) {
// For multi-select, get all selected options
const selectedOptions = await selectLocator
- .locator(valueLabelClass || '.multi-value-label__text')
+ .locator(selectors.hasMany[selectType])
.allTextContents()
return selectedOptions || []
}
// For single-select, get the selected value
- const singleValue = await selectLocator
- .locator(valueLabelClass || '.react-select--single-value')
- .textContent()
+ const singleValue = await selectLocator.locator(selectors.hasOne[selectType]).textContent()
return (singleValue ?? undefined) as any
}
diff --git a/test/joins/int.spec.ts b/test/joins/int.spec.ts
index 56149f2d857..878963ba899 100644
--- a/test/joins/int.spec.ts
+++ b/test/joins/int.spec.ts
@@ -1837,6 +1837,61 @@ describe('Joins Field', () => {
expect(found.docs).toHaveLength(1)
expect(found.docs[0].id).toBe(category.id)
})
+
+ it('should support where querying by a join field multiple times', async () => {
+ const category = await payload.create({ collection: 'categories', data: {} })
+ await payload.create({
+ collection: 'posts',
+ data: { group: { category: category.id }, isFiltered: true, title: 'my-category-title' },
+ })
+
+ const found = await payload.find({
+ collection: 'categories',
+ where: {
+ and: [
+ {
+ 'group.relatedPosts.title': { equals: 'my-category-title' },
+ },
+ {
+ 'group.relatedPosts.title': { exists: true },
+ },
+ {
+ 'group.relatedPosts.isFiltered': { equals: true },
+ },
+ ],
+ },
+ })
+
+ expect(found.docs).toHaveLength(1)
+ expect(found.docs[0].id).toBe(category.id)
+ })
+
+ it('should support where querying by a join field with hasMany relationship multiple times', async () => {
+ const category = await payload.create({ collection: 'categories', data: {} })
+ await payload.create({
+ collection: 'posts',
+ data: { categories: [category.id], title: 'my-title', isFiltered: true },
+ })
+
+ const found = await payload.find({
+ collection: 'categories',
+ where: {
+ and: [
+ {
+ 'hasManyPosts.title': { equals: 'my-title' },
+ },
+ {
+ 'hasManyPosts.title': { exists: true },
+ },
+ {
+ 'hasManyPosts.isFiltered': { equals: true },
+ },
+ ],
+ },
+ })
+ expect(found.docs).toHaveLength(1)
+ expect(found.docs[0].id).toBe(category.id)
+ })
})
async function createPost(overrides?: Partial, locale?: Config['locale']) {