diff --git a/docs/admin/preview.mdx b/docs/admin/preview.mdx index 6cff2a9bdbd..aae9111153e 100644 --- a/docs/admin/preview.mdx +++ b/docs/admin/preview.mdx @@ -63,7 +63,7 @@ preview: (doc, { req }) => `${req.protocol}//${req.host}/${doc.slug}` // highlig ## Draft Preview -The Preview feature can be used to achieve "Draft Preview". After clicking the preview button from the Admin Panel, you can enter into "draft mode" within your front-end application. This will allow you to adjust your page queries to include the `draft: true` param. When this param is present on the request, Payload will send back a draft document as opposed to a published one based on the document's `_status` field. +The Preview feature can be used to achieve "Draft Preview". After clicking the preview button from the Admin Panel, you can enter into "draft mode" within your front-end application. This will allow you to adjust your page queries to `version: 'latest'` (or `version=latest` on REST). Payload then returns the newest saved draft when one exists, otherwise the published document. To enter draft mode, the URL provided to the `preview` function can point to a custom endpoint in your front-end application that sets a cookie or session variable to indicate that draft mode is enabled. This is framework specific, so the mechanisms here vary from framework to framework although the underlying concept is the same. @@ -200,7 +200,7 @@ export default async function Page({ params: paramsPromise }) { const page = await payload.find({ collection: 'pages', depth: 0, - draft: isDraftMode, // highlight-line + version: isDraftMode ? 'latest' : 'published', // highlight-line limit: 1, overrideAccess: isDraftMode, where: { diff --git a/docs/configuration/overview.mdx b/docs/configuration/overview.mdx index 0d48f370788..73a358ea173 100644 --- a/docs/configuration/overview.mdx +++ b/docs/configuration/overview.mdx @@ -131,13 +131,12 @@ export default buildConfig({ The following options are available: -| Option | Description | -| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **`autoGenerate`** | By default, Payload will auto-generate TypeScript interfaces for all collections and globals that your config defines. Opt out by setting `typescript.autoGenerate: false`. [More details](../typescript/overview). | -| **`generateInputTypes`** | Opt in (defaults to `false`) to also generate a write-shaped input type (e.g. `PostInput`) alongside each read type, by setting `typescript.generateInputTypes: true`. [More details](../typescript/generating-types#input-and-output-types). | -| **`declare`** | By default, Payload adds a `declare` block to your generated types, which makes sure that Payload uses your generated types for all Local API methods. Opt out by setting `typescript.declare: false`. | -| **`outputFile`** | Control the output path and filename of Payload's auto-generated types by defining the `typescript.outputFile` property to a full, absolute path. | -| **`strictDraftTypes`** | Enable strict type safety for draft mode. When enabled: (1) Query operations (`find`, `findByID`) with `draft: true` will type required fields as optional, since validation is skipped for drafts. (2) The `draft` property is forbidden for collections without drafts in create operations. (3) Create operations enforce proper data requirements via discriminated unions. Defaults to `false`. **This will become the default behavior in v4.0.** | +| Option | Description | +| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **`autoGenerate`** | By default, Payload will auto-generate TypeScript interfaces for all collections and globals that your config defines. Opt out by setting `typescript.autoGenerate: false`. [More details](../typescript/overview). | +| **`generateInputTypes`** | Opt in (defaults to `false`) to also generate a write-shaped input type (e.g. `PostInput`) alongside each read type, by setting `typescript.generateInputTypes: true`. [More details](../typescript/generating-types#input-and-output-types). | +| **`declare`** | By default, Payload adds a `declare` block to your generated types, which makes sure that Payload uses your generated types for all Local API methods. Opt out by setting `typescript.declare: false`. | +| **`outputFile`** | Control the output path and filename of Payload's auto-generated types by defining the `typescript.outputFile` property to a full, absolute path. | ## Config Location diff --git a/docs/hierarchy/overview.mdx b/docs/hierarchy/overview.mdx index 47f0e5ae975..0ac601d8fee 100644 --- a/docs/hierarchy/overview.mdx +++ b/docs/hierarchy/overview.mdx @@ -815,7 +815,7 @@ const published = await payload.findByID({ collection: 'pages', id: 'page-id', context: { computeHierarchyPaths: true }, - // draft: false (default) + // version: 'published' (default) }) // published._h_slugPath: 'products/clothing' (uses published parent title) @@ -823,7 +823,7 @@ const published = await payload.findByID({ const draft = await payload.findByID({ collection: 'pages', id: 'page-id', - draft: true, + version: 'latest', context: { computeHierarchyPaths: true }, }) // draft._h_slugPath: 'products/apparel' (uses draft title if changed) @@ -831,7 +831,7 @@ const draft = await payload.findByID({ **How it works:** -1. When computing paths, hierarchy fetches ancestors using the same `draft` context +1. When computing paths, hierarchy fetches ancestors using the same `version` context 2. If reading a draft, ancestor titles come from draft versions (if they exist) 3. If reading published, ancestor titles come from published versions 4. This ensures paths always reflect the correct version's hierarchy state @@ -871,7 +871,7 @@ await payload.update({ collection: 'pages', id: 'doc-id', data: { parent: 'parent-2' }, - draft: true, + action: 'saveDraft', }) // Publish only French @@ -880,7 +880,7 @@ await payload.update({ id: 'doc-id', locale: 'fr', data: { _status: 'published' }, - draft: false, + action: 'publish', }) // Result: parent changed to 'parent-2' for ALL locales // Paths computed on next read will reflect new parent for all locales diff --git a/docs/hooks/collections.mdx b/docs/hooks/collections.mdx index 3e06bee621e..b1e7d38b409 100644 --- a/docs/hooks/collections.mdx +++ b/docs/hooks/collections.mdx @@ -195,15 +195,16 @@ const afterChangeHook: CollectionAfterChangeHook = async ({ The following arguments are provided to the `afterChange` hook: -| Option | Description | -| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -| **`collection`** | The [Collection](../configuration/collections) in which this Hook is running against. | -| **`context`** | Custom context passed between hooks. [More details](./context). | -| **`data`** | The incoming data passed through the operation. | -| **`doc`** | The resulting Document after changes are applied. | -| **`operation`** | The name of the operation that this hook is running within. | -| **`previousDoc`** | The Document before changes were applied. | -| **`req`** | The [Web Request](https://developer.mozilla.org/en-US/docs/Web/API/Request) object. This is mocked for [Local API](../local-api/overview) operations. | +| Option | Description | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **`action`** | Resolved write action for this operation, including defaults. `undefined` when drafts are not enabled. Create/duplicate: `saveDraft` or `publish`. Update: `saveDraft`, `publish`, or `unpublish`. Restore: `saveDraft` or `publish`. | +| **`collection`** | The [Collection](../configuration/collections) in which this Hook is running against. | +| **`context`** | Custom context passed between hooks. [More details](./context). | +| **`data`** | The incoming data passed through the operation. | +| **`doc`** | The resulting Document after changes are applied. | +| **`operation`** | The name of the operation that this hook is running within. | +| **`previousDoc`** | The Document before changes were applied. | +| **`req`** | The [Web Request](https://developer.mozilla.org/en-US/docs/Web/API/Request) object. This is mocked for [Local API](../local-api/overview) operations. | ### beforeRead diff --git a/docs/hooks/globals.mdx b/docs/hooks/globals.mdx index 1307dfa520e..4bb90571d39 100644 --- a/docs/hooks/globals.mdx +++ b/docs/hooks/globals.mdx @@ -169,6 +169,7 @@ The following arguments are provided to the `afterChange` hook: | Option | Description | | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| **`action`** | Resolved write action for this operation, including defaults. `undefined` when drafts are not enabled. | | **`global`** | The [Global](../configuration/globals) in which this Hook is running against. | | **`context`** | Custom context passed between hooks. [More details](./context). | | **`data`** | The incoming data passed through the operation. | diff --git a/docs/live-preview/server.mdx b/docs/live-preview/server.mdx index 4163fed49b7..fc58e4cf12c 100644 --- a/docs/live-preview/server.mdx +++ b/docs/live-preview/server.mdx @@ -50,7 +50,7 @@ export default async function Page() { const page = await payload.findByID({ collection: 'pages', id: '123', - draft: true, + version: 'latest', trash: true, // add this if trash is enabled in your collection and want to preview trashed documents }) diff --git a/docs/local-api/overview.mdx b/docs/local-api/overview.mdx index 83c166c66a9..82bb23ae168 100644 --- a/docs/local-api/overview.mdx +++ b/docs/local-api/overview.mdx @@ -80,6 +80,8 @@ You can specify more options within the Local API vs. REST or GraphQL due to the | `context` | [Context](/docs/hooks/context), which will then be passed to `context` and `req.context`, which can be read by hooks. Useful if you want to pass additional information to the hooks which shouldn't be necessarily part of the document, for example a `triggerBeforeChange` option which can be read by the BeforeChange hook to determine if it should run or not. | | `disableErrors` | When set to `true`, errors will not be thrown. Instead, the `findByID` operation will return `null`, and the `find` operation will return an empty documents array. | | `disableTransaction` | When set to `true`, a [database transactions](../database/transactions) will not be initialized. | +| **`version`** | Read operations: `'published'` (default), `'latest'`, or `'draft'`. [More](/docs/versions/drafts#reading-with-version). | +| **`action`** | Write operations: `saveDraft`, `publish`, or `unpublish` (update only). [More](/docs/versions/drafts#writing-with-action). | _There are more options available on an operation by operation basis outlined below._ diff --git a/docs/migration-guide/v4.mdx b/docs/migration-guide/v4.mdx index 8c0eabb1829..eca4d328fdc 100644 --- a/docs/migration-guide/v4.mdx +++ b/docs/migration-guide/v4.mdx @@ -198,6 +198,61 @@ Run `migrate-versions-default` if you want zero schema changes — it adds `vers Run `remove-versions-true` to clean up any bare `versions: true` that is now the default. +### Public `draft` operation argument replaced by `version` and `action` + +There is no v4 compatibility shim for the public `draft` boolean on read and write operations. Reads use `version`. Writes use `action`. `_status` remains on returned documents and in typed write inputs. + +The `migrate-version-action-api` transform in `@payloadcms/codemod` rewrites unambiguous Local, REST, GraphQL, and SDK call sites and removes `typescript.strictDraftTypes`. It emits notes rather than guessing for dynamic `draft` values, update `draft: false` without a static `_status`, wrapper helpers, and conflicting `draft` / `_status` / `action` combinations. + +**Reads** + +| v3 | v4 | +| ------------------------ | ---------------------------------------------------------- | +| omitted / `draft: false` | omitted / `version: 'published'` (default) | +| `draft: true` | `version: 'latest'` (newest draft with published fallback) | +| n/a | `version: 'draft'` (newest draft only, no fallback) | + +**Writes** — precedence is explicit `action`, then recognized `_status`, then the operation default. `_status: 'draft'` infers `saveDraft`; `_status: 'published'` infers `publish`; `_status` never infers `unpublish`. Create and duplicate default to `saveDraft`. Update and restore default to `publish`. + +```ts +// Local API +await payload.find({ collection: 'posts', version: 'latest' }) +await payload.create({ collection: 'posts', data: { title: 'Draft' }, action: 'saveDraft' }) +await payload.update({ collection: 'posts', id, data: { title: 'Live' }, action: 'publish' }) +await payload.update({ collection: 'posts', id, action: 'unpublish' }) + +// REST +GET /api/posts?version=latest +POST /api/posts?action=saveDraft +PATCH /api/posts/123?action=publish + +// GraphQL +query { Posts(version: latest) { docs { title } } } +mutation { updatePost(id: $id, action: saveDraft, data: { title: "Draft" }) { title } } + +// SDK +await sdk.find({ collection: 'posts', version: 'latest' }) +await sdk.create({ collection: 'posts', data: { title: 'Draft' }, action: 'saveDraft' }) +``` + +`afterChange` receives the resolved `action` (including defaults). It is `undefined` on collections and globals without drafts. + +`typescript.strictDraftTypes` is removed with no replacement. Local API and SDK version/action typing is always strict. + +Cases the codemod will not rewrite, which you must migrate by hand: + +- `draft: shouldSaveDraft` and other dynamic expressions +- Update `draft: false` without a static `_status` (old behavior depended on existing document state) +- Detached options objects and wrappers whose operation cannot be identified +- Conflicting `draft` + `_status` values +- REST URLs and GraphQL strings without enough surrounding operation context + +Search leftover operation arguments with: + +```sh +rg -n "draft:\\s*(true|false)|[?&]draft=" src +``` + ### List View Select API is now the default The `admin.enableListViewSelectAPI` Collection Config property has been removed. The List View now always uses the [Select API](../queries/select) to query only the active columns, which was previously opt-in. @@ -1717,7 +1772,7 @@ defineCollectionTool({ }) ``` -**Built-in collection tools are now generic.** Per-collection names like `createPosts` / `updatePosts` were replaced by `createDocuments` / `updateDocument`. Pass the target collection as `slug`. For creates, pass a `documents` array whose items contain `data` and an optional `file`; for updates, pass `data` with an `id` or `where` at the top level. Options such as `depth`, `draft`, and `locale` also remain at the top level. `_status`, `id`, `createdAt`, and `updatedAt` are no longer accepted inside `data`; set publish state with a custom tool if you need to. +**Built-in collection tools are now generic.** Per-collection names like `createPosts` / `updatePosts` were replaced by `createDocuments` / `updateDocument`. Pass the target collection as `slug`. For creates, pass a `documents` array whose items contain `data` and an optional `file`; for updates, pass `data` with an `id` or `where` at the top level. Options such as `depth`, `action`, `version`, and `locale` remain at the top level. `id`, `createdAt`, and `updatedAt` are no longer accepted inside `data`. `_status` remains accepted in write `data` and infers `action` when `action` is omitted. ```ts // arguments to a `createDocuments` tool call @@ -1728,8 +1783,8 @@ defineCollectionTool({ // after { slug: 'posts', - documents: [{ data: { title: 'Hello' } }], - draft: true, + documents: [{ data: { title: 'Hello', _status: 'draft' } }], + action: 'saveDraft', depth: 2, } ``` diff --git a/docs/plugins/mcp.mdx b/docs/plugins/mcp.mdx index 42dc6fe1b16..8f8a032f3c9 100644 --- a/docs/plugins/mcp.mdx +++ b/docs/plugins/mcp.mdx @@ -195,8 +195,8 @@ multi-document requests. ### Creating documents `createDocuments` accepts a `documents` array with at least one item. Each item -has its own `data` and optional `file`. Options such as `depth`, `draft`, -`locale`, and `fallbackLocale` apply to the whole request. +has its own `data` and optional `file`. Options such as `depth`, `action`, +`locale`, `fallbackLocale`, and `select` apply to the whole request. Documents are created one at a time through Payload's Local API. The operation is best-effort: a failed item does not roll back successful items. Results and diff --git a/docs/plugins/multi-tenant.mdx b/docs/plugins/multi-tenant.mdx index 04089bb510e..0921c72bcaa 100644 --- a/docs/plugins/multi-tenant.mdx +++ b/docs/plugins/multi-tenant.mdx @@ -340,7 +340,7 @@ In your frontend you can query and constrain data by tenant with the following: const pagesBySlug = await payload.find({ collection: 'pages', depth: 1, - draft: false, + version: 'published', limit: 1000, overrideAccess: false, where: { diff --git a/docs/rest-api/overview.mdx b/docs/rest-api/overview.mdx index 4b93b7d3b64..646a878f718 100644 --- a/docs/rest-api/overview.mdx +++ b/docs/rest-api/overview.mdx @@ -36,6 +36,8 @@ To enhance DX, you can use [Payload SDK](#payload-rest-api-sdk) to query your RE - [sort](../queries/sort#rest-api) - specifies the field(s) to use to sort the returned documents by - [where](../queries/overview) - specifies advanced filters to use to query documents - [joins](/docs/fields/join#rest-api) - specifies the custom request for each join field by name of the field +- [version](/docs/versions/drafts#reading-with-version) - `'published'`, `'latest'`, or `'draft'` on read operations +- [action](/docs/versions/drafts#writing-with-action) - `saveDraft`, `publish`, or `unpublish` on write operations ## Collections @@ -851,7 +853,7 @@ const sdk = new PayloadSDK({ // Find operation const posts = await sdk.find({ collection: 'posts', - draft: true, + version: 'latest', limit: 10, locale: 'en', page: 1, @@ -862,7 +864,7 @@ const posts = await sdk.find({ const posts = await sdk.findByID({ id, collection: 'posts', - draft: true, + version: 'latest', locale: 'en', }) diff --git a/docs/versions/autosave.mdx b/docs/versions/autosave.mdx index b29606ea19f..ecfbb0dff65 100644 --- a/docs/versions/autosave.mdx +++ b/docs/versions/autosave.mdx @@ -67,7 +67,7 @@ export const Pages: CollectionConfig = { ## Autosave API -When `autosave` is enabled, all `update` operations within Payload expose a new argument called `autosave`. When set to `true`, Payload will treat the incoming draft update as an `autosave`. This is primarily used by the Admin UI, but there may be some cases where you are building an app for your users and wish to implement `autosave` in your own app. To do so, use the `autosave` argument in your `update` operations. +When `autosave` is enabled, all `update` operations within Payload expose a new argument called `autosave`. When set to `true`, Payload will treat the incoming `saveDraft` update as an `autosave`. `autosave` is valid only with resolved `saveDraft`. This is primarily used by the Admin UI, but there may be some cases where you are building an app for your users and wish to implement `autosave` in your own app. To do so, pass `action: 'saveDraft'` together with `autosave: true` on `update`. ### How autosaves are stored diff --git a/docs/versions/drafts.mdx b/docs/versions/drafts.mdx index f16ba79d98d..c277fc94663 100644 --- a/docs/versions/drafts.mdx +++ b/docs/versions/drafts.mdx @@ -41,121 +41,113 @@ Within the Admin UI, if drafts are enabled, a document can be shown with one of 1. **Changed** - if a document has been published, but there are newer drafts available and not yet published -## Draft API +## Version and action APIs - If drafts are enabled on your collection or global, important and powerful - changes are made to your REST, GraphQL, and Local APIs that allow you to - specify if you are interacting with drafts or with live documents. + If drafts are enabled on your collection or global, REST, GraphQL, Local, and + SDK operations expose `version` on reads and `action` on writes. There is no + public `draft` boolean on these operations. -#### Using the `draft` parameter - -When drafts are enabled, the `create`, `update`, `find`, and `findByID` operations for REST, GraphQL, and Local APIs expose a `draft` parameter. For write operations, it controls validation and where data is written. For read operations, it determines whether to return draft versions. +Reads use `version`. Writes use `action`. `_status` remains on returned documents and in typed write `data`; it is a fallback for write intent only when `action` is omitted. ```ts // REST API -POST /api/your-collection?draft=true +GET /api/pages?version=latest +POST /api/pages?action=saveDraft +PATCH /api/pages/123?action=publish -// Local API +// Local API / SDK +await payload.find({ collection: 'pages', version: 'latest' }) await payload.create({ - collection: 'your-collection', - data: { - // your data here - }, - draft: true, + collection: 'pages', + data: { title: 'Draft page' }, + action: 'saveDraft', +}) +await payload.update({ + collection: 'pages', + id, + data: { title: 'Published page' }, + action: 'publish', }) // GraphQL +query { + Pages(version: latest) { docs { title } } +} mutation { - createYourCollection(data: { ... }, draft: true) { - // ... - } + createPage(data: { title: "Draft page" }, action: saveDraft) { title } } ``` -**Understanding `draft` parameter and `_status` field** - -The `draft` parameter and `_status` field work together but serve different purposes: +### Reading with `version` -**`draft` parameter** - Controls two things: +| `version` | Result | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| omitted / `'published'` | Published content from the main document. This is the default. | +| `'latest'` | Newest saved draft when one exists, otherwise the published document. | +| `'draft'` | Newest draft only, with **no** published fallback. Missing drafts follow each operation's existing empty / not-found behavior. | -1. **Validation**: When `draft: true`, required fields are not enforced, allowing you to save incomplete documents -2. **Write location**: Determines whether the main collection document is updated - - `draft: true` - Saves ONLY to versions table (main collection unchanged) - - `draft: false` or omitted - Saves to BOTH main collection AND versions table +`latest` is the preview mode: show unpublished work when it exists, otherwise fall back to what is live. `draft` is draft-only and never falls back. -**`_status` field** - Indicates whether a document is published or in draft state +**Example scenario:** -- Defaults to `'draft'` when not explicitly provided -- Can be explicitly set in your data to `'published'` or `'draft'` +1. You create a document with `action: 'publish'` +1. You update with `action: 'saveDraft'` so the published document is unchanged +1. You save another draft -**First document creation** +A normal `find` / `findByID` (or `version: 'published'`) returns the published document. `version: 'latest'` returns the newest draft. `version: 'draft'` also returns that draft, but returns empty / not-found if no draft exists. -When you first create a document, it's always written to the main collection (since no document exists yet): +On collections and globals **without** drafts, omitted and `'published'` are ordinary reads, `'latest'` maps to published, and `'draft'` returns no result. -- If you don't specify `_status`, it defaults to `_status: 'draft'` -- A version is also created in the versions table -- The `draft` parameter controls validation but doesn't change where the initial document is written + + **Important:** `version` does not replace Access Control. Restrict who can + read unpublished documents with a query constraint on `_status`, as shown + below. + -**Subsequent updates** +### Writing with `action` -After initial creation, the `draft` parameter controls where your updates are written: +| Operation | Allowed `action` values | Default when `action` and recognized `_status` are both omitted | +| ------------------ | ----------------------------------- | --------------------------------------------------------------- | +| Create / duplicate | `saveDraft`, `publish` | `saveDraft` | +| Update | `saveDraft`, `publish`, `unpublish` | `publish` | +| Restore | `saveDraft`, `publish` | `publish` | -- **`draft: true`** - Only the versions table is updated; the main collection document remains unchanged -- **`draft: false` or omitted** - Both the main collection and versions table are updated +`saveDraft` skips required-field validation (unless `versions.drafts.validate` is `true`) and writes only the versions table on updates, leaving the published document unchanged. `publish` enforces validation and updates the main document. `unpublish` reverts a published document to draft; it is never inferred from `_status`. -**Important:** The `draft` parameter does NOT control whether a document is published or not. A document remains with `_status: 'draft'` by default unless you explicitly set `_status: 'published'` in your data. +**Runtime precedence** is explicit `action`, then a recognized `_status` in write data, then the operation default. Explicit `action` always wins. Payload then canonicalizes persisted `_status` from the effective action; it does not mutate the caller's original `data` object. -**Publishing a document** +| `_status` in data | Inferred action when `action` is omitted | +| ----------------------- | ---------------------------------------- | +| `'draft'` | `saveDraft` | +| `'published'` | `publish` | +| omitted / anything else | Operation default | -To publish a document, you must explicitly set `_status: 'published'` in your data. When you do this: +Localized `_status` inference uses the active write locale. Publishing or unpublishing every locale requires explicit `action` plus `publishAllLocales` / `unpublishAllLocales`. `autosave: true` is valid only with `saveDraft`. -- If you use `draft: false` or omit it, the main collection will be updated with the published status -- If you use `draft: true`, the `_status: 'published'` takes precedence and will still update the main collection as published (overriding the `draft: true` behavior) +**Required fields:** `_status: 'draft'` infers `saveDraft` and therefore skips required-field validation. Setting `_status: 'published'` infers `publish` and enforces them. To save incomplete documents, use `action: 'saveDraft'` or omit action with `_status: 'draft'` / omitted status on create. **Quick reference** -| Operation | `draft` param | `_status` in data | Result | -| --------- | ------------------ | -------------------- | -------------------------------------------------------------- | -| Create | `true` or `false` | omitted | Main collection updated with `_status: 'draft'` | -| Create | `true` or `false` | `'published'` | Main collection updated with `_status: 'published'` | -| Update | `true` | omitted or `'draft'` | Only versions table updated, main collection unchanged | -| Update | `true` | `'published'` | Main collection updated with `_status: 'published'` (override) | -| Update | `false` or omitted | omitted | Main collection updated with `_status: 'draft'` | -| Update | `false` or omitted | `'published'` | Main collection updated with `_status: 'published'` | - -**Required fields** - -Setting `_status: "draft"` will not bypass required field validation. You need to set `draft: true` to save incomplete documents as shown in the previous examples. +| Operation | `action` | `_status` in data | Result | +| --------- | ------------- | ----------------- | --------------------------------------------------------------- | +| Create | omitted | omitted | Draft (`saveDraft` default). Partial data allowed. | +| Create | omitted | `'published'` | Published. Required fields enforced. | +| Create | `'saveDraft'` | any | Draft. Action wins; status is canonicalized to `draft`. | +| Create | `'publish'` | any | Published. Action wins; status is canonicalized to `published`. | +| Update | omitted | omitted | Published (`publish` default). | +| Update | omitted | `'draft'` | Draft (`saveDraft`). Main document unchanged. | +| Update | `'unpublish'` | any | Unpublished. `_status` cannot infer this. | +| Restore | omitted | n/a | Published (`publish` default). | -#### Reading drafts vs. published documents +On collections and globals **without** drafts, omitted action and `publish` perform ordinary writes. `saveDraft` and `unpublish` throw `400`. -In addition to the `draft` argument within `create` and `update` operations, a `draft` argument is also exposed for `find` and `findByID` operations. +`afterChange` (collection, global, and field) receives the **resolved** `action`, including defaults. It is `undefined` on entities without drafts. -When `draft` is set to `true` while reading a document, **Payload will return the most recent version from the versions table**, regardless of whether it's a draft or published document. +### TypeScript -**Example scenario:** - -1. You create a document with `_status: 'published'` (published in main collection) -1. You update with `draft: true` to make changes without affecting the published version -1. You update again with `draft: true` to make more draft changes - -At this point, your published document remains unchanged in the main collection, and you have two newer draft versions in the `_[collectionSlug]_versions` table. - -When you fetch the document with a standard `find` or `findByID` operation, the published document from the main collection is returned and draft versions are ignored. - -However, if you pass `draft: true` to the read operation, Payload will return the most recent version from the versions table. In the scenario above with two draft versions, you'll get the latest (second) draft. - -**Note:** If there are no newer drafts (e.g., you published a document and haven't made draft changes since), querying with `draft: true` will still return the latest version from the versions table, which would be the same published content as in the main collection. - - - **Important:** the `draft` argument on its own will not restrict documents - with `_status: 'draft'` from being returned from the API. You need to use - Access Control to prevent documents with `_status: 'draft'` from being - returned to unauthenticated users. Read below for more information on how this - works. - +Local API and SDK `version` / `action` types are always strict. `typescript.strictDraftTypes` has been removed with no replacement flag. Non-draft collections reject `version` and draft-only actions. `latest` and `draft` reads use draft-safe result shapes. `saveDraft` (and omitted-action create with draft or omitted status) accepts partial data; `publish` (and omitted-action create with `_status: 'published'`) requires publish-valid data. `_status` remains on generated write input types. ## Controlling who can see Collection drafts @@ -303,7 +295,7 @@ You can enable this functionality on both collections and globals via the `versi ## Unpublishing drafts -If a document is published, the Payload Admin UI will be updated to show an "unpublish" button at the top of the sidebar, which will "unpublish" the currently published document. Consider this as a way to "revert" a document back to a draft state. On the API side, this is done by simply setting `_status: 'draft'` on any document. +If a document is published, the Payload Admin UI will be updated to show an "unpublish" button at the top of the sidebar, which will "unpublish" the currently published document. Consider this as a way to "revert" a document back to a draft state. On the API side, this is `action: 'unpublish'`. `_status: 'draft'` infers `saveDraft` and never unpublishes. ## Reverting to published diff --git a/docs/versions/overview.mdx b/docs/versions/overview.mdx index c148bde6ebd..5bfb1084970 100644 --- a/docs/versions/overview.mdx +++ b/docs/versions/overview.mdx @@ -197,6 +197,7 @@ const result = await payload.findVersionByID({ const result = await payload.restoreVersion({ collection: 'posts', // required id: '507f1f77bcf86cd799439013', // required + action: 'publish', // default; use 'saveDraft' to restore without publishing depth: 2, user: dummyUser, overrideAccess: false, diff --git a/examples/draft-preview/README.md b/examples/draft-preview/README.md index 12b1d2a1232..5cb9378ef96 100644 --- a/examples/draft-preview/README.md +++ b/examples/draft-preview/README.md @@ -34,12 +34,12 @@ See the [Collections](https://payloadcms.com/docs/configuration/collections) doc - #### Pages - The `pages` collection is draft-enabled and has access control that restricts public users from viewing pages with a `_status` of `draft`. To fetch draft documents on your front-end, simply include the `draft=true` query param along with the `Authorization` header once you have entered [Preview Mode](#preview-mode). + The `pages` collection is draft-enabled and has access control that restricts public users from viewing pages with a `_status` of `draft`. To fetch draft documents on your front-end, include `version=latest` along with the `Authorization` header once you have entered [Preview Mode](#preview-mode). ```ts const preview = true // set this based on your own front-end environment (see `Preview Mode` below) const pageSlug = 'example-page' // same here - const searchParams = `?where[slug][equals]=${pageSlug}&depth=1${preview ? `&draft=true` : ''}` + const searchParams = `?where[slug][equals]=${pageSlug}&depth=1${preview ? `&version=latest` : ''}` // when previewing, send the payload token to bypass draft access control const pageReq = await fetch(`${process.env.NEXT_PUBLIC_PAYLOAD_URL}/api/pages${searchParams}`, { diff --git a/examples/draft-preview/src/app/(app)/[slug]/page.tsx b/examples/draft-preview/src/app/(app)/[slug]/page.tsx index a4db9e99566..055e78ca735 100644 --- a/examples/draft-preview/src/app/(app)/[slug]/page.tsx +++ b/examples/draft-preview/src/app/(app)/[slug]/page.tsx @@ -16,7 +16,7 @@ export async function generateStaticParams() { const pages = await payload.find({ collection: 'pages', - draft: false, + version: 'published', limit: 1000, overrideAccess: false, }) @@ -76,7 +76,7 @@ const queryPageBySlug = cache(async ({ slug }: { slug: string }) => { const result = await payload.find({ collection: 'pages', - draft, + version: draft ? 'latest' : 'published', limit: 1, overrideAccess: draft, where: { @@ -86,5 +86,5 @@ const queryPageBySlug = cache(async ({ slug }: { slug: string }) => { }, }) - return result.docs?.[0] || null + return (result.docs?.[0] || null) as PageType | null }) diff --git a/examples/draft-preview/src/migrations/seed.ts b/examples/draft-preview/src/migrations/seed.ts index fff3f6dbff8..9f46db129ba 100644 --- a/examples/draft-preview/src/migrations/seed.ts +++ b/examples/draft-preview/src/migrations/seed.ts @@ -28,7 +28,7 @@ export async function up({ payload }: MigrateUpArgs): Promise { skipRevalidate: true, }, data: examplePageDraft as any, // eslint-disable-line - draft: true, + action: 'saveDraft', }) const homepageJSON = JSON.parse(JSON.stringify(home).replace('{{DRAFT_PAGE_ID}}', examplePageID)) diff --git a/examples/form-builder/src/app/(app)/[slug]/page.tsx b/examples/form-builder/src/app/(app)/[slug]/page.tsx index 4f195966078..60ee2078513 100644 --- a/examples/form-builder/src/app/(app)/[slug]/page.tsx +++ b/examples/form-builder/src/app/(app)/[slug]/page.tsx @@ -20,7 +20,7 @@ export default async function Page({ params: paramsPromise }: PageParams) { const pageRes = await payload.find({ collection: 'pages', - draft: false, + version: 'published', limit: 1, overrideAccess: false, where: { @@ -47,7 +47,7 @@ export async function generateStaticParams() { const payload = await getPayload({ config }) const pagesRes = await payload.find({ collection: 'pages', - draft: false, + version: 'published', limit: 100, overrideAccess: false, }) diff --git a/examples/live-preview/README.md b/examples/live-preview/README.md index f17229d2ca2..9c688efefbd 100644 --- a/examples/live-preview/README.md +++ b/examples/live-preview/README.md @@ -106,7 +106,7 @@ export default async function Page() { const page = await payload.find({ collection: 'pages', - draft: true, + version: 'latest', }) return ( diff --git a/examples/live-preview/src/app/(app)/[slug]/page.tsx b/examples/live-preview/src/app/(app)/[slug]/page.tsx index cb32aa862af..e3f0b9a148b 100644 --- a/examples/live-preview/src/app/(app)/[slug]/page.tsx +++ b/examples/live-preview/src/app/(app)/[slug]/page.tsx @@ -23,7 +23,7 @@ export default async function Page({ params: paramsPromise }: PageParams) { const pageRes = await payload.find({ collection: 'pages', - draft: true, + version: 'latest', limit: 1, where: { slug: { @@ -56,7 +56,7 @@ export async function generateStaticParams() { const pagesRes = await payload.find({ collection: 'pages', depth: 0, - draft: true, + version: 'latest', limit: 100, }) diff --git a/examples/localization/src/app/(frontend)/[locale]/[slug]/page.tsx b/examples/localization/src/app/(frontend)/[locale]/[slug]/page.tsx index 4bf9c89f4cc..7de64bb657b 100644 --- a/examples/localization/src/app/(frontend)/[locale]/[slug]/page.tsx +++ b/examples/localization/src/app/(frontend)/[locale]/[slug]/page.tsx @@ -19,7 +19,7 @@ export async function generateStaticParams() { const payload = await getPayload({ config: configPromise }) const pages = await payload.find({ collection: 'pages', - draft: false, + version: 'published', limit: 1000, overrideAccess: false, }) @@ -94,7 +94,7 @@ const queryPage = cache(async ({ slug, locale }: { slug: string; locale: TypedLo const result = await payload.find({ collection: 'pages', depth: 2, - draft, + version: draft ? 'latest' : 'published', limit: 1, locale, overrideAccess: draft, @@ -105,5 +105,5 @@ const queryPage = cache(async ({ slug, locale }: { slug: string; locale: TypedLo }, }) - return result.docs?.[0] || null + return (result.docs?.[0] || null) as PageType | null }) diff --git a/examples/localization/src/app/(frontend)/[locale]/page.tsx b/examples/localization/src/app/(frontend)/[locale]/page.tsx index 1a6647fc36b..83885846663 100644 --- a/examples/localization/src/app/(frontend)/[locale]/page.tsx +++ b/examples/localization/src/app/(frontend)/[locale]/page.tsx @@ -72,7 +72,7 @@ const queryPage = cache(async ({ locale, slug }: { locale: TypedLocale; slug: st const result = await payload.find({ collection: 'pages', depth: 2, - draft, + version: draft ? 'latest' : 'published', limit: 1, overrideAccess: draft, locale: locale, @@ -83,5 +83,5 @@ const queryPage = cache(async ({ locale, slug }: { locale: TypedLocale; slug: st }, }) - return result.docs?.[0] || null + return (result.docs?.[0] || null) as PageType | null }) diff --git a/examples/localization/src/app/(frontend)/[locale]/posts/[slug]/page.tsx b/examples/localization/src/app/(frontend)/[locale]/posts/[slug]/page.tsx index 25771000b50..9d82491296d 100644 --- a/examples/localization/src/app/(frontend)/[locale]/posts/[slug]/page.tsx +++ b/examples/localization/src/app/(frontend)/[locale]/posts/[slug]/page.tsx @@ -19,7 +19,7 @@ export async function generateStaticParams() { const payload = await getPayload({ config: configPromise }) const posts = await payload.find({ collection: 'posts', - draft: false, + version: 'published', limit: 1000, overrideAccess: false, }) @@ -89,7 +89,7 @@ const queryPost = cache(async ({ slug, locale }: { slug: string; locale: TypedLo const result = await payload.find({ collection: 'posts', depth: 2, - draft, + version: draft ? 'latest' : 'published', limit: 1, overrideAccess: draft, locale, @@ -100,5 +100,5 @@ const queryPost = cache(async ({ slug, locale }: { slug: string; locale: TypedLo }, }) - return result.docs?.[0] || null + return (result.docs?.[0] || null) as Post | null }) diff --git a/examples/localization/src/app/(frontend)/[locale]/posts/page/[pageNumber]/page.tsx b/examples/localization/src/app/(frontend)/[locale]/posts/page/[pageNumber]/page.tsx index 2cd8a68c548..d5a8bffb863 100644 --- a/examples/localization/src/app/(frontend)/[locale]/posts/page/[pageNumber]/page.tsx +++ b/examples/localization/src/app/(frontend)/[locale]/posts/page/[pageNumber]/page.tsx @@ -80,7 +80,7 @@ export async function generateStaticParams() { collection: 'posts', depth: 0, limit: 10, - draft: false, + version: 'published', overrideAccess: false, }) diff --git a/examples/localization/src/app/(frontend)/next/preview/route.ts b/examples/localization/src/app/(frontend)/next/preview/route.ts index b1eca1e0d07..b7b348eae85 100644 --- a/examples/localization/src/app/(frontend)/next/preview/route.ts +++ b/examples/localization/src/app/(frontend)/next/preview/route.ts @@ -68,7 +68,7 @@ export async function GET( try { const docs = await payload.find({ collection: collection, - draft: true, + version: 'latest', locale: path.split('/')[0] as TypedLocale, where: { slug: { diff --git a/packages/codemod/README.md b/packages/codemod/README.md index 5c5cd27e6cc..20154ffa7fb 100644 --- a/packages/codemod/README.md +++ b/packages/codemod/README.md @@ -49,6 +49,26 @@ The tool loads your project via [ts-morph](https://ts-morph.com/), using your `t - `migrate-build-script` — rewrites the `build` npm script in `package.json` from `next build` to `payload build`, so the Import Map (and types) are generated before the Next.js build. Matches the `next build` invocation only (leaves `next build-storybook` and the like untouched) and is a no-op when `build` is already `payload build`. - `migrate-slug-field` — converts the removed experimental `slugField()` helper (imported from `payload`) into the native `{ type: 'slug' }` field, mapping `useAsSlug`/`fieldToUse`, `slugify`, `required`, `localized`, `disableUnique` (→ `unique: false`), and `position` (→ `admin.position`), and dropping the obsolete `checkboxName`. Removes the now-unused `slugField` import. Calls using `overrides` (or other unrecognized options) are left in place with a note for manual migration. - `rename-experimental-table-feature` — renames imports of `EXPERIMENTAL_TableFeature` from `@payloadcms/richtext-lexical` to `TableFeature` (the table feature is now stable) and updates all local usages, e.g. `EXPERIMENTAL_TableFeature()` call sites. +- `migrate-version-action-api` — rewrites leftover `draft` operation options to `version` on reads and `action` on writes, and removes `typescript.strictDraftTypes`. Automatic rewrites (only when the operation is statically identifiable): + + - Read `draft: true` → `version: 'latest'`; `draft: false` → `version: 'published'` + - Write `draft: true` → `action: 'saveDraft'` when no static `_status: 'published'` or existing `action`/`version` changes its meaning + - Create/duplicate `draft: false` → `action: 'publish'` + - Restore `draft: false` → `action: 'publish'`; `draft: true` → `action: 'saveDraft'` + - Static `_status` + `draft` combinations that already resolve to one action drop the obsolete `draft` and keep `_status` + - Unambiguous REST `draft=true|false` query params and GraphQL `draft:` arguments + - `strictDraftTypes` is removed; `strictDraftTypes: false` also emits a note that Local API and SDK types are now always strict + + Manual-review notes (not rewritten): + + - Update `draft: false` without a static `_status` (old behavior depends on existing document state) + - Dynamic `draft` expressions such as `draft: shouldSaveDraft` + - Detached options objects and wrapper helpers whose operation cannot be identified + - Conflicting `action` / `version` / `draft` / `_status` values + - GraphQL strings and REST URLs without enough surrounding operation context + - Localized or computed `_status` values + + The transform never rewrites a property solely because it is named `draft`. Document fields, `versions.drafts`, and UI copy are left untouched. `_status` in write data is preserved. ## Contributing diff --git a/packages/codemod/src/registry.ts b/packages/codemod/src/registry.ts index 13b0eeaa818..098edf5f133 100644 --- a/packages/codemod/src/registry.ts +++ b/packages/codemod/src/registry.ts @@ -18,6 +18,7 @@ import { migrateNextGenerateViewportExport } from './transforms/migrate-next-gen import { migrateNextSubpathExports } from './transforms/migrate-next-subpath-exports/index.js' import { migrateSlugField } from './transforms/migrate-slug-field/index.js' import { migrateStorageAdaptersToConfig } from './transforms/migrate-storage-adapters-to-config/index.js' +import { migrateVersionActionApi } from './transforms/migrate-version-action-api/index.js' import { migrateVersionsDefault } from './transforms/migrate-versions-default/index.js' import { removeGroupByTrue } from './transforms/remove-group-by-true/index.js' import { removeLocalizeStatusConfig } from './transforms/remove-localize-status-config/index.js' @@ -52,6 +53,7 @@ export const transforms: Transform[] = [ removeLocalizeStatusConfig, removeVersionsTrue, removePublishSpecificLocale, + migrateVersionActionApi, renameTypescriptSchemaToJsonSchema, renameExperimentalTableFeature, ] diff --git a/packages/codemod/src/transforms/migrate-version-action-api/alias.input.ts b/packages/codemod/src/transforms/migrate-version-action-api/alias.input.ts new file mode 100644 index 00000000000..367c68444a0 --- /dev/null +++ b/packages/codemod/src/transforms/migrate-version-action-api/alias.input.ts @@ -0,0 +1,23 @@ +import type { Payload, PayloadRequest } from 'payload' +import type { PayloadSDK } from '@payloadcms/sdk' + +export async function aliasedReads(payload: Payload, sdk: PayloadSDK, req: PayloadRequest) { + const fromReq = await req.payload.find({ + collection: 'posts', + draft: true, + }) + + const fromSdk = await sdk.find({ + collection: 'posts', + draft: false, + }) + + const { findByID } = payload + const byID = await findByID({ + id: '1', + collection: 'posts', + draft: true, + }) + + return { byID, fromReq, fromSdk } +} diff --git a/packages/codemod/src/transforms/migrate-version-action-api/alias.output.ts b/packages/codemod/src/transforms/migrate-version-action-api/alias.output.ts new file mode 100644 index 00000000000..1fccd3090fd --- /dev/null +++ b/packages/codemod/src/transforms/migrate-version-action-api/alias.output.ts @@ -0,0 +1,23 @@ +import type { Payload, PayloadRequest } from 'payload' +import type { PayloadSDK } from '@payloadcms/sdk' + +export async function aliasedReads(payload: Payload, sdk: PayloadSDK, req: PayloadRequest) { + const fromReq = await req.payload.find({ + collection: 'posts', + version: 'latest', + }) + + const fromSdk = await sdk.find({ + collection: 'posts', + version: 'published', + }) + + const { findByID } = payload + const byID = await findByID({ + id: '1', + collection: 'posts', + version: 'latest', + }) + + return { byID, fromReq, fromSdk } +} diff --git a/packages/codemod/src/transforms/migrate-version-action-api/already-migrated.input.ts b/packages/codemod/src/transforms/migrate-version-action-api/already-migrated.input.ts new file mode 100644 index 00000000000..e0e4f334731 --- /dev/null +++ b/packages/codemod/src/transforms/migrate-version-action-api/already-migrated.input.ts @@ -0,0 +1,16 @@ +export async function alreadyMigrated(payload) { + const latest = await payload.find({ + collection: 'posts', + version: 'latest', + }) + + const created = await payload.create({ + collection: 'posts', + action: 'saveDraft', + data: { + title: 'Hi', + }, + }) + + return { created, latest } +} diff --git a/packages/codemod/src/transforms/migrate-version-action-api/ambiguous-url.input.ts b/packages/codemod/src/transforms/migrate-version-action-api/ambiguous-url.input.ts new file mode 100644 index 00000000000..16bca79d207 --- /dev/null +++ b/packages/codemod/src/transforms/migrate-version-action-api/ambiguous-url.input.ts @@ -0,0 +1,6 @@ +export function ambiguousStrings() { + const query = 'draft=true' + const search = new URLSearchParams() + search.set('draft', 'true') + return { query, search } +} diff --git a/packages/codemod/src/transforms/migrate-version-action-api/conflict.input.ts b/packages/codemod/src/transforms/migrate-version-action-api/conflict.input.ts new file mode 100644 index 00000000000..65cebd3e0e6 --- /dev/null +++ b/packages/codemod/src/transforms/migrate-version-action-api/conflict.input.ts @@ -0,0 +1,21 @@ +import type { Payload } from 'payload' + +export async function conflicts(payload: Payload) { + const read = await payload.find({ + collection: 'posts', + draft: true, + version: 'published', + }) + + const write = await payload.update({ + id: '1', + action: 'publish', + collection: 'posts', + data: { + title: 'Conflict', + }, + draft: true, + }) + + return { read, write } +} diff --git a/packages/codemod/src/transforms/migrate-version-action-api/detached.input.ts b/packages/codemod/src/transforms/migrate-version-action-api/detached.input.ts new file mode 100644 index 00000000000..48614762ad1 --- /dev/null +++ b/packages/codemod/src/transforms/migrate-version-action-api/detached.input.ts @@ -0,0 +1,9 @@ +const opts = { + collection: 'posts', + draft: true, + limit: 5, +} + +export async function detached(payload) { + return payload.find(opts) +} diff --git a/packages/codemod/src/transforms/migrate-version-action-api/dynamic.input.ts b/packages/codemod/src/transforms/migrate-version-action-api/dynamic.input.ts new file mode 100644 index 00000000000..4d534de2425 --- /dev/null +++ b/packages/codemod/src/transforms/migrate-version-action-api/dynamic.input.ts @@ -0,0 +1,12 @@ +import type { Payload } from 'payload' + +export async function dynamicDraft(payload: Payload, shouldSaveDraft: boolean) { + return payload.update({ + id: '1', + collection: 'posts', + data: { + title: 'Dynamic', + }, + draft: shouldSaveDraft, + }) +} diff --git a/packages/codemod/src/transforms/migrate-version-action-api/external.input.ts b/packages/codemod/src/transforms/migrate-version-action-api/external.input.ts new file mode 100644 index 00000000000..b8be3de6067 --- /dev/null +++ b/packages/codemod/src/transforms/migrate-version-action-api/external.input.ts @@ -0,0 +1,25 @@ +import type { Payload } from 'payload' + +declare const payload: Payload +declare const client: { + find(options: { collection: string; draft: boolean }): Promise +} + +void payload.find({ collection: 'posts', draft: true }) +void client.find({ collection: 'articles', draft: true }) +void fetch('https://other.example/posts?draft=true') +void fetch('/api/feature-flags?draft=true') +void fetch('/api/graphql', { + method: 'POST', + body: JSON.stringify({ + query: `query ThirdPartyQuery { Articles(draft: true) { id } }`, + }), +}) + +const thirdPartyQuery = ` + query Articles { + Articles(draft: true) { id } + } +` + +void thirdPartyQuery diff --git a/packages/codemod/src/transforms/migrate-version-action-api/external.output.ts b/packages/codemod/src/transforms/migrate-version-action-api/external.output.ts new file mode 100644 index 00000000000..0860b930ff6 --- /dev/null +++ b/packages/codemod/src/transforms/migrate-version-action-api/external.output.ts @@ -0,0 +1,25 @@ +import type { Payload } from 'payload' + +declare const payload: Payload +declare const client: { + find(options: { collection: string; draft: boolean }): Promise +} + +void payload.find({ collection: 'posts', version: 'latest' }) +void client.find({ collection: 'articles', draft: true }) +void fetch('https://other.example/posts?draft=true') +void fetch('/api/feature-flags?draft=true') +void fetch('/api/graphql', { + method: 'POST', + body: JSON.stringify({ + query: `query ThirdPartyQuery { Articles(draft: true) { id } }`, + }), +}) + +const thirdPartyQuery = ` + query Articles { + Articles(draft: true) { id } + } +` + +void thirdPartyQuery diff --git a/packages/codemod/src/transforms/migrate-version-action-api/graphql-ambiguous.input.ts b/packages/codemod/src/transforms/migrate-version-action-api/graphql-ambiguous.input.ts new file mode 100644 index 00000000000..bd9e7d93ec4 --- /dev/null +++ b/packages/codemod/src/transforms/migrate-version-action-api/graphql-ambiguous.input.ts @@ -0,0 +1,15 @@ +export const createWithoutMutationKeyword = ` + createPost(data: { title: "Hi" }, draft: true) { + title + } +` + +export const urlConstant = '/api/posts?draft=true' + +export const dynamicGraphql = ` + query Latest($includeDraft: Boolean) { + Posts(draft: $includeDraft) { + docs { title } + } + } +` diff --git a/packages/codemod/src/transforms/migrate-version-action-api/graphql.input.ts b/packages/codemod/src/transforms/migrate-version-action-api/graphql.input.ts new file mode 100644 index 00000000000..94cdb7f3337 --- /dev/null +++ b/packages/codemod/src/transforms/migrate-version-action-api/graphql.input.ts @@ -0,0 +1,49 @@ +import type { Payload } from 'payload' + +export async function queryPayloadGraphQL(payload: Payload) { + const latestPosts = await fetch( + `${payload.config.serverURL}${payload.config.routes.api}/graphql`, + { + method: 'POST', + body: JSON.stringify({ + query: ` + query LatestPosts { + Posts(draft: true) { + docs { title } + } + } + `, + }), + }, + ) + + const publishedPost = await fetch( + `${payload.config.serverURL}${payload.config.routes.api}/graphql`, + { + method: 'POST', + body: JSON.stringify({ + query: ` + query PublishedPost($id: String!) { + Post(id: $id, draft: false) { title } + } + `, + }), + }, + ) + + const createDraft = await fetch( + `${payload.config.serverURL}${payload.config.routes.api}/graphql`, + { + method: 'POST', + body: JSON.stringify({ + query: ` + mutation CreateDraft { + createPost(data: { title: "Hi" }, draft: true) { title } + } + `, + }), + }, + ) + + return { createDraft, latestPosts, publishedPost } +} diff --git a/packages/codemod/src/transforms/migrate-version-action-api/graphql.output.ts b/packages/codemod/src/transforms/migrate-version-action-api/graphql.output.ts new file mode 100644 index 00000000000..7861916bd4e --- /dev/null +++ b/packages/codemod/src/transforms/migrate-version-action-api/graphql.output.ts @@ -0,0 +1,49 @@ +import type { Payload } from 'payload' + +export async function queryPayloadGraphQL(payload: Payload) { + const latestPosts = await fetch( + `${payload.config.serverURL}${payload.config.routes.api}/graphql`, + { + method: 'POST', + body: JSON.stringify({ + query: ` + query LatestPosts { + Posts(version: latest) { + docs { title } + } + } + `, + }), + }, + ) + + const publishedPost = await fetch( + `${payload.config.serverURL}${payload.config.routes.api}/graphql`, + { + method: 'POST', + body: JSON.stringify({ + query: ` + query PublishedPost($id: String!) { + Post(id: $id, version: published) { title } + } + `, + }), + }, + ) + + const createDraft = await fetch( + `${payload.config.serverURL}${payload.config.routes.api}/graphql`, + { + method: 'POST', + body: JSON.stringify({ + query: ` + mutation CreateDraft { + createPost(data: { title: "Hi" }, action: saveDraft) { title } + } + `, + }), + }, + ) + + return { createDraft, latestPosts, publishedPost } +} diff --git a/packages/codemod/src/transforms/migrate-version-action-api/index.spec.ts b/packages/codemod/src/transforms/migrate-version-action-api/index.spec.ts new file mode 100644 index 00000000000..39a491dadc5 --- /dev/null +++ b/packages/codemod/src/transforms/migrate-version-action-api/index.spec.ts @@ -0,0 +1,265 @@ +import { readFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { Project } from 'ts-morph' +import { describe, expect, it } from 'vitest' + +import { runTransform } from '../../utils/test-helpers.js' +import { migrateVersionActionApi } from './index.js' + +const here = dirname(fileURLToPath(import.meta.url)) +const fixture = (name: string) => readFile(join(here, name), 'utf8') + +async function apply(name: string): Promise { + const input = await fixture(name) + return runTransform({ source: input, transform: migrateVersionActionApi }) +} + +async function applyProject(files: Record) { + const project = new Project({ useInMemoryFileSystem: true }) + for (const [path, contents] of Object.entries(files)) { + project.createSourceFile(path, contents) + } + return migrateVersionActionApi.apply({ packageJsons: [], project }) +} + +describe('migrate-version-action-api', () => { + it('should rewrite Local API/SDK read draft booleans to version', async () => { + const output = await fixture('read.output.ts') + + expect(await apply('read.input.ts')).toBe(output) + }) + + it('should preserve comments around rewritten read options', async () => { + const output = await apply('read.input.ts') + + expect(output).toContain('// fetch the newest draft when one exists') + expect(output).toContain("version: 'latest'") + }) + + it('should rewrite safe write draft booleans to action', async () => { + const output = await fixture('write.output.ts') + + expect(await apply('write.input.ts')).toBe(output) + }) + + it('should rewrite restore draft booleans to action', async () => { + const output = await fixture('restore.output.ts') + + expect(await apply('restore.input.ts')).toBe(output) + }) + + it('should remove typescript.strictDraftTypes', async () => { + const output = await fixture('strict-draft-types.output.ts') + + expect(await apply('strict-draft-types.input.ts')).toBe(output) + }) + + it('should remove strictDraftTypes: false and notes that types are always strict', async () => { + const output = await fixture('strict-draft-types-false.output.ts') + const input = await fixture('strict-draft-types-false.input.ts') + const project = new Project({ useInMemoryFileSystem: true }) + project.createSourceFile('config.ts', input) + + const result = await migrateVersionActionApi.apply({ packageJsons: [], project }) + + expect(project.getSourceFileOrThrow('config.ts').getFullText()).toBe(output) + expect(result.notes).toEqual([expect.stringContaining('removed `strictDraftTypes: false`')]) + }) + + it('should rewrite contextual REST draft query params', async () => { + const output = await fixture('rest.output.ts') + + expect(await apply('rest.input.ts')).toBe(output) + }) + + it('should rewrite contextual GraphQL draft arguments', async () => { + const output = await fixture('graphql.output.ts') + + expect(await apply('graphql.input.ts')).toBe(output) + }) + + it('should drop obsolete draft when static _status already infers the action', async () => { + const output = await fixture('status.output.ts') + + expect(await apply('status.input.ts')).toBe(output) + }) + + it('should rewrite aliased payload, sdk, and identifier call sites', async () => { + const output = await fixture('alias.output.ts') + + expect(await apply('alias.input.ts')).toBe(output) + }) + + it('should leave unrelated client calls, REST URLs, and GraphQL documents unchanged', async () => { + const input = await fixture('external.input.ts') + const output = await fixture('external.output.ts') + const project = new Project({ useInMemoryFileSystem: true }) + project.createSourceFile('/external.ts', input) + + const result = await migrateVersionActionApi.apply({ packageJsons: [], project }) + + expect(project.getSourceFileOrThrow('/external.ts').getFullText()).toBe(output) + expect(result.notes).toEqual([ + expect.stringContaining('wrapper or unclassified call'), + expect.stringContaining('REST `draft` query without enough operation context'), + expect.stringContaining('REST `draft` query without enough operation context'), + expect.stringContaining('GraphQL `draft` argument without enough operation context'), + expect.stringContaining('GraphQL `draft` argument without enough operation context'), + ]) + }) + + it('should be a no-op on already-migrated input', async () => { + const input = await fixture('already-migrated.input.ts') + + expect(await apply('already-migrated.input.ts')).toBe(input) + }) + + it('should be idempotent when run on rewritten output', async () => { + for (const name of [ + 'read.output.ts', + 'write.output.ts', + 'restore.output.ts', + 'strict-draft-types.output.ts', + 'rest.output.ts', + 'graphql.output.ts', + 'status.output.ts', + 'alias.output.ts', + 'strict-draft-types-false.output.ts', + ]) { + const output = await fixture(name) + + expect(await runTransform({ source: output, transform: migrateVersionActionApi })).toBe( + output, + ) + } + }) + + it('should not rewrite update draft: false without static status and emits a note', async () => { + const input = await fixture('update-draft-false.input.ts') + const project = new Project({ useInMemoryFileSystem: true }) + project.createSourceFile('update.ts', input) + + const result = await migrateVersionActionApi.apply({ packageJsons: [], project }) + + expect(project.getSourceFileOrThrow('update.ts').getFullText()).toBe(input) + expect(result.filesChanged).toEqual([]) + expect(result.notes).toEqual([expect.stringContaining('update `draft: false`')]) + }) + + it('should not rewrite dynamic draft values and emits a note', async () => { + const input = await fixture('dynamic.input.ts') + + expect(await apply('dynamic.input.ts')).toBe(input) + + const result = await applyProject({ '/dynamic.ts': input }) + expect(result.notes).toEqual([expect.stringContaining('dynamic `draft`')]) + }) + + it('should not rewrite detached options objects and emits a note', async () => { + const input = await fixture('detached.input.ts') + + expect(await apply('detached.input.ts')).toBe(input) + + const result = await applyProject({ '/detached.ts': input }) + expect(result.notes).toEqual([expect.stringContaining('detached options object')]) + }) + + it('should not rewrite wrapper-built options and emits a note', async () => { + const input = await fixture('wrapper.input.ts') + + expect(await apply('wrapper.input.ts')).toBe(input) + + const result = await applyProject({ '/wrapper.ts': input }) + expect(result.notes).toEqual([expect.stringContaining('detached options object')]) + }) + + it('should not rewrite conflicting draft/_status combinations and emits a note', async () => { + const input = await fixture('status-conflict.input.ts') + + expect(await apply('status-conflict.input.ts')).toBe(input) + + const result = await applyProject({ '/status-conflict.ts': input }) + expect(result.filesChanged).toEqual([]) + expect(result.notes).toEqual([ + expect.stringContaining('conflicting `draft` and `_status`'), + expect.stringContaining('conflicting `draft` and `_status`'), + ]) + }) + + it('should not rewrite GraphQL/REST strings without operation context', async () => { + const input = await fixture('graphql-ambiguous.input.ts') + + expect(await apply('graphql-ambiguous.input.ts')).toBe(input) + + const result = await applyProject({ '/gql.ts': input }) + expect(result.filesChanged).toEqual([]) + expect(result.notes).toEqual([ + expect.stringContaining('REST `draft` query without enough operation context'), + expect.stringContaining('GraphQL `draft` argument without enough operation context'), + expect.stringContaining('GraphQL `draft` argument without enough operation context'), + ]) + }) + + it('should not rewrite conflicting draft/version/action values and emits a note', async () => { + const input = await fixture('conflict.input.ts') + + expect(await apply('conflict.input.ts')).toBe(input) + + const result = await applyProject({ '/conflict.ts': input }) + expect(result.notes).toEqual([ + expect.stringContaining('conflicting `draft`'), + expect.stringContaining('conflicting `draft`'), + ]) + }) + + it('should not rewrite ambiguous REST strings and emits a note', async () => { + const input = await fixture('ambiguous-url.input.ts') + + expect(await apply('ambiguous-url.input.ts')).toBe(input) + + const result = await applyProject({ '/url.ts': input }) + expect(result.notes).toEqual([ + expect.stringContaining('REST `draft` query without enough operation context'), + ]) + }) + + it('should not rewrite localized or computed _status combinations and emits a note', async () => { + const input = await fixture('localized-status.input.ts') + + expect(await apply('localized-status.input.ts')).toBe(input) + + const result = await applyProject({ '/status.ts': input }) + expect(result.notes).toEqual([ + expect.stringContaining('localized or computed `_status`'), + expect.stringContaining('localized or computed `_status`'), + ]) + }) + + it('should not rewrite legitimate drafts config, document fields, or UI copy', async () => { + const input = await fixture('non-matching.input.ts') + + expect(await apply('non-matching.input.ts')).toBe(input) + }) + + it('should report exact filesChanged for rewritten files only', async () => { + const read = await fixture('read.input.ts') + const untouched = await fixture('already-migrated.input.ts') + const result = await applyProject({ + '/migrated.ts': untouched, + '/posts.ts': read, + }) + + expect(result.filesChanged).toEqual(['/posts.ts']) + }) + + it('should not touch the filesystem', async () => { + const project = new Project({ useInMemoryFileSystem: true }) + project.createSourceFile('/memory.ts', await fixture('read.input.ts')) + + const result = await migrateVersionActionApi.apply({ packageJsons: [], project }) + + expect(result.filesChanged).toEqual(['/memory.ts']) + expect(project.getSourceFileOrThrow('/memory.ts').getFullText()).toContain("version: 'latest'") + }) +}) diff --git a/packages/codemod/src/transforms/migrate-version-action-api/index.ts b/packages/codemod/src/transforms/migrate-version-action-api/index.ts new file mode 100644 index 00000000000..205c908e837 --- /dev/null +++ b/packages/codemod/src/transforms/migrate-version-action-api/index.ts @@ -0,0 +1,1024 @@ +import type { + CallExpression, + Node as MorphNode, + ObjectLiteralExpression, + PropertyAssignment, + SourceFile, +} from 'ts-morph' + +import { Node, SyntaxKind } from 'ts-morph' + +import type { Transform } from '../../types.js' + +type OperationKind = 'create' | 'read' | 'restore' | 'update' + +type StaticStatus = 'computed' | 'draft' | 'localized' | 'published' + +const READ_METHODS = new Set(['count', 'find', 'findByID', 'findDistinct', 'findGlobal', 'findOne']) + +const CREATE_METHODS = new Set(['create', 'duplicate']) + +const UPDATE_METHODS = new Set(['update', 'updateGlobal']) + +const RESTORE_METHODS = new Set(['restoreGlobalVersion', 'restoreVersion']) + +export const migrateVersionActionApi: Transform = { + name: 'migrate-version-action-api', + apply: ({ project }) => { + const filesChanged = new Set() + const notes: string[] = [] + + for (const sourceFile of project.getSourceFiles()) { + const filePath = sourceFile.getFilePath() + let mutated = false + + if (rewriteCallOptions({ filePath, notes, sourceFile })) { + mutated = true + } + + if (rewriteStrictDraftTypes({ filePath, notes, sourceFile })) { + mutated = true + } + + if (rewriteStringDrafts({ filePath, notes, sourceFile })) { + mutated = true + } + + noteUnhandledDraftOptions({ filePath, notes, sourceFile }) + + if (mutated) { + filesChanged.add(filePath) + } + } + + return { + filesChanged: [...filesChanged], + ...(notes.length > 0 ? { notes } : {}), + } + }, + description: + 'Rewrites leftover `draft` operation options to `version` on reads and `action` on writes, removes `typescript.strictDraftTypes`, and rewrites unambiguous REST/GraphQL `draft` arguments. Emits notes for update `draft: false` without static `_status`, dynamic values, detached options, wrappers, conflicts, ambiguous strings/URLs, localized/computed `_status`, and `strictDraftTypes: false`.', +} + +function rewriteCallOptions({ + filePath, + notes, + sourceFile, +}: { + filePath: string + notes: string[] + sourceFile: SourceFile +}): boolean { + let mutated = false + + for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) { + const kind = getOperationKind(call) + const options = getOptionsObject(call) + + if (!kind) { + if (options && hasDraftProperty(options) && looksLikeOperationOptions(options)) { + notes.push( + `${filePath}: wrapper or unclassified call with \`draft\` could not be mapped — set \`version\` or \`action\` at the Payload operation call site.`, + ) + } + continue + } + + if (!options) { + continue + } + + if (rewriteOptionsObject({ filePath, kind, notes, options })) { + mutated = true + } + } + + return mutated +} + +function rewriteOptionsObject({ + filePath, + kind, + notes, + options, +}: { + filePath: string + kind: OperationKind + notes: string[] + options: ObjectLiteralExpression +}): boolean { + const draftProp = getNamedPropertyAssignment(options, 'draft') + if (!draftProp) { + const shorthand = options.getProperty('draft') + if (shorthand && Node.isShorthandPropertyAssignment(shorthand)) { + notes.push( + `${filePath}: dynamic \`draft\` value cannot be rewritten safely — replace it with \`version\` or \`action\` manually.`, + ) + } + return false + } + + const draftValue = getStaticBoolean(draftProp.getInitializer()) + if (draftValue === undefined) { + notes.push( + `${filePath}: dynamic \`draft\` value cannot be rewritten safely — replace it with \`version\` or \`action\` manually.`, + ) + return false + } + + if (options.getProperty('version') || options.getProperty('action')) { + notes.push( + `${filePath}: conflicting \`draft\` and \`version\`/\`action\` values — resolve the operation intent manually.`, + ) + return false + } + + const status = getDataStatus(options) + + if (status === 'localized' || status === 'computed') { + notes.push( + `${filePath}: localized or computed \`_status\` combined with \`draft\` — set \`action\` explicitly and keep \`_status\` in write data.`, + ) + return false + } + + if (kind === 'read') { + draftProp.set({ + name: 'version', + initializer: draftValue ? "'latest'" : "'published'", + }) + return true + } + + if (kind === 'restore') { + draftProp.set({ + name: 'action', + initializer: draftValue ? "'saveDraft'" : "'publish'", + }) + return true + } + + if (!draftValue && kind === 'update' && status === undefined) { + notes.push( + `${filePath}: update \`draft: false\` without a static \`_status\` depends on existing document state — set \`action: 'publish'\` or \`action: 'saveDraft'\` explicitly.`, + ) + return false + } + + const inferredFromStatus = + status === 'draft' ? 'saveDraft' : status === 'published' ? 'publish' : undefined + const mappedAction = draftValue ? 'saveDraft' : 'publish' + + if (inferredFromStatus && inferredFromStatus !== mappedAction) { + notes.push( + `${filePath}: conflicting \`draft\` and \`_status\` values — set \`action\` explicitly and keep \`_status\` in write data.`, + ) + return false + } + + if (inferredFromStatus === mappedAction) { + draftProp.remove() + return true + } + + draftProp.set({ + name: 'action', + initializer: `'${mappedAction}'`, + }) + return true +} + +function rewriteStrictDraftTypes({ + filePath, + notes, + sourceFile, +}: { + filePath: string + notes: string[] + sourceFile: SourceFile +}): boolean { + let mutated = false + + for (const prop of [...sourceFile.getDescendantsOfKind(SyntaxKind.PropertyAssignment)]) { + if (prop.wasForgotten() || prop.getName() !== 'strictDraftTypes') { + continue + } + + const parent = prop.getParentIfKind(SyntaxKind.ObjectLiteralExpression) + const typescriptProp = parent?.getParent() + if ( + !parent || + !Node.isPropertyAssignment(typescriptProp) || + typescriptProp.getName() !== 'typescript' + ) { + continue + } + + const wasFalse = getStaticBoolean(prop.getInitializer()) === false + + prop.remove() + mutated = true + + if (wasFalse) { + notes.push( + `${filePath}: removed \`strictDraftTypes: false\`; Local API and SDK types are now always strict.`, + ) + } + + if (parent && !parent.wasForgotten() && parent.getProperties().length === 0) { + const typescriptProp = parent.getParent() + if (Node.isPropertyAssignment(typescriptProp) && typescriptProp.getName() === 'typescript') { + typescriptProp.remove() + } + } + } + + return mutated +} + +function rewriteStringDrafts({ + filePath, + notes, + sourceFile, +}: { + filePath: string + notes: string[] + sourceFile: SourceFile +}): boolean { + let mutated = false + const handled = new Set() + + for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) { + const method = getCallMethodName(call) + if (method !== 'fetch') { + continue + } + + const urlArg = call.getArguments()[0] + if (!urlArg || !isStringLike(urlArg)) { + continue + } + + if (!isPayloadRestUrl({ node: urlArg })) { + const urlText = getStringLikeText({ node: urlArg }) + if (hasDynamicDraftQuery(urlText)) { + notes.push( + `${filePath}: dynamic \`draft\` query cannot be rewritten safely — replace it with \`version\` or \`action\` manually.`, + ) + } else if (hasDraftQueryParam(urlText)) { + notes.push( + `${filePath}: REST \`draft\` query without enough operation context — replace with \`version\` or \`action\` manually.`, + ) + } + handled.add(urlArg) + continue + } + + const fetchKind = getFetchOperationKind(call) + const rewritten = rewriteQueryDraft(urlArg.getText(), fetchKind) + + if (rewritten.note) { + notes.push(`${filePath}: ${rewritten.note}`) + } + + if (rewritten.text && rewritten.text !== urlArg.getText()) { + urlArg.replaceWithText(rewritten.text) + mutated = true + } + + handled.add(urlArg) + + if (isPayloadGraphqlUrl({ node: urlArg })) { + const initArg = call.getArguments()[1] + if (initArg) { + for (const literal of getStringLikeDescendants({ node: initArg })) { + const rewrittenGraphql = rewriteGraphqlDraftArgs(literal.getText()) + if (rewrittenGraphql.changed) { + literal.replaceWithText(rewrittenGraphql.text) + mutated = true + } else if (rewrittenGraphql.ambiguous) { + notes.push( + `${filePath}: GraphQL \`draft\` argument without enough operation context — replace with \`version\` or \`action\` manually.`, + ) + } + handled.add(literal) + } + } + } + } + + for (const literal of [ + ...sourceFile.getDescendantsOfKind(SyntaxKind.StringLiteral), + ...sourceFile.getDescendantsOfKind(SyntaxKind.NoSubstitutionTemplateLiteral), + ]) { + if (handled.has(literal) || literal.wasForgotten()) { + continue + } + + if (Node.isTaggedTemplateExpression(literal.getParent())) { + continue + } + + const original = literal.getText() + const graphqlRewritten = rewriteGraphqlDraftArgs(original) + + if (graphqlRewritten.changed || graphqlRewritten.ambiguous) { + notes.push( + `${filePath}: GraphQL \`draft\` argument without enough operation context — replace with \`version\` or \`action\` manually.`, + ) + continue + } + + const body = Node.isStringLiteral(literal) ? literal.getLiteralText() : literal.getLiteralText() + + if (hasDynamicDraftQuery(body) || hasDynamicGraphqlDraft(body)) { + notes.push( + `${filePath}: dynamic \`draft\` value cannot be rewritten safely — replace it with \`version\` or \`action\` manually.`, + ) + continue + } + + if (hasDraftQueryParam(body)) { + notes.push( + `${filePath}: REST \`draft\` query without enough operation context — replace with \`version\` or \`action\` manually.`, + ) + } + } + + for (const tagged of sourceFile.getDescendantsOfKind(SyntaxKind.TaggedTemplateExpression)) { + const tag = tagged.getTag() + const tagName = tag.getText() + if (tagName !== 'gql' && tagName !== 'graphql' && !tagName.endsWith('.gql')) { + continue + } + + const template = tagged.getTemplate() + const original = template.getText() + const graphqlRewritten = rewriteGraphqlDraftArgs(original) + + if (graphqlRewritten.changed || graphqlRewritten.ambiguous) { + notes.push( + `${filePath}: GraphQL \`draft\` argument without enough operation context — replace with \`version\` or \`action\` manually.`, + ) + } + } + + return mutated +} + +function isPayloadRestUrl({ node }: { node: MorphNode }): boolean { + if (!Node.isTemplateExpression(node)) { + return false + } + + return node + .getTemplateSpans() + .some((span) => isPayloadApiRouteExpression({ node: span.getExpression() })) +} + +function isPayloadGraphqlUrl({ node }: { node: MorphNode }): boolean { + if (!isPayloadRestUrl({ node })) { + return false + } + + const text = getStringLikeText({ node }) + return /\}\s*\/graphql(?:[/?#]|$)/.test(text) +} + +function isPayloadApiRouteExpression({ node }: { node: MorphNode }): boolean { + const apiAccess = unwrap(node) ?? node + if (!Node.isPropertyAccessExpression(apiAccess) || apiAccess.getName() !== 'api') { + return false + } + + const routesAccess = unwrap(apiAccess.getExpression()) ?? apiAccess.getExpression() + if (!Node.isPropertyAccessExpression(routesAccess) || routesAccess.getName() !== 'routes') { + return false + } + + const configAccess = unwrap(routesAccess.getExpression()) ?? routesAccess.getExpression() + if (!Node.isPropertyAccessExpression(configAccess) || configAccess.getName() !== 'config') { + return false + } + + return isProvenPayloadReceiver({ node: configAccess.getExpression() }) +} + +function getStringLikeText({ node }: { node: MorphNode }): string { + if (Node.isStringLiteral(node) || Node.isNoSubstitutionTemplateLiteral(node)) { + return node.getLiteralText() + } + + return node.getText().slice(1, -1) +} + +function getStringLikeDescendants({ node }: { node: MorphNode }) { + return [ + ...node.getDescendantsOfKind(SyntaxKind.StringLiteral), + ...node.getDescendantsOfKind(SyntaxKind.NoSubstitutionTemplateLiteral), + ] +} + +function noteUnhandledDraftOptions({ + filePath, + notes, + sourceFile, +}: { + filePath: string + notes: string[] + sourceFile: SourceFile +}): void { + for (const prop of sourceFile.getDescendantsOfKind(SyntaxKind.PropertyAssignment)) { + if (prop.getName() !== 'draft') { + continue + } + + const parent = prop.getParentIfKind(SyntaxKind.ObjectLiteralExpression) + if (!parent || isInsideDataProperty(prop) || isCallOptionsObject(parent)) { + continue + } + + if (looksLikeOperationOptions(parent)) { + notes.push( + `${filePath}: detached options object with \`draft\` is not at a Payload call site — inline it or set \`version\`/\`action\` on the call.`, + ) + } + } +} + +function getOperationKind(call: CallExpression): OperationKind | undefined { + if (!isProvenPayloadOperationCall({ call })) { + return undefined + } + + const methodName = getCallMethodName(call) + if (!methodName) { + return undefined + } + + if (READ_METHODS.has(methodName)) { + return 'read' + } + if (CREATE_METHODS.has(methodName)) { + return 'create' + } + if (UPDATE_METHODS.has(methodName)) { + return 'update' + } + if (RESTORE_METHODS.has(methodName)) { + return 'restore' + } + + return undefined +} + +function isProvenPayloadOperationCall({ call }: { call: CallExpression }): boolean { + const expression = call.getExpression() + + if (Node.isPropertyAccessExpression(expression)) { + return isProvenPayloadReceiver({ node: expression.getExpression() }) + } + + if (!Node.isIdentifier(expression)) { + return false + } + + const sourceFile = call.getSourceFile() + const name = expression.getText() + + for (const declaration of sourceFile.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) { + const nameNode = declaration.getNameNode() + if (!Node.isObjectBindingPattern(nameNode)) { + continue + } + + const binding = nameNode + .getElements() + .find((element) => element.getNameNode().getText() === name) + const initializer = declaration.getInitializer() + + if (binding && initializer && isProvenPayloadReceiver({ node: initializer })) { + return true + } + } + + return false +} + +function isProvenPayloadReceiver({ node }: { node: MorphNode }): boolean { + const receiver = unwrapReceiver({ node }) + if (!receiver) { + return false + } + const sourceFile = receiver.getSourceFile() + + if (Node.isPropertyAccessExpression(receiver) && receiver.getName() === 'payload') { + return isIdentifierWithPayloadType({ + allowedExports: ['PayloadRequest'], + node: receiver.getExpression(), + }) + } + + if (!Node.isIdentifier(receiver)) { + return false + } + + const name = receiver.getText() + + for (const importDeclaration of sourceFile.getImportDeclarations()) { + const moduleName = importDeclaration.getModuleSpecifierValue() + const defaultImport = importDeclaration.getDefaultImport() + + if (moduleName === 'payload' && defaultImport?.getText() === name) { + return true + } + } + + return ( + isIdentifierWithPayloadType({ + allowedExports: ['Payload', 'PayloadSDK'], + node: receiver, + }) || isIdentifierInitializedByPayloadFactory({ node: receiver }) + ) +} + +function isIdentifierWithPayloadType({ + allowedExports, + node, +}: { + allowedExports: string[] + node: MorphNode +}): boolean { + if (!Node.isIdentifier(node)) { + return false + } + + const name = node.getText() + const sourceFile = node.getSourceFile() + const payloadTypeNames = getImportedPayloadNames({ allowedExports, sourceFile }) + + for (const parameter of sourceFile.getDescendantsOfKind(SyntaxKind.Parameter)) { + if ( + parameter.getName() === name && + typeUsesPayloadName({ payloadTypeNames, typeNode: parameter.getTypeNode() }) + ) { + return true + } + } + + for (const declaration of sourceFile.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) { + if ( + declaration.getName() === name && + typeUsesPayloadName({ payloadTypeNames, typeNode: declaration.getTypeNode() }) + ) { + return true + } + } + + return false +} + +function getImportedPayloadNames({ + allowedExports, + sourceFile, +}: { + allowedExports: string[] + sourceFile: SourceFile +}): Set { + const names = new Set() + + for (const importDeclaration of sourceFile.getImportDeclarations()) { + const moduleName = importDeclaration.getModuleSpecifierValue() + if (moduleName !== 'payload' && moduleName !== '@payloadcms/sdk') { + continue + } + + for (const namedImport of importDeclaration.getNamedImports()) { + if (allowedExports.includes(namedImport.getName())) { + names.add(namedImport.getAliasNode()?.getText() ?? namedImport.getName()) + } + } + } + + return names +} + +function typeUsesPayloadName({ + payloadTypeNames, + typeNode, +}: { + payloadTypeNames: Set + typeNode: MorphNode | undefined +}): boolean { + if (!typeNode) { + return false + } + + const typeText = typeNode.getText() + return [...payloadTypeNames].some((name) => new RegExp(`\\b${name}\\b`).test(typeText)) +} + +function isIdentifierInitializedByPayloadFactory({ node }: { node: MorphNode }): boolean { + if (!Node.isIdentifier(node)) { + return false + } + + const name = node.getText() + const sourceFile = node.getSourceFile() + const sdkNames = getImportedPayloadNames({ allowedExports: ['PayloadSDK'], sourceFile }) + const getPayloadNames = getImportedPayloadNames({ allowedExports: ['getPayload'], sourceFile }) + + for (const declaration of sourceFile.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) { + if (declaration.getName() !== name) { + continue + } + + const initializer = unwrapReceiver({ node: declaration.getInitializer() }) + if (Node.isNewExpression(initializer)) { + return sdkNames.has(initializer.getExpression().getText()) + } + + if (Node.isCallExpression(initializer)) { + return getPayloadNames.has(initializer.getExpression().getText()) + } + } + + return false +} + +function unwrapReceiver({ node }: { node: MorphNode | undefined }): MorphNode | undefined { + if (!node) { + return undefined + } + + let current = unwrap(node) ?? node + while (Node.isAwaitExpression(current)) { + current = unwrap(current.getExpression()) ?? current.getExpression() + } + + return current +} + +function getCallMethodName(call: CallExpression): string | undefined { + const expr = call.getExpression() + + if (Node.isPropertyAccessExpression(expr)) { + return expr.getName() + } + + if (Node.isIdentifier(expr)) { + return expr.getText() + } + + return undefined +} + +function getOptionsObject(call: CallExpression): ObjectLiteralExpression | undefined { + const firstArg = call.getArguments()[0] + if (!firstArg || !Node.isObjectLiteralExpression(firstArg)) { + return undefined + } + return firstArg +} + +function getNamedPropertyAssignment( + obj: ObjectLiteralExpression, + name: string, +): PropertyAssignment | undefined { + const prop = obj.getProperty(name) + if (!prop || !Node.isPropertyAssignment(prop)) { + return undefined + } + return prop +} + +function hasDraftProperty(obj: ObjectLiteralExpression): boolean { + return obj.getProperty('draft') !== undefined +} + +function looksLikeOperationOptions(obj: ObjectLiteralExpression): boolean { + if (obj.getProperty('collection')) { + return true + } + + if (obj.getProperty('slug') && !obj.getProperty('fields')) { + return Boolean(obj.getProperty('data') || obj.getProperty('depth') || obj.getProperty('where')) + } + + return false +} + +function isCallOptionsObject(obj: ObjectLiteralExpression): boolean { + const parent = obj.getParent() + return Node.isCallExpression(parent) && parent.getArguments()[0] === obj +} + +function isInsideDataProperty(prop: PropertyAssignment): boolean { + let current: MorphNode | undefined = prop.getParent() + + while (current) { + if (Node.isPropertyAssignment(current) && current.getName() === 'data') { + return true + } + current = current.getParent() + } + + return false +} + +function getDataStatus(options: ObjectLiteralExpression): StaticStatus | undefined { + const dataProp = getNamedPropertyAssignment(options, 'data') + if (!dataProp) { + return undefined + } + + const dataInit = unwrap(dataProp.getInitializer()) + if (!dataInit) { + return undefined + } + + if (Node.isIdentifier(dataInit)) { + return 'computed' + } + + if (!Node.isObjectLiteralExpression(dataInit)) { + return 'computed' + } + + const statusProp = getNamedPropertyAssignment(dataInit, '_status') + if (!statusProp) { + return undefined + } + + const statusInit = unwrap(statusProp.getInitializer()) + if (!statusInit) { + return 'computed' + } + + if (Node.isStringLiteral(statusInit) || Node.isNoSubstitutionTemplateLiteral(statusInit)) { + const value = statusInit.getLiteralValue() + if (value === 'draft' || value === 'published') { + return value + } + return 'computed' + } + + if (Node.isObjectLiteralExpression(statusInit)) { + return 'localized' + } + + return 'computed' +} + +function getStaticBoolean(node: MorphNode | undefined): boolean | undefined { + if (!node) { + return undefined + } + + const inner = unwrap(node) + if (!inner) { + return undefined + } + if (inner.getKind() === SyntaxKind.TrueKeyword) { + return true + } + if (inner.getKind() === SyntaxKind.FalseKeyword) { + return false + } + + return undefined +} + +function unwrap(node: MorphNode | undefined): MorphNode | undefined { + if (!node) { + return undefined + } + + let current = node + + while ( + Node.isAsExpression(current) || + Node.isParenthesizedExpression(current) || + Node.isSatisfiesExpression(current) + ) { + current = current.getExpression() + } + + return current +} + +function getFetchOperationKind(call: CallExpression): OperationKind { + const initArg = call.getArguments()[1] + const method = getFetchMethod(initArg) + + if (method === 'POST') { + const urlText = call.getArguments()[0]?.getText() ?? '' + if (/restore/i.test(urlText)) { + return 'restore' + } + return 'create' + } + + if (method === 'PATCH' || method === 'PUT') { + return 'update' + } + + return 'read' +} + +function getFetchMethod(initArg: MorphNode | undefined): string | undefined { + if (!initArg || !Node.isObjectLiteralExpression(initArg)) { + return undefined + } + + const methodProp = getNamedPropertyAssignment(initArg, 'method') + const value = methodProp?.getInitializer() + if (!value || !(Node.isStringLiteral(value) || Node.isNoSubstitutionTemplateLiteral(value))) { + return undefined + } + + return value.getLiteralValue().toUpperCase() +} + +function isStringLike(node: MorphNode): boolean { + return ( + Node.isStringLiteral(node) || + Node.isNoSubstitutionTemplateLiteral(node) || + Node.isTemplateExpression(node) + ) +} + +function rewriteQueryDraft(text: string, kind: OperationKind): { note?: string; text: string } { + if (hasDynamicDraftQuery(text)) { + return { + note: 'dynamic `draft` query cannot be rewritten safely — replace it with `version` or `action` manually.', + text, + } + } + + if (!/\bdraft=(?:true|false)\b/.test(text)) { + return { text } + } + + if (kind === 'read') { + return { + text: text + .replace(/\bdraft=true\b/g, 'version=latest') + .replace(/\bdraft=false\b/g, 'version=published'), + } + } + + if (kind === 'update') { + if (/\bdraft=false\b/.test(text) && !/\b_status=published\b/.test(text)) { + return { + note: 'update `draft=false` REST query without a static `_status` depends on existing document state — set `action=publish` or `action=saveDraft` explicitly.', + text, + } + } + } + + return { + text: text + .replace(/\bdraft=true\b/g, 'action=saveDraft') + .replace(/\bdraft=false\b/g, 'action=publish'), + } +} + +function rewriteGraphqlDraftArgs(text: string): { + ambiguous: boolean + changed: boolean + text: string +} { + if (hasDynamicGraphqlDraft(text) && /[({]/.test(text)) { + return { ambiguous: true, changed: false, text } + } + + if (!/\bdraft:\s*(?:true|false)\b/.test(text)) { + return { ambiguous: false, changed: false, text } + } + + let changed = false + let ambiguous = false + const next = text.replace( + /\bdraft:\s*(true|false)\b/g, + (match, value: string, offset: number) => { + const enclosing = enclosingBracket({ offset, text }) + if (enclosing !== '(') { + return match + } + + const fieldName = graphqlFieldNameBefore({ offset, text }) + const operation = graphqlOperationBefore({ offset, text }) + const boolValue = value === 'true' + + if (operation === 'mutation') { + if (!fieldName) { + ambiguous = true + return match + } + + if (/^restore/i.test(fieldName)) { + changed = true + return `action: ${boolValue ? 'saveDraft' : 'publish'}` + } + + if (/^(?:create|duplicate)/i.test(fieldName)) { + changed = true + return `action: ${boolValue ? 'saveDraft' : 'publish'}` + } + + if (/^update/i.test(fieldName)) { + if (!boolValue) { + ambiguous = true + return match + } + changed = true + return `action: saveDraft` + } + + ambiguous = true + return match + } + + if (operation === 'query' || operation === 'subscription') { + changed = true + return `version: ${boolValue ? 'latest' : 'published'}` + } + + ambiguous = true + return match + }, + ) + + return { ambiguous, changed, text: next } +} + +function enclosingBracket({ + offset, + text, +}: { + offset: number + text: string +}): '(' | '{' | undefined { + const stack: Array<'(' | '{'> = [] + + for (let i = 0; i < offset; i++) { + const char = text[i] + if (char === '(' || char === '{') { + stack.push(char) + } else if (char === ')' || char === '}') { + stack.pop() + } + } + + return stack.at(-1) +} + +function graphqlFieldNameBefore({ + offset, + text, +}: { + offset: number + text: string +}): string | undefined { + const before = text.slice(0, offset) + const openParen = before.lastIndexOf('(') + if (openParen === -1) { + return undefined + } + + const nameMatch = before.slice(0, openParen).match(/([A-Z_]\w*)\s*$/i) + return nameMatch?.[1] +} + +function graphqlOperationBefore({ + offset, + text, +}: { + offset: number + text: string +}): 'mutation' | 'query' | 'subscription' | undefined { + const before = text.slice(0, offset) + if (/\bmutation\b/.test(before)) { + return 'mutation' + } + if (/\bsubscription\b/.test(before)) { + return 'subscription' + } + if (/\bquery\b/.test(before)) { + return 'query' + } + return undefined +} + +function hasDraftQueryParam(value: string): boolean { + return /\bdraft=(?:true|false)\b/.test(value) +} + +function hasDynamicDraftQuery(value: string): boolean { + return /\bdraft=(?!true\b|false\b)/.test(value) || /\bdraft=\$\{/.test(value) +} + +function hasDynamicGraphqlDraft(value: string): boolean { + return /\bdraft:\s*(?!true\b|false\b)[^\s,)]+/.test(value) +} diff --git a/packages/codemod/src/transforms/migrate-version-action-api/localized-status.input.ts b/packages/codemod/src/transforms/migrate-version-action-api/localized-status.input.ts new file mode 100644 index 00000000000..ca428b218d8 --- /dev/null +++ b/packages/codemod/src/transforms/migrate-version-action-api/localized-status.input.ts @@ -0,0 +1,25 @@ +import type { Payload } from 'payload' + +export async function localizedStatus(payload: Payload) { + return payload.update({ + id: '1', + collection: 'posts', + data: { + _status: { + en: 'draft', + es: 'published', + }, + title: 'Localized', + }, + draft: true, + }) +} + +export async function computedStatus(payload, existingDoc) { + return payload.update({ + id: '1', + collection: 'posts', + data: existingDoc, + draft: true, + }) +} diff --git a/packages/codemod/src/transforms/migrate-version-action-api/non-matching.input.ts b/packages/codemod/src/transforms/migrate-version-action-api/non-matching.input.ts new file mode 100644 index 00000000000..29f86bd1cea --- /dev/null +++ b/packages/codemod/src/transforms/migrate-version-action-api/non-matching.input.ts @@ -0,0 +1,26 @@ +import type { CollectionConfig } from 'payload' + +export const Posts: CollectionConfig = { + slug: 'posts', + fields: [ + { + name: 'draft', + type: 'checkbox', + }, + ], + versions: { + drafts: true, + }, +} + +export async function createWithDraftField(payload) { + return payload.create({ + collection: 'posts', + data: { + draft: true, + title: 'Document field named draft', + }, + }) +} + +export const copy = 'Save Draft' diff --git a/packages/codemod/src/transforms/migrate-version-action-api/read.input.ts b/packages/codemod/src/transforms/migrate-version-action-api/read.input.ts new file mode 100644 index 00000000000..cdc195493c8 --- /dev/null +++ b/packages/codemod/src/transforms/migrate-version-action-api/read.input.ts @@ -0,0 +1,28 @@ +import type { Payload } from 'payload' + +export async function loadPosts(payload: Payload) { + const latest = await payload.find({ + collection: 'posts', + // fetch the newest draft when one exists + draft: true, + limit: 10, + }) + + const published = await payload.findByID({ + id: '1', + collection: 'posts', + draft: false, + }) + + const globalLatest = await payload.findGlobal({ + slug: 'settings', + draft: true, + }) + + const counted = await payload.count({ + collection: 'posts', + draft: false, + }) + + return { counted, globalLatest, latest, published } +} diff --git a/packages/codemod/src/transforms/migrate-version-action-api/read.output.ts b/packages/codemod/src/transforms/migrate-version-action-api/read.output.ts new file mode 100644 index 00000000000..edf74873ef6 --- /dev/null +++ b/packages/codemod/src/transforms/migrate-version-action-api/read.output.ts @@ -0,0 +1,28 @@ +import type { Payload } from 'payload' + +export async function loadPosts(payload: Payload) { + const latest = await payload.find({ + collection: 'posts', + // fetch the newest draft when one exists + version: 'latest', + limit: 10, + }) + + const published = await payload.findByID({ + id: '1', + collection: 'posts', + version: 'published', + }) + + const globalLatest = await payload.findGlobal({ + slug: 'settings', + version: 'latest', + }) + + const counted = await payload.count({ + collection: 'posts', + version: 'published', + }) + + return { counted, globalLatest, latest, published } +} diff --git a/packages/codemod/src/transforms/migrate-version-action-api/rest.input.ts b/packages/codemod/src/transforms/migrate-version-action-api/rest.input.ts new file mode 100644 index 00000000000..5c330e4dfa4 --- /dev/null +++ b/packages/codemod/src/transforms/migrate-version-action-api/rest.input.ts @@ -0,0 +1,16 @@ +import type { Payload } from 'payload' + +export async function fetchPosts(payload: Payload, id: string) { + const list = await fetch( + `${payload.config.serverURL}${payload.config.routes.api}/posts?draft=true`, + ) + const doc = await fetch( + `${payload.config.serverURL}${payload.config.routes.api}/posts/${id}?draft=false`, + ) + const created = await fetch( + `${payload.config.serverURL}${payload.config.routes.api}/posts?draft=true`, + { method: 'POST' }, + ) + + return { created, doc, list } +} diff --git a/packages/codemod/src/transforms/migrate-version-action-api/rest.output.ts b/packages/codemod/src/transforms/migrate-version-action-api/rest.output.ts new file mode 100644 index 00000000000..e83b66a9018 --- /dev/null +++ b/packages/codemod/src/transforms/migrate-version-action-api/rest.output.ts @@ -0,0 +1,16 @@ +import type { Payload } from 'payload' + +export async function fetchPosts(payload: Payload, id: string) { + const list = await fetch( + `${payload.config.serverURL}${payload.config.routes.api}/posts?version=latest`, + ) + const doc = await fetch( + `${payload.config.serverURL}${payload.config.routes.api}/posts/${id}?version=published`, + ) + const created = await fetch( + `${payload.config.serverURL}${payload.config.routes.api}/posts?action=saveDraft`, + { method: 'POST' }, + ) + + return { created, doc, list } +} diff --git a/packages/codemod/src/transforms/migrate-version-action-api/restore.input.ts b/packages/codemod/src/transforms/migrate-version-action-api/restore.input.ts new file mode 100644 index 00000000000..f9ff5b2eb95 --- /dev/null +++ b/packages/codemod/src/transforms/migrate-version-action-api/restore.input.ts @@ -0,0 +1,23 @@ +import type { Payload } from 'payload' + +export async function restorePosts(payload: Payload) { + const published = await payload.restoreVersion({ + id: '1', + collection: 'posts', + draft: false, + }) + + const draft = await payload.restoreVersion({ + id: '2', + collection: 'posts', + draft: true, + }) + + const globalPublished = await payload.restoreGlobalVersion({ + id: '3', + slug: 'settings', + draft: false, + }) + + return { draft, globalPublished, published } +} diff --git a/packages/codemod/src/transforms/migrate-version-action-api/restore.output.ts b/packages/codemod/src/transforms/migrate-version-action-api/restore.output.ts new file mode 100644 index 00000000000..92b6ddb02f0 --- /dev/null +++ b/packages/codemod/src/transforms/migrate-version-action-api/restore.output.ts @@ -0,0 +1,23 @@ +import type { Payload } from 'payload' + +export async function restorePosts(payload: Payload) { + const published = await payload.restoreVersion({ + id: '1', + collection: 'posts', + action: 'publish', + }) + + const draft = await payload.restoreVersion({ + id: '2', + collection: 'posts', + action: 'saveDraft', + }) + + const globalPublished = await payload.restoreGlobalVersion({ + id: '3', + slug: 'settings', + action: 'publish', + }) + + return { draft, globalPublished, published } +} diff --git a/packages/codemod/src/transforms/migrate-version-action-api/status-conflict.input.ts b/packages/codemod/src/transforms/migrate-version-action-api/status-conflict.input.ts new file mode 100644 index 00000000000..89ca1672a43 --- /dev/null +++ b/packages/codemod/src/transforms/migrate-version-action-api/status-conflict.input.ts @@ -0,0 +1,24 @@ +import type { Payload } from 'payload' + +export async function statusConflicts(payload: Payload) { + const publishMany = await payload.update({ + id: '1', + collection: 'posts', + data: { + _status: 'published', + title: 'Publish many', + }, + draft: true, + }) + + const createMismatch = await payload.create({ + collection: 'posts', + data: { + _status: 'draft', + title: 'Create mismatch', + }, + draft: false, + }) + + return { createMismatch, publishMany } +} diff --git a/packages/codemod/src/transforms/migrate-version-action-api/status.input.ts b/packages/codemod/src/transforms/migrate-version-action-api/status.input.ts new file mode 100644 index 00000000000..f1e32b29960 --- /dev/null +++ b/packages/codemod/src/transforms/migrate-version-action-api/status.input.ts @@ -0,0 +1,24 @@ +import type { Payload } from 'payload' + +export async function writeWithStatus(payload: Payload) { + const inferredDraft = await payload.create({ + collection: 'posts', + data: { + _status: 'draft', + title: 'Already a draft', + }, + draft: true, + }) + + const inferredPublish = await payload.update({ + id: inferredDraft.id, + collection: 'posts', + data: { + _status: 'published', + title: 'Publish me', + }, + draft: false, + }) + + return { inferredDraft, inferredPublish } +} diff --git a/packages/codemod/src/transforms/migrate-version-action-api/status.output.ts b/packages/codemod/src/transforms/migrate-version-action-api/status.output.ts new file mode 100644 index 00000000000..938207128a7 --- /dev/null +++ b/packages/codemod/src/transforms/migrate-version-action-api/status.output.ts @@ -0,0 +1,22 @@ +import type { Payload } from 'payload' + +export async function writeWithStatus(payload: Payload) { + const inferredDraft = await payload.create({ + collection: 'posts', + data: { + _status: 'draft', + title: 'Already a draft', + } + }) + + const inferredPublish = await payload.update({ + id: inferredDraft.id, + collection: 'posts', + data: { + _status: 'published', + title: 'Publish me', + } + }) + + return { inferredDraft, inferredPublish } +} diff --git a/packages/codemod/src/transforms/migrate-version-action-api/strict-draft-types-false.input.ts b/packages/codemod/src/transforms/migrate-version-action-api/strict-draft-types-false.input.ts new file mode 100644 index 00000000000..e67ce89f762 --- /dev/null +++ b/packages/codemod/src/transforms/migrate-version-action-api/strict-draft-types-false.input.ts @@ -0,0 +1,8 @@ +import { buildConfig } from 'payload' + +export default buildConfig({ + collections: [], + typescript: { + strictDraftTypes: false, + }, +}) diff --git a/packages/codemod/src/transforms/migrate-version-action-api/strict-draft-types-false.output.ts b/packages/codemod/src/transforms/migrate-version-action-api/strict-draft-types-false.output.ts new file mode 100644 index 00000000000..65b09131739 --- /dev/null +++ b/packages/codemod/src/transforms/migrate-version-action-api/strict-draft-types-false.output.ts @@ -0,0 +1,5 @@ +import { buildConfig } from 'payload' + +export default buildConfig({ + collections: [] +}) diff --git a/packages/codemod/src/transforms/migrate-version-action-api/strict-draft-types.input.ts b/packages/codemod/src/transforms/migrate-version-action-api/strict-draft-types.input.ts new file mode 100644 index 00000000000..77c020e37a8 --- /dev/null +++ b/packages/codemod/src/transforms/migrate-version-action-api/strict-draft-types.input.ts @@ -0,0 +1,9 @@ +import { buildConfig } from 'payload' + +export default buildConfig({ + collections: [], + typescript: { + outputFile: 'payload-types.ts', + strictDraftTypes: true, + }, +}) diff --git a/packages/codemod/src/transforms/migrate-version-action-api/strict-draft-types.output.ts b/packages/codemod/src/transforms/migrate-version-action-api/strict-draft-types.output.ts new file mode 100644 index 00000000000..06d2212efb5 --- /dev/null +++ b/packages/codemod/src/transforms/migrate-version-action-api/strict-draft-types.output.ts @@ -0,0 +1,8 @@ +import { buildConfig } from 'payload' + +export default buildConfig({ + collections: [], + typescript: { + outputFile: 'payload-types.ts' + }, +}) diff --git a/packages/codemod/src/transforms/migrate-version-action-api/update-draft-false.input.ts b/packages/codemod/src/transforms/migrate-version-action-api/update-draft-false.input.ts new file mode 100644 index 00000000000..b7abd64c549 --- /dev/null +++ b/packages/codemod/src/transforms/migrate-version-action-api/update-draft-false.input.ts @@ -0,0 +1,12 @@ +import type { Payload } from 'payload' + +export async function updateWithoutStatus(payload: Payload) { + return payload.update({ + id: '1', + collection: 'posts', + data: { + title: 'Maybe publish', + }, + draft: false, + }) +} diff --git a/packages/codemod/src/transforms/migrate-version-action-api/wrapper.input.ts b/packages/codemod/src/transforms/migrate-version-action-api/wrapper.input.ts new file mode 100644 index 00000000000..6c9c90b1072 --- /dev/null +++ b/packages/codemod/src/transforms/migrate-version-action-api/wrapper.input.ts @@ -0,0 +1,11 @@ +function withDraft(options) { + return { + ...options, + collection: 'posts', + draft: true, + } +} + +export async function wrapper(payload) { + return payload.find(withDraft({ limit: 10 })) +} diff --git a/packages/codemod/src/transforms/migrate-version-action-api/write.input.ts b/packages/codemod/src/transforms/migrate-version-action-api/write.input.ts new file mode 100644 index 00000000000..ea8b8cbb4d6 --- /dev/null +++ b/packages/codemod/src/transforms/migrate-version-action-api/write.input.ts @@ -0,0 +1,36 @@ +import type { Payload } from 'payload' + +export async function writePosts(payload: Payload) { + const draft = await payload.create({ + collection: 'posts', + data: { + title: 'Draft post', + }, + draft: true, + }) + + const published = await payload.create({ + collection: 'posts', + data: { + title: 'Published post', + }, + draft: false, + }) + + const updated = await payload.update({ + id: draft.id, + collection: 'posts', + data: { + title: 'Updated draft', + }, + draft: true, + }) + + const copy = await payload.duplicate({ + id: published.id, + collection: 'posts', + draft: true, + }) + + return { copy, draft, published, updated } +} diff --git a/packages/codemod/src/transforms/migrate-version-action-api/write.output.ts b/packages/codemod/src/transforms/migrate-version-action-api/write.output.ts new file mode 100644 index 00000000000..b978728e723 --- /dev/null +++ b/packages/codemod/src/transforms/migrate-version-action-api/write.output.ts @@ -0,0 +1,36 @@ +import type { Payload } from 'payload' + +export async function writePosts(payload: Payload) { + const draft = await payload.create({ + collection: 'posts', + data: { + title: 'Draft post', + }, + action: 'saveDraft', + }) + + const published = await payload.create({ + collection: 'posts', + data: { + title: 'Published post', + }, + action: 'publish', + }) + + const updated = await payload.update({ + id: draft.id, + collection: 'posts', + data: { + title: 'Updated draft', + }, + action: 'saveDraft', + }) + + const copy = await payload.duplicate({ + id: published.id, + collection: 'posts', + action: 'saveDraft', + }) + + return { copy, draft, published, updated } +} diff --git a/packages/graphql/src/resolvers/auth/me.ts b/packages/graphql/src/resolvers/auth/me.ts index 29e3701ff03..589b8f9088c 100644 --- a/packages/graphql/src/resolvers/auth/me.ts +++ b/packages/graphql/src/resolvers/auth/me.ts @@ -8,11 +8,17 @@ export function me(collection: Collection): any { async function resolver(_, args, context: Context) { const currentToken = extractJWT(context.req) + if (args.version) { + context.req.query = context.req.query || {} + context.req.query.version = args.version + } + const options = { collection, currentToken, depth: 0, req: isolateObjectProperty(context.req, 'transactionID'), + version: args.version, } const result = await meOperation(options) diff --git a/packages/graphql/src/resolvers/collections/create.ts b/packages/graphql/src/resolvers/collections/create.ts index ce7a4e49584..b67d9a22854 100644 --- a/packages/graphql/src/resolvers/collections/create.ts +++ b/packages/graphql/src/resolvers/collections/create.ts @@ -1,6 +1,7 @@ import type { Collection, CollectionSlug, + CreateAction, DataFromCollectionSlug, PayloadRequest, RequiredDataFromCollectionSlug, @@ -13,8 +14,8 @@ import type { Context } from '../types.js' export type Resolver = ( _: unknown, args: { + action?: CreateAction data: RequiredDataFromCollectionSlug - draft: boolean locale?: string }, context: { @@ -31,10 +32,10 @@ export function createResolver( } const result = await createOperation({ + action: args.action, collection, data: args.data, depth: 0, - draft: args.draft, req: isolateObjectProperty(context.req, 'transactionID'), }) diff --git a/packages/graphql/src/resolvers/collections/delete.ts b/packages/graphql/src/resolvers/collections/delete.ts index b033447b877..da655edd779 100644 --- a/packages/graphql/src/resolvers/collections/delete.ts +++ b/packages/graphql/src/resolvers/collections/delete.ts @@ -7,7 +7,6 @@ import type { Context } from '../types.js' export type Resolver = ( _: unknown, args: { - draft: boolean fallbackLocale?: string id: number | string locale?: string @@ -33,16 +32,6 @@ export function getDeleteResolver( req.query = {} } - const draft: boolean = - (args.draft ?? req.query?.draft === 'false') - ? false - : req.query?.draft === 'true' - ? true - : undefined - if (typeof draft === 'boolean') { - req.query.draft = String(draft) - } - context.req = req const options = { diff --git a/packages/graphql/src/resolvers/collections/duplicate.ts b/packages/graphql/src/resolvers/collections/duplicate.ts index 73fca9b15db..44f31e922ed 100644 --- a/packages/graphql/src/resolvers/collections/duplicate.ts +++ b/packages/graphql/src/resolvers/collections/duplicate.ts @@ -1,4 +1,10 @@ -import type { Collection, CollectionSlug, DataFromCollectionSlug, PayloadRequest } from 'payload' +import type { + Collection, + CollectionSlug, + CreateAction, + DataFromCollectionSlug, + PayloadRequest, +} from 'payload' import { duplicateOperation, isolateObjectProperty } from 'payload' @@ -7,8 +13,8 @@ import type { Context } from '../types.js' export type Resolver = ( _: unknown, args: { + action?: CreateAction data: TData - draft: boolean fallbackLocale?: string id: string locale?: string @@ -31,10 +37,10 @@ export function duplicateResolver( const result = await duplicateOperation({ id: args.id, + action: args.action, collection, data: args.data, depth: 0, - draft: args.draft, req: isolateObjectProperty(req, 'transactionID'), }) diff --git a/packages/graphql/src/resolvers/collections/find.ts b/packages/graphql/src/resolvers/collections/find.ts index 1aee54a84aa..584797dfef0 100644 --- a/packages/graphql/src/resolvers/collections/find.ts +++ b/packages/graphql/src/resolvers/collections/find.ts @@ -1,5 +1,5 @@ import type { GraphQLResolveInfo } from 'graphql' -import type { Collection, PaginatedDocs, Where } from 'payload' +import type { Collection, PaginatedDocs, ReadVersion, Where } from 'payload' import { findOperation, isolateObjectProperty } from 'payload' @@ -11,7 +11,6 @@ export type Resolver = ( _: unknown, args: { data: Record - draft: boolean fallbackLocale?: string limit?: number locale?: string @@ -20,6 +19,7 @@ export type Resolver = ( select?: boolean sort?: string trash?: boolean + version?: ReadVersion where?: Where }, context: Context, @@ -40,14 +40,8 @@ export function findResolver(collection: Collection): Resolver { req.fallbackLocale = args.fallbackLocale || req.fallbackLocale req.query = req.query || {} - const draft: boolean = - (args.draft ?? req.query?.draft === 'false') - ? false - : req.query?.draft === 'true' - ? true - : undefined - if (typeof draft === 'boolean') { - req.query.draft = String(draft) + if (args.version) { + req.query.version = args.version } const { sort } = args @@ -55,7 +49,6 @@ export function findResolver(collection: Collection): Resolver { const options = { collection, depth: 0, - draft: args.draft, limit: args.limit, page: args.page, pagination: args.pagination, @@ -63,6 +56,7 @@ export function findResolver(collection: Collection): Resolver { select, sort: sort && typeof sort === 'string' ? sort.split(',') : undefined, trash: args.trash, + version: args.version, where: args.where, } diff --git a/packages/graphql/src/resolvers/collections/findByID.ts b/packages/graphql/src/resolvers/collections/findByID.ts index 804e37bf90b..5bfd926c994 100644 --- a/packages/graphql/src/resolvers/collections/findByID.ts +++ b/packages/graphql/src/resolvers/collections/findByID.ts @@ -1,5 +1,5 @@ import type { GraphQLResolveInfo } from 'graphql' -import type { Collection, CollectionSlug, DataFromCollectionSlug } from 'payload' +import type { Collection, CollectionSlug, DataFromCollectionSlug, ReadVersion } from 'payload' import { findByIDOperation, isolateObjectProperty } from 'payload' @@ -10,12 +10,12 @@ import { buildSelectForCollection } from '../../utilities/select.js' export type Resolver = ( _: unknown, args: { - draft: boolean fallbackLocale?: string id: string locale?: string select?: boolean trash?: boolean + version?: ReadVersion }, context: Context, info: GraphQLResolveInfo, @@ -25,31 +25,29 @@ export function findByIDResolver( collection: Collection, ): Resolver> { return async function resolver(_, args, context, info) { - const req = context.req = isolateObjectProperty(context.req, ['locale', 'fallbackLocale', 'transactionID']) - const select = context.select = args.select ? buildSelectForCollection(info) : undefined + const req = (context.req = isolateObjectProperty(context.req, [ + 'locale', + 'fallbackLocale', + 'transactionID', + ])) + const select = (context.select = args.select ? buildSelectForCollection(info) : undefined) req.locale = args.locale || req.locale req.fallbackLocale = args.fallbackLocale || req.fallbackLocale req.query = req.query || {} - const draft: boolean = - (args.draft ?? req.query?.draft === 'false') - ? false - : req.query?.draft === 'true' - ? true - : undefined - if (typeof draft === 'boolean') { - req.query.draft = String(draft) + if (args.version) { + req.query.version = args.version } const options = { id: args.id, collection, depth: 0, - draft: args.draft, req, select, trash: args.trash, + version: args.version, } const result = await findByIDOperation(options) diff --git a/packages/graphql/src/resolvers/collections/findVersions.ts b/packages/graphql/src/resolvers/collections/findVersions.ts index 07dd3efb1f6..2bb4b1a62db 100644 --- a/packages/graphql/src/resolvers/collections/findVersions.ts +++ b/packages/graphql/src/resolvers/collections/findVersions.ts @@ -10,7 +10,6 @@ import { buildSelectForCollectionMany } from '../../utilities/select.js' export type Resolver = ( _: unknown, args: { - draft?: boolean fallbackLocale?: string limit?: number locale?: string @@ -38,16 +37,6 @@ export function findVersionsResolver(collection: Collection): Resolver { req.fallbackLocale = args.fallbackLocale || req.fallbackLocale req.query = req.query || {} - const draft: boolean = - (args.draft ?? req.query?.draft === 'false') - ? false - : req.query?.draft === 'true' - ? true - : undefined - if (typeof draft === 'boolean') { - req.query.draft = String(draft) - } - const { sort } = args const options = { diff --git a/packages/graphql/src/resolvers/collections/restoreVersion.ts b/packages/graphql/src/resolvers/collections/restoreVersion.ts index 5fac4fae11a..f81a82e605e 100644 --- a/packages/graphql/src/resolvers/collections/restoreVersion.ts +++ b/packages/graphql/src/resolvers/collections/restoreVersion.ts @@ -1,4 +1,4 @@ -import type { Collection, PayloadRequest } from 'payload' +import type { Collection, PayloadRequest, RestoreAction } from 'payload' import { isolateObjectProperty, restoreVersionOperation } from 'payload' @@ -7,7 +7,7 @@ import type { Context } from '../types.js' export type Resolver = ( _: unknown, args: { - draft?: boolean + action?: RestoreAction id: number | string }, context: { @@ -19,9 +19,9 @@ export function restoreVersionResolver(collection: Collection): Resolver { async function resolver(_, args, context: Context) { const options = { id: args.id, + action: args.action, collection, depth: 0, - draft: args.draft, req: isolateObjectProperty(context.req, 'transactionID'), } diff --git a/packages/graphql/src/resolvers/collections/update.ts b/packages/graphql/src/resolvers/collections/update.ts index 0feff36fb69..94e844f847b 100644 --- a/packages/graphql/src/resolvers/collections/update.ts +++ b/packages/graphql/src/resolvers/collections/update.ts @@ -1,4 +1,10 @@ -import type { Collection, CollectionSlug, DataFromCollectionSlug, PayloadRequest } from 'payload' +import type { + Collection, + CollectionSlug, + DataFromCollectionSlug, + PayloadRequest, + UpdateAction, +} from 'payload' import { isolateObjectProperty, updateByIDOperation } from 'payload' @@ -7,9 +13,9 @@ import type { Context } from '../types.js' export type Resolver = ( _: unknown, args: { + action?: UpdateAction autosave: boolean data: DataFromCollectionSlug - draft: boolean fallbackLocale?: string id: number | string locale?: string @@ -35,25 +41,15 @@ export function updateResolver( req.query = {} } - const draft: boolean = - (args.draft ?? req.query?.draft === 'false') - ? false - : req.query?.draft === 'true' - ? true - : undefined - if (typeof draft === 'boolean') { - req.query.draft = String(draft) - } - context.req = req const options = { id: args.id, + action: args.action, autosave: args.autosave, collection, data: args.data as any, depth: 0, - draft: args.draft, req: isolateObjectProperty(req, 'transactionID'), trash: args.trash, } diff --git a/packages/graphql/src/resolvers/globals/findOne.ts b/packages/graphql/src/resolvers/globals/findOne.ts index 8561b0c1636..45d9c2a676f 100644 --- a/packages/graphql/src/resolvers/globals/findOne.ts +++ b/packages/graphql/src/resolvers/globals/findOne.ts @@ -1,5 +1,5 @@ import type { GraphQLResolveInfo } from 'graphql' -import type { Document, SanitizedGlobalConfig } from 'payload' +import type { Document, ReadVersion, SanitizedGlobalConfig } from 'payload' import { findOneOperation, isolateObjectProperty } from 'payload' @@ -10,33 +10,41 @@ import { buildSelectForCollection } from '../../utilities/select.js' export type Resolver = ( _: unknown, args: { - draft?: boolean fallbackLocale?: string id: number | string locale?: string select?: boolean + version?: ReadVersion }, context: Context, - info: GraphQLResolveInfo + info: GraphQLResolveInfo, ) => Promise export function findOne(globalConfig: SanitizedGlobalConfig): Resolver { return async function resolver(_, args, context, info) { - const req = context.req = isolateObjectProperty(context.req, ['locale', 'fallbackLocale', 'transactionID']) - const select = context.select = args.select ? buildSelectForCollection(info) : undefined + const req = (context.req = isolateObjectProperty(context.req, [ + 'locale', + 'fallbackLocale', + 'transactionID', + ])) + const select = (context.select = args.select ? buildSelectForCollection(info) : undefined) const { slug } = globalConfig req.locale = args.locale || req.locale req.fallbackLocale = args.fallbackLocale || req.fallbackLocale req.query = req.query || {} + if (args.version) { + req.query.version = args.version + } + const options = { slug, depth: 0, - draft: args.draft, globalConfig, req, select, + version: args.version, } const result = await findOneOperation(options) diff --git a/packages/graphql/src/resolvers/globals/findVersionByID.ts b/packages/graphql/src/resolvers/globals/findVersionByID.ts index 314d33a9fe7..451ca81361f 100644 --- a/packages/graphql/src/resolvers/globals/findVersionByID.ts +++ b/packages/graphql/src/resolvers/globals/findVersionByID.ts @@ -10,20 +10,23 @@ import { buildSelectForCollection } from '../../utilities/select.js' export type Resolver = ( _: unknown, args: { - draft?: boolean fallbackLocale?: string id: number | string locale?: string select?: boolean }, context: Context, - info: GraphQLResolveInfo + info: GraphQLResolveInfo, ) => Promise export function findVersionByID(globalConfig: SanitizedGlobalConfig): Resolver { return async function resolver(_, args, context, info) { - const req = context.req = isolateObjectProperty(context.req, ['locale', 'fallbackLocale', 'transactionID']) - const select = context.select = args.select ? buildSelectForCollection(info) : undefined + const req = (context.req = isolateObjectProperty(context.req, [ + 'locale', + 'fallbackLocale', + 'transactionID', + ])) + const select = (context.select = args.select ? buildSelectForCollection(info) : undefined) req.locale = args.locale || req.locale req.fallbackLocale = args.fallbackLocale || req.fallbackLocale @@ -32,7 +35,6 @@ export function findVersionByID(globalConfig: SanitizedGlobalConfig): Resolver { const options = { id: args.id, depth: 0, - draft: args.draft, globalConfig, req, select, diff --git a/packages/graphql/src/resolvers/globals/restoreVersion.ts b/packages/graphql/src/resolvers/globals/restoreVersion.ts index 3bc8f2a80f6..4d9c23bc5cd 100644 --- a/packages/graphql/src/resolvers/globals/restoreVersion.ts +++ b/packages/graphql/src/resolvers/globals/restoreVersion.ts @@ -1,4 +1,4 @@ -import type { Document, PayloadRequest, SanitizedGlobalConfig } from 'payload' +import type { Document, PayloadRequest, RestoreAction, SanitizedGlobalConfig } from 'payload' import { isolateObjectProperty, restoreVersionOperationGlobal } from 'payload' @@ -7,7 +7,7 @@ import type { Context } from '../types.js' type Resolver = ( _: unknown, args: { - draft?: boolean + action?: RestoreAction id: number | string }, context: { @@ -18,8 +18,8 @@ export function restoreVersion(globalConfig: SanitizedGlobalConfig): Resolver { return async function resolver(_, args, context: Context) { const options = { id: args.id, + action: args.action, depth: 0, - draft: args.draft, globalConfig, req: isolateObjectProperty(context.req, 'transactionID'), } diff --git a/packages/graphql/src/resolvers/globals/update.ts b/packages/graphql/src/resolvers/globals/update.ts index 22678d7807a..e5cf9d8c56a 100644 --- a/packages/graphql/src/resolvers/globals/update.ts +++ b/packages/graphql/src/resolvers/globals/update.ts @@ -4,6 +4,7 @@ import type { PayloadRequest, SanitizedGlobalConfig, SelectType, + UpdateAction, } from 'payload' import type { DeepPartial } from 'ts-essentials' @@ -14,8 +15,8 @@ import type { Context } from '../types.js' type Resolver = ( _: unknown, args: { + action?: UpdateAction data?: DeepPartial, 'id'>> - draft?: boolean fallbackLocale?: string locale?: string }, @@ -39,9 +40,9 @@ export function update( const options = { slug, + action: args.action, data: args.data, depth: 0, - draft: args.draft, globalConfig, req: isolateObjectProperty(context.req, 'transactionID'), } diff --git a/packages/graphql/src/schema/fieldToSchemaMap.ts b/packages/graphql/src/schema/fieldToSchemaMap.ts index 83312c2837b..f9fa494e365 100644 --- a/packages/graphql/src/schema/fieldToSchemaMap.ts +++ b/packages/graphql/src/schema/fieldToSchemaMap.ts @@ -16,6 +16,7 @@ import type { NumberField, PointField, RadioField, + ReadVersion, RelationshipField, RichTextAdapter, RichTextField, @@ -53,8 +54,22 @@ import { formatOptions } from '../utilities/formatOptions.js' import { resolveSelect } from '../utilities/select.js' import { buildObjectType, type ObjectTypeConfig } from './buildObjectType.js' import { isFieldNullable } from './isFieldNullable.js' +import { GraphQLReadVersion } from './versionActionEnums.js' import { withNullableType } from './withNullableType.js' +function resolveFieldReadVersion(args: { version?: ReadVersion }, context: Context): ReadVersion { + if (args.version === 'published' || args.version === 'latest' || args.version === 'draft') { + return args.version + } + + const queryVersion = context.req.query?.version + if (queryVersion === 'published' || queryVersion === 'latest' || queryVersion === 'draft') { + return queryVersion + } + + return 'published' +} + function formattedNameResolver({ field, ...rest @@ -421,6 +436,7 @@ export const fieldToSchemaMap: FieldToSchemaMap = { sort: { type: GraphQLString, }, + version: { type: GraphQLReadVersion }, where: { type: Array.isArray(field.collection) ? GraphQLJSON @@ -436,7 +452,7 @@ export const fieldToSchemaMap: FieldToSchemaMap = { const { count = false, limit, page, sort, where } = args const { req } = context - const draft = Boolean(args.draft ?? context.req.query?.draft) + const version = resolveFieldReadVersion(args, context) const select = resolveSelect(info, context.select) const targetField = (field as FlattenedJoinField).targetField @@ -473,7 +489,6 @@ export const fieldToSchemaMap: FieldToSchemaMap = { const { docs, totalDocs } = await req.payload.find({ collection, depth: 0, - draft, fallbackLocale: req.fallbackLocale, // Fetch one extra document to determine if there are more documents beyond the requested limit (used for hasNextPage calculation). limit: typeof limit === 'number' && limit > 0 ? limit + 1 : 0, @@ -484,6 +499,7 @@ export const fieldToSchemaMap: FieldToSchemaMap = { req, select, sort, + version, where: fullWhere, }) @@ -634,22 +650,16 @@ export const fieldToSchemaMap: FieldToSchemaMap = { type = type || newlyCreatedBlockType const relationshipArgs: { - draft: GraphQLArgumentConfig fallbackLocale: GraphQLArgumentConfig limit: GraphQLArgumentConfig locale: GraphQLArgumentConfig page: GraphQLArgumentConfig + version: GraphQLArgumentConfig where: GraphQLArgumentConfig } = {} as any - const relationsUseDrafts = (Array.isArray(relationTo) ? relationTo : [relationTo]) - .filter((relation) => graphQLCollections.some((collection) => collection.slug === relation)) - .some((relation) => graphqlResult.collections[relation].config.versions?.drafts) - - if (relationsUseDrafts) { - relationshipArgs.draft = { - type: GraphQLBoolean, - } + relationshipArgs.version = { + type: GraphQLReadVersion, } if (config.localization) { @@ -680,7 +690,7 @@ export const fieldToSchemaMap: FieldToSchemaMap = { const locale = args.locale || context.req.locale const fallbackLocale = args.fallbackLocale || context.req.fallbackLocale let relatedCollectionSlug = field.relationTo - const draft = Boolean(args.draft ?? context.req.query?.draft) + const version = resolveFieldReadVersion(args, context) const select = resolveSelect(info, context.select) if (hasManyValues) { @@ -706,13 +716,13 @@ export const fieldToSchemaMap: FieldToSchemaMap = { currentDepth: 0, depth: 0, docID: id, - draft, fallbackLocale, locale, overrideAccess: false, select, showHiddenFields: false, transactionID: context.req.transactionID, + version, }), ) @@ -756,13 +766,13 @@ export const fieldToSchemaMap: FieldToSchemaMap = { currentDepth: 0, depth: 0, docID: id, - draft, fallbackLocale, locale, overrideAccess: false, select, showHiddenFields: false, transactionID: context.req.transactionID, + version, }), ) @@ -835,11 +845,11 @@ export const fieldToSchemaMap: FieldToSchemaMap = { const populationPromises = [] const populateDepth = field?.maxDepth !== undefined && field?.maxDepth < depth ? field?.maxDepth : depth + const version = resolveFieldReadVersion(args, context) editor?.graphQLPopulationPromises({ context, depth: populateDepth, - draft: args.draft, field, fieldPromises, findMany: false, @@ -850,6 +860,7 @@ export const fieldToSchemaMap: FieldToSchemaMap = { req: context.req, showHiddenFields: false, siblingDoc: parent, + version, }) await Promise.all(fieldPromises) await Promise.all(populationPromises) @@ -1053,22 +1064,16 @@ export const fieldToSchemaMap: FieldToSchemaMap = { type = type || newlyCreatedBlockType const relationshipArgs: { - draft?: GraphQLArgumentConfig fallbackLocale?: GraphQLArgumentConfig limit?: GraphQLArgumentConfig locale?: GraphQLArgumentConfig page?: GraphQLArgumentConfig + version?: GraphQLArgumentConfig where?: GraphQLArgumentConfig } = {} as any - const relationsUseDrafts = (Array.isArray(relationTo) ? relationTo : [relationTo]).some( - (relation) => graphqlResult.collections[relation].config.versions?.drafts, - ) - - if (relationsUseDrafts) { - relationshipArgs.draft = { - type: GraphQLBoolean, - } + relationshipArgs.version = { + type: GraphQLReadVersion, } if (config.localization) { @@ -1099,7 +1104,7 @@ export const fieldToSchemaMap: FieldToSchemaMap = { const locale = args.locale || context.req.locale const fallbackLocale = args.fallbackLocale || context.req.fallbackLocale let relatedCollectionSlug = field.relationTo - const draft = Boolean(args.draft ?? context.req.query?.draft) + const version = resolveFieldReadVersion(args, context) const select = resolveSelect(info, context.select) if (hasManyValues) { @@ -1125,13 +1130,13 @@ export const fieldToSchemaMap: FieldToSchemaMap = { currentDepth: 0, depth: 0, docID: id, - draft, fallbackLocale, locale, overrideAccess: false, select, showHiddenFields: false, transactionID: context.req.transactionID, + version, }), ) @@ -1175,13 +1180,13 @@ export const fieldToSchemaMap: FieldToSchemaMap = { currentDepth: 0, depth: 0, docID: id, - draft, fallbackLocale, locale, overrideAccess: false, select, showHiddenFields: false, transactionID: context.req.transactionID, + version, }), ) diff --git a/packages/graphql/src/schema/initCollections.ts b/packages/graphql/src/schema/initCollections.ts index 0364d8544b5..57b856b35f9 100644 --- a/packages/graphql/src/schema/initCollections.ts +++ b/packages/graphql/src/schema/initCollections.ts @@ -44,6 +44,12 @@ import { buildObjectType } from './buildObjectType.js' import { buildPaginatedListType } from './buildPaginatedListType.js' import { buildPolicyType } from './buildPoliciesType.js' import { buildWhereInputType } from './buildWhereInputType.js' +import { + GraphQLCreateAction, + GraphQLReadVersion, + GraphQLRestoreAction, + GraphQLUpdateAction, +} from './versionActionEnums.js' type InitCollectionsGraphQLArgs = { config: SanitizedConfig @@ -202,7 +208,7 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ type: collection.graphQL.type, args: { id: { type: new GraphQLNonNull(idType) }, - draft: { type: GraphQLBoolean }, + version: { type: GraphQLReadVersion }, ...(config.localization ? { fallbackLocale: { type: graphqlResult.types.fallbackLocaleInputType }, @@ -218,7 +224,7 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ graphqlResult.Query.fields[pluralName] = { type: buildPaginatedListType(pluralName, collection.graphQL.type), args: { - draft: { type: GraphQLBoolean }, + version: { type: GraphQLReadVersion }, where: { type: collection.graphQL.whereInputType }, ...(config.localization ? { @@ -244,7 +250,6 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ }, }), args: { - draft: { type: GraphQLBoolean }, trash: { type: GraphQLBoolean }, where: { type: collection.graphQL.whereInputType }, ...(config.localization @@ -277,7 +282,7 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ ...(createMutationInputType ? { data: { type: collection.graphQL.mutationInputType } } : {}), - draft: { type: GraphQLBoolean }, + action: { type: GraphQLCreateAction }, ...(config.localization ? { locale: { type: graphqlResult.types.localeInputType }, @@ -295,7 +300,7 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ ...(updateMutationInputType ? { data: { type: collection.graphQL.updateMutationInputType } } : {}), - draft: { type: GraphQLBoolean }, + action: { type: GraphQLUpdateAction }, ...(config.localization ? { locale: { type: graphqlResult.types.localeInputType }, @@ -320,6 +325,7 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ type: collection.graphQL.type, args: { id: { type: new GraphQLNonNull(idType) }, + action: { type: GraphQLCreateAction }, ...(createMutationInputType ? { data: { type: collection.graphQL.mutationInputType } } : {}), @@ -409,7 +415,7 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ type: collection.graphQL.type, args: { id: { type: versionIDType }, - draft: { type: GraphQLBoolean }, + action: { type: GraphQLRestoreAction }, }, resolve: restoreVersionResolver(collection), } @@ -469,6 +475,9 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ }, }, }), + args: { + version: { type: GraphQLReadVersion }, + }, resolve: me(collection), } diff --git a/packages/graphql/src/schema/initGlobals.ts b/packages/graphql/src/schema/initGlobals.ts index eae3a5cd31d..a93829d4b8f 100644 --- a/packages/graphql/src/schema/initGlobals.ts +++ b/packages/graphql/src/schema/initGlobals.ts @@ -19,6 +19,11 @@ import { buildObjectType } from './buildObjectType.js' import { buildPaginatedListType } from './buildPaginatedListType.js' import { buildPolicyType } from './buildPoliciesType.js' import { buildWhereInputType } from './buildWhereInputType.js' +import { + GraphQLReadVersion, + GraphQLRestoreAction, + GraphQLUpdateAction, +} from './versionActionEnums.js' type InitGlobalsGraphQLArgs = { config: SanitizedConfig @@ -70,7 +75,7 @@ export function initGlobals({ config, graphqlResult }: InitGlobalsGraphQLArgs): graphqlResult.Query.fields[formattedName] = { type: graphqlResult.globals.graphQL[slug].type, args: { - draft: { type: GraphQLBoolean }, + version: { type: GraphQLReadVersion }, ...(config.localization ? { fallbackLocale: { type: graphqlResult.types.fallbackLocaleInputType }, @@ -100,7 +105,7 @@ export function initGlobals({ config, graphqlResult }: InitGlobalsGraphQLArgs): ...(updateMutationInputType ? { data: { type: graphqlResult.globals.graphQL[slug].mutationInputType } } : {}), - draft: { type: GraphQLBoolean }, + action: { type: GraphQLUpdateAction }, ...(config.localization ? { locale: { type: graphqlResult.types.localeInputType }, @@ -146,7 +151,6 @@ export function initGlobals({ config, graphqlResult }: InitGlobalsGraphQLArgs): type: graphqlResult.globals.graphQL[slug].versionType, args: { id: { type: idType }, - draft: { type: GraphQLBoolean }, ...(config.localization ? { fallbackLocale: { type: graphqlResult.types.fallbackLocaleInputType }, @@ -191,7 +195,7 @@ export function initGlobals({ config, graphqlResult }: InitGlobalsGraphQLArgs): type: graphqlResult.globals.graphQL[slug].type, args: { id: { type: idType }, - draft: { type: GraphQLBoolean }, + action: { type: GraphQLRestoreAction }, }, resolve: restoreVersion(global), } diff --git a/packages/graphql/src/schema/versionActionEnums.spec.ts b/packages/graphql/src/schema/versionActionEnums.spec.ts new file mode 100644 index 00000000000..38fcd000ea1 --- /dev/null +++ b/packages/graphql/src/schema/versionActionEnums.spec.ts @@ -0,0 +1,96 @@ +import { GraphQLError, GraphQLObjectType, GraphQLSchema, GraphQLString, Kind } from 'graphql' +import { describe, expect, it } from 'vitest' + +import { + GraphQLCreateAction, + GraphQLReadVersion, + GraphQLRestoreAction, + GraphQLUpdateAction, +} from './versionActionEnums' + +describe('versionActionEnums', () => { + it('should expose stable GraphQL names and public lower-case/camel-case values', () => { + expect(GraphQLReadVersion.name).toBe('ReadVersion') + expect(GraphQLReadVersion.getValues().map((value) => value.name)).toEqual([ + 'published', + 'latest', + 'draft', + ]) + expect(GraphQLReadVersion.getValues().map((value) => value.value)).toEqual([ + 'published', + 'latest', + 'draft', + ]) + + expect(GraphQLCreateAction.name).toBe('CreateAction') + expect(GraphQLCreateAction.getValues().map((value) => value.value)).toEqual([ + 'publish', + 'saveDraft', + ]) + + expect(GraphQLUpdateAction.name).toBe('UpdateAction') + expect(GraphQLUpdateAction.getValues().map((value) => value.value)).toEqual([ + 'publish', + 'saveDraft', + 'unpublish', + ]) + + expect(GraphQLRestoreAction.name).toBe('RestoreAction') + expect(GraphQLRestoreAction.getValues().map((value) => value.value)).toEqual([ + 'publish', + 'saveDraft', + ]) + }) + + it('should reject Boolean and unknown values', () => { + expect(() => GraphQLReadVersion.parseValue(true)).toThrow(GraphQLError) + expect(() => GraphQLReadVersion.parseValue(false)).toThrow(GraphQLError) + expect(() => GraphQLCreateAction.parseValue(true)).toThrow(GraphQLError) + expect(() => GraphQLUpdateAction.parseValue(false)).toThrow(GraphQLError) + expect(() => GraphQLReadVersion.parseValue('Latest')).toThrow(GraphQLError) + expect(() => GraphQLCreateAction.parseValue('unpublish')).toThrow(GraphQLError) + + expect(() => GraphQLReadVersion.parseLiteral({ kind: Kind.BOOLEAN, value: true }, {})).toThrow( + GraphQLError, + ) + expect(() => + GraphQLUpdateAction.parseLiteral({ kind: Kind.BOOLEAN, value: false }, {}), + ).toThrow(GraphQLError) + }) + + it('should reuse the same enum objects across a schema', () => { + const Query = new GraphQLObjectType({ + name: 'Query', + fields: { + first: { + type: GraphQLString, + args: { version: { type: GraphQLReadVersion } }, + }, + second: { + type: GraphQLString, + args: { version: { type: GraphQLReadVersion } }, + }, + }, + }) + + const Mutation = new GraphQLObjectType({ + name: 'Mutation', + fields: { + create: { + type: GraphQLString, + args: { action: { type: GraphQLCreateAction } }, + }, + update: { + type: GraphQLString, + args: { action: { type: GraphQLUpdateAction } }, + }, + restore: { + type: GraphQLString, + args: { action: { type: GraphQLRestoreAction } }, + }, + }, + }) + + expect(() => new GraphQLSchema({ query: Query, mutation: Mutation })).not.toThrow() + }) +}) diff --git a/packages/graphql/src/schema/versionActionEnums.ts b/packages/graphql/src/schema/versionActionEnums.ts new file mode 100644 index 00000000000..fa4dc3f0272 --- /dev/null +++ b/packages/graphql/src/schema/versionActionEnums.ts @@ -0,0 +1,31 @@ +import { GraphQLEnumType } from 'graphql' + +function enumValues(values: readonly T[]): Record { + return values.reduce( + (acc, value) => { + acc[value] = { value } + return acc + }, + {} as Record, + ) +} + +export const GraphQLReadVersion = new GraphQLEnumType({ + name: 'ReadVersion', + values: enumValues(['published', 'latest', 'draft'] as const), +}) + +export const GraphQLCreateAction = new GraphQLEnumType({ + name: 'CreateAction', + values: enumValues(['publish', 'saveDraft'] as const), +}) + +export const GraphQLUpdateAction = new GraphQLEnumType({ + name: 'UpdateAction', + values: enumValues(['publish', 'saveDraft', 'unpublish'] as const), +}) + +export const GraphQLRestoreAction = new GraphQLEnumType({ + name: 'RestoreAction', + values: enumValues(['publish', 'saveDraft'] as const), +}) diff --git a/packages/payload/skills/payload/SKILL.md b/packages/payload/skills/payload/SKILL.md index 4fe021c866e..9f0bfb4721d 100644 --- a/packages/payload/skills/payload/SKILL.md +++ b/packages/payload/skills/payload/SKILL.md @@ -14,7 +14,7 @@ Payload is a Next.js native CMS with TypeScript-first architecture, providing ad | Auto-generate slugs | `{ type: 'slug', useAsSlug: 'title' }` | [FIELDS.md#slug-field](reference/FIELDS.md#slug-field) | | Restrict content by user | Access control with query | [ACCESS-CONTROL.md#row-level-security-with-complex-queries](reference/ACCESS-CONTROL.md#row-level-security-with-complex-queries) | | Local API user ops | `user` + `overrideAccess: false` | [QUERIES.md#access-control-in-local-api](reference/QUERIES.md#access-control-in-local-api) | -| Draft/publish workflow | `versions: { drafts: true }` | [COLLECTIONS.md#versioning--drafts](reference/COLLECTIONS.md#versioning--drafts) | +| Draft/publish workflow | `versions: { drafts: true }` + `version` / `action` | [COLLECTIONS.md#versioning--drafts](reference/COLLECTIONS.md#versioning--drafts) | | Computed fields | `virtual: true` with **field-level** `hooks.afterRead` returning the value | [FIELDS.md#virtual-fields](reference/FIELDS.md#virtual-fields) | | Document titles | Stored top-level field in `admin.useAsTitle` | [COLLECTIONS.md#useastitle](reference/COLLECTIONS.md#useastitle) | | Conditional fields | `admin.condition` | [FIELDS.md#conditional-fields](reference/FIELDS.md#conditional-fields) | @@ -159,7 +159,7 @@ For all field types (array, blocks, point, join, virtual, conditional, etc.), se ### Hook Example -Hooks live at one of two levels and they are not interchangeable. **Collection hooks** receive `{ doc, data, req, operation, ... }` and act on the whole document. **Field hooks** live inside an individual field's `hooks` object, receive `{ value, siblingData, ... }`, and **return the new value** for that field. Computed/virtual fields, per-field formatters, and per-field access masking are field hooks; cross-field business logic is a collection hook. +Hooks live at one of two levels and they are not interchangeable. **Collection hooks** receive `{ doc, data, req, operation, action, ... }` and act on the whole document. **Field hooks** live inside an individual field's `hooks` object, receive `{ value, siblingData, ... }`, and **return the new value** for that field. Computed/virtual fields, per-field formatters, and per-field access masking are field hooks; cross-field business logic is a collection hook. ```ts // Collection-level: business logic across the document diff --git a/packages/payload/skills/payload/reference/COLLECTIONS.md b/packages/payload/skills/payload/reference/COLLECTIONS.md index 70d1b8b4865..7a76d8ae3b7 100644 --- a/packages/payload/skills/payload/reference/COLLECTIONS.md +++ b/packages/payload/skills/payload/reference/COLLECTIONS.md @@ -240,43 +240,107 @@ export const Pages: CollectionConfig = { } ``` -### Draft API Usage +### Version and action APIs + +Reads use `version`. Writes use `action`. There is no public `draft` boolean on operations. `_status` stays on documents and in write `data`. ```ts -// Create draft +// Create draft (create/duplicate default is saveDraft when action and _status are omitted) await payload.create({ collection: 'posts', data: { title: 'Draft Post' }, - draft: true, // Saves as draft, skips required field validation + action: 'saveDraft', }) -// Update as draft +// Publish (update/restore default is publish when action and _status are omitted) await payload.update({ collection: 'posts', id: '123', - data: { title: 'Updated Draft' }, - draft: true, + data: { title: 'Published Post' }, + action: 'publish', }) -// Read with drafts (returns newest draft if available) -const post = await payload.findByID({ +// Unpublish — explicit action only; `_status: 'draft'` infers saveDraft, never unpublish +await payload.update({ collection: 'posts', id: '123', - draft: true, // Returns draft version if exists + action: 'unpublish', }) -// Query only published (REST API) -// GET /api/posts (returns only _status: 'published') +// Reads +await payload.findByID({ collection: 'posts', id: '123' }) // published (default) +await payload.findByID({ collection: 'posts', id: '123', version: 'latest' }) // newest draft, else published +await payload.findByID({ collection: 'posts', id: '123', version: 'draft' }) // draft only, no fallback + +// REST +// GET /api/posts?version=latest +// POST /api/posts?action=saveDraft +// PATCH /api/posts/123?action=publish + +// GraphQL +// query { Posts(version: latest) { docs { title } } } +// mutation { createPost(data: { title: "Draft" }, action: saveDraft) { title } } + +// SDK +await sdk.find({ collection: 'posts', version: 'latest' }) +``` + +**Read matrix** -// Access control for drafts +| `version` | Result | +| ----------------------- | -------------------------------------------- | +| omitted / `'published'` | Published main document | +| `'latest'` | Newest draft if present, otherwise published | +| `'draft'` | Newest draft only; empty / not-found if none | + +**Write matrix** — precedence is explicit `action`, then recognized `_status`, then operation default. Action always wins; core canonicalizes persisted `_status` from the effective action. + +| Operation | Allowed actions | Default | +| ------------------ | ----------------------------------- | ----------- | +| Create / duplicate | `saveDraft`, `publish` | `saveDraft` | +| Update | `saveDraft`, `publish`, `unpublish` | `publish` | +| Restore | `saveDraft`, `publish` | `publish` | + +`_status: 'draft'` infers `saveDraft`. `_status: 'published'` infers `publish`. Localized `_status` uses the active write locale. Non-draft collections accept omitted/`publish` only; `saveDraft`/`unpublish` throw. `afterChange.action` is the resolved action, or `undefined` without drafts. + +Local API and SDK types are always strict. `typescript.strictDraftTypes` is gone — do not add a replacement flag. + +**Codemod will not rewrite these — migrate by hand:** + +```ts +// Dynamic write +await payload.update({ + collection: 'posts', + id, + data, + action: shouldSaveDraft ? 'saveDraft' : 'publish', +}) + +// Preview read from Next.js draftMode +const { isEnabled: isDraftMode } = await draftMode() +await payload.find({ collection: 'pages', version: isDraftMode ? 'latest' : 'published' }) + +// Update that used to pass draft: false without _status — old behavior depended on existing state. +// Pick explicit action: 'publish' | 'saveDraft' | 'unpublish'. +``` + +**Search checklist for leftover `draft` operation arguments:** + +```sh +rg -n "draft:\\s*(true|false)|draft:\\s*\\w|[?&]draft=|strictDraftTypes" src +``` + +Do not rewrite `versions.drafts`, document `_status: 'draft'`, or UI "Save Draft" copy. Those are still correct. + +Access control still uses `_status`, not `version`: + +```ts export const Posts: CollectionConfig = { slug: 'posts', versions: { drafts: true }, access: { read: ({ req: { user } }) => { - // Public can only see published if (!user) return { _status: { equals: 'published' } } - // Authenticated can see all return true }, }, diff --git a/packages/payload/skills/payload/reference/HOOKS.md b/packages/payload/skills/payload/reference/HOOKS.md index b67556e8833..990f8723394 100644 --- a/packages/payload/skills/payload/reference/HOOKS.md +++ b/packages/payload/skills/payload/reference/HOOKS.md @@ -28,10 +28,10 @@ export const Posts: CollectionConfig = { }, ], - // After save + // After save — `action` is the resolved write action (including defaults) afterChange: [ - async ({ doc, req, operation, previousDoc }) => { - if (operation === 'create') { + async ({ doc, req, operation, action }) => { + if (action === 'publish') { await sendNotification(doc) } return doc @@ -117,19 +117,19 @@ import { revalidatePath } from 'next/cache' import type { Page } from '../payload-types' export const revalidatePage: CollectionAfterChangeHook = ({ + action, doc, previousDoc, req: { payload, context }, }) => { if (!context.disableRevalidate) { - if (doc._status === 'published') { + if (action === 'publish') { const path = doc.slug === 'home' ? '/' : `/${doc.slug}` payload.logger.info(`Revalidating page at path: ${path}`) revalidatePath(path) } - // Revalidate old path if unpublished - if (previousDoc?._status === 'published' && doc._status !== 'published') { + if (action === 'unpublish' && previousDoc?._status === 'published') { const oldPath = previousDoc.slug === 'home' ? '/' : `/${previousDoc.slug}` payload.logger.info(`Revalidating old page at path: ${oldPath}`) revalidatePath(oldPath) diff --git a/packages/payload/skills/payload/reference/QUERIES.md b/packages/payload/skills/payload/reference/QUERIES.md index 89cfff4c61e..bd0580fcd59 100644 --- a/packages/payload/skills/payload/reference/QUERIES.md +++ b/packages/payload/skills/payload/reference/QUERIES.md @@ -65,6 +65,7 @@ const nestedQuery: Where = { // Find documents const posts = await payload.find({ collection: 'posts', + version: 'published', where: { status: { equals: 'published' }, 'author.name': { contains: 'john' }, diff --git a/packages/payload/src/admin/RichText.ts b/packages/payload/src/admin/RichText.ts index 923fda1cf48..dcff9c4efdc 100644 --- a/packages/payload/src/admin/RichText.ts +++ b/packages/payload/src/admin/RichText.ts @@ -15,6 +15,8 @@ import type { SanitizedGlobalConfig } from '../globals/config/types.js' import type { RequestContext, TypedFallbackLocale } from '../index.js' import type { JsonObject, PayloadRequest, PopulateType } from '../types/index.js' import type { FieldsToJSONSchemaArgs } from '../utilities/configToJSONSchema.js' +import type { WriteAction } from '../versions/actions/types.js' +import type { ReadVersion } from '../versions/types.js' import type { RichTextFieldClientProps, RichTextFieldServerProps } from './fields/RichText.js' import type { FieldDiffClientProps, FieldDiffServerProps, FieldSchemaMap } from './types.js' @@ -30,8 +32,6 @@ export type AfterReadRichTextHookArgs< depth?: number - draft?: boolean - fallbackLocale?: TypedFallbackLocale fieldPromises?: Promise[] @@ -53,6 +53,7 @@ export type AfterReadRichTextHookArgs< showHiddenFields?: boolean triggerAccessControl?: boolean triggerHooks?: boolean + version?: ReadVersion } export type AfterChangeRichTextHookArgs< @@ -60,6 +61,8 @@ export type AfterChangeRichTextHookArgs< TValue = any, TSiblingData = any, > = { + /** The already-resolved write action for this operation. */ + action?: WriteAction /** A string relating to which operation the field type is currently executing within. */ operation: 'create' | 'update' /** The document before changes were applied. */ @@ -231,7 +234,6 @@ type RichTextAdapterBase< context: RequestContext currentDepth?: number depth: number - draft: boolean field: RichTextField fieldPromises: Promise[] findMany: boolean @@ -243,6 +245,7 @@ type RichTextAdapterBase< req: PayloadRequest showHiddenFields: boolean siblingDoc: JsonObject + version?: ReadVersion }) => void hooks?: RichTextHooks /** diff --git a/packages/payload/src/auth/endpoints/me.ts b/packages/payload/src/auth/endpoints/me.ts index c022ba7b026..b3075d66238 100644 --- a/packages/payload/src/auth/endpoints/me.ts +++ b/packages/payload/src/auth/endpoints/me.ts @@ -1,50 +1,27 @@ import { status as httpStatus } from 'http-status' import type { PayloadHandler } from '../../config/types.js' -import type { JoinParams } from '../../utilities/sanitizeJoinParams.js' import { getRequestCollection } from '../../utilities/getRequestEntity.js' import { headersWithCors } from '../../utilities/headersWithCors.js' -import { isNumber } from '../../utilities/isNumber.js' -import { sanitizeJoinParams } from '../../utilities/sanitizeJoinParams.js' -import { sanitizePopulateParam } from '../../utilities/sanitizePopulateParam.js' -import { sanitizeSelectParam } from '../../utilities/sanitizeSelectParam.js' +import { parseParams } from '../../utilities/parseParams/index.js' import { extractJWT } from '../extractJWT.js' import { meOperation } from '../operations/me.js' export const meHandler: PayloadHandler = async (req) => { - const { searchParams } = req const collection = getRequestCollection(req) const currentToken = extractJWT(req) - const depthFromSearchParams = searchParams.get('depth') - const draftFromSearchParams = searchParams.get('depth') - - const { - depth: depthFromQuery, - draft: draftFromQuery, - joins, - populate, - select, - } = req.query as { - depth?: string - draft?: string - joins?: JoinParams - populate?: Record - select?: Record - } - - const depth = depthFromQuery || depthFromSearchParams - const draft = draftFromQuery || draftFromSearchParams + const { depth, joins, populate, select, version } = parseParams(req.query) const result = await meOperation({ collection, currentToken: currentToken!, - depth: isNumber(depth) ? Number(depth) : undefined, - draft: draft === 'true', - joins: sanitizeJoinParams(joins), - populate: sanitizePopulateParam(populate), + depth, + joins, + populate, req, - select: sanitizeSelectParam(select), + select, + version, }) if (collection.config.auth.removeTokenFromResponses) { diff --git a/packages/payload/src/auth/operations/login.ts b/packages/payload/src/auth/operations/login.ts index f89579ee0bc..6181519d1c5 100644 --- a/packages/payload/src/auth/operations/login.ts +++ b/packages/payload/src/auth/operations/login.ts @@ -355,14 +355,13 @@ export const loginOperation = async ( context: req.context, depth: depth!, doc: user, - // @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve - draft: undefined, fallbackLocale: fallbackLocale!, global: null, locale: locale!, overrideAccess, req, showHiddenFields: showHiddenFields!, + version: 'published', }) // ///////////////////////////////////// diff --git a/packages/payload/src/auth/operations/me.ts b/packages/payload/src/auth/operations/me.ts index fa774927e71..b6a04f73515 100644 --- a/packages/payload/src/auth/operations/me.ts +++ b/packages/payload/src/auth/operations/me.ts @@ -3,6 +3,7 @@ import { decodeJwt } from 'jose' import type { Collection } from '../../collections/config/types.js' import type { AuthenticatedUser } from '../../index.js' import type { JoinQuery, PayloadRequest, PopulateType, SelectType } from '../../types/index.js' +import type { ReadVersion } from '../../versions/types.js' export type MeOperationResult = { collection?: string @@ -15,15 +16,15 @@ export type Arguments = { collection: Collection currentToken?: string depth?: number - draft?: boolean joins?: JoinQuery populate?: PopulateType req: PayloadRequest select?: SelectType + version?: ReadVersion } export const meOperation = async (args: Arguments): Promise => { - const { collection, currentToken, depth, draft, joins, populate, req, select } = args + const { collection, currentToken, depth, joins, populate, req, select, version } = args let result: MeOperationResult = { user: null!, @@ -43,13 +44,13 @@ export const meOperation = async (args: Arguments): Promise = id: req.user.id, collection: collection.config.slug, depth: isGraphQL ? 0 : (depth ?? collection.config.auth.depth), - draft, joins, overrideAccess: false, populate, req, select, showHiddenFields: false, + version, })) as AuthenticatedUser if (user) { diff --git a/packages/payload/src/cli/commands/collections/createDocuments.ts b/packages/payload/src/cli/commands/collections/createDocuments.ts index a9b7e93d23c..12ab1d7c3a1 100644 --- a/packages/payload/src/cli/commands/collections/createDocuments.ts +++ b/packages/payload/src/cli/commands/collections/createDocuments.ts @@ -2,7 +2,6 @@ import type { EntityInputSchema } from '../../../utilities/entityInputSchema/typ import { createDocumentsLocalInputSchema } from '../../../collections/operations/inputSchemas.js' import { createLocalReq } from '../../../utilities/createLocalReq.js' -import { hasDraftValidationEnabled } from '../../../utilities/getVersionsConfig.js' import { defineCLICommand } from '../../defineCLICommand.js' import { localFileSchema, @@ -36,16 +35,10 @@ export const createCreateDocumentsCommand = defineCLICommand({ handler: async ({ args, getPayload, isJSON }) => { const payload = await getPayload() const collection = args.slug - const collectionConfig = payload.collections[collection]?.config const docs: Array<{ doc: unknown; index: number } | { id: number | string; index: number }> = [] const errors: Array<{ index: number; issues?: unknown[]; message: string }> = [] let schema: EntityInputSchema | undefined const req = await createLocalReq({}, payload) - const shouldUsePartialSchema = - args.draft === true && - collectionConfig !== undefined && - !hasDraftValidationEnabled(collectionConfig) - for (const [index, { data, file }] of args.documents.entries()) { const inputData = stripCollectionVirtualFields({ collection, data, payload }) @@ -53,17 +46,17 @@ export const createCreateDocumentsCommand = defineCLICommand({ validateCollectionData({ slug: collection, data: inputData, - partial: shouldUsePartialSchema, + partial: true, req, }) const resolvedFile = await resolveCLIFile({ slug: collection, input: file, req }) const doc = await payload.create({ + action: args.action, collection, data: prepareCollectionData({ collection, data: inputData, payload }), depth: args.depth, - draft: args.draft, fallbackLocale: args.fallbackLocale, ...resolvedFile, locale: args.locale, diff --git a/packages/payload/src/cli/commands/collections/duplicateDocument.ts b/packages/payload/src/cli/commands/collections/duplicateDocument.ts index 457843cada4..8a1c8e8d319 100644 --- a/packages/payload/src/cli/commands/collections/duplicateDocument.ts +++ b/packages/payload/src/cli/commands/collections/duplicateDocument.ts @@ -48,12 +48,12 @@ export const createDuplicateDocumentCommand = defineCLICommand({ result = await payload.duplicate({ id: parseDocumentID({ id: args.id, collectionSlug: collection, payload }), + action: args.action, collection, data: inputData ? prepareCollectionData({ collection, data: inputData, payload }) : undefined, depth: args.depth, - draft: args.draft, fallbackLocale: args.fallbackLocale, locale: args.locale, overrideAccess: args.overrideAccess, diff --git a/packages/payload/src/cli/commands/collections/findDocuments.ts b/packages/payload/src/cli/commands/collections/findDocuments.ts index d479cd05a64..1328817d8c7 100644 --- a/packages/payload/src/cli/commands/collections/findDocuments.ts +++ b/packages/payload/src/cli/commands/collections/findDocuments.ts @@ -26,20 +26,20 @@ export const createFindDocumentsCommand = defineCLICommand({ id: parseDocumentID({ id: args.id, collectionSlug: collection, payload }), collection, ...commonOptions, - draft: args.draft, joins: args.joins, trash: args.trash, + version: args.version, }) : await payload.find({ collection, ...commonOptions, - draft: args.draft, joins: args.joins, limit: args.limit, page: args.page, pagination: args.pagination, sort: args.sort, trash: args.trash, + version: args.version, where: args.where, }) diff --git a/packages/payload/src/cli/commands/collections/findVersionByID.ts b/packages/payload/src/cli/commands/collections/findVersionByID.ts index f967b0b8dfd..b0c329c128a 100644 --- a/packages/payload/src/cli/commands/collections/findVersionByID.ts +++ b/packages/payload/src/cli/commands/collections/findVersionByID.ts @@ -18,7 +18,6 @@ export const createFindVersionByIDCommand = defineCLICommand({ id: String(args.id), collection: args.slug, ...getReadOptions(args), - draft: args.draft, trash: args.trash, }) diff --git a/packages/payload/src/cli/commands/collections/findVersions.ts b/packages/payload/src/cli/commands/collections/findVersions.ts index efbcc9b9df7..fe46d71bc00 100644 --- a/packages/payload/src/cli/commands/collections/findVersions.ts +++ b/packages/payload/src/cli/commands/collections/findVersions.ts @@ -18,7 +18,6 @@ export const createFindVersionsCommand = defineCLICommand({ const result = await payload.findVersions({ collection: args.slug, ...getReadOptions(args), - draft: args.draft, limit: args.limit, page: args.page, pagination: args.pagination, diff --git a/packages/payload/src/cli/commands/collections/restoreVersion.ts b/packages/payload/src/cli/commands/collections/restoreVersion.ts index a1806fee7f2..78cb320f36d 100644 --- a/packages/payload/src/cli/commands/collections/restoreVersion.ts +++ b/packages/payload/src/cli/commands/collections/restoreVersion.ts @@ -16,9 +16,9 @@ export const createRestoreVersionCommand = defineCLICommand({ const payload = await getPayload() const result = await payload.restoreVersion({ id: String(args.id), + action: args.action, collection: args.slug, ...getReadOptions(args), - draft: args.draft, }) if (!isJSON) { diff --git a/packages/payload/src/cli/commands/collections/updateDocument.ts b/packages/payload/src/cli/commands/collections/updateDocument.ts index f0c84d1a3cd..22e0fe537c2 100644 --- a/packages/payload/src/cli/commands/collections/updateDocument.ts +++ b/packages/payload/src/cli/commands/collections/updateDocument.ts @@ -52,10 +52,10 @@ export const createUpdateDocumentCommand = defineCLICommand({ if (args.id !== undefined) { const doc = await payload.update({ id: parseDocumentID({ id: args.id, collectionSlug: collection, payload }), + action: args.action, collection, data, depth: args.depth, - draft: args.draft, fallbackLocale: args.fallbackLocale, ...resolvedFile, locale: args.locale, @@ -81,10 +81,10 @@ export const createUpdateDocumentCommand = defineCLICommand({ if (args.where !== undefined) { const updateResult = await payload.update({ + action: args.action, collection, data, depth: args.depth, - draft: args.draft, fallbackLocale: args.fallbackLocale, ...resolvedFile, limit: args.limit, diff --git a/packages/payload/src/cli/commands/globals/findGlobal.ts b/packages/payload/src/cli/commands/globals/findGlobal.ts index 5051771f182..494d22c5acc 100644 --- a/packages/payload/src/cli/commands/globals/findGlobal.ts +++ b/packages/payload/src/cli/commands/globals/findGlobal.ts @@ -16,6 +16,7 @@ export const createFindGlobalCommand = defineCLICommand({ const result = await payload.findGlobal({ slug: args.slug, ...getReadOptions(args), + version: args.version, }) if (!isJSON) { diff --git a/packages/payload/src/cli/commands/globals/restoreGlobalVersion.ts b/packages/payload/src/cli/commands/globals/restoreGlobalVersion.ts index 88a5d86fe97..3b163b430d4 100644 --- a/packages/payload/src/cli/commands/globals/restoreGlobalVersion.ts +++ b/packages/payload/src/cli/commands/globals/restoreGlobalVersion.ts @@ -17,6 +17,7 @@ export const createRestoreGlobalVersionCommand = defineCLICommand({ const result = await payload.restoreGlobalVersion({ id: String(args.id), slug: args.slug, + action: args.action, ...getReadOptions(args), }) diff --git a/packages/payload/src/cli/commands/globals/updateGlobal.ts b/packages/payload/src/cli/commands/globals/updateGlobal.ts index c627a155b3e..1406847cf7c 100644 --- a/packages/payload/src/cli/commands/globals/updateGlobal.ts +++ b/packages/payload/src/cli/commands/globals/updateGlobal.ts @@ -31,9 +31,9 @@ export const createUpdateGlobalCommand = defineCLICommand({ result = await payload.updateGlobal({ slug, + action: args.action, data: prepareGlobalData({ slug, data: inputData, payload }), depth: args.depth, - draft: args.draft, fallbackLocale: args.fallbackLocale, locale: args.locale, overrideAccess: args.overrideAccess, diff --git a/packages/payload/src/collections/config/types.ts b/packages/payload/src/collections/config/types.ts index 6b74873c448..698863657e9 100644 --- a/packages/payload/src/collections/config/types.ts +++ b/packages/payload/src/collections/config/types.ts @@ -61,8 +61,10 @@ import type { WithSelectFn, } from '../../types/index.js' import type { SanitizedUploadConfig, UploadConfig } from '../../uploads/types.js' +import type { CreateAction, RestoreAction, UpdateAction } from '../../versions/actions/types.js' import type { IncomingCollectionVersions, + ReadVersion, SanitizedCollectionVersions, } from '../../versions/types.js' import type { @@ -84,6 +86,8 @@ export type IDTypeForCollectionSlug = export type SelectFromCollectionSlug = TypedCollectionSelect[TSlug] +type HasGeneratedCollectionTypes = 'collections' extends keyof GeneratedTypes ? true : false + /** * Collection slugs that do not have drafts enabled. * Detects collections without drafts by checking for the absence of the `_status` field. @@ -93,31 +97,89 @@ export type CollectionsWithoutDrafts = { }[CollectionSlug] /** - * Conditionally allows or forbids the `draft` property based on collection configuration. - * When `strictDraftTypes` is enabled, the `draft` property is forbidden on collections without drafts. + * Allows `version` on draft-enabled collections and forbids it on collections without drafts. + * When generated types are untyped, `CollectionsWithoutDrafts` is wide and version remains allowed. */ -export type DraftFlagFromCollectionSlug = GeneratedTypes extends { - strictDraftTypes: true -} - ? TSlug extends CollectionsWithoutDrafts +export type VersionFromCollectionSlug = + HasGeneratedCollectionTypes extends false ? { /** - * The `draft` property is not allowed because this collection does not have `versions.drafts` enabled. + * Which document representation to read. [More](https://payloadcms.com/docs/versions/drafts) + * + * @default 'published' */ - draft?: never + version?: ReadVersion } - : { + : TSlug extends CollectionsWithoutDrafts + ? { + /** + * `version` is not allowed because this collection does not have `versions.drafts` enabled. + */ + version?: never + } + : { + /** + * Which document representation to read. [More](https://payloadcms.com/docs/versions/drafts) + * + * @default 'published' + */ + version?: ReadVersion + } + +/** + * Allows create/duplicate `action` on draft-enabled collections. Non-draft collections may only omit it or pass `publish`. + */ +export type CreateActionFromCollectionSlug = + HasGeneratedCollectionTypes extends false + ? { + action?: CreateAction + } + : TSlug extends CollectionsWithoutDrafts + ? { + action?: 'publish' + } + : { + action?: CreateAction + } + +/** + * Allows update `action` on draft-enabled collections. Non-draft collections may only omit it or pass `publish`. + */ +export type UpdateActionFromCollectionSlug = + HasGeneratedCollectionTypes extends false + ? { + action?: UpdateAction + } + : TSlug extends CollectionsWithoutDrafts + ? { + action?: 'publish' + } + : { + action?: UpdateAction + } + +/** + * Allows restore `action` on draft-enabled collections. Non-draft collections may only omit it or pass `publish`. + * Restore does not accept `unpublish`. Omitted action publishes. + */ +export type RestoreActionFromCollectionSlug = + HasGeneratedCollectionTypes extends false + ? { /** - * Whether the document(s) should be queried from the versions table/collection or not. [More](https://payloadcms.com/docs/versions/drafts#draft-api) + * Restore and publish (`publish`, default) or restore as a draft (`saveDraft`). */ - draft?: boolean + action?: RestoreAction } - : { - /** - * Whether the document(s) should be queried from the versions table/collection or not. [More](https://payloadcms.com/docs/versions/drafts#draft-api) - */ - draft?: boolean - } + : TSlug extends CollectionsWithoutDrafts + ? { + action?: 'publish' + } + : { + /** + * Restore and publish (`publish`, default) or restore as a draft (`saveDraft`). + */ + action?: RestoreAction + } export type AuthOperationsFromCollectionSlug = TypedAuthOperations[TSlug] @@ -144,6 +206,51 @@ export type DraftDataFromCollectionSlug = DraftDat DataFromCollectionSlug > +/** + * Create write data discriminated by explicit `action`, then omitted-action `_status`. + * Explicit `saveDraft` always accepts draft-safe partial data. Explicit `publish` always requires + * publish-valid data. Omitted action accepts complete publish-valid data with any status; partial + * data remains limited to a draft, null, or omitted status. + */ +type PermissiveCreateDataFromCollectionSlug = { + action?: CreateAction + data: DraftDataFromCollectionSlug | RequiredDataFromCollectionSlug +} + +export type CreateDataFromCollectionSlug = + HasGeneratedCollectionTypes extends false + ? PermissiveCreateDataFromCollectionSlug + : CollectionsWithoutDrafts extends TSlug + ? PermissiveCreateDataFromCollectionSlug + : TSlug extends CollectionsWithoutDrafts + ? { + action?: 'publish' + data: RequiredDataFromCollectionSlug + } + : + | { + /** + * Publish the document. Required fields must be provided. + */ + action: 'publish' + data: RequiredDataFromCollectionSlug + } + | { + /** + * Save a draft. Required fields are optional because draft validation is skipped. + */ + action: 'saveDraft' + data: DraftDataFromCollectionSlug + } + | { + action?: undefined + data: { _status?: 'draft' | null } & DraftDataFromCollectionSlug + } + | { + action?: undefined + data: RequiredDataFromCollectionSlug + } + /** * Helper type for draft data OUTPUT (e.g., query results) - makes user fields optional but keeps id required * When querying drafts, required fields may be null/undefined as validation is skipped, but system fields like id are always present @@ -218,16 +325,12 @@ export type BeforeChangeHook = (args: { req: PayloadRequest }) => any -export type AfterChangeHook = (args: { +type AfterChangeHookBase = { /** The collection which this hook is being run on */ collection: SanitizedCollectionConfig context: RequestContext data: Partial doc: T - /** - * Hook operation being performed - */ - operation: CreateOrUpdateOperation /** * Whether access control is being overridden for this operation */ @@ -236,7 +339,29 @@ export type AfterChangeHook = (args: { req: PayloadRequest /** Resolved field selection for the operation's response. */ select?: SelectType -}) => any +} + +export type AfterChangeHook = ( + args: ( + | { + /** + * Resolved write action for this operation. `undefined` when drafts are not enabled. + * Create/duplicate expose `saveDraft` or `publish` only. + */ + action?: CreateAction + operation: 'create' + } + | { + /** + * Resolved write action for this operation. `undefined` when drafts are not enabled. + * Update exposes `saveDraft`, `publish`, or `unpublish`. Restore exposes `saveDraft` or `publish`. + */ + action?: RestoreAction | UpdateAction + operation: 'update' + } + ) & + AfterChangeHookBase, +) => any export type BeforeReadHook = (args: { /** The collection which this hook is being run on */ @@ -263,6 +388,10 @@ export type AfterReadHook = (args: { overrideAccess?: boolean query?: { [key: string]: any } req: PayloadRequest + /** + * Only available on find / findByID / findGlobal reads. + */ + version?: ReadVersion }) => any export type BeforeDeleteHook = (args: { diff --git a/packages/payload/src/collections/dataloader.ts b/packages/payload/src/collections/dataloader.ts index 95f304ca008..3c4d414a596 100644 --- a/packages/payload/src/collections/dataloader.ts +++ b/packages/payload/src/collections/dataloader.ts @@ -5,6 +5,7 @@ import DataLoader from 'dataloader' import type { FindArgs } from '../database/types.js' import type { Payload, TypedFallbackLocale } from '../index.js' import type { PayloadRequest, PopulateType, SelectType } from '../types/index.js' +import type { ReadVersion } from '../versions/types.js' import type { TypeWithID } from './config/types.js' import type { FindOptions } from './operations/local/find.js' @@ -59,7 +60,7 @@ const batchAndLoadDocs = fallbackLocale, overrideAccess, showHiddenFields, - draft, + version, select, populate, ] = JSON.parse(key) @@ -73,7 +74,7 @@ const batchAndLoadDocs = fallbackLocale, overrideAccess, showHiddenFields, - draft, + version, select, populate, ] @@ -100,7 +101,7 @@ const batchAndLoadDocs = fallbackLocale, overrideAccess, showHiddenFields, - draft, + version, select, populate, ] = JSON.parse(batchKey) @@ -115,7 +116,6 @@ const batchAndLoadDocs = currentDepth, depth, disableErrors: true, - draft, fallbackLocale, locale, overrideAccess: Boolean(overrideAccess), @@ -124,6 +124,7 @@ const batchAndLoadDocs = req, select: selectWithDeletedAt, showHiddenFields: Boolean(showHiddenFields), + version, ...(enableTrash ? { trash: true } : {}), where: { id: { @@ -140,7 +141,6 @@ const batchAndLoadDocs = currentDepth, depth, docID: doc.id, - draft, fallbackLocale, locale, overrideAccess, @@ -148,6 +148,7 @@ const batchAndLoadDocs = select, showHiddenFields, transactionID: req.transactionID!, + version, }) const docsIndex = keys.findIndex((key) => key === docKey) @@ -186,7 +187,6 @@ const createFindDataloaderCacheKey = ({ currentDepth, depth, disableErrors, - draft, includeLockStatus, joins, limit, @@ -198,6 +198,7 @@ const createFindDataloaderCacheKey = ({ select, showHiddenFields, sort, + version, where, }: FindOptions): string => JSON.stringify([ @@ -205,7 +206,7 @@ const createFindDataloaderCacheKey = ({ currentDepth, depth, disableErrors, - draft, + version, includeLockStatus, joins, limit, @@ -228,7 +229,6 @@ type CreateCacheKeyArgs = { currentDepth: number depth: number docID: number | string - draft: boolean fallbackLocale: TypedFallbackLocale locale: string | string[] overrideAccess: boolean @@ -236,13 +236,13 @@ type CreateCacheKeyArgs = { select?: SelectType showHiddenFields: boolean transactionID: number | Promise | string + version: ReadVersion } export const createDataloaderCacheKey = ({ collectionSlug, currentDepth, depth, docID, - draft, fallbackLocale, locale, overrideAccess, @@ -250,6 +250,7 @@ export const createDataloaderCacheKey = ({ select, showHiddenFields, transactionID, + version, }: CreateCacheKeyArgs): string => JSON.stringify([ transactionID, @@ -261,7 +262,7 @@ export const createDataloaderCacheKey = ({ fallbackLocale, overrideAccess, showHiddenFields, - draft, + version, select, populate, ]) diff --git a/packages/payload/src/collections/endpoints/create.ts b/packages/payload/src/collections/endpoints/create.ts index 9a471a3ad1d..94ed9b3c2c9 100644 --- a/packages/payload/src/collections/endpoints/create.ts +++ b/packages/payload/src/collections/endpoints/create.ts @@ -5,20 +5,36 @@ import type { PayloadHandler } from '../../config/types.js' import { getRequestCollection } from '../../utilities/getRequestEntity.js' import { headersWithCors } from '../../utilities/headersWithCors.js' -import { parseParams } from '../../utilities/parseParams/index.js' +import { + createActionValues, + parseEnumParam, + parseParams, +} from '../../utilities/parseParams/index.js' import { createOperation } from '../operations/create.js' export const createHandler: PayloadHandler = async (req) => { const collection = getRequestCollection(req) - const { autosave, depth, draft, populate, publishAllLocales, select } = parseParams(req.query) + const { + action: requestedAction, + autosave, + depth, + populate, + publishAllLocales, + select, + } = parseParams(req.query) + const action = parseEnumParam({ + allowed: createActionValues, + param: 'action', + value: requestedAction, + }) const doc = await createOperation({ + action, autosave, collection, data: req.data!, depth, - draft, populate, publishAllLocales, req, diff --git a/packages/payload/src/collections/endpoints/duplicate.ts b/packages/payload/src/collections/endpoints/duplicate.ts index c9842189d10..b75e6d563c1 100644 --- a/packages/payload/src/collections/endpoints/duplicate.ts +++ b/packages/payload/src/collections/endpoints/duplicate.ts @@ -5,20 +5,35 @@ import type { PayloadHandler } from '../../config/types.js' import { getRequestCollectionWithID } from '../../utilities/getRequestEntity.js' import { headersWithCors } from '../../utilities/headersWithCors.js' -import { parseParams } from '../../utilities/parseParams/index.js' +import { + createActionValues, + parseEnumParam, + parseParams, +} from '../../utilities/parseParams/index.js' import { duplicateOperation } from '../operations/duplicate.js' export const duplicateHandler: PayloadHandler = async (req) => { const { id, collection } = getRequestCollectionWithID(req) - const { depth, draft = true, populate, select, selectedLocales } = parseParams(req.query) + const { + action: requestedAction, + depth, + populate, + select, + selectedLocales, + } = parseParams(req.query) + const action = parseEnumParam({ + allowed: createActionValues, + param: 'action', + value: requestedAction, + }) const doc = await duplicateOperation({ id, + action, collection, data: req.data, depth, - draft, populate, req, select, diff --git a/packages/payload/src/collections/endpoints/find.ts b/packages/payload/src/collections/endpoints/find.ts index d18c6988fe6..220d7bd8b98 100644 --- a/packages/payload/src/collections/endpoints/find.ts +++ b/packages/payload/src/collections/endpoints/find.ts @@ -10,13 +10,12 @@ import { findOperation } from '../operations/find.js' export const findHandler: PayloadHandler = async (req) => { const collection = getRequestCollection(req) - const { depth, draft, joins, limit, page, pagination, populate, select, sort, trash, where } = + const { depth, joins, limit, page, pagination, populate, select, sort, trash, version, where } = parseParams(req.query) const result = await findOperation({ collection, depth, - draft, joins, limit, page, @@ -26,6 +25,7 @@ export const findHandler: PayloadHandler = async (req) => { select, sort, trash, + version, where, }) diff --git a/packages/payload/src/collections/endpoints/findByID.ts b/packages/payload/src/collections/endpoints/findByID.ts index 7a3eec561b3..ff79281005d 100644 --- a/packages/payload/src/collections/endpoints/findByID.ts +++ b/packages/payload/src/collections/endpoints/findByID.ts @@ -11,7 +11,7 @@ export const findByIDHandler: PayloadHandler = async (req) => { const { data: dataArg } = req const { id, collection } = getRequestCollectionWithID(req) - const { data, depth, draft, flattenLocales, joins, populate, select, trash } = parseParams({ + const { data, depth, flattenLocales, joins, populate, select, trash, version } = parseParams({ ...req.query, ...dataArg, }) @@ -21,13 +21,13 @@ export const findByIDHandler: PayloadHandler = async (req) => { collection, data, depth, - draft, flattenLocales, joins, populate, req, select, trash, + version, }) return Response.json(result, { diff --git a/packages/payload/src/collections/endpoints/restoreVersion.ts b/packages/payload/src/collections/endpoints/restoreVersion.ts index 5d24849d1e6..800dc9b5822 100644 --- a/packages/payload/src/collections/endpoints/restoreVersion.ts +++ b/packages/payload/src/collections/endpoints/restoreVersion.ts @@ -4,19 +4,28 @@ import type { PayloadHandler } from '../../config/types.js' import { getRequestCollectionWithID } from '../../utilities/getRequestEntity.js' import { headersWithCors } from '../../utilities/headersWithCors.js' -import { parseParams } from '../../utilities/parseParams/index.js' +import { + parseEnumParam, + parseParams, + restoreActionValues, +} from '../../utilities/parseParams/index.js' import { restoreVersionOperation } from '../operations/restoreVersion.js' export const restoreVersionHandler: PayloadHandler = async (req) => { const { id, collection } = getRequestCollectionWithID(req) - const { depth, draft, populate } = parseParams(req.query) + const { action: requestedAction, depth, populate } = parseParams(req.query) + const action = parseEnumParam({ + allowed: restoreActionValues, + param: 'action', + value: requestedAction, + }) const result = await restoreVersionOperation({ id, + action, collection, depth, - draft, populate, req, }) diff --git a/packages/payload/src/collections/endpoints/update.ts b/packages/payload/src/collections/endpoints/update.ts index db8bf0b4e4f..0a096003d41 100644 --- a/packages/payload/src/collections/endpoints/update.ts +++ b/packages/payload/src/collections/endpoints/update.ts @@ -12,8 +12,8 @@ export const updateHandler: PayloadHandler = async (req) => { const collection = getRequestCollection(req) const { + action, depth, - draft, limit, overrideLock, populate, @@ -26,10 +26,10 @@ export const updateHandler: PayloadHandler = async (req) => { } = parseParams(req.query) const result = await updateOperation({ + action, collection, data: req.data!, depth, - draft, limit, overrideLock: overrideLock ?? false, populate, diff --git a/packages/payload/src/collections/endpoints/updateByID.ts b/packages/payload/src/collections/endpoints/updateByID.ts index 9afc156b562..6aaca610103 100644 --- a/packages/payload/src/collections/endpoints/updateByID.ts +++ b/packages/payload/src/collections/endpoints/updateByID.ts @@ -11,9 +11,9 @@ export const updateByIDHandler: PayloadHandler = async (req) => { const { id, collection } = getRequestCollectionWithID(req) const { + action, autosave, depth, - draft, overrideLock, populate, publishAllLocales, @@ -24,11 +24,11 @@ export const updateByIDHandler: PayloadHandler = async (req) => { const doc = await updateByIDOperation({ id, + action, autosave, collection, data: req.data!, depth, - draft, overrideLock: overrideLock ?? false, populate, publishAllLocales, @@ -40,7 +40,7 @@ export const updateByIDHandler: PayloadHandler = async (req) => { let message = req.t('general:updatedSuccessfully') - if (draft) { + if (action === 'saveDraft') { message = req.t('version:draftSavedSuccessfully') } if (autosave) { diff --git a/packages/payload/src/collections/operations/create.ts b/packages/payload/src/collections/operations/create.ts index 14febc4d764..0cf2aadfffc 100644 --- a/packages/payload/src/collections/operations/create.ts +++ b/packages/payload/src/collections/operations/create.ts @@ -8,6 +8,7 @@ import type { SelectType, TransformCollectionWithSelect, } from '../../types/index.js' +import type { CreateAction } from '../../versions/actions/types.js' import type { Collection, DataFromCollectionSlug, @@ -40,17 +41,18 @@ import { killTransaction } from '../../utilities/killTransaction.js' import { resolveSelect } from '../../utilities/resolveSelect.js' import { sanitizeInternalFields } from '../../utilities/sanitizeInternalFields.js' import { sanitizeSelect } from '../../utilities/sanitizeSelect.js' +import { canonicalizeWriteStatus, resolveAction } from '../../versions/actions/resolveAction.js' import { buildAfterOperation } from './utilities/buildAfterOperation.js' import { buildBeforeOperation } from './utilities/buildBeforeOperation.js' export type Arguments = { + action?: CreateAction autosave?: boolean collection: Collection data: RequiredDataFromCollectionSlug depth?: number disableTransaction?: boolean disableVerificationEmail?: boolean - draft?: boolean duplicateFromID?: DataFromCollectionSlug['id'] overrideAccess?: boolean overwriteExistingFiles?: boolean @@ -92,12 +94,12 @@ export const createOperation = async < }) const { + action, autosave = false, collection: { config: collectionConfig }, collection, depth, disableVerificationEmail, - draft = false, duplicateFromID, overrideAccess, overwriteExistingFiles = false, @@ -117,15 +119,30 @@ export const createOperation = async < let { data } = args + const draftsEnabled = hasDraftsEnabled(collectionConfig) + const resolvedAction = resolveAction({ + action, + autosave, + draftsEnabled, + locale, + localizedStatusEnabled: hasLocalizeStatusEnabled(collectionConfig), + operation: duplicateFromID ? 'duplicate' : 'create', + publishAllLocales: publishAllLocalesArg, + status: data && typeof data === 'object' && '_status' in data ? data._status : undefined, + }) + + data = canonicalizeWriteStatus({ + action: resolvedAction, + data, + locale, + publishAllLocales: publishAllLocalesArg, + }) + // For creates there is no existing doc — always publish all locales when not a draft. + const isSavingDraft = resolvedAction === 'saveDraft' const publishAllLocales = - !draft && + !isSavingDraft && (publishAllLocalesArg ?? (hasLocalizeStatusEnabled(collectionConfig) ? false : true)) - const isSavingDraft = Boolean(draft && hasDraftsEnabled(collectionConfig) && !publishAllLocales) - - if (isSavingDraft) { - data._status = 'draft' - } let duplicatedFromDocWithLocales: JsonObject = {} let duplicatedFromDoc: JsonObject = {} @@ -133,8 +150,11 @@ export const createOperation = async < if (duplicateFromID) { const duplicateResult = await getDuplicateDocumentData({ id: duplicateFromID, + action: + resolvedAction === 'saveDraft' || resolvedAction === 'publish' + ? resolvedAction + : undefined, collectionConfig, - draftArg: isSavingDraft, overrideAccess, req, selectedLocales, @@ -357,6 +377,7 @@ export const createOperation = async < autosave, collection: collectionConfig, docWithLocales: resultWithLocales, + draft: isSavingDraft, operation: 'create', payload, req, @@ -389,7 +410,6 @@ export const createOperation = async < context: req.context, depth: depth!, doc: resultWithLocales, - draft, fallbackLocale: fallbackLocale!, global: null, locale: locale!, @@ -398,6 +418,7 @@ export const createOperation = async < req, select, showHiddenFields: showHiddenFields!, + version: isSavingDraft ? 'latest' : 'published', }) // ///////////////////////////////////// @@ -422,6 +443,7 @@ export const createOperation = async < // ///////////////////////////////////// result = await afterChange({ + action: resolvedAction, collection: collectionConfig, context: req.context, data, @@ -440,6 +462,7 @@ export const createOperation = async < for (const hook of collectionConfig.hooks.afterChange) { result = (await hook({ + action: resolvedAction as CreateAction | undefined, collection: collectionConfig, context: req.context, data, diff --git a/packages/payload/src/collections/operations/delete.ts b/packages/payload/src/collections/operations/delete.ts index adf0d5d3f6b..047850f391a 100644 --- a/packages/payload/src/collections/operations/delete.ts +++ b/packages/payload/src/collections/operations/delete.ts @@ -202,8 +202,6 @@ export const deleteOperation = async < context: req.context, depth: depth!, doc, - // @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve - draft: undefined, fallbackLocale: fallbackLocale!, global: null, locale: locale!, diff --git a/packages/payload/src/collections/operations/deleteByID.ts b/packages/payload/src/collections/operations/deleteByID.ts index 8d3fc08a938..87356251daf 100644 --- a/packages/payload/src/collections/operations/deleteByID.ts +++ b/packages/payload/src/collections/operations/deleteByID.ts @@ -226,7 +226,6 @@ export const deleteByIDOperation = async , 'select'> @@ -82,7 +86,6 @@ export const findOperation = async < currentDepth, depth, disableErrors, - draft: draftsEnabled, includeLockStatus: includeLockStatusFromArgs, joins, limit, @@ -94,6 +97,7 @@ export const findOperation = async < showHiddenFields, sort: incomingSort, trash = false, + version, where, } = args @@ -104,6 +108,13 @@ export const findOperation = async < const { fallbackLocale, locale, payload } = req + const draftsEnabledOnCollection = hasDraftsEnabled(collectionConfig) + const readVersion = resolveReadVersion({ + draftsEnabled: draftsEnabledOnCollection, + version, + }) + const queryVersions = isVersionedRead({ version: readVersion }) && draftsEnabledOnCollection + const select = sanitizeSelect({ fields: collectionConfig.flattenedFields, select: resolveSelect({ @@ -154,6 +165,18 @@ export const findOperation = async < let result: PaginatedDocs> let fullWhere = combineQueries(where!, accessResult!) + + if (readVersion === 'published' && draftsEnabledOnCollection) { + fullWhere = combineQueries( + fullWhere, + getPublishedStatusWhere({ + entity: collectionConfig, + locale: locale!, + payload, + }), + ) + } + sanitizeWhereQuery({ fields: collectionConfig.flattenedFields, payload, where: fullWhere }) // Exclude trashed documents when trash: false @@ -182,7 +205,22 @@ export const findOperation = async < req, }) - if (hasDraftsEnabled(collectionConfig) && draftsEnabled) { + if (readVersion === 'draft' && !draftsEnabledOnCollection) { + return { + docs: [], + hasNextPage: false, + hasPrevPage: false, + limit: limit!, + nextPage: null, + page: 1, + pagingCounter: 1, + prevPage: null, + totalDocs: 0, + totalPages: 1, + } + } + + if (queryVersions) { fullWhere = appendVersionToQueryKey(fullWhere) await validateQueryPaths({ @@ -193,6 +231,17 @@ export const findOperation = async < where: appendVersionToQueryKey(where), }) + if (readVersion === 'draft') { + fullWhere = combineQueries( + fullWhere, + getDraftStatusWhere({ + entity: collectionConfig, + locale: locale!, + payload, + }), + ) + } + result = await payload.db.queryDrafts>({ collection: collectionConfig.slug, joins: req.payloadAPI === 'GraphQL' ? false : sanitizedJoins, @@ -218,7 +267,7 @@ export const findOperation = async < result = await payload.db.find>({ collection: collectionConfig.slug, - draftsEnabled, + draftsEnabled: queryVersions, joins: req.payloadAPI === 'GraphQL' ? false : sanitizedJoins, limit: sanitizedLimit, locale: locale!, @@ -337,7 +386,7 @@ export const findOperation = async < currentDepth, depth: depth!, doc, - draft: draftsEnabled!, + draft: isVersionedRead({ version: readVersion }), fallbackLocale: fallbackLocale!, findMany: true, global: null, @@ -347,6 +396,7 @@ export const findOperation = async < req, select, showHiddenFields: showHiddenFields!, + version: readVersion, }), ), ) @@ -370,6 +420,7 @@ export const findOperation = async < overrideAccess: overrideAccess!, query: fullWhere, req, + version: readVersion, })) || docRef } diff --git a/packages/payload/src/collections/operations/findByID.ts b/packages/payload/src/collections/operations/findByID.ts index 0e23c8fa46c..f07fc80cf53 100644 --- a/packages/payload/src/collections/operations/findByID.ts +++ b/packages/payload/src/collections/operations/findByID.ts @@ -8,6 +8,7 @@ import type { SelectType, TransformCollectionWithSelect, } from '../../types/index.js' +import type { ReadVersion } from '../../versions/types.js' import type { Collection, DataFromCollectionSlug, @@ -28,7 +29,9 @@ import { getSelectMode } from '../../utilities/getSelectMode.js' import { hasDraftsEnabled } from '../../utilities/getVersionsConfig.js' import { resolveSelect } from '../../utilities/resolveSelect.js' import { sanitizeSelect } from '../../utilities/sanitizeSelect.js' -import { replaceWithDraftIfAvailable } from '../../versions/drafts/replaceWithDraftIfAvailable.js' +import { getPublishedStatusWhere } from '../../versions/read/getPublishedStatusWhere.js' +import { replaceWithVersion } from '../../versions/read/replaceWithVersion.js' +import { isVersionedRead, resolveReadVersion } from '../../versions/resolveReadVersion.js' import { buildAfterOperation } from './utilities/buildAfterOperation.js' import { buildBeforeOperation } from './utilities/buildBeforeOperation.js' @@ -42,7 +45,6 @@ export type FindByIDArgs = { data?: Record depth?: number disableErrors?: boolean - draft?: boolean id: number | string includeLockStatus?: boolean joins?: JoinQuery @@ -51,6 +53,7 @@ export type FindByIDArgs = { req: PayloadRequest showHiddenFields?: boolean trash?: boolean + version?: ReadVersion } & Pick, 'flattenLocales'> & Pick, 'select'> @@ -80,7 +83,6 @@ export const findByIDOperation = async < currentDepth, depth, disableErrors, - draft: replaceWithVersion = false, flattenLocales, includeLockStatus: includeLockStatusFromArgs, joins, @@ -91,11 +93,19 @@ export const findByIDOperation = async < select: incomingSelect, showHiddenFields, trash = false, + version, } = args const includeLockStatus = includeLockStatusFromArgs && req.payload.collections?.[lockedDocumentsCollectionSlug] + const draftsEnabledOnCollection = hasDraftsEnabled(collectionConfig) + const readVersion = resolveReadVersion({ + draftsEnabled: draftsEnabledOnCollection, + version, + }) + const queryVersions = isVersionedRead({ version: readVersion }) && draftsEnabledOnCollection + const select = sanitizeSelect({ fields: collectionConfig.flattenedFields, select: resolveSelect({ @@ -126,6 +136,19 @@ export const findByIDOperation = async < let fullWhere = combineQueries(where, accessResult) + if (readVersion === 'published' && draftsEnabledOnCollection) { + fullWhere = { + and: [ + ...(fullWhere.and ?? []), + getPublishedStatusWhere({ + entity: collectionConfig, + locale: locale!, + payload: req.payload, + }), + ], + } + } + // Exclude trashed documents when trash: false fullWhere = appendNonTrashedFilter({ enableTrash: collectionConfig.trash, @@ -164,7 +187,7 @@ export const findByIDOperation = async < if ( collectionConfig.versions?.drafts && - replaceWithVersion && + queryVersions && select && getSelectMode(select) === 'include' ) { @@ -173,7 +196,7 @@ export const findByIDOperation = async < const findOneArgs: FindOneArgs = { collection: collectionConfig.slug, - draftsEnabled: replaceWithVersion, + draftsEnabled: queryVersions, joins: req.payloadAPI === 'GraphQL' ? false : sanitizedJoins, locale: locale!, req: { @@ -189,7 +212,30 @@ export const findByIDOperation = async < const docWithLocales = await req.payload.db.findOne(findOneArgs) - if (!docWithLocales && !args.data) { + // A working draft can satisfy the requested filters even when the published + // main row does not (for example, restoring a trashed document as a draft). + // Fetch an ID/timestamp anchor so the version lookup can still run, but never + // use this unfiltered document as the published fallback. + const versionAnchor = + !docWithLocales && !args.data && queryVersions + ? await req.payload.db.findOne({ + collection: collectionConfig.slug, + locale: locale!, + req: findOneArgs.req, + select: { + id: true, + createdAt: true, + updatedAt: true, + }, + where: { + id: { + equals: id, + }, + }, + }) + : docWithLocales + + if (!versionAnchor && !args.data) { if (!disableErrors) { throw new NotFound(req.t) } @@ -197,7 +243,7 @@ export const findByIDOperation = async < } let result: DataFromCollectionSlug = - (args.data as DataFromCollectionSlug) ?? docWithLocales! + (args.data as DataFromCollectionSlug) ?? versionAnchor! // ///////////////////////////////////// // Add collection property for auth collections @@ -262,20 +308,39 @@ export const findByIDOperation = async < result._userEditing = lockStatus?.user?.value ?? null } + if (readVersion === 'draft' && !draftsEnabledOnCollection) { + if (!disableErrors) { + throw new NotFound(req.t) + } + return null! + } + // ///////////////////////////////////// - // Replace document with draft if available + // Replace published document with the requested version // ///////////////////////////////////// - if (replaceWithVersion && hasDraftsEnabled(collectionConfig)) { - result = await replaceWithDraftIfAvailable({ + if (queryVersions) { + const versionedDoc = await replaceWithVersion({ accessResult, doc: result, entity: collectionConfig, entityType: 'collection', + fallbackDoc: (args.data as DataFromCollectionSlug) ?? docWithLocales, overrideAccess, + policy: readVersion === 'draft' ? 'draft' : 'latest', req, select, + where: fullWhere, }) + + if (!versionedDoc) { + if (!disableErrors) { + throw new NotFound(req.t) + } + return null! + } + + result = versionedDoc } // ///////////////////////////////////// @@ -306,7 +371,7 @@ export const findByIDOperation = async < currentDepth, depth: depth!, doc: result, - draft: replaceWithVersion, + draft: isVersionedRead({ version: readVersion }), fallbackLocale: fallbackLocale!, flattenLocales, global: null, @@ -316,6 +381,7 @@ export const findByIDOperation = async < req, select, showHiddenFields: showHiddenFields!, + version: readVersion, }) // ///////////////////////////////////// @@ -332,6 +398,7 @@ export const findByIDOperation = async < overrideAccess, query: findOneArgs.where, req, + version: readVersion, })) || result } } diff --git a/packages/payload/src/collections/operations/findDistinct.ts b/packages/payload/src/collections/operations/findDistinct.ts index 6e45b730836..9290e8f8d67 100644 --- a/packages/payload/src/collections/operations/findDistinct.ts +++ b/packages/payload/src/collections/operations/findDistinct.ts @@ -236,6 +236,7 @@ export const findDistinctOperation = async ( req, showHiddenFields: false, siblingDoc: doc, + version: 'published', }), ) } diff --git a/packages/payload/src/collections/operations/findVersionByID.ts b/packages/payload/src/collections/operations/findVersionByID.ts index dcf2d6c12a1..a76c429ff66 100644 --- a/packages/payload/src/collections/operations/findVersionByID.ts +++ b/packages/payload/src/collections/operations/findVersionByID.ts @@ -164,8 +164,6 @@ export const findVersionByIDOperation = async ( currentDepth, depth: depth!, doc: result.version, - // @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve - draft: undefined, fallbackLocale: fallbackLocale!, global: null, locale: locale!, diff --git a/packages/payload/src/collections/operations/findVersions.ts b/packages/payload/src/collections/operations/findVersions.ts index 739cd6abcf0..9bf87e48498 100644 --- a/packages/payload/src/collections/operations/findVersions.ts +++ b/packages/payload/src/collections/operations/findVersions.ts @@ -180,8 +180,6 @@ export const findVersionsOperation = async context: req.context, depth: depth!, doc: data.version, - // @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve - draft: undefined, fallbackLocale: fallbackLocale!, findMany: true, global: null, diff --git a/packages/payload/src/collections/operations/inputSchemas.ts b/packages/payload/src/collections/operations/inputSchemas.ts index 2ba17edcafd..77e7ef4e1b0 100644 --- a/packages/payload/src/collections/operations/inputSchemas.ts +++ b/packages/payload/src/collections/operations/inputSchemas.ts @@ -7,11 +7,11 @@ import * as z from 'zod/mini' import { + createActionSchema, dataSchema, defaultLimitSchema, defaultPageSchema, depthSchema, - draftSchema, fallbackLocaleSchema, fieldSchema, idSchema, @@ -27,6 +27,7 @@ import { publishAllLocalesSchema, requireIDOrWhere, requireReturningForSelect, + restoreActionSchema, returningSchema, selectedLocalesSchema, selectSchema, @@ -35,8 +36,9 @@ import { sortSchema, trashSchema, unpublishAllLocalesSchema, + updateActionSchema, + versionSchema, whereSchema, - writeDraftSchema, } from '../../utilities/sharedInputSchemas.js' import { strictObject } from '../../utilities/zod.js' @@ -75,6 +77,7 @@ const getCreateDocumentsInputShape = ({ file: TFile }) => ({ slug: slugSchema, + action: createActionSchema, depth: depthSchema, documents: z .array( @@ -84,7 +87,6 @@ const getCreateDocumentsInputShape = ({ }), ) .check(z.minLength(1), z.describe('A JSON array of {"data": {...}, "file"?: ...} objects.')), - draft: writeDraftSchema, fallbackLocale: fallbackLocaleSchema, locale: localeSchema, populate: populateSchema, @@ -142,9 +144,9 @@ export const deleteDocumentsLocalInputSchema = strictObject( const duplicateDocumentInputShape = { id: idSchema, slug: slugSchema, + action: createActionSchema, data: z.optional(dataSchema), depth: depthSchema, - draft: writeDraftSchema, fallbackLocale: fallbackLocaleSchema, locale: localeSchema, populate: populateSchema, @@ -187,7 +189,6 @@ const findDocumentsInputShape = { id: z.optional(idSchema), slug: slugSchema, depth: depthSchema, - draft: draftSchema, fallbackLocale: fallbackLocaleSchema, joins: joinsSchema, limit: defaultLimitSchema, @@ -198,6 +199,7 @@ const findDocumentsInputShape = { select: selectSchema, sort: sortSchema, trash: trashSchema, + version: versionSchema, where: whereSchema, } @@ -214,7 +216,6 @@ const findVersionByIDInputShape = { id: idSchema, slug: slugSchema, depth: depthSchema, - draft: draftSchema, fallbackLocale: fallbackLocaleSchema, locale: localeSchema, populate: populateSchema, @@ -234,7 +235,6 @@ export const findVersionByIDLocalInputSchema = strictObject({ const findVersionsInputShape = { slug: slugSchema, depth: depthSchema, - draft: draftSchema, fallbackLocale: fallbackLocaleSchema, limit: defaultLimitSchema, locale: localeSchema, @@ -263,8 +263,8 @@ export const getCollectionSchemaInputSchema = strictObject({ const restoreVersionInputShape = { id: idSchema, slug: slugSchema, + action: restoreActionSchema, depth: depthSchema, - draft: writeDraftSchema, fallbackLocale: fallbackLocaleSchema, locale: localeSchema, populate: populateSchema, @@ -283,9 +283,9 @@ export const restoreVersionLocalInputSchema = strictObject({ const getUpdateDocumentInputShape = ({ file }: { file: TFile }) => ({ id: z.optional(idSchema), slug: slugSchema, + action: updateActionSchema, data: dataSchema, depth: depthSchema, - draft: writeDraftSchema, fallbackLocale: fallbackLocaleSchema, file: z.optional(file), limit: limitSchema, diff --git a/packages/payload/src/collections/operations/local/create.ts b/packages/payload/src/collections/operations/local/create.ts index 1862bdfe132..d96f6950825 100644 --- a/packages/payload/src/collections/operations/local/create.ts +++ b/packages/payload/src/collections/operations/local/create.ts @@ -7,9 +7,8 @@ import type { import type { File } from '../../../uploads/types.js' import type { CreateLocalReqOptions } from '../../../utilities/createLocalReq.js' import type { - CollectionsWithoutDrafts, + CreateDataFromCollectionSlug, DataFromCollectionSlug, - DraftDataFromCollectionSlug, RequiredDataFromCollectionSlug, SelectFromCollectionSlug, } from '../../config/types.js' @@ -19,7 +18,6 @@ import { type CollectionSlug, deepCopyObjectSimple, type FindOptions, - type GeneratedTypes, type Payload, type RequestContext, type TypedLocale, @@ -111,76 +109,11 @@ type BaseOptions = { user?: null | User } & Pick, 'select'> -export type Options< - TSlug extends CollectionSlug, - TSelect extends SelectType, -> = GeneratedTypes extends { strictDraftTypes: true } - ? CollectionsWithoutDrafts extends TSlug - ? { - /** - * The data for the document to create. - */ - data: DataFromCollectionSlug - /** - * Create a **draft** document. [More](https://payloadcms.com/docs/versions/drafts#draft-api) - */ - draft?: boolean - } & BaseOptions - : TSlug extends CollectionsWithoutDrafts - ? { - data: RequiredDataFromCollectionSlug - /** - * The `draft` property is not allowed because this collection does not have `versions.drafts` enabled. - */ - draft?: never - } & BaseOptions - : ( - | { - /** - * The data for the document to create. - */ - data: RequiredDataFromCollectionSlug - /** - * Create a **draft** document. [More](https://payloadcms.com/docs/versions/drafts#draft-api) - * Omit this property or set to `false` to create a published document. - */ - draft?: false - } - | { - /** - * The data for the document to create. - * When creating a draft, required fields are optional as validation is skipped by default. - */ - data: DraftDataFromCollectionSlug - /** - * Create a **draft** document. [More](https://payloadcms.com/docs/versions/drafts#draft-api) - */ - draft: true - } - ) & - BaseOptions - : - | ({ - /** - * The data for the document to create. - */ - data: RequiredDataFromCollectionSlug - /** - * Create a **draft** document. [More](https://payloadcms.com/docs/versions/drafts#draft-api) - */ - draft?: false - } & BaseOptions) - | ({ - /** - * The data for the document to create. - * When creating a draft, required fields are optional as validation is skipped by default. - */ - data: DraftDataFromCollectionSlug - /** - * Create a **draft** document. [More](https://payloadcms.com/docs/versions/drafts#draft-api) - */ - draft: true - } & BaseOptions) +export type Options = BaseOptions< + TSlug, + TSelect +> & + CreateDataFromCollectionSlug export async function createLocal< TSlug extends CollectionSlug, @@ -190,12 +123,12 @@ export async function createLocal< options: Options, ): Promise> { const { + action, collection: collectionSlug, data, depth, disableTransaction, disableVerificationEmail, - draft, duplicateFromID, file, filePath, @@ -220,12 +153,12 @@ export async function createLocal< req.file = file ?? (await getFileByPath(filePath!)) return createOperation({ + action, collection, - data: deepCopyObjectSimple(data), // Ensure mutation of data in create operation hooks doesn't affect the original data + data: deepCopyObjectSimple(data) as RequiredDataFromCollectionSlug, // Ensure mutation of data in create operation hooks doesn't affect the original data depth, disableTransaction, disableVerificationEmail, - draft, duplicateFromID, overrideAccess, overwriteExistingFiles, diff --git a/packages/payload/src/collections/operations/local/duplicate.ts b/packages/payload/src/collections/operations/local/duplicate.ts index a2831992ebc..f7c9a07979d 100644 --- a/packages/payload/src/collections/operations/local/duplicate.ts +++ b/packages/payload/src/collections/operations/local/duplicate.ts @@ -2,10 +2,15 @@ import type { DeepPartial } from 'ts-essentials' import type { CollectionSlug, TypedLocale } from '../../..//index.js' import type { FindOptions, Payload, RequestContext, User } from '../../../index.js' -import type { PayloadRequest, PopulateType, SelectType, TransformCollectionWithSelect } from '../../../types/index.js' +import type { + PayloadRequest, + PopulateType, + SelectType, + TransformCollectionWithSelect, +} from '../../../types/index.js' import type { CreateLocalReqOptions } from '../../../utilities/createLocalReq.js' import type { - DraftFlagFromCollectionSlug, + CreateActionFromCollectionSlug, RequiredDataFromCollectionSlug, SelectFromCollectionSlug, } from '../../config/types.js' @@ -86,7 +91,7 @@ export type Options = TSlug, TSelect > & - DraftFlagFromCollectionSlug + CreateActionFromCollectionSlug export async function duplicateLocal< TSlug extends CollectionSlug, @@ -97,11 +102,11 @@ export async function duplicateLocal< ): Promise> { const { id, + action, collection: collectionSlug, data, depth, disableTransaction, - draft, overrideAccess = true, populate, select, @@ -128,11 +133,11 @@ export async function duplicateLocal< return duplicateOperation({ id, + action, collection, data, depth, disableTransaction, - draft, overrideAccess, populate, req, diff --git a/packages/payload/src/collections/operations/local/find.ts b/packages/payload/src/collections/operations/local/find.ts index 4405ac802b6..3755489a872 100644 --- a/packages/payload/src/collections/operations/local/find.ts +++ b/packages/payload/src/collections/operations/local/find.ts @@ -3,7 +3,6 @@ import type { CollectionSlug, JoinQuery, Payload, - PayloadTypes, RequestContext, TypedFallbackLocale, TypedLocale, @@ -19,7 +18,8 @@ import type { Where, } from '../../../types/index.js' import type { CreateLocalReqOptions } from '../../../utilities/createLocalReq.js' -import type { DraftFlagFromCollectionSlug, SelectFromCollectionSlug } from '../../config/types.js' +import type { ReadVersion } from '../../../versions/types.js' +import type { SelectFromCollectionSlug, VersionFromCollectionSlug } from '../../config/types.js' import { APIError } from '../../../errors/index.js' import { createLocalReq } from '../../../utilities/createLocalReq.js' @@ -179,7 +179,7 @@ export type Options = TSlug, TSelect > & - DraftFlagFromCollectionSlug + VersionFromCollectionSlug // Backward compatibility export export type FindOptions = Options< @@ -190,16 +190,14 @@ export type FindOptions, - TDraft extends boolean = false, + TVersion extends ReadVersion | undefined = undefined, >( payload: Payload, - options: { draft?: TDraft } & FindOptions, + options: { version?: TVersion } & FindOptions, ): Promise< PaginatedDocs< - TDraft extends true - ? PayloadTypes extends { strictDraftTypes: true } - ? DraftTransformCollectionWithSelect - : TransformCollectionWithSelect + TVersion extends 'draft' | 'latest' + ? DraftTransformCollectionWithSelect : TransformCollectionWithSelect > > { @@ -208,7 +206,6 @@ export async function findLocal< currentDepth, depth, disableErrors, - draft = false, includeLockStatus, joins, limit, @@ -220,6 +217,7 @@ export async function findLocal< showHiddenFields, sort, trash = false, + version, where, } = options @@ -236,7 +234,6 @@ export async function findLocal< currentDepth, depth, disableErrors, - draft, includeLockStatus, joins, limit, @@ -249,6 +246,7 @@ export async function findLocal< showHiddenFields, sort, trash, + version, where, }) } diff --git a/packages/payload/src/collections/operations/local/findByID.ts b/packages/payload/src/collections/operations/local/findByID.ts index 53723f10b7f..c8bdd662d60 100644 --- a/packages/payload/src/collections/operations/local/findByID.ts +++ b/packages/payload/src/collections/operations/local/findByID.ts @@ -11,12 +11,14 @@ import type { } from '../../../index.js' import type { ApplyDisableErrors, + DraftTransformCollectionWithSelect, PayloadRequest, PopulateType, TransformCollectionWithSelect, } from '../../../types/index.js' import type { CreateLocalReqOptions } from '../../../utilities/createLocalReq.js' -import type { DraftFlagFromCollectionSlug, SelectFromCollectionSlug } from '../../config/types.js' +import type { ReadVersion } from '../../../versions/types.js' +import type { SelectFromCollectionSlug, VersionFromCollectionSlug } from '../../config/types.js' import { APIError } from '../../../errors/index.js' import { createLocalReq } from '../../../utilities/createLocalReq.js' @@ -118,16 +120,24 @@ export type Options< TSlug extends CollectionSlug, TDisableErrors extends boolean, TSelect extends SelectType, -> = BaseFindByIDOptions & DraftFlagFromCollectionSlug +> = BaseFindByIDOptions & VersionFromCollectionSlug export async function findByIDLocal< TSlug extends CollectionSlug, TDisableErrors extends boolean, TSelect extends SelectFromCollectionSlug, + TVersion extends ReadVersion | undefined = undefined, >( payload: Payload, - options: Options, -): Promise, TDisableErrors>> { + options: { version?: TVersion } & Options, +): Promise< + ApplyDisableErrors< + TVersion extends 'draft' | 'latest' + ? DraftTransformCollectionWithSelect + : TransformCollectionWithSelect, + TDisableErrors + > +> { const { id, collection: collectionSlug, @@ -135,7 +145,6 @@ export async function findByIDLocal< data, depth, disableErrors = false, - draft = false, flattenLocales, includeLockStatus, joins, @@ -144,6 +153,7 @@ export async function findByIDLocal< select, showHiddenFields, trash = false, + version, } = options const collection = payload.collections[collectionSlug] @@ -161,7 +171,6 @@ export async function findByIDLocal< data, depth, disableErrors, - draft, flattenLocales, includeLockStatus, joins, @@ -171,5 +180,6 @@ export async function findByIDLocal< select, showHiddenFields, trash, + version, }) } diff --git a/packages/payload/src/collections/operations/local/findVersionByID.ts b/packages/payload/src/collections/operations/local/findVersionByID.ts index 379820eb78a..98abfce4ad0 100644 --- a/packages/payload/src/collections/operations/local/findVersionByID.ts +++ b/packages/payload/src/collections/operations/local/findVersionByID.ts @@ -9,7 +9,7 @@ import type { import type { PayloadRequest, PopulateType, SelectType } from '../../../types/index.js' import type { CreateLocalReqOptions } from '../../../utilities/createLocalReq.js' import type { TypeWithVersion } from '../../../versions/types.js' -import type { DataFromCollectionSlug, DraftFlagFromCollectionSlug } from '../../config/types.js' +import type { DataFromCollectionSlug } from '../../config/types.js' import { APIError } from '../../../errors/index.js' import { createLocalReq } from '../../../utilities/createLocalReq.js' @@ -83,8 +83,7 @@ type BaseOptions = { user?: null | User } & Pick, 'select'> -export type Options = BaseOptions & - DraftFlagFromCollectionSlug +export type Options = BaseOptions export async function findVersionByIDLocal( payload: Payload, diff --git a/packages/payload/src/collections/operations/local/findVersions.ts b/packages/payload/src/collections/operations/local/findVersions.ts index e4c508cffde..f2b4c7da201 100644 --- a/packages/payload/src/collections/operations/local/findVersions.ts +++ b/packages/payload/src/collections/operations/local/findVersions.ts @@ -10,7 +10,7 @@ import type { import type { PayloadRequest, PopulateType, SelectType, Sort, Where } from '../../../types/index.js' import type { CreateLocalReqOptions } from '../../../utilities/createLocalReq.js' import type { TypeWithVersion } from '../../../versions/types.js' -import type { DataFromCollectionSlug, DraftFlagFromCollectionSlug } from '../../config/types.js' +import type { DataFromCollectionSlug } from '../../config/types.js' import { APIError } from '../../../errors/index.js' import { createLocalReq } from '../../../utilities/createLocalReq.js' @@ -101,8 +101,7 @@ type BaseOptions = { where?: Where } & Pick, 'select'> -export type Options = BaseOptions & - DraftFlagFromCollectionSlug +export type Options = BaseOptions export async function findVersionsLocal( payload: Payload, diff --git a/packages/payload/src/collections/operations/local/restoreVersion.ts b/packages/payload/src/collections/operations/local/restoreVersion.ts index c7c277813ed..005fe72ec7b 100644 --- a/packages/payload/src/collections/operations/local/restoreVersion.ts +++ b/packages/payload/src/collections/operations/local/restoreVersion.ts @@ -8,7 +8,7 @@ import type { } from '../../../index.js' import type { PayloadRequest, PopulateType, SelectType } from '../../../types/index.js' import type { CreateLocalReqOptions } from '../../../utilities/createLocalReq.js' -import type { DataFromCollectionSlug, DraftFlagFromCollectionSlug } from '../../config/types.js' +import type { DataFromCollectionSlug, RestoreActionFromCollectionSlug } from '../../config/types.js' import { APIError } from '../../../errors/index.js' import { createLocalReq } from '../../../utilities/createLocalReq.js' @@ -69,7 +69,7 @@ type BaseOptions = { } & Pick, 'select'> export type Options = BaseOptions & - DraftFlagFromCollectionSlug + RestoreActionFromCollectionSlug export async function restoreVersionLocal( payload: Payload, @@ -77,6 +77,7 @@ export async function restoreVersionLocal( ): Promise> { const { id, + action, collection: collectionSlug, depth, overrideAccess = true, @@ -97,6 +98,7 @@ export async function restoreVersionLocal( const args = { id, + action, collection, depth, overrideAccess, diff --git a/packages/payload/src/collections/operations/local/update.ts b/packages/payload/src/collections/operations/local/update.ts index abfc1db555e..7b781a1c8cd 100644 --- a/packages/payload/src/collections/operations/local/update.ts +++ b/packages/payload/src/collections/operations/local/update.ts @@ -20,9 +20,9 @@ import type { File } from '../../../uploads/types.js' import type { CreateLocalReqOptions } from '../../../utilities/createLocalReq.js' import type { BulkOperationResult, - DraftFlagFromCollectionSlug, RequiredDataFromCollectionSlug, SelectFromCollectionSlug, + UpdateActionFromCollectionSlug, } from '../../config/types.js' import { APIError } from '../../../errors/index.js' @@ -157,7 +157,7 @@ export type ByIDOptions< */ where?: never } & BaseOptions & - DraftFlagFromCollectionSlug + UpdateActionFromCollectionSlug export type ManyOptions< TSlug extends CollectionSlug, @@ -182,7 +182,7 @@ export type ManyOptions< */ where: Where } & BaseOptions & - DraftFlagFromCollectionSlug + UpdateActionFromCollectionSlug export type Options< TSlug extends CollectionSlug, @@ -219,12 +219,12 @@ async function updateLocal< ): Promise | TransformCollectionWithSelect> { const { id, + action, autosave, collection: collectionSlug, data, depth, disableTransaction, - draft, file, filePath, limit, @@ -254,12 +254,12 @@ async function updateLocal< const args = { id, + action, autosave, collection, data, depth, disableTransaction, - draft, limit, overrideAccess, overrideLock, diff --git a/packages/payload/src/collections/operations/restoreVersion.ts b/packages/payload/src/collections/operations/restoreVersion.ts index 46c03e28817..9ae58a1ab08 100644 --- a/packages/payload/src/collections/operations/restoreVersion.ts +++ b/packages/payload/src/collections/operations/restoreVersion.ts @@ -2,6 +2,7 @@ import { status as httpStatus } from 'http-status' import type { FindOneArgs } from '../../database/types.js' import type { JsonObject, PayloadRequest, PopulateType, SelectType } from '../../types/index.js' +import type { RestoreAction } from '../../versions/actions/types.js' import type { Collection, TypeWithID } from '../config/types.js' import type { FindOptions } from './local/find.js' @@ -15,23 +16,25 @@ import { beforeChange } from '../../fields/hooks/beforeChange/index.js' import { beforeValidate } from '../../fields/hooks/beforeValidate/index.js' import { commitTransaction } from '../../utilities/commitTransaction.js' import { deepCopyObjectSimple } from '../../utilities/deepCopyObject.js' -import { hasDraftValidationEnabled } from '../../utilities/getVersionsConfig.js' +import { hasDraftsEnabled, hasDraftValidationEnabled } from '../../utilities/getVersionsConfig.js' import { initTransaction } from '../../utilities/initTransaction.js' import { isolateObjectProperty } from '../../utilities/isolateObjectProperty.js' import { killTransaction } from '../../utilities/killTransaction.js' import { resolveSelect } from '../../utilities/resolveSelect.js' import { sanitizeSelect } from '../../utilities/sanitizeSelect.js' +import { canonicalizeWriteStatus, resolveAction } from '../../versions/actions/resolveAction.js' import { getLatestCollectionVersion } from '../../versions/getLatestCollectionVersion.js' import { saveVersion } from '../../versions/saveVersion.js' import { buildAfterOperation } from './utilities/buildAfterOperation.js' import { buildBeforeOperation } from './utilities/buildBeforeOperation.js' + export type Arguments = { + action?: RestoreAction collection: Collection currentDepth?: number depth?: number disableErrors?: boolean disableTransaction?: boolean - draft?: boolean id: number | string overrideAccess?: boolean populate?: PopulateType @@ -42,20 +45,9 @@ export type Arguments = { export const restoreVersionOperation = async < TData extends JsonObject & TypeWithID = JsonObject & TypeWithID, >( - args: Arguments, + incomingArgs: Arguments, ): Promise => { - const { - id, - collection: { config: collectionConfig }, - depth, - draft: draftArg = false, - overrideAccess = false, - populate, - req, - req: { fallbackLocale, locale, payload }, - select: incomingSelect, - showHiddenFields, - } = args + let args = incomingArgs try { const shouldCommit = !args.disableTransaction && (await initTransaction(args.req)) @@ -68,13 +60,35 @@ export const restoreVersionOperation = async < args, collection: args.collection.config, operation: 'restoreVersion', - overrideAccess, + overrideAccess: args.overrideAccess!, }) + const { + id, + action, + collection: { config: collectionConfig }, + depth, + overrideAccess = false, + populate, + req, + req: { fallbackLocale, locale, payload }, + select: incomingSelect, + showHiddenFields, + } = args + if (!id) { throw new APIError('Missing ID of version to restore.', httpStatus.BAD_REQUEST) } + const resolvedAction = resolveAction({ + action, + draftsEnabled: hasDraftsEnabled(collectionConfig), + locale, + operation: 'restore', + }) + const isSavingDraft = resolvedAction === 'saveDraft' + const readVersion = isSavingDraft ? 'latest' : 'published' + // ///////////////////////////////////// // Retrieve original raw version // ///////////////////////////////////// @@ -94,7 +108,12 @@ export const restoreVersionOperation = async < throw new NotFound(req.t) } - const { parent: parentDocID, version: versionToRestoreWithLocales } = rawVersionToRestore + const { parent: parentDocID } = rawVersionToRestore + const versionToRestoreWithLocales = canonicalizeWriteStatus({ + action: resolvedAction, + data: rawVersionToRestore.version, + locale, + }) // ///////////////////////////////////// // Access @@ -157,13 +176,13 @@ export const restoreVersionOperation = async < context: req.context, depth: 0, doc: deepCopyObjectSimple(prevDocWithLocales), - draft: draftArg, fallbackLocale: null, global: null, locale: validationLocale, overrideAccess: true, req, showHiddenFields: true, + version: readVersion, }) // Use locale-hoisted version data for validation while preserving all locales in docWithLocales. @@ -171,14 +190,14 @@ export const restoreVersionOperation = async < collection: collectionConfig, context: req.context, depth: 0, - doc: deepCopyObjectSimple(rawVersionToRestore.version), - draft: draftArg, + doc: deepCopyObjectSimple(versionToRestoreWithLocales), fallbackLocale: null, global: null, locale: validationLocale, overrideAccess: true, req, showHiddenFields: true, + version: readVersion, }) // ///////////////////////////////////// @@ -254,7 +273,13 @@ export const restoreVersionOperation = async < operation: 'update', overrideAccess, req: reqWithValidationLocale, - skipValidation: draftArg && !hasDraftValidationEnabled(collectionConfig), + skipValidation: isSavingDraft && !hasDraftValidationEnabled(collectionConfig), + }) + + result = canonicalizeWriteStatus({ + action: resolvedAction, + data: result, + locale, }) // ///////////////////////////////////// @@ -273,9 +298,7 @@ export const restoreVersionOperation = async < // Ensure updatedAt date is always updated result.updatedAt = new Date().toISOString() - // Ensure status respects restoreAsDraft arg - result._status = draftArg ? 'draft' : result._status - if (!draftArg) { + if (!isSavingDraft) { result = await req.payload.db.updateOne({ id: parentDocID, collection: collectionConfig.slug, @@ -294,7 +317,7 @@ export const restoreVersionOperation = async < autosave: false, collection: collectionConfig, docWithLocales: result, - draft: draftArg, + draft: isSavingDraft, operation: 'restoreVersion', payload, req: reqWithValidationLocale, @@ -310,8 +333,6 @@ export const restoreVersionOperation = async < context: req.context, depth: depth!, doc: result, - // @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve - draft: undefined, fallbackLocale: fallbackLocale!, global: null, locale: locale!, @@ -320,6 +341,7 @@ export const restoreVersionOperation = async < req, select, showHiddenFields: showHiddenFields!, + version: readVersion, }) // ///////////////////////////////////// @@ -344,6 +366,7 @@ export const restoreVersionOperation = async < // ///////////////////////////////////// result = await afterChange({ + action: resolvedAction, collection: collectionConfig, context: req.context, data: result, @@ -362,6 +385,7 @@ export const restoreVersionOperation = async < for (const hook of collectionConfig.hooks.afterChange) { result = (await hook({ + action: resolvedAction, collection: collectionConfig, context: req.context, data: result, @@ -392,7 +416,7 @@ export const restoreVersionOperation = async < return result } catch (error: unknown) { - await killTransaction(req) + await killTransaction(args.req) throw error } } diff --git a/packages/payload/src/collections/operations/update.ts b/packages/payload/src/collections/operations/update.ts index 4e18037c142..434bd191338 100644 --- a/packages/payload/src/collections/operations/update.ts +++ b/packages/payload/src/collections/operations/update.ts @@ -4,6 +4,7 @@ import { status as httpStatus } from 'http-status' import type { AccessResult } from '../../config/types.js' import type { PayloadRequest, PopulateType, SelectType, Sort, Where } from '../../types/index.js' +import type { UpdateAction } from '../../versions/actions/types.js' import type { BulkOperationResult, Collection, @@ -23,12 +24,13 @@ import { generateFileData } from '../../uploads/generateFileData.js' import { unlinkTempFiles } from '../../uploads/unlinkTempFiles.js' import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js' import { commitTransaction } from '../../utilities/commitTransaction.js' -import { hasDraftsEnabled } from '../../utilities/getVersionsConfig.js' +import { hasDraftsEnabled, hasLocalizeStatusEnabled } from '../../utilities/getVersionsConfig.js' import { initTransaction } from '../../utilities/initTransaction.js' import { isErrorPublic } from '../../utilities/isErrorPublic.js' import { killTransaction } from '../../utilities/killTransaction.js' import { resolveSelect } from '../../utilities/resolveSelect.js' import { sanitizeSelect } from '../../utilities/sanitizeSelect.js' +import { canonicalizeWriteStatus, resolveAction } from '../../versions/actions/resolveAction.js' import { buildVersionCollectionFields } from '../../versions/buildCollectionFields.js' import { appendVersionToQueryKey } from '../../versions/drafts/appendVersionToQueryKey.js' import { getQueryDraftsSort } from '../../versions/drafts/getQueryDraftsSort.js' @@ -39,13 +41,13 @@ import { sanitizeSortQuery } from './utilities/sanitizeSortQuery.js' import { updateDocument } from './utilities/update.js' export type Arguments = { + action?: UpdateAction autosave?: boolean collection: Collection data: DeepPartial> depth?: number disableTransaction?: boolean disableVerificationEmail?: boolean - draft?: boolean limit?: number overrideAccess?: boolean overrideLock?: boolean @@ -92,11 +94,11 @@ export const updateOperation = async < }) const { + action, autosave = false, collection: { config: collectionConfig }, collection, depth, - draft: draftArg = false, limit = 0, overrideAccess, overrideLock, @@ -122,8 +124,33 @@ export const updateOperation = async < throw new APIError("Missing 'where' query of documents to update.", httpStatus.BAD_REQUEST) } - const { data: bulkUpdateData } = args - const shouldSaveDraft = Boolean(draftArg && hasDraftsEnabled(collectionConfig)) + let { data: bulkUpdateData } = args + const resolvedAction = resolveAction({ + action, + autosave, + draftsEnabled: hasDraftsEnabled(collectionConfig), + locale, + localizedStatusEnabled: hasLocalizeStatusEnabled(collectionConfig), + operation: 'update', + publishAllLocales, + status: + bulkUpdateData && + typeof bulkUpdateData === 'object' && + bulkUpdateData !== null && + '_status' in bulkUpdateData + ? bulkUpdateData._status + : undefined, + unpublishAllLocales, + }) + const shouldSaveDraft = resolvedAction === 'saveDraft' + + bulkUpdateData = canonicalizeWriteStatus({ + action: resolvedAction, + data: bulkUpdateData, + locale, + publishAllLocales, + unpublishAllLocales, + }) // ///////////////////////////////////// // Access @@ -190,7 +217,10 @@ export const updateOperation = async < let docs - if (hasDraftsEnabled(collectionConfig) && (shouldSaveDraft || isTrashAttempt)) { + if ( + hasDraftsEnabled(collectionConfig) && + (shouldSaveDraft || resolvedAction === 'publish' || isTrashAttempt) + ) { const versionsWhere = appendVersionToQueryKey(fullWhere) await validateQueryPaths({ @@ -234,6 +264,7 @@ export const updateOperation = async < collection, config, data: bulkUpdateData, + draft: shouldSaveDraft, operation: 'update', overwriteExistingFiles, req, @@ -267,6 +298,7 @@ export const updateOperation = async < // /////////////////////////////////////////////// let updatedDoc = await updateDocument({ id, + action: resolvedAction as undefined | UpdateAction, autosave, collectionConfig, config, @@ -278,7 +310,6 @@ export const updateOperation = async < }), depth: depth!, docWithLocales, - draftArg, fallbackLocale: fallbackLocale!, filesToUpload, locale: locale!, diff --git a/packages/payload/src/collections/operations/updateByID.ts b/packages/payload/src/collections/operations/updateByID.ts index 2cf34eee3e7..fbbedd47955 100644 --- a/packages/payload/src/collections/operations/updateByID.ts +++ b/packages/payload/src/collections/operations/updateByID.ts @@ -9,6 +9,7 @@ import type { SelectType, TransformCollectionWithSelect, } from '../../types/index.js' +import type { UpdateAction } from '../../versions/actions/types.js' import type { Collection, RequiredDataFromCollectionSlug, @@ -25,23 +26,25 @@ import { generateFileData } from '../../uploads/generateFileData.js' import { unlinkTempFiles } from '../../uploads/unlinkTempFiles.js' import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js' import { commitTransaction } from '../../utilities/commitTransaction.js' +import { hasDraftsEnabled, hasLocalizeStatusEnabled } from '../../utilities/getVersionsConfig.js' import { initTransaction } from '../../utilities/initTransaction.js' import { killTransaction } from '../../utilities/killTransaction.js' import { resolveSelect } from '../../utilities/resolveSelect.js' import { sanitizeSelect } from '../../utilities/sanitizeSelect.js' +import { canonicalizeWriteStatus, resolveAction } from '../../versions/actions/resolveAction.js' import { getLatestCollectionVersion } from '../../versions/getLatestCollectionVersion.js' import { buildAfterOperation } from './utilities/buildAfterOperation.js' import { buildBeforeOperation } from './utilities/buildBeforeOperation.js' import { updateDocument } from './utilities/update.js' export type Arguments = { + action?: UpdateAction autosave?: boolean collection: Collection data: DeepPartial> depth?: number disableTransaction?: boolean disableVerificationEmail?: boolean - draft?: boolean id: number | string overrideAccess?: boolean overrideLock?: boolean @@ -78,11 +81,11 @@ export const updateByIDOperation = async < const { id, + action, autosave = false, collection: { config: collectionConfig }, collection, depth, - draft: draftArg = false, overrideAccess, overrideLock, overwriteExistingFiles = false, @@ -105,7 +108,31 @@ export const updateByIDOperation = async < throw new APIError('Missing ID of document to update.', httpStatus.BAD_REQUEST) } - const { data } = args + let { data } = args + + const resolvedAction = resolveAction({ + action, + autosave, + draftsEnabled: hasDraftsEnabled(collectionConfig), + locale, + localizedStatusEnabled: hasLocalizeStatusEnabled(collectionConfig), + operation: 'update', + publishAllLocales, + status: + data && typeof data === 'object' && data !== null && '_status' in data + ? data._status + : undefined, + unpublishAllLocales, + }) + const isSavingDraft = resolvedAction === 'saveDraft' + + data = canonicalizeWriteStatus({ + action: resolvedAction, + data, + locale, + publishAllLocales, + unpublishAllLocales, + }) // ///////////////////////////////////// // Access @@ -185,6 +212,7 @@ export const updateByIDOperation = async < collection, config, data, + draft: isSavingDraft, operation: 'update', overwriteExistingFiles, req, @@ -207,13 +235,18 @@ export const updateByIDOperation = async < let result = await updateDocument({ id, + action: + resolvedAction === 'unpublish' || + resolvedAction === 'saveDraft' || + resolvedAction === 'publish' + ? resolvedAction + : undefined, autosave, collectionConfig, config, data: deepCopyObjectSimple(newFileData), depth: depth!, docWithLocales, - draftArg, fallbackLocale: fallbackLocale!, filesToUpload, locale: locale!, diff --git a/packages/payload/src/collections/operations/utilities/update.ts b/packages/payload/src/collections/operations/utilities/update.ts index ffa5a5510fa..b3eef64cd57 100644 --- a/packages/payload/src/collections/operations/utilities/update.ts +++ b/packages/payload/src/collections/operations/utilities/update.ts @@ -15,6 +15,7 @@ import type { SelectType, TransformCollectionWithSelect, } from '../../../types/index.js' +import type { UpdateAction } from '../../../versions/actions/types.js' import type { DataFromCollectionSlug, SanitizedCollectionConfig, @@ -33,19 +34,19 @@ import { deleteAssociatedFiles } from '../../../uploads/deleteAssociatedFiles.js import { uploadFiles } from '../../../uploads/uploadFiles.js' import { checkDocumentLockStatus } from '../../../utilities/checkDocumentLockStatus.js' import { - hasDraftsEnabled, hasDraftValidationEnabled, hasLocalizeStatusEnabled, } from '../../../utilities/getVersionsConfig.js' import { buildLocalizedPublishData } from '../../../versions/buildSingleLocalePublishData.js' + export type SharedUpdateDocumentArgs = { + action?: UpdateAction autosave: boolean collectionConfig: SanitizedCollectionConfig config: SanitizedConfig data: DeepPartial> depth: number docWithLocales: JsonObject & TypeWithID - draftArg: boolean fallbackLocale: TypedFallbackLocale filesToUpload: FileToSave[] id: number | string @@ -79,13 +80,13 @@ export const updateDocument = async < TSelect extends SelectFromCollectionSlug = SelectType, >({ id, + action, autosave, collectionConfig, config, data, depth, docWithLocales, - draftArg, fallbackLocale, filesToUpload, locale, @@ -100,18 +101,17 @@ export const updateDocument = async < unpublishAllLocales: unpublishAllLocalesArg, }: SharedUpdateDocumentArgs): Promise> => { const password = data?.password + const isSavingDraft = action === 'saveDraft' + const isUnpublishing = action === 'unpublish' const publishAllLocales = - !draftArg && + !isSavingDraft && + !isUnpublishing && (publishAllLocalesArg ?? (hasLocalizeStatusEnabled(collectionConfig) && locale !== 'all' ? false : true)) const unpublishAllLocales = typeof unpublishAllLocalesArg === 'string' ? unpublishAllLocalesArg === 'true' : !!unpublishAllLocalesArg - const isSavingDraft = - Boolean(draftArg && hasDraftsEnabled(collectionConfig)) && - data._status !== 'published' && - !publishAllLocales const shouldSavePassword = Boolean( password && collectionConfig.auth && @@ -121,10 +121,6 @@ export const updateDocument = async < !isSavingDraft, ) - if (isSavingDraft) { - data._status = 'draft' - } - // ///////////////////////////////////// // Handle potentially locked documents // ///////////////////////////////////// @@ -142,16 +138,16 @@ export const updateDocument = async < context: req.context, depth: 0, doc: deepCopyObjectSimple(docWithLocales), - draft: draftArg, fallbackLocale: id ? null : fallbackLocale, global: null, locale, overrideAccess: true, req, showHiddenFields: true, + version: isSavingDraft ? 'latest' : 'published', }) - const isRestoringDraftFromTrash = Boolean(originalDoc?.deletedAt) && data?._status !== 'published' + const isRestoringDraftFromTrash = Boolean(originalDoc?.deletedAt) && action !== 'publish' if (collectionConfig.auth) { ensureUsernameOrEmail({ @@ -266,8 +262,8 @@ export const updateDocument = async < (isSavingDraft && !hasDraftValidationEnabled(collectionConfig)) || // Skip validation for trash operations since they're just metadata updates (collectionConfig.trash && (Boolean(data?.deletedAt) || isRestoringDraftFromTrash)) || - // Skip validation for unpublish operations — they only change _status, not document data - unpublishAllLocales, + // Skip validation for unpublish operations — they only change publication state + isUnpublishing, } // ///////////////////////////////////// @@ -366,7 +362,7 @@ export const updateDocument = async < dataToUpdate.updatedAt = new Date().toISOString() if (localizedPublishData) { // Single-locale publish: save filtered data to main doc but keep full locale data for - // the version so draft fetches (replaceWithDraftIfAvailable) return complete data. + // the version so latest/draft fetches (replaceWithVersion) return complete data. await req.payload.db.updateOne({ id, collection: collectionConfig.slug, @@ -400,7 +396,7 @@ export const updateDocument = async < operation: 'update', payload, req, - unpublish: unpublishAllLocales, + unpublish: isUnpublishing, }) } @@ -413,7 +409,6 @@ export const updateDocument = async < context: req.context, depth, doc: resultWithLocales, - draft: draftArg, fallbackLocale, global: null, locale, @@ -422,6 +417,7 @@ export const updateDocument = async < req, select, showHiddenFields, + version: isSavingDraft ? 'latest' : 'published', }) // ///////////////////////////////////// @@ -446,6 +442,7 @@ export const updateDocument = async < // ///////////////////////////////////// result = await afterChange({ + action, collection: collectionConfig, context: req.context, data, @@ -464,6 +461,7 @@ export const updateDocument = async < for (const hook of collectionConfig.hooks.afterChange) { result = (await hook({ + action, collection: collectionConfig, context: req.context, data, diff --git a/packages/payload/src/config/orderable/index.ts b/packages/payload/src/config/orderable/index.ts index b13e1e04bc4..2bc52c181cc 100644 --- a/packages/payload/src/config/orderable/index.ts +++ b/packages/payload/src/config/orderable/index.ts @@ -312,12 +312,12 @@ export const addOrderableEndpoint = ( await req.payload.update({ id, + action: draft ? 'saveDraft' : 'publish', collection: collection.slug, data: { [orderableFieldName]: orderValues[index], }, depth: 0, - draft, req, }) } diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts index 14020bf14c8..515bb752e25 100644 --- a/packages/payload/src/config/types.ts +++ b/packages/payload/src/config/types.ts @@ -1417,16 +1417,6 @@ type RootTypeScriptConfig = { jsonSchema: JSONSchema4 }) => JSONSchema4 > - - /** - * Enable strict type safety for draft operations. When enabled, the `draft` parameter is forbidden - * on collections without drafts, and query results with `draft: true` type required fields as optional. - * This prevents invalid draft usage at compile time and ensures type correctness across all Local API operations. - * - * @default false - * @todo Remove in v4. Strict draft types will become the default behavior. - */ - strictDraftTypes?: boolean } /** diff --git a/packages/payload/src/duplicateDocument/index.ts b/packages/payload/src/duplicateDocument/index.ts index a89a12afaeb..2d84c021e95 100644 --- a/packages/payload/src/duplicateDocument/index.ts +++ b/packages/payload/src/duplicateDocument/index.ts @@ -1,6 +1,7 @@ import type { SanitizedCollectionConfig } from '../collections/config/types.js' import type { FindOneArgs } from '../database/types.js' import type { JsonObject, PayloadRequest } from '../types/index.js' +import type { CreateAction } from '../versions/actions/types.js' import { executeAccess } from '../auth/executeAccess.js' import { hasWhereAccessResult } from '../auth/types.js' @@ -14,8 +15,8 @@ import { filterDataToSelectedLocales } from '../utilities/filterDataToSelectedLo import { getLatestCollectionVersion } from '../versions/getLatestCollectionVersion.js' type GetDuplicateDocumentArgs = { + action?: CreateAction collectionConfig: SanitizedCollectionConfig - draftArg?: boolean id: number | string overrideAccess?: boolean req: PayloadRequest @@ -23,8 +24,8 @@ type GetDuplicateDocumentArgs = { } export const getDuplicateDocumentData = async ({ id, + action, collectionConfig, - draftArg, overrideAccess, req, selectedLocales, @@ -99,13 +100,13 @@ export const getDuplicateDocumentData = async ({ context: req.context, depth: 0, doc: deepCopyObjectSimple(duplicatedFromDocWithLocales), - draft: draftArg!, fallbackLocale: null, global: null, locale: req.locale!, overrideAccess: true, req, showHiddenFields: true, + version: action === 'saveDraft' ? 'latest' : 'published', }) return { duplicatedFromDoc, duplicatedFromDocWithLocales } diff --git a/packages/payload/src/fields/config/types.ts b/packages/payload/src/fields/config/types.ts index b6bcbffb105..fb42feefcb2 100644 --- a/packages/payload/src/fields/config/types.ts +++ b/packages/payload/src/fields/config/types.ts @@ -148,6 +148,8 @@ import type { Where, } from '../../types/index.js' import type { SchemaVariant } from '../../utilities/configToJSONSchema.js' +import type { WriteAction } from '../../versions/actions/types.js' +import type { ReadVersion } from '../../versions/types.js' import type { Slugify } from '../baseFields/slug/types.js' import type { DisabledOptions } from '../isFieldDisabled.js' import type { @@ -170,6 +172,11 @@ export type BrowserAutoComplete = Extract< > export type FieldHookArgs = { + /** + * Resolved write action. Only available in `afterChange` hooks. + * `undefined` when drafts are not enabled. + */ + action?: WriteAction /** * The data of the nearest parent block. If the field is not within a block, `blockData` will be equal to `undefined`. */ @@ -235,6 +242,10 @@ export type FieldHookArgs = ( diff --git a/packages/payload/src/fields/hooks/afterChange/index.ts b/packages/payload/src/fields/hooks/afterChange/index.ts index 071d0fb7cd0..0a9ca33d406 100644 --- a/packages/payload/src/fields/hooks/afterChange/index.ts +++ b/packages/payload/src/fields/hooks/afterChange/index.ts @@ -2,10 +2,12 @@ import type { SanitizedCollectionConfig } from '../../../collections/config/type import type { SanitizedGlobalConfig } from '../../../globals/config/types.js' import type { RequestContext } from '../../../index.js' import type { JsonObject, PayloadRequest } from '../../../types/index.js' +import type { WriteAction } from '../../../versions/actions/types.js' import { traverseFields } from './traverseFields.js' type Args = { + action?: WriteAction collection: null | SanitizedCollectionConfig context: RequestContext /** @@ -27,6 +29,7 @@ type Args = { * - Execute field hooks */ export const afterChange = async ({ + action, collection, context, data, @@ -37,6 +40,7 @@ export const afterChange = async ({ req, }: Args): Promise => { await traverseFields({ + action, collection, context, data, diff --git a/packages/payload/src/fields/hooks/afterChange/promise.ts b/packages/payload/src/fields/hooks/afterChange/promise.ts index 85f5f110664..4efa87de893 100644 --- a/packages/payload/src/fields/hooks/afterChange/promise.ts +++ b/packages/payload/src/fields/hooks/afterChange/promise.ts @@ -3,6 +3,7 @@ import type { SanitizedCollectionConfig } from '../../../collections/config/type import type { SanitizedGlobalConfig } from '../../../globals/config/types.js' import type { RequestContext } from '../../../index.js' import type { JsonObject, PayloadRequest } from '../../../types/index.js' +import type { WriteAction } from '../../../versions/actions/types.js' import type { Block, Field, TabAsField } from '../../config/types.js' import { MissingEditorProp } from '../../../errors/index.js' @@ -11,6 +12,7 @@ import { getFieldPaths } from '../../getFieldPaths.js' import { traverseFields } from './traverseFields.js' type Args = { + action?: WriteAction /** * Data of the nearest parent block. If no parent block exists, this will be the `undefined` */ @@ -39,6 +41,7 @@ type Args = { // - Execute field hooks export const promise = async ({ + action, blockData, collection, context, @@ -82,6 +85,7 @@ export const promise = async ({ if ('hooks' in field && field.hooks?.afterChange) { for (const hook of field.hooks.afterChange) { const hookedValue = await hook({ + action, blockData, collection, context, @@ -120,6 +124,7 @@ export const promise = async ({ rows.forEach((row, rowIndex) => { promises.push( traverseFields({ + action, blockData, collection, context, @@ -164,6 +169,7 @@ export const promise = async ({ if (block) { promises.push( traverseFields({ + action, blockData: siblingData?.[field.name]?.[rowIndex], collection, context, @@ -195,6 +201,7 @@ export const promise = async ({ case 'collapsible': case 'row': { await traverseFields({ + action, blockData, collection, context, @@ -220,6 +227,7 @@ export const promise = async ({ case 'group': { if (fieldAffectsData(field)) { await traverseFields({ + action, blockData, collection, context, @@ -240,6 +248,7 @@ export const promise = async ({ }) } else { await traverseFields({ + action, blockData, collection, context, @@ -277,6 +286,7 @@ export const promise = async ({ if (editor?.hooks?.afterChange?.length) { for (const hook of editor.hooks.afterChange) { const hookedValue = await hook({ + action, collection, context, data, @@ -319,6 +329,7 @@ export const promise = async ({ } await traverseFields({ + action, blockData, collection, context, @@ -343,6 +354,7 @@ export const promise = async ({ case 'tabs': { await traverseFields({ + action, blockData, collection, context, diff --git a/packages/payload/src/fields/hooks/afterChange/traverseFields.ts b/packages/payload/src/fields/hooks/afterChange/traverseFields.ts index d268a0ce6e8..6a56a9cd221 100644 --- a/packages/payload/src/fields/hooks/afterChange/traverseFields.ts +++ b/packages/payload/src/fields/hooks/afterChange/traverseFields.ts @@ -2,11 +2,13 @@ import type { SanitizedCollectionConfig } from '../../../collections/config/type import type { SanitizedGlobalConfig } from '../../../globals/config/types.js' import type { RequestContext } from '../../../index.js' import type { JsonObject, PayloadRequest } from '../../../types/index.js' +import type { WriteAction } from '../../../versions/actions/types.js' import type { Field, TabAsField } from '../../config/types.js' import { promise } from './promise.js' type Args = { + action?: WriteAction /** * Data of the nearest parent block. If no parent block exists, this will be the `undefined` */ @@ -34,6 +36,7 @@ type Args = { } export const traverseFields = async ({ + action, blockData, collection, context, @@ -58,6 +61,7 @@ export const traverseFields = async ({ fields.forEach((field, fieldIndex) => { promises.push( promise({ + action, blockData, collection, context, diff --git a/packages/payload/src/fields/hooks/afterRead/index.ts b/packages/payload/src/fields/hooks/afterRead/index.ts index 4fd020306a8..f6e25666a47 100644 --- a/packages/payload/src/fields/hooks/afterRead/index.ts +++ b/packages/payload/src/fields/hooks/afterRead/index.ts @@ -2,6 +2,7 @@ import type { SanitizedCollectionConfig } from '../../../collections/config/type import type { SanitizedGlobalConfig } from '../../../globals/config/types.js' import type { RequestContext, TypedFallbackLocale } from '../../../index.js' import type { JsonObject, PayloadRequest, PopulateType, SelectType } from '../../../types/index.js' +import type { ReadVersion } from '../../../versions/types.js' import { getSelectMode } from '../../../utilities/getSelectMode.js' import { traverseFields } from './traverseFields.js' @@ -12,7 +13,7 @@ export type AfterReadArgs = { currentDepth?: number depth: number doc: T - draft: boolean + draft?: boolean fallbackLocale: TypedFallbackLocale findMany?: boolean /** @@ -29,6 +30,7 @@ export type AfterReadArgs = { req: PayloadRequest select?: SelectType showHiddenFields: boolean + version?: ReadVersion } /** @@ -59,6 +61,7 @@ export async function afterRead(args: AfterReadArgs): P req, select, showHiddenFields, + version, } = args const fieldPromises: Promise[] = [] @@ -73,6 +76,8 @@ export async function afterRead(args: AfterReadArgs): P } const currentDepth = incomingCurrentDepth || 1 + const readVersion = version ?? (draft ? 'latest' : 'published') + const draftRead = readVersion === 'draft' || readVersion === 'latest' traverseFields({ collection, @@ -80,7 +85,7 @@ export async function afterRead(args: AfterReadArgs): P currentDepth, depth, doc: incomingDoc, - draft, + draft: draftRead, fallbackLocale, fieldDepth: 0, fieldPromises, @@ -101,6 +106,7 @@ export async function afterRead(args: AfterReadArgs): P selectMode: select ? getSelectMode(select) : undefined, showHiddenFields, siblingDoc: incomingDoc, + version: readVersion, }) /** diff --git a/packages/payload/src/fields/hooks/afterRead/promise.ts b/packages/payload/src/fields/hooks/afterRead/promise.ts index 2372038b706..b382a8f59bf 100644 --- a/packages/payload/src/fields/hooks/afterRead/promise.ts +++ b/packages/payload/src/fields/hooks/afterRead/promise.ts @@ -9,6 +9,7 @@ import type { SelectMode, SelectType, } from '../../../types/index.js' +import type { ReadVersion } from '../../../versions/types.js' import type { Block, Field, TabAsField } from '../../config/types.js' import type { AfterReadArgs } from './index.js' @@ -69,6 +70,7 @@ type Args = { siblingFields?: (Field | TabAsField)[] triggerAccessControl?: boolean triggerHooks?: boolean + version: ReadVersion } & Required, 'flattenLocales'>> // This function is responsible for the following actions, in order: @@ -111,6 +113,7 @@ export const promise = async ({ siblingFields, triggerAccessControl = true, triggerHooks = true, + version, }: Args): Promise => { const { indexPath, path, schemaPath } = getFieldPaths({ field, @@ -309,6 +312,7 @@ export const promise = async ({ siblingData: siblingDoc, siblingFields: siblingFields!, value, + version, }) if (hookedValue !== undefined) { @@ -339,6 +343,7 @@ export const promise = async ({ siblingData: siblingDoc, siblingFields: siblingFields!, value: siblingDoc[field.name], + version, }) if (hookedValue !== undefined) { @@ -366,6 +371,7 @@ export const promise = async ({ segments: field.virtual.split('.'), showHiddenFields, siblingDoc, + version, }), ) } @@ -435,6 +441,7 @@ export const promise = async ({ req, showHiddenFields, siblingDoc, + version, }), ) } @@ -485,6 +492,7 @@ export const promise = async ({ siblingDoc: row || {}, triggerAccessControl, triggerHooks, + version, }) }) } else if (!shouldHoistLocalizedValue && typeof rows === 'object' && rows !== null) { @@ -519,6 +527,7 @@ export const promise = async ({ siblingDoc: (row as JsonObject) || {}, triggerAccessControl, triggerHooks, + version, }) }) } @@ -583,6 +592,7 @@ export const promise = async ({ siblingDoc: (row as JsonObject) || {}, triggerAccessControl, triggerHooks, + version, }) } }) @@ -627,6 +637,7 @@ export const promise = async ({ siblingDoc: (row as JsonObject) || {}, triggerAccessControl, triggerHooks, + version, }) } }) @@ -673,6 +684,7 @@ export const promise = async ({ siblingDoc, triggerAccessControl, triggerHooks, + version, }) break @@ -717,6 +729,7 @@ export const promise = async ({ siblingDoc: localizedData || {}, triggerAccessControl, triggerHooks, + version, }) }) } else { @@ -750,6 +763,7 @@ export const promise = async ({ siblingDoc: typeof siblingDoc[field.name] !== 'object' ? {} : siblingDoc[field.name], triggerAccessControl, triggerHooks, + version, }) } } else { @@ -783,6 +797,7 @@ export const promise = async ({ siblingDoc, triggerAccessControl, triggerHooks, + version, }) } @@ -813,7 +828,6 @@ export const promise = async ({ currentDepth, data: doc, depth, - draft, fallbackLocale: fallbackLocale!, field, fieldPromises, @@ -836,6 +850,7 @@ export const promise = async ({ triggerAccessControl, triggerHooks, value, + version, }) if (hookedValue !== undefined) { @@ -850,7 +865,6 @@ export const promise = async ({ currentDepth, data: doc, depth, - draft, fallbackLocale: fallbackLocale!, field, fieldPromises, @@ -873,6 +887,7 @@ export const promise = async ({ triggerAccessControl, triggerHooks, value: siblingDoc[field.name], + version, }) if (hookedValue !== undefined) { @@ -926,6 +941,7 @@ export const promise = async ({ siblingDoc: localizedData || {}, triggerAccessControl, triggerHooks, + version, }) }) } else { @@ -959,6 +975,7 @@ export const promise = async ({ siblingDoc: typeof siblingDoc[field.name] !== 'object' ? {} : siblingDoc[field.name], triggerAccessControl, triggerHooks, + version, }) } } else { @@ -992,6 +1009,7 @@ export const promise = async ({ siblingDoc: tabDoc, triggerAccessControl, triggerHooks, + version, }) } @@ -1029,6 +1047,7 @@ export const promise = async ({ siblingDoc, triggerAccessControl, triggerHooks, + version, }) break diff --git a/packages/payload/src/fields/hooks/afterRead/relationshipPopulationPromise.ts b/packages/payload/src/fields/hooks/afterRead/relationshipPopulationPromise.ts index 67ab168e7bf..43e458747da 100644 --- a/packages/payload/src/fields/hooks/afterRead/relationshipPopulationPromise.ts +++ b/packages/payload/src/fields/hooks/afterRead/relationshipPopulationPromise.ts @@ -1,5 +1,6 @@ import type { TypedFallbackLocale } from '../../../index.js' import type { PayloadRequest, PopulateType } from '../../../types/index.js' +import type { ReadVersion } from '../../../versions/types.js' import type { JoinField, RelationshipField, UploadField } from '../../config/types.js' import { createDataloaderCacheKey } from '../../../collections/dataloader.js' @@ -20,6 +21,7 @@ type PopulateArgs = { populateArg?: PopulateType req: PayloadRequest showHiddenFields: boolean + version?: ReadVersion } // TODO: this function is mess, refactor logic @@ -38,7 +40,9 @@ const populate = async ({ populateArg, req, showHiddenFields, + version, }: PopulateArgs) => { + const readVersion = version ?? (draft ? 'latest' : 'published') const dataToUpdate = dataReference let relation if (field.type === 'join') { @@ -80,7 +84,6 @@ const populate = async ({ currentDepth: currentDepth + 1, depth, docID: id as string, - draft, fallbackLocale: fallbackLocale!, locale: locale!, overrideAccess, @@ -90,6 +93,7 @@ const populate = async ({ relatedCollection.config.defaultPopulate, showHiddenFields, transactionID: req.transactionID!, + version: readVersion, }), ) } @@ -149,6 +153,7 @@ type PromiseArgs = { req: PayloadRequest showHiddenFields: boolean siblingDoc: Record + version?: ReadVersion } export const relationshipPopulationPromise = async ({ @@ -164,6 +169,7 @@ export const relationshipPopulationPromise = async ({ req, showHiddenFields, siblingDoc, + version, }: PromiseArgs): Promise => { const resultingDoc = siblingDoc const populateDepth = fieldHasMaxDepth(field) && field.maxDepth! < depth ? field.maxDepth : depth @@ -195,6 +201,7 @@ export const relationshipPopulationPromise = async ({ populateArg, req, showHiddenFields, + version, }) } rowPromises.push(rowPromise()) @@ -228,6 +235,7 @@ export const relationshipPopulationPromise = async ({ populateArg, req, showHiddenFields, + version, }) } } @@ -257,6 +265,7 @@ export const relationshipPopulationPromise = async ({ populateArg, req, showHiddenFields, + version, }) } rowPromises.push(rowPromise()) @@ -277,6 +286,7 @@ export const relationshipPopulationPromise = async ({ populateArg, req, showHiddenFields, + version, }) } await Promise.all(rowPromises) diff --git a/packages/payload/src/fields/hooks/afterRead/traverseFields.ts b/packages/payload/src/fields/hooks/afterRead/traverseFields.ts index e6888828309..4134f6c8a09 100644 --- a/packages/payload/src/fields/hooks/afterRead/traverseFields.ts +++ b/packages/payload/src/fields/hooks/afterRead/traverseFields.ts @@ -8,6 +8,7 @@ import type { SelectMode, SelectType, } from '../../../types/index.js' +import type { ReadVersion } from '../../../versions/types.js' import type { Field, TabAsField } from '../../config/types.js' import { promise } from './promise.js' @@ -58,6 +59,7 @@ type Args = { siblingDoc: JsonObject triggerAccessControl?: boolean triggerHooks?: boolean + version?: ReadVersion } export const traverseFields = ({ @@ -90,7 +92,10 @@ export const traverseFields = ({ siblingDoc, triggerAccessControl = true, triggerHooks = true, + version, }: Args): void => { + const readVersion = version ?? (draft ? 'latest' : 'published') + fields.forEach((field, fieldIndex) => { fieldPromises.push( promise({ @@ -125,6 +130,7 @@ export const traverseFields = ({ siblingFields: fields, triggerAccessControl, triggerHooks, + version: readVersion, }), ) }) diff --git a/packages/payload/src/fields/hooks/afterRead/virtualFieldPopulationPromise.ts b/packages/payload/src/fields/hooks/afterRead/virtualFieldPopulationPromise.ts index 9048659d2ab..5926b4c2916 100644 --- a/packages/payload/src/fields/hooks/afterRead/virtualFieldPopulationPromise.ts +++ b/packages/payload/src/fields/hooks/afterRead/virtualFieldPopulationPromise.ts @@ -1,5 +1,6 @@ import type { TypedFallbackLocale } from '../../../index.js' import type { PayloadRequest } from '../../../types/index.js' +import type { ReadVersion } from '../../../versions/types.js' import type { FlattenedField } from '../../config/types.js' import { createDataloaderCacheKey } from '../../../collections/dataloader.js' @@ -17,6 +18,7 @@ export const virtualFieldPopulationPromise = async ({ segments, showHiddenFields, siblingDoc, + version, }: { draft: boolean fallbackLocale: TypedFallbackLocale @@ -31,6 +33,7 @@ export const virtualFieldPopulationPromise = async ({ shift?: boolean showHiddenFields: boolean siblingDoc: Record + version: ReadVersion }): Promise => { const currentSegment = segments.shift() @@ -80,6 +83,7 @@ export const virtualFieldPopulationPromise = async ({ segments, showHiddenFields, siblingDoc, + version, }) } @@ -137,13 +141,13 @@ export const virtualFieldPopulationPromise = async ({ currentDepth: 0, depth: 0, docID, - draft, fallbackLocale, locale, overrideAccess, select, showHiddenFields, transactionID: req.transactionID as number, + version, }), ) }), @@ -167,6 +171,7 @@ export const virtualFieldPopulationPromise = async ({ segments: [...segments], showHiddenFields, siblingDoc, + version, }) } @@ -196,13 +201,13 @@ export const virtualFieldPopulationPromise = async ({ currentDepth: 0, depth: 0, docID, - draft, fallbackLocale, locale, overrideAccess, select, showHiddenFields, transactionID: req.transactionID as number, + version, }), ) @@ -223,6 +228,7 @@ export const virtualFieldPopulationPromise = async ({ segments, showHiddenFields, siblingDoc, + version, }) } } diff --git a/packages/payload/src/globals/config/types.ts b/packages/payload/src/globals/config/types.ts index 21460dcef27..f218dd60af9 100644 --- a/packages/payload/src/globals/config/types.ts +++ b/packages/payload/src/globals/config/types.ts @@ -20,17 +20,34 @@ import type { GlobalAdminCustom, GlobalCustom, GlobalSlug, + JsonObject, RequestContext, TypedGlobal, TypedGlobalSelect, } from '../../index.js' import type { PayloadRequest, SelectIncludeType, Where, WithSelectFn } from '../../types/index.js' -import type { IncomingGlobalVersions, SanitizedGlobalVersions } from '../../versions/types.js' +import type { RestoreAction, UpdateAction } from '../../versions/actions/types.js' +import type { + IncomingGlobalVersions, + ReadVersion, + SanitizedGlobalVersions, +} from '../../versions/types.js' export type DataFromGlobalSlug = TypedGlobal[TSlug] export type SelectFromGlobalSlug = TypedGlobalSelect[TSlug] +type HasGeneratedGlobalTypes = 'globals' extends keyof GeneratedTypes ? true : false + +/** + * Helper type for draft data OUTPUT (e.g., query results) - makes user fields optional + */ +export type QueryDraftDataFromGlobal = Partial + +export type QueryDraftDataFromGlobalSlug = QueryDraftDataFromGlobal< + DataFromGlobalSlug +> + export type GlobalAccess = { read?: Access readVersions?: Access @@ -46,31 +63,71 @@ export type GlobalsWithoutDrafts = { }[GlobalSlug] /** - * Conditionally allows or forbids the `draft` property based on global configuration. - * When `strictDraftTypes` is enabled, the `draft` property is forbidden on globals without drafts. + * Allows `version` on draft-enabled globals and forbids it on globals without drafts. */ -export type DraftFlagFromGlobalSlug = GeneratedTypes extends { - strictDraftTypes: true -} - ? TSlug extends GlobalsWithoutDrafts +export type VersionFromGlobalSlug = HasGeneratedGlobalTypes extends false + ? { + /** + * Which document representation to read. [More](https://payloadcms.com/docs/versions/drafts) + * + * @default 'published' + */ + version?: ReadVersion + } + : TSlug extends GlobalsWithoutDrafts ? { /** - * The `draft` property is not allowed because this global does not have `versions.drafts` enabled. + * `version` is not allowed because this global does not have `versions.drafts` enabled. */ - draft?: never + version?: never } : { /** - * Whether the global should be queried from the versions table/collection or not. [More](https://payloadcms.com/docs/versions/drafts#draft-api) + * Which document representation to read. [More](https://payloadcms.com/docs/versions/drafts) + * + * @default 'published' */ - draft?: boolean + version?: ReadVersion } - : { - /** - * Whether the global should be queried from the versions table/collection or not. [More](https://payloadcms.com/docs/versions/drafts#draft-api) - */ - draft?: boolean - } + +/** + * Allows update `action` on draft-enabled globals. Non-draft globals may only omit it or pass `publish`. + */ +export type UpdateActionFromGlobalSlug = + HasGeneratedGlobalTypes extends false + ? { + action?: UpdateAction + } + : TSlug extends GlobalsWithoutDrafts + ? { + action?: 'publish' + } + : { + action?: UpdateAction + } + +/** + * Allows restore `action` on draft-enabled globals. Non-draft globals may only omit it or pass `publish`. + * Restore does not accept `unpublish`. Omitted action publishes. + */ +export type RestoreActionFromGlobalSlug = + HasGeneratedGlobalTypes extends false + ? { + /** + * Restore and publish (`publish`, default) or restore as a draft (`saveDraft`). + */ + action?: RestoreAction + } + : TSlug extends GlobalsWithoutDrafts + ? { + action?: 'publish' + } + : { + /** + * Restore and publish (`publish`, default) or restore as a draft (`saveDraft`). + */ + action?: RestoreAction + } export type BeforeValidateHook = (args: { context: RequestContext @@ -99,6 +156,10 @@ export type BeforeChangeHook = (args: { }) => any export type AfterChangeHook = (args: { + /** + * Resolved write action for this operation. `undefined` when drafts are not enabled. + */ + action?: RestoreAction | UpdateAction context: RequestContext data: any doc: any @@ -136,6 +197,10 @@ export type AfterReadHook = (args: { overrideAccess?: boolean query?: Where req: PayloadRequest + /** + * Only available on findGlobal reads. + */ + version?: ReadVersion }) => any export type HookOperationType = 'countVersions' | 'read' | 'restoreVersion' | 'update' diff --git a/packages/payload/src/globals/endpoints/findOne.ts b/packages/payload/src/globals/endpoints/findOne.ts index eb06455598e..2e58eec1198 100644 --- a/packages/payload/src/globals/endpoints/findOne.ts +++ b/packages/payload/src/globals/endpoints/findOne.ts @@ -4,36 +4,27 @@ import type { PayloadHandler } from '../../config/types.js' import { getRequestGlobal } from '../../utilities/getRequestEntity.js' import { headersWithCors } from '../../utilities/headersWithCors.js' -import { isNumber } from '../../utilities/isNumber.js' -import { sanitizePopulateParam } from '../../utilities/sanitizePopulateParam.js' -import { sanitizeSelectParam } from '../../utilities/sanitizeSelectParam.js' +import { parseParams } from '../../utilities/parseParams/index.js' import { findOneOperation } from '../operations/findOne.js' export const findOneHandler: PayloadHandler = async (req) => { const globalConfig = getRequestGlobal(req) - const { data, searchParams } = req - const depth = data ? data.depth : searchParams.get('depth') - const flattenLocales = data - ? data.flattenLocales - : searchParams.has('flattenLocales') - ? searchParams.get('flattenLocales') === 'true' - : // flattenLocales should be undfined if not provided, so that the default (true) is applied in the operation - undefined + const { data: dataArg } = req + const { data, depth, flattenLocales, populate, select, version } = parseParams({ + ...req.query, + ...dataArg, + }) const result = await findOneOperation({ slug: globalConfig.slug, - data: data - ? data?.data - : searchParams.get('data') - ? JSON.parse(searchParams.get('data') as string) - : undefined, - depth: isNumber(depth) ? Number(depth) : undefined, - draft: data ? data.draft : searchParams.get('draft') === 'true', + data: dataArg ? (dataArg.data ?? data) : data, + depth, flattenLocales, globalConfig, - populate: sanitizePopulateParam(req.query.populate), + populate, req, - select: sanitizeSelectParam(req.query.select), + select, + version, }) return Response.json(result, { diff --git a/packages/payload/src/globals/endpoints/restoreVersion.ts b/packages/payload/src/globals/endpoints/restoreVersion.ts index f7fa3421897..564cb661f17 100644 --- a/packages/payload/src/globals/endpoints/restoreVersion.ts +++ b/packages/payload/src/globals/endpoints/restoreVersion.ts @@ -6,17 +6,27 @@ import { restoreVersionOperationGlobal, sanitizePopulateParam } from '../../inde import { getRequestGlobal } from '../../utilities/getRequestEntity.js' import { headersWithCors } from '../../utilities/headersWithCors.js' import { isNumber } from '../../utilities/isNumber.js' +import { + parseEnumParam, + parseParams, + restoreActionValues, +} from '../../utilities/parseParams/index.js' export const restoreVersionHandler: PayloadHandler = async (req) => { const globalConfig = getRequestGlobal(req) const { searchParams } = req + const { action: requestedAction } = parseParams(req.query) + const action = parseEnumParam({ + allowed: restoreActionValues, + param: 'action', + value: requestedAction, + }) const depth = searchParams.get('depth') - const draft = searchParams.get('draft') const doc = await restoreVersionOperationGlobal({ id: req.routeParams!.id as string, + action, depth: isNumber(depth) ? Number(depth) : undefined, - draft: draft === 'true' ? true : undefined, globalConfig, populate: sanitizePopulateParam(req.query.populate), req, diff --git a/packages/payload/src/globals/endpoints/update.ts b/packages/payload/src/globals/endpoints/update.ts index 60ecb4100c0..7a1ca7a6c27 100644 --- a/packages/payload/src/globals/endpoints/update.ts +++ b/packages/payload/src/globals/endpoints/update.ts @@ -5,6 +5,7 @@ import type { PayloadHandler } from '../../config/types.js' import { getRequestGlobal } from '../../utilities/getRequestEntity.js' import { headersWithCors } from '../../utilities/headersWithCors.js' import { isNumber } from '../../utilities/isNumber.js' +import { parseParams } from '../../utilities/parseParams/index.js' import { sanitizePopulateParam } from '../../utilities/sanitizePopulateParam.js' import { sanitizeSelectParam } from '../../utilities/sanitizeSelectParam.js' import { updateOperation } from '../operations/update.js' @@ -12,18 +13,17 @@ import { updateOperation } from '../operations/update.js' export const updateHandler: PayloadHandler = async (req) => { const globalConfig = getRequestGlobal(req) const { searchParams } = req + const { action, autosave } = parseParams(req.query) const depth = searchParams.get('depth') - const draft = searchParams.get('draft') === 'true' - const autosave = searchParams.get('autosave') === 'true' const publishAllLocales = searchParams.get('publishAllLocales') === 'true' const unpublishAllLocales = searchParams.get('unpublishAllLocales') === 'true' const result = await updateOperation({ slug: globalConfig.slug, + action, autosave, data: req.data!, depth: isNumber(depth) ? Number(depth) : undefined, - draft, globalConfig, populate: sanitizePopulateParam(req.query.populate), publishAllLocales, @@ -34,7 +34,7 @@ export const updateHandler: PayloadHandler = async (req) => { let message = req.t('general:updatedSuccessfully') - if (draft) { + if (action === 'saveDraft') { message = req.t('version:draftSavedSuccessfully') } if (autosave) { diff --git a/packages/payload/src/globals/operations/findOne.ts b/packages/payload/src/globals/operations/findOne.ts index 796037316f3..44bc4db32a0 100644 --- a/packages/payload/src/globals/operations/findOne.ts +++ b/packages/payload/src/globals/operations/findOne.ts @@ -9,9 +9,11 @@ import type { SelectType, Where, } from '../../types/index.js' +import type { ReadVersion } from '../../versions/types.js' import type { SanitizedGlobalConfig } from '../config/types.js' import { executeAccess } from '../../auth/executeAccess.js' +import { combineQueries } from '../../database/combineQueries.js' import { NotFound } from '../../errors/NotFound.js' import { afterRead, type AfterReadArgs } from '../../fields/hooks/afterRead/index.js' import { lockedDocumentsCollectionSlug } from '../../locked-documents/config.js' @@ -19,7 +21,9 @@ import { getSelectMode } from '../../utilities/getSelectMode.js' import { hasDraftsEnabled } from '../../utilities/getVersionsConfig.js' import { resolveSelect } from '../../utilities/resolveSelect.js' import { sanitizeSelect } from '../../utilities/sanitizeSelect.js' -import { replaceWithDraftIfAvailable } from '../../versions/drafts/replaceWithDraftIfAvailable.js' +import { getPublishedStatusWhere } from '../../versions/read/getPublishedStatusWhere.js' +import { replaceWithVersion } from '../../versions/read/replaceWithVersion.js' +import { isVersionedRead, resolveReadVersion } from '../../versions/resolveReadVersion.js' export type GlobalFindOneArgs = { /** @@ -29,7 +33,6 @@ export type GlobalFindOneArgs = { data?: Record depth?: number disableErrors?: boolean - draft?: boolean globalConfig: SanitizedGlobalConfig includeLockStatus?: boolean overrideAccess?: boolean @@ -37,6 +40,7 @@ export type GlobalFindOneArgs = { req: PayloadRequest showHiddenFields?: boolean slug: string + version?: ReadVersion } & Pick, 'flattenLocales'> & Pick, 'select'> @@ -47,7 +51,6 @@ export const findOneOperation = async >( slug, depth, disableErrors, - draft: replaceWithVersion = false, flattenLocales, globalConfig, includeLockStatus: includeLockStatusFromArgs, @@ -57,11 +60,19 @@ export const findOneOperation = async >( req, select: incomingSelect, showHiddenFields, + version, } = args const includeLockStatus = includeLockStatusFromArgs && req.payload.collections?.[lockedDocumentsCollectionSlug] + const draftsEnabledOnGlobal = hasDraftsEnabled(globalConfig) + const readVersion = resolveReadVersion({ + draftsEnabled: draftsEnabledOnGlobal, + version, + }) + const queryVersions = isVersionedRead({ version: readVersion }) && draftsEnabledOnGlobal + // ///////////////////////////////////// // beforeOperation - Global // ///////////////////////////////////// @@ -118,23 +129,43 @@ export const findOneOperation = async >( if ( globalConfig.versions?.drafts && - replaceWithVersion && + queryVersions && select && getSelectMode(select) === 'include' ) { dbSelect = { ...select, createdAt: true, updatedAt: true } } + let where = overrideAccess ? undefined : (accessResult as Where) + + if (readVersion === 'published' && draftsEnabledOnGlobal) { + where = combineQueries( + where!, + getPublishedStatusWhere({ + entity: globalConfig, + locale: locale!, + payload: req.payload, + }), + ) + } + const docFromDB = await req.payload.db.findGlobal({ slug, locale: locale!, req, select: dbSelect, - where: overrideAccess ? undefined : (accessResult as Where), + where, }) // Check if no document was returned (Postgres returns {} instead of null) const hasDoc = docFromDB && Object.keys(docFromDB).length > 0 + if (!hasDoc && !args.data && readVersion === 'published' && draftsEnabledOnGlobal) { + if (!disableErrors) { + throw new NotFound(req.t) + } + return null! + } + if (!hasDoc && !args.data && !overrideAccess && accessResult !== true) { if (!disableErrors) { return {} as any @@ -192,20 +223,42 @@ export const findOneOperation = async >( doc._userEditing = lockStatus?.user?.value ?? null } + if (readVersion === 'draft' && !draftsEnabledOnGlobal) { + if (!disableErrors) { + throw new NotFound(req.t) + } + return null! + } + // ///////////////////////////////////// - // Replace document with draft if available + // Replace published document with the requested version // ///////////////////////////////////// - if (replaceWithVersion && hasDraftsEnabled(globalConfig)) { - doc = await replaceWithDraftIfAvailable({ + if (queryVersions) { + const versionedDoc = await replaceWithVersion({ accessResult, doc, entity: globalConfig, entityType: 'global', overrideAccess, + policy: readVersion === 'draft' ? 'draft' : 'latest', req, select, }) + + if (!versionedDoc && readVersion === 'latest' && !hasDoc) { + // Globals exist conceptually before their first save. Preserve the empty + // document shape so the Admin UI can initialize a new global while + // collection reads still require an actual saved document. + doc = {} + } else if (!versionedDoc) { + if (!disableErrors) { + throw new NotFound(req.t) + } + return null! + } else { + doc = versionedDoc + } } // ///////////////////////////////////// @@ -247,7 +300,7 @@ export const findOneOperation = async >( context: req.context, depth: depth!, doc, - draft: replaceWithVersion, + draft: isVersionedRead({ version: readVersion }), fallbackLocale: fallbackLocale!, flattenLocales, global: globalConfig, @@ -257,6 +310,7 @@ export const findOneOperation = async >( req, select, showHiddenFields: showHiddenFields!, + version: readVersion, }) // ///////////////////////////////////// @@ -272,6 +326,7 @@ export const findOneOperation = async >( global: globalConfig, overrideAccess, req, + version: readVersion, })) || doc } } diff --git a/packages/payload/src/globals/operations/findVersionByID.ts b/packages/payload/src/globals/operations/findVersionByID.ts index ba4fc17a0da..a8070cdbc94 100644 --- a/packages/payload/src/globals/operations/findVersionByID.ts +++ b/packages/payload/src/globals/operations/findVersionByID.ts @@ -139,7 +139,6 @@ export const findVersionByIDOperation = async = an currentDepth, depth: depth!, doc: result.version, - draft: undefined!, fallbackLocale: fallbackLocale!, global: globalConfig, locale: locale!, diff --git a/packages/payload/src/globals/operations/findVersions.ts b/packages/payload/src/globals/operations/findVersions.ts index c5e9c0b7fa2..ce5d173fec9 100644 --- a/packages/payload/src/globals/operations/findVersions.ts +++ b/packages/payload/src/globals/operations/findVersions.ts @@ -129,7 +129,6 @@ export const findVersionsOperation = async >( // Patch globalType onto version doc globalType: globalConfig.slug, }, - draft: undefined!, fallbackLocale: fallbackLocale!, findMany: true, global: globalConfig, diff --git a/packages/payload/src/globals/operations/inputSchemas.ts b/packages/payload/src/globals/operations/inputSchemas.ts index 5a45ebd6a4e..f0805a08420 100644 --- a/packages/payload/src/globals/operations/inputSchemas.ts +++ b/packages/payload/src/globals/operations/inputSchemas.ts @@ -17,13 +17,15 @@ import { paginationSchema, populateSchema, publishAllLocalesSchema, + restoreActionSchema, selectSchema, showHiddenFieldsSchema, slugSchema, sortSchema, unpublishAllLocalesSchema, + updateActionSchema, + versionSchema, whereSchema, - writeDraftSchema, } from '../../utilities/sharedInputSchemas.js' import { strictObject } from '../../utilities/zod.js' @@ -48,6 +50,7 @@ const findGlobalInputShape = { locale: localeSchema, populate: populateSchema, select: selectSchema, + version: versionSchema, } export const findGlobalInputSchema = strictObject(findGlobalInputShape) @@ -108,6 +111,7 @@ export const getGlobalSchemaInputSchema = strictObject({ const restoreGlobalVersionInputShape = { id: idSchema, slug: slugSchema, + action: restoreActionSchema, depth: depthSchema, fallbackLocale: fallbackLocaleSchema, locale: localeSchema, @@ -126,9 +130,9 @@ export const restoreGlobalVersionLocalInputSchema = strictObject({ const updateGlobalInputShape = { slug: slugSchema, + action: updateActionSchema, data: dataSchema, depth: depthSchema, - draft: writeDraftSchema, fallbackLocale: fallbackLocaleSchema, locale: localeSchema, overrideLock: overrideLockSchema, diff --git a/packages/payload/src/globals/operations/local/findOne.ts b/packages/payload/src/globals/operations/local/findOne.ts index 75217a4e59a..10c020cb236 100644 --- a/packages/payload/src/globals/operations/local/findOne.ts +++ b/packages/payload/src/globals/operations/local/findOne.ts @@ -8,13 +8,15 @@ import type { User, } from '../../../index.js' import type { + DraftTransformGlobalWithSelect, PayloadRequest, PopulateType, SelectType, TransformGlobalWithSelect, } from '../../../types/index.js' import type { CreateLocalReqOptions } from '../../../utilities/createLocalReq.js' -import type { DraftFlagFromGlobalSlug, SelectFromGlobalSlug } from '../../config/types.js' +import type { ReadVersion } from '../../../versions/types.js' +import type { SelectFromGlobalSlug, VersionFromGlobalSlug } from '../../config/types.js' import { APIError } from '../../../errors/index.js' import { createLocalReq } from '../../../utilities/createLocalReq.js' @@ -41,10 +43,6 @@ type BaseFindOneOptions = * When set to `true`, errors will not be thrown. */ disableErrors?: boolean - /** - * Whether the document should be queried from the versions table/collection or not. [More](https://payloadcms.com/docs/versions/drafts#draft-api) - */ - draft?: boolean /** * Specify a [fallback locale](https://payloadcms.com/docs/configuration/localization) to use for any returned documents. */ @@ -92,27 +90,32 @@ export type Options = Base TSlug, TSelect > & - DraftFlagFromGlobalSlug + VersionFromGlobalSlug export async function findOneGlobalLocal< TSlug extends GlobalSlug, TSelect extends SelectFromGlobalSlug, + TVersion extends ReadVersion | undefined = undefined, >( payload: Payload, - options: Options, -): Promise> { + options: { version?: TVersion } & Options, +): Promise< + TVersion extends 'draft' | 'latest' + ? DraftTransformGlobalWithSelect + : TransformGlobalWithSelect +> { const { slug: globalSlug, data, depth, disableErrors, - draft = false, flattenLocales, includeLockStatus, overrideAccess = true, populate, select, showHiddenFields, + version, } = options const globalConfig = payload.globals.config.find((config) => config.slug === globalSlug) @@ -126,7 +129,6 @@ export async function findOneGlobalLocal< data, depth, disableErrors, - draft, flattenLocales, globalConfig, includeLockStatus, @@ -135,5 +137,6 @@ export async function findOneGlobalLocal< req: await createLocalReq(options as CreateLocalReqOptions, payload), select, showHiddenFields, + version, }) } diff --git a/packages/payload/src/globals/operations/local/restoreVersion.ts b/packages/payload/src/globals/operations/local/restoreVersion.ts index a6119ff9d6a..2cc8c923887 100644 --- a/packages/payload/src/globals/operations/local/restoreVersion.ts +++ b/packages/payload/src/globals/operations/local/restoreVersion.ts @@ -1,7 +1,7 @@ import type { GlobalSlug, Payload, RequestContext, TypedLocale, User } from '../../../index.js' import type { PayloadRequest, PopulateType } from '../../../types/index.js' import type { CreateLocalReqOptions } from '../../../utilities/createLocalReq.js' -import type { DataFromGlobalSlug } from '../../config/types.js' +import type { DataFromGlobalSlug, RestoreActionFromGlobalSlug } from '../../config/types.js' import { APIError } from '../../../errors/index.js' import { createLocalReq } from '../../../utilities/createLocalReq.js' @@ -59,13 +59,21 @@ export type Options = { * If you set `overrideAccess` to `false`, you can pass a user to use against the access control checks. */ user?: null | User -} +} & RestoreActionFromGlobalSlug export async function restoreGlobalVersionLocal( payload: Payload, options: Options, ): Promise> { - const { id, slug: globalSlug, depth, overrideAccess = true, populate, showHiddenFields } = options + const { + id, + slug: globalSlug, + action, + depth, + overrideAccess = true, + populate, + showHiddenFields, + } = options const globalConfig = payload.globals.config.find((config) => config.slug === globalSlug) @@ -75,6 +83,7 @@ export async function restoreGlobalVersionLocal( return restoreVersionOperation({ id, + action, depth, globalConfig, overrideAccess, diff --git a/packages/payload/src/globals/operations/local/update.ts b/packages/payload/src/globals/operations/local/update.ts index dc71365ada8..dda590dbbc1 100644 --- a/packages/payload/src/globals/operations/local/update.ts +++ b/packages/payload/src/globals/operations/local/update.ts @@ -9,8 +9,8 @@ import type { import type { CreateLocalReqOptions } from '../../../utilities/createLocalReq.js' import type { DataFromGlobalSlug, - DraftFlagFromGlobalSlug, SelectFromGlobalSlug, + UpdateActionFromGlobalSlug, } from '../../config/types.js' import { APIError } from '../../../errors/index.js' @@ -27,6 +27,11 @@ import { createLocalReq } from '../../../utilities/createLocalReq.js' import { updateOperation } from '../update.js' type BaseOptions = { + /** + * Whether the current update should be marked as from autosave. + * `versions.drafts.autosave` should be specified. + */ + autosave?: boolean /** * [Context](https://payloadcms.com/docs/hooks/context), which will then be passed to `context` and `req.context`, * which can be read by hooks. Useful if you want to pass additional information to the hooks which @@ -102,7 +107,7 @@ export type Options = Base TSlug, TSelect > & - DraftFlagFromGlobalSlug + UpdateActionFromGlobalSlug export async function updateGlobalLocal< TSlug extends GlobalSlug, @@ -113,9 +118,10 @@ export async function updateGlobalLocal< ): Promise> { const { slug: globalSlug, + action, + autosave, data, depth, - draft, overrideAccess = true, overrideLock, populate, @@ -133,9 +139,10 @@ export async function updateGlobalLocal< return updateOperation({ slug: globalSlug as string, + action, + autosave, data: deepCopyObjectSimple(data), // Ensure mutation of data in create operation hooks doesn't affect the original data depth, - draft, globalConfig, overrideAccess, overrideLock, diff --git a/packages/payload/src/globals/operations/restoreVersion.ts b/packages/payload/src/globals/operations/restoreVersion.ts index 5151e0eea8a..8192e4095d5 100644 --- a/packages/payload/src/globals/operations/restoreVersion.ts +++ b/packages/payload/src/globals/operations/restoreVersion.ts @@ -1,4 +1,5 @@ import type { PayloadRequest, PopulateType } from '../../types/index.js' +import type { RestoreAction } from '../../versions/actions/types.js' import type { TypeWithVersion } from '../../versions/types.js' import type { SanitizedGlobalConfig } from '../config/types.js' @@ -7,12 +8,14 @@ import { NotFound } from '../../errors/index.js' import { afterChange } from '../../fields/hooks/afterChange/index.js' import { afterRead } from '../../fields/hooks/afterRead/index.js' import { commitTransaction } from '../../utilities/commitTransaction.js' +import { hasDraftsEnabled } from '../../utilities/getVersionsConfig.js' import { initTransaction } from '../../utilities/initTransaction.js' import { killTransaction } from '../../utilities/killTransaction.js' +import { canonicalizeWriteStatus, resolveAction } from '../../versions/actions/resolveAction.js' export type Arguments = { + action?: RestoreAction depth?: number - draft?: boolean globalConfig: SanitizedGlobalConfig id: number | string overrideAccess?: boolean @@ -22,11 +25,10 @@ export type Arguments = { } export const restoreVersionOperation = async = any>( - args: Arguments, + incomingArgs: Arguments, ): Promise => { - const { id, depth, draft, globalConfig, overrideAccess, populate, showHiddenFields } = args + let args = incomingArgs const req = args.req! - const { fallbackLocale, locale, payload } = req try { const shouldCommit = await initTransaction(req) @@ -35,20 +37,32 @@ export const restoreVersionOperation = async = any // beforeOperation - Global // ///////////////////////////////////// - if (globalConfig.hooks?.beforeOperation?.length) { - for (const hook of globalConfig.hooks.beforeOperation) { + if (args.globalConfig.hooks?.beforeOperation?.length) { + for (const hook of args.globalConfig.hooks.beforeOperation) { args = (await hook({ args, context: req.context, - global: globalConfig, + global: args.globalConfig, operation: 'restoreVersion', - overrideAccess, + overrideAccess: args.overrideAccess, req, })) || args } } + const { id, action, depth, globalConfig, overrideAccess, populate, showHiddenFields } = args + const { fallbackLocale, locale, payload } = req + + const resolvedAction = resolveAction({ + action, + draftsEnabled: hasDraftsEnabled(globalConfig), + locale, + operation: 'restore', + }) + const isSavingDraft = resolvedAction === 'saveDraft' + const readVersion = isSavingDraft ? 'latest' : 'published' + // ///////////////////////////////////// // Access // ///////////////////////////////////// @@ -76,12 +90,12 @@ export const restoreVersionOperation = async = any // Patch globalType onto version doc rawVersion.version.globalType = globalConfig.slug + rawVersion.version = canonicalizeWriteStatus({ + action: resolvedAction, + data: rawVersion.version, + locale, + }) - // Overwrite draft status if draft is true - - if (draft) { - rawVersion.version._status = 'draft' - } // ///////////////////////////////////// // fetch previousDoc // ///////////////////////////////////// @@ -98,40 +112,41 @@ export const restoreVersionOperation = async = any // Update global // ///////////////////////////////////// - const global = await payload.db.findGlobal({ + const existingGlobal = await payload.db.findGlobal({ slug: globalConfig.slug, req, }) let result = rawVersion.version - - if (global) { - // Ensure updatedAt date is always updated - result.updatedAt = new Date().toISOString() - result = await payload.db.updateGlobal({ - slug: globalConfig.slug, - data: result, - req, - }) - - const now = new Date().toISOString() - - result = await payload.db.createGlobalVersion({ - autosave: false, - createdAt: result.createdAt ? new Date(result.createdAt).toISOString() : now, - globalSlug: globalConfig.slug, - req, - updatedAt: draft ? now : new Date(result.updatedAt).toISOString(), - versionData: result, - }) - } else { - result = await payload.db.createGlobal({ - slug: globalConfig.slug, - data: result, - req, - }) + result.updatedAt = new Date().toISOString() + + if (!isSavingDraft) { + if (existingGlobal) { + result = await payload.db.updateGlobal({ + slug: globalConfig.slug, + data: result, + req, + }) + } else { + result = await payload.db.createGlobal({ + slug: globalConfig.slug, + data: result, + req, + }) + } } + const now = new Date().toISOString() + + result = await payload.db.createGlobalVersion({ + autosave: false, + createdAt: result.createdAt ? new Date(result.createdAt).toISOString() : now, + globalSlug: globalConfig.slug, + req, + updatedAt: isSavingDraft ? now : new Date(result.updatedAt).toISOString(), + versionData: result, + }) + // ///////////////////////////////////// // afterRead - Fields // ///////////////////////////////////// @@ -141,7 +156,6 @@ export const restoreVersionOperation = async = any context: req.context, depth: depth!, doc: result, - draft: undefined!, fallbackLocale: fallbackLocale!, global: globalConfig, locale: locale!, @@ -149,6 +163,7 @@ export const restoreVersionOperation = async = any populate, req, showHiddenFields: showHiddenFields!, + version: readVersion, }) // ///////////////////////////////////// @@ -173,6 +188,7 @@ export const restoreVersionOperation = async = any // ///////////////////////////////////// result = await afterChange({ + action: resolvedAction, collection: null, context: req.context, data: result, @@ -191,6 +207,7 @@ export const restoreVersionOperation = async = any for (const hook of globalConfig.hooks.afterChange) { result = (await hook({ + action: resolvedAction, context: req.context, data: result, doc: result, diff --git a/packages/payload/src/globals/operations/update.ts b/packages/payload/src/globals/operations/update.ts index 2abebc44188..3d951bf8ead 100644 --- a/packages/payload/src/globals/operations/update.ts +++ b/packages/payload/src/globals/operations/update.ts @@ -10,6 +10,7 @@ import type { TransformGlobalWithSelect, Where, } from '../../types/index.js' +import type { UpdateAction } from '../../versions/actions/types.js' import type { DataFromGlobalSlug, SanitizedGlobalConfig, @@ -34,15 +35,16 @@ import { initTransaction } from '../../utilities/initTransaction.js' import { killTransaction } from '../../utilities/killTransaction.js' import { resolveSelect } from '../../utilities/resolveSelect.js' import { sanitizeSelect } from '../../utilities/sanitizeSelect.js' +import { canonicalizeWriteStatus, resolveAction } from '../../versions/actions/resolveAction.js' import { buildLocalizedPublishData } from '../../versions/buildSingleLocalePublishData.js' import { getLatestGlobalVersion } from '../../versions/getLatestGlobalVersion.js' import { saveVersion } from '../../versions/saveVersion.js' type Args = { + action?: UpdateAction autosave?: boolean data: DeepPartial, 'id'>> depth?: number disableTransaction?: boolean - draft?: boolean globalConfig: SanitizedGlobalConfig overrideAccess?: boolean overrideLock?: boolean @@ -65,7 +67,6 @@ export const updateOperation = async < autosave, depth, disableTransaction, - draft: draftArg, globalConfig, overrideAccess, overrideLock, @@ -101,22 +102,37 @@ export const updateOperation = async < let { data } = args + const resolvedAction = resolveAction({ + action: args.action, + autosave: args.autosave, + draftsEnabled: hasDraftsEnabled(globalConfig), + locale, + localizedStatusEnabled: hasLocalizeStatusEnabled(globalConfig), + operation: 'update', + publishAllLocales: args.publishAllLocales, + status: + data && typeof data === 'object' && data !== null && '_status' in data + ? data._status + : undefined, + unpublishAllLocales: args.unpublishAllLocales, + }) + + data = canonicalizeWriteStatus({ + action: resolvedAction, + data, + locale, + publishAllLocales: publishAllLocalesArg, + unpublishAllLocales: unpublishAllLocalesArg, + }) + + const isSavingDraft = resolvedAction === 'saveDraft' + const isUnpublishing = resolvedAction === 'unpublish' const publishAllLocales = - !draftArg && + !isSavingDraft && + !isUnpublishing && (publishAllLocalesArg ?? (hasLocalizeStatusEnabled(globalConfig) && locale !== 'all' ? false : true)) - const unpublishAllLocales = - typeof unpublishAllLocalesArg === 'string' - ? unpublishAllLocalesArg === 'true' - : !!unpublishAllLocalesArg - const isSavingDraft = - Boolean(draftArg && hasDraftsEnabled(globalConfig)) && - data._status !== 'published' && - !publishAllLocales - - if (isSavingDraft) { - data._status = 'draft' - } + const unpublishAllLocales = !!unpublishAllLocalesArg // ///////////////////////////////////// // 1. Retrieve and execute access @@ -167,13 +183,13 @@ export const updateOperation = async < context: req.context, depth: 0, doc: deepCopyObjectSimple(globalJSON), - draft: draftArg!, fallbackLocale: fallbackLocale!, global: globalConfig, locale: locale!, overrideAccess: true, req, showHiddenFields: showHiddenFields!, + version: isSavingDraft ? 'latest' : 'published', }) // /////////////////////////////////////////// @@ -251,10 +267,7 @@ export const updateOperation = async < global: globalConfig, operation: 'update' as Operation, req, - skipValidation: - (isSavingDraft && !hasDraftValidationEnabled(globalConfig)) || - // Skip validation for unpublish operations — they only change _status, not document data - unpublishAllLocales, + skipValidation: (isSavingDraft && !hasDraftValidationEnabled(globalConfig)) || isUnpublishing, } let result: JsonObject = await beforeChange(beforeChangeArgs) @@ -393,7 +406,7 @@ export const updateOperation = async < payload, req, select, - unpublish: unpublishAllLocales, + unpublish: isUnpublishing, }) resultWithLocales = { @@ -424,7 +437,6 @@ export const updateOperation = async < context: req.context, depth: depth!, doc: resultWithLocales, - draft: draftArg!, fallbackLocale: null, global: globalConfig, locale: locale!, @@ -433,6 +445,7 @@ export const updateOperation = async < req, select, showHiddenFields: showHiddenFields!, + version: isSavingDraft ? 'latest' : 'published', }) // ///////////////////////////////////// @@ -457,6 +470,7 @@ export const updateOperation = async < // ///////////////////////////////////// result = await afterChange({ + action: resolvedAction, collection: null, context: req.context, data, @@ -475,6 +489,7 @@ export const updateOperation = async < for (const hook of globalConfig.hooks.afterChange) { result = (await hook({ + action: resolvedAction, context: req.context, data, doc: result, diff --git a/packages/payload/src/hierarchy/hooks/collectionAfterRead.ts b/packages/payload/src/hierarchy/hooks/collectionAfterRead.ts index 135d1ea0ecf..bfd2f76741e 100644 --- a/packages/payload/src/hierarchy/hooks/collectionAfterRead.ts +++ b/packages/payload/src/hierarchy/hooks/collectionAfterRead.ts @@ -57,7 +57,7 @@ type Args = { export const hierarchyCollectionAfterRead = ({ parentFieldName, slugPathFieldName, titlePathFieldName }: Args): CollectionAfterReadHook => - async ({ collection, context, doc, req }) => { + async ({ collection, context, doc, req, version }) => { // Skip if deleting if (context?.isDeleting) { return doc @@ -91,6 +91,7 @@ export const hierarchyCollectionAfterRead = req, slugPathFieldName, titlePathFieldName, + version, }) // Attach computed paths to document using configured field names diff --git a/packages/payload/src/hierarchy/utils/computePaths.ts b/packages/payload/src/hierarchy/utils/computePaths.ts index cff2d11b8b0..970da48e9e3 100644 --- a/packages/payload/src/hierarchy/utils/computePaths.ts +++ b/packages/payload/src/hierarchy/utils/computePaths.ts @@ -1,5 +1,6 @@ import type { SanitizedCollectionConfig } from '../../collections/config/types.js' import type { Document, PayloadRequest } from '../../types/index.js' +import type { ReadVersion } from '../../versions/types.js' import { slugify as payloadSlugify } from '../../utilities/slugify.js' import { HIERARCHY_SLUG_PATH_FIELD, HIERARCHY_TITLE_PATH_FIELD } from '../constants.js' @@ -16,6 +17,7 @@ type ComputePathsArgs = { req: PayloadRequest slugPathFieldName?: string titlePathFieldName?: string + version?: ReadVersion } type ComputePathsResult = { @@ -123,8 +125,11 @@ export async function computePaths(args: ComputePathsArgs): Promise payloadSlugify(text) || '') @@ -218,7 +223,7 @@ export async function computePaths(args: ComputePathsArgs): Promise { if (locale === 'all') { - try { - // First try to fetch published version with all locales - return await req.payload.findByID({ - id: validParentID, - collection: collection.slug, - depth: 0, - locale, - overrideAccess: true, - req: parentReq, - select: { - [parentHierarchyConfig.parentFieldName]: true, - [parentHierarchyConfig.slugPathFieldName]: true, - [parentHierarchyConfig.titlePathFieldName]: true, - [parentTitleField]: true, - ...(slugFieldName ? { [slugFieldName]: true } : {}), - }, - user: req.user, - }) - } catch (_error) { - // Published version not found, must be a draft - // Payload doesn't support fetching all locales of a draft with a single query - // So we need to fetch each locale separately and combine the path data - const locales = req.payload.config.localization - ? req.payload.config.localization.localeCodes - : [] - const parentPathsByLocale: Record< - string, - { slugPath?: string; title?: string; titlePath?: string } - > = {} - - // Get parent collection's slugify function (may differ from child's) - const parentSlugify = - (parentCollectionConfig.hierarchy !== false && - parentCollectionConfig.hierarchy.slugify) || - ((text: string) => payloadSlugify(text) || '') - - // Fetch parent for each locale to get paths - for (const loc of locales) { - // Create a new request with this specific locale but keep computeHierarchyPaths flag - const localeReq = { - ...req, - context: { - ...req.context, - computeHierarchyPaths: true, + if (readVersion === 'published') { + try { + return await req.payload.findByID({ + id: validParentID, + collection: collection.slug, + depth: 0, + locale, + overrideAccess: true, + req: parentReq, + select: { + [parentHierarchyConfig.parentFieldName]: true, + [parentHierarchyConfig.slugPathFieldName]: true, + [parentHierarchyConfig.titlePathFieldName]: true, + [parentTitleField]: true, + ...(slugFieldName ? { [slugFieldName]: true } : {}), }, + user: req.user, + version: readVersion, + }) + } catch { + // Published version not found, must be a draft-only ancestor. + } + } + + // Latest/draft versions are stored per locale, so fetch each locale separately. + const locales = req.payload.config.localization + ? req.payload.config.localization.localeCodes + : [] + const parentPathsByLocale: Record< + string, + { slugPath?: string; title?: string; titlePath?: string } + > = {} + + // Get parent collection's slugify function (may differ from child's) + const parentSlugify = + (parentCollectionConfig.hierarchy !== false && + parentCollectionConfig.hierarchy.slugify) || + ((text: string) => payloadSlugify(text) || '') + + // Fetch parent for each locale to get paths + for (const loc of locales) { + // Create a new request with this specific locale but keep computeHierarchyPaths flag + const localeReq = { + ...req, + context: { + ...req.context, + computeHierarchyPaths: true, + }, + locale: loc, + } + + try { + const parentForLocale = await req.payload.findByID({ + id: validParentID, + collection: collection.slug, + depth: 0, locale: loc, + overrideAccess: true, + req: localeReq, + user: req.user, + version: readVersion, + }) + + // Extract the path fields and title from the parent + // If paths weren't computed (undefined), compute them manually for root documents + let parentSlugPath = parentForLocale[parentHierarchyConfig.slugPathFieldName] + let parentTitlePath = parentForLocale[parentHierarchyConfig.titlePathFieldName] + const parentTitle = parentForLocale[parentTitleField] + + // If paths are undefined, this might be a root document - compute paths from title + if (!parentSlugPath && parentTitle) { + parentSlugPath = parentSlugify(parentTitle) + } + if (!parentTitlePath && parentTitle) { + parentTitlePath = parentTitle } - try { - const parentForLocale = await req.payload.findByID({ - id: validParentID, - collection: collection.slug, - depth: 0, - draft: true, - locale: loc, - overrideAccess: true, - req: localeReq, - user: req.user, - }) - - // Extract the path fields and title from the parent - // If paths weren't computed (undefined), compute them manually for root documents - let parentSlugPath = parentForLocale[parentHierarchyConfig.slugPathFieldName] - let parentTitlePath = parentForLocale[parentHierarchyConfig.titlePathFieldName] - const parentTitle = parentForLocale[parentTitleField] - - // If paths are undefined, this might be a root document - compute paths from title - if (!parentSlugPath && parentTitle) { - parentSlugPath = parentSlugify(parentTitle) - } - if (!parentTitlePath && parentTitle) { - parentTitlePath = parentTitle - } - - parentPathsByLocale[loc] = { - slugPath: parentSlugPath, - title: parentTitle, - titlePath: parentTitlePath, - } - } catch (_localeError) { - // This locale doesn't exist, skip it + parentPathsByLocale[loc] = { + slugPath: parentSlugPath, + title: parentTitle, + titlePath: parentTitlePath, } + } catch (_localeError) { + // This locale doesn't exist, skip it } + } - // Combine the path data from all locales into a single parent object - const combinedSlugPaths: Record = {} - const combinedTitlePaths: Record = {} - const combinedTitles: Record = {} + // Combine the path data from all locales into a single parent object + const combinedSlugPaths: Record = {} + const combinedTitlePaths: Record = {} + const combinedTitles: Record = {} - for (const loc of locales) { - if (parentPathsByLocale[loc]) { - combinedSlugPaths[loc] = parentPathsByLocale[loc].slugPath - combinedTitlePaths[loc] = parentPathsByLocale[loc].titlePath - combinedTitles[loc] = parentPathsByLocale[loc].title - } + for (const loc of locales) { + if (parentPathsByLocale[loc]) { + combinedSlugPaths[loc] = parentPathsByLocale[loc].slugPath + combinedTitlePaths[loc] = parentPathsByLocale[loc].titlePath + combinedTitles[loc] = parentPathsByLocale[loc].title } + } - // Return a parent object with only the hierarchy fields we need - // (all properly formatted as multi-locale objects) - return { - id: validParentID, - [parentHierarchyConfig.slugPathFieldName]: combinedSlugPaths, - [parentHierarchyConfig.titlePathFieldName]: combinedTitlePaths, - [parentTitleField]: combinedTitles, - } + // Return a parent object with only the hierarchy fields we need + // (all properly formatted as multi-locale objects) + return { + id: validParentID, + [parentHierarchyConfig.slugPathFieldName]: combinedSlugPaths, + [parentHierarchyConfig.titlePathFieldName]: combinedTitlePaths, + [parentTitleField]: combinedTitles, } } else { // Normal case: single locale, can pass draft parameter @@ -367,7 +372,6 @@ export async function computePaths(args: ComputePathsArgs): Promise, - TDraft extends boolean = false, + TVersion extends ReadVersion | undefined = undefined, >( - options: { draft?: TDraft } & FindOptions, + options: { version?: TVersion } & FindOptions, ): Promise< PaginatedDocs< - TDraft extends true - ? PayloadTypes extends { strictDraftTypes: true } - ? DraftTransformCollectionWithSelect - : TransformCollectionWithSelect + TVersion extends 'draft' | 'latest' + ? DraftTransformCollectionWithSelect : TransformCollectionWithSelect > > => { - return findLocal(this, options) + return findLocal(this, options) } /** @@ -615,10 +614,18 @@ export class BasePayload { TSlug extends CollectionSlug, TDisableErrors extends boolean, TSelect extends SelectFromCollectionSlug, + TVersion extends ReadVersion | undefined = undefined, >( - options: FindByIDOptions, - ): Promise, TDisableErrors>> => { - return findByIDLocal(this, options) + options: { version?: TVersion } & FindByIDOptions, + ): Promise< + ApplyDisableErrors< + TVersion extends 'draft' | 'latest' + ? DraftTransformCollectionWithSelect + : TransformCollectionWithSelect, + TDisableErrors + > + > => { + return findByIDLocal(this, options) } /** @@ -635,10 +642,18 @@ export class BasePayload { return findDistinctLocal(this, options) } - findGlobal = async >( - options: FindGlobalOptions, - ): Promise> => { - return findOneGlobalLocal(this, options) + findGlobal = async < + TSlug extends GlobalSlug, + TSelect extends SelectFromGlobalSlug, + TVersion extends ReadVersion | undefined = undefined, + >( + options: { version?: TVersion } & FindGlobalOptions, + ): Promise< + TVersion extends 'draft' | 'latest' + ? DraftTransformGlobalWithSelect + : TransformGlobalWithSelect + > => { + return findOneGlobalLocal(this, options) } /** @@ -1485,17 +1500,26 @@ export type { CollectionAccess, CollectionAdminOptions, CollectionConfig, + CollectionsWithoutDrafts, + CreateActionFromCollectionSlug, + CreateDataFromCollectionSlug, DataFromCollectionSlug, + DraftDataFromCollectionSlug, HookOperationType, IDTypeForCollectionSlug, MeHook as CollectionMeHook, + QueryDraftDataFromCollection, + QueryDraftDataFromCollectionSlug, RefreshHook as CollectionRefreshHook, RequiredDataFromCollection, RequiredDataFromCollectionSlug, + RestoreActionFromCollectionSlug, SanitizedCollectionConfig, SanitizedJoins, TypeWithID, TypeWithTimestamps, + UpdateActionFromCollectionSlug, + VersionFromCollectionSlug, } from './collections/config/types.js' export type { CompoundIndex, FoldersConfig, TagsConfig } from './collections/config/types.js' @@ -1892,7 +1916,12 @@ export type { GlobalAccess, GlobalAdminOptions, GlobalConfig, + GlobalsWithoutDrafts, + QueryDraftDataFromGlobalSlug, + RestoreActionFromGlobalSlug, SanitizedGlobalConfig, + UpdateActionFromGlobalSlug, + VersionFromGlobalSlug, } from './globals/config/types.js' export { docAccessOperation as docAccessOperationGlobal } from './globals/operations/docAccess.js' export { findOneOperation } from './globals/operations/findOne.js' @@ -2095,6 +2124,13 @@ export { transformPointDataToPayload } from './utilities/transformPointDataToPay export { traverseFields } from './utilities/traverseFields.js' export type { TraverseFieldsCallback } from './utilities/traverseFields.js' export { strictObject } from './utilities/zod.js' +export type { + CreateAction, + RestoreAction, + UpdateAction, + WriteAction, + WriteOperation, +} from './versions/actions/types.js' export { buildVersionCollectionFields } from './versions/buildCollectionFields.js' export { buildVersionGlobalFields } from './versions/buildGlobalFields.js' export { buildVersionCompoundIndexes } from './versions/buildVersionCompoundIndexes.js' @@ -2110,6 +2146,6 @@ export { getLatestGlobalVersion } from './versions/getLatestGlobalVersion.js' export { saveVersion } from './versions/saveVersion.js' export type { SchedulePublishTaskInput } from './versions/schedule/types.js' -export type { SchedulePublish, TypeWithVersion } from './versions/types.js' +export type { ReadVersion, SchedulePublish, TypeWithVersion } from './versions/types.js' export { deepMergeSimple } from '@payloadcms/translations/utilities' export { z } from 'zod/mini' diff --git a/packages/payload/src/types/index.ts b/packages/payload/src/types/index.ts index 9b6f4de7fae..3eb797e0c0c 100644 --- a/packages/payload/src/types/index.ts +++ b/packages/payload/src/types/index.ts @@ -10,6 +10,7 @@ import type { TypeWithID, TypeWithTimestamps, } from '../collections/config/types.js' +import type { QueryDraftDataFromGlobalSlug } from '../globals/config/types.js' import type payload from '../index.js' import type { AuthenticatedUser, @@ -329,6 +330,13 @@ export type TransformGlobalWithSelect< ? TransformDataWithSelect, TSelect> : DataFromGlobalSlug +export type DraftTransformGlobalWithSelect< + TSlug extends GlobalSlug, + TSelect extends SelectType, +> = TSelect extends SelectType + ? TransformDataWithSelect, TSelect> + : QueryDraftDataFromGlobalSlug + export type PopulateType = Partial export type ResolvedFilterOptions = { [collection: string]: Where } diff --git a/packages/payload/src/uploads/generateFileData.ts b/packages/payload/src/uploads/generateFileData.ts index 29d685a16a3..a19db73d9d4 100644 --- a/packages/payload/src/uploads/generateFileData.ts +++ b/packages/payload/src/uploads/generateFileData.ts @@ -26,6 +26,9 @@ type Args = { collection: Collection config: SanitizedConfig data: T + /** + * Derived storage flag: skip requiring a file and mark uploaded file data as a draft. + */ draft?: boolean isDuplicating?: boolean operation: 'create' | 'update' diff --git a/packages/payload/src/utilities/configToJSONSchema.ts b/packages/payload/src/utilities/configToJSONSchema.ts index 5770bfde7e6..b85df140719 100644 --- a/packages/payload/src/utilities/configToJSONSchema.ts +++ b/packages/payload/src/utilities/configToJSONSchema.ts @@ -1664,14 +1664,6 @@ export function configToJSONSchema( globalsInput: generateEntityInputSchemas(config.globals || []), } : {}), - ...(config.typescript?.strictDraftTypes - ? { - strictDraftTypes: { - type: 'boolean', - const: true, - }, - } - : {}), user: generateAuthEntitySchemas(config.collections), }, required: [ @@ -1683,7 +1675,6 @@ export function configToJSONSchema( 'collectionsJoins', 'globalsSelect', ...(generateInputTypes ? ['collectionsInput', 'globalsInput'] : []), - ...(config.typescript?.strictDraftTypes ? ['strictDraftTypes'] : []), 'globals', 'auth', 'db', diff --git a/packages/payload/src/utilities/fieldValueExists.ts b/packages/payload/src/utilities/fieldValueExists.ts index cb149c883de..9f9692b5354 100644 --- a/packages/payload/src/utilities/fieldValueExists.ts +++ b/packages/payload/src/utilities/fieldValueExists.ts @@ -25,7 +25,7 @@ type Args = { * * Runs the `find` operation outside the caller's transaction while preserving the rest of the * request. A committed read is what a uniqueness check wants, and isolating the transaction avoids - * the "cursor on a session with a transaction in progress" error from a hook. `draft` includes + * the "cursor on a session with a transaction in progress" error from a hook. `version: 'latest'` includes * slugs that only exist in a draft version. */ export const fieldValueExists = async ({ @@ -46,12 +46,12 @@ export const fieldValueExists = async ({ collection, depth: 0, disableErrors: true, - draft: Boolean(draftsEnabled), limit: 2, locale: locale as Parameters[0]['locale'], overrideAccess, pagination: false, req: queryReq, + version: draftsEnabled ? 'latest' : undefined, where: { [field]: { equals: value } }, }) diff --git a/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts b/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts index 672191afa01..a1657ea708b 100644 --- a/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts +++ b/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts @@ -117,6 +117,7 @@ export async function getEntityPermissions { describe('boolean parameters', () => { @@ -256,14 +266,14 @@ describe('parseParams', () => { it('should handle mixed parameter types', () => { const result = parseParams({ - draft: 'true', + autosave: 'true', depth: '5', sort: 'name,createdAt', data: '{"test": true}', customParam: 'custom', }) - expect(result.draft).toBe(true) + expect(result.autosave).toBe(true) expect(result.depth).toBe(5) expect(result.sort).toEqual(['name', 'createdAt']) expect(result.data).toEqual({ test: true }) @@ -286,12 +296,120 @@ describe('parseParams', () => { }) it('should only process parameters that exist in the input', () => { - const result = parseParams({ draft: 'true' }) + const result = parseParams({ autosave: 'true' }) - expect(result.draft).toBe(true) - expect(result).not.toHaveProperty('autosave') + expect(result.autosave).toBe(true) + expect(result).not.toHaveProperty('trash') expect(result).not.toHaveProperty('depth') expect(result).not.toHaveProperty('sort') }) }) + + describe('version parameter', () => { + it.each(['published', 'latest', 'draft'] as const)( + 'should parse exact version value %s', + (version) => { + const result = parseParams({ version }) + expect(result.version).toBe(version) + }, + ) + + it('should omit version when it is not provided', () => { + const result = parseParams({}) + expect(result).not.toHaveProperty('version') + }) + + it('should reject invalid casing', () => { + expect(() => parseParams({ version: 'Latest' })).toThrow(APIError) + expect(() => parseParams({ version: 'DRAFT' })).toThrow(APIError) + }) + + it('should reject invalid strings', () => { + expect(() => parseParams({ version: 'autosave' })).toThrow(APIError) + expect(() => parseParams({ version: 'true' })).toThrow(APIError) + }) + + it('should reject boolean values', () => { + expect(() => parseParams({ version: true as unknown as string })).toThrow(APIError) + expect(() => parseParams({ version: false as unknown as string })).toThrow(APIError) + }) + + it('should reject repeated values', () => { + expect(() => parseParams({ version: ['latest', 'draft'] })).toThrow(APIError) + }) + }) + + describe('action parameter', () => { + it.each(['publish', 'saveDraft', 'unpublish'] as const)( + 'should parse exact write action %s', + (action) => { + const result = parseParams({ action }) + expect(result.action).toBe(action) + }, + ) + + it('should omit action when it is not provided', () => { + const result = parseParams({}) + expect(result).not.toHaveProperty('action') + }) + + it('should reject invalid casing', () => { + expect(() => parseParams({ action: 'SaveDraft' })).toThrow(APIError) + expect(() => parseParams({ action: 'PUBLISH' })).toThrow(APIError) + }) + + it('should reject invalid strings', () => { + expect(() => parseParams({ action: 'draft' })).toThrow(APIError) + expect(() => parseParams({ action: 'true' })).toThrow(APIError) + }) + + it('should reject boolean values', () => { + expect(() => parseParams({ action: true as unknown as string })).toThrow(APIError) + }) + + it('should reject repeated values', () => { + expect(() => parseParams({ action: ['saveDraft', 'publish'] })).toThrow(APIError) + }) + + it('should reject unpublish for create and restore enums', () => { + expect(() => + parseEnumParam({ + allowed: createActionValues, + param: 'action', + value: 'unpublish', + }), + ).toThrow(APIError) + + expect(() => + parseEnumParam({ + allowed: restoreActionValues, + param: 'action', + value: 'unpublish', + }), + ).toThrow(APIError) + + expect( + parseEnumParam({ + allowed: updateActionValues, + param: 'action', + value: 'unpublish', + }), + ).toBe('unpublish') + }) + }) + + describe('obsolete draft parameter', () => { + it('should reject boolean true', () => { + expect(() => parseParams({ draft: 'true' })).toThrow(APIError) + }) + + it('should reject boolean false', () => { + expect(() => parseParams({ draft: 'false' })).toThrow(APIError) + }) + + it('should reject boolean values without coercing them', () => { + expect(() => parseParams({ draft: true as unknown as string })).toThrow(APIError) + expect(() => parseParams({ draft: false as unknown as string })).toThrow(APIError) + }) + }) }) diff --git a/packages/payload/src/utilities/parseParams/index.ts b/packages/payload/src/utilities/parseParams/index.ts index cdcaf6e856a..a5ad54d0d53 100644 --- a/packages/payload/src/utilities/parseParams/index.ts +++ b/packages/payload/src/utilities/parseParams/index.ts @@ -1,6 +1,16 @@ +import { status as httpStatus } from 'http-status' + import type { JoinQuery, PopulateType, SelectType, Where } from '../../types/index.js' +import type { + CreateAction, + RestoreAction, + UpdateAction, + WriteAction, +} from '../../versions/actions/types.js' +import type { ReadVersion } from '../../versions/types.js' import type { JoinParams } from '../sanitizeJoinParams.js' +import { APIError } from '../../errors/APIError.js' import { isNumber } from '../isNumber.js' import { parseBooleanString } from '../parseBooleanString.js' import { sanitizeJoinParams } from '../sanitizeJoinParams.js' @@ -8,8 +18,37 @@ import { sanitizePopulateParam } from '../sanitizePopulateParam.js' import { sanitizeSelectParam } from '../sanitizeSelectParam.js' import { sanitizeSortParams } from '../sanitizeSortParams.js' +export const readVersionValues = [ + 'published', + 'latest', + 'draft', +] as const satisfies readonly ReadVersion[] + +export const createActionValues = [ + 'publish', + 'saveDraft', +] as const satisfies readonly CreateAction[] + +export const updateActionValues = [ + 'publish', + 'saveDraft', + 'unpublish', +] as const satisfies readonly UpdateAction[] + +export const restoreActionValues = [ + 'publish', + 'saveDraft', +] as const satisfies readonly RestoreAction[] + +export const writeActionValues = [ + 'publish', + 'saveDraft', + 'unpublish', +] as const satisfies readonly WriteAction[] + export type RawParams = { [key: string]: unknown + action?: string | string[] autosave?: string data?: string depth?: string @@ -28,14 +67,15 @@ export type RawParams = { sort?: string | string[] trash?: string unpublishAllLocales?: string + version?: string | string[] where?: string | Where } export type ParsedParams = { + action?: WriteAction autosave?: boolean data?: Record depth?: number - draft?: boolean field?: string flattenLocales?: boolean joins?: JoinQuery @@ -50,28 +90,58 @@ export type ParsedParams = { sort?: string[] trash?: boolean unpublishAllLocales?: boolean + version?: ReadVersion where?: Where } & Record -export const booleanParams = [ - 'autosave', - 'draft', - 'trash', - 'overrideLock', - 'pagination', - 'flattenLocales', -] +export const booleanParams = ['autosave', 'trash', 'overrideLock', 'pagination', 'flattenLocales'] export const numberParams = ['depth', 'limit', 'page'] +export type ParseEnumParamArgs = { + allowed: readonly T[] + param: string + value: unknown +} + +/** + * Parses an exact enum query value. Repeated values, invalid casing, and unknown strings throw 400. + */ +export function parseEnumParam({ + allowed, + param, + value, +}: ParseEnumParamArgs): T | undefined { + if (value === undefined || value === null) { + return undefined + } + + if (typeof value === 'string' && (allowed as readonly string[]).includes(value)) { + return value as T + } + + throw new APIError( + `Invalid ${param} ${JSON.stringify(value)}. Valid values are: ${allowed.join(', ')}.`, + httpStatus.BAD_REQUEST, + ) +} + /** * Takes raw query parameters and parses them into the correct types that Payload expects. * Examples: - * a. `draft` provided as a string of "true" is converted to a boolean + * a. `autosave` provided as a string of "true" is converted to a boolean * b. `depth` provided as a string of "0" is converted to a number * c. `sort` provided as a comma-separated string or array is converted to an array of strings + * d. `version` and `action` are validated as exact enum strings */ export const parseParams = (params: RawParams): ParsedParams => { + if ('draft' in params) { + throw new APIError( + 'The query parameter "draft" is no longer supported. Use "version" for reads and "action" for writes.', + httpStatus.BAD_REQUEST, + ) + } + const parsedParams = (params || {}) as ParsedParams // iterate through known params to make this very fast @@ -113,5 +183,21 @@ export const parseParams = (params: RawParams): ParsedParams => { parsedParams.where = JSON.parse(params.where) as Where } + if ('version' in params) { + parsedParams.version = parseEnumParam({ + allowed: readVersionValues, + param: 'version', + value: params.version, + }) + } + + if ('action' in params) { + parsedParams.action = parseEnumParam({ + allowed: writeActionValues, + param: 'action', + value: params.action, + }) + } + return parsedParams } diff --git a/packages/payload/src/utilities/sharedInputSchemas.ts b/packages/payload/src/utilities/sharedInputSchemas.ts index 5222ac50ed7..3b757b7121f 100644 --- a/packages/payload/src/utilities/sharedInputSchemas.ts +++ b/packages/payload/src/utilities/sharedInputSchemas.ts @@ -25,9 +25,9 @@ export const defaultPageSchema = z export const depthSchema = z ._default(z.int().check(z.minimum(0), z.maximum(10)), 0) .check(z.describe('How many levels deep to populate relationships.')) -export const draftSchema = z - .optional(z.boolean()) - .check(z.describe('Include or read draft content.')) +export const createActionSchema = z + .optional(z.enum(['saveDraft', 'publish'])) + .check(z.describe('Save a draft or publish the created document.')) export const fallbackLocaleSchema = z .optional(z.union([z.string(), z.literal(false)])) .check(z.describe('Optional fallback locale code, or false to disable fallback.')) @@ -101,9 +101,15 @@ export const trashSchema = z export const unpublishAllLocalesSchema = z .optional(z.boolean()) .check(z.describe('Unpublish all locales.')) -export const writeDraftSchema = z - ._default(z.boolean(), false) - .check(z.describe('Write draft content.')) +export const restoreActionSchema = z + .optional(z.enum(['saveDraft', 'publish'])) + .check(z.describe('Save the restored version as a draft or publish it.')) +export const updateActionSchema = z + .optional(z.enum(['saveDraft', 'publish', 'unpublish'])) + .check(z.describe('Save a draft, publish, or unpublish the updated document.')) +export const versionSchema = z + .optional(z.enum(['published', 'latest', 'draft'])) + .check(z.describe('Select published, latest, or draft content.')) export const slugSchema = z.string().check(z.minLength(1), z.describe('The target slug.')) const whereFieldSchema = z.partialRecord(z.enum(validOperators), z.unknown()) diff --git a/packages/payload/src/versions/actions/resolveAction.spec.ts b/packages/payload/src/versions/actions/resolveAction.spec.ts new file mode 100644 index 00000000000..c9f129cb8d1 --- /dev/null +++ b/packages/payload/src/versions/actions/resolveAction.spec.ts @@ -0,0 +1,557 @@ +import { describe, expect, it } from 'vitest' + +import type { ResolveActionArgs } from './types.js' + +import { APIError } from '../../errors/APIError.js' +import { canonicalizeWriteStatus, resolveAction, statusFromAction } from './resolveAction.js' + +const draftOps = (overrides: Partial & Pick) => + resolveAction({ + draftsEnabled: true, + ...overrides, + }) + +describe('resolveAction', () => { + describe('draft-enabled defaults', () => { + it.each([ + ['create', 'saveDraft'], + ['duplicate', 'saveDraft'], + ['update', 'publish'], + ['restore', 'publish'], + ] as const)('%s defaults to %s when action and status are omitted', (operation, expected) => { + expect(draftOps({ operation })).toBe(expected) + }) + }) + + describe('explicit actions', () => { + it.each([ + ['create', 'saveDraft'], + ['create', 'publish'], + ['duplicate', 'saveDraft'], + ['duplicate', 'publish'], + ['update', 'saveDraft'], + ['update', 'publish'], + ['update', 'unpublish'], + ['restore', 'saveDraft'], + ['restore', 'publish'], + ] as const)('%s honors explicit action %s', (operation, action) => { + expect(draftOps({ action, operation })).toBe(action) + }) + }) + + describe('scalar status fallback', () => { + it.each(['create', 'duplicate', 'update', 'restore'] as const)( + '%s infers saveDraft from _status "draft" when action is omitted', + (operation) => { + expect(draftOps({ operation, status: 'draft' })).toBe('saveDraft') + }, + ) + + it.each(['create', 'duplicate', 'update', 'restore'] as const)( + '%s infers publish from _status "published" when action is omitted', + (operation) => { + expect(draftOps({ operation, status: 'published' })).toBe('publish') + }, + ) + + it('should not infer an action from unrecognized status values', () => { + expect(draftOps({ operation: 'update', status: 'archived' })).toBe('publish') + expect(draftOps({ operation: 'create', status: true })).toBe('saveDraft') + expect(draftOps({ operation: 'create', status: 1 })).toBe('saveDraft') + }) + }) + + describe('localized status fallback', () => { + const localizedStatus = { + en: 'draft', + es: 'published', + } + + it('should use the active write locale to infer action', () => { + expect( + draftOps({ + locale: 'en', + operation: 'update', + status: localizedStatus, + }), + ).toBe('saveDraft') + + expect( + draftOps({ + locale: 'es', + operation: 'update', + status: localizedStatus, + }), + ).toBe('publish') + }) + + it('should require an explicit action and modifier for an all-locale transition', () => { + expect(() => + draftOps({ + action: 'publish', + locale: 'all', + localizedStatusEnabled: true, + operation: 'update', + status: 'published', + }), + ).toThrow( + 'Publishing all locales requires an explicit "publish" action and publishAllLocales: true.', + ) + + expect(() => + draftOps({ + locale: 'all', + operation: 'update', + status: localizedStatus, + }), + ).toThrow( + 'Publishing all locales requires an explicit "publish" action and publishAllLocales: true.', + ) + + expect(() => + draftOps({ + locale: 'all', + operation: 'update', + publishAllLocales: true, + status: localizedStatus, + }), + ).toThrow( + 'Publishing all locales requires an explicit "publish" action and publishAllLocales: true.', + ) + + expect(() => + draftOps({ + action: 'publish', + locale: 'all', + operation: 'update', + status: localizedStatus, + }), + ).toThrow( + 'Publishing all locales requires an explicit "publish" action and publishAllLocales: true.', + ) + + expect( + draftOps({ + action: 'publish', + locale: 'all', + operation: 'update', + publishAllLocales: true, + status: localizedStatus, + }), + ).toBe('publish') + + expect(() => + draftOps({ + action: 'unpublish', + locale: 'all', + operation: 'update', + status: localizedStatus, + }), + ).toThrow( + 'Unpublishing all locales requires an explicit "unpublish" action and unpublishAllLocales: true.', + ) + + expect( + draftOps({ + action: 'unpublish', + locale: 'all', + operation: 'update', + status: localizedStatus, + unpublishAllLocales: true, + }), + ).toBe('unpublish') + }) + + it('should not infer from localized status when no locale is provided', () => { + expect( + draftOps({ + operation: 'create', + status: localizedStatus, + }), + ).toBe('saveDraft') + }) + + it('should not infer from a missing locale key', () => { + expect( + draftOps({ + locale: 'de', + operation: 'update', + status: localizedStatus, + }), + ).toBe('publish') + }) + }) + + describe('explicit action wins over status', () => { + it('should use publish when action and draft status conflict', () => { + expect( + draftOps({ + action: 'publish', + operation: 'update', + status: 'draft', + }), + ).toBe('publish') + }) + + it('should use saveDraft when action and published status conflict', () => { + expect( + draftOps({ + action: 'saveDraft', + operation: 'create', + status: 'published', + }), + ).toBe('saveDraft') + }) + + it('should use unpublish when action and published status conflict', () => { + expect( + draftOps({ + action: 'unpublish', + operation: 'update', + status: 'published', + }), + ).toBe('unpublish') + }) + }) + + describe('unpublish is never inferred', () => { + it('should not infer unpublish from any status value', () => { + expect(draftOps({ operation: 'update', status: 'draft' })).toBe('saveDraft') + expect(draftOps({ operation: 'update', status: 'published' })).toBe('publish') + expect(draftOps({ operation: 'update', status: 'unpublished' })).toBe('publish') + }) + + it('should require an explicit unpublish action', () => { + expect(draftOps({ action: 'unpublish', operation: 'update' })).toBe('unpublish') + }) + }) + + describe('non-draft entities', () => { + it('should return undefined for omitted action', () => { + expect( + resolveAction({ + draftsEnabled: false, + operation: 'update', + }), + ).toBeUndefined() + }) + + it('should return undefined for explicit publish', () => { + expect( + resolveAction({ + action: 'publish', + draftsEnabled: false, + operation: 'create', + }), + ).toBeUndefined() + }) + + it('should ignore recognized status because no publication transition occurs', () => { + expect( + resolveAction({ + draftsEnabled: false, + operation: 'update', + status: 'draft', + }), + ).toBeUndefined() + }) + + it.each(['create', 'duplicate', 'update'] as const)( + 'rejects saveDraft on %s when drafts are not enabled', + (operation) => { + expect(() => + resolveAction({ + action: 'saveDraft', + draftsEnabled: false, + operation, + }), + ).toThrow(APIError) + + expect(() => + resolveAction({ + action: 'saveDraft', + draftsEnabled: false, + operation, + }), + ).toThrow('The action "saveDraft" cannot be used because drafts are not enabled.') + }, + ) + + it('should reject unpublish when drafts are not enabled', () => { + expect(() => + resolveAction({ + action: 'unpublish', + draftsEnabled: false, + operation: 'update', + }), + ).toThrow('The action "unpublish" cannot be used because drafts are not enabled.') + }) + }) + + describe('invalid runtime values', () => { + it('should reject unknown action strings', () => { + expect(() => draftOps({ action: 'SAVE_DRAFT', operation: 'update' })).toThrow( + 'Invalid action "SAVE_DRAFT". Valid actions for update are: saveDraft, publish, unpublish.', + ) + }) + + it('should reject leftover boolean draft intent', () => { + expect(() => draftOps({ action: true, operation: 'create' })).toThrow( + 'Invalid action true. Valid actions for create are: saveDraft, publish.', + ) + }) + + it.each([ + ['create', 'unpublish'], + ['duplicate', 'unpublish'], + ['restore', 'unpublish'], + ] as const)('rejects %s action %s', (operation, action) => { + expect(() => draftOps({ action, operation })).toThrow( + `Invalid action "${action}". Valid actions for ${operation} are: saveDraft, publish.`, + ) + }) + }) + + describe('autosave', () => { + it('should allow autosave when the resolved action is saveDraft', () => { + expect( + draftOps({ + action: 'saveDraft', + autosave: true, + operation: 'update', + }), + ).toBe('saveDraft') + + expect( + draftOps({ + autosave: true, + operation: 'create', + }), + ).toBe('saveDraft') + }) + + it('should reject autosave when the resolved action is not saveDraft', () => { + expect(() => + draftOps({ + action: 'publish', + autosave: true, + operation: 'update', + }), + ).toThrow('autosave is only valid when the resolved action is "saveDraft".') + + expect(() => + draftOps({ + autosave: true, + operation: 'update', + }), + ).toThrow('autosave is only valid when the resolved action is "saveDraft".') + }) + + it('should reject autosave on non-draft entities', () => { + expect(() => + resolveAction({ + autosave: true, + draftsEnabled: false, + operation: 'create', + }), + ).toThrow('autosave is only valid when the resolved action is "saveDraft".') + }) + }) + + describe('locale modifiers', () => { + it('should allow publishAllLocales with publish', () => { + expect( + draftOps({ + action: 'publish', + operation: 'update', + publishAllLocales: true, + }), + ).toBe('publish') + }) + + it('should allow unpublishAllLocales with unpublish', () => { + expect( + draftOps({ + action: 'unpublish', + operation: 'update', + unpublishAllLocales: true, + }), + ).toBe('unpublish') + }) + + it('should reject publishAllLocales when the resolved action is not publish', () => { + expect(() => + draftOps({ + action: 'saveDraft', + operation: 'update', + publishAllLocales: true, + }), + ).toThrow('publishAllLocales is only valid when the resolved action is "publish".') + + expect(() => + draftOps({ + operation: 'create', + publishAllLocales: true, + }), + ).toThrow('publishAllLocales is only valid when the resolved action is "publish".') + }) + + it('should reject unpublishAllLocales when the resolved action is not unpublish', () => { + expect(() => + draftOps({ + operation: 'update', + unpublishAllLocales: true, + }), + ).toThrow('unpublishAllLocales is only valid when the resolved action is "unpublish".') + }) + + it('should reject combining both locale modifiers', () => { + expect(() => + draftOps({ + action: 'publish', + operation: 'update', + publishAllLocales: true, + unpublishAllLocales: true, + }), + ).toThrow('publishAllLocales and unpublishAllLocales cannot both be true.') + }) + + it('should not infer all-locale unpublish from status', () => { + expect(() => + draftOps({ + operation: 'update', + status: 'draft', + unpublishAllLocales: true, + }), + ).toThrow('unpublishAllLocales is only valid when the resolved action is "unpublish".') + }) + }) +}) + +describe('statusFromAction', () => { + it.each([ + ['saveDraft', 'draft'], + ['publish', 'published'], + ['unpublish', 'draft'], + [undefined, undefined], + ] as const)('%s derives status %s', (action, expected) => { + expect(statusFromAction({ action })).toBe(expected) + }) +}) + +describe('canonicalizeWriteStatus', () => { + it('should not mutate the caller data object', () => { + const data = { + _status: 'published' as const, + title: 'Hello', + } + + const result = canonicalizeWriteStatus({ + action: 'saveDraft', + data, + }) + + expect(data._status).toBe('published') + expect(result).not.toBe(data) + expect(result._status).toBe('draft') + expect(result.title).toBe('Hello') + }) + + it('should not mutate nested localized status objects', () => { + const data = { + _status: { + en: 'published', + es: 'draft', + }, + } + + const result = canonicalizeWriteStatus({ + action: 'saveDraft', + data, + locale: 'en', + }) + + expect(data._status.en).toBe('published') + expect(result._status).toEqual({ + en: 'draft', + es: 'draft', + }) + expect(result._status).not.toBe(data._status) + }) + + it('should replace conflicting caller status with the action-derived status', () => { + expect( + canonicalizeWriteStatus({ + action: 'publish', + data: { _status: 'draft', title: 'Post' }, + })._status, + ).toBe('published') + + expect( + canonicalizeWriteStatus({ + action: 'unpublish', + data: { _status: 'published' }, + })._status, + ).toBe('draft') + }) + + it('should return the original data when no publication transition occurred', () => { + const data = { title: 'Page' } + + expect( + canonicalizeWriteStatus({ + action: undefined, + data, + }), + ).toBe(data) + }) + + it('should write all locale keys when publishing or unpublishing all locales', () => { + const data = { + _status: { + en: 'draft', + es: 'draft', + }, + } + + expect( + canonicalizeWriteStatus({ + action: 'publish', + data, + publishAllLocales: true, + })._status, + ).toEqual({ + en: 'published', + es: 'published', + }) + + expect( + canonicalizeWriteStatus({ + action: 'unpublish', + data, + unpublishAllLocales: true, + })._status, + ).toEqual({ + en: 'draft', + es: 'draft', + }) + }) + + it('should write all locale keys when locale is all', () => { + expect( + canonicalizeWriteStatus({ + action: 'publish', + data: { + _status: { + en: 'draft', + es: 'draft', + }, + }, + locale: 'all', + })._status, + ).toEqual({ + en: 'published', + es: 'published', + }) + }) +}) diff --git a/packages/payload/src/versions/actions/resolveAction.ts b/packages/payload/src/versions/actions/resolveAction.ts new file mode 100644 index 00000000000..9e29c9fead0 --- /dev/null +++ b/packages/payload/src/versions/actions/resolveAction.ts @@ -0,0 +1,380 @@ +import { status as httpStatus } from 'http-status' + +import type { JsonObject } from '../../types/index.js' +import type { + CanonicalizeWriteStatusArgs, + CreateAction, + ResolveActionArgs, + RestoreAction, + UpdateAction, + WriteAction, + WriteOperation, +} from './types.js' + +import { APIError } from '../../errors/APIError.js' +import { deepCopyObjectSimple } from '../../utilities/deepCopyObject.js' + +type DocumentStatus = 'draft' | 'published' + +type ActionForOperation = { + create: CreateAction + duplicate: CreateAction + restore: RestoreAction + update: UpdateAction +} + +type OperationPolicies = { + [TOperation in WriteOperation]: { + defaultAction: ActionForOperation[TOperation] + validActions: readonly ActionForOperation[TOperation][] + } +} + +const operationPolicies = { + create: { + defaultAction: 'saveDraft', + validActions: ['saveDraft', 'publish'], + }, + duplicate: { + defaultAction: 'saveDraft', + validActions: ['saveDraft', 'publish'], + }, + restore: { + defaultAction: 'publish', + validActions: ['saveDraft', 'publish'], + }, + update: { + defaultAction: 'publish', + validActions: ['saveDraft', 'publish', 'unpublish'], + }, +} as const satisfies OperationPolicies + +/** + * Resolves the effective write action from explicit `action`, recognized `_status`, and the + * operation default. Returns `undefined` for ordinary writes on entities without drafts. + * + * Modifier flags (`autosave`, `publishAllLocales`, `unpublishAllLocales`) are validated against + * the resolved action so collection and global operations share one contract. + */ +export function resolveAction({ + action: requestedAction, + autosave, + draftsEnabled, + locale, + localizedStatusEnabled, + operation, + publishAllLocales, + status, + unpublishAllLocales, +}: ResolveActionArgs): CreateAction | RestoreAction | undefined | UpdateAction { + const explicitAction = parseExplicitAction({ action: requestedAction, operation }) + + if (!draftsEnabled) { + return resolveNonDraftAction({ + action: explicitAction, + autosave, + unpublishAllLocales, + }) + } + + validateAllLocaleTransition({ + explicitAction, + locale, + localizedStatusEnabled, + operation, + publishAllLocales, + status, + unpublishAllLocales, + }) + + const resolvedAction = + explicitAction ?? + inferActionFromStatus({ locale, status }) ?? + operationPolicies[operation].defaultAction + + validateModifiers({ + action: resolvedAction, + autosave, + publishAllLocales, + unpublishAllLocales, + }) + + return resolvedAction +} + +/** + * Writes the status required by a resolved action onto a core-owned copy of write data. + * Does not mutate the caller's object. When `action` is `undefined`, the original data is returned. + */ +export function canonicalizeWriteStatus({ + action, + data, + locale, + publishAllLocales, + unpublishAllLocales, +}: CanonicalizeWriteStatusArgs): T { + const nextStatus = statusFromAction({ action }) + + if (nextStatus === undefined) { + return data + } + + const nextData = deepCopyObjectSimple(data as JsonObject) as T + const currentStatus = getDataStatus(data) + + if (isLocalizedStatus(currentStatus)) { + const localizedStatus = { ...currentStatus } + + if (publishAllLocales || unpublishAllLocales || locale === 'all') { + for (const localeCode of Object.keys(localizedStatus)) { + localizedStatus[localeCode] = nextStatus + } + } else if (locale) { + localizedStatus[locale] = nextStatus + } else { + ;(nextData as JsonObject)._status = nextStatus + return nextData + } + + ;(nextData as JsonObject)._status = localizedStatus + return nextData + } + + ;(nextData as JsonObject)._status = nextStatus + return nextData +} + +export function statusFromAction({ + action, +}: { + action: undefined | WriteAction +}): DocumentStatus | undefined { + if (action === undefined) { + return undefined + } + + switch (action) { + case 'publish': + return 'published' + case 'saveDraft': + case 'unpublish': + return 'draft' + default: { + const exhaustive: never = action + return exhaustive + } + } +} + +function validateAllLocaleTransition({ + explicitAction, + locale, + localizedStatusEnabled, + operation, + publishAllLocales, + status, + unpublishAllLocales, +}: { + explicitAction: undefined | WriteAction + locale?: null | string + localizedStatusEnabled?: boolean + operation: WriteOperation + publishAllLocales?: boolean + status: unknown + unpublishAllLocales?: boolean +}): void { + if ( + locale !== 'all' || + operation === 'restore' || + (!localizedStatusEnabled && !isLocalizedStatus(status)) || + explicitAction === 'saveDraft' + ) { + return + } + + if (explicitAction === 'unpublish') { + if (unpublishAllLocales) { + return + } + + throw new APIError( + 'Unpublishing all locales requires an explicit "unpublish" action and unpublishAllLocales: true.', + httpStatus.BAD_REQUEST, + ) + } + + if (explicitAction === 'publish' && publishAllLocales) { + return + } + + throw new APIError( + 'Publishing all locales requires an explicit "publish" action and publishAllLocales: true.', + httpStatus.BAD_REQUEST, + ) +} + +function parseExplicitAction({ + action, + operation, +}: { + action: unknown + operation: WriteOperation +}): undefined | WriteAction { + if (action === undefined || action === null) { + return undefined + } + + if (typeof action !== 'string') { + throw invalidAction({ action, operation }) + } + + const isValidAction = operationPolicies[operation].validActions.some( + (validAction) => validAction === action, + ) + + if (!isValidAction) { + throw invalidAction({ action, operation }) + } + + return action as WriteAction +} + +function resolveNonDraftAction({ + action, + autosave, + unpublishAllLocales, +}: { + action: undefined | WriteAction + autosave?: boolean + unpublishAllLocales?: boolean +}): undefined { + if (action === 'saveDraft' || action === 'unpublish') { + throw new APIError( + `The action "${action}" cannot be used because drafts are not enabled.`, + httpStatus.BAD_REQUEST, + ) + } + + if (autosave) { + throw new APIError( + 'autosave is only valid when the resolved action is "saveDraft".', + httpStatus.BAD_REQUEST, + ) + } + + if (unpublishAllLocales) { + throw new APIError( + 'unpublishAllLocales is only valid when the resolved action is "unpublish".', + httpStatus.BAD_REQUEST, + ) + } + + return undefined +} + +function inferActionFromStatus({ + locale, + status, +}: { + locale?: null | string + status: unknown +}): 'publish' | 'saveDraft' | undefined { + const recognized = recognizedStatus({ locale, status }) + + switch (recognized) { + case 'draft': + return 'saveDraft' + case 'published': + return 'publish' + default: + return undefined + } +} + +function recognizedStatus({ + locale, + status, +}: { + locale?: null | string + status: unknown +}): DocumentStatus | undefined { + if (status === 'draft' || status === 'published') { + return status + } + + if (!isLocalizedStatus(status) || !locale || locale === 'all') { + return undefined + } + + const localeStatus = status[locale] + if (localeStatus === 'draft' || localeStatus === 'published') { + return localeStatus + } + + return undefined +} + +function validateModifiers({ + action, + autosave, + publishAllLocales, + unpublishAllLocales, +}: { + action: WriteAction + autosave?: boolean + publishAllLocales?: boolean + unpublishAllLocales?: boolean +}): void { + if (publishAllLocales && unpublishAllLocales) { + throw new APIError( + 'publishAllLocales and unpublishAllLocales cannot both be true.', + httpStatus.BAD_REQUEST, + ) + } + + if (autosave && action !== 'saveDraft') { + throw new APIError( + 'autosave is only valid when the resolved action is "saveDraft".', + httpStatus.BAD_REQUEST, + ) + } + + if (publishAllLocales && action !== 'publish') { + throw new APIError( + 'publishAllLocales is only valid when the resolved action is "publish".', + httpStatus.BAD_REQUEST, + ) + } + + if (unpublishAllLocales && action !== 'unpublish') { + throw new APIError( + 'unpublishAllLocales is only valid when the resolved action is "unpublish".', + httpStatus.BAD_REQUEST, + ) + } +} + +function invalidAction({ + action, + operation, +}: { + action: unknown + operation: WriteOperation +}): APIError { + return new APIError( + `Invalid action ${JSON.stringify(action)}. Valid actions for ${operation} are: ${operationPolicies[operation].validActions.join(', ')}.`, + httpStatus.BAD_REQUEST, + ) +} + +function isLocalizedStatus(status: unknown): status is Record { + return typeof status === 'object' && status !== null && !Array.isArray(status) +} + +function getDataStatus(data: object): unknown { + if ('_status' in data) { + return data._status + } + + return undefined +} diff --git a/packages/payload/src/versions/actions/types.ts b/packages/payload/src/versions/actions/types.ts new file mode 100644 index 00000000000..8aa504e0ecd --- /dev/null +++ b/packages/payload/src/versions/actions/types.ts @@ -0,0 +1,29 @@ +export type CreateAction = 'publish' | 'saveDraft' + +export type UpdateAction = 'publish' | 'saveDraft' | 'unpublish' + +export type RestoreAction = 'publish' | 'saveDraft' + +export type WriteAction = CreateAction | RestoreAction | UpdateAction + +export type WriteOperation = 'create' | 'duplicate' | 'restore' | 'update' + +export type ResolveActionArgs = { + action?: unknown + autosave?: boolean + draftsEnabled: boolean + locale?: null | string + localizedStatusEnabled?: boolean + operation: WriteOperation + publishAllLocales?: boolean + status?: unknown + unpublishAllLocales?: boolean +} + +export type CanonicalizeWriteStatusArgs = { + action: undefined | WriteAction + data: T + locale?: null | string + publishAllLocales?: boolean + unpublishAllLocales?: boolean +} diff --git a/packages/payload/src/versions/buildSingleLocalePublishData.ts b/packages/payload/src/versions/buildSingleLocalePublishData.ts index a5fbfd9da61..d3f883762c3 100644 --- a/packages/payload/src/versions/buildSingleLocalePublishData.ts +++ b/packages/payload/src/versions/buildSingleLocalePublishData.ts @@ -45,7 +45,7 @@ export function buildLocalizedPublishData({ // Only carry forward locales that were previously published. The main doc can contain // stale draft-locale data written by the initial create (which always inserts into the - // main collection, even when draft:true). Restricting to published locales prevents that + // main collection, even for saveDraft). Restricting to published locales prevents that // data from leaking into the published doc. const previouslyPublishedLocales = Object.entries(currentDocStatus) .filter(([, status]) => status === 'published') diff --git a/packages/payload/src/versions/read/getDraftStatusWhere.ts b/packages/payload/src/versions/read/getDraftStatusWhere.ts new file mode 100644 index 00000000000..7834dae0bcc --- /dev/null +++ b/packages/payload/src/versions/read/getDraftStatusWhere.ts @@ -0,0 +1,46 @@ +import type { SanitizedCollectionConfig } from '../../collections/config/types.js' +import type { SanitizedGlobalConfig } from '../../globals/config/types.js' +import type { Payload } from '../../index.js' +import type { Where } from '../../types/index.js' + +import { hasLocalizeStatusEnabled } from '../../utilities/getVersionsConfig.js' + +export type GetDraftStatusWhereArgs = { + entity: SanitizedCollectionConfig | SanitizedGlobalConfig + locale?: string + payload: Payload +} + +/** + * Constraint that selects version records whose latest status is draft. + * Used for draft-only list reads and for `replaceWithVersion` lookups. + */ +export function getDraftStatusWhere({ entity, locale, payload }: GetDraftStatusWhereArgs): Where { + if (hasLocalizeStatusEnabled(entity)) { + if (locale === 'all') { + return { + or: ((payload.config.localization && payload.config.localization.localeCodes) || []).map( + (localeCode) => ({ + [`version._status.${localeCode}`]: { + equals: 'draft', + }, + }), + ), + } + } + + if (locale) { + return { + [`version._status.${locale}`]: { + equals: 'draft', + }, + } + } + } + + return { + 'version._status': { + equals: 'draft', + }, + } +} diff --git a/packages/payload/src/versions/read/getPublishedStatusWhere.ts b/packages/payload/src/versions/read/getPublishedStatusWhere.ts new file mode 100644 index 00000000000..a406fb0ca97 --- /dev/null +++ b/packages/payload/src/versions/read/getPublishedStatusWhere.ts @@ -0,0 +1,62 @@ +import type { SanitizedCollectionConfig } from '../../collections/config/types.js' +import type { SanitizedGlobalConfig } from '../../globals/config/types.js' +import type { Payload } from '../../index.js' +import type { Where } from '../../types/index.js' + +import { hasLocalizeStatusEnabled } from '../../utilities/getVersionsConfig.js' + +export type GetPublishedStatusWhereArgs = { + entity: SanitizedCollectionConfig | SanitizedGlobalConfig + locale?: string + payload: Payload +} + +/** + * Constraint that selects published main documents. Rows written before `_status` existed are + * treated as published for backwards compatibility. + */ +export function getPublishedStatusWhere({ + entity, + locale, + payload, +}: GetPublishedStatusWhereArgs): Where { + const publishedConditions: Where[] = [] + + if (hasLocalizeStatusEnabled(entity)) { + if (locale === 'all') { + const localeCodes = payload.config.localization ? payload.config.localization.localeCodes : [] + + for (const localeCode of localeCodes) { + publishedConditions.push({ + [`_status.${localeCode}`]: { + equals: 'published', + }, + }) + } + } else if (locale) { + publishedConditions.push({ + [`_status.${locale}`]: { + equals: 'published', + }, + }) + } + } + + if (publishedConditions.length === 0) { + publishedConditions.push({ + _status: { + equals: 'published', + }, + }) + } + + publishedConditions.push({ + _status: { + exists: false, + }, + }) + + return { + or: publishedConditions, + } +} diff --git a/packages/payload/src/versions/read/replaceWithVersion.spec.ts b/packages/payload/src/versions/read/replaceWithVersion.spec.ts new file mode 100644 index 00000000000..030d9e73c68 --- /dev/null +++ b/packages/payload/src/versions/read/replaceWithVersion.spec.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest' + +import { applyReplacePolicy } from './replaceWithVersion.js' +import { getDraftStatusWhere } from './getDraftStatusWhere.js' + +describe('applyReplacePolicy', () => { + const published = { id: '1', title: 'Published' } + const draft = { id: '1', title: 'Draft' } + + it('should return the draft when one exists for latest and draft policies', () => { + expect( + applyReplacePolicy({ + draftVersion: draft, + fallbackDoc: published, + policy: 'latest', + }), + ).toBe(draft) + + expect( + applyReplacePolicy({ + draftVersion: draft, + fallbackDoc: published, + policy: 'draft', + }), + ).toBe(draft) + }) + + it('should fall back to published content for latest when no draft exists', () => { + expect( + applyReplacePolicy({ + draftVersion: undefined, + fallbackDoc: published, + policy: 'latest', + }), + ).toBe(published) + }) + + it('should return no result for draft-only when no draft exists', () => { + expect( + applyReplacePolicy({ + draftVersion: undefined, + fallbackDoc: published, + policy: 'draft', + }), + ).toBeNull() + }) + + it('should return a draft main row when no draft version row exists', () => { + expect( + applyReplacePolicy({ + draftVersion: undefined, + fallbackDoc: draft, + fallbackIsDraft: true, + policy: 'draft', + }), + ).toBe(draft) + }) +}) + +describe('getDraftStatusWhere', () => { + const payload = { + config: { + localization: { + localeCodes: ['en', 'es'], + }, + }, + } as Parameters[0]['payload'] + + const collection = { + versions: { + drafts: true, + }, + } as Parameters[0]['entity'] + + const localizedCollection = { + versions: { + drafts: { + localizeStatus: true, + }, + }, + } as Parameters[0]['entity'] + + it('should constrain scalar status to draft', () => { + expect(getDraftStatusWhere({ entity: collection, payload })).toEqual({ + 'version._status': { + equals: 'draft', + }, + }) + }) + + it('should use the active locale for localized status', () => { + expect( + getDraftStatusWhere({ + entity: localizedCollection, + locale: 'es', + payload, + }), + ).toEqual({ + 'version._status.es': { + equals: 'draft', + }, + }) + }) + + it('should match any locale when locale is all', () => { + expect( + getDraftStatusWhere({ + entity: localizedCollection, + locale: 'all', + payload, + }), + ).toEqual({ + or: [ + { 'version._status.en': { equals: 'draft' } }, + { 'version._status.es': { equals: 'draft' } }, + ], + }) + }) +}) diff --git a/packages/payload/src/versions/drafts/replaceWithDraftIfAvailable.ts b/packages/payload/src/versions/read/replaceWithVersion.ts similarity index 51% rename from packages/payload/src/versions/drafts/replaceWithDraftIfAvailable.ts rename to packages/payload/src/versions/read/replaceWithVersion.ts index 68b25b91415..7a334dfb6b8 100644 --- a/packages/payload/src/versions/drafts/replaceWithDraftIfAvailable.ts +++ b/packages/payload/src/versions/read/replaceWithVersion.ts @@ -1,4 +1,3 @@ -// @ts-strict-ignore import type { SanitizedCollectionConfig, TypeWithID } from '../../collections/config/types.js' import type { AccessResult } from '../../config/types.js' import type { FindGlobalVersionsArgs, FindVersionsArgs } from '../../database/types.js' @@ -8,68 +7,74 @@ import type { PayloadRequest, SelectType, Where } from '../../types/index.js' import { hasWhereAccessResult } from '../../auth/index.js' import { combineQueries } from '../../database/combineQueries.js' import { docHasTimestamps } from '../../types/index.js' -import { hasLocalizeStatusEnabled } from '../../utilities/getVersionsConfig.js' import { sanitizeInternalFields } from '../../utilities/sanitizeInternalFields.js' -import { appendVersionToQueryKey } from './appendVersionToQueryKey.js' -import { getQueryDraftsSelect } from './getQueryDraftsSelect.js' +import { appendVersionToQueryKey } from '../drafts/appendVersionToQueryKey.js' +import { getQueryDraftsSelect } from '../drafts/getQueryDraftsSelect.js' +import { getDraftStatusWhere } from './getDraftStatusWhere.js' + +export type ReplaceWithVersionPolicy = 'draft' | 'latest' type Arguments = { accessResult: AccessResult doc: T entity: SanitizedCollectionConfig | SanitizedGlobalConfig entityType: 'collection' | 'global' + fallbackDoc?: null | T overrideAccess: boolean + policy: ReplaceWithVersionPolicy req: PayloadRequest select?: SelectType + where?: Where } -export const replaceWithDraftIfAvailable = async ({ +/** + * Chooses between a found draft version and the published document. + * `latest` falls back to published content. `draft` returns no result when no draft exists. + */ +export function applyReplacePolicy({ + draftVersion, + fallbackDoc, + fallbackIsDraft = false, + policy, +}: { + draftVersion: T | undefined + fallbackDoc: null | T + fallbackIsDraft?: boolean + policy: ReplaceWithVersionPolicy +}): null | T { + if (draftVersion) { + return draftVersion + } + + if (fallbackDoc && (policy === 'latest' || fallbackIsDraft)) { + return fallbackDoc + } + + return null +} + +/** + * Replaces a published document with its newest draft when one exists. + * + * - `latest`: newest saved draft, otherwise the published document + * - `draft`: newest draft only, with no published fallback + */ +export const replaceWithVersion = async ({ accessResult, doc, entity, entityType, + fallbackDoc: fallbackDocArg, + policy, req, select, -}: Arguments): Promise => { + where, +}: Arguments): Promise => { const { locale, payload } = req + const fallbackDoc = fallbackDocArg === undefined ? doc : fallbackDocArg - let queryToBuild: Where = { - and: [ - { - 'version._status': { - equals: 'draft', - }, - }, - ], - } - - if (hasLocalizeStatusEnabled(entity)) { - if (locale === 'all') { - queryToBuild = { - and: [ - { - or: ( - (payload.config.localization && payload.config.localization.localeCodes) || - [] - ).map((localeCode) => ({ - [`version._status.${localeCode}`]: { - equals: 'draft', - }, - })), - }, - ], - } - } else if (locale) { - queryToBuild = { - and: [ - { - [`version._status.${locale}`]: { - equals: 'draft', - }, - }, - ], - } - } + const queryToBuild: Where = { + and: [getDraftStatusWhere({ entity, locale: locale ?? undefined, payload })], } if (entityType === 'collection') { @@ -103,6 +108,12 @@ export const replaceWithDraftIfAvailable = async ({ versionAccessResult = appendVersionToQueryKey(accessResult) } + let versionWhere = combineQueries(queryToBuild, versionAccessResult!) + + if (where) { + versionWhere = combineQueries(versionWhere, appendVersionToQueryKey(where)) + } + const findVersionsArgs: FindGlobalVersionsArgs & FindVersionsArgs = { collection: entity.slug, global: entity.slug, @@ -112,7 +123,7 @@ export const replaceWithDraftIfAvailable = async ({ req, select: getQueryDraftsSelect({ select }), sort: '-updatedAt', - where: combineQueries(queryToBuild, versionAccessResult!), + where: versionWhere, } let versionDocs @@ -125,27 +136,39 @@ export const replaceWithDraftIfAvailable = async ({ let draft = versionDocs[0] if (!draft) { - return doc + const fallbackStatus = (fallbackDoc as null | Record)?._status + const fallbackIsDraft = + fallbackStatus === 'draft' || + (fallbackStatus !== null && + typeof fallbackStatus === 'object' && + (locale === 'all' || locale === '*' || !locale + ? Object.values(fallbackStatus).some((status) => status === 'draft') + : (fallbackStatus as Record)[locale] === 'draft')) + + return applyReplacePolicy({ + draftVersion: undefined, + fallbackDoc, + fallbackIsDraft, + policy, + }) } draft = sanitizeInternalFields(draft) - // Patch globalType onto version doc if (entityType === 'global' && 'globalType' in doc) { // @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve draft.version.globalType = doc.globalType } - // handle when .version wasn't selected due to projection if (!draft.version) { draft.version = {} as T } - // Disregard all other draft content at this point, - // Only interested in the version itself. - // Operations will handle firing hooks, etc. - draft.version.id = doc.id - return draft.version + return applyReplacePolicy({ + draftVersion: draft.version, + fallbackDoc, + policy, + }) } diff --git a/packages/payload/src/versions/resolveReadVersion.spec.ts b/packages/payload/src/versions/resolveReadVersion.spec.ts new file mode 100644 index 00000000000..aba0aac8a42 --- /dev/null +++ b/packages/payload/src/versions/resolveReadVersion.spec.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' + +import { APIError } from '../errors/APIError.js' +import { resolveReadVersion } from './resolveReadVersion.js' + +describe('resolveReadVersion', () => { + it('should default omission to published', () => { + expect(resolveReadVersion({ draftsEnabled: true })).toBe('published') + expect(resolveReadVersion({ draftsEnabled: false })).toBe('published') + expect(resolveReadVersion({ draftsEnabled: true, version: null })).toBe('published') + }) + + it('should preserve published, latest, and draft on draft-enabled entities', () => { + expect(resolveReadVersion({ draftsEnabled: true, version: 'published' })).toBe('published') + expect(resolveReadVersion({ draftsEnabled: true, version: 'latest' })).toBe('latest') + expect(resolveReadVersion({ draftsEnabled: true, version: 'draft' })).toBe('draft') + }) + + it('should map latest to published on entities without drafts', () => { + expect(resolveReadVersion({ draftsEnabled: false, version: 'latest' })).toBe('published') + expect(resolveReadVersion({ draftsEnabled: false, version: 'published' })).toBe('published') + }) + + it('should keep draft as draft-only even on entities without drafts', () => { + expect(resolveReadVersion({ draftsEnabled: false, version: 'draft' })).toBe('draft') + }) + + it('should reject invalid runtime strings', () => { + expect(() => resolveReadVersion({ draftsEnabled: true, version: 'Published' })).toThrow( + APIError, + ) + expect(() => resolveReadVersion({ draftsEnabled: true, version: 'Published' })).toThrow( + 'Invalid version "Published". Valid values are: published, latest, draft.', + ) + }) + + it('should reject leftover boolean draft intent', () => { + expect(() => resolveReadVersion({ draftsEnabled: true, version: true })).toThrow( + 'Invalid version true. Valid values are: published, latest, draft.', + ) + }) +}) diff --git a/packages/payload/src/versions/resolveReadVersion.ts b/packages/payload/src/versions/resolveReadVersion.ts new file mode 100644 index 00000000000..12fa966e410 --- /dev/null +++ b/packages/payload/src/versions/resolveReadVersion.ts @@ -0,0 +1,62 @@ +import { status as httpStatus } from 'http-status' + +import type { ReadVersion } from './types.js' + +import { APIError } from '../errors/APIError.js' + +export type ResolveReadVersionArgs = { + draftsEnabled: boolean + version?: unknown +} + +/** + * Normalizes a public read `version` value. + * + * Omission becomes `published`. On entities without drafts, `latest` maps to `published` + * while `draft` stays draft-only so the operation can return no result. + */ +export function resolveReadVersion({ + draftsEnabled, + version, +}: ResolveReadVersionArgs): ReadVersion { + if (version === undefined || version === null) { + return 'published' + } + + const parsed = parseReadVersion(version) + + if (!draftsEnabled && parsed === 'latest') { + return 'published' + } + + return parsed +} + +/** + * True when the read should come from version storage rather than published main documents. + */ +export function isVersionedRead({ version }: { version: ReadVersion }): boolean { + return version === 'draft' || version === 'latest' +} + +function parseReadVersion(version: unknown): ReadVersion { + if (typeof version === 'string') { + switch (version) { + case 'draft': + case 'latest': + case 'published': + return version + default: + throw invalidReadVersion(version) + } + } + + throw invalidReadVersion(version) +} + +function invalidReadVersion(version: unknown): APIError { + return new APIError( + `Invalid version ${JSON.stringify(version)}. Valid values are: published, latest, draft.`, + httpStatus.BAD_REQUEST, + ) +} diff --git a/packages/payload/src/versions/saveVersion.ts b/packages/payload/src/versions/saveVersion.ts index eadc0fa29e9..0eca931f6f5 100644 --- a/packages/payload/src/versions/saveVersion.ts +++ b/packages/payload/src/versions/saveVersion.ts @@ -14,6 +14,9 @@ type Args = { autosave?: boolean collection?: SanitizedCollectionConfig docWithLocales: T + /** + * Derived storage flag used when persisting a draft version row. + */ draft?: boolean global?: SanitizedGlobalConfig id?: number | string diff --git a/packages/payload/src/versions/schedule/job.ts b/packages/payload/src/versions/schedule/job.ts index 526f0d3db49..e4a26a21892 100644 --- a/packages/payload/src/versions/schedule/job.ts +++ b/packages/payload/src/versions/schedule/job.ts @@ -43,6 +43,7 @@ export const getSchedulePublishTask = ({ await req.payload.update({ id, + action: _status === 'published' ? 'publish' : 'unpublish', collection: input.doc.relationTo, data: { _status, @@ -57,6 +58,7 @@ export const getSchedulePublishTask = ({ if (input.global) { await req.payload.updateGlobal({ slug: input.global, + action: _status === 'published' ? 'publish' : 'unpublish', data: { _status, }, diff --git a/packages/payload/src/versions/types.ts b/packages/payload/src/versions/types.ts index ab6cc5f9ff4..4f767a99800 100644 --- a/packages/payload/src/versions/types.ts +++ b/packages/payload/src/versions/types.ts @@ -1,3 +1,12 @@ +/** + * Selects which representation of a document to read. + * + * - `published` (default): published content from main document storage + * - `latest`: newest saved draft when one exists, otherwise published content + * - `draft`: newest draft only, with no published fallback + */ +export type ReadVersion = 'draft' | 'latest' | 'published' + export type Autosave = { /** * Define an `interval` in milliseconds to automatically save progress while documents are edited. diff --git a/packages/plugin-cloud-storage/src/hooks/afterChange.ts b/packages/plugin-cloud-storage/src/hooks/afterChange.ts index 344cae49dab..b3fe25a495a 100644 --- a/packages/plugin-cloud-storage/src/hooks/afterChange.ts +++ b/packages/plugin-cloud-storage/src/hooks/afterChange.ts @@ -11,9 +11,14 @@ interface Args { collection: CollectionConfig } +type CloudStorageDocument = { + _status?: 'draft' | 'published' | Record +} & FileData & + TypeWithID + export const getAfterChangeHook = - ({ adapter, collection }: Args): CollectionAfterChangeHook => - async ({ data, doc, operation, previousDoc, req, select }) => { + ({ adapter, collection }: Args): CollectionAfterChangeHook => + async ({ action, data, doc, operation, previousDoc, req, select }) => { // Skip if this is an internal update to prevent infinite loop if (req.context?.skipCloudStorage) { return doc @@ -21,9 +26,10 @@ export const getAfterChangeHook = // Restore upload metadata removed by select, including partially selected image sizes. const uploadData = select ? deepMergeWithSourceArrays(data, doc) : doc - const isDraftSave = (uploadData as { _status?: string })._status === 'draft' + const isDraftSave = action === 'saveDraft' const isDraftOverPublished = isDraftSave && (previousDoc as { _status?: string } | undefined)?._status === 'published' + const metadataAction = action === 'saveDraft' || action === 'unpublish' ? action : 'publish' try { const files = getIncomingFiles({ data: uploadData, req }) @@ -67,16 +73,22 @@ export const getAfterChangeHook = try { const updatedDoc = await req.payload.update({ id: doc.id, + action: metadataAction, collection: collection.slug, data: uploadMetadata, depth: 0, - draft: isDraftSave, req, select, }) // Persist all adapter metadata, but do not add unselected fields to the response. - docWithMetadata = select ? { ...doc, ...updatedDoc } : { ...doc, ...uploadMetadata } + docWithMetadata = select + ? { ...doc, ...updatedDoc } + : { + ...doc, + ...uploadMetadata, + ...(doc._status !== undefined ? { _status: doc._status } : {}), + } } finally { delete req.context.skipCloudStorage } diff --git a/packages/plugin-cloud-storage/src/utilities/getFilePrefix.ts b/packages/plugin-cloud-storage/src/utilities/getFilePrefix.ts index 0abf54aac01..bb8e426bb75 100644 --- a/packages/plugin-cloud-storage/src/utilities/getFilePrefix.ts +++ b/packages/plugin-cloud-storage/src/utilities/getFilePrefix.ts @@ -45,9 +45,9 @@ export async function getFilePrefix({ const files = await req.payload.find({ collection: collection.slug, depth: 0, - draft: true, limit: 1, pagination: false, + version: 'latest', where: { or: [ { diff --git a/packages/plugin-ecommerce/src/ui/VariantOptionsSelector/index.tsx b/packages/plugin-ecommerce/src/ui/VariantOptionsSelector/index.tsx index a2b81d05554..1774b8e456e 100644 --- a/packages/plugin-ecommerce/src/ui/VariantOptionsSelector/index.tsx +++ b/packages/plugin-ecommerce/src/ui/VariantOptionsSelector/index.tsx @@ -20,12 +20,12 @@ export const VariantOptionsSelector: React.FC = async (props) => { id: data.product, collection: productsSlug, depth: 0, - draft: true, select: { variants: true, variantTypes: true, }, user, + version: 'latest', }) // @ts-expect-error - TODO: Fix types diff --git a/packages/plugin-import-export/src/export/batchProcessor.ts b/packages/plugin-import-export/src/export/batchProcessor.ts index be822483e7f..4b31846f4c7 100644 --- a/packages/plugin-import-export/src/export/batchProcessor.ts +++ b/packages/plugin-import-export/src/export/batchProcessor.ts @@ -2,7 +2,7 @@ * Export-specific batch processor for processing documents in batches during export. * Uses the generic batch processing utilities from useBatchProcessor. */ -import type { PayloadRequest, SelectType, Sort, User, Where } from 'payload' +import type { PayloadRequest, ReadVersion, SelectType, Sort, User, Where } from 'payload' import type { ExportAfterHook, ExportBeforeHook } from '../types.js' @@ -21,7 +21,6 @@ export interface ExportBatchProcessorOptions extends BatchProcessorOptions { export interface ExportFindArgs { collection: string depth: number - draft: boolean limit: number locale?: string overrideAccess: boolean @@ -29,6 +28,7 @@ export interface ExportFindArgs { select?: SelectType sort?: Sort user?: User + version?: ReadVersion where?: Where } @@ -103,7 +103,7 @@ export interface ExportResult { * * const result = await processor.processExport({ * collectionSlug: 'posts', - * findArgs: { collection: 'posts', depth: 1, draft: false, limit: 100, overrideAccess: false }, + * findArgs: { collection: 'posts', depth: 1, limit: 100, overrideAccess: false, version: 'published' }, * format: 'csv', * maxDocs: 1000, * req, diff --git a/packages/plugin-import-export/src/export/createExport.ts b/packages/plugin-import-export/src/export/createExport.ts index e4b3336ebe4..1c4bb1795f3 100644 --- a/packages/plugin-import-export/src/export/createExport.ts +++ b/packages/plugin-import-export/src/export/createExport.ts @@ -164,6 +164,7 @@ export const createExport = async (args: CreateExportArgs) => { user, locale, overrideAccess: false, + where, }) totalDocs = countResult.totalDocs } catch (error) { @@ -185,7 +186,6 @@ export const createExport = async (args: CreateExportArgs) => { const findArgs = { collection: collectionSlug, depth: 1, - draft, limit: batchSize, locale, overrideAccess: false, @@ -193,6 +193,7 @@ export const createExport = async (args: CreateExportArgs) => { select, sort, user, + version: draft ? ('latest' as const) : ('published' as const), where, } diff --git a/packages/plugin-import-export/src/export/handlePreview.ts b/packages/plugin-import-export/src/export/handlePreview.ts index a17e68e362b..13efdd69b26 100644 --- a/packages/plugin-import-export/src/export/handlePreview.ts +++ b/packages/plugin-import-export/src/export/handlePreview.ts @@ -176,7 +176,6 @@ export const handlePreview = async (req: PayloadRequest): Promise => { const result = await req.payload.find({ collection: collectionSlug, depth: 1, - draft, limit: previewLimit, locale, overrideAccess: false, @@ -184,6 +183,7 @@ export const handlePreview = async (req: PayloadRequest): Promise => { req, select, sort, + version: draft ? 'latest' : 'published', where, }) diff --git a/packages/plugin-import-export/src/import/batchProcessor.spec.ts b/packages/plugin-import-export/src/import/batchProcessor.spec.ts new file mode 100644 index 00000000000..3595d096564 --- /dev/null +++ b/packages/plugin-import-export/src/import/batchProcessor.spec.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' + +import { extractMultiLocaleData, resolveImportWriteAction } from './batchProcessor.js' + +describe('localized import publication actions', () => { + it('should resolve each locale from its own localized status', () => { + const { flatData, localeUpdates } = extractMultiLocaleData({ + configuredLocales: ['en', 'es'], + data: { + _status: { + en: 'published', + es: 'draft', + }, + localized: { + en: 'Published English', + es: 'Draft Spanish', + }, + }, + defaultLocale: 'en', + }) + + expect(flatData._status).toBe('published') + expect(localeUpdates.es?._status).toBe('draft') + expect( + resolveImportWriteAction({ collectionHasVersions: true, status: flatData._status }), + ).toBe('publish') + expect( + resolveImportWriteAction({ + collectionHasVersions: true, + status: localeUpdates.es?._status, + }), + ).toBe('saveDraft') + }) + + it('should not collapse a localized status object into a single publish action', () => { + expect( + resolveImportWriteAction({ + collectionHasVersions: true, + status: { en: 'published', es: 'draft' }, + }), + ).toBeUndefined() + }) +}) diff --git a/packages/plugin-import-export/src/import/batchProcessor.ts b/packages/plugin-import-export/src/import/batchProcessor.ts index c2ca76f6b80..72694a1039c 100644 --- a/packages/plugin-import-export/src/import/batchProcessor.ts +++ b/packages/plugin-import-export/src/import/batchProcessor.ts @@ -12,6 +12,28 @@ import { extractErrorMessage, } from '../utilities/useBatchProcessor.js' +export const resolveImportWriteAction = ({ + collectionHasVersions, + status, +}: { + collectionHasVersions: boolean + status: unknown +}): 'publish' | 'saveDraft' | undefined => { + if (!collectionHasVersions) { + return undefined + } + + if (status === 'draft') { + return 'saveDraft' + } + + if (status === 'published') { + return 'publish' + } + + return undefined +} + /** * Import-specific batch processor options */ @@ -78,11 +100,15 @@ export interface ImportProcessOptions { * - hasMultiLocale: Whether any multi-locale fields were found * - localeUpdates: Map of locale -> field data for follow-up updates */ -function extractMultiLocaleData( - data: Record, - configuredLocales?: string[], - defaultLocale?: string, -): { +export function extractMultiLocaleData({ + configuredLocales, + data, + defaultLocale, +}: { + configuredLocales?: string[] + data: Record + defaultLocale?: string +}): { flatData: Record hasMultiLocale: boolean localeUpdates: Record> @@ -186,6 +212,26 @@ async function processImportBatch({ ? req.payload.config.localization.defaultLocale : undefined + const getWriteAction = ({ + data, + fallbackStatus = options.defaultVersionStatus, + }: { + data: Record + fallbackStatus?: unknown + }) => + resolveImportWriteAction({ + collectionHasVersions, + status: data._status ?? fallbackStatus, + }) + + const getWriteActionOptions = (args: { + data: Record + fallbackStatus?: unknown + }): { action?: 'publish' | 'saveDraft' } => { + const action = getWriteAction(args) + return action ? { action } : {} + } + const startingRowNumber = batchIndex * options.batchSize for (let i = 0; i < batch.length; i++) { @@ -205,11 +251,9 @@ async function processImportBatch({ delete createData.id } - let draftOption: boolean | undefined if (collectionHasVersions) { - const statusValue = createData._status || options.defaultVersionStatus + const statusValue = createData._status ?? options.defaultVersionStatus const isPublished = statusValue !== 'draft' - draftOption = !isPublished createData._status = statusValue if (req.payload.config.debug) { @@ -217,7 +261,7 @@ async function processImportBatch({ _status: createData._status, isPublished, msg: 'Status handling in create', - willSetDraft: draftOption, + writeAction: getWriteAction({ data: createData }), }) } } @@ -232,11 +276,11 @@ async function processImportBatch({ } // Check if we have multi-locale data and extract it - const { flatData, hasMultiLocale, localeUpdates } = extractMultiLocaleData( - createData, + const { flatData, hasMultiLocale, localeUpdates } = extractMultiLocaleData({ configuredLocales, + data: createData, defaultLocale, - ) + }) if (hasMultiLocale) { // Create with default locale data @@ -244,10 +288,10 @@ async function processImportBatch({ savedDocument = await req.payload.create({ collection: collectionSlug, data: flatData, - draft: draftOption, overrideAccess: false, req: defaultLocaleReq, user, + ...getWriteActionOptions({ data: flatData }), }) if (savedDocument && Object.keys(localeUpdates).length > 0) { @@ -257,10 +301,13 @@ async function processImportBatch({ id: savedDocument.id as number | string, collection: collectionSlug, data: localeData, - draft: collectionHasVersions ? false : undefined, overrideAccess: false, req: { ...req, locale }, user, + ...getWriteActionOptions({ + data: localeData, + fallbackStatus: flatData._status, + }), }) } catch (error) { req.payload.logger.error({ @@ -275,10 +322,10 @@ async function processImportBatch({ savedDocument = await req.payload.create({ collection: collectionSlug, data: createData, - draft: draftOption, overrideAccess: false, req, user, + ...getWriteActionOptions({ data: createData }), }) } } else if (importMode === 'update' || importMode === 'upsert') { @@ -314,6 +361,7 @@ async function processImportBatch({ overrideAccess: false, req, user, + version: 'latest', where: { [matchField || 'id']: { equals: matchValue, @@ -357,11 +405,11 @@ async function processImportBatch({ delete updateData.updatedAt // Check if we have multi-locale data and extract it - const { flatData, hasMultiLocale, localeUpdates } = extractMultiLocaleData( - updateData, + const { flatData, hasMultiLocale, localeUpdates } = extractMultiLocaleData({ configuredLocales, + data: updateData, defaultLocale, - ) + }) if (req.payload.config.debug) { req.payload.logger.info({ @@ -389,10 +437,10 @@ async function processImportBatch({ collection: collectionSlug, data: flatData, depth: 0, - // Don't specify draft - this creates a new draft for versioned collections overrideAccess: false, req: defaultLocaleReq, user, + ...getWriteActionOptions({ data: flatData }), }) if (savedDocument && Object.keys(localeUpdates).length > 0) { @@ -406,6 +454,10 @@ async function processImportBatch({ overrideAccess: false, req: { ...req, locale }, user, + ...getWriteActionOptions({ + data: localeData, + fallbackStatus: flatData._status, + }), }) } catch (error) { req.payload.logger.error({ @@ -428,17 +480,15 @@ async function processImportBatch({ }) } - // Update the document - don't specify draft to let Payload handle versions properly - // This will create a new draft version for collections with versions enabled savedDocument = await req.payload.update({ id: existingDoc.id as number | string, collection: collectionSlug, data: updateData, depth: 0, - // Don't specify draft - this creates a new draft for versioned collections overrideAccess: false, req, user, + ...getWriteActionOptions({ data: updateData }), }) if (req.payload.config.debug && savedDocument) { @@ -475,21 +525,18 @@ async function processImportBatch({ } // Only handle _status for versioned collections - let draftOption: boolean | undefined if (collectionHasVersions) { // Use defaultVersionStatus from config if _status not provided - const statusValue = createData._status || options.defaultVersionStatus - const isPublished = statusValue !== 'draft' - draftOption = !isPublished + const statusValue = createData._status ?? options.defaultVersionStatus createData._status = statusValue } // Check if we have multi-locale data and extract it - const { flatData, hasMultiLocale, localeUpdates } = extractMultiLocaleData( - createData, + const { flatData, hasMultiLocale, localeUpdates } = extractMultiLocaleData({ configuredLocales, + data: createData, defaultLocale, - ) + }) if (hasMultiLocale) { // Create with default locale data @@ -497,10 +544,10 @@ async function processImportBatch({ savedDocument = await req.payload.create({ collection: collectionSlug, data: flatData, - draft: draftOption, overrideAccess: false, req: defaultLocaleReq, user, + ...getWriteActionOptions({ data: flatData }), }) if (savedDocument && Object.keys(localeUpdates).length > 0) { @@ -510,10 +557,13 @@ async function processImportBatch({ id: savedDocument.id as number | string, collection: collectionSlug, data: localeData, - draft: collectionHasVersions ? false : undefined, overrideAccess: false, req: { ...req, locale }, user, + ...getWriteActionOptions({ + data: localeData, + fallbackStatus: flatData._status, + }), }) } catch (error) { req.payload.logger.error({ @@ -528,10 +578,10 @@ async function processImportBatch({ savedDocument = await req.payload.create({ collection: collectionSlug, data: createData, - draft: draftOption, overrideAccess: false, req, user, + ...getWriteActionOptions({ data: createData }), }) } } else { diff --git a/packages/plugin-mcp/src/mcp/builtin/collections/createTool.ts b/packages/plugin-mcp/src/mcp/builtin/collections/createTool.ts index f1bd01f2592..313994154f8 100644 --- a/packages/plugin-mcp/src/mcp/builtin/collections/createTool.ts +++ b/packages/plugin-mcp/src/mcp/builtin/collections/createTool.ts @@ -1,7 +1,6 @@ import { createDocumentsInputSchema, getCollectionVirtualFieldNames, - hasDraftValidationEnabled, stripVirtualFields, transformPointDataToPayload, validateCollectionData, @@ -30,12 +29,11 @@ export const createDocumentsTool = defineCollectionTool({ input: createDocumentsInputSchema({ file: fileInputSchema }), }).handler(async ({ slug, authorizedMCP, input, req }) => { const payload = req.payload - const collectionConfig = payload.collections[slug]?.config const logger = getLogger({ payload }) const { + action, depth, documents, - draft, fallbackLocale, locale, populate, @@ -43,9 +41,6 @@ export const createDocumentsTool = defineCollectionTool({ returning, select, } = input - const shouldUsePartialSchema = - draft === true && collectionConfig !== undefined && !hasDraftValidationEnabled(collectionConfig) - logger.info(`Creating ${documents.length} documents in collection: ${slug}`) try { @@ -62,17 +57,17 @@ export const createDocumentsTool = defineCollectionTool({ validateCollectionData({ slug, data: inputData, - partial: shouldUsePartialSchema, + partial: true, req, }) const parsedData = transformPointDataToPayload(inputData) const file = await resolveFile({ slug, input: document.file, req }) const result = await payload.create({ + action, collection: slug, data: parsedData, depth, - draft, overrideAccess: authorizedMCP.overrideAccess, populate, publishAllLocales, diff --git a/packages/plugin-mcp/src/mcp/builtin/collections/duplicateTool.ts b/packages/plugin-mcp/src/mcp/builtin/collections/duplicateTool.ts index 0cb40f9495c..8722abe12bf 100644 --- a/packages/plugin-mcp/src/mcp/builtin/collections/duplicateTool.ts +++ b/packages/plugin-mcp/src/mcp/builtin/collections/duplicateTool.ts @@ -30,7 +30,7 @@ export const duplicateDocumentTool = defineCollectionTool({ }).handler(async ({ slug, authorizedMCP, input, req }) => { const payload = req.payload const logger = getLogger({ payload }) - const { id, data, depth, draft, fallbackLocale, locale, populate, select, selectedLocales } = + const { id, action, data, depth, fallbackLocale, locale, populate, select, selectedLocales } = input logger.info(`Duplicating document in collection: ${slug} with ID: ${id}`) @@ -52,9 +52,9 @@ export const duplicateDocumentTool = defineCollectionTool({ const result = await payload.duplicate({ id: parseDocumentID({ id, collectionSlug: slug, payload }), + action, collection: slug, depth, - draft, overrideAccess: authorizedMCP.overrideAccess, req, ...(parsedData ? { data: parsedData } : {}), diff --git a/packages/plugin-mcp/src/mcp/builtin/collections/findTool.ts b/packages/plugin-mcp/src/mcp/builtin/collections/findTool.ts index 9606b0728da..42fe669a96d 100644 --- a/packages/plugin-mcp/src/mcp/builtin/collections/findTool.ts +++ b/packages/plugin-mcp/src/mcp/builtin/collections/findTool.ts @@ -26,7 +26,6 @@ export const findDocumentsTool = defineCollectionTool({ const { id, depth, - draft, fallbackLocale, joins, limit, @@ -37,6 +36,7 @@ export const findDocumentsTool = defineCollectionTool({ select, sort, trash, + version, where, } = input @@ -58,7 +58,7 @@ export const findDocumentsTool = defineCollectionTool({ ...(joins !== undefined && { joins }), ...(locale && { locale }), ...(fallbackLocale !== undefined && { fallbackLocale }), - ...(draft !== undefined && { draft }), + ...(version !== undefined && { version }), ...(trash !== undefined && { trash }), }) @@ -96,7 +96,7 @@ export const findDocumentsTool = defineCollectionTool({ ...(joins !== undefined && { joins }), ...(locale && { locale }), ...(fallbackLocale !== undefined && { fallbackLocale }), - ...(draft !== undefined && { draft }), + ...(version !== undefined && { version }), ...(pagination !== undefined && { pagination }), ...(trash !== undefined && { trash }), } diff --git a/packages/plugin-mcp/src/mcp/builtin/collections/findVersionByIDTool.ts b/packages/plugin-mcp/src/mcp/builtin/collections/findVersionByIDTool.ts index 0e37c4a311c..a32c9913c00 100644 --- a/packages/plugin-mcp/src/mcp/builtin/collections/findVersionByIDTool.ts +++ b/packages/plugin-mcp/src/mcp/builtin/collections/findVersionByIDTool.ts @@ -22,7 +22,7 @@ export const findVersionByIDTool = defineCollectionTool({ }).handler(async ({ slug, authorizedMCP, input, req }) => { const payload = req.payload const logger = getLogger({ payload }) - const { id, depth, draft, fallbackLocale, locale, populate, select, trash } = input + const { id, depth, fallbackLocale, locale, populate, select, trash } = input logger.info(`Finding version in collection: ${slug} with ID: ${id}`) @@ -33,7 +33,6 @@ export const findVersionByIDTool = defineCollectionTool({ depth, overrideAccess: authorizedMCP.overrideAccess, req, - ...(draft !== undefined ? { draft } : {}), ...(fallbackLocale !== undefined ? { fallbackLocale } : {}), ...(locale ? { locale } : {}), ...(populate ? { populate } : {}), diff --git a/packages/plugin-mcp/src/mcp/builtin/collections/findVersionsTool.ts b/packages/plugin-mcp/src/mcp/builtin/collections/findVersionsTool.ts index f997abf97bc..dc320731397 100644 --- a/packages/plugin-mcp/src/mcp/builtin/collections/findVersionsTool.ts +++ b/packages/plugin-mcp/src/mcp/builtin/collections/findVersionsTool.ts @@ -24,7 +24,6 @@ export const findVersionsTool = defineCollectionTool({ const logger = getLogger({ payload }) const { depth, - draft, fallbackLocale, limit, locale, @@ -47,7 +46,6 @@ export const findVersionsTool = defineCollectionTool({ overrideAccess: authorizedMCP.overrideAccess, page, req, - ...(draft !== undefined ? { draft } : {}), ...(fallbackLocale !== undefined ? { fallbackLocale } : {}), ...(locale ? { locale } : {}), ...(pagination !== undefined ? { pagination } : {}), diff --git a/packages/plugin-mcp/src/mcp/builtin/collections/restoreVersionTool.ts b/packages/plugin-mcp/src/mcp/builtin/collections/restoreVersionTool.ts index fa61a50d03e..605059d715a 100644 --- a/packages/plugin-mcp/src/mcp/builtin/collections/restoreVersionTool.ts +++ b/packages/plugin-mcp/src/mcp/builtin/collections/restoreVersionTool.ts @@ -22,16 +22,16 @@ export const restoreVersionTool = defineCollectionTool({ }).handler(async ({ slug, authorizedMCP, input, req }) => { const payload = req.payload const logger = getLogger({ payload }) - const { id, depth, draft, fallbackLocale, locale, populate, select } = input + const { id, action, depth, fallbackLocale, locale, populate, select } = input logger.info(`Restoring version in collection: ${slug} with ID: ${id}`) try { const result = await payload.restoreVersion({ id: String(id), + action, collection: slug, depth, - draft, overrideAccess: authorizedMCP.overrideAccess, req, ...(fallbackLocale !== undefined ? { fallbackLocale } : {}), diff --git a/packages/plugin-mcp/src/mcp/builtin/collections/updateTool.ts b/packages/plugin-mcp/src/mcp/builtin/collections/updateTool.ts index 1f121d32f4a..f528a9e35fd 100644 --- a/packages/plugin-mcp/src/mcp/builtin/collections/updateTool.ts +++ b/packages/plugin-mcp/src/mcp/builtin/collections/updateTool.ts @@ -37,9 +37,9 @@ export const updateDocumentTool = defineCollectionTool({ const { id, + action, data, depth, - draft, fallbackLocale, file: fileInput, limit, @@ -56,7 +56,7 @@ export const updateDocumentTool = defineCollectionTool({ } = input logger.info( - `Updating document in collection: ${slug}${id ? ` with ID: ${id}` : ' with where clause'}, draft: ${draft}${locale ? `, locale: ${locale}` : ''}`, + `Updating document in collection: ${slug}${id ? ` with ID: ${id}` : ' with where clause'}, action: ${action ?? 'default'}${locale ? `, locale: ${locale}` : ''}`, ) try { @@ -77,10 +77,10 @@ export const updateDocumentTool = defineCollectionTool({ if (id !== undefined) { const result = await payload.update({ id: parseDocumentID({ id, collectionSlug: slug, payload }), + action, collection: slug, data: parsedData, depth, - draft, fallbackLocale, locale, overrideAccess: authorizedMCP.overrideAccess, @@ -108,10 +108,10 @@ export const updateDocumentTool = defineCollectionTool({ } const result = await payload.update({ + action, collection: slug, data: parsedData, depth, - draft, fallbackLocale, limit, locale, diff --git a/packages/plugin-mcp/src/mcp/builtin/globals/findTool.ts b/packages/plugin-mcp/src/mcp/builtin/globals/findTool.ts index 2439d541ed9..44ab9c016ae 100644 --- a/packages/plugin-mcp/src/mcp/builtin/globals/findTool.ts +++ b/packages/plugin-mcp/src/mcp/builtin/globals/findTool.ts @@ -21,7 +21,7 @@ export const findGlobalTool = defineGlobalTool({ const payload = req.payload const logger = getLogger({ payload }) - const { depth, fallbackLocale, locale, populate, select } = input + const { depth, fallbackLocale, locale, populate, select, version } = input logger.info(`Reading global: ${slug}, depth: ${depth}${locale ? `, locale: ${locale}` : ''}`) @@ -31,6 +31,7 @@ export const findGlobalTool = defineGlobalTool({ depth, overrideAccess: authorizedMCP.overrideAccess, req, + version, } if (locale) { diff --git a/packages/plugin-mcp/src/mcp/builtin/globals/restoreVersionTool.ts b/packages/plugin-mcp/src/mcp/builtin/globals/restoreVersionTool.ts index 9841f55de98..f44eff68b1a 100644 --- a/packages/plugin-mcp/src/mcp/builtin/globals/restoreVersionTool.ts +++ b/packages/plugin-mcp/src/mcp/builtin/globals/restoreVersionTool.ts @@ -21,7 +21,7 @@ export const restoreGlobalVersionTool = defineGlobalTool({ }).handler(async ({ slug, authorizedMCP, input, req }) => { const payload = req.payload const logger = getLogger({ payload }) - const { id, depth, fallbackLocale, locale, populate, select } = input + const { id, action, depth, fallbackLocale, locale, populate, select } = input logger.info(`Restoring version for global: ${slug} with ID: ${id}`) @@ -29,6 +29,7 @@ export const restoreGlobalVersionTool = defineGlobalTool({ const result = await payload.restoreGlobalVersion({ id: String(id), slug, + action, depth, overrideAccess: authorizedMCP.overrideAccess, req, diff --git a/packages/plugin-mcp/src/mcp/builtin/globals/updateTool.ts b/packages/plugin-mcp/src/mcp/builtin/globals/updateTool.ts index 1d5ac50807b..f9281c73fde 100644 --- a/packages/plugin-mcp/src/mcp/builtin/globals/updateTool.ts +++ b/packages/plugin-mcp/src/mcp/builtin/globals/updateTool.ts @@ -29,9 +29,9 @@ export const updateGlobalTool = defineGlobalTool({ const logger = getLogger({ payload }) const { + action, data, depth, - draft, fallbackLocale, locale, overrideLock, @@ -41,7 +41,9 @@ export const updateGlobalTool = defineGlobalTool({ unpublishAllLocales, } = input - logger.info(`Updating global: ${slug}, draft: ${draft}${locale ? `, locale: ${locale}` : ''}`) + logger.info( + `Updating global: ${slug}, action: ${action ?? 'default'}${locale ? `, locale: ${locale}` : ''}`, + ) try { const virtualFieldNames = getGlobalVirtualFieldNames(payload.config, slug) @@ -52,9 +54,9 @@ export const updateGlobalTool = defineGlobalTool({ const updateOptions: Parameters[0] = { slug, + action, data: parsedData, depth, - draft, overrideAccess: authorizedMCP.overrideAccess, overrideLock, populate, diff --git a/packages/plugin-multi-tenant/src/utilities/getGlobalViewRedirect.ts b/packages/plugin-multi-tenant/src/utilities/getGlobalViewRedirect.ts index b20edec4ab1..0b2bf865673 100644 --- a/packages/plugin-multi-tenant/src/utilities/getGlobalViewRedirect.ts +++ b/packages/plugin-multi-tenant/src/utilities/getGlobalViewRedirect.ts @@ -79,6 +79,7 @@ export async function getGlobalViewRedirect({ select: { id: true, }, + version: 'latest', where: { [tenantFieldName]: { in: [tenant], @@ -165,12 +166,12 @@ async function generateCreateRedirect({ // Autosave is enabled, create a document first try { const doc = await payload.create({ + action: 'saveDraft', collection: collectionSlug, data: { tenant: tenantID, }, depth: 0, - draft: true, select: { id: true, }, diff --git a/packages/plugin-nested-docs/src/hooks/resaveChildren.ts b/packages/plugin-nested-docs/src/hooks/resaveChildren.ts index fc007f65e94..3ac26f2965b 100644 --- a/packages/plugin-nested-docs/src/hooks/resaveChildren.ts +++ b/packages/plugin-nested-docs/src/hooks/resaveChildren.ts @@ -8,8 +8,8 @@ import { populateBreadcrumbs } from '../utilities/populateBreadcrumbs.js' export const resaveChildren = (pluginConfig: NestedDocsPluginConfig): CollectionAfterChangeHook => - async ({ collection, doc, req }) => { - if (collection?.versions?.drafts && doc._status !== 'published') { + async ({ action, collection, doc, req }) => { + if (collection?.versions?.drafts && action !== 'publish') { // If the parent is a draft, don't resave children return } @@ -19,10 +19,10 @@ export const resaveChildren = const initialDraftChildren = await req.payload.find({ collection: collection.slug, depth: 0, - draft: true, limit: 0, locale: req.locale, req, + version: 'latest', where: { [parentSlug]: { equals: doc.id, @@ -35,10 +35,10 @@ export const resaveChildren = const publishedChildren = await req.payload.find({ collection: collection.slug, depth: 0, - draft: false, limit: 0, locale: req.locale, req, + version: 'published', where: { [parentSlug]: { equals: doc.id, @@ -70,6 +70,7 @@ export const resaveChildren = await req.payload.update({ id: child.id, + action: isDraft ? 'saveDraft' : 'publish', collection: collection.slug, data: await populateBreadcrumbs({ collection, @@ -80,7 +81,6 @@ export const resaveChildren = req, }), depth: 0, - draft: isDraft, locale: req.locale, req, }) diff --git a/packages/plugin-nested-docs/src/hooks/resaveSelfAfterCreate.ts b/packages/plugin-nested-docs/src/hooks/resaveSelfAfterCreate.ts index 62026be9f98..6066714d33e 100644 --- a/packages/plugin-nested-docs/src/hooks/resaveSelfAfterCreate.ts +++ b/packages/plugin-nested-docs/src/hooks/resaveSelfAfterCreate.ts @@ -7,7 +7,7 @@ import type { Breadcrumb, NestedDocsPluginConfig } from '../types.js' export const resaveSelfAfterCreate = (pluginConfig: NestedDocsPluginConfig): CollectionAfterChangeHook => - async ({ collection, doc, operation, req }) => { + async ({ action, collection, doc, operation, req }) => { if (operation !== 'create') { return undefined } @@ -19,6 +19,9 @@ export const resaveSelfAfterCreate = try { await payload.update({ id: doc.id, + ...(collection?.versions?.drafts + ? { action: action === 'saveDraft' ? 'saveDraft' : 'publish' } + : {}), collection: collection.slug, data: { [breadcrumbSlug]: @@ -28,7 +31,6 @@ export const resaveSelfAfterCreate = })) || [], }, depth: 0, - draft: collection?.versions?.drafts && doc._status !== 'published', locale, req, }) diff --git a/packages/plugin-nested-docs/src/utilities/getParents.ts b/packages/plugin-nested-docs/src/utilities/getParents.ts index a5ebee0a5e7..4174dc37adf 100644 --- a/packages/plugin-nested-docs/src/utilities/getParents.ts +++ b/packages/plugin-nested-docs/src/utilities/getParents.ts @@ -22,6 +22,7 @@ export const getParents = async ( depth: 0, disableErrors: true, req, + version: 'latest', }) } diff --git a/packages/plugin-search/src/utilities/generateReindexHandler.ts b/packages/plugin-search/src/utilities/generateReindexHandler.ts index ad6190fef3b..c1e1e71b419 100644 --- a/packages/plugin-search/src/utilities/generateReindexHandler.ts +++ b/packages/plugin-search/src/utilities/generateReindexHandler.ts @@ -136,6 +136,7 @@ export const generateReindexHandler = limit: batchSize, locale: defaultLocale, page: i + 1, + version: syncDrafts && draftsEnabled ? 'latest' : 'published', where: syncDrafts || !draftsEnabled ? undefined : whereStatusPublished, ...defaultLocalApiProps, }) diff --git a/packages/plugin-search/src/utilities/syncDocAsSearchIndex.ts b/packages/plugin-search/src/utilities/syncDocAsSearchIndex.ts index ddffc70e94f..65a13a7ea0c 100644 --- a/packages/plugin-search/src/utilities/syncDocAsSearchIndex.ts +++ b/packages/plugin-search/src/utilities/syncDocAsSearchIndex.ts @@ -76,6 +76,7 @@ export const syncDocAsSearchIndex = async ({ req, // Include trashed documents when the document being synced is trashed trash: isTrashDocument, + version: 'latest', }) } dataToSave = await beforeSync({ @@ -213,11 +214,11 @@ export const syncDocAsSearchIndex = async ({ } = await payload.find({ collection, depth: 0, - draft: false, limit: 1, locale: syncLocale, pagination: false, req, + version: 'published', where: { and: [ { diff --git a/packages/richtext-lexical/src/features/blocks/server/graphQLPopulationPromise.ts b/packages/richtext-lexical/src/features/blocks/server/graphQLPopulationPromise.ts index 363a8e6af26..6824ea55696 100644 --- a/packages/richtext-lexical/src/features/blocks/server/graphQLPopulationPromise.ts +++ b/packages/richtext-lexical/src/features/blocks/server/graphQLPopulationPromise.ts @@ -14,7 +14,6 @@ export const blockPopulationPromiseHOC = ( context, currentDepth, depth, - draft, editorPopulationPromises, field, fieldPromises, @@ -26,6 +25,7 @@ export const blockPopulationPromiseHOC = ( populationPromises, req, showHiddenFields, + version, }) => { const blockFieldData = node.fields @@ -40,7 +40,6 @@ export const blockPopulationPromiseHOC = ( currentDepth, data: blockFieldData, depth, - draft, editorPopulationPromises, fieldPromises, fields: block.fields, @@ -52,6 +51,7 @@ export const blockPopulationPromiseHOC = ( req, showHiddenFields, siblingDoc: blockFieldData, + version, }) } diff --git a/packages/richtext-lexical/src/features/converters/lexicalToHtml/async/field/index.ts b/packages/richtext-lexical/src/features/converters/lexicalToHtml/async/field/index.ts index 7b83ed87cd9..0fca8000cd9 100644 --- a/packages/richtext-lexical/src/features/converters/lexicalToHtml/async/field/index.ts +++ b/packages/richtext-lexical/src/features/converters/lexicalToHtml/async/field/index.ts @@ -51,11 +51,11 @@ export const lexicalHTMLField: (args: Args) => Field = (args) => { async ({ currentDepth, depth, - draft, overrideAccess, req, showHiddenFields, siblingData, + version, }) => { const lexicalFieldData: SerializedEditorState = siblingData[lexicalFieldName] @@ -66,10 +66,10 @@ export const lexicalHTMLField: (args: Args) => Field = (args) => { const htmlPopulateFn = await getPayloadPopulateFn({ currentDepth: currentDepth ?? 0, depth: depth ?? req.payload.config.defaultDepth, - draft: draft ?? false, overrideAccess: overrideAccess ?? false, req, showHiddenFields: showHiddenFields ?? false, + version, }) return await convertLexicalToHTMLAsync({ diff --git a/packages/richtext-lexical/src/features/converters/utilities/payloadPopulateFn.ts b/packages/richtext-lexical/src/features/converters/utilities/payloadPopulateFn.ts index 98ca79d8fd2..d1192b69383 100644 --- a/packages/richtext-lexical/src/features/converters/utilities/payloadPopulateFn.ts +++ b/packages/richtext-lexical/src/features/converters/utilities/payloadPopulateFn.ts @@ -1,4 +1,10 @@ -import { createLocalReq, type Payload, type PayloadRequest, type TypedLocale } from 'payload' +import { + createLocalReq, + type Payload, + type PayloadRequest, + type ReadVersion, + type TypedLocale, +} from 'payload' import type { HTMLPopulateFn } from '../lexicalToHtml/async/types.js' @@ -8,11 +14,11 @@ export const getPayloadPopulateFn: ( args: { currentDepth: number depth: number - draft?: boolean locale?: TypedLocale overrideAccess?: boolean showHiddenFields?: boolean + version?: ReadVersion } & ( | { /** @@ -40,11 +46,11 @@ export const getPayloadPopulateFn: ( ) => Promise = async ({ currentDepth, depth, - draft, overrideAccess, payload, req, showHiddenFields, + version, }) => { let reqToUse: PayloadRequest | undefined = req if (req === undefined && payload) { @@ -66,12 +72,12 @@ export const getPayloadPopulateFn: ( currentDepth, data: dataContainer, depth, - draft: draft ?? false, key: 'value', overrideAccess: overrideAccess ?? true, req: reqToUse, select, showHiddenFields: showHiddenFields ?? false, + version, }) return dataContainer.value diff --git a/packages/richtext-lexical/src/features/converters/utilities/restPopulateFn.ts b/packages/richtext-lexical/src/features/converters/utilities/restPopulateFn.ts index 885623c1379..a4599227764 100644 --- a/packages/richtext-lexical/src/features/converters/utilities/restPopulateFn.ts +++ b/packages/richtext-lexical/src/features/converters/utilities/restPopulateFn.ts @@ -1,3 +1,5 @@ +import type { ReadVersion } from 'payload' + import { stringify } from 'qs-esm' import type { HTMLPopulateFn } from '../lexicalToHtml/async/types.js' @@ -8,12 +10,12 @@ export const getRestPopulateFn: (args: { */ apiURL: string depth?: number - draft?: boolean locale?: string -}) => HTMLPopulateFn = ({ apiURL, depth, draft, locale }) => { + version?: ReadVersion +}) => HTMLPopulateFn = ({ apiURL, depth, locale, version }) => { const populateFn: HTMLPopulateFn = async ({ id, collectionSlug, select }) => { const query = stringify( - { depth: depth ?? 0, draft: draft ?? false, locale, select }, + { depth: depth ?? 0, locale, select, version }, { addQueryPrefix: true }, ) diff --git a/packages/richtext-lexical/src/features/link/server/graphQLPopulationPromise.ts b/packages/richtext-lexical/src/features/link/server/graphQLPopulationPromise.ts index 625f3e35b52..42b2dda50a9 100644 --- a/packages/richtext-lexical/src/features/link/server/graphQLPopulationPromise.ts +++ b/packages/richtext-lexical/src/features/link/server/graphQLPopulationPromise.ts @@ -11,7 +11,6 @@ export const linkPopulationPromiseHOC = ( context, currentDepth, depth, - draft, editorPopulationPromises, field, fieldPromises, @@ -23,6 +22,7 @@ export const linkPopulationPromiseHOC = ( populationPromises, req, showHiddenFields, + version, }) => { if (!props.fields?.length) { return @@ -37,7 +37,6 @@ export const linkPopulationPromiseHOC = ( currentDepth, data: node.fields, depth, - draft, editorPopulationPromises, fieldPromises, fields: props.fields, @@ -49,6 +48,7 @@ export const linkPopulationPromiseHOC = ( req, showHiddenFields, siblingDoc: node.fields, + version, }) } } diff --git a/packages/richtext-lexical/src/features/relationship/server/graphQLPopulationPromise.ts b/packages/richtext-lexical/src/features/relationship/server/graphQLPopulationPromise.ts index 1c7a9a96c8b..0d75ee818ba 100644 --- a/packages/richtext-lexical/src/features/relationship/server/graphQLPopulationPromise.ts +++ b/packages/richtext-lexical/src/features/relationship/server/graphQLPopulationPromise.ts @@ -10,12 +10,12 @@ export const relationshipPopulationPromiseHOC = ( const relationshipPopulationPromise: PopulationPromise = ({ currentDepth, depth, - draft, node, overrideAccess, populationPromises, req, showHiddenFields, + version, }) => { if (node?.value) { // @ts-expect-error @@ -34,11 +34,11 @@ export const relationshipPopulationPromiseHOC = ( currentDepth, data: node, depth: populateDepth, - draft, key: 'value', overrideAccess, req, showHiddenFields, + version, }), ) } diff --git a/packages/richtext-lexical/src/features/relationship/server/index.ts b/packages/richtext-lexical/src/features/relationship/server/index.ts index 572b95c439c..595c0013b12 100644 --- a/packages/richtext-lexical/src/features/relationship/server/index.ts +++ b/packages/richtext-lexical/src/features/relationship/server/index.ts @@ -62,13 +62,13 @@ export const RelationshipFeature = createServerFeature< ({ currentDepth, depth, - draft, node, overrideAccess, populateArg, populationPromises, req, showHiddenFields, + version, }) => { if (!node?.value) { return node @@ -90,13 +90,13 @@ export const RelationshipFeature = createServerFeature< currentDepth, data: node, depth: populateDepth, - draft, key: 'value', overrideAccess, req, select: populateArg?.[collection.config.slug] ?? collection.config.defaultPopulate, showHiddenFields, + version, }), ) diff --git a/packages/richtext-lexical/src/features/typesServer.ts b/packages/richtext-lexical/src/features/typesServer.ts index f1a5b229e46..beac11941a5 100644 --- a/packages/richtext-lexical/src/features/typesServer.ts +++ b/packages/richtext-lexical/src/features/typesServer.ts @@ -17,6 +17,7 @@ import type { PayloadComponent, PayloadRequest, PopulateType, + ReadVersion, ReplaceAny, RequestContext, RichTextField, @@ -25,6 +26,7 @@ import type { TypedFallbackLocale, ValidateOptions, ValidationFieldError, + WriteAction, } from 'payload' import type { ServerEditorConfig } from '../lexical/config/types.js' @@ -36,7 +38,6 @@ export type PopulationPromise void export type NodeValidation = ({ @@ -120,7 +122,6 @@ export type AfterReadNodeHookArgs = { * Only available in `afterRead` hooks. */ depth: number - draft: boolean fallbackLocale: TypedFallbackLocale /** * Only available in `afterRead` field hooks. @@ -154,9 +155,12 @@ export type AfterReadNodeHookArgs = { * Only available in `afterRead` hooks. */ triggerHooks: boolean + version?: ReadVersion } export type AfterChangeNodeHookArgs = { + /** The already-resolved write action for this operation. */ + action?: WriteAction /** A string relating to which operation the field type is currently executing within. Useful within beforeValidate, beforeChange, and afterChange hooks to differentiate between create and update operations. */ operation: 'create' | 'delete' | 'read' | 'update' /** The value of the node before any changes. Not available in afterRead hooks */ diff --git a/packages/richtext-lexical/src/features/upload/server/graphQLPopulationPromise.ts b/packages/richtext-lexical/src/features/upload/server/graphQLPopulationPromise.ts index 854e24f4a16..3393c247439 100644 --- a/packages/richtext-lexical/src/features/upload/server/graphQLPopulationPromise.ts +++ b/packages/richtext-lexical/src/features/upload/server/graphQLPopulationPromise.ts @@ -12,7 +12,6 @@ export const uploadPopulationPromiseHOC = ( context, currentDepth, depth, - draft, editorPopulationPromises, field, fieldPromises, @@ -24,6 +23,7 @@ export const uploadPopulationPromiseHOC = ( populationPromises, req, showHiddenFields, + version, }) => { if (node?.value) { const collection = req.payload.collections[node?.relationTo] @@ -42,11 +42,11 @@ export const uploadPopulationPromiseHOC = ( currentDepth, data: node, depth: populateDepth, - draft, key: 'value', overrideAccess, req, showHiddenFields, + version, }), ) @@ -63,7 +63,6 @@ export const uploadPopulationPromiseHOC = ( depth, parentIsLocalized: parentIsLocalized || field.localized || false, - draft, editorPopulationPromises, fieldPromises, fields: collectionFieldSchema, @@ -74,6 +73,7 @@ export const uploadPopulationPromiseHOC = ( req, showHiddenFields, siblingDoc: node.fields || {}, + version, }) } } diff --git a/packages/richtext-lexical/src/features/upload/server/index.ts b/packages/richtext-lexical/src/features/upload/server/index.ts index 729ae6645e1..57e13e3ac4d 100644 --- a/packages/richtext-lexical/src/features/upload/server/index.ts +++ b/packages/richtext-lexical/src/features/upload/server/index.ts @@ -158,13 +158,13 @@ export const UploadFeature = createServerFeature< ({ currentDepth, depth, - draft, node, overrideAccess, populateArg, populationPromises, req, showHiddenFields, + version, }) => { if (!node?.value) { return node @@ -189,13 +189,13 @@ export const UploadFeature = createServerFeature< currentDepth, data: node, depth: populateDepth, - draft, key: 'value', overrideAccess, req, select: populateArg?.[collection.config.slug] ?? collection.config.defaultPopulate, showHiddenFields, + version, }), ) diff --git a/packages/richtext-lexical/src/hooks.ts b/packages/richtext-lexical/src/hooks.ts index f834dbd61fe..fded9af7ddc 100644 --- a/packages/richtext-lexical/src/hooks.ts +++ b/packages/richtext-lexical/src/hooks.ts @@ -19,6 +19,7 @@ export const getLexicalHooks: (args: { afterChange: [ async (args) => { const { + action, collection, context: _context, data, @@ -97,6 +98,7 @@ export const getLexicalHooks: (args: { continue } node = await hook({ + action, context, node, operation, @@ -122,6 +124,7 @@ export const getLexicalHooks: (args: { if (subFields?.length) { await afterChangeTraverseFields({ + action, blockData: nodeSiblingData, collection, context, @@ -156,7 +159,6 @@ export const getLexicalHooks: (args: { context: context, currentDepth, depth, - draft, fallbackLocale, field, fieldPromises, @@ -176,6 +178,7 @@ export const getLexicalHooks: (args: { showHiddenFields, triggerAccessControl, triggerHooks, + version, } = args let { value } = args @@ -208,7 +211,6 @@ export const getLexicalHooks: (args: { context, currentDepth: currentDepth!, depth: depth!, - draft: draft!, fallbackLocale: fallbackLocale!, fieldPromises: fieldPromises!, findMany: findMany!, @@ -224,6 +226,7 @@ export const getLexicalHooks: (args: { showHiddenFields: showHiddenFields!, triggerAccessControl: triggerAccessControl!, triggerHooks: triggerHooks!, + version, }) } } @@ -242,7 +245,7 @@ export const getLexicalHooks: (args: { currentDepth: currentDepth!, depth: depth!, doc: originalDoc, - draft: draft!, + draft: version === 'latest' || version === 'draft', fallbackLocale: fallbackLocale!, fieldPromises: fieldPromises!, fields: subFields, @@ -262,6 +265,7 @@ export const getLexicalHooks: (args: { siblingDoc: nodeSiblingData, triggerAccessControl, triggerHooks, + version, }) } } diff --git a/packages/richtext-lexical/src/index.ts b/packages/richtext-lexical/src/index.ts index 249cac969e4..7090b64ee44 100644 --- a/packages/richtext-lexical/src/index.ts +++ b/packages/richtext-lexical/src/index.ts @@ -124,7 +124,6 @@ export function lexicalEditor(args?: LexicalEditorProps): LexicalRichTextAdapter context, currentDepth, depth, - draft, field, fieldPromises, findMany, @@ -135,6 +134,7 @@ export function lexicalEditor(args?: LexicalEditorProps): LexicalRichTextAdapter req, showHiddenFields, siblingDoc, + version, }) { // check if there are any features with nodes which have populationPromises for this field if (finalSanitizedEditorConfig?.features?.graphQLPopulationPromises?.size) { @@ -142,7 +142,6 @@ export function lexicalEditor(args?: LexicalEditorProps): LexicalRichTextAdapter context, currentDepth: currentDepth ?? 0, depth, - draft, editorPopulationPromises: finalSanitizedEditorConfig.features.graphQLPopulationPromises, field, fieldPromises, @@ -154,6 +153,7 @@ export function lexicalEditor(args?: LexicalEditorProps): LexicalRichTextAdapter req, showHiddenFields, siblingDoc, + version, }) } }, diff --git a/packages/richtext-lexical/src/populateGraphQL/populate.ts b/packages/richtext-lexical/src/populateGraphQL/populate.ts index ff99d1d16ad..db14be8bc83 100644 --- a/packages/richtext-lexical/src/populateGraphQL/populate.ts +++ b/packages/richtext-lexical/src/populateGraphQL/populate.ts @@ -1,4 +1,4 @@ -import type { PayloadRequest, SelectType } from 'payload' +import type { PayloadRequest, ReadVersion, SelectType } from 'payload' import { createDataloaderCacheKey } from 'payload' @@ -7,13 +7,13 @@ type PopulateArguments = { currentDepth?: number data: unknown depth: number - draft: boolean id: number | string key: number | string overrideAccess: boolean req: PayloadRequest select?: SelectType showHiddenFields: boolean + version?: ReadVersion } type PopulateFn = (args: PopulateArguments) => Promise @@ -24,12 +24,12 @@ export const populate: PopulateFn = async ({ currentDepth, data, depth, - draft, key, overrideAccess, req, select, showHiddenFields, + version, }) => { const shouldPopulate = depth && currentDepth! <= depth // usually depth is checked within recursivelyPopulateFieldsForGraphQL. But since this populate function can be called outside of that (in rest afterRead node hooks) we need to check here too @@ -45,13 +45,13 @@ export const populate: PopulateFn = async ({ currentDepth: currentDepth! + 1, depth, docID: id as string, - draft, fallbackLocale: req.fallbackLocale!, locale: req.locale!, overrideAccess, select, showHiddenFields, transactionID: req.transactionID!, + version: version ?? 'published', }), ) diff --git a/packages/richtext-lexical/src/populateGraphQL/populateLexicalPopulationPromises.ts b/packages/richtext-lexical/src/populateGraphQL/populateLexicalPopulationPromises.ts index 595abc37c10..83ec90ef5d4 100644 --- a/packages/richtext-lexical/src/populateGraphQL/populateLexicalPopulationPromises.ts +++ b/packages/richtext-lexical/src/populateGraphQL/populateLexicalPopulationPromises.ts @@ -20,7 +20,6 @@ export const populateLexicalPopulationPromises = ({ context, currentDepth, depth, - draft, editorPopulationPromises, field, fieldPromises, @@ -32,6 +31,7 @@ export const populateLexicalPopulationPromises = ({ req, showHiddenFields, siblingDoc, + version, }: Args) => { const shouldPopulate = depth && currentDepth! <= depth @@ -48,7 +48,6 @@ export const populateLexicalPopulationPromises = ({ context, currentDepth: currentDepth!, depth, - draft, editorPopulationPromises, field, fieldPromises, @@ -61,6 +60,7 @@ export const populateLexicalPopulationPromises = ({ req, showHiddenFields, siblingDoc, + version, }) } } diff --git a/packages/richtext-lexical/src/populateGraphQL/recursivelyPopulateFieldsForGraphQL.ts b/packages/richtext-lexical/src/populateGraphQL/recursivelyPopulateFieldsForGraphQL.ts index 22d3c6cfc3d..4f613a0c897 100644 --- a/packages/richtext-lexical/src/populateGraphQL/recursivelyPopulateFieldsForGraphQL.ts +++ b/packages/richtext-lexical/src/populateGraphQL/recursivelyPopulateFieldsForGraphQL.ts @@ -1,4 +1,4 @@ -import type { Field, JsonObject, PayloadRequest, RequestContext } from 'payload' +import type { Field, JsonObject, PayloadRequest, ReadVersion, RequestContext } from 'payload' import { afterReadTraverseFields } from 'payload' @@ -9,7 +9,6 @@ type NestedRichTextFieldsArgs = { currentDepth?: number data: unknown depth: number - draft: boolean /** * This maps all the population promises to the node types */ @@ -27,6 +26,7 @@ type NestedRichTextFieldsArgs = { req: PayloadRequest showHiddenFields: boolean siblingDoc: JsonObject + version?: ReadVersion } export const recursivelyPopulateFieldsForGraphQL = ({ @@ -34,7 +34,6 @@ export const recursivelyPopulateFieldsForGraphQL = ({ currentDepth = 0, data, depth, - draft, fieldPromises, fields, findMany, @@ -45,6 +44,7 @@ export const recursivelyPopulateFieldsForGraphQL = ({ req, showHiddenFields, siblingDoc, + version, }: NestedRichTextFieldsArgs): void => { afterReadTraverseFields({ collection: null, // Pass from core? This is only needed for hooks, so we can leave this null for now @@ -52,7 +52,7 @@ export const recursivelyPopulateFieldsForGraphQL = ({ currentDepth, depth, doc: data as any, // Looks like it's only needed for hooks and access control, so doesn't matter what we pass here right now - draft, + draft: version === 'latest' || version === 'draft', fallbackLocale: req.fallbackLocale!, fieldPromises, fields, @@ -70,5 +70,6 @@ export const recursivelyPopulateFieldsForGraphQL = ({ showHiddenFields, siblingDoc, triggerHooks: false, + version: version ?? 'published', }) } diff --git a/packages/sdk/README.md b/packages/sdk/README.md index cf8ad2471ce..e5c1dc2a021 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -14,7 +14,7 @@ const sdk = new PayloadSDK({ // Find operation const posts = await sdk.find({ collection: 'posts', - draft: true, + version: 'latest', limit: 10, locale: 'en', page: 1, @@ -25,7 +25,7 @@ const posts = await sdk.find({ const posts = await sdk.findByID({ id, collection: 'posts', - draft: true, + version: 'latest', locale: 'en', }) diff --git a/packages/sdk/src/auth/me.ts b/packages/sdk/src/auth/me.ts index f88d2a1c59f..fa62e8a40a5 100644 --- a/packages/sdk/src/auth/me.ts +++ b/packages/sdk/src/auth/me.ts @@ -1,26 +1,40 @@ -import type { AuthCollectionSlug, PayloadTypesShape } from 'payload' +import type { AuthCollectionSlug, PayloadTypesShape, ReadVersion } from 'payload' import type { PayloadSDK } from '../index.js' -import type { DataFromAuthSlug } from '../types.js' +import type { CollectionVersionOptions, TransformAuthWithVersion } from '../types.js' -export type MeOptions> = { +export type MeOptions< + T extends PayloadTypesShape, + TSlug extends AuthCollectionSlug, + TVersion extends ReadVersion | undefined = undefined, +> = { collection: TSlug -} + version?: TVersion +} & CollectionVersionOptions -export type MeResult> = { +export type MeResult< + T extends PayloadTypesShape, + TSlug extends AuthCollectionSlug, + TVersion extends ReadVersion | undefined = undefined, +> = { collection?: TSlug exp?: number message: string token?: string - user: { _strategy?: string } & DataFromAuthSlug + user: { _strategy?: string } & TransformAuthWithVersion } -export async function me>( +export async function me< + T extends PayloadTypesShape, + TSlug extends AuthCollectionSlug, + TVersion extends ReadVersion | undefined = undefined, +>( sdk: PayloadSDK, - options: MeOptions, + options: MeOptions, init?: RequestInit, -): Promise> { +): Promise> { const response = await sdk.request({ + args: options, init, method: 'GET', path: `/${options.collection}/me`, diff --git a/packages/sdk/src/collections/create.ts b/packages/sdk/src/collections/create.ts index 8f721ce7d93..7f8e6e9f3e3 100644 --- a/packages/sdk/src/collections/create.ts +++ b/packages/sdk/src/collections/create.ts @@ -8,8 +8,8 @@ import type { import type { PayloadSDK } from '../index.js' import type { + CollectionCreateWriteOptions, PopulateType, - RequiredDataFromCollectionSlug, TransformCollectionWithSelect, } from '../types.js' @@ -24,18 +24,10 @@ export type CreateOptions< * the Collection slug to operate against. */ collection: TSlug - /** - * The data for the document to create. - */ - data: RequiredDataFromCollectionSlug /** * [Control auto-population](https://payloadcms.com/docs/queries/depth) of nested relationship and upload fields. */ depth?: number - /** - * Create a **draft** document. [More](https://payloadcms.com/docs/versions/drafts#draft-api) - */ - draft?: boolean /** * Specify a [fallback locale](https://payloadcms.com/docs/configuration/localization) to use for any returned documents. */ @@ -54,7 +46,7 @@ export type CreateOptions< * Specify [select](https://payloadcms.com/docs/queries/select) to control which fields to include to the result. */ select?: TSelect -} +} & CollectionCreateWriteOptions export async function create< T extends PayloadTypesShape, diff --git a/packages/sdk/src/collections/delete.ts b/packages/sdk/src/collections/delete.ts index 4b9dc73c7bf..f72ab51ae78 100644 --- a/packages/sdk/src/collections/delete.ts +++ b/packages/sdk/src/collections/delete.ts @@ -21,7 +21,6 @@ export type DeleteBaseOptions< * [Control auto-population](https://payloadcms.com/docs/queries/depth) of nested relationship and upload fields. */ depth?: number - draft?: boolean /** * Specify a [fallback locale](https://payloadcms.com/docs/configuration/localization) to use for any returned documents. */ diff --git a/packages/sdk/src/collections/find.ts b/packages/sdk/src/collections/find.ts index 9ffed8c0079..495032741b2 100644 --- a/packages/sdk/src/collections/find.ts +++ b/packages/sdk/src/collections/find.ts @@ -2,7 +2,7 @@ import type { CollectionSlug, PaginatedDocs, PayloadTypesShape, - SelectType, + ReadVersion, Sort, TypedLocale, Where, @@ -10,10 +10,11 @@ import type { import type { PayloadSDK } from '../index.js' import type { + CollectionVersionOptions, JoinQuery, PopulateType, SelectFromCollectionSlug, - TransformCollectionWithSelect, + TransformCollectionWithSelectByVersion, } from '../types.js' export type FindOptions< @@ -29,10 +30,6 @@ export type FindOptions< * [Control auto-population](https://payloadcms.com/docs/queries/depth) of nested relationship and upload fields. */ depth?: number - /** - * Whether the documents should be queried from the versions table/collection or not. [More](https://payloadcms.com/docs/versions/drafts#draft-api) - */ - draft?: boolean /** * Specify a [fallback locale](https://payloadcms.com/docs/configuration/localization) to use for any returned documents. */ @@ -89,17 +86,18 @@ export type FindOptions< * A filter [query](https://payloadcms.com/docs/queries/overview) */ where?: Where -} +} & CollectionVersionOptions export async function find< T extends PayloadTypesShape, TSlug extends CollectionSlug, TSelect extends SelectFromCollectionSlug, + TVersion extends ReadVersion | undefined = undefined, >( sdk: PayloadSDK, - options: FindOptions, + options: { version?: TVersion } & FindOptions, init?: RequestInit, -): Promise>> { +): Promise>> { const response = await sdk.request({ args: options, init, diff --git a/packages/sdk/src/collections/findByID.ts b/packages/sdk/src/collections/findByID.ts index e776772c415..a6a3a3fd83d 100644 --- a/packages/sdk/src/collections/findByID.ts +++ b/packages/sdk/src/collections/findByID.ts @@ -3,16 +3,18 @@ import type { CollectionSlug, FindOptions, PayloadTypesShape, + ReadVersion, SelectType, TypedLocale, } from 'payload' import type { PayloadSDK } from '../index.js' import type { + CollectionVersionOptions, JoinQuery, PopulateType, SelectFromCollectionSlug, - TransformCollectionWithSelect, + TransformCollectionWithSelectByVersion, } from '../types.js' export type FindByIDOptions< @@ -34,10 +36,6 @@ export type FindByIDOptions< * `null` will be returned instead, if the document on this ID was not found. */ disableErrors?: TDisableErrors - /** - * Whether the document should be queried from the versions table/collection or not. [More](https://payloadcms.com/docs/versions/drafts#draft-api) - */ - draft?: boolean /** * Specify a [fallback locale](https://payloadcms.com/docs/configuration/localization) to use for any returned documents. */ @@ -64,18 +62,25 @@ export type FindByIDOptions< * @default false */ trash?: boolean -} & Pick, 'select'> +} & CollectionVersionOptions & + Pick, 'select'> export async function findByID< T extends PayloadTypesShape, TSlug extends CollectionSlug, TDisableErrors extends boolean, TSelect extends SelectFromCollectionSlug, + TVersion extends ReadVersion | undefined = undefined, >( sdk: PayloadSDK, - options: FindByIDOptions, + options: { version?: TVersion } & FindByIDOptions, init?: RequestInit, -): Promise, TDisableErrors>> { +): Promise< + ApplyDisableErrors< + TransformCollectionWithSelectByVersion, + TDisableErrors + > +> { try { const response = await sdk.request({ args: options, diff --git a/packages/sdk/src/collections/findVersionByID.ts b/packages/sdk/src/collections/findVersionByID.ts index 4d1c2cbf1fe..c54d46d3fdc 100644 --- a/packages/sdk/src/collections/findVersionByID.ts +++ b/packages/sdk/src/collections/findVersionByID.ts @@ -28,10 +28,6 @@ export type FindVersionByIDOptions< * `null` will be returned instead, if the document on this ID was not found. */ disableErrors?: TDisableErrors - /** - * Whether the document should be queried from the versions table/collection or not. [More](https://payloadcms.com/docs/versions/drafts#draft-api) - */ - draft?: boolean /** * Specify a [fallback locale](https://payloadcms.com/docs/configuration/localization) to use for any returned documents. */ diff --git a/packages/sdk/src/collections/findVersions.ts b/packages/sdk/src/collections/findVersions.ts index 47e857cd963..891a9c7f482 100644 --- a/packages/sdk/src/collections/findVersions.ts +++ b/packages/sdk/src/collections/findVersions.ts @@ -21,10 +21,6 @@ export type FindVersionsOptions -} +} & CollectionRestoreActionOptions export async function restoreVersion>( sdk: PayloadSDK, diff --git a/packages/sdk/src/collections/update.ts b/packages/sdk/src/collections/update.ts index e81c5234adc..008634e8486 100644 --- a/packages/sdk/src/collections/update.ts +++ b/packages/sdk/src/collections/update.ts @@ -11,6 +11,7 @@ import type { DeepPartial } from 'ts-essentials' import type { PayloadSDK } from '../index.js' import type { BulkOperationResult, + CollectionUpdateActionOptions, PopulateType, RequiredDataFromCollectionSlug, SelectFromCollectionSlug, @@ -41,10 +42,6 @@ export type UpdateBaseOptions< * [Control auto-population](https://payloadcms.com/docs/queries/depth) of nested relationship and upload fields. */ depth?: number - /** - * Update documents to a draft. - */ - draft?: boolean /** * Specify a [fallback locale](https://payloadcms.com/docs/configuration/localization) to use for any returned documents. */ @@ -68,7 +65,7 @@ export type UpdateBaseOptions< * @default false */ trash?: boolean -} +} & CollectionUpdateActionOptions export type UpdateByIDOptions< T extends PayloadTypesShape, diff --git a/packages/sdk/src/globals/findOne.ts b/packages/sdk/src/globals/findOne.ts index ca2bcd4bee4..fc312b46d9a 100644 --- a/packages/sdk/src/globals/findOne.ts +++ b/packages/sdk/src/globals/findOne.ts @@ -1,7 +1,12 @@ -import type { GlobalSlug, PayloadTypesShape, SelectType, TypedLocale } from 'payload' +import type { GlobalSlug, PayloadTypesShape, ReadVersion, TypedLocale } from 'payload' import type { PayloadSDK } from '../index.js' -import type { PopulateType, SelectFromGlobalSlug, TransformGlobalWithSelect } from '../types.js' +import type { + GlobalVersionOptions, + PopulateType, + SelectFromGlobalSlug, + TransformGlobalWithSelectByVersion, +} from '../types.js' export type FindGlobalOptions< T extends PayloadTypesShape, @@ -12,10 +17,6 @@ export type FindGlobalOptions< * [Control auto-population](https://payloadcms.com/docs/queries/depth) of nested relationship and upload fields. */ depth?: number - /** - * Whether the document should be queried from the versions table/collection or not. [More](https://payloadcms.com/docs/versions/drafts#draft-api) - */ - draft?: boolean /** * Specify a [fallback locale](https://payloadcms.com/docs/configuration/localization) to use for any returned documents. */ @@ -36,17 +37,18 @@ export type FindGlobalOptions< * the Global slug to operate against. */ slug: TSlug -} +} & GlobalVersionOptions export async function findGlobal< T extends PayloadTypesShape, TSlug extends GlobalSlug, TSelect extends SelectFromGlobalSlug, + TVersion extends ReadVersion | undefined = undefined, >( sdk: PayloadSDK, - options: FindGlobalOptions, + options: { version?: TVersion } & FindGlobalOptions, init?: RequestInit, -): Promise> { +): Promise> { const response = await sdk.request({ args: options, init, diff --git a/packages/sdk/src/globals/findVersionByID.ts b/packages/sdk/src/globals/findVersionByID.ts index e63010e2f98..135ad88a4fa 100644 --- a/packages/sdk/src/globals/findVersionByID.ts +++ b/packages/sdk/src/globals/findVersionByID.ts @@ -24,7 +24,6 @@ export type FindGlobalVersionByIDOptions< * `null` will be returned instead, if the document on this ID was not found. */ disableErrors?: TDisableErrors - draft?: boolean /** * Specify a [fallback locale](https://payloadcms.com/docs/configuration/localization) to use for any returned documents. */ diff --git a/packages/sdk/src/globals/findVersions.ts b/packages/sdk/src/globals/findVersions.ts index e49441ee8f5..9be3e264605 100644 --- a/packages/sdk/src/globals/findVersions.ts +++ b/packages/sdk/src/globals/findVersions.ts @@ -17,7 +17,6 @@ export type FindGlobalVersionsOptions export async function restoreGlobalVersion< T extends PayloadTypesShape, diff --git a/packages/sdk/src/globals/update.ts b/packages/sdk/src/globals/update.ts index f28579b4f12..9164cef2da8 100644 --- a/packages/sdk/src/globals/update.ts +++ b/packages/sdk/src/globals/update.ts @@ -4,6 +4,7 @@ import type { DeepPartial } from 'ts-essentials' import type { PayloadSDK } from '../index.js' import type { DataFromGlobalSlug, + GlobalUpdateActionOptions, PopulateType, SelectFromGlobalSlug, TransformGlobalWithSelect, @@ -22,10 +23,6 @@ export type UpdateGlobalOptions< * [Control auto-population](https://payloadcms.com/docs/queries/depth) of nested relationship and upload fields. */ depth?: number - /** - * Update documents to a draft. - */ - draft?: boolean /** * Specify a [fallback locale](https://payloadcms.com/docs/configuration/localization) to use for any returned documents. */ @@ -46,7 +43,7 @@ export type UpdateGlobalOptions< * the Global slug to operate against. */ slug: TSlug -} +} & GlobalUpdateActionOptions export async function updateGlobal< T extends PayloadTypesShape, diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index ed5467c0cf8..82188e9fa5c 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -7,6 +7,7 @@ import type { PaginatedDocs, PayloadTypes, PayloadTypesShape, + ReadVersion, SelectType, TypeWithVersion, } from 'payload' @@ -36,7 +37,9 @@ import type { SelectFromCollectionSlug, SelectFromGlobalSlug, TransformCollectionWithSelect, + TransformCollectionWithSelectByVersion, TransformGlobalWithSelect, + TransformGlobalWithSelectByVersion, } from './types.js' import type { OperationArgs } from './utilities/buildSearchParams.js' @@ -196,10 +199,14 @@ export class PayloadSDK { * @param options * @returns documents satisfying query */ - find, TSelect extends SelectFromCollectionSlug>( - options: FindOptions, + find< + TSlug extends CollectionSlug, + TSelect extends SelectFromCollectionSlug, + TVersion extends ReadVersion | undefined = undefined, + >( + options: { version?: TVersion } & FindOptions, init?: RequestInit, - ): Promise>> { + ): Promise>> { return find(this, options, init) } @@ -212,17 +219,27 @@ export class PayloadSDK { TSlug extends CollectionSlug, TDisableErrors extends boolean, TSelect extends SelectFromCollectionSlug, + TVersion extends ReadVersion | undefined = undefined, >( - options: FindByIDOptions, + options: { version?: TVersion } & FindByIDOptions, init?: RequestInit, - ): Promise, TDisableErrors>> { + ): Promise< + ApplyDisableErrors< + TransformCollectionWithSelectByVersion, + TDisableErrors + > + > { return findByID(this, options, init) } - findGlobal, TSelect extends SelectFromGlobalSlug>( - options: FindGlobalOptions, + findGlobal< + TSlug extends GlobalSlug, + TSelect extends SelectFromGlobalSlug, + TVersion extends ReadVersion | undefined = undefined, + >( + options: { version?: TVersion } & FindGlobalOptions, init?: RequestInit, - ): Promise> { + ): Promise> { return findGlobal(this, options, init) } @@ -268,10 +285,10 @@ export class PayloadSDK { return login(this, options, init) } - me>( - options: MeOptions, + me, TVersion extends ReadVersion | undefined = undefined>( + options: MeOptions, init?: RequestInit, - ): Promise> { + ): Promise> { return me(this, options, init) } diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index ab2c8b067cb..b3c7f9b7a53 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -1,14 +1,29 @@ import type { AuthCollectionSlug, CollectionSlug, + CreateAction, + CreateDataFromCollectionSlug, + DraftTransformCollectionWithSelect, + DraftTransformGlobalWithSelect, GlobalSlug, JsonObject, PayloadTypesShape, + QueryDraftDataFromCollection, + QueryDraftDataFromCollectionSlug, + QueryDraftDataFromGlobalSlug, + ReadVersion, + RestoreAction, + RestoreActionFromCollectionSlug, + RestoreActionFromGlobalSlug, SelectType, Sort, TransformDataWithSelect, - TypedCollectionSelect, TypeWithID, + UpdateAction, + UpdateActionFromCollectionSlug, + UpdateActionFromGlobalSlug, + VersionFromCollectionSlug, + VersionFromGlobalSlug, Where, } from 'payload' @@ -101,3 +116,101 @@ export type BulkOperationResult< docs: TransformCollectionWithSelect[] errors: { id: IDType; message: string }[] } + +export type CollectionVersionOptions = TSlug extends CollectionSlug + ? VersionFromCollectionSlug + : { + /** + * Which document representation to read. [More](https://payloadcms.com/docs/versions/drafts) + * + * @default 'published' + */ + version?: ReadVersion + } + +export type CollectionCreateWriteOptions< + T extends PayloadTypesShape, + TSlug extends CollectionSlug, +> = TSlug extends CollectionSlug + ? CreateDataFromCollectionSlug + : { + action?: CreateAction + data: RequiredDataFromCollectionSlug + } + +export type CollectionUpdateActionOptions = TSlug extends CollectionSlug + ? UpdateActionFromCollectionSlug + : { + action?: UpdateAction + } + +export type CollectionRestoreActionOptions = TSlug extends CollectionSlug + ? RestoreActionFromCollectionSlug + : { + /** + * Restore and publish (`publish`, default) or restore as a draft (`saveDraft`). + */ + action?: RestoreAction + } + +export type GlobalVersionOptions = TSlug extends GlobalSlug + ? VersionFromGlobalSlug + : { + /** + * Which document representation to read. [More](https://payloadcms.com/docs/versions/drafts) + * + * @default 'published' + */ + version?: ReadVersion + } + +export type GlobalUpdateActionOptions = TSlug extends GlobalSlug + ? UpdateActionFromGlobalSlug + : { + action?: UpdateAction + } + +export type GlobalRestoreActionOptions = TSlug extends GlobalSlug + ? RestoreActionFromGlobalSlug + : { + /** + * Restore and publish (`publish`, default) or restore as a draft (`saveDraft`). + */ + action?: RestoreAction + } + +export type TransformCollectionWithSelectByVersion< + T extends PayloadTypesShape, + TSlug extends CollectionSlug, + TSelect, + TVersion extends ReadVersion | undefined, +> = TVersion extends 'draft' | 'latest' + ? TSlug extends CollectionSlug + ? TSelect extends SelectType + ? DraftTransformCollectionWithSelect + : QueryDraftDataFromCollectionSlug + : TransformCollectionWithSelect + : TransformCollectionWithSelect + +export type TransformAuthWithVersion< + T extends PayloadTypesShape, + TSlug extends AuthCollectionSlug, + TVersion extends ReadVersion | undefined, +> = TVersion extends 'draft' | 'latest' + ? DataFromAuthSlug extends JsonObject + ? QueryDraftDataFromCollection> + : DataFromAuthSlug + : DataFromAuthSlug + +export type TransformGlobalWithSelectByVersion< + T extends PayloadTypesShape, + TSlug extends GlobalSlug, + TSelect, + TVersion extends ReadVersion | undefined, +> = TVersion extends 'draft' | 'latest' + ? TSlug extends GlobalSlug + ? TSelect extends SelectType + ? DraftTransformGlobalWithSelect + : QueryDraftDataFromGlobalSlug + : TransformGlobalWithSelect + : TransformGlobalWithSelect diff --git a/packages/sdk/src/utilities/buildSearchParams.spec.ts b/packages/sdk/src/utilities/buildSearchParams.spec.ts new file mode 100644 index 00000000000..fcee1cfcb4b --- /dev/null +++ b/packages/sdk/src/utilities/buildSearchParams.spec.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' + +import { buildSearchParams } from './buildSearchParams.js' + +describe('buildSearchParams', () => { + it('should serialize version as an exact query string', () => { + expect(buildSearchParams({ version: 'latest' })).toBe('?version=latest') + expect(buildSearchParams({ version: 'draft' })).toBe('?version=draft') + expect(buildSearchParams({ version: 'published' })).toBe('?version=published') + }) + + it('should serialize action as an exact query string', () => { + expect(buildSearchParams({ action: 'saveDraft' })).toBe('?action=saveDraft') + expect(buildSearchParams({ action: 'publish' })).toBe('?action=publish') + expect(buildSearchParams({ action: 'unpublish' })).toBe('?action=unpublish') + }) + + it('should not serialize boolean draft or coerce booleans onto version and action', () => { + expect(buildSearchParams({ draft: true } as never)).toBe('') + expect(buildSearchParams({ version: true as never })).toBe('') + expect(buildSearchParams({ action: false as never })).toBe('') + }) +}) diff --git a/packages/sdk/src/utilities/buildSearchParams.ts b/packages/sdk/src/utilities/buildSearchParams.ts index 70d669de188..dc3bc6eaee2 100644 --- a/packages/sdk/src/utilities/buildSearchParams.ts +++ b/packages/sdk/src/utilities/buildSearchParams.ts @@ -3,8 +3,8 @@ import type { Sort, Where } from 'payload' import { stringify } from 'qs-esm' export type OperationArgs = { + action?: string depth?: number - draft?: boolean fallbackLocale?: unknown joins?: false | Record limit?: number @@ -15,6 +15,7 @@ export type OperationArgs = { select?: unknown sort?: Sort trash?: boolean + version?: string where?: Where } @@ -33,8 +34,12 @@ export const buildSearchParams = (args: OperationArgs): string => { search.limit = String(args.limit) } - if (typeof args.draft === 'boolean') { - search.draft = String(args.draft) + if (typeof args.version === 'string') { + search.version = args.version + } + + if (typeof args.action === 'string') { + search.action = args.action } if (typeof args.trash === 'boolean') { diff --git a/packages/ui/src/elements/Autosave/index.tsx b/packages/ui/src/elements/Autosave/index.tsx index d5b5c62d6a2..a3462f3238d 100644 --- a/packages/ui/src/elements/Autosave/index.tsx +++ b/packages/ui/src/elements/Autosave/index.tsx @@ -110,9 +110,9 @@ export const Autosave: React.FC = ({ id, collection, global: globalDoc }) let entitySlug: string const params = qs.stringify( { + action: 'saveDraft', autosave: true, depth: 0, - draft: true, 'fallback-locale': 'null', locale, }, diff --git a/packages/ui/src/elements/DuplicateDocument/index.tsx b/packages/ui/src/elements/DuplicateDocument/index.tsx index b65ea4c565f..8ea2b673368 100644 --- a/packages/ui/src/elements/DuplicateDocument/index.tsx +++ b/packages/ui/src/elements/DuplicateDocument/index.tsx @@ -82,6 +82,9 @@ export const DuplicateDocument: React.FC = ({ if (hasSelectedLocales) { queryParams.selectedLocales = selectedLocales } + if (hasDraftsEnabled(collectionConfig)) { + queryParams.action = 'saveDraft' + } const headers = { 'Accept-Language': i18n.language, diff --git a/packages/ui/src/elements/EditMany/DrawerContent.tsx b/packages/ui/src/elements/EditMany/DrawerContent.tsx index 36e43e67faf..74a9072204a 100644 --- a/packages/ui/src/elements/EditMany/DrawerContent.tsx +++ b/packages/ui/src/elements/EditMany/DrawerContent.tsx @@ -367,14 +367,14 @@ export const EditManyDrawerContent: React.FC = (prop diff --git a/packages/ui/src/elements/PublishButton/index.tsx b/packages/ui/src/elements/PublishButton/index.tsx index b87af9c2f67..3dd97a7a842 100644 --- a/packages/ui/src/elements/PublishButton/index.tsx +++ b/packages/ui/src/elements/PublishButton/index.tsx @@ -91,8 +91,8 @@ export function PublishButton({ const params = qs.stringify( { + action: 'saveDraft', depth: 0, - draft: true, 'fallback-locale': 'null', locale: localeCode, }, @@ -147,6 +147,7 @@ export function PublishButton({ const params = qs.stringify( { + action: 'publish', depth: 0, locale: localeCode, ...(localizeStatusEnabled && { publishAllLocales: true }), @@ -195,6 +196,7 @@ export function PublishButton({ const params = qs.stringify( { + action: 'publish', depth: 0, locale, }, diff --git a/packages/ui/src/elements/PublishMany/DrawerContent.tsx b/packages/ui/src/elements/PublishMany/DrawerContent.tsx index 3228f4da586..a657378a248 100644 --- a/packages/ui/src/elements/PublishMany/DrawerContent.tsx +++ b/packages/ui/src/elements/PublishMany/DrawerContent.tsx @@ -98,6 +98,7 @@ export function PublishManyDrawerContent(props: PublishManyDrawerContentProps) { return qs.stringify( { + action: 'publish', locale, select: {}, where: combineWhereConstraints(whereConstraints), @@ -109,7 +110,7 @@ export function PublishManyDrawerContent(props: PublishManyDrawerContentProps) { const handlePublish = useCallback(async () => { const url = formatAdminURL({ apiRoute: api, - path: `/${slug}${queryString}&draft=true`, + path: `/${slug}${queryString}`, }) await requests .patch(url, { diff --git a/packages/ui/src/elements/SaveDraftButton/index.tsx b/packages/ui/src/elements/SaveDraftButton/index.tsx index 04b9c66f09e..828438b1f96 100644 --- a/packages/ui/src/elements/SaveDraftButton/index.tsx +++ b/packages/ui/src/elements/SaveDraftButton/index.tsx @@ -43,7 +43,7 @@ export function SaveDraftButton(props: SaveDraftButtonClientProps) { return } - const search = `?locale=${locale}&depth=0&fallback-locale=null&draft=true` + const search = `?locale=${locale}&depth=0&fallback-locale=null&action=saveDraft` let action let method = 'POST' diff --git a/packages/ui/src/elements/Table/RelationshipProvider/index.tsx b/packages/ui/src/elements/Table/RelationshipProvider/index.tsx index 71ac23043bb..ac6e4add90f 100644 --- a/packages/ui/src/elements/Table/RelationshipProvider/index.tsx +++ b/packages/ui/src/elements/Table/RelationshipProvider/index.tsx @@ -68,8 +68,8 @@ export const RelationshipProvider: React.FC<{ readonly children?: React.ReactNod const select: SelectType = {} params.append('depth', '0') - params.append('draft', 'true') params.append('limit', '250') + params.append('version', 'latest') const collection = collections.find((c) => c.slug === slug) diff --git a/packages/ui/src/elements/UnpublishButton/index.tsx b/packages/ui/src/elements/UnpublishButton/index.tsx index e20d41d28f1..6b12e5f8003 100644 --- a/packages/ui/src/elements/UnpublishButton/index.tsx +++ b/packages/ui/src/elements/UnpublishButton/index.tsx @@ -68,6 +68,7 @@ export function UnpublishButton({ const queryString = qs.stringify( { + action: 'unpublish', depth: 0, 'fallback-locale': 'null', locale: unpublishAll ? undefined : localeCode, diff --git a/packages/ui/src/elements/UnpublishMany/DrawerContent.tsx b/packages/ui/src/elements/UnpublishMany/DrawerContent.tsx index 0419a2866c2..942c7edd88d 100644 --- a/packages/ui/src/elements/UnpublishMany/DrawerContent.tsx +++ b/packages/ui/src/elements/UnpublishMany/DrawerContent.tsx @@ -85,8 +85,10 @@ export function UnpublishManyDrawerContent(props: UnpublishManyDrawerContentProp return qs.stringify( { + action: 'unpublish', locale, select: {}, + unpublishAllLocales: true, where: combineWhereConstraints(whereConstraints), }, { addQueryPrefix: true }, diff --git a/packages/ui/src/fields/Relationship/Input.tsx b/packages/ui/src/fields/Relationship/Input.tsx index 6d942222d5a..645e7a50412 100644 --- a/packages/ui/src/fields/Relationship/Input.tsx +++ b/packages/ui/src/fields/Relationship/Input.tsx @@ -254,7 +254,6 @@ export const RelationshipInput: React.FC = (props) => { where: Where } = { depth: 0, - draft: true, limit: maxResultsPerRequest, locale, page: lastLoadedPageToUse, @@ -262,6 +261,7 @@ export const RelationshipInput: React.FC = (props) => { [fieldToSearch]: true, }, sort: fieldToSort, + version: 'latest', where: { and: [ { @@ -437,12 +437,12 @@ export const RelationshipInput: React.FC = (props) => { const query = { depth: 0, - draft: true, limit: idsToLoad.length, locale, select: { [fieldToSelect]: true, }, + version: 'latest', where: { id: { in: idsToLoad, diff --git a/packages/ui/src/fields/Upload/Input.tsx b/packages/ui/src/fields/Upload/Input.tsx index 56a0bf25bd0..91acfab47d0 100644 --- a/packages/ui/src/fields/Upload/Input.tsx +++ b/packages/ui/src/fields/Upload/Input.tsx @@ -267,9 +267,9 @@ export function UploadInput(props: UploadInputProps) { const fetches = Object.entries(grouped).map(async ([collection, ids]) => { const query = { depth: 0, - draft: true, limit: ids.length, locale: code, + version: 'latest', where: { and: [ { diff --git a/packages/ui/src/utilities/buildTableState.ts b/packages/ui/src/utilities/buildTableState.ts index 4425ef3f80e..9c8f8fa1c7d 100644 --- a/packages/ui/src/utilities/buildTableState.ts +++ b/packages/ui/src/utilities/buildTableState.ts @@ -188,13 +188,13 @@ const buildTableState: ServerFunction< data = await payload.find({ collection: collectionSlug, depth: 0, - draft: true, limit: query?.limit, locale: req.locale, overrideAccess: false, page: query?.page, sort: query?.sort, user: req.user, + version: 'latest', where: query?.where, }) } diff --git a/packages/ui/src/utilities/copyDataFromLocale.ts b/packages/ui/src/utilities/copyDataFromLocale.ts index 82502266a0e..e61f04266ef 100644 --- a/packages/ui/src/utilities/copyDataFromLocale.ts +++ b/packages/ui/src/utilities/copyDataFromLocale.ts @@ -9,7 +9,12 @@ import { type ServerFunction, traverseFields, } from 'payload' -import { fieldAffectsData, fieldShouldBeLocalized, tabHasName } from 'payload/shared' +import { + fieldAffectsData, + fieldShouldBeLocalized, + hasDraftsEnabled, + tabHasName, +} from 'payload/shared' const ObjectId = 'default' in ObjectIdImport ? ObjectIdImport.default : ObjectIdImport @@ -250,42 +255,42 @@ export const copyDataFromLocale = async (args: CopyDataFromLocaleArgs) => { ? payload.findGlobal({ slug: globalSlug, depth: 0, - draft: true, locale: fromLocale, overrideAccess: false, user, + version: 'latest', // `select` would allow us to select only the fields we need in the future }) : payload.findByID({ id: docID, collection: collectionSlug, depth: 0, - draft: true, joins: false, locale: fromLocale, overrideAccess: false, user, + version: 'latest', // `select` would allow us to select only the fields we need in the future }), globalSlug ? payload.findGlobal({ slug: globalSlug, depth: 0, - draft: true, locale: toLocale, overrideAccess: false, user, + version: 'latest', // `select` would allow us to select only the fields we need in the future }) : payload.findByID({ id: docID, collection: collectionSlug, depth: 0, - draft: true, joins: false, locale: toLocale, overrideAccess: false, user, + version: 'latest', // `select` would allow us to select only the fields we need in the future }), ]) @@ -311,11 +316,15 @@ export const copyDataFromLocale = async (args: CopyDataFromLocaleArgs) => { const data = removeIdIfParentIsLocalized(dataWithID, fields) + const draftsEnabled = globalSlug + ? hasDraftsEnabled(globals[globalSlug].config) + : hasDraftsEnabled(collections[collectionSlug].config) + return globalSlug ? await payload.updateGlobal({ slug: globalSlug, + ...(draftsEnabled ? { action: 'saveDraft' as const } : {}), data, - draft: true, locale: toLocale, overrideAccess: false, req, @@ -323,9 +332,9 @@ export const copyDataFromLocale = async (args: CopyDataFromLocaleArgs) => { }) : await payload.update({ id: docID, + ...(draftsEnabled ? { action: 'saveDraft' as const } : {}), collection: collectionSlug, data, - draft: true, locale: toLocale, overrideAccess: false, req, diff --git a/packages/ui/src/utilities/getDocumentData.ts b/packages/ui/src/utilities/getDocumentData.ts index 7ea8a4c6b13..848d4ac099f 100644 --- a/packages/ui/src/utilities/getDocumentData.ts +++ b/packages/ui/src/utilities/getDocumentData.ts @@ -42,7 +42,6 @@ export const getDocumentData = async ({ id, collection: collectionSlug, depth: 0, - draft: true, fallbackLocale: false, locale: locale?.code, overrideAccess: false, @@ -51,6 +50,7 @@ export const getDocumentData = async ({ }, trash: isTrashedDoc ? true : false, user, + version: 'latest', }) } @@ -58,7 +58,6 @@ export const getDocumentData = async ({ resolvedData = await payload.findGlobal({ slug: globalSlug, depth: 0, - draft: true, fallbackLocale: false, locale: locale?.code, overrideAccess: false, @@ -66,6 +65,7 @@ export const getDocumentData = async ({ ...rest, }, user, + version: 'latest', }) } } catch (err) { diff --git a/packages/ui/src/utilities/getVersions.ts b/packages/ui/src/utilities/getVersions.ts index 9f4e1eff9ca..0598c3db073 100644 --- a/packages/ui/src/utilities/getVersions.ts +++ b/packages/ui/src/utilities/getVersions.ts @@ -221,6 +221,7 @@ export const getVersions = async ({ publishedDoc = await payload.findGlobal({ slug: globalConfig.slug, depth: 0, + disableErrors: true, locale, select: { updatedAt: true, diff --git a/packages/ui/src/utilities/handleStaleDataCheck.ts b/packages/ui/src/utilities/handleStaleDataCheck.ts index 48ca8b68f1c..d046ca73a91 100644 --- a/packages/ui/src/utilities/handleStaleDataCheck.ts +++ b/packages/ui/src/utilities/handleStaleDataCheck.ts @@ -34,12 +34,12 @@ export const handleStaleDataCheck = async ({ id, collection: collectionSlug, depth: 0, - draft: collectionHasDrafts, overrideAccess: false, select: { updatedAt: true, }, user: req.user, + version: collectionHasDrafts ? 'latest' : undefined, }) currentUpdatedAt = currentDoc?.updatedAt as string @@ -51,12 +51,12 @@ export const handleStaleDataCheck = async ({ const currentGlobal = await req.payload.findGlobal({ slug: globalSlug, depth: 0, - draft: globalHasDrafts, overrideAccess: false, select: { updatedAt: true, }, user: req.user, + version: globalHasDrafts ? 'latest' : undefined, }) currentUpdatedAt = currentGlobal?.updatedAt as string diff --git a/packages/ui/src/views/API/index.client.tsx b/packages/ui/src/views/API/index.client.tsx index 4c48708b2fa..e689ad73106 100644 --- a/packages/ui/src/views/API/index.client.tsx +++ b/packages/ui/src/views/API/index.client.tsx @@ -62,7 +62,7 @@ export const APIViewClient: React.FC = () => { } const [data, setData] = React.useState(initialData) - const [draft, setDraft] = React.useState(searchParams.get('draft') === 'true') + const [draft, setDraft] = React.useState(searchParams.get('version') === 'latest') const [locale, setLocale] = React.useState(searchParams?.get('locale') || code) const [depth, setDepth] = React.useState( searchParams.get('depth') || defaultDepth.toString(), @@ -93,10 +93,13 @@ export const APIViewClient: React.FC = () => { const params = new URLSearchParams({ depth, - draft: String(draft), locale, trash: trashParam ? 'true' : 'false', - }).toString() + }) + + if (draft) { + params.set('version', 'latest') + } const fetchURL = formatAdminURL({ apiRoute, diff --git a/packages/ui/src/views/Document/index.tsx b/packages/ui/src/views/Document/index.tsx index 48588e0330d..c355810b52b 100644 --- a/packages/ui/src/views/Document/index.tsx +++ b/packages/ui/src/views/Document/index.tsx @@ -289,7 +289,7 @@ export const renderDocument = async ({ const formattedParams = new URLSearchParams() if (hasDraftsEnabled(collectionConfig || globalConfig)) { - formattedParams.append('draft', 'true') + formattedParams.append('version', 'latest') } if (locale?.code) { @@ -354,10 +354,10 @@ export const renderDocument = async ({ if (shouldAutosave && !validateDraftData && !idFromArgs && collectionSlug) { doc = await payload.create({ + action: 'saveDraft', collection: collectionSlug, data: initialData || {}, depth: 0, - draft: true, fallbackLocale: false, locale: locale?.code, req, diff --git a/packages/ui/src/views/List/enrichDocsWithVersionStatus.ts b/packages/ui/src/views/List/enrichDocsWithVersionStatus.ts index ff1973ebbfe..c82eb0acc61 100644 --- a/packages/ui/src/views/List/enrichDocsWithVersionStatus.ts +++ b/packages/ui/src/views/List/enrichDocsWithVersionStatus.ts @@ -2,7 +2,7 @@ import type { PaginatedDocs, PayloadRequest, SanitizedCollectionConfig } from 'p /** * Enriches list view documents with correct draft status display. - * When draft=true is used in the query, Payload returns the latest draft version if it exists. + * When version=latest is used in the query, Payload returns the newest draft if it exists. * This function checks if draft documents also have a published version to determine "changed" status. * * Performance: Uses a single query to find all documents with "changed" status instead of N queries. @@ -23,7 +23,7 @@ export async function enrichDocsWithVersionStatus({ } // Find all draft documents - // When querying with draft:true, we get the latest draft if it exists + // When querying with version: 'latest', we get the newest draft if it exists // We need to check if these drafts have a published version const draftDocs = data.docs.filter((doc) => doc._status === 'draft') diff --git a/packages/ui/src/views/List/handleGroupBy.ts b/packages/ui/src/views/List/handleGroupBy.ts index 2b7ae7f4126..c19f1c13615 100644 --- a/packages/ui/src/views/List/handleGroupBy.ts +++ b/packages/ui/src/views/List/handleGroupBy.ts @@ -118,7 +118,6 @@ export const handleGroupBy = async ({ const groupData = await req.payload.find({ collection: collectionSlug, depth: 0, - draft: true, fallbackLocale: false, includeLockStatus: true, limit: query?.queryByGroup?.[valueOrRelationshipID]?.limit @@ -136,6 +135,7 @@ export const handleGroupBy = async ({ sort: query?.sort, trash, user, + version: 'latest', where: { ...(whereWithMergedSearch || {}), [groupByFieldPath]: { diff --git a/packages/ui/src/views/List/handleHierarchy.ts b/packages/ui/src/views/List/handleHierarchy.ts index d76f05bfd8a..963ca117e4f 100644 --- a/packages/ui/src/views/List/handleHierarchy.ts +++ b/packages/ui/src/views/List/handleHierarchy.ts @@ -133,7 +133,6 @@ export const handleHierarchy = async ({ const childrenData = await req.payload.find({ collection: collectionSlug, depth: 0, - draft: true, fallbackLocale: false, includeLockStatus: true, limit: DEFAULT_HIERARCHY_LIST_LIMIT, @@ -142,6 +141,7 @@ export const handleHierarchy = async ({ page: 1, req, user, + version: 'latest', where: combineWhereConstraints([childrenWhere, baseFilter]), }) @@ -220,7 +220,6 @@ export const handleHierarchy = async ({ const data = await req.payload.find({ collection: relatedSlug, depth: 0, - draft: true, fallbackLocale: false, includeLockStatus: true, limit: DEFAULT_HIERARCHY_LIST_LIMIT, @@ -229,6 +228,7 @@ export const handleHierarchy = async ({ page: 1, req, user, + version: 'latest', where, }) diff --git a/packages/ui/src/views/List/index.tsx b/packages/ui/src/views/List/index.tsx index 606d886974f..0be770e68ca 100644 --- a/packages/ui/src/views/List/index.tsx +++ b/packages/ui/src/views/List/index.tsx @@ -343,7 +343,6 @@ export const renderListView = async ( data = await req.payload.find({ collection: collectionSlug, depth: 0, - draft: true, fallbackLocale: false, includeLockStatus: true, limit: query?.limit ? Number(query.limit) : undefined, @@ -355,6 +354,7 @@ export const renderListView = async ( sort: query?.sort, trash, user, + version: 'latest', where: whereWithMergedSearch, }) diff --git a/packages/ui/src/views/Version/Restore/index.tsx b/packages/ui/src/views/Version/Restore/index.tsx index ebf0c852f4d..f0a2c2f4998 100644 --- a/packages/ui/src/views/Version/Restore/index.tsx +++ b/packages/ui/src/views/Version/Restore/index.tsx @@ -72,7 +72,7 @@ export const Restore: React.FC = ({ let redirectURL: string if (collectionConfig) { - fetchURL = `${fetchURL}/${collectionConfig.slug}/versions/${versionID}?draft=${draft}` + fetchURL = `${fetchURL}/${collectionConfig.slug}/versions/${versionID}?action=${draft ? 'saveDraft' : 'publish'}` redirectURL = formatAdminURL({ adminRoute, path: `/collections/${collectionConfig.slug}/${originalDocID}`, @@ -80,7 +80,7 @@ export const Restore: React.FC = ({ } if (globalConfig) { - fetchURL = `${fetchURL}/globals/${globalConfig.slug}/versions/${versionID}?draft=${draft}` + fetchURL = `${fetchURL}/globals/${globalConfig.slug}/versions/${versionID}?action=${draft ? 'saveDraft' : 'publish'}` redirectURL = formatAdminURL({ adminRoute, path: `/globals/${globalConfig.slug}`, diff --git a/packages/ui/src/views/Version/index.tsx b/packages/ui/src/views/Version/index.tsx index 09873f54bf0..56e9d89a133 100644 --- a/packages/ui/src/views/Version/index.tsx +++ b/packages/ui/src/views/Version/index.tsx @@ -87,7 +87,6 @@ export async function VersionView(props: DocumentViewServerProps) { // If versionFromIDFromParams is provided, the previous version is only used in the version comparison dropdown => depth 0 is enough. // If it's not provided, this is used as `versionFrom` in the comparison, which expects populated data => depth 1 is needed. depth: versionFromIDFromParams ? 0 : 1, - draft: true, globalSlug, limit: 1, locale: 'all', @@ -153,7 +152,6 @@ export async function VersionView(props: DocumentViewServerProps) { ? fetchVersions({ collectionSlug, depth: 0, - draft: true, globalSlug, limit: 1, locale: 'all', diff --git a/packages/ui/src/views/Versions/fetchVersions.ts b/packages/ui/src/views/Versions/fetchVersions.ts index aef67378fd2..4a7e5cdf8d8 100644 --- a/packages/ui/src/views/Versions/fetchVersions.ts +++ b/packages/ui/src/views/Versions/fetchVersions.ts @@ -63,7 +63,6 @@ export const fetchVersion = async ({ export const fetchVersions = async ({ collectionSlug, depth, - draft, globalSlug, limit, locale, @@ -78,7 +77,6 @@ export const fetchVersions = async ({ }: { collectionSlug?: string depth?: number - draft?: boolean globalSlug?: string limit?: number locale?: 'all' | ({} & string) @@ -105,7 +103,6 @@ export const fetchVersions = async ({ return (await req.payload.findVersions({ collection: collectionSlug, depth, - draft, limit, locale, overrideAccess, @@ -189,7 +186,6 @@ export const fetchLatestVersion = async ({ const latest = await fetchVersions({ collectionSlug, depth, - draft: true, globalSlug, limit: 1, locale, diff --git a/templates/ecommerce/src/app/(app)/[slug]/page.tsx b/templates/ecommerce/src/app/(app)/[slug]/page.tsx index 1685fd4d65a..1845ddb3edb 100644 --- a/templates/ecommerce/src/app/(app)/[slug]/page.tsx +++ b/templates/ecommerce/src/app/(app)/[slug]/page.tsx @@ -16,7 +16,7 @@ export async function generateStaticParams() { const payload = await getPayload({ config: configPromise }) const pages = await payload.find({ collection: 'pages', - draft: false, + version: 'published', limit: 1000, overrideAccess: false, pagination: false, @@ -87,7 +87,7 @@ const queryPageBySlug = async ({ slug }: { slug: string }) => { const result = await payload.find({ collection: 'pages', depth: 2, - draft, + version: draft ? 'latest' : 'published', limit: 1, overrideAccess: draft, pagination: false, @@ -103,5 +103,5 @@ const queryPageBySlug = async ({ slug }: { slug: string }) => { }, }) - return result.docs?.[0] || null + return (result.docs?.[0] || null) as Page | null } diff --git a/templates/ecommerce/src/app/(app)/products/[slug]/page.tsx b/templates/ecommerce/src/app/(app)/products/[slug]/page.tsx index 361439ddd1a..093c561b45f 100644 --- a/templates/ecommerce/src/app/(app)/products/[slug]/page.tsx +++ b/templates/ecommerce/src/app/(app)/products/[slug]/page.tsx @@ -190,7 +190,7 @@ const queryProductBySlug = async ({ slug }: { slug: string }) => { const result = await payload.find({ collection: 'products', depth: 3, - draft, + version: draft ? 'latest' : 'published', limit: 1, overrideAccess: draft, pagination: false, @@ -214,5 +214,5 @@ const queryProductBySlug = async ({ slug }: { slug: string }) => { }, }) - return result.docs?.[0] || null + return (result.docs?.[0] || null) as Product | null } diff --git a/templates/ecommerce/src/app/(app)/shop/page.tsx b/templates/ecommerce/src/app/(app)/shop/page.tsx index bcb980450b1..d8991a541be 100644 --- a/templates/ecommerce/src/app/(app)/shop/page.tsx +++ b/templates/ecommerce/src/app/(app)/shop/page.tsx @@ -21,7 +21,7 @@ export default async function ShopPage({ searchParams }: Props) { const products = await payload.find({ collection: 'products', - draft: false, + version: 'published', overrideAccess: false, select: { title: true, diff --git a/templates/ecommerce/src/payload-types.ts b/templates/ecommerce/src/payload-types.ts index ea84e3dc3c2..1f2905cf203 100644 --- a/templates/ecommerce/src/payload-types.ts +++ b/templates/ecommerce/src/payload-types.ts @@ -65,6 +65,74 @@ export type SupportedTimezones = | 'Pacific/Noumea' | 'Pacific/Auckland' | 'Pacific/Fiji'; +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "LexicalNodes_03B47A70". + */ +export type LexicalNodes_03B47A70 = + | SerializedTextNode + | SerializedTabNode + | SerializedLineBreakNode + | SerializedParagraphNode + | SerializedHeadingNode + | SerializedTableNode + | SerializedTableCellNode + | SerializedTableRowNode + | SerializedAutoLinkNode + | SerializedLinkNode + | SerializedListNode + | SerializedListItemNode; +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "LexicalNodes_0E2D08C7". + */ +export type LexicalNodes_0E2D08C7 = + | SerializedTextNode + | SerializedTabNode + | SerializedLineBreakNode + | SerializedParagraphNode + | SerializedHeadingNode + | SerializedTableNode + | SerializedTableCellNode + | SerializedTableRowNode + | SerializedAutoLinkNode + | SerializedLinkNode + | SerializedListNode + | SerializedListItemNode; +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "LexicalNodes_BCFC362F". + */ +export type LexicalNodes_BCFC362F = + | SerializedTextNode + | SerializedTabNode + | SerializedLineBreakNode + | SerializedParagraphNode + | SerializedTableNode + | SerializedTableCellNode + | SerializedTableRowNode + | SerializedAutoLinkNode + | SerializedLinkNode + | SerializedListNode + | SerializedListItemNode; +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "LexicalNodes_F0CBD193". + */ +export type LexicalNodes_F0CBD193 = + | SerializedTextNode + | SerializedTabNode + | SerializedLineBreakNode + | SerializedParagraphNode + | SerializedHorizontalRuleNode + | SerializedHeadingNode + | SerializedTableNode + | SerializedTableCellNode + | SerializedTableRowNode + | SerializedAutoLinkNode + | SerializedLinkNode + | SerializedListNode + | SerializedListItemNode; export interface Config { auth: { @@ -139,6 +207,8 @@ export interface Config { locale: null; widgets: { collections: CollectionsWidget; + 'collection-query': CollectionQueryWidget; + activity: ActivityWidget; }; user: User; jobs: { @@ -266,21 +336,7 @@ export interface Order { export interface Product { id: string; title: string; - description?: { - root: { - type: string; - children: { - type: any; - version: number; - [k: string]: unknown; - }[]; - direction: ('ltr' | 'rtl') | null; - format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; - indent: number; - version: number; - }; - [k: string]: unknown; - } | null; + description?: LexicalRichText | null; gallery?: | { image: string | Media; @@ -309,10 +365,6 @@ export interface Product { description?: string | null; }; categories?: (string | Category)[] | null; - /** - * When enabled, the slug will auto-generate from the title field on save and autosave. - */ - generateSlug?: boolean | null; slug: string; updatedAt: string; createdAt: string; @@ -326,21 +378,7 @@ export interface Product { export interface Media { id: string; alt: string; - caption?: { - root: { - type: string; - children: { - type: any; - version: number; - [k: string]: unknown; - }[]; - direction: ('ltr' | 'rtl') | null; - format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; - indent: number; - version: number; - }; - [k: string]: unknown; - } | null; + caption?: LexicalRichText | null; updatedAt: string; createdAt: string; url?: string | null; @@ -392,21 +430,7 @@ export interface VariantType { * via the `definition` "CallToActionBlock". */ export interface CallToActionBlock { - richText?: { - root: { - type: string; - children: { - type: any; - version: number; - [k: string]: unknown; - }[]; - direction: ('ltr' | 'rtl') | null; - format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; - indent: number; - version: number; - }; - [k: string]: unknown; - } | null; + richText?: LexicalRichText | null; links?: | { link: { @@ -440,21 +464,7 @@ export interface Page { publishedOn?: string | null; hero: { type: 'none' | 'highImpact' | 'mediumImpact' | 'lowImpact'; - richText?: { - root: { - type: string; - children: { - type: any; - version: number; - [k: string]: unknown; - }[]; - direction: ('ltr' | 'rtl') | null; - format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; - indent: number; - version: number; - }; - [k: string]: unknown; - } | null; + richText?: LexicalRichText | null; links?: | { link: { @@ -494,10 +504,6 @@ export interface Page { image?: (string | null) | Media; description?: string | null; }; - /** - * When enabled, the slug will auto-generate from the title field on save and autosave. - */ - generateSlug?: boolean | null; slug: string; updatedAt: string; createdAt: string; @@ -511,21 +517,7 @@ export interface ContentBlock { columns?: | { size?: ('oneThird' | 'half' | 'twoThirds' | 'full') | null; - richText?: { - root: { - type: string; - children: { - type: any; - version: number; - [k: string]: unknown; - }[]; - direction: ('ltr' | 'rtl') | null; - format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; - indent: number; - version: number; - }; - [k: string]: unknown; - } | null; + richText?: LexicalRichText | null; enableLink?: boolean | null; link?: { type?: ('reference' | 'custom') | null; @@ -563,21 +555,7 @@ export interface MediaBlock { * via the `definition` "ArchiveBlock". */ export interface ArchiveBlock { - introContent?: { - root: { - type: string; - children: { - type: any; - version: number; - [k: string]: unknown; - }[]; - direction: ('ltr' | 'rtl') | null; - format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; - indent: number; - version: number; - }; - [k: string]: unknown; - } | null; + introContent?: LexicalRichText | null; populateBy?: ('collection' | 'selection') | null; relationTo?: 'products' | null; categories?: (string | Category)[] | null; @@ -599,10 +577,6 @@ export interface ArchiveBlock { export interface Category { id: string; title: string; - /** - * When enabled, the slug will auto-generate from the title field on save and autosave. - */ - generateSlug?: boolean | null; slug: string; updatedAt: string; createdAt: string; @@ -655,21 +629,7 @@ export interface ThreeItemGridBlock { */ export interface BannerBlock { style: 'info' | 'warning' | 'error' | 'success'; - content: { - root: { - type: string; - children: { - type: any; - version: number; - [k: string]: unknown; - }[]; - direction: ('ltr' | 'rtl') | null; - format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; - indent: number; - version: number; - }; - [k: string]: unknown; - }; + content: LexicalRichText; id?: string | null; blockName?: string | null; blockType: 'banner'; @@ -681,21 +641,7 @@ export interface BannerBlock { export interface FormBlock { form: string | Form; enableIntro?: boolean | null; - introContent?: { - root: { - type: string; - children: { - type: any; - version: number; - [k: string]: unknown; - }[]; - direction: ('ltr' | 'rtl') | null; - format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; - indent: number; - version: number; - }; - [k: string]: unknown; - } | null; + introContent?: LexicalRichText | null; id?: string | null; blockName?: string | null; blockType: 'formBlock'; @@ -707,141 +653,13 @@ export interface FormBlock { export interface Form { id: string; title: string; - fields?: - | ( - | { - name: string; - label?: string | null; - width?: number | null; - required?: boolean | null; - defaultValue?: boolean | null; - id?: string | null; - blockName?: string | null; - blockType: 'checkbox'; - } - | { - name: string; - label?: string | null; - width?: number | null; - required?: boolean | null; - id?: string | null; - blockName?: string | null; - blockType: 'country'; - } - | { - name: string; - label?: string | null; - width?: number | null; - required?: boolean | null; - id?: string | null; - blockName?: string | null; - blockType: 'email'; - } - | { - message?: { - root: { - type: string; - children: { - type: any; - version: number; - [k: string]: unknown; - }[]; - direction: ('ltr' | 'rtl') | null; - format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; - indent: number; - version: number; - }; - [k: string]: unknown; - } | null; - id?: string | null; - blockName?: string | null; - blockType: 'message'; - } - | { - name: string; - label?: string | null; - width?: number | null; - defaultValue?: number | null; - required?: boolean | null; - id?: string | null; - blockName?: string | null; - blockType: 'number'; - } - | { - name: string; - label?: string | null; - width?: number | null; - defaultValue?: string | null; - placeholder?: string | null; - options?: - | { - label: string; - value: string; - id?: string | null; - }[] - | null; - required?: boolean | null; - id?: string | null; - blockName?: string | null; - blockType: 'select'; - } - | { - name: string; - label?: string | null; - width?: number | null; - required?: boolean | null; - id?: string | null; - blockName?: string | null; - blockType: 'state'; - } - | { - name: string; - label?: string | null; - width?: number | null; - defaultValue?: string | null; - required?: boolean | null; - id?: string | null; - blockName?: string | null; - blockType: 'text'; - } - | { - name: string; - label?: string | null; - width?: number | null; - defaultValue?: string | null; - required?: boolean | null; - id?: string | null; - blockName?: string | null; - blockType: 'textarea'; - } - )[] - | null; + fields?: (Checkbox | Country | Email | Message | Number | Select | State | Text | Textarea)[] | null; submitButtonLabel?: string | null; - /** - * Choose whether to display an on-page message or redirect to a different page after they submit the form. - */ confirmationType?: ('message' | 'redirect') | null; - confirmationMessage?: { - root: { - type: string; - children: { - type: any; - version: number; - [k: string]: unknown; - }[]; - direction: ('ltr' | 'rtl') | null; - format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; - indent: number; - version: number; - }; - [k: string]: unknown; - } | null; + confirmationMessage?: LexicalRichText; redirect?: { url: string; }; - /** - * Send custom emails when the form submits. Use comma separated lists to send the same email to multiple recipients. To reference a value from this form, wrap that field's name with double curly brackets, i.e. {{firstName}}. You can use a wildcard {{*}} to output all data and {{*:table}} to format it as an HTML table in the email. - */ emails?: | { emailTo?: string | null; @@ -850,30 +668,140 @@ export interface Form { replyTo?: string | null; emailFrom?: string | null; subject: string; - /** - * Enter the message that should be sent in this email. - */ - message?: { - root: { - type: string; - children: { - type: any; - version: number; - [k: string]: unknown; - }[]; - direction: ('ltr' | 'rtl') | null; - format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; - indent: number; - version: number; - }; - [k: string]: unknown; - } | null; + message?: LexicalRichText | null; id?: string | null; }[] | null; updatedAt: string; createdAt: string; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "Checkbox". + */ +export interface Checkbox { + name: string; + label?: string | null; + width?: number | null; + required?: boolean | null; + defaultValue?: boolean | null; + id?: string | null; + blockName?: string | null; + blockType: 'checkbox'; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "Country". + */ +export interface Country { + name: string; + label?: string | null; + width?: number | null; + required?: boolean | null; + id?: string | null; + blockName?: string | null; + blockType: 'country'; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "Email". + */ +export interface Email { + name: string; + label?: string | null; + width?: number | null; + required?: boolean | null; + id?: string | null; + blockName?: string | null; + blockType: 'email'; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "Message". + */ +export interface Message { + message?: LexicalRichText | null; + id?: string | null; + blockName?: string | null; + blockType: 'message'; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "Number". + */ +export interface Number { + name: string; + label?: string | null; + width?: number | null; + defaultValue?: number | null; + required?: boolean | null; + id?: string | null; + blockName?: string | null; + blockType: 'number'; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "Select". + */ +export interface Select { + name: string; + label?: string | null; + width?: number | null; + defaultValue?: string | null; + placeholder?: string | null; + options?: + | { + label: string; + value: string; + id?: string | null; + }[] + | null; + required?: boolean | null; + id?: string | null; + blockName?: string | null; + blockType: 'select'; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "State". + */ +export interface State { + name: string; + label?: string | null; + width?: number | null; + required?: boolean | null; + id?: string | null; + blockName?: string | null; + blockType: 'state'; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "Text". + */ +export interface Text { + name: string; + label?: string | null; + width?: number | null; + defaultValue?: string | null; + required?: boolean | null; + id?: string | null; + blockName?: string | null; + blockType: 'text'; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "Textarea". + */ +export interface Textarea { + name: string; + label?: string | null; + width?: number | null; + defaultValue?: string | null; + required?: boolean | null; + id?: string | null; + blockName?: string | null; + blockType: 'textarea'; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "variants". @@ -1234,7 +1162,6 @@ export interface PagesSelect { image?: T; description?: T; }; - generateSlug?: T; slug?: T; updatedAt?: T; createdAt?: T; @@ -1364,7 +1291,6 @@ export interface FormBlockSelect { */ export interface CategoriesSelect { title?: T; - generateSlug?: T; slug?: T; updatedAt?: T; createdAt?: T; @@ -1634,7 +1560,6 @@ export interface ProductsSelect { description?: T; }; categories?: T; - generateSlug?: T; slug?: T; updatedAt?: T; createdAt?: T; @@ -1890,6 +1815,68 @@ export interface CollectionsWidget { }; width: 'full'; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "collection-query_widget". + */ +export interface CollectionQueryWidget { + data?: { + title?: string | null; + relatedCollection: + | 'users' + | 'pages' + | 'categories' + | 'media' + | 'forms' + | 'form-submissions' + | 'variants' + | 'variantTypes' + | 'variantOptions' + | 'products' + | 'carts' + | 'orders' + | 'transactions'; + where?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + sortField?: string | null; + sortDirection?: ('asc' | 'desc') | null; + limit?: number | null; + }; + width: 'x-small' | 'small' | 'medium' | 'large' | 'x-large' | 'full'; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "activity_widget". + */ +export interface ActivityWidget { + data?: { + excludedCollections?: + | ( + | 'users' + | 'pages' + | 'categories' + | 'media' + | 'forms' + | 'form-submissions' + | 'variants' + | 'variantTypes' + | 'variantOptions' + | 'products' + | 'carts' + | 'orders' + | 'transactions' + )[] + | null; + }; + width: 'x-small' | 'small' | 'medium' | 'large' | 'x-large' | 'full'; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "auth". @@ -1898,6 +1885,133 @@ export interface Auth { [k: string]: unknown; } +/** @internal Core Lexical types — see @payloadcms/richtext-lexical. */ +export type LexicalElementFormat = 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; +export type LexicalElementDirection = ('ltr' | 'rtl') | null; + +export interface SerializedLexicalElementBase { + children: TChildren[]; + direction: LexicalElementDirection; + format: LexicalElementFormat; + indent: number; + textFormat?: number; + textStyle?: string; + version: number; +} + +export type LexicalTextMode = 'normal' | 'token' | 'segmented'; + +export interface SerializedTextNode { + type: 'text'; + detail: number; + format: number; + mode: LexicalTextMode; + style: string; + text: string; + version: number; +} + +export interface SerializedTabNode { + type: 'tab'; + detail: number; + format: number; + mode: LexicalTextMode; + style: string; + text: string; + version: number; +} + +export interface SerializedLineBreakNode { + type: 'linebreak'; + version: number; +} + +export interface SerializedParagraphNode extends SerializedLexicalElementBase { + type: 'paragraph'; + textFormat: number; + textStyle: string; +} + +export interface SerializedHeadingNode< + TChildren, + TTag extends 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6', +> extends SerializedLexicalElementBase { + type: 'heading'; + tag: TTag; +} + +export interface SerializedTableNode extends SerializedLexicalElementBase { + type: 'table'; + colWidths?: number[]; + frozenColumnCount?: number; + frozenRowCount?: number; + rowStriping?: boolean; +} +export interface SerializedTableRowNode extends SerializedLexicalElementBase { + type: 'tablerow'; + height?: number; +} +export interface SerializedTableCellNode extends SerializedLexicalElementBase { + type: 'tablecell'; + backgroundColor?: string | null; + colSpan?: number; + headerState: number; + rowSpan?: number; + verticalAlign?: string; + width?: number; +} + +export interface LexicalLinkFields { + [k: string]: unknown; + doc?: { + relationTo: string; + value: Config['db']['defaultIDType'] | { [k: string]: unknown; id: Config['db']['defaultIDType'] }; + } | null; + linkType: 'custom' | 'internal'; + newTab: boolean; + url?: string; +} +export interface SerializedLinkNode extends SerializedLexicalElementBase { + type: 'link'; + fields: TFields; + id?: string; +} +export interface SerializedAutoLinkNode extends SerializedLexicalElementBase { + type: 'autolink'; + fields: TFields; +} + +export interface SerializedListNode extends SerializedLexicalElementBase { + type: 'list'; + checked?: boolean; + listType: 'number' | 'bullet' | 'check'; + start: number; + tag: 'ul' | 'ol'; +} + +export interface SerializedListItemNode extends SerializedLexicalElementBase { + type: 'listitem'; + checked?: boolean; + value: number; +} + +/** Shape of a Lexical `richText` field. */ +export interface LexicalRichText { + root: { + children: TNode[]; + direction: LexicalElementDirection; + format: LexicalElementFormat; + indent: number; + type: 'root'; + version: number; + }; +} + +export interface SerializedHorizontalRuleNode { + type: 'horizontalrule'; + version: number; +} + declare module 'payload' { export interface GeneratedTypes extends Config {} diff --git a/templates/website/src/app/(frontend)/(sitemaps)/pages-sitemap.xml/route.ts b/templates/website/src/app/(frontend)/(sitemaps)/pages-sitemap.xml/route.ts index c73e1ea7559..2ddec7961f1 100644 --- a/templates/website/src/app/(frontend)/(sitemaps)/pages-sitemap.xml/route.ts +++ b/templates/website/src/app/(frontend)/(sitemaps)/pages-sitemap.xml/route.ts @@ -14,7 +14,7 @@ const getPagesSitemap = unstable_cache( const results = await payload.find({ collection: 'pages', overrideAccess: false, - draft: false, + version: 'published', depth: 0, limit: 1000, pagination: false, diff --git a/templates/website/src/app/(frontend)/(sitemaps)/posts-sitemap.xml/route.ts b/templates/website/src/app/(frontend)/(sitemaps)/posts-sitemap.xml/route.ts index 0716abbc2c8..c129dced54e 100644 --- a/templates/website/src/app/(frontend)/(sitemaps)/posts-sitemap.xml/route.ts +++ b/templates/website/src/app/(frontend)/(sitemaps)/posts-sitemap.xml/route.ts @@ -14,7 +14,7 @@ const getPostsSitemap = unstable_cache( const results = await payload.find({ collection: 'posts', overrideAccess: false, - draft: false, + version: 'published', depth: 0, limit: 1000, pagination: false, diff --git a/templates/website/src/app/(frontend)/[slug]/page.tsx b/templates/website/src/app/(frontend)/[slug]/page.tsx index eaf3d5ddf54..70a2b25e8aa 100644 --- a/templates/website/src/app/(frontend)/[slug]/page.tsx +++ b/templates/website/src/app/(frontend)/[slug]/page.tsx @@ -17,7 +17,7 @@ export async function generateStaticParams() { const payload = await getPayload({ config: configPromise }) const pages = await payload.find({ collection: 'pages', - draft: false, + version: 'published', limit: 1000, overrideAccess: false, pagination: false, @@ -99,7 +99,7 @@ const queryPageBySlug = cache(async ({ slug }: { slug: string }) => { const result = await payload.find({ collection: 'pages', depth: 2, - draft, + version: draft ? 'latest' : 'published', limit: 1, pagination: false, overrideAccess: draft, @@ -110,5 +110,5 @@ const queryPageBySlug = cache(async ({ slug }: { slug: string }) => { }, }) - return result.docs?.[0] || null + return (result.docs?.[0] || null) as RequiredDataFromCollectionSlug<'pages'> | null }) diff --git a/templates/website/src/app/(frontend)/posts/[slug]/page.tsx b/templates/website/src/app/(frontend)/posts/[slug]/page.tsx index ac5c9a1e6e7..a3e7f6c9c35 100644 --- a/templates/website/src/app/(frontend)/posts/[slug]/page.tsx +++ b/templates/website/src/app/(frontend)/posts/[slug]/page.tsx @@ -19,7 +19,7 @@ export async function generateStaticParams() { const payload = await getPayload({ config: configPromise }) const posts = await payload.find({ collection: 'posts', - draft: false, + version: 'published', limit: 1000, overrideAccess: false, pagination: false, @@ -94,7 +94,7 @@ const queryPostBySlug = cache(async ({ slug }: { slug: string }) => { const result = await payload.find({ collection: 'posts', depth: 2, - draft, + version: draft ? 'latest' : 'published', limit: 1, overrideAccess: draft, pagination: false, @@ -105,5 +105,5 @@ const queryPostBySlug = cache(async ({ slug }: { slug: string }) => { }, }) - return result.docs?.[0] || null + return (result.docs?.[0] || null) as Post | null }) diff --git a/templates/website/src/payload-types.ts b/templates/website/src/payload-types.ts index 55295b55282..2ccd92961e9 100644 --- a/templates/website/src/payload-types.ts +++ b/templates/website/src/payload-types.ts @@ -157,14 +157,18 @@ export interface Config { globals: { header: Header; footer: Footer; + 'payload-jobs-stats': PayloadJobsStat; }; globalsSelect: { header: HeaderSelect | HeaderSelect; footer: FooterSelect | FooterSelect; + 'payload-jobs-stats': PayloadJobsStatsSelect | PayloadJobsStatsSelect; }; locale: null; widgets: { collections: CollectionsWidget; + 'collection-query': CollectionQueryWidget; + activity: ActivityWidget; }; user: User; jobs: { @@ -255,10 +259,6 @@ export interface Page { description?: string | null; }; publishedAt?: string | null; - /** - * When enabled, the slug will auto-generate from the title field on save and autosave. - */ - generateSlug?: boolean | null; slug: string; updatedAt: string; createdAt: string; @@ -291,10 +291,6 @@ export interface Post { name?: string | null; }[] | null; - /** - * When enabled, the slug will auto-generate from the title field on save and autosave. - */ - generateSlug?: boolean | null; slug: string; updatedAt: string; createdAt: string; @@ -386,10 +382,6 @@ export interface Media { export interface Category { id: string; title: string; - /** - * When enabled, the slug will auto-generate from the title field on save and autosave. - */ - generateSlug?: boolean | null; slug: string; parent?: (string | null) | Category; breadcrumbs?: @@ -550,17 +542,11 @@ export interface Form { title: string; fields?: (Checkbox | Country | Email | Message | Number | Select | State | Text | Textarea)[] | null; submitButtonLabel?: string | null; - /** - * Choose whether to display an on-page message or redirect to a different page after they submit the form. - */ confirmationType?: ('message' | 'redirect') | null; confirmationMessage?: LexicalRichText; redirect?: { url: string; }; - /** - * Send custom emails when the form submits. Use comma separated lists to send the same email to multiple recipients. To reference a value from this form, wrap that field's name with double curly brackets, i.e. {{firstName}}. You can use a wildcard {{*}} to output all data and {{*:table}} to format it as an HTML table in the email. - */ emails?: | { emailTo?: string | null; @@ -569,9 +555,6 @@ export interface Form { replyTo?: string | null; emailFrom?: string | null; subject: string; - /** - * Enter the message that should be sent in this email. - */ message?: LexicalRichText | null; id?: string | null; }[] @@ -824,6 +807,15 @@ export interface PayloadJob { | number | boolean | null; + meta?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; completedAt?: string | null; totalTried?: number | null; /** @@ -851,7 +843,7 @@ export interface PayloadJob { completedAt: string; taskSlug: 'inline' | 'schedulePublish'; taskID: string; - input?: + input: | { [k: string]: unknown; } @@ -879,13 +871,22 @@ export interface PayloadJob { | number | boolean | null; + parent?: { + taskSlug?: ('inline' | 'schedulePublish') | null; + taskID?: string | null; + }; id?: string | null; }[] | null; taskSlug?: ('inline' | 'schedulePublish') | null; queue?: string | null; waitUntil?: string | null; - processing?: boolean | null; + processingUntil?: string | null; + processingToken?: string | null; + /** + * Used for concurrency control. Jobs with the same key are subject to exclusive/supersedes rules. + */ + concurrencyKey?: string | null; updatedAt: string; createdAt: string; } @@ -1035,7 +1036,6 @@ export interface PagesSelect { description?: T; }; publishedAt?: T; - generateSlug?: T; slug?: T; updatedAt?: T; createdAt?: T; @@ -1150,7 +1150,6 @@ export interface PostsSelect { id?: T; name?: T; }; - generateSlug?: T; slug?: T; updatedAt?: T; createdAt?: T; @@ -1256,7 +1255,6 @@ export interface MediaSelect { */ export interface CategoriesSelect { title?: T; - generateSlug?: T; slug?: T; parent?: T; breadcrumbs?: @@ -1500,6 +1498,7 @@ export interface PayloadKvSelect { export interface PayloadJobsSelect { input?: T; taskStatus?: T; + meta?: T; completedAt?: T; totalTried?: T; hasError?: T; @@ -1515,12 +1514,20 @@ export interface PayloadJobsSelect { output?: T; state?: T; error?: T; + parent?: + | T + | { + taskSlug?: T; + taskID?: T; + }; id?: T; }; taskSlug?: T; queue?: T; waitUntil?: T; - processing?: T; + processingUntil?: T; + processingToken?: T; + concurrencyKey?: T; updatedAt?: T; createdAt?: T; } @@ -1614,6 +1621,24 @@ export interface Footer { updatedAt?: string | null; createdAt?: string | null; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-jobs-stats". + */ +export interface PayloadJobsStat { + id: string; + stats?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + updatedAt?: string | null; + createdAt?: string | null; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "header_select". @@ -1660,6 +1685,16 @@ export interface FooterSelect { createdAt?: T; globalType?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-jobs-stats_select". + */ +export interface PayloadJobsStatsSelect { + stats?: T; + updatedAt?: T; + createdAt?: T; + globalType?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "collections_widget". @@ -1670,6 +1705,62 @@ export interface CollectionsWidget { }; width: 'full'; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "collection-query_widget". + */ +export interface CollectionQueryWidget { + data?: { + title?: string | null; + relatedCollection: + | 'folders' + | 'pages' + | 'posts' + | 'media' + | 'categories' + | 'users' + | 'redirects' + | 'forms' + | 'form-submissions' + | 'search'; + where?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + sortField?: string | null; + sortDirection?: ('asc' | 'desc') | null; + limit?: number | null; + }; + width: 'x-small' | 'small' | 'medium' | 'large' | 'x-large' | 'full'; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "activity_widget". + */ +export interface ActivityWidget { + data?: { + excludedCollections?: + | ( + | 'folders' + | 'pages' + | 'posts' + | 'media' + | 'categories' + | 'users' + | 'redirects' + | 'forms' + | 'form-submissions' + | 'search' + )[] + | null; + }; + width: 'x-small' | 'small' | 'medium' | 'large' | 'x-large' | 'full'; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "TaskSchedulePublish". diff --git a/templates/with-vercel-website/src/app/(frontend)/(sitemaps)/pages-sitemap.xml/route.ts b/templates/with-vercel-website/src/app/(frontend)/(sitemaps)/pages-sitemap.xml/route.ts index c73e1ea7559..2ddec7961f1 100644 --- a/templates/with-vercel-website/src/app/(frontend)/(sitemaps)/pages-sitemap.xml/route.ts +++ b/templates/with-vercel-website/src/app/(frontend)/(sitemaps)/pages-sitemap.xml/route.ts @@ -14,7 +14,7 @@ const getPagesSitemap = unstable_cache( const results = await payload.find({ collection: 'pages', overrideAccess: false, - draft: false, + version: 'published', depth: 0, limit: 1000, pagination: false, diff --git a/templates/with-vercel-website/src/app/(frontend)/(sitemaps)/posts-sitemap.xml/route.ts b/templates/with-vercel-website/src/app/(frontend)/(sitemaps)/posts-sitemap.xml/route.ts index 0716abbc2c8..c129dced54e 100644 --- a/templates/with-vercel-website/src/app/(frontend)/(sitemaps)/posts-sitemap.xml/route.ts +++ b/templates/with-vercel-website/src/app/(frontend)/(sitemaps)/posts-sitemap.xml/route.ts @@ -14,7 +14,7 @@ const getPostsSitemap = unstable_cache( const results = await payload.find({ collection: 'posts', overrideAccess: false, - draft: false, + version: 'published', depth: 0, limit: 1000, pagination: false, diff --git a/templates/with-vercel-website/src/app/(frontend)/[slug]/page.tsx b/templates/with-vercel-website/src/app/(frontend)/[slug]/page.tsx index eaf3d5ddf54..70a2b25e8aa 100644 --- a/templates/with-vercel-website/src/app/(frontend)/[slug]/page.tsx +++ b/templates/with-vercel-website/src/app/(frontend)/[slug]/page.tsx @@ -17,7 +17,7 @@ export async function generateStaticParams() { const payload = await getPayload({ config: configPromise }) const pages = await payload.find({ collection: 'pages', - draft: false, + version: 'published', limit: 1000, overrideAccess: false, pagination: false, @@ -99,7 +99,7 @@ const queryPageBySlug = cache(async ({ slug }: { slug: string }) => { const result = await payload.find({ collection: 'pages', depth: 2, - draft, + version: draft ? 'latest' : 'published', limit: 1, pagination: false, overrideAccess: draft, @@ -110,5 +110,5 @@ const queryPageBySlug = cache(async ({ slug }: { slug: string }) => { }, }) - return result.docs?.[0] || null + return (result.docs?.[0] || null) as RequiredDataFromCollectionSlug<'pages'> | null }) diff --git a/templates/with-vercel-website/src/app/(frontend)/posts/[slug]/page.tsx b/templates/with-vercel-website/src/app/(frontend)/posts/[slug]/page.tsx index ac5c9a1e6e7..a3e7f6c9c35 100644 --- a/templates/with-vercel-website/src/app/(frontend)/posts/[slug]/page.tsx +++ b/templates/with-vercel-website/src/app/(frontend)/posts/[slug]/page.tsx @@ -19,7 +19,7 @@ export async function generateStaticParams() { const payload = await getPayload({ config: configPromise }) const posts = await payload.find({ collection: 'posts', - draft: false, + version: 'published', limit: 1000, overrideAccess: false, pagination: false, @@ -94,7 +94,7 @@ const queryPostBySlug = cache(async ({ slug }: { slug: string }) => { const result = await payload.find({ collection: 'posts', depth: 2, - draft, + version: draft ? 'latest' : 'published', limit: 1, overrideAccess: draft, pagination: false, @@ -105,5 +105,5 @@ const queryPostBySlug = cache(async ({ slug }: { slug: string }) => { }, }) - return result.docs?.[0] || null + return (result.docs?.[0] || null) as Post | null }) diff --git a/test/__helpers/shared/sdk/types.ts b/test/__helpers/shared/sdk/types.ts index 4bea0d4b901..14f8ee44767 100644 --- a/test/__helpers/shared/sdk/types.ts +++ b/test/__helpers/shared/sdk/types.ts @@ -45,6 +45,7 @@ export type CreateArgs< TGeneratedTypes extends GeneratedTypes, TSlug extends keyof TGeneratedTypes['collections'], > = { + action?: 'publish' | 'saveDraft' collection: TSlug data: MarkOptional< TGeneratedTypes['collections'][TSlug], @@ -52,7 +53,6 @@ export type CreateArgs< > depth?: number disableTransaction?: boolean - draft?: boolean fallbackLocale?: string file?: File filePath?: string @@ -83,11 +83,11 @@ export type UpdateBaseArgs< TGeneratedTypes extends GeneratedTypes, TSlug extends keyof TGeneratedTypes['collections'], > = { + action?: 'publish' | 'saveDraft' | 'unpublish' autosave?: boolean collection: TSlug data: DeepPartial depth?: number - draft?: boolean fallbackLocale?: string file?: File filePath?: string @@ -118,7 +118,6 @@ export type FindArgs< collection: TSlug depth?: number disableErrors?: boolean - draft?: boolean fallbackLocale?: string limit?: number locale?: string @@ -129,6 +128,7 @@ export type FindArgs< sort?: string trash?: boolean user?: TypeWithID + version?: 'draft' | 'latest' | 'published' where?: Where } & BaseArgs diff --git a/test/__setup/e2e/catchConsoleErrors.ts b/test/__setup/e2e/catchConsoleErrors.ts index 93a09189382..ac73ce6d6ca 100644 --- a/test/__setup/e2e/catchConsoleErrors.ts +++ b/test/__setup/e2e/catchConsoleErrors.ts @@ -66,7 +66,10 @@ export function catchConsoleErrors(page: Page, options?: { ignoreCORS?: boolean msg.text().includes("No 'Access-Control-Allow-Origin' header is present") ) && // Conditionally ignore network-related errors - !msg.text().includes('Failed to load resource: net::ERR_FAILED') + !msg.text().includes('Failed to load resource: net::ERR_FAILED') && + // Next.js forwards Node process warnings to the browser as console.error. + // Mongoose's findOneAndUpdate `new` deprecation is not an application error. + !msg.text().includes('[MONGOOSE] Warning:') ) { // "Failed to fetch RSC payload for" happens seemingly randomly. There are lots of issues in the next.js repository for this. Causes e2e tests to fail and flake. Will ignore for now // the the server responded with a status of error happens frequently. Will ignore it for now. diff --git a/test/access-control/int.spec.ts b/test/access-control/int.spec.ts index 62a17a8394f..a1fc5737ad9 100644 --- a/test/access-control/int.spec.ts +++ b/test/access-control/int.spec.ts @@ -363,7 +363,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Access Control' }), payload.find({ collection: 'fields-and-top-access', - draft: true, + version: 'latest', overrideAccess: false, sort: 'secret', }), @@ -708,14 +708,17 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Access Control' payload, }) => { await payload.create({ + action: 'publish', collection: 'fields-and-top-access', data: { secret: 'will-fail-access-read' }, }) const { id: hitID } = await payload.create({ + action: 'publish', collection: 'fields-and-top-access', data: { secret: 'will-success-access-read' }, }) await payload.create({ + action: 'publish', collection: 'fields-and-top-access', data: { secret: 'will-fail-access-read' }, }) @@ -728,9 +731,9 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Access Control' expect(resFind.docs[0].id).toBe(hitID) expect(resFind.docs).toHaveLength(1) - // assert find draft: true + // assert find version: 'latest' const resFindDraft = await payload.find({ - draft: true, + version: 'latest', overrideAccess: false, collection: 'fields-and-top-access', }) diff --git a/test/admin/e2e/list-view/e2e.spec.ts b/test/admin/e2e/list-view/e2e.spec.ts index 6c47149564a..0d8c0e1e2b7 100644 --- a/test/admin/e2e/list-view/e2e.spec.ts +++ b/test/admin/e2e/list-view/e2e.spec.ts @@ -2112,6 +2112,7 @@ describe('List View', () => { test('should use different URL for trash view', async () => { // Create a document and then move it to trash const trashDoc = await payload.create({ + action: 'publish', collection: formatDocURLCollectionSlug, data: { description: 'This should show trash URL', title: 'trash-test' }, }) @@ -2145,6 +2146,7 @@ describe('List View', () => { test('should add published query param for published documents', async () => { // Create a published document const publishedDoc = await payload.create({ + action: 'publish', collection: formatDocURLCollectionSlug, data: { _status: 'published', @@ -2211,6 +2213,7 @@ describe('List View', () => { async function createPost(overrides?: Partial): Promise { return payload.create({ + action: 'publish', collection: postsCollectionSlug, data: { description, diff --git a/test/admin/seed.ts b/test/admin/seed.ts index ee9c8fd71a3..fd39ae08c54 100644 --- a/test/admin/seed.ts +++ b/test/admin/seed.ts @@ -60,6 +60,7 @@ export const seed = async (_payload: Payload) => { }) return await _payload.update({ + action: 'saveDraft', collection: postsCollectionSlug, where: { id: { diff --git a/test/bulk-edit/e2e.spec.ts b/test/bulk-edit/e2e.spec.ts index 93a8f0756b6..f68d743d204 100644 --- a/test/bulk-edit/e2e.spec.ts +++ b/test/bulk-edit/e2e.spec.ts @@ -98,7 +98,7 @@ test.describe('Bulk Edit', () => { await Promise.all([ createPost({ title: titleOfPostToDelete1 }), - createPost({ title: titleOfPostToDelete2 }, { draft: true }), + createPost({ title: titleOfPostToDelete2 }, { action: 'saveDraft' }), ]) await page.goto(postsUrl.list) @@ -130,7 +130,7 @@ test.describe('Bulk Edit', () => { await Promise.all([ createPost({ title: titleOfPostToPublish1 }), - createPost({ title: titleOfPostToPublish2 }, { draft: true }), + createPost({ title: titleOfPostToPublish2 }, { action: 'saveDraft' }), ]) await page.goto(postsUrl.list) @@ -147,7 +147,7 @@ test.describe('Bulk Edit', () => { await page.locator('#publish-posts [data-dialog-action="confirm"]').click() await expect(page.locator('.payload-toast-container .toast-success')).toContainText( - 'Updated 2 Posts successfully.', + 'Updated 1 Post successfully.', ) await expect(await findTableCell(page, '_status', titleOfPostToPublish1)).toContainText( @@ -166,7 +166,7 @@ test.describe('Bulk Edit', () => { await Promise.all([ createPost({ title: titleOfPostToUnpublish1 }), - createPost({ title: titleOfPostToUnpublish2 }, { draft: true }), + createPost({ title: titleOfPostToUnpublish2 }, { action: 'saveDraft' }), ]) await page.goto(postsUrl.list) @@ -241,7 +241,7 @@ test.describe('Bulk Edit', () => { await Promise.all([ createPost({ title: titleOfPostToPublish1 }), - createPost({ title: titleOfPostToPublish2 }, { draft: true }), + createPost({ title: titleOfPostToPublish2 }, { action: 'saveDraft' }), ]) const description = 'published document' @@ -288,7 +288,7 @@ test.describe('Bulk Edit', () => { await Promise.all([ createPost({ title: titleOfPostToDraft1 }), - createPost({ title: titleOfPostToDraft2 }, { draft: true }), + createPost({ title: titleOfPostToDraft2 }, { action: 'saveDraft' }), ]) const description = 'draft document' @@ -316,7 +316,9 @@ test.describe('Bulk Edit', () => { 'Updated 2 Posts successfully.', ) - await expect(await findTableCell(page, '_status', titleOfPostToDraft1)).toContainText('Draft') + await expect(await findTableCell(page, '_status', titleOfPostToDraft1)).toContainText( + 'Draft (has published version)', + ) await expect(await findTableCell(page, '_status', titleOfPostToDraft2)).toContainText('Draft') }) @@ -573,7 +575,7 @@ test.describe('Bulk Edit', () => { const postCount = 3 for (let i = 1; i <= postCount; i++) { - await createPost({ title: `Post ${i}` }, { draft: true }) + await createPost({ title: `Post ${i}` }, { action: 'saveDraft' }) // Wait 50ms to ensure the createdAt date is different enough to ensure posts are in the correct order await wait(50) } @@ -1073,6 +1075,7 @@ async function createPost( ): Promise { return payload.create({ collection: postsSlug, + action: 'publish', ...(overrides || {}), data: { title: 'Post Title', diff --git a/test/cli/config.ts b/test/cli/config.ts index 647af204f0f..7898f7094c8 100644 --- a/test/cli/config.ts +++ b/test/cli/config.ts @@ -165,6 +165,7 @@ export default buildConfigWithDefaults({ slug: 'noop', handler: async ({ req }) => { await req.payload.create({ + action: 'publish', collection: 'pages', data: { title: 'CLI job ran' }, } as never) diff --git a/test/cli/int.spec.ts b/test/cli/int.spec.ts index 73613cef0df..bb687c0aae4 100644 --- a/test/cli/int.spec.ts +++ b/test/cli/int.spec.ts @@ -494,7 +494,7 @@ test.suite({ config: './config.ts' })('CLI', () => { `createDocuments --slug pages --documents '[{"data":{"title":"one","location":{"longitude":1,"latitude":2}}},{"data":{"title":"two"}}]' --json`, async ({ cli, payload }) => { const output = await cli( - 'createDocuments --slug pages --documents \'[{"data":{"title":"one","location":{"longitude":1,"latitude":2}}},{"data":{"title":"two"}}]\' --json', + 'createDocuments --slug pages --documents \'[{"data":{"title":"one","location":{"longitude":1,"latitude":2}}},{"data":{"title":"two"}}]\' --action publish --json', ) const pages = await payload.find({ collection: 'pages', @@ -535,7 +535,9 @@ test.suite({ config: './config.ts' })('CLI', () => { JSON.stringify([{ data: { title: 'file one' } }, { data: { title: 'file two' } }]), ) - const output = await cli(`createDocuments --slug pages --documents @${documentsFile} --json`) + const output = await cli( + `createDocuments --slug pages --documents @${documentsFile} --action publish --json`, + ) const pages = await payload.find({ collection: 'pages', pagination: false, @@ -560,6 +562,7 @@ test.suite({ config: './config.ts' })('CLI', () => { await writeFile( inputFile, JSON.stringify({ + action: 'publish', slug: 'pages', documents: [{ data: { title: 'Merged input' } }], returning: false, @@ -601,7 +604,9 @@ test.suite({ config: './config.ts' })('CLI', () => { }, }, ]) - const output = await cli(`createDocuments --slug media --documents '${documents}' --json`) + const output = await cli( + `createDocuments --slug media --documents '${documents}' --action publish --json`, + ) const response = JSON.parse(output.stdout) const createdMedia = await payload.findByID({ id: response.result.docs[0].id, @@ -621,11 +626,11 @@ test.suite({ config: './config.ts' })('CLI', () => { }) }) - test(`createDocuments --slug pages --documents '[{"data":{}}]' --draft --returning --json`, async ({ + test(`createDocuments --slug pages --documents '[{"data":{}}]' --action saveDraft --returning --json`, async ({ cli, }) => { const output = await cli({ - command: `createDocuments --slug pages --documents '[{"data":{}}]' --draft --returning --json`, + command: `createDocuments --slug pages --documents '[{"data":{}}]' --action saveDraft --returning --json`, reject: false, }) const response = JSON.parse(output.stdout) @@ -646,7 +651,7 @@ test.suite({ config: './config.ts' })('CLI', () => { payload, }) => { const output = await cli({ - command: `createDocuments --slug pages --documents '[{"data":{"title":"created"}},{"data":{"title":null}}]' --json`, + command: `createDocuments --slug pages --documents '[{"data":{"title":"created"}},{"data":{"title":null}}]' --action publish --json`, reject: false, }) const response = JSON.parse(output.stdout) @@ -974,8 +979,12 @@ test.suite({ config: './config.ts' })('CLI', () => { }) }) - test('findDocuments --slug pages --draft --trash --no-pagination --json', async ({ cli }) => { - const output = await cli('findDocuments --slug pages --draft --trash --no-pagination --json') + test('findDocuments --slug pages --version latest --trash --no-pagination --json', async ({ + cli, + }) => { + const output = await cli( + 'findDocuments --slug pages --version latest --trash --no-pagination --json', + ) expect(JSON.parse(output.stdout)).toMatchObject({ command: 'findDocuments', diff --git a/test/cli/seed.ts b/test/cli/seed.ts index 9c9c9ab1fe5..e7facc22be7 100644 --- a/test/cli/seed.ts +++ b/test/cli/seed.ts @@ -2,6 +2,7 @@ import type { Payload } from 'payload' export const seed = async (payload: Payload): Promise => { await payload.create({ + action: 'publish', collection: 'pages', data: { title: 'Seeded page' }, } as never) @@ -9,6 +10,7 @@ export const seed = async (payload: Payload): Promise => { const fileData = Buffer.from('Seeded media') await payload.create({ + action: 'publish', collection: 'media', data: { title: 'Seeded media' }, file: { @@ -20,6 +22,7 @@ export const seed = async (payload: Payload): Promise => { } as never) await payload.updateGlobal({ + action: 'publish', slug: 'settings', data: { title: 'Seeded settings' }, } as never) diff --git a/test/collections-graphql/int.spec.ts b/test/collections-graphql/int.spec.ts index 913708529c2..8e7c36a411c 100644 --- a/test/collections-graphql/int.spec.ts +++ b/test/collections-graphql/int.spec.ts @@ -31,7 +31,7 @@ test.suite({ config: './config.ts' })('collections-graphql', () => { test('should create', async ({ restClient }) => { const query = `mutation { - createPost(data: {title: "${title}"}) { + createPost(action: publish, data: {title: "${title}"}) { id title } @@ -101,13 +101,29 @@ test.suite({ config: './config.ts' })('collections-graphql', () => { }) test('should sort by multiple fields', async ({ payload, restClient }) => { - const doc1 = await payload.create({ collection: 'sort', data: { title: 'a', number: 1 } }) - const doc2 = await payload.create({ collection: 'sort', data: { title: 'b', number: 1 } }) - const doc3 = await payload.create({ collection: 'sort', data: { title: 'a', number: 2 } }) - const doc4 = await payload.create({ collection: 'sort', data: { title: 'b', number: 3 } }) + const doc1 = await payload.create({ + action: 'publish', + collection: 'sort', + data: { title: 'a', number: 1 }, + }) + const doc2 = await payload.create({ + action: 'publish', + collection: 'sort', + data: { title: 'b', number: 1 }, + }) + const doc3 = await payload.create({ + action: 'publish', + collection: 'sort', + data: { title: 'a', number: 2 }, + }) + const doc4 = await payload.create({ + action: 'publish', + collection: 'sort', + data: { title: 'b', number: 3 }, + }) const query = `query { - Sorts(sort: "title, number") { + Sorts(sort: "title,-number") { docs { id title @@ -191,6 +207,7 @@ test.suite({ config: './config.ts' })('collections-graphql', () => { const firstTitle = 'first title' const secondTitle = 'second title' const first = await payload.create({ + action: 'publish', collection: errorOnHookSlug, data: { errorBeforeChange: true, @@ -198,6 +215,7 @@ test.suite({ config: './config.ts' })('collections-graphql', () => { }, }) const second = await payload.create({ + action: 'publish', collection: errorOnHookSlug, data: { errorBeforeChange: true, @@ -208,7 +226,7 @@ test.suite({ config: './config.ts' })('collections-graphql', () => { const updated = 'updated title' const query = `mutation { - createPost(data: {title: "${title}"}) { + createPost(action: publish, data: {title: "${title}"}) { id title } @@ -333,11 +351,13 @@ test.suite({ config: './config.ts' })('collections-graphql', () => { restClient, }) => { const recalls = await payload.create({ + action: 'publish', collection: relationSlug, data: { name: 'recalls' }, }) const electricCars = await payload.create({ + action: 'publish', collection: relationSlug, data: { name: 'electric-cars' }, }) @@ -732,6 +752,7 @@ test.suite({ config: './config.ts' })('collections-graphql', () => { // randomize the creation timestamp await wait(Math.random()) await payload.create({ + action: 'publish', collection: pointSlug, data: { // only randomize longitude to make distance comparison easy @@ -1010,6 +1031,7 @@ test.suite({ config: './config.ts' })('collections-graphql', () => { restClient, }) => { const relation = await payload.create({ + action: 'publish', collection: relationSlug, data: { name: 'test', @@ -1017,6 +1039,7 @@ test.suite({ config: './config.ts' })('collections-graphql', () => { }) await payload.create({ + action: 'publish', collection: slug, data: { relationField: relation.id, @@ -1026,6 +1049,7 @@ test.suite({ config: './config.ts' })('collections-graphql', () => { await payload.delete({ id: relation.id, + action: 'publish', collection: relationSlug, }) @@ -1062,6 +1086,7 @@ test.suite({ config: './config.ts' })('collections-graphql', () => { }) await payload.create({ + action: 'publish', collection: slug, data: { relationHasManyField: [relation.id], @@ -1097,6 +1122,7 @@ test.suite({ config: './config.ts' })('collections-graphql', () => { test('should query relationships with locale', async ({ payload, restClient }) => { const newDoc = await payload.create({ + action: 'publish', collection: 'cyclical-relationship', data: { title: { @@ -1105,6 +1131,7 @@ test.suite({ config: './config.ts' })('collections-graphql', () => { }, }, locale: '*', + publishAllLocales: true, }) await payload.update({ @@ -1140,17 +1167,18 @@ test.suite({ config: './config.ts' })('collections-graphql', () => { const relation_1_draft = await payload.create({ collection: 'relation', data: { _status: 'draft', name: 'relation_1_draft' }, - draft: true, + action: 'saveDraft', }) const relation_2 = await payload.create({ + action: 'publish', collection: 'relation', data: { name: 'relation_2', _status: 'published' }, }) await payload.create({ collection: 'posts', - draft: true, + action: 'saveDraft', data: { _status: 'draft', title: 'post with relations in draft', @@ -1161,7 +1189,7 @@ test.suite({ config: './config.ts' })('collections-graphql', () => { await payload.delete({ collection: 'relation', id: relation_1_draft.id }) const query = `query { - Posts(draft:true,where: { title: { equals: "post with relations in draft" }}) { + Posts(version:latest,where: { title: { equals: "post with relations in draft" }}) { docs { id title @@ -1200,7 +1228,7 @@ test.suite({ config: './config.ts' })('collections-graphql', () => { await payload.create({ collection: 'posts', - draft: true, + action: 'saveDraft', data: { _status: 'draft', title: 'post with relation restricted', @@ -1209,7 +1237,7 @@ test.suite({ config: './config.ts' })('collections-graphql', () => { }) const query = `query { - Posts(draft:true,where: { title: { equals: "post with relation restricted" }}) { + Posts(version:latest,where: { title: { equals: "post with relation restricted" }}) { docs { id title @@ -1244,7 +1272,7 @@ test.suite({ config: './config.ts' })('collections-graphql', () => { data: { title: publishValue, }, - draft: false, + action: 'publish', }) // create cyclical relationship @@ -1263,14 +1291,14 @@ test.suite({ config: './config.ts' })('collections-graphql', () => { data: { title: draftValue, }, - draft: true, + action: 'saveDraft', }) const draftParentPublishedChild = `{ - CyclicalRelationships(draft: true) { + CyclicalRelationships(version: latest) { docs { title - relationToSelf(draft: false) { + relationToSelf(version: published) { title } } @@ -1287,10 +1315,10 @@ test.suite({ config: './config.ts' })('collections-graphql', () => { expect(queriedDoc.relationToSelf.title).toEqual(publishValue) const publishedParentDraftChild = `{ - CyclicalRelationships(draft: false) { + CyclicalRelationships(version: published) { docs { title - relationToSelf(draft: true) { + relationToSelf(version: latest) { title } } @@ -1311,6 +1339,7 @@ test.suite({ config: './config.ts' })('collections-graphql', () => { const file = await getFileByPath(path.resolve(dirname, '../uploads/test-image.jpg')) const mediaDoc = await payload.create({ + action: 'publish', collection: 'media', data: { title: 'example', @@ -1320,6 +1349,7 @@ test.suite({ config: './config.ts' })('collections-graphql', () => { // doc with upload relation const newDoc = await payload.create({ + action: 'publish', collection: 'cyclical-relationship', data: { media: mediaDoc.id, @@ -1368,7 +1398,7 @@ test.suite({ config: './config.ts' })('collections-graphql', () => { restClient, }) => { const query = `mutation { - createPost(data: {min: 1}) { + createPost(action: publish, data: {min: 1}) { id min createdAt @@ -1470,6 +1500,7 @@ test.suite({ config: './config.ts' })('collections-graphql', () => { async function createPost({ payload }: { payload: Payload }, overrides?: Partial) { const doc = await payload.create({ + action: 'publish', collection: slug, data: { title: 'title', ...overrides }, }) diff --git a/test/collections-graphql/schema.graphql b/test/collections-graphql/schema.graphql index 6946291d4a8..4b489ceb71f 100644 --- a/test/collections-graphql/schema.graphql +++ b/test/collections-graphql/schema.graphql @@ -1,75 +1,97 @@ +enum ReadVersion { + published + latest + draft +} + +enum CreateAction { + publish + saveDraft +} + +enum UpdateAction { + publish + saveDraft + unpublish +} + +enum RestoreAction { + publish + saveDraft +} + type Query { - User(id: Int!, draft: Boolean, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, select: Boolean, trash: Boolean): User - Users(draft: Boolean, where: User_where, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, limit: Int, page: Int, pagination: Boolean, select: Boolean, sort: String, trash: Boolean): Users - countUsers(draft: Boolean, trash: Boolean, where: User_where, locale: LocaleInputType): countUsers + User(id: Int!, version: ReadVersion, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, select: Boolean, trash: Boolean): User + Users(version: ReadVersion, where: User_where, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, limit: Int, page: Int, pagination: Boolean, select: Boolean, sort: String, trash: Boolean): Users + countUsers(trash: Boolean, where: User_where, locale: LocaleInputType): countUsers docAccessUser(id: Int!): usersDocAccess - meUser: usersMe + meUser(version: ReadVersion): usersMe initializedUser: Boolean - Point(id: Int!, draft: Boolean, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, select: Boolean, trash: Boolean): Point - Points(draft: Boolean, where: Point_where, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, limit: Int, page: Int, pagination: Boolean, select: Boolean, sort: String, trash: Boolean): Points - countPoints(draft: Boolean, trash: Boolean, where: Point_where, locale: LocaleInputType): countPoints + Point(id: Int!, version: ReadVersion, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, select: Boolean, trash: Boolean): Point + Points(version: ReadVersion, where: Point_where, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, limit: Int, page: Int, pagination: Boolean, select: Boolean, sort: String, trash: Boolean): Points + countPoints(trash: Boolean, where: Point_where, locale: LocaleInputType): countPoints docAccessPoint(id: Int!): pointDocAccess - Post(id: Int!, draft: Boolean, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, select: Boolean, trash: Boolean): Post - Posts(draft: Boolean, where: Post_where, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, limit: Int, page: Int, pagination: Boolean, select: Boolean, sort: String, trash: Boolean): Posts - countPosts(draft: Boolean, trash: Boolean, where: Post_where, locale: LocaleInputType): countPosts + Post(id: Int!, version: ReadVersion, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, select: Boolean, trash: Boolean): Post + Posts(version: ReadVersion, where: Post_where, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, limit: Int, page: Int, pagination: Boolean, select: Boolean, sort: String, trash: Boolean): Posts + countPosts(trash: Boolean, where: Post_where, locale: LocaleInputType): countPosts docAccessPost(id: Int!): postsDocAccess versionPost(id: Int, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, trash: Boolean): PostVersion versionsPosts(where: versionsPost_where, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, limit: Int, page: Int, pagination: Boolean, select: Boolean, sort: String, trash: Boolean): versionsPosts - CustomId(id: Int!, draft: Boolean, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, select: Boolean, trash: Boolean): CustomId - CustomIds(draft: Boolean, where: CustomId_where, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, limit: Int, page: Int, pagination: Boolean, select: Boolean, sort: String, trash: Boolean): CustomIds - countCustomIds(draft: Boolean, trash: Boolean, where: CustomId_where, locale: LocaleInputType): countCustomIds + CustomId(id: Int!, version: ReadVersion, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, select: Boolean, trash: Boolean): CustomId + CustomIds(version: ReadVersion, where: CustomId_where, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, limit: Int, page: Int, pagination: Boolean, select: Boolean, sort: String, trash: Boolean): CustomIds + countCustomIds(trash: Boolean, where: CustomId_where, locale: LocaleInputType): countCustomIds docAccessCustomId(id: Int!): custom_idsDocAccess - Relation(id: Int!, draft: Boolean, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, select: Boolean, trash: Boolean): Relation - Relations(draft: Boolean, where: Relation_where, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, limit: Int, page: Int, pagination: Boolean, select: Boolean, sort: String, trash: Boolean): Relations - countRelations(draft: Boolean, trash: Boolean, where: Relation_where, locale: LocaleInputType): countRelations + Relation(id: Int!, version: ReadVersion, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, select: Boolean, trash: Boolean): Relation + Relations(version: ReadVersion, where: Relation_where, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, limit: Int, page: Int, pagination: Boolean, select: Boolean, sort: String, trash: Boolean): Relations + countRelations(trash: Boolean, where: Relation_where, locale: LocaleInputType): countRelations docAccessRelation(id: Int!): relationDocAccess versionRelation(id: Int, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, trash: Boolean): RelationVersion versionsRelations(where: versionsRelation_where, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, limit: Int, page: Int, pagination: Boolean, select: Boolean, sort: String, trash: Boolean): versionsRelations - Dummy(id: Int!, draft: Boolean, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, select: Boolean, trash: Boolean): Dummy - Dummies(draft: Boolean, where: Dummy_where, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, limit: Int, page: Int, pagination: Boolean, select: Boolean, sort: String, trash: Boolean): Dummies - countDummies(draft: Boolean, trash: Boolean, where: Dummy_where, locale: LocaleInputType): countDummies + Dummy(id: Int!, version: ReadVersion, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, select: Boolean, trash: Boolean): Dummy + Dummies(version: ReadVersion, where: Dummy_where, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, limit: Int, page: Int, pagination: Boolean, select: Boolean, sort: String, trash: Boolean): Dummies + countDummies(trash: Boolean, where: Dummy_where, locale: LocaleInputType): countDummies docAccessDummy(id: Int!): dummyDocAccess - ErrorOnHook(id: Int!, draft: Boolean, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, select: Boolean, trash: Boolean): ErrorOnHook - ErrorOnHooks(draft: Boolean, where: ErrorOnHook_where, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, limit: Int, page: Int, pagination: Boolean, select: Boolean, sort: String, trash: Boolean): ErrorOnHooks - countErrorOnHooks(draft: Boolean, trash: Boolean, where: ErrorOnHook_where, locale: LocaleInputType): countErrorOnHooks + ErrorOnHook(id: Int!, version: ReadVersion, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, select: Boolean, trash: Boolean): ErrorOnHook + ErrorOnHooks(version: ReadVersion, where: ErrorOnHook_where, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, limit: Int, page: Int, pagination: Boolean, select: Boolean, sort: String, trash: Boolean): ErrorOnHooks + countErrorOnHooks(trash: Boolean, where: ErrorOnHook_where, locale: LocaleInputType): countErrorOnHooks docAccessErrorOnHook(id: Int!): error_on_hooksDocAccess - PayloadApiTestOne(id: Int!, draft: Boolean, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, select: Boolean, trash: Boolean): PayloadApiTestOne - PayloadApiTestOnes(draft: Boolean, where: PayloadApiTestOne_where, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, limit: Int, page: Int, pagination: Boolean, select: Boolean, sort: String, trash: Boolean): PayloadApiTestOnes - countPayloadApiTestOnes(draft: Boolean, trash: Boolean, where: PayloadApiTestOne_where, locale: LocaleInputType): countPayloadApiTestOnes + PayloadApiTestOne(id: Int!, version: ReadVersion, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, select: Boolean, trash: Boolean): PayloadApiTestOne + PayloadApiTestOnes(version: ReadVersion, where: PayloadApiTestOne_where, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, limit: Int, page: Int, pagination: Boolean, select: Boolean, sort: String, trash: Boolean): PayloadApiTestOnes + countPayloadApiTestOnes(trash: Boolean, where: PayloadApiTestOne_where, locale: LocaleInputType): countPayloadApiTestOnes docAccessPayloadApiTestOne(id: Int!): payload_api_test_onesDocAccess - PayloadApiTestTwo(id: Int!, draft: Boolean, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, select: Boolean, trash: Boolean): PayloadApiTestTwo - PayloadApiTestTwos(draft: Boolean, where: PayloadApiTestTwo_where, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, limit: Int, page: Int, pagination: Boolean, select: Boolean, sort: String, trash: Boolean): PayloadApiTestTwos - countPayloadApiTestTwos(draft: Boolean, trash: Boolean, where: PayloadApiTestTwo_where, locale: LocaleInputType): countPayloadApiTestTwos + PayloadApiTestTwo(id: Int!, version: ReadVersion, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, select: Boolean, trash: Boolean): PayloadApiTestTwo + PayloadApiTestTwos(version: ReadVersion, where: PayloadApiTestTwo_where, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, limit: Int, page: Int, pagination: Boolean, select: Boolean, sort: String, trash: Boolean): PayloadApiTestTwos + countPayloadApiTestTwos(trash: Boolean, where: PayloadApiTestTwo_where, locale: LocaleInputType): countPayloadApiTestTwos docAccessPayloadApiTestTwo(id: Int!): payload_api_test_twosDocAccess - ContentType(id: Int!, draft: Boolean, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, select: Boolean, trash: Boolean): ContentType - ContentTypes(draft: Boolean, where: ContentType_where, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, limit: Int, page: Int, pagination: Boolean, select: Boolean, sort: String, trash: Boolean): ContentTypes - countContentTypes(draft: Boolean, trash: Boolean, where: ContentType_where, locale: LocaleInputType): countContentTypes + ContentType(id: Int!, version: ReadVersion, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, select: Boolean, trash: Boolean): ContentType + ContentTypes(version: ReadVersion, where: ContentType_where, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, limit: Int, page: Int, pagination: Boolean, select: Boolean, sort: String, trash: Boolean): ContentTypes + countContentTypes(trash: Boolean, where: ContentType_where, locale: LocaleInputType): countContentTypes docAccessContentType(id: Int!): content_typeDocAccess - CyclicalRelationship(id: Int!, draft: Boolean, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, select: Boolean, trash: Boolean): CyclicalRelationship - CyclicalRelationships(draft: Boolean, where: CyclicalRelationship_where, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, limit: Int, page: Int, pagination: Boolean, select: Boolean, sort: String, trash: Boolean): CyclicalRelationships - countCyclicalRelationships(draft: Boolean, trash: Boolean, where: CyclicalRelationship_where, locale: LocaleInputType): countCyclicalRelationships + CyclicalRelationship(id: Int!, version: ReadVersion, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, select: Boolean, trash: Boolean): CyclicalRelationship + CyclicalRelationships(version: ReadVersion, where: CyclicalRelationship_where, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, limit: Int, page: Int, pagination: Boolean, select: Boolean, sort: String, trash: Boolean): CyclicalRelationships + countCyclicalRelationships(trash: Boolean, where: CyclicalRelationship_where, locale: LocaleInputType): countCyclicalRelationships docAccessCyclicalRelationship(id: Int!): cyclical_relationshipDocAccess versionCyclicalRelationship(id: Int, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, trash: Boolean): CyclicalRelationshipVersion versionsCyclicalRelationships(where: versionsCyclicalRelationship_where, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, limit: Int, page: Int, pagination: Boolean, select: Boolean, sort: String, trash: Boolean): versionsCyclicalRelationships - Media(id: Int!, draft: Boolean, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, select: Boolean, trash: Boolean): Media - allMedia(draft: Boolean, where: Media_where, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, limit: Int, page: Int, pagination: Boolean, select: Boolean, sort: String, trash: Boolean): allMedia - countallMedia(draft: Boolean, trash: Boolean, where: Media_where, locale: LocaleInputType): countallMedia + Media(id: Int!, version: ReadVersion, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, select: Boolean, trash: Boolean): Media + allMedia(version: ReadVersion, where: Media_where, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, limit: Int, page: Int, pagination: Boolean, select: Boolean, sort: String, trash: Boolean): allMedia + countallMedia(trash: Boolean, where: Media_where, locale: LocaleInputType): countallMedia docAccessMedia(id: Int!): mediaDocAccess - Sort(id: Int!, draft: Boolean, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, select: Boolean, trash: Boolean): Sort - Sorts(draft: Boolean, where: Sort_where, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, limit: Int, page: Int, pagination: Boolean, select: Boolean, sort: String, trash: Boolean): Sorts - countSorts(draft: Boolean, trash: Boolean, where: Sort_where, locale: LocaleInputType): countSorts + Sort(id: Int!, version: ReadVersion, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, select: Boolean, trash: Boolean): Sort + Sorts(version: ReadVersion, where: Sort_where, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, limit: Int, page: Int, pagination: Boolean, select: Boolean, sort: String, trash: Boolean): Sorts + countSorts(trash: Boolean, where: Sort_where, locale: LocaleInputType): countSorts docAccessSort(id: Int!): sortDocAccess - PayloadKv(id: Int!, draft: Boolean, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, select: Boolean, trash: Boolean): PayloadKv - PayloadKvs(draft: Boolean, where: PayloadKv_where, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, limit: Int, page: Int, pagination: Boolean, select: Boolean, sort: String, trash: Boolean): PayloadKvs - countPayloadKvs(draft: Boolean, trash: Boolean, where: PayloadKv_where, locale: LocaleInputType): countPayloadKvs + PayloadKv(id: Int!, version: ReadVersion, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, select: Boolean, trash: Boolean): PayloadKv + PayloadKvs(version: ReadVersion, where: PayloadKv_where, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, limit: Int, page: Int, pagination: Boolean, select: Boolean, sort: String, trash: Boolean): PayloadKvs + countPayloadKvs(trash: Boolean, where: PayloadKv_where, locale: LocaleInputType): countPayloadKvs docAccessPayloadKv(id: Int!): payload_kvDocAccess - PayloadLockedDocument(id: Int!, draft: Boolean, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, select: Boolean, trash: Boolean): PayloadLockedDocument - PayloadLockedDocuments(draft: Boolean, where: PayloadLockedDocument_where, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, limit: Int, page: Int, pagination: Boolean, select: Boolean, sort: String, trash: Boolean): PayloadLockedDocuments - countPayloadLockedDocuments(draft: Boolean, trash: Boolean, where: PayloadLockedDocument_where, locale: LocaleInputType): countPayloadLockedDocuments + PayloadLockedDocument(id: Int!, version: ReadVersion, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, select: Boolean, trash: Boolean): PayloadLockedDocument + PayloadLockedDocuments(version: ReadVersion, where: PayloadLockedDocument_where, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, limit: Int, page: Int, pagination: Boolean, select: Boolean, sort: String, trash: Boolean): PayloadLockedDocuments + countPayloadLockedDocuments(trash: Boolean, where: PayloadLockedDocument_where, locale: LocaleInputType): countPayloadLockedDocuments docAccessPayloadLockedDocument(id: Int!): payload_locked_documentsDocAccess - PayloadPreference(id: Int!, draft: Boolean, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, select: Boolean, trash: Boolean): PayloadPreference - PayloadPreferences(draft: Boolean, where: PayloadPreference_where, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, limit: Int, page: Int, pagination: Boolean, select: Boolean, sort: String, trash: Boolean): PayloadPreferences - countPayloadPreferences(draft: Boolean, trash: Boolean, where: PayloadPreference_where, locale: LocaleInputType): countPayloadPreferences + PayloadPreference(id: Int!, version: ReadVersion, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, select: Boolean, trash: Boolean): PayloadPreference + PayloadPreferences(version: ReadVersion, where: PayloadPreference_where, fallbackLocale: FallbackLocaleInputType, locale: LocaleInputType, limit: Int, page: Int, pagination: Boolean, select: Boolean, sort: String, trash: Boolean): PayloadPreferences + countPayloadPreferences(trash: Boolean, where: PayloadPreference_where, locale: LocaleInputType): countPayloadPreferences docAccessPayloadPreference(id: Int!): payload_preferencesDocAccess Access: Access QueryWithInternalError: QueryWithInternalError @@ -681,11 +703,11 @@ type Post { description: String number: Float min: Float - relationField(draft: Boolean, locale: LocaleInputType, fallbackLocale: FallbackLocaleInputType): Relation + relationField(version: ReadVersion, locale: LocaleInputType, fallbackLocale: FallbackLocaleInputType): Relation relationToCustomID(locale: LocaleInputType, fallbackLocale: FallbackLocaleInputType): CustomId - relationHasManyField(draft: Boolean, locale: LocaleInputType, fallbackLocale: FallbackLocaleInputType): [Relation!] - relationMultiRelationTo(draft: Boolean, locale: LocaleInputType, fallbackLocale: FallbackLocaleInputType): Post_RelationMultiRelationTo_Relationship - relationMultiRelationToHasMany(draft: Boolean, locale: LocaleInputType, fallbackLocale: FallbackLocaleInputType): [Post_RelationMultiRelationToHasMany_Relationship!] + relationHasManyField(version: ReadVersion, locale: LocaleInputType, fallbackLocale: FallbackLocaleInputType): [Relation!] + relationMultiRelationTo(version: ReadVersion, locale: LocaleInputType, fallbackLocale: FallbackLocaleInputType): Post_RelationMultiRelationTo_Relationship + relationMultiRelationToHasMany(version: ReadVersion, locale: LocaleInputType, fallbackLocale: FallbackLocaleInputType): [Post_RelationMultiRelationToHasMany_Relationship!] A1: Post_A1 B1: Post_B1 C1: Post_C1 @@ -1676,7 +1698,7 @@ type PostsReadVersionsDocAccess { } type PostVersion { - parent(draft: Boolean, locale: LocaleInputType, fallbackLocale: FallbackLocaleInputType): Post + parent(version: ReadVersion, locale: LocaleInputType, fallbackLocale: FallbackLocaleInputType): Post version: PostVersion_Version createdAt: DateTime updatedAt: DateTime @@ -1691,11 +1713,11 @@ type PostVersion_Version { description: String number: Float min: Float - relationField(draft: Boolean, locale: LocaleInputType, fallbackLocale: FallbackLocaleInputType): Relation + relationField(version: ReadVersion, locale: LocaleInputType, fallbackLocale: FallbackLocaleInputType): Relation relationToCustomID(locale: LocaleInputType, fallbackLocale: FallbackLocaleInputType): CustomId - relationHasManyField(draft: Boolean, locale: LocaleInputType, fallbackLocale: FallbackLocaleInputType): [Relation!] - relationMultiRelationTo(draft: Boolean, locale: LocaleInputType, fallbackLocale: FallbackLocaleInputType): PostVersion_Version_RelationMultiRelationTo_Relationship - relationMultiRelationToHasMany(draft: Boolean, locale: LocaleInputType, fallbackLocale: FallbackLocaleInputType): [PostVersion_Version_RelationMultiRelationToHasMany_Relationship!] + relationHasManyField(version: ReadVersion, locale: LocaleInputType, fallbackLocale: FallbackLocaleInputType): [Relation!] + relationMultiRelationTo(version: ReadVersion, locale: LocaleInputType, fallbackLocale: FallbackLocaleInputType): PostVersion_Version_RelationMultiRelationTo_Relationship + relationMultiRelationToHasMany(version: ReadVersion, locale: LocaleInputType, fallbackLocale: FallbackLocaleInputType): [PostVersion_Version_RelationMultiRelationToHasMany_Relationship!] A1: PostVersion_Version_A1 B1: PostVersion_Version_B1 C1: PostVersion_Version_C1 @@ -2571,7 +2593,7 @@ type RelationReadVersionsDocAccess { } type RelationVersion { - parent(draft: Boolean, locale: LocaleInputType, fallbackLocale: FallbackLocaleInputType): Relation + parent(version: ReadVersion, locale: LocaleInputType, fallbackLocale: FallbackLocaleInputType): Relation version: RelationVersion_Version createdAt: DateTime updatedAt: DateTime @@ -3831,7 +3853,7 @@ type ContentTypeDeleteDocAccess { type CyclicalRelationship { id: Int! title: String - relationToSelf(draft: Boolean, locale: LocaleInputType, fallbackLocale: FallbackLocaleInputType): CyclicalRelationship + relationToSelf(version: ReadVersion, locale: LocaleInputType, fallbackLocale: FallbackLocaleInputType): CyclicalRelationship media(locale: LocaleInputType, fallbackLocale: FallbackLocaleInputType): Media updatedAt: DateTime createdAt: DateTime @@ -4170,7 +4192,7 @@ type CyclicalRelationshipReadVersionsDocAccess { } type CyclicalRelationshipVersion { - parent(draft: Boolean, locale: LocaleInputType, fallbackLocale: FallbackLocaleInputType): CyclicalRelationship + parent(version: ReadVersion, locale: LocaleInputType, fallbackLocale: FallbackLocaleInputType): CyclicalRelationship version: CyclicalRelationshipVersion_Version createdAt: DateTime updatedAt: DateTime @@ -4182,7 +4204,7 @@ type CyclicalRelationshipVersion { type CyclicalRelationshipVersion_Version { title: String - relationToSelf(draft: Boolean, locale: LocaleInputType, fallbackLocale: FallbackLocaleInputType): CyclicalRelationship + relationToSelf(version: ReadVersion, locale: LocaleInputType, fallbackLocale: FallbackLocaleInputType): CyclicalRelationship media(locale: LocaleInputType, fallbackLocale: FallbackLocaleInputType): Media updatedAt: DateTime createdAt: DateTime @@ -5321,7 +5343,7 @@ type PayloadKvDeleteDocAccess { type PayloadLockedDocument { id: Int! - document(draft: Boolean, locale: LocaleInputType, fallbackLocale: FallbackLocaleInputType): PayloadLockedDocument_Document_Relationship + document(version: ReadVersion, locale: LocaleInputType, fallbackLocale: FallbackLocaleInputType): PayloadLockedDocument_Document_Relationship globalSlug: String user(locale: LocaleInputType, fallbackLocale: FallbackLocaleInputType): PayloadLockedDocument_User_Relationship! updatedAt: DateTime @@ -8706,8 +8728,8 @@ type QueryWithInternalError { } type Mutation { - createUser(data: mutationUserInput!, draft: Boolean, locale: LocaleInputType): User - updateUser(id: Int!, autosave: Boolean, data: mutationUserUpdateInput!, draft: Boolean, locale: LocaleInputType, trash: Boolean): User + createUser(data: mutationUserInput!, action: CreateAction, locale: LocaleInputType): User + updateUser(id: Int!, autosave: Boolean, data: mutationUserUpdateInput!, action: UpdateAction, locale: LocaleInputType, trash: Boolean): User deleteUser(id: Int!, trash: Boolean): User refreshTokenUser: usersRefreshedUser logoutUser(allSessions: Boolean): String @@ -8716,69 +8738,69 @@ type Mutation { forgotPasswordUser(disableEmail: Boolean, expiration: Int, email: String!): Boolean! resetPasswordUser(password: String, token: String): usersResetPassword verifyEmailUser(token: String): Boolean - createPoint(data: mutationPointInput!, draft: Boolean, locale: LocaleInputType): Point - updatePoint(id: Int!, autosave: Boolean, data: mutationPointUpdateInput!, draft: Boolean, locale: LocaleInputType, trash: Boolean): Point + createPoint(data: mutationPointInput!, action: CreateAction, locale: LocaleInputType): Point + updatePoint(id: Int!, autosave: Boolean, data: mutationPointUpdateInput!, action: UpdateAction, locale: LocaleInputType, trash: Boolean): Point deletePoint(id: Int!, trash: Boolean): Point - duplicatePoint(id: Int!, data: mutationPointInput!): Point - createPost(data: mutationPostInput!, draft: Boolean, locale: LocaleInputType): Post - updatePost(id: Int!, autosave: Boolean, data: mutationPostUpdateInput!, draft: Boolean, locale: LocaleInputType, trash: Boolean): Post + duplicatePoint(id: Int!, action: CreateAction, data: mutationPointInput!): Point + createPost(data: mutationPostInput!, action: CreateAction, locale: LocaleInputType): Post + updatePost(id: Int!, autosave: Boolean, data: mutationPostUpdateInput!, action: UpdateAction, locale: LocaleInputType, trash: Boolean): Post deletePost(id: Int!, trash: Boolean): Post - duplicatePost(id: Int!, data: mutationPostInput!): Post - restoreVersionPost(id: Int, draft: Boolean): Post - createCustomId(data: mutationCustomIdInput!, draft: Boolean, locale: LocaleInputType): CustomId - updateCustomId(id: Int!, autosave: Boolean, data: mutationCustomIdUpdateInput!, draft: Boolean, locale: LocaleInputType, trash: Boolean): CustomId + duplicatePost(id: Int!, action: CreateAction, data: mutationPostInput!): Post + restoreVersionPost(id: Int, action: RestoreAction): Post + createCustomId(data: mutationCustomIdInput!, action: CreateAction, locale: LocaleInputType): CustomId + updateCustomId(id: Int!, autosave: Boolean, data: mutationCustomIdUpdateInput!, action: UpdateAction, locale: LocaleInputType, trash: Boolean): CustomId deleteCustomId(id: Int!, trash: Boolean): CustomId - duplicateCustomId(id: Int!, data: mutationCustomIdInput!): CustomId - createRelation(data: mutationRelationInput!, draft: Boolean, locale: LocaleInputType): Relation - updateRelation(id: Int!, autosave: Boolean, data: mutationRelationUpdateInput!, draft: Boolean, locale: LocaleInputType, trash: Boolean): Relation + duplicateCustomId(id: Int!, action: CreateAction, data: mutationCustomIdInput!): CustomId + createRelation(data: mutationRelationInput!, action: CreateAction, locale: LocaleInputType): Relation + updateRelation(id: Int!, autosave: Boolean, data: mutationRelationUpdateInput!, action: UpdateAction, locale: LocaleInputType, trash: Boolean): Relation deleteRelation(id: Int!, trash: Boolean): Relation - duplicateRelation(id: Int!, data: mutationRelationInput!): Relation - restoreVersionRelation(id: Int, draft: Boolean): Relation - createDummy(data: mutationDummyInput!, draft: Boolean, locale: LocaleInputType): Dummy - updateDummy(id: Int!, autosave: Boolean, data: mutationDummyUpdateInput!, draft: Boolean, locale: LocaleInputType, trash: Boolean): Dummy + duplicateRelation(id: Int!, action: CreateAction, data: mutationRelationInput!): Relation + restoreVersionRelation(id: Int, action: RestoreAction): Relation + createDummy(data: mutationDummyInput!, action: CreateAction, locale: LocaleInputType): Dummy + updateDummy(id: Int!, autosave: Boolean, data: mutationDummyUpdateInput!, action: UpdateAction, locale: LocaleInputType, trash: Boolean): Dummy deleteDummy(id: Int!, trash: Boolean): Dummy - duplicateDummy(id: Int!, data: mutationDummyInput!): Dummy - createErrorOnHook(data: mutationErrorOnHookInput!, draft: Boolean, locale: LocaleInputType): ErrorOnHook - updateErrorOnHook(id: Int!, autosave: Boolean, data: mutationErrorOnHookUpdateInput!, draft: Boolean, locale: LocaleInputType, trash: Boolean): ErrorOnHook + duplicateDummy(id: Int!, action: CreateAction, data: mutationDummyInput!): Dummy + createErrorOnHook(data: mutationErrorOnHookInput!, action: CreateAction, locale: LocaleInputType): ErrorOnHook + updateErrorOnHook(id: Int!, autosave: Boolean, data: mutationErrorOnHookUpdateInput!, action: UpdateAction, locale: LocaleInputType, trash: Boolean): ErrorOnHook deleteErrorOnHook(id: Int!, trash: Boolean): ErrorOnHook - duplicateErrorOnHook(id: Int!, data: mutationErrorOnHookInput!): ErrorOnHook - createPayloadApiTestOne(data: mutationPayloadApiTestOneInput!, draft: Boolean, locale: LocaleInputType): PayloadApiTestOne - updatePayloadApiTestOne(id: Int!, autosave: Boolean, data: mutationPayloadApiTestOneUpdateInput!, draft: Boolean, locale: LocaleInputType, trash: Boolean): PayloadApiTestOne + duplicateErrorOnHook(id: Int!, action: CreateAction, data: mutationErrorOnHookInput!): ErrorOnHook + createPayloadApiTestOne(data: mutationPayloadApiTestOneInput!, action: CreateAction, locale: LocaleInputType): PayloadApiTestOne + updatePayloadApiTestOne(id: Int!, autosave: Boolean, data: mutationPayloadApiTestOneUpdateInput!, action: UpdateAction, locale: LocaleInputType, trash: Boolean): PayloadApiTestOne deletePayloadApiTestOne(id: Int!, trash: Boolean): PayloadApiTestOne - duplicatePayloadApiTestOne(id: Int!, data: mutationPayloadApiTestOneInput!): PayloadApiTestOne - createPayloadApiTestTwo(data: mutationPayloadApiTestTwoInput!, draft: Boolean, locale: LocaleInputType): PayloadApiTestTwo - updatePayloadApiTestTwo(id: Int!, autosave: Boolean, data: mutationPayloadApiTestTwoUpdateInput!, draft: Boolean, locale: LocaleInputType, trash: Boolean): PayloadApiTestTwo + duplicatePayloadApiTestOne(id: Int!, action: CreateAction, data: mutationPayloadApiTestOneInput!): PayloadApiTestOne + createPayloadApiTestTwo(data: mutationPayloadApiTestTwoInput!, action: CreateAction, locale: LocaleInputType): PayloadApiTestTwo + updatePayloadApiTestTwo(id: Int!, autosave: Boolean, data: mutationPayloadApiTestTwoUpdateInput!, action: UpdateAction, locale: LocaleInputType, trash: Boolean): PayloadApiTestTwo deletePayloadApiTestTwo(id: Int!, trash: Boolean): PayloadApiTestTwo - duplicatePayloadApiTestTwo(id: Int!, data: mutationPayloadApiTestTwoInput!): PayloadApiTestTwo - createContentType(data: mutationContentTypeInput!, draft: Boolean, locale: LocaleInputType): ContentType - updateContentType(id: Int!, autosave: Boolean, data: mutationContentTypeUpdateInput!, draft: Boolean, locale: LocaleInputType, trash: Boolean): ContentType + duplicatePayloadApiTestTwo(id: Int!, action: CreateAction, data: mutationPayloadApiTestTwoInput!): PayloadApiTestTwo + createContentType(data: mutationContentTypeInput!, action: CreateAction, locale: LocaleInputType): ContentType + updateContentType(id: Int!, autosave: Boolean, data: mutationContentTypeUpdateInput!, action: UpdateAction, locale: LocaleInputType, trash: Boolean): ContentType deleteContentType(id: Int!, trash: Boolean): ContentType - duplicateContentType(id: Int!, data: mutationContentTypeInput!): ContentType - createCyclicalRelationship(data: mutationCyclicalRelationshipInput!, draft: Boolean, locale: LocaleInputType): CyclicalRelationship - updateCyclicalRelationship(id: Int!, autosave: Boolean, data: mutationCyclicalRelationshipUpdateInput!, draft: Boolean, locale: LocaleInputType, trash: Boolean): CyclicalRelationship + duplicateContentType(id: Int!, action: CreateAction, data: mutationContentTypeInput!): ContentType + createCyclicalRelationship(data: mutationCyclicalRelationshipInput!, action: CreateAction, locale: LocaleInputType): CyclicalRelationship + updateCyclicalRelationship(id: Int!, autosave: Boolean, data: mutationCyclicalRelationshipUpdateInput!, action: UpdateAction, locale: LocaleInputType, trash: Boolean): CyclicalRelationship deleteCyclicalRelationship(id: Int!, trash: Boolean): CyclicalRelationship - duplicateCyclicalRelationship(id: Int!, data: mutationCyclicalRelationshipInput!): CyclicalRelationship - restoreVersionCyclicalRelationship(id: Int, draft: Boolean): CyclicalRelationship - createMedia(data: mutationMediaInput!, draft: Boolean, locale: LocaleInputType): Media - updateMedia(id: Int!, autosave: Boolean, data: mutationMediaUpdateInput!, draft: Boolean, locale: LocaleInputType, trash: Boolean): Media + duplicateCyclicalRelationship(id: Int!, action: CreateAction, data: mutationCyclicalRelationshipInput!): CyclicalRelationship + restoreVersionCyclicalRelationship(id: Int, action: RestoreAction): CyclicalRelationship + createMedia(data: mutationMediaInput!, action: CreateAction, locale: LocaleInputType): Media + updateMedia(id: Int!, autosave: Boolean, data: mutationMediaUpdateInput!, action: UpdateAction, locale: LocaleInputType, trash: Boolean): Media deleteMedia(id: Int!, trash: Boolean): Media - duplicateMedia(id: Int!, data: mutationMediaInput!): Media - createSort(data: mutationSortInput!, draft: Boolean, locale: LocaleInputType): Sort - updateSort(id: Int!, autosave: Boolean, data: mutationSortUpdateInput!, draft: Boolean, locale: LocaleInputType, trash: Boolean): Sort + duplicateMedia(id: Int!, action: CreateAction, data: mutationMediaInput!): Media + createSort(data: mutationSortInput!, action: CreateAction, locale: LocaleInputType): Sort + updateSort(id: Int!, autosave: Boolean, data: mutationSortUpdateInput!, action: UpdateAction, locale: LocaleInputType, trash: Boolean): Sort deleteSort(id: Int!, trash: Boolean): Sort - duplicateSort(id: Int!, data: mutationSortInput!): Sort - createPayloadKv(data: mutationPayloadKvInput!, draft: Boolean, locale: LocaleInputType): PayloadKv - updatePayloadKv(id: Int!, autosave: Boolean, data: mutationPayloadKvUpdateInput!, draft: Boolean, locale: LocaleInputType, trash: Boolean): PayloadKv + duplicateSort(id: Int!, action: CreateAction, data: mutationSortInput!): Sort + createPayloadKv(data: mutationPayloadKvInput!, action: CreateAction, locale: LocaleInputType): PayloadKv + updatePayloadKv(id: Int!, autosave: Boolean, data: mutationPayloadKvUpdateInput!, action: UpdateAction, locale: LocaleInputType, trash: Boolean): PayloadKv deletePayloadKv(id: Int!, trash: Boolean): PayloadKv - duplicatePayloadKv(id: Int!, data: mutationPayloadKvInput!): PayloadKv - createPayloadLockedDocument(data: mutationPayloadLockedDocumentInput!, draft: Boolean, locale: LocaleInputType): PayloadLockedDocument - updatePayloadLockedDocument(id: Int!, autosave: Boolean, data: mutationPayloadLockedDocumentUpdateInput!, draft: Boolean, locale: LocaleInputType, trash: Boolean): PayloadLockedDocument + duplicatePayloadKv(id: Int!, action: CreateAction, data: mutationPayloadKvInput!): PayloadKv + createPayloadLockedDocument(data: mutationPayloadLockedDocumentInput!, action: CreateAction, locale: LocaleInputType): PayloadLockedDocument + updatePayloadLockedDocument(id: Int!, autosave: Boolean, data: mutationPayloadLockedDocumentUpdateInput!, action: UpdateAction, locale: LocaleInputType, trash: Boolean): PayloadLockedDocument deletePayloadLockedDocument(id: Int!, trash: Boolean): PayloadLockedDocument - duplicatePayloadLockedDocument(id: Int!, data: mutationPayloadLockedDocumentInput!): PayloadLockedDocument - createPayloadPreference(data: mutationPayloadPreferenceInput!, draft: Boolean, locale: LocaleInputType): PayloadPreference - updatePayloadPreference(id: Int!, autosave: Boolean, data: mutationPayloadPreferenceUpdateInput!, draft: Boolean, locale: LocaleInputType, trash: Boolean): PayloadPreference + duplicatePayloadLockedDocument(id: Int!, action: CreateAction, data: mutationPayloadLockedDocumentInput!): PayloadLockedDocument + createPayloadPreference(data: mutationPayloadPreferenceInput!, action: CreateAction, locale: LocaleInputType): PayloadPreference + updatePayloadPreference(id: Int!, autosave: Boolean, data: mutationPayloadPreferenceUpdateInput!, action: UpdateAction, locale: LocaleInputType, trash: Boolean): PayloadPreference deletePayloadPreference(id: Int!, trash: Boolean): PayloadPreference - duplicatePayloadPreference(id: Int!, data: mutationPayloadPreferenceInput!): PayloadPreference + duplicatePayloadPreference(id: Int!, action: CreateAction, data: mutationPayloadPreferenceInput!): PayloadPreference } input mutationUserInput { diff --git a/test/collections-graphql/seed.ts b/test/collections-graphql/seed.ts index 92aef0c1dcd..502338c5041 100644 --- a/test/collections-graphql/seed.ts +++ b/test/collections-graphql/seed.ts @@ -6,6 +6,7 @@ import { pointSlug, relationSlug, slug } from './shared.js' export const seed = async (_payload: Payload) => { await _payload.create({ + action: 'publish', collection: 'users', data: { email: devUser.email, @@ -14,6 +15,7 @@ export const seed = async (_payload: Payload) => { }) await _payload.create({ + action: 'publish', collection: 'custom-ids', data: { id: 1, @@ -22,6 +24,7 @@ export const seed = async (_payload: Payload) => { }) await _payload.create({ + action: 'publish', collection: slug, data: { relationToCustomID: 1, @@ -30,6 +33,7 @@ export const seed = async (_payload: Payload) => { }) await _payload.create({ + action: 'publish', collection: slug, data: { title: 'post1', @@ -37,6 +41,7 @@ export const seed = async (_payload: Payload) => { }) await _payload.create({ + action: 'publish', collection: slug, data: { title: 'post2', @@ -44,6 +49,7 @@ export const seed = async (_payload: Payload) => { }) await _payload.create({ + action: 'publish', collection: slug, data: { description: 'description', @@ -52,6 +58,7 @@ export const seed = async (_payload: Payload) => { }) await _payload.create({ + action: 'publish', collection: slug, data: { number: 1, @@ -60,6 +67,7 @@ export const seed = async (_payload: Payload) => { }) await _payload.create({ + action: 'publish', collection: slug, data: { number: 2, @@ -68,6 +76,7 @@ export const seed = async (_payload: Payload) => { }) const rel1 = await _payload.create({ + action: 'publish', collection: relationSlug, data: { name: 'name', @@ -75,6 +84,7 @@ export const seed = async (_payload: Payload) => { }) const rel2 = await _payload.create({ + action: 'publish', collection: relationSlug, data: { name: 'name2', @@ -82,6 +92,7 @@ export const seed = async (_payload: Payload) => { }) await _payload.create({ + action: 'publish', collection: slug, data: { relationHasManyField: rel1.id, @@ -90,6 +101,7 @@ export const seed = async (_payload: Payload) => { }) await _payload.create({ + action: 'publish', collection: slug, data: { relationHasManyField: rel2.id, @@ -98,6 +110,7 @@ export const seed = async (_payload: Payload) => { }) await _payload.create({ + action: 'publish', collection: slug, data: { relationMultiRelationTo: { @@ -109,6 +122,7 @@ export const seed = async (_payload: Payload) => { }) await _payload.create({ + action: 'publish', collection: slug, data: { relationMultiRelationToHasMany: [ @@ -126,11 +140,13 @@ export const seed = async (_payload: Payload) => { }) const payloadAPITest1 = await _payload.create({ + action: 'publish', collection: 'payload-api-test-ones', data: {}, }) await _payload.create({ + action: 'publish', collection: 'payload-api-test-twos', data: { relation: payloadAPITest1.id, @@ -138,6 +154,7 @@ export const seed = async (_payload: Payload) => { }) await _payload.create({ + action: 'publish', collection: pointSlug, data: { point: [10, 20], @@ -145,6 +162,7 @@ export const seed = async (_payload: Payload) => { }) await _payload.create({ + action: 'publish', collection: 'content-type', data: {}, }) diff --git a/test/database/int.spec.ts b/test/database/int.spec.ts index 6cb059da873..5db49b54454 100644 --- a/test/database/int.spec.ts +++ b/test/database/int.spec.ts @@ -1049,6 +1049,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('database', () = const docsCount = Math.random() > 0.5 ? 3 : Math.random() > 0.5 ? 2 : 1 for (let i = 0; i < docsCount; i++) { await payload.create({ + action: 'publish', collection: 'posts', data: { number: numbers[titles.indexOf(entry)]! + Math.random(), @@ -1086,14 +1087,22 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('database', () = const categoriesIDS: { category: string }[] = [] for (const { title } of categories) { - const doc = await payload.create({ collection: 'categories', data: { title } }) + const doc = await payload.create({ + action: 'publish', + collection: 'categories', + data: { title }, + }) categoriesIDS.push({ category: doc.id }) } for (const { category } of categoriesIDS) { const docsCount = Math.random() > 0.5 ? 3 : Math.random() > 0.5 ? 2 : 1 for (let i = 0; i < docsCount; i++) { - await payload.create({ collection: 'posts', data: { category, title: randomUUID() } }) + await payload.create({ + action: 'publish', + collection: 'posts', + data: { category, title: randomUUID() }, + }) } } @@ -1132,11 +1141,16 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('database', () = const categoriesIDS: { categories: string }[] = [] for (const { title } of categories) { - const doc = await payload.create({ collection: 'categories', data: { title } }) + const doc = await payload.create({ + action: 'publish', + collection: 'categories', + data: { title }, + }) categoriesIDS.push({ categories: doc.id }) } await payload.create({ + action: 'publish', collection: 'posts', data: { categories: [categoriesIDS[0]?.categories, categoriesIDS[1]?.categories], @@ -1145,6 +1159,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('database', () = }) await payload.create({ + action: 'publish', collection: 'posts', data: { categories: [ @@ -1157,6 +1172,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('database', () = }) await payload.create({ + action: 'publish', collection: 'posts', data: { categories: [ @@ -1216,6 +1232,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('database', () = await payload.delete({ collection: 'categories', where: {} }) const category_1 = await payload.create({ + action: 'publish', collection: 'categories', data: { title: 'category_1' }, }) @@ -2124,6 +2141,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('database', () = test('should create and read doc with custom db names', async ({ payload }) => { const relationA = await payload.create({ + action: 'publish', collection: 'relation-a', data: { title: 'hello', @@ -2131,6 +2149,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('database', () = }) const { id } = await payload.create({ + action: 'publish', collection: 'custom-schema', data: { array: [ @@ -2174,6 +2193,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('database', () = payload, }) => { const doc = await payload.create({ + action: 'publish', collection: customSchemaSlug, data: { array: [{ text: 'array row' }], @@ -2182,6 +2202,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('database', () = }) await payload.db.updateOne({ + action: 'publish', collection: customSchemaSlug, id: doc.id, data: { @@ -2207,6 +2228,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('database', () = payload, }) => { const doc = await payload.create({ + action: 'publish', collection: customSchemaSlug, data: { select: ['a', 'b'], @@ -2236,6 +2258,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('database', () = test('arrays should work with both long field names and dbName', async ({ payload }) => { const { id } = await payload.create({ + action: 'publish', collection: 'aliases', data: { thisIsALongFieldNameThatCanCauseAPostgresErrorEvenThoughWeSetAShorterDBName: [ @@ -3579,6 +3602,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('database', () = await expect( payload.create({ + action: 'publish', collection: 'places', data: { city: 'C', @@ -3612,12 +3636,14 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('database', () = test.describe('virtual fields', () => { test('should not save a field with `virtual: true` to the db', async ({ payload }) => { const createRes = await payload.create({ + action: 'publish', collection: 'fields-persistance', data: { array: [], text: 'asd', textHooked: 'asd' }, }) const resLocal = await payload.findByID({ id: createRes.id, + action: 'publish', collection: 'fields-persistance', }) @@ -3653,6 +3679,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('database', () = test('should not save a virtual field inside a block to the db', async ({ payload }) => { const created = await payload.create({ + action: 'publish', collection: fieldsPersistanceSlug, data: { blockWithVirtual: [ @@ -3678,8 +3705,13 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('database', () = }) test('should allow virtual field with reference', async ({ payload }) => { - const post = await payload.create({ collection: 'posts', data: { title: 'my-title' } }) + const post = await payload.create({ + action: 'publish', + collection: 'posts', + data: { title: 'my-title' }, + }) const { id } = await payload.create({ + action: 'publish', collection: 'virtual-relations', data: { post: post.id }, depth: 0, @@ -3696,8 +3728,13 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('database', () = }) test('should not break when using select', async ({ payload }) => { - const post = await payload.create({ collection: 'posts', data: { title: 'my-title-10' } }) + const post = await payload.create({ + action: 'publish', + collection: 'posts', + data: { title: 'my-title-10' }, + }) const { id } = await payload.create({ + action: 'publish', collection: 'virtual-relations', data: { post: post.id }, depth: 0, @@ -3713,8 +3750,13 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('database', () = }) test('should respect hidden: true for virtual fields with reference', async ({ payload }) => { - const post = await payload.create({ collection: 'posts', data: { title: 'my-title-3' } }) + const post = await payload.create({ + action: 'publish', + collection: 'posts', + data: { title: 'my-title-3' }, + }) const { id } = await payload.create({ + action: 'publish', collection: 'virtual-relations', data: { post: post.id }, depth: 0, @@ -3733,8 +3775,13 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('database', () = }) test('should allow virtual field as reference to ID', async ({ payload }) => { - const post = await payload.create({ collection: 'posts', data: { title: 'my-title' } }) + const post = await payload.create({ + action: 'publish', + collection: 'posts', + data: { title: 'my-title' }, + }) const { id } = await payload.create({ + action: 'publish', collection: 'virtual-relations', data: { post: post.id }, depth: 0, @@ -3747,8 +3794,13 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('database', () = }) test('should allow virtual field as reference to custom ID', async ({ payload }) => { - const customID = await payload.create({ collection: 'custom-ids', data: {} }) + const customID = await payload.create({ + action: 'publish', + collection: 'custom-ids', + data: {}, + }) const { id } = await payload.create({ + action: 'publish', collection: 'virtual-relations', data: { customID: customID.id }, depth: 0, @@ -3766,14 +3818,17 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('database', () = test('should allow deep virtual field as reference to ID', async ({ payload }) => { const category = await payload.create({ + action: 'publish', collection: 'categories', data: { title: 'category-3' }, }) const post = await payload.create({ + action: 'publish', collection: 'posts', data: { category: category.id, title: 'my-title-3' }, }) const { id } = await payload.create({ + action: 'publish', collection: 'virtual-relations', data: { post: post.id }, depth: 0, @@ -3787,6 +3842,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('database', () = test('should allow virtual field with reference localized', async ({ payload }) => { const post = await payload.create({ + action: 'publish', collection: 'posts', data: { localized: 'localized en', title: 'my-title' }, }) @@ -3799,6 +3855,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('database', () = }) const { id } = await payload.create({ + action: 'publish', collection: 'virtual-relations', data: { post: post.id }, depth: 0, @@ -3814,15 +3871,25 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('database', () = test('should allow to query by a virtual field with reference', async ({ payload }) => { await payload.delete({ collection: 'posts', where: {} }) await payload.delete({ collection: 'virtual-relations', where: {} }) - const post_1 = await payload.create({ collection: 'posts', data: { title: 'Dan' } }) - const post_2 = await payload.create({ collection: 'posts', data: { title: 'Mr.Dan' } }) + const post_1 = await payload.create({ + action: 'publish', + collection: 'posts', + data: { title: 'Dan' }, + }) + const post_2 = await payload.create({ + action: 'publish', + collection: 'posts', + data: { title: 'Mr.Dan' }, + }) const doc_1 = await payload.create({ + action: 'publish', collection: 'virtual-relations', data: { post: post_1.id }, depth: 0, }) const doc_2 = await payload.create({ + action: 'publish', collection: 'virtual-relations', data: { post: post_2.id }, depth: 0, @@ -3851,27 +3918,39 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('database', () = test('should allow virtual field 2x deep', async ({ payload }) => { const category = await payload.create({ + action: 'publish', collection: 'categories', data: { title: '1-category' }, }) const post = await payload.create({ + action: 'publish', collection: 'posts', data: { category: category.id, title: '1-post' }, }) - const doc = await payload.create({ collection: 'virtual-relations', data: { post: post.id } }) + const doc = await payload.create({ + action: 'publish', + collection: 'virtual-relations', + data: { post: post.id }, + }) expect(doc.postCategoryTitle).toBe('1-category') }) test('should not break when using select 2x deep', async ({ payload }) => { const category = await payload.create({ + action: 'publish', collection: 'categories', data: { title: '3-category' }, }) const post = await payload.create({ + action: 'publish', collection: 'posts', data: { category: category.id, title: '3-post' }, }) - const doc = await payload.create({ collection: 'virtual-relations', data: { post: post.id } }) + const doc = await payload.create({ + action: 'publish', + collection: 'virtual-relations', + data: { post: post.id }, + }) const docWithSelect = await payload.findByID({ id: doc.id, @@ -3884,14 +3963,20 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('database', () = test('should allow to query by virtual field 2x deep', async ({ payload }) => { const category = await payload.create({ + action: 'publish', collection: 'categories', data: { title: '2-category' }, }) const post = await payload.create({ + action: 'publish', collection: 'posts', data: { category: category.id, title: '2-post' }, }) - const doc = await payload.create({ collection: 'virtual-relations', data: { post: post.id } }) + const doc = await payload.create({ + action: 'publish', + collection: 'virtual-relations', + data: { post: post.id }, + }) const found = await payload.find({ collection: 'virtual-relations', where: { postCategoryTitle: { equals: '2-category' } }, @@ -3900,7 +3985,9 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('database', () = expect(found.docs[0].id).toBe(doc.id) }) - test('should allow to query by virtual field 2x deep with draft:true', async ({ payload }) => { + test("should allow to query by virtual field 2x deep with version: 'latest'", async ({ + payload, + }) => { await payload.delete({ collection: 'virtual-relations', where: {} }) const category = await payload.create({ collection: 'categories', @@ -3913,7 +4000,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('database', () = const doc = await payload.create({ collection: 'virtual-relations', data: { post: post.id } }) const found = await payload.find({ collection: 'virtual-relations', - draft: true, + version: 'latest', where: { postCategoryTitle: { equals: '3-category' } }, }) expect(found.docs).toHaveLength(1) @@ -3921,7 +4008,11 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('database', () = }) test('should allow referenced virtual field in globals', async ({ payload }) => { - const post = await payload.create({ collection: 'posts', data: { title: 'post' } }) + const post = await payload.create({ + action: 'publish', + collection: 'posts', + data: { title: 'post' }, + }) const globalData = await payload.updateGlobal({ slug: 'virtual-relation-global', data: { post: post.id }, @@ -3933,8 +4024,13 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('database', () = test('should allow referenced virtual field in collection update response', async ({ payload, }) => { - const post = await payload.create({ collection: 'posts', data: { title: 'post-updated' } }) + const post = await payload.create({ + action: 'publish', + collection: 'posts', + data: { title: 'post-updated' }, + }) const doc = await payload.create({ + action: 'publish', collection: 'virtual-relations', data: {}, depth: 0, @@ -3955,26 +4051,32 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('database', () = }) => { await payload.delete({ collection: 'virtual-relations', where: {} }) const category_1 = await payload.create({ + action: 'publish', collection: 'categories-custom-id', data: { id: 1 }, }) const category_2 = await payload.create({ + action: 'publish', collection: 'categories-custom-id', data: { id: 2 }, }) const post_1 = await payload.create({ + action: 'publish', collection: 'posts', data: { categoryCustomID: category_1.id, title: 'p-1' }, }) const post_2 = await payload.create({ + action: 'publish', collection: 'posts', data: { categoryCustomID: category_2.id, title: 'p-2' }, }) const virtual_1 = await payload.create({ + action: 'publish', collection: 'virtual-relations', data: { post: post_1.id }, }) const virtual_2 = await payload.create({ + action: 'publish', collection: 'virtual-relations', data: { post: post_2.id }, }) @@ -4011,17 +4113,27 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('database', () = where: { email: { equals: devUser.email } }, }) if (existingUsers.length === 0) { - await payload.create({ collection: 'users', data: devUser }) + await payload.create({ action: 'publish', collection: 'users', data: devUser }) } await restClient.login({ slug: 'users', credentials: devUser }) - const post_1 = await payload.create({ collection: 'posts', data: { title: 'A' } }) - const post_2 = await payload.create({ collection: 'posts', data: { title: 'B' } }) + const post_1 = await payload.create({ + action: 'publish', + collection: 'posts', + data: { title: 'A' }, + }) + const post_2 = await payload.create({ + action: 'publish', + collection: 'posts', + data: { title: 'B' }, + }) const doc_1 = await payload.create({ + action: 'publish', collection: 'virtual-relations', data: { post: post_1 }, }) const doc_2 = await payload.create({ + action: 'publish', collection: 'virtual-relations', data: { post: post_2 }, }) @@ -4823,14 +4935,14 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('database', () = } }) - test('should allow to query like by ID with draft: true', async ({ payload }) => { + test("should allow to query like by ID with version: 'latest'", async ({ payload }) => { const category = await payload.create({ collection: 'categories', data: { title: 'category123' }, }) const res = await payload.find({ collection: 'categories', - draft: true, + version: 'latest', where: { id: { like: typeof category.id === 'number' ? `${category.id}` : category.id } }, }) expect(res.docs).toHaveLength(1) @@ -6171,7 +6283,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('database', () = const doc = await payload.create({ collection: 'categories', data: { name: `Category ${i}` }, - draft: true, + action: 'saveDraft', }) createdIds.push(doc.id) } @@ -6183,7 +6295,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('database', () = const resultsNoSort = await payload.find({ collection: 'categories', limit: 10, - draft: true, + version: 'latest', // No sort parameter }) diff --git a/test/database/pg-replica/int.spec.ts b/test/database/pg-replica/int.spec.ts index 38553f16922..38026042adc 100644 --- a/test/database/pg-replica/int.spec.ts +++ b/test/database/pg-replica/int.spec.ts @@ -213,7 +213,7 @@ test.suite({ db: (adapter) => adapter === 'postgres-read-replicas' })( const doc = await (payload as any).create({ collection: 'posts', data: { title: 'versioned-doc', _status: 'draft' }, - draft: true, + action: 'saveDraft', }) expect(doc).toBeDefined() @@ -231,7 +231,7 @@ test.suite({ db: (adapter) => adapter === 'postgres-read-replicas' })( const doc = await (payload as any).create({ collection: 'posts', data: { title: 'draft-original', _status: 'draft' }, - draft: true, + action: 'saveDraft', }) // This triggers updateOne (has getPrimaryDb) + createVersion (now fixed) @@ -239,7 +239,7 @@ test.suite({ db: (adapter) => adapter === 'postgres-read-replicas' })( collection: 'posts', id: doc.id, data: { title: 'draft-updated' }, - draft: true, + action: 'saveDraft', }) expect(updated.title).toBe('draft-updated') @@ -256,14 +256,14 @@ test.suite({ db: (adapter) => adapter === 'postgres-read-replicas' })( const doc = await (payload as any).create({ collection: 'posts', data: { title: 'restore-v1', _status: 'draft' }, - draft: true, + action: 'saveDraft', }) await (payload as any).update({ collection: 'posts', id: doc.id, data: { title: 'restore-v2' }, - draft: true, + action: 'saveDraft', }) const versions = await (payload as any).findVersions({ diff --git a/test/evals/datasets/fields/codegen.ts b/test/evals/datasets/fields/codegen.ts index d6d4c167268..611150c23e6 100644 --- a/test/evals/datasets/fields/codegen.ts +++ b/test/evals/datasets/fields/codegen.ts @@ -130,4 +130,34 @@ export const fieldsCodegenDataset: EvalCase[] = [ ) }, }, + { + category: 'fields', + configPath: 'fields/codegen/version-action', + input: + 'version-action: Enable Payload drafts on the posts collection using versions.drafts. Do not add a custom status select field. Add an afterChange hook that uses the resolved action argument (not a draft boolean) and only performs a side effect when action is publish.', + verify: ({ + ast, + config: { + collections: { posts }, + }, + expect, + score, + source, + }) => { + expect(posts).toBeDefined() + expect( + posts?.versions && typeof posts.versions === 'object' && Boolean(posts.versions.drafts), + ).toBe(true) + expect(posts?.hooks?.afterChange).toBeDefined() + expect(source).toMatch(/action\s*===?\s*['"]publish['"]/) + expect(source).not.toMatch(/\bdraft\s*:\s*true\b/) + expect(source).not.toMatch(/strictDraftTypes/) + expect( + ast.collections.find((collection) => collection.slug === 'posts')?.hooks.afterChange, + ).toBe(true) + return score( + 'posts.versions.drafts enabled, no custom status field, afterChange hook reads resolved action and branches on action === "publish"', + ) + }, + }, ] diff --git a/test/evals/datasets/mcp.ts b/test/evals/datasets/mcp.ts index 7cc484673f4..f90e29a2d42 100644 --- a/test/evals/datasets/mcp.ts +++ b/test/evals/datasets/mcp.ts @@ -422,7 +422,7 @@ export const mcpDataset: EvalCase[] = [ verify: async ({ audit, expect, payload, transcript }) => { const { docs: draftArticles } = await payload.find({ collection: 'articles', - draft: true, + version: 'latest', locale: 'en', where: { title: { equals: 'MCP Draft Update Saved' } }, }) @@ -432,7 +432,7 @@ export const mcpDataset: EvalCase[] = [ const publishedArticle = await payload.findByID({ id: draftArticle!.id, collection: 'articles', - draft: false, + version: 'published', locale: 'en', }) @@ -466,7 +466,7 @@ export const mcpDataset: EvalCase[] = [ verify: async ({ audit, expect, payload, transcript }) => { const { docs: publishedArticles } = await payload.find({ collection: 'articles', - draft: false, + version: 'published', locale: 'en', where: { title: { equals: 'MCP Published Update Saved' } }, }) @@ -501,7 +501,7 @@ export const mcpDataset: EvalCase[] = [ verify: async ({ audit, expect, payload, transcript }) => { const { docs: unpublishedArticles } = await payload.find({ collection: 'articles', - draft: false, + version: 'published', locale: 'en', where: { title: { equals: 'MCP Unpublish Target' } }, }) @@ -536,14 +536,14 @@ export const mcpDataset: EvalCase[] = [ id: article.id, collection: 'articles', data: { title: 'MCP Draft Must Not Be Read' }, - draft: true, + action: 'saveDraft', locale: 'en', }) }, verify: async ({ audit, expect, payload, transcript }) => { const { docs: publishedArticles } = await payload.find({ collection: 'articles', - draft: false, + version: 'published', locale: 'en', where: { title: { equals: 'MCP Published Read Target' } }, }) @@ -553,7 +553,7 @@ export const mcpDataset: EvalCase[] = [ const draftArticle = await payload.findByID({ id: publishedArticle!.id, collection: 'articles', - draft: true, + version: 'latest', locale: 'en', }) const agentResponse = getFinalAgentResponse({ transcript }) @@ -593,14 +593,14 @@ export const mcpDataset: EvalCase[] = [ id: article.id, collection: 'articles', data: { title: 'MCP Draft Read Latest Title' }, - draft: true, + action: 'saveDraft', locale: 'en', }) }, verify: async ({ audit, expect, payload, transcript }) => { const { docs: draftArticles } = await payload.find({ collection: 'articles', - draft: true, + version: 'latest', locale: 'en', where: { title: { equals: 'MCP Draft Read Latest Title' } }, }) @@ -641,7 +641,7 @@ export const mcpDataset: EvalCase[] = [ id: article.id, collection: 'articles', data: { _status: 'published', title: 'MCP Spanish Published Title' }, - draft: false, + action: 'publish', locale: 'es', publishAllLocales: false, }) @@ -649,14 +649,14 @@ export const mcpDataset: EvalCase[] = [ id: article.id, collection: 'articles', data: { title: 'MCP Spanish Draft Title' }, - draft: true, + action: 'saveDraft', locale: 'es', }) }, verify: async ({ audit, expect, payload, transcript }) => { const { docs: publishedEnglishArticles } = await payload.find({ collection: 'articles', - draft: false, + version: 'published', locale: 'en', where: { title: { equals: 'MCP English Published Title' } }, }) @@ -666,13 +666,13 @@ export const mcpDataset: EvalCase[] = [ const publishedSpanish = await payload.findByID({ id: publishedEnglish!.id, collection: 'articles', - draft: false, + version: 'published', locale: 'es', }) const draftSpanish = await payload.findByID({ id: publishedEnglish!.id, collection: 'articles', - draft: true, + version: 'latest', locale: 'es', }) @@ -701,7 +701,7 @@ export const mcpDataset: EvalCase[] = [ verify: async ({ audit, expect, payload, transcript }) => { const { docs } = await payload.find({ collection: 'articles', - draft: true, + version: 'latest', locale: 'en', where: { title: { equals: 'MCP Newly Created Draft' } }, }) @@ -727,7 +727,7 @@ export const mcpDataset: EvalCase[] = [ verify: async ({ audit, expect, payload, transcript }) => { const { docs } = await payload.find({ collection: 'articles', - draft: false, + version: 'published', locale: 'en', where: { title: { equals: 'MCP Newly Created Published' } }, }) @@ -745,4 +745,92 @@ export const mcpDataset: EvalCase[] = [ }) }, }, + { + bootConfig: true, + category: 'mcp', + configPath: 'mcp/shared', + input: + 'version-action: Create a new unpublished draft article titled "Version Action Created Draft". Use the v4 action API, not the removed draft boolean.', + verify: async ({ audit, expect, payload, transcript }) => { + const { docs } = await payload.find({ + collection: 'articles', + locale: 'en', + version: 'latest', + where: { title: { equals: 'Version Action Created Draft' } }, + }) + const article = docs[0] + const createCalls = audit.filter( + (event) => event.type === 'mcp-tool-call' && event.name === 'createDocuments', + ) + const createCall = createCalls[0] + const createInput = createCall?.input as { action?: unknown; draft?: unknown } | undefined + + expect(docs).toHaveLength(1) + expect(article?._status).toBe('draft') + expect(createInput).not.toHaveProperty('draft') + expect(createInput?.action === 'saveDraft' || createInput?.action === undefined).toBe(true) + + return scoreMCPExecution({ + audit, + optimalModificationAttempts: 1, + optimalToolCalls: 2, + requiredPayloadOperation: { slug: 'articles', kind: 'mutation' }, + transcript, + }) + }, + }, + { + bootConfig: true, + category: 'mcp', + configPath: 'mcp/shared', + input: + 'version-action: Show me the latest unpublished draft of the article currently published as "Version Action Read Published". Ignore the published title.', + setup: async ({ payload }) => { + const article = await payload.create({ + collection: 'articles', + data: { _status: 'published', title: 'Version Action Read Published' }, + locale: 'en', + }) + + await payload.update({ + id: article.id, + action: 'saveDraft', + collection: 'articles', + data: { title: 'Version Action Read Latest' }, + locale: 'en', + }) + }, + verify: async ({ audit, expect, payload, transcript }) => { + const { docs: draftArticles } = await payload.find({ + collection: 'articles', + locale: 'en', + version: 'latest', + where: { title: { equals: 'Version Action Read Latest' } }, + }) + const findCalls = audit.filter( + (event) => event.type === 'mcp-tool-call' && event.name === 'findDocuments', + ) + const findCall = findCalls[0] + const findInput = findCall?.input as { draft?: unknown; version?: unknown } | undefined + const agentResponse = getFinalAgentResponse({ transcript }) + + expect(draftArticles).toHaveLength(1) + expect(agentResponse).toContain('Version Action Read Latest') + expect(agentResponse).not.toContain('Version Action Read Published') + expect(findInput).not.toHaveProperty('draft') + expect(['latest', 'draft']).toContain(findInput?.version) + + return scoreMCPExecution({ + audit, + optimalModificationAttempts: 0, + optimalToolCalls: 1, + requiredPayloadOperation: { + entityType: 'collection', + kind: 'read', + slug: 'articles', + }, + transcript, + }) + }, + }, ] diff --git a/test/evals/fixtures/fields/codegen/version-action/payload.config.ts b/test/evals/fixtures/fields/codegen/version-action/payload.config.ts new file mode 100644 index 00000000000..b7d2a9ccde9 --- /dev/null +++ b/test/evals/fixtures/fields/codegen/version-action/payload.config.ts @@ -0,0 +1,20 @@ +import { stubAdapter } from '@/db-stub.js' +import { buildConfig } from 'payload' + +export default buildConfig({ + db: stubAdapter, + secret: 'eval-fixture', + collections: [ + { + slug: 'posts', + fields: [ + { + name: 'title', + type: 'text', + required: true, + }, + ], + versions: false, + }, + ], +}) diff --git a/test/fields-relationship/e2e.spec.ts b/test/fields-relationship/e2e.spec.ts index cd157c6d802..eea8a1de5c4 100644 --- a/test/fields-relationship/e2e.spec.ts +++ b/test/fields-relationship/e2e.spec.ts @@ -776,7 +776,7 @@ describe('Relationship Field', () => { data: { title: '', }, - draft: true, + action: 'saveDraft', }) await payload.update({ @@ -785,7 +785,7 @@ describe('Relationship Field', () => { data: { title: 'Draft Only Title', }, - draft: true, + action: 'saveDraft', }) // Create the doc that holds the relationship to the draft-only related doc. diff --git a/test/fields/int.spec.ts b/test/fields/int.spec.ts index e041e193d7d..fcca5cc052c 100644 --- a/test/fields/int.spec.ts +++ b/test/fields/int.spec.ts @@ -541,7 +541,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Fields', () => test('should generate the slug from the source on a draft create', async ({ payload }) => { const draft = await payload.create({ collection: 'slug-autosave', - draft: true, + action: 'saveDraft', data: { title: 'Draft One' }, }) created.push(draft.id) @@ -553,7 +553,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Fields', () => }) => { const draft = await payload.create({ collection: 'slug-autosave', - draft: true, + action: 'saveDraft', data: {}, }) created.push(draft.id) @@ -564,7 +564,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Fields', () => const latestDraft = await payload.findByID({ collection: 'slug-autosave', id: draft.id, - draft: true, + version: 'latest', }) expect(latestDraft.slug).toBe('slug-autosave-1') }) @@ -574,7 +574,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Fields', () => }) => { const draft = await payload.create({ collection: 'slug-autosave', - draft: true, + action: 'saveDraft', data: { slug: '!!!' }, }) created.push(draft.id) @@ -586,14 +586,14 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Fields', () => }) => { const first = await payload.create({ collection: 'slug-autosave', - draft: true, + action: 'saveDraft', data: {}, }) created.push(first.id) const second = await payload.create({ collection: 'slug-autosave', - draft: true, + action: 'saveDraft', data: {}, }) created.push(second.id) @@ -605,7 +605,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Fields', () => test('should reject a draft slug that collides with another draft', async ({ payload }) => { const first = await payload.create({ collection: 'slug-autosave', - draft: true, + action: 'saveDraft', data: { title: 'First', slug: 'shared-draft-slug' }, }) created.push(first.id) @@ -614,7 +614,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Fields', () => await expect( payload.create({ collection: 'slug-autosave', - draft: true, + action: 'saveDraft', data: { title: 'Second', slug: 'shared-draft-slug' }, }), ).rejects.toThrow() @@ -625,13 +625,13 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Fields', () => }) => { const a = await payload.create({ collection: 'slug-autosave', - draft: true, + action: 'saveDraft', data: { title: 'A', slug: 'draft-a' }, }) created.push(a.id) const b = await payload.create({ collection: 'slug-autosave', - draft: true, + action: 'saveDraft', data: { title: 'B', slug: 'draft-b' }, }) created.push(b.id) @@ -640,7 +640,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Fields', () => payload.update({ collection: 'slug-autosave', id: b.id, - draft: true, + action: 'saveDraft', data: { slug: 'draft-a' }, }), ).rejects.toThrow() @@ -651,7 +651,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Fields', () => }) => { const en = await payload.create({ collection: 'slug-autosave', - draft: true, + action: 'saveDraft', data: { localizedTitle: 'One', localizedSlug: 'shared-draft-localized' }, locale: 'en', }) @@ -661,7 +661,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Fields', () => // Same value in a different locale is fine — uniqueness is per-locale. const es = await payload.create({ collection: 'slug-autosave', - draft: true, + action: 'saveDraft', data: { localizedTitle: 'Uno', localizedSlug: 'shared-draft-localized' }, locale: 'es', }) @@ -672,7 +672,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Fields', () => await expect( payload.create({ collection: 'slug-autosave', - draft: true, + action: 'saveDraft', data: { localizedTitle: 'Two', localizedSlug: 'shared-draft-localized' }, locale: 'en', }), @@ -684,7 +684,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Fields', () => }) => { const draft = await payload.create({ collection: 'slug-autosave', - draft: true, + action: 'saveDraft', data: {}, locale: 'en', }) @@ -695,7 +695,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Fields', () => const latestDraft = await payload.findByID({ collection: 'slug-autosave', id: draft.id, - draft: true, + version: 'latest', locale: 'en', }) expect(latestDraft.localizedSlug).toBe('slug-autosave-1') @@ -714,7 +714,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Fields', () => }) => { const draft = await payload.create({ collection: 'slug-autosave', - draft: true, + action: 'saveDraft', data: {}, locale: 'en', }) @@ -723,7 +723,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Fields', () => const allLocales = await payload.findByID({ collection: 'slug-autosave', id: draft.id, - draft: true, + version: 'latest', locale: 'all', }) const localizedSlug = allLocales.localizedSlug as unknown as Record @@ -736,7 +736,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Fields', () => }) => { const en = await payload.create({ collection: 'slug-autosave', - draft: true, + action: 'saveDraft', data: {}, locale: 'en', }) @@ -747,7 +747,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Fields', () => const es = await payload.update({ collection: 'slug-autosave', id: en.id, - draft: true, + action: 'saveDraft', data: {}, locale: 'es', }) @@ -757,7 +757,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Fields', () => test('should give a duplicated draft its own unique slug', async ({ payload }) => { const original = await payload.create({ collection: 'slug-autosave', - draft: true, + action: 'saveDraft', data: { title: 'Dup Me', slug: 'dup-me' }, }) created.push(original.id) @@ -775,7 +775,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Fields', () => }) => { const draft = await payload.create({ collection: 'slug-autosave', - draft: true, + action: 'saveDraft', data: { title: 'Draft One', slug: 'user-typed' }, }) created.push(draft.id) @@ -785,7 +785,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Fields', () => test('should freeze the slug across subsequent autosaves once set', async ({ payload }) => { const draft = await payload.create({ collection: 'slug-autosave', - draft: true, + action: 'saveDraft', data: { title: 'Draft One' }, }) created.push(draft.id) @@ -794,7 +794,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Fields', () => const updated = await payload.update({ collection: 'slug-autosave', id: draft.id, - draft: true, + action: 'saveDraft', data: { title: 'Draft One Updated' }, }) expect(updated.slug).toBe('draft-one') @@ -803,7 +803,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Fields', () => test('should keep an admin overwrite across subsequent autosaves', async ({ payload }) => { const draft = await payload.create({ collection: 'slug-autosave', - draft: true, + action: 'saveDraft', data: { title: 'Draft One' }, }) created.push(draft.id) @@ -811,14 +811,14 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Fields', () => await payload.update({ collection: 'slug-autosave', id: draft.id, - draft: true, + action: 'saveDraft', data: { title: 'Draft Two' }, }) const overwritten = await payload.update({ collection: 'slug-autosave', id: draft.id, - draft: true, + action: 'saveDraft', data: { slug: 'human-chosen-slug' }, }) expect(overwritten.slug).toBe('human-chosen-slug') @@ -826,7 +826,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Fields', () => const afterMoreEdits = await payload.update({ collection: 'slug-autosave', id: draft.id, - draft: true, + action: 'saveDraft', data: { title: 'Draft Three' }, }) expect(afterMoreEdits.slug).toBe('human-chosen-slug') @@ -835,7 +835,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Fields', () => test('should not change an already-set slug on publish or after', async ({ payload }) => { const draft = await payload.create({ collection: 'slug-autosave', - draft: true, + action: 'saveDraft', data: { title: 'Draft One' }, }) created.push(draft.id) @@ -844,7 +844,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Fields', () => await payload.update({ collection: 'slug-autosave', id: draft.id, - draft: true, + action: 'saveDraft', data: { title: 'Publishable Title' }, }) @@ -2000,7 +2000,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Fields', () => const array = await payload.create({ collection: 'select-versions-fields', data: { array: [{ hasManyArr: ['a', 'b'] }] }, - draft: true, + action: 'saveDraft', }) expect(array.array[0]?.hasManyArr).toStrictEqual(['a', 'b']) @@ -2024,7 +2024,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Fields', () => id: data.id, collection: 'select-versions-fields', data: { hasMany: ['a'] }, - draft: true, + action: 'saveDraft', }) expect(data.hasMany).toStrictEqual(['a']) @@ -2032,7 +2032,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Fields', () => id: data.id, collection: 'select-versions-fields', data: { hasMany: ['a', 'b', 'c', 'd'] }, - draft: true, + action: 'saveDraft', autosave: true, }) expect(data.hasMany).toStrictEqual(['a', 'b', 'c', 'd']) @@ -2041,7 +2041,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Fields', () => id: data.id, collection: 'select-versions-fields', data: { hasMany: ['a'] }, - draft: true, + action: 'saveDraft', autosave: true, }) expect(data.hasMany).toStrictEqual(['a']) @@ -5578,7 +5578,6 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Fields', () => dateWithOffsetTimezone: '2027-08-12T04:30:00.000Z', dateWithOffsetTimezone_tz: '+05:30', }, - draft: true, }) expect(doc.dateWithOffsetTimezone).toEqual('2027-08-12T04:30:00.000Z') @@ -5593,7 +5592,6 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Fields', () => dateWithMixedTimezones: '2027-08-12T14:00:00.000Z', dateWithMixedTimezones_tz: 'America/New_York', }, - draft: true, }) expect(doc.dateWithMixedTimezones_tz).toEqual('America/New_York') @@ -5617,7 +5615,6 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Fields', () => dateWithOffsetTimezone: '2027-08-12T04:30:00.000Z', dateWithOffsetTimezone_tz: '+05:30', }, - draft: true, }) await payload.create({ @@ -5627,7 +5624,6 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Fields', () => dateWithOffsetTimezone: '2027-08-12T08:00:00.000Z', dateWithOffsetTimezone_tz: '-08:00', }, - draft: true, }) const indiaTimezoneResults = await payload.find({ @@ -5653,7 +5649,6 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Fields', () => dateWithMixedTimezones: '2027-08-12T14:00:00.000Z', dateWithMixedTimezones_tz: 'America/New_York', }, - draft: true, }) expect(doc.dateWithMixedTimezones_tz).toEqual('America/New_York') @@ -5666,7 +5661,6 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Fields', () => dateWithMixedTimezones: '2027-08-12T04:30:00.000Z', dateWithMixedTimezones_tz: '+05:30', }, - draft: true, }) expect(doc2.dateWithMixedTimezones_tz).toEqual('+05:30') @@ -5681,7 +5675,6 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Fields', () => dateWithOffsetTimezone: '2027-08-12T04:30:00.000Z', dateWithOffsetTimezone_tz: '+05:30', }, - draft: true, }) expect(doc1.dateWithOffsetTimezone_tz).toEqual('+05:30') @@ -5694,7 +5687,6 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Fields', () => dateWithOffsetTimezone: '2027-08-12T16:00:00.000Z', dateWithOffsetTimezone_tz: '-08:00', }, - draft: true, }) expect(doc2.dateWithOffsetTimezone_tz).toEqual('-08:00') @@ -5707,7 +5699,6 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Fields', () => dateWithOffsetTimezone: '2027-08-12T10:00:00.000Z', dateWithOffsetTimezone_tz: '+00:00', }, - draft: true, }) expect(doc3.dateWithOffsetTimezone_tz).toEqual('+00:00') @@ -5726,7 +5717,6 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Fields', () => dateWithOffsetTimezone: '2027-08-12T04:30:00.000Z', dateWithOffsetTimezone_tz: '+05:30', }, - draft: true, }) const query = ` @@ -5761,7 +5751,6 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Fields', () => dateWithOffsetTimezone: '2027-08-12T16:00:00.000Z', dateWithOffsetTimezone_tz: '-08:00', }, - draft: true, }) const query = ` @@ -5790,7 +5779,6 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Fields', () => dateWithMixedTimezones: '2027-08-12T14:00:00.000Z', dateWithMixedTimezones_tz: 'America/New_York', }, - draft: true, }) const query = ` @@ -5892,7 +5880,6 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Fields', () => dateWithOffsetTimezone: '2027-08-12T04:30:00.000Z', dateWithOffsetTimezone_tz: '+05:30', }, - draft: true, }) const mutation = ` @@ -5967,7 +5954,6 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Fields', () => dateWithMixedTimezones: '2027-08-12T04:30:00.000Z', dateWithMixedTimezones_tz: '+05:30', }, - draft: true, }) const mutation = ` @@ -6004,7 +5990,6 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Fields', () => dateWithOffsetTimezone: '2027-08-12T04:30:00.000Z', dateWithOffsetTimezone_tz: '+05:30', }, - draft: true, }) await payload.create({ @@ -6014,7 +5999,6 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Fields', () => dateWithOffsetTimezone: '2027-08-12T16:00:00.000Z', dateWithOffsetTimezone_tz: '-08:00', }, - draft: true, }) const query = ` @@ -6096,7 +6080,6 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Fields', () => dateWithTimezoneWithDisabledColumns: '2027-08-12T10:00:00.000Z', dateWithTimezoneWithDisabledColumns_tz: 'America/New_York', }, - draft: true, }) expect(doc.dateWithTimezoneWithDisabledColumns_tz).toEqual('America/New_York') @@ -6128,7 +6111,6 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Fields', () => ...dataWithoutNoDefaultTz, dateWithTimezoneNoDefault: '2027-08-12T14:00:00.000Z', }, - draft: true, }) expect(doc.dateWithTimezoneNoDefault_tz).toBeFalsy() @@ -6143,7 +6125,6 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Fields', () => ...dataWithoutMixedTz, dateWithMixedTimezones: '2027-08-12T14:00:00.000Z', }, - draft: true, }) expect(doc.dateWithMixedTimezones_tz).toEqual('America/New_York') diff --git a/test/graphql-schema-gen/schema.graphql b/test/graphql-schema-gen/schema.graphql index 3a8042257fe..d07b4c8a2f5 100644 --- a/test/graphql-schema-gen/schema.graphql +++ b/test/graphql-schema-gen/schema.graphql @@ -1,25 +1,47 @@ +enum ReadVersion { + published + latest + draft +} + +enum CreateAction { + publish + saveDraft +} + +enum UpdateAction { + publish + saveDraft + unpublish +} + +enum RestoreAction { + publish + saveDraft +} + type Query { - Collection1(id: String!, draft: Boolean, trash: Boolean): Collection1 - Collection1s(draft: Boolean, where: Collection1_where, limit: Int, page: Int, pagination: Boolean, sort: String, trash: Boolean): Collection1s - countCollection1s(draft: Boolean, trash: Boolean, where: Collection1_where): countCollection1s + Collection1(id: String!, version: ReadVersion, trash: Boolean): Collection1 + Collection1s(version: ReadVersion, where: Collection1_where, limit: Int, page: Int, pagination: Boolean, sort: String, trash: Boolean): Collection1s + countCollection1s(trash: Boolean, where: Collection1_where): countCollection1s docAccessCollection1(id: String!): collection1DocAccess - Collection2(id: String!, draft: Boolean, trash: Boolean): Collection2 - Collection2s(draft: Boolean, where: Collection2_where, limit: Int, page: Int, pagination: Boolean, sort: String, trash: Boolean): Collection2s - countCollection2s(draft: Boolean, trash: Boolean, where: Collection2_where): countCollection2s + Collection2(id: String!, version: ReadVersion, trash: Boolean): Collection2 + Collection2s(version: ReadVersion, where: Collection2_where, limit: Int, page: Int, pagination: Boolean, sort: String, trash: Boolean): Collection2s + countCollection2s(trash: Boolean, where: Collection2_where): countCollection2s docAccessCollection2(id: String!): collection2DocAccess - User(id: String!, draft: Boolean, trash: Boolean): User - Users(draft: Boolean, where: User_where, limit: Int, page: Int, pagination: Boolean, sort: String, trash: Boolean): Users - countUsers(draft: Boolean, trash: Boolean, where: User_where): countUsers + User(id: String!, version: ReadVersion, trash: Boolean): User + Users(version: ReadVersion, where: User_where, limit: Int, page: Int, pagination: Boolean, sort: String, trash: Boolean): Users + countUsers(trash: Boolean, where: User_where): countUsers docAccessUser(id: String!): usersDocAccess - meUser: usersMe + meUser(version: ReadVersion): usersMe initializedUser: Boolean - PayloadLockedDocument(id: String!, draft: Boolean, trash: Boolean): PayloadLockedDocument - PayloadLockedDocuments(draft: Boolean, where: PayloadLockedDocument_where, limit: Int, page: Int, pagination: Boolean, sort: String, trash: Boolean): PayloadLockedDocuments - countPayloadLockedDocuments(draft: Boolean, trash: Boolean, where: PayloadLockedDocument_where): countPayloadLockedDocuments + PayloadLockedDocument(id: String!, version: ReadVersion, trash: Boolean): PayloadLockedDocument + PayloadLockedDocuments(version: ReadVersion, where: PayloadLockedDocument_where, limit: Int, page: Int, pagination: Boolean, sort: String, trash: Boolean): PayloadLockedDocuments + countPayloadLockedDocuments(trash: Boolean, where: PayloadLockedDocument_where): countPayloadLockedDocuments docAccessPayloadLockedDocument(id: String!): payload_locked_documentsDocAccess - PayloadPreference(id: String!, draft: Boolean, trash: Boolean): PayloadPreference - PayloadPreferences(draft: Boolean, where: PayloadPreference_where, limit: Int, page: Int, pagination: Boolean, sort: String, trash: Boolean): PayloadPreferences - countPayloadPreferences(draft: Boolean, trash: Boolean, where: PayloadPreference_where): countPayloadPreferences + PayloadPreference(id: String!, version: ReadVersion, trash: Boolean): PayloadPreference + PayloadPreferences(version: ReadVersion, where: PayloadPreference_where, limit: Int, page: Int, pagination: Boolean, sort: String, trash: Boolean): PayloadPreferences + countPayloadPreferences(trash: Boolean, where: PayloadPreference_where): countPayloadPreferences docAccessPayloadPreference(id: String!): payload_preferencesDocAccess Access: Access } @@ -3243,16 +3265,16 @@ type PayloadPreferencesDeleteAccess { } type Mutation { - createCollection1(data: mutationCollection1Input!, draft: Boolean): Collection1 - updateCollection1(id: String!, autosave: Boolean, data: mutationCollection1UpdateInput!, draft: Boolean, trash: Boolean): Collection1 + createCollection1(data: mutationCollection1Input!, action: CreateAction): Collection1 + updateCollection1(id: String!, autosave: Boolean, data: mutationCollection1UpdateInput!, action: UpdateAction, trash: Boolean): Collection1 deleteCollection1(id: String!, trash: Boolean): Collection1 - duplicateCollection1(id: String!, data: mutationCollection1Input!): Collection1 - createCollection2(data: mutationCollection2Input!, draft: Boolean): Collection2 - updateCollection2(id: String!, autosave: Boolean, data: mutationCollection2UpdateInput!, draft: Boolean, trash: Boolean): Collection2 + duplicateCollection1(id: String!, action: CreateAction, data: mutationCollection1Input!): Collection1 + createCollection2(data: mutationCollection2Input!, action: CreateAction): Collection2 + updateCollection2(id: String!, autosave: Boolean, data: mutationCollection2UpdateInput!, action: UpdateAction, trash: Boolean): Collection2 deleteCollection2(id: String!, trash: Boolean): Collection2 - duplicateCollection2(id: String!, data: mutationCollection2Input!): Collection2 - createUser(data: mutationUserInput!, draft: Boolean): User - updateUser(id: String!, autosave: Boolean, data: mutationUserUpdateInput!, draft: Boolean, trash: Boolean): User + duplicateCollection2(id: String!, action: CreateAction, data: mutationCollection2Input!): Collection2 + createUser(data: mutationUserInput!, action: CreateAction): User + updateUser(id: String!, autosave: Boolean, data: mutationUserUpdateInput!, action: UpdateAction, trash: Boolean): User deleteUser(id: String!, trash: Boolean): User refreshTokenUser: usersRefreshedUser logoutUser(allSessions: Boolean): String @@ -3261,14 +3283,14 @@ type Mutation { forgotPasswordUser(disableEmail: Boolean, expiration: Int, email: String!): Boolean! resetPasswordUser(password: String, token: String): usersResetPassword verifyEmailUser(token: String): Boolean - createPayloadLockedDocument(data: mutationPayloadLockedDocumentInput!, draft: Boolean): PayloadLockedDocument - updatePayloadLockedDocument(id: String!, autosave: Boolean, data: mutationPayloadLockedDocumentUpdateInput!, draft: Boolean, trash: Boolean): PayloadLockedDocument + createPayloadLockedDocument(data: mutationPayloadLockedDocumentInput!, action: CreateAction): PayloadLockedDocument + updatePayloadLockedDocument(id: String!, autosave: Boolean, data: mutationPayloadLockedDocumentUpdateInput!, action: UpdateAction, trash: Boolean): PayloadLockedDocument deletePayloadLockedDocument(id: String!, trash: Boolean): PayloadLockedDocument - duplicatePayloadLockedDocument(id: String!, data: mutationPayloadLockedDocumentInput!): PayloadLockedDocument - createPayloadPreference(data: mutationPayloadPreferenceInput!, draft: Boolean): PayloadPreference - updatePayloadPreference(id: String!, autosave: Boolean, data: mutationPayloadPreferenceUpdateInput!, draft: Boolean, trash: Boolean): PayloadPreference + duplicatePayloadLockedDocument(id: String!, action: CreateAction, data: mutationPayloadLockedDocumentInput!): PayloadLockedDocument + createPayloadPreference(data: mutationPayloadPreferenceInput!, action: CreateAction): PayloadPreference + updatePayloadPreference(id: String!, autosave: Boolean, data: mutationPayloadPreferenceUpdateInput!, action: UpdateAction, trash: Boolean): PayloadPreference deletePayloadPreference(id: String!, trash: Boolean): PayloadPreference - duplicatePayloadPreference(id: String!, data: mutationPayloadPreferenceInput!): PayloadPreference + duplicatePayloadPreference(id: String!, action: CreateAction, data: mutationPayloadPreferenceInput!): PayloadPreference } input mutationCollection1Input { diff --git a/test/hierarchy/e2e.spec.ts b/test/hierarchy/e2e.spec.ts index c48d1e5401c..7d2a530ba23 100644 --- a/test/hierarchy/e2e.spec.ts +++ b/test/hierarchy/e2e.spec.ts @@ -311,6 +311,7 @@ test.describe('Hierarchy Sidebar', () => { test.beforeAll(async () => { // Create a test organization for selection tests testOrg = await payload.create({ + action: 'publish', collection: 'organizations', data: { title: 'Selection Test Org' }, }) @@ -454,7 +455,7 @@ test.describe('Hierarchy Sidebar', () => { test.afterEach(async () => { const createdOrganizations = await payload.find({ collection: 'organizations', - draft: true, + version: 'latest', where: { title: { equals: organizationTitle } }, }) @@ -495,7 +496,7 @@ test.describe('Hierarchy Sidebar', () => { const autosavedOrganizations = await payload.find({ collection: 'organizations', depth: 0, - draft: true, + version: 'latest', where: { title: { equals: organizationTitle } }, }) @@ -739,6 +740,7 @@ test.describe('Hierarchy Sidebar', () => { // Create a product with the child folder selected // Field is 'parentFolder' because Folders collection overrides parentFieldName productWithFolder = await payload.create({ + action: 'publish', collection: 'products', data: { name: `Product In Child Folder ${uniqueSuffix}`, diff --git a/test/hierarchy/int.spec.ts b/test/hierarchy/int.spec.ts index 3237e773ddb..e32389f0204 100644 --- a/test/hierarchy/int.spec.ts +++ b/test/hierarchy/int.spec.ts @@ -75,6 +75,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hierarchy', () test('should compute correct paths for root document', async ({ payload }) => { const rootPage = await payload.create({ + action: 'publish', collection: 'organizations', context: { computeHierarchyPaths: true }, data: { @@ -90,6 +91,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hierarchy', () test('should compute correct paths for nested documents', async ({ payload }) => { // Create root const rootPage = await payload.create({ + action: 'publish', collection: 'organizations', data: { parent: null, @@ -99,6 +101,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hierarchy', () // Create child const childPage = await payload.create({ + action: 'publish', collection: 'organizations', context: { computeHierarchyPaths: true }, data: { @@ -112,6 +115,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hierarchy', () // Create grandchild const grandchildPage = await payload.create({ + action: 'publish', collection: 'organizations', context: { computeHierarchyPaths: true }, data: { @@ -127,21 +131,25 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hierarchy', () test('should compute updated paths when parent changes', async ({ payload }) => { // Create initial tree: Root -> Child -> Grandchild const rootPage = await payload.create({ + action: 'publish', collection: 'organizations', data: { parent: null, title: 'Root' }, }) const anotherRoot = await payload.create({ + action: 'publish', collection: 'organizations', data: { parent: null, title: 'Another Root' }, }) const childPage = await payload.create({ + action: 'publish', collection: 'organizations', data: { parent: rootPage.id, title: 'Child' }, }) const grandchildPage = await payload.create({ + action: 'publish', collection: 'organizations', data: { parent: childPage.id, title: 'Grandchild' }, }) @@ -172,11 +180,13 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hierarchy', () test('should compute updated paths when ancestor title changes', async ({ payload }) => { // Create tree const rootPage = await payload.create({ + action: 'publish', collection: 'organizations', data: { parent: null, title: 'Root' }, }) const childPage = await payload.create({ + action: 'publish', collection: 'organizations', data: { parent: rootPage.id, title: 'Child' }, }) @@ -202,11 +212,13 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hierarchy', () test('should handle moving to root level', async ({ payload }) => { // Create tree const rootPage = await payload.create({ + action: 'publish', collection: 'organizations', data: { parent: null, title: 'Root' }, }) const childPage = await payload.create({ + action: 'publish', collection: 'organizations', data: { parent: rootPage.id, title: 'Child' }, }) @@ -235,6 +247,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hierarchy', () test('should prevent self-referential parent', async ({ payload }) => { const page = await payload.create({ + action: 'publish', collection: 'organizations', data: { parent: null, title: 'Test Page' }, }) @@ -250,11 +263,13 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hierarchy', () test('should prevent circular reference with direct child', async ({ payload }) => { const parentPage = await payload.create({ + action: 'publish', collection: 'organizations', data: { parent: null, title: 'Parent' }, }) const childPage = await payload.create({ + action: 'publish', collection: 'organizations', data: { parent: parentPage.id, title: 'Child' }, }) @@ -270,16 +285,19 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hierarchy', () test('should prevent circular reference with grandchild', async ({ payload }) => { const grandparent = await payload.create({ + action: 'publish', collection: 'organizations', data: { parent: null, title: 'Grandparent' }, }) const parent = await payload.create({ + action: 'publish', collection: 'organizations', data: { parent: grandparent.id, title: 'Parent' }, }) const child = await payload.create({ + action: 'publish', collection: 'organizations', data: { parent: parent.id, title: 'Child' }, }) @@ -295,16 +313,19 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hierarchy', () test('should allow moving to a non-circular parent', async ({ payload }) => { const page1 = await payload.create({ + action: 'publish', collection: 'organizations', data: { parent: null, title: 'Page 1' }, }) const page2 = await payload.create({ + action: 'publish', collection: 'organizations', data: { parent: null, title: 'Page 2' }, }) const child = await payload.create({ + action: 'publish', collection: 'organizations', data: { parent: page1.id, title: 'Child' }, }) @@ -336,11 +357,13 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hierarchy', () test('should find root documents by querying parent field', async ({ payload }) => { const root = await payload.create({ + action: 'publish', collection: 'organizations', data: { parent: null, title: 'Root' }, }) await payload.create({ + action: 'publish', collection: 'organizations', data: { parent: root.id, title: 'Child 1' }, }) @@ -358,21 +381,25 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hierarchy', () test('should find direct children by querying parent field', async ({ payload }) => { const root = await payload.create({ + action: 'publish', collection: 'organizations', data: { parent: null, title: 'Root' }, }) const child1 = await payload.create({ + action: 'publish', collection: 'organizations', data: { parent: root.id, title: 'Child 1' }, }) const child2 = await payload.create({ + action: 'publish', collection: 'organizations', data: { parent: root.id, title: 'Child 2' }, }) await payload.create({ + action: 'publish', collection: 'organizations', data: { parent: child1.id, title: 'Grandchild 1' }, }) @@ -441,6 +468,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hierarchy', () for (let i = 0; i < 10; i++) { currentParent = await payload.create({ + action: 'publish', collection: 'organizations', context: { computeHierarchyPaths: true }, data: { @@ -468,12 +496,14 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hierarchy', () test('should compute paths correctly for published and draft versions', async ({ payload }) => { // Create parent and child const parent = await payload.create({ + action: 'publish', collection: 'organizations', data: { parent: null, title: 'Products' }, }) // Publish child const child = await payload.create({ + action: 'publish', collection: 'organizations', data: { _status: 'published', parent: parent.id, title: 'Clothing' }, }) @@ -483,11 +513,12 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hierarchy', () id: child.id, collection: 'organizations', data: { title: 'Apparel' }, - draft: true, + action: 'saveDraft', }) // Move parent const grandParent = await payload.create({ + action: 'publish', collection: 'organizations', data: { _status: 'published', parent: null, title: 'Categories' }, }) @@ -503,7 +534,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hierarchy', () id: child.id, collection: 'organizations', context: { computeHierarchyPaths: true }, - draft: false, + version: 'published', }) expect(publishedChild._h_slugPath).toBe('categories/products/clothing') @@ -513,7 +544,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hierarchy', () id: child.id, collection: 'organizations', context: { computeHierarchyPaths: true }, - draft: true, + version: 'latest', }) expect(draftChild._h_slugPath).toBe('categories/products/apparel') @@ -521,16 +552,19 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hierarchy', () test('should compute paths when no draft exists', async ({ payload }) => { const parent = await payload.create({ + action: 'publish', collection: 'organizations', data: { _status: 'published', parent: null, title: 'Services' }, }) const child = await payload.create({ + action: 'publish', collection: 'organizations', data: { _status: 'published', parent: parent.id, title: 'Consulting' }, }) const newParent = await payload.create({ + action: 'publish', collection: 'organizations', data: { parent: null, title: 'Offerings' }, }) @@ -551,12 +585,12 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hierarchy', () expect(publishedChild._h_slugPath).toBe('offerings/services/consulting') expect(publishedChild._status).toBe('published') - // When no draft exists, draft: true returns published version + // When no draft exists, version: 'latest' returns published version const draftChild = await payload.findByID({ id: child.id, collection: 'organizations', context: { computeHierarchyPaths: true }, - draft: true, + version: 'latest', }) expect(draftChild._h_slugPath).toBe('offerings/services/consulting') @@ -567,26 +601,26 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hierarchy', () const parent1 = await payload.create({ collection: 'organizations', data: { parent: null, title: 'Future' }, - draft: true, + action: 'saveDraft', }) const child = await payload.create({ collection: 'organizations', data: { parent: parent1.id, title: 'Plans' }, - draft: true, + action: 'saveDraft', }) const newParent = await payload.create({ collection: 'organizations', data: { parent: null, title: 'Roadmap' }, - draft: true, + action: 'saveDraft', }) await payload.update({ id: parent1.id, collection: 'organizations', data: { parent: newParent.id }, - draft: true, + action: 'saveDraft', }) // Path is computed from current draft parent chain @@ -594,7 +628,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hierarchy', () id: child.id, collection: 'organizations', context: { computeHierarchyPaths: true }, - draft: true, + version: 'latest', }) expect(draftChild._h_slugPath).toBe('roadmap/future/plans') @@ -603,16 +637,19 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hierarchy', () test('should compute paths for collections without versioning', async ({ payload }) => { const parent = await payload.create({ + action: 'publish', collection: 'organizations', data: { parent: null, title: 'Electronics' }, }) const child = await payload.create({ + action: 'publish', collection: 'organizations', data: { parent: parent.id, title: 'Phones' }, }) const newParent = await payload.create({ + action: 'publish', collection: 'organizations', data: { parent: null, title: 'Tech' }, }) @@ -639,6 +676,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hierarchy', () // Create parent with default locale (en) const parent = await payload.create({ collection: 'products', + action: 'publish', data: { name: 'Clothing', parent: null, @@ -664,6 +702,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hierarchy', () // Create child with default locale (en) const child = await payload.create({ collection: 'products', + action: 'publish', data: { name: 'Shirts', parent: parent.id, @@ -712,6 +751,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hierarchy', () // Create parent with default locale (en) const parent = await payload.create({ collection: 'products', + action: 'publish', data: { name: 'Clothing', parent: null, @@ -735,6 +775,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hierarchy', () // Create child with default locale (en) const child = await payload.create({ collection: 'products', + action: 'publish', data: { name: 'Shirts', parent: parent.id, @@ -758,6 +799,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hierarchy', () // Create new parent with default locale (en) const newParent = await payload.create({ collection: 'products', + action: 'publish', data: { name: 'Apparel', parent: null, @@ -810,6 +852,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hierarchy', () // Create parent with default locale (en) const parent = await payload.create({ collection: 'products', + action: 'publish', data: { name: 'Clothing', parent: null, @@ -833,6 +876,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hierarchy', () // Create child with default locale (en) const child = await payload.create({ collection: 'products', + action: 'publish', data: { name: 'Shirts', parent: parent.id, @@ -900,6 +944,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hierarchy', () // Create parent with default locale (en) const parent = await payload.create({ collection: 'products', + action: 'publish', data: { name: 'Clothing', parent: null, @@ -923,6 +968,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hierarchy', () // Create child with default locale (en) const child = await payload.create({ collection: 'products', + action: 'publish', data: { name: 'Shirts', parent: parent.id, @@ -948,7 +994,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hierarchy', () id: child.id, collection: 'products', data: { _status: 'published' }, - draft: false, + action: 'publish', publishAllLocales: true, }) @@ -965,7 +1011,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hierarchy', () data: { name: titleMap[locale], }, - draft: true, + action: 'saveDraft', locale, }) } @@ -973,6 +1019,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hierarchy', () // Create newParent with default locale (en) const newParent = await payload.create({ collection: 'products', + action: 'publish', data: { name: 'Apparel', parent: null, @@ -1019,7 +1066,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hierarchy', () id: child.id, collection: 'products', context: { computeHierarchyPaths: true }, - draft: true, + version: 'latest', locale: 'all', }) @@ -1038,7 +1085,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hierarchy', () name: 'Future', parent: null, }, - draft: true, + action: 'saveDraft', locale: 'en', }) @@ -1047,14 +1094,14 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hierarchy', () id: parent.id, collection: 'products', data: { name: 'Futuro' }, - draft: true, + action: 'saveDraft', locale: 'es', }) await payload.update({ id: parent.id, collection: 'products', data: { name: 'Zukunft' }, - draft: true, + action: 'saveDraft', locale: 'de', }) @@ -1065,7 +1112,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hierarchy', () name: 'Plans', parent: parent.id, }, - draft: true, + action: 'saveDraft', locale: 'en', }) @@ -1074,20 +1121,21 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hierarchy', () id: child.id, collection: 'products', data: { name: 'Planes' }, - draft: true, + action: 'saveDraft', locale: 'es', }) await payload.update({ id: child.id, collection: 'products', data: { name: 'Pläne' }, - draft: true, + action: 'saveDraft', locale: 'de', }) // Create new parent (published) with default locale const newParent = await payload.create({ collection: 'products', + action: 'publish', data: { name: 'Roadmap', parent: null, @@ -1113,7 +1161,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hierarchy', () id: parent.id, collection: 'products', data: { parent: newParent.id }, - draft: true, + action: 'saveDraft', locale: 'en', }) @@ -1121,7 +1169,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hierarchy', () id: child.id, collection: 'products', context: { computeHierarchyPaths: true }, - draft: true, + version: 'latest', locale: 'all', }) @@ -1153,11 +1201,13 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hierarchy', () }) => { // Create a hierarchy: Root > Category > 5 children const root = await payload.create({ + action: 'publish', collection: 'organizations', data: { parent: null, title: 'Root' }, }) const category = await payload.create({ + action: 'publish', collection: 'organizations', data: { parent: root.id, title: 'Category' }, }) @@ -1166,6 +1216,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hierarchy', () for (let i = 1; i <= 5; i++) { const child = await payload.create({ + action: 'publish', collection: 'organizations', data: { parent: category.id, title: `Child ${i}` }, }) @@ -1215,16 +1266,19 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hierarchy', () test('should show cache benefit: 10 docs with shared ancestors', async ({ payload }) => { // Create deeper hierarchy const root = await payload.create({ + action: 'publish', collection: 'organizations', data: { parent: null, title: 'Products' }, }) const cat1 = await payload.create({ + action: 'publish', collection: 'organizations', data: { parent: root.id, title: 'Electronics' }, }) const cat2 = await payload.create({ + action: 'publish', collection: 'organizations', data: { parent: root.id, title: 'Clothing' }, }) @@ -1234,6 +1288,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hierarchy', () // 5 products under Electronics for (let i = 1; i <= 5; i++) { const child = await payload.create({ + action: 'publish', collection: 'organizations', data: { parent: cat1.id, title: `Product E${i}` }, }) @@ -1243,6 +1298,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hierarchy', () // 5 products under Clothing for (let i = 1; i <= 5; i++) { const child = await payload.create({ + action: 'publish', collection: 'organizations', data: { parent: cat2.id, title: `Product C${i}` }, }) @@ -1372,6 +1428,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hierarchy', () test('should compute full paths when selecting path fields', async ({ payload }) => { // Create parent const parent = await payload.create({ + action: 'publish', collection: 'organizations', data: { parent: null, title: 'Parent Org' }, }) @@ -1379,6 +1436,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hierarchy', () // Create child const child = await payload.create({ + action: 'publish', collection: 'organizations', data: { parent: parent.id, title: 'Child Org' }, }) @@ -1402,6 +1460,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hierarchy', () test('should not expose auto-added fields in response', async ({ payload }) => { // Create parent const parent = await payload.create({ + action: 'publish', collection: 'organizations', data: { parent: null, title: 'Hidden Parent' }, }) @@ -1409,6 +1468,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hierarchy', () // Create child const child = await payload.create({ + action: 'publish', collection: 'organizations', data: { parent: parent.id, title: 'Hidden Child' }, }) @@ -1435,6 +1495,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hierarchy', () test('should keep explicitly selected fields in response', async ({ payload }) => { // Create parent const parent = await payload.create({ + action: 'publish', collection: 'organizations', data: { parent: null, title: 'Explicit Parent' }, }) @@ -1442,6 +1503,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hierarchy', () // Create child const child = await payload.create({ + action: 'publish', collection: 'organizations', data: { parent: parent.id, title: 'Explicit Child' }, }) @@ -1470,18 +1532,21 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hierarchy', () test('should work with deeply nested hierarchy using select', async ({ payload }) => { // Create 3-level hierarchy const level1 = await payload.create({ + action: 'publish', collection: 'organizations', data: { parent: null, title: 'Level 1' }, }) createdOrgIds.push(level1.id) const level2 = await payload.create({ + action: 'publish', collection: 'organizations', data: { parent: level1.id, title: 'Level 2' }, }) createdOrgIds.push(level2.id) const level3 = await payload.create({ + action: 'publish', collection: 'organizations', data: { parent: level2.id, title: 'Level 3' }, }) diff --git a/test/hierarchy/seed.ts b/test/hierarchy/seed.ts index b59cf59bf53..70e837af606 100644 --- a/test/hierarchy/seed.ts +++ b/test/hierarchy/seed.ts @@ -37,41 +37,49 @@ export async function seed(payload: Payload): Promise { // Create organization hierarchy const acmeCorp = await payload.create({ + action: 'publish', collection: organizationsSlug, data: { title: 'Acme Corp' }, }) await payload.create({ + action: 'publish', collection: organizationsSlug, data: { title: 'Beta Corp' }, }) await payload.create({ + action: 'publish', collection: organizationsSlug, data: { title: 'Gamma Corp' }, }) const engineeringDiv = await payload.create({ + action: 'publish', collection: organizationsSlug, data: { parent: acmeCorp.id, title: 'Engineering Division' }, }) await payload.create({ + action: 'publish', collection: organizationsSlug, data: { parent: engineeringDiv.id, title: 'Frontend Team' }, }) await payload.create({ + action: 'publish', collection: organizationsSlug, data: { parent: engineeringDiv.id, title: 'Backend Team' }, }) await payload.create({ + action: 'publish', collection: organizationsSlug, data: { parent: acmeCorp.id, title: 'Marketing Division' }, }) await payload.create({ + action: 'publish', collection: organizationsSlug, data: { parent: acmeCorp.id, title: 'Zeta Division' }, }) @@ -104,26 +112,31 @@ export async function seed(payload: Payload): Promise { // Create product hierarchy (tests localization) const electronicsCategory = await payload.create({ + action: 'publish', collection: productsSlug, data: { name: 'Electronics' }, }) const computersCategory = await payload.create({ + action: 'publish', collection: productsSlug, data: { name: 'Computers', parent: electronicsCategory.id }, }) await payload.create({ + action: 'publish', collection: productsSlug, data: { name: 'Laptops', parent: computersCategory.id }, }) await payload.create({ + action: 'publish', collection: productsSlug, data: { name: 'Desktops', parent: computersCategory.id }, }) await payload.create({ + action: 'publish', collection: productsSlug, data: { name: 'Phones', parent: electronicsCategory.id }, }) diff --git a/test/hooks/collections/AfterChangeAction/index.ts b/test/hooks/collections/AfterChangeAction/index.ts new file mode 100644 index 00000000000..c30bc9d8d00 --- /dev/null +++ b/test/hooks/collections/AfterChangeAction/index.ts @@ -0,0 +1,57 @@ +import type { CollectionConfig } from 'payload' + +export const afterChangeActionSlug = 'after-change-action' + +type CapturedAfterChangeAction = { + action: unknown + operation?: unknown +} + +const collectionActions: CapturedAfterChangeAction[] = [] +const fieldActions: CapturedAfterChangeAction[] = [] + +export const getAfterChangeActions = () => ({ + collection: collectionActions.at(-1), + collections: [...collectionActions], + field: fieldActions.at(-1), + fields: [...fieldActions], +}) + +export const clearAfterChangeActions = () => { + collectionActions.length = 0 + fieldActions.length = 0 +} + +export const AfterChangeActionCollection: CollectionConfig = { + slug: afterChangeActionSlug, + access: { + create: () => true, + delete: () => true, + read: () => true, + update: () => true, + }, + versions: { + drafts: true, + }, + hooks: { + afterChange: [ + ({ action, operation }) => { + collectionActions.push({ action, operation }) + }, + ], + }, + fields: [ + { + name: 'title', + type: 'text', + required: true, + hooks: { + afterChange: [ + ({ action, operation }) => { + fieldActions.push({ action, operation }) + }, + ], + }, + }, + ], +} diff --git a/test/hooks/collections/NestedAfterChangeHook/index.ts b/test/hooks/collections/NestedAfterChangeHook/index.ts index 97b437aada6..674b6e958ee 100644 --- a/test/hooks/collections/NestedAfterChangeHook/index.ts +++ b/test/hooks/collections/NestedAfterChangeHook/index.ts @@ -3,8 +3,33 @@ import type { CollectionConfig } from 'payload' import { BlocksFeature, lexicalEditor, LinkFeature } from '@payloadcms/richtext-lexical' export const nestedAfterChangeHooksSlug = 'nested-after-change-hooks' +type CapturedAfterChangeAction = { + action: unknown + operation?: unknown +} + +let lastCollectionAfterChangeAction: CapturedAfterChangeAction | undefined +let lastFieldAfterChangeAction: CapturedAfterChangeAction | undefined + +export const getLastNestedAfterChangeActions = () => ({ + collection: lastCollectionAfterChangeAction, + field: lastFieldAfterChangeAction, +}) + +export const clearLastNestedAfterChangeActions = () => { + lastCollectionAfterChangeAction = undefined + lastFieldAfterChangeAction = undefined +} + const NestedAfterChangeHooks: CollectionConfig = { slug: nestedAfterChangeHooksSlug, + hooks: { + afterChange: [ + ({ action, operation }) => { + lastCollectionAfterChangeAction = { action, operation } + }, + ], + }, fields: [ { type: 'text', @@ -23,7 +48,8 @@ const NestedAfterChangeHooks: CollectionConfig = { name: 'nestedAfterChange', hooks: { afterChange: [ - ({ previousValue, operation }) => { + ({ previousValue, operation, action }) => { + lastFieldAfterChangeAction = { action, operation } if (operation === 'update' && typeof previousValue === 'undefined') { throw new Error('previousValue is missing in nested beforeChange hook') } diff --git a/test/hooks/globals/AfterChangeAction/index.ts b/test/hooks/globals/AfterChangeAction/index.ts new file mode 100644 index 00000000000..2727c8c9419 --- /dev/null +++ b/test/hooks/globals/AfterChangeAction/index.ts @@ -0,0 +1,54 @@ +import type { GlobalConfig } from 'payload' + +export const afterChangeActionGlobalSlug = 'after-change-action-global' + +type CapturedAfterChangeAction = { + action: unknown +} + +const globalActions: CapturedAfterChangeAction[] = [] +const fieldActions: CapturedAfterChangeAction[] = [] + +export const getGlobalAfterChangeActions = () => ({ + field: fieldActions.at(-1), + fields: [...fieldActions], + global: globalActions.at(-1), + globals: [...globalActions], +}) + +export const clearGlobalAfterChangeActions = () => { + fieldActions.length = 0 + globalActions.length = 0 +} + +export const AfterChangeActionGlobal: GlobalConfig = { + slug: afterChangeActionGlobalSlug, + access: { + read: () => true, + update: () => true, + }, + versions: { + drafts: true, + }, + hooks: { + afterChange: [ + ({ action }) => { + globalActions.push({ action }) + }, + ], + }, + fields: [ + { + name: 'title', + type: 'text', + required: true, + hooks: { + afterChange: [ + ({ action }) => { + fieldActions.push({ action }) + }, + ], + }, + }, + ], +} diff --git a/test/hooks/globals/Data/index.ts b/test/hooks/globals/Data/index.ts index 6a8954731c5..09ccb413a7d 100644 --- a/test/hooks/globals/Data/index.ts +++ b/test/hooks/globals/Data/index.ts @@ -2,6 +2,23 @@ import type { GlobalConfig } from 'payload' export const dataHooksGlobalSlug = 'data-hooks-global' +type CapturedAfterChangeAction = { + action: unknown +} + +let lastGlobalAfterChangeAction: CapturedAfterChangeAction | undefined +let lastFieldAfterChangeAction: CapturedAfterChangeAction | undefined + +export const getLastDataGlobalAfterChangeActions = () => ({ + field: lastFieldAfterChangeAction, + global: lastGlobalAfterChangeAction, +}) + +export const clearLastDataGlobalAfterChangeActions = () => { + lastFieldAfterChangeAction = undefined + lastGlobalAfterChangeAction = undefined +} + export const DataHooksGlobal: GlobalConfig = { slug: dataHooksGlobalSlug, access: { @@ -35,7 +52,8 @@ export const DataHooksGlobal: GlobalConfig = { }, ], afterChange: [ - ({ context, global, doc }) => { + ({ action, context, global, doc }) => { + lastGlobalAfterChangeAction = { action } context['global_afterChange_global'] = JSON.stringify(global) // Needs to be done for both afterRead (for findOne test) and afterChange (for update test), as afterChange is called after afterRead @@ -63,6 +81,11 @@ export const DataHooksGlobal: GlobalConfig = { }, ], + afterChange: [ + ({ action }) => { + lastFieldAfterChangeAction = { action } + }, + ], afterRead: [ ({ global, field, context }) => { if (context['field_beforeChange_GlobalAndField_override']) { diff --git a/test/hooks/int.spec.ts b/test/hooks/int.spec.ts index e6e039241fc..7f53720ef53 100644 --- a/test/hooks/int.spec.ts +++ b/test/hooks/int.spec.ts @@ -772,6 +772,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Hooks', () => { test('should pass correct operation arg on read (findByID)', async ({ payload }) => { const doc = await payload.create({ + action: 'publish', collection: beforeOperationSlug, data: {}, }) diff --git a/test/joins/e2e.spec.ts b/test/joins/e2e.spec.ts index 43da50b2b81..821489978ac 100644 --- a/test/joins/e2e.spec.ts +++ b/test/joins/e2e.spec.ts @@ -921,7 +921,7 @@ describe('Join Field', () => { data: { title: 'Version 1 - Draft', }, - draft: true, + action: 'saveDraft', }) await page.goto(categoriesVersionsURL.edit(categoryVersionsDoc.id)) diff --git a/test/joins/int.spec.ts b/test/joins/int.spec.ts index 27d6f294811..5c811db899f 100644 --- a/test/joins/int.spec.ts +++ b/test/joins/int.spec.ts @@ -793,14 +793,19 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Joins Field', ( }) test('should populate joins when versions on both sides draft false', async ({ payload }) => { - const category = await payload.create({ collection: 'categories-versions', data: {} }) + const category = await payload.create({ + action: 'publish', + collection: 'categories-versions', + data: {}, + }) const version = await payload.create({ + action: 'publish', collection: 'versions', data: { title: 'version', categoryVersion: category.id }, }) - const res = await payload.find({ collection: 'categories-versions', draft: false }) + const res = await payload.find({ collection: 'categories-versions', version: 'published' }) expect(res.docs[0].relatedVersions.docs[0].id).toBe(version.id) }) @@ -808,14 +813,19 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Joins Field', ( test('should populate joins with hasMany relationships when versions on both sides draft false', async ({ payload, }) => { - const category = await payload.create({ collection: 'categories-versions', data: {} }) + const category = await payload.create({ + action: 'publish', + collection: 'categories-versions', + data: {}, + }) const version = await payload.create({ + action: 'publish', collection: 'versions', data: { title: 'version', categoryVersions: [category.id] }, }) - const res = await payload.find({ collection: 'categories-versions', draft: false }) + const res = await payload.find({ collection: 'categories-versions', version: 'published' }) expect(res.docs[0].relatedVersionsMany.docs[0].id).toBe(version.id) }) @@ -832,7 +842,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Joins Field', ( const res = await payload.find({ collection: 'categories-versions', - draft: true, + version: 'latest', }) expect(res.docs[0].relatedVersions.docs[0].id).toBe(version.id) @@ -844,25 +854,25 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Joins Field', ( const category = await payload.create({ collection: 'categories-versions', data: { _status: 'draft' }, - draft: true, + action: 'saveDraft', }) const version = await payload.create({ collection: 'versions', data: { title: 'original-title', _status: 'draft', categoryVersion: category.id }, - draft: true, + action: 'saveDraft', }) await payload.update({ collection: 'versions', id: version.id, data: { title: 'updated-title' }, - draft: true, + action: 'saveDraft', }) const res = await payload.find({ collection: 'categories-versions', - draft: true, + version: 'latest', }) expect(res.docs[0].relatedVersions.docs[0].id).toBe(version.id) @@ -881,7 +891,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Joins Field', ( const res = await payload.find({ collection: 'categories-versions', - draft: true, + version: 'latest', }) expect(res.docs[0].relatedVersionsMany.docs[0].id).toBe(version.id) @@ -1307,24 +1317,24 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Joins Field', ( const category = await payload.create({ collection: 'categories-versions', data: { _status: 'draft' }, - draft: true, + action: 'saveDraft', }) const version = await payload.create({ collection: 'versions', data: { _status: 'draft', title: 'original-title', categoryVersion: category.id }, - draft: true, + action: 'saveDraft', }) await payload.update({ collection: 'versions', - draft: true, + action: 'saveDraft', id: version.id, data: { title: 'updated-title' }, }) const query = `query { - CategoriesVersions(draft: true) { + CategoriesVersions(version: latest) { docs { relatedVersions( limit: 1 diff --git a/test/live-preview/app-tanstack/functions/livePreview.functions.ts b/test/live-preview/app-tanstack/functions/livePreview.functions.ts index 4ff3d2416cb..383056c9c20 100644 --- a/test/live-preview/app-tanstack/functions/livePreview.functions.ts +++ b/test/live-preview/app-tanstack/functions/livePreview.functions.ts @@ -12,8 +12,8 @@ export const getLivePreviewDoc = createServerFn({ method: 'GET' }) const { docs } = await payload.find({ collection, depth: 2, - draft: true, trash: true, + version: 'latest', where: { slug: { equals: slug } }, }) diff --git a/test/live-preview/app/live-preview/(pages)/custom-live-preview/[slug]/page.tsx b/test/live-preview/app/live-preview/(pages)/custom-live-preview/[slug]/page.tsx index 893b6cf9888..6ffdac440d4 100644 --- a/test/live-preview/app/live-preview/(pages)/custom-live-preview/[slug]/page.tsx +++ b/test/live-preview/app/live-preview/(pages)/custom-live-preview/[slug]/page.tsx @@ -23,7 +23,7 @@ export default async function SSRAutosavePage({ params: paramsPromise }: Args) { const data = await getDoc({ slug, collection: customLivePreviewSlug, - draft: true, + version: 'latest', }) if (!data) { diff --git a/test/live-preview/app/live-preview/(pages)/ssr-autosave/[slug]/page.tsx b/test/live-preview/app/live-preview/(pages)/ssr-autosave/[slug]/page.tsx index 3088066b742..9df94bd989d 100644 --- a/test/live-preview/app/live-preview/(pages)/ssr-autosave/[slug]/page.tsx +++ b/test/live-preview/app/live-preview/(pages)/ssr-autosave/[slug]/page.tsx @@ -23,7 +23,7 @@ export default async function SSRAutosavePage({ params: paramsPromise }: Args) { const data = await getDoc({ slug, collection: ssrAutosavePagesSlug, - draft: true, + version: 'latest', }) if (!data) { diff --git a/test/live-preview/app/live-preview/(pages)/ssr/[slug]/page.tsx b/test/live-preview/app/live-preview/(pages)/ssr/[slug]/page.tsx index 602caec1a13..b3d68616a4e 100644 --- a/test/live-preview/app/live-preview/(pages)/ssr/[slug]/page.tsx +++ b/test/live-preview/app/live-preview/(pages)/ssr/[slug]/page.tsx @@ -23,7 +23,7 @@ export default async function SSRPage({ params: paramsPromise }: Args) { const data = await getDoc({ slug, collection: ssrPagesSlug, - draft: true, + version: 'latest', }) if (!data) { diff --git a/test/live-preview/app/live-preview/_api/getDoc.ts b/test/live-preview/app/live-preview/_api/getDoc.ts index 07c11cb2d55..e350801fc7e 100644 --- a/test/live-preview/app/live-preview/_api/getDoc.ts +++ b/test/live-preview/app/live-preview/_api/getDoc.ts @@ -1,4 +1,4 @@ -import type { CollectionSlug, Where } from 'payload' +import type { CollectionSlug, ReadVersion, Where } from 'payload' import config from '@payload-config' import { getPayload } from 'payload' @@ -6,11 +6,11 @@ import { getPayload } from 'payload' export const getDoc = async (args: { collection: CollectionSlug depth?: number - draft?: boolean slug?: string + version?: ReadVersion }): Promise => { const payload = await getPayload({ config }) - const { slug, collection, depth = 2, draft } = args || {} + const { slug, collection, depth = 2, version } = args || {} const where: Where = {} @@ -24,9 +24,9 @@ export const getDoc = async (args: { const { docs } = await payload.find({ collection, depth, - where, - draft, trash: true, // Include trashed documents + version, + where, }) if (docs[0]) { diff --git a/test/live-preview/prod/app/live-preview/(pages)/ssr-autosave/[slug]/page.tsx b/test/live-preview/prod/app/live-preview/(pages)/ssr-autosave/[slug]/page.tsx index 6bf6889de11..7c047c523b1 100644 --- a/test/live-preview/prod/app/live-preview/(pages)/ssr-autosave/[slug]/page.tsx +++ b/test/live-preview/prod/app/live-preview/(pages)/ssr-autosave/[slug]/page.tsx @@ -22,7 +22,7 @@ export default async function SSRAutosavePage({ params: paramsPromise }: Args) { const data = await getDoc({ slug, collection: ssrAutosavePagesSlug, - draft: true, + version: 'latest', }) if (!data) { diff --git a/test/live-preview/prod/app/live-preview/(pages)/ssr/[slug]/page.tsx b/test/live-preview/prod/app/live-preview/(pages)/ssr/[slug]/page.tsx index 4a543545a00..b266eed51b7 100644 --- a/test/live-preview/prod/app/live-preview/(pages)/ssr/[slug]/page.tsx +++ b/test/live-preview/prod/app/live-preview/(pages)/ssr/[slug]/page.tsx @@ -22,7 +22,7 @@ export default async function SSRPage({ params: paramsPromise }: Args) { const data = await getDoc({ slug, collection: ssrPagesSlug, - draft: true, + version: 'latest', }) if (!data) { diff --git a/test/live-preview/prod/app/live-preview/_api/getDoc.ts b/test/live-preview/prod/app/live-preview/_api/getDoc.ts index 07c11cb2d55..e350801fc7e 100644 --- a/test/live-preview/prod/app/live-preview/_api/getDoc.ts +++ b/test/live-preview/prod/app/live-preview/_api/getDoc.ts @@ -1,4 +1,4 @@ -import type { CollectionSlug, Where } from 'payload' +import type { CollectionSlug, ReadVersion, Where } from 'payload' import config from '@payload-config' import { getPayload } from 'payload' @@ -6,11 +6,11 @@ import { getPayload } from 'payload' export const getDoc = async (args: { collection: CollectionSlug depth?: number - draft?: boolean slug?: string + version?: ReadVersion }): Promise => { const payload = await getPayload({ config }) - const { slug, collection, depth = 2, draft } = args || {} + const { slug, collection, depth = 2, version } = args || {} const where: Where = {} @@ -24,9 +24,9 @@ export const getDoc = async (args: { const { docs } = await payload.find({ collection, depth, - where, - draft, trash: true, // Include trashed documents + version, + where, }) if (docs[0]) { diff --git a/test/localization/int.spec.ts b/test/localization/int.spec.ts index d97ad9d35b2..46f50c2acad 100644 --- a/test/localization/int.spec.ts +++ b/test/localization/int.spec.ts @@ -2113,6 +2113,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Localization', test('should allow creating nested blocks per locale', async ({ payload }) => { const doc = await payload.create({ collection: 'blocks-fields', + action: 'publish', data: { content: [ { @@ -2150,6 +2151,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Localization', await payload.update({ collection: 'blocks-fields', id, + action: 'publish', locale: 'es', data: { content: [ @@ -2269,6 +2271,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Localization', const createdEnDoc = await payload.create({ collection: 'nested-arrays', + action: 'publish', locale: 'en', depth: 0, data: { @@ -2283,6 +2286,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Localization', const updatedEsDoc = await payload.update({ collection: 'nested-arrays', id: createdEnDoc.id, + action: 'publish', depth: 0, locale: 'es', data: { @@ -2335,6 +2339,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Localization', const createdEnDoc = await payload.create({ collection: 'nested-arrays', + action: 'publish', locale: 'en', depth: 0, data: { @@ -2349,6 +2354,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Localization', const updatedEsDoc = await payload.update({ collection: 'nested-arrays', id: createdEnDoc.id, + action: 'publish', depth: 0, locale: 'es', data: { @@ -3284,6 +3290,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Localization', // Create a document with content in en locale const doc = await payload.create({ collection: 'blocks-fields', + action: 'publish', locale: 'en', data: { title: 'English Title', @@ -3306,6 +3313,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Localization', await payload.update({ collection: 'blocks-fields', id: doc.id, + action: 'publish', locale: 'es', data: { title: 'Spanish Title', @@ -3356,7 +3364,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Localization', id: doc.id, collection: 'blocks-fields', locale: 'es', - draft: true, + version: 'latest', }) expect(esDocAfter.title).toBe('English Title') @@ -3370,7 +3378,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Localization', const doc = await payload.create({ collection: 'blocks-fields', locale: 'en', - draft: true, + action: 'saveDraft', data: { title: 'Draft English Title', content: [ @@ -3387,7 +3395,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Localization', id: doc.id, collection: 'blocks-fields', locale: 'en', - draft: true, + version: 'latest', }) expect(draftBefore.title).toBe('Draft English Title') @@ -3408,7 +3416,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Localization', id: doc.id, collection: 'blocks-fields', locale: 'en', - draft: true, + version: 'latest', }) expect(draftAfter.title).toBe('Draft English Title') @@ -3421,6 +3429,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Localization', // Create published doc in en const doc = await payload.create({ collection: 'blocks-fields', + action: 'publish', locale: 'en', data: { title: 'Published EN', @@ -3432,7 +3441,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Localization', collection: 'blocks-fields', id: doc.id, locale: 'en', - draft: true, + action: 'saveDraft', data: { title: 'Draft EN', }, @@ -3443,13 +3452,13 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Localization', id: doc.id, collection: 'blocks-fields', locale: 'en', - draft: false, + version: 'published', }) const enDraftBefore = await payload.findByID({ id: doc.id, collection: 'blocks-fields', locale: 'en', - draft: true, + version: 'latest', }) expect(enPublishedBefore.title).toBe('Published EN') @@ -3472,7 +3481,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Localization', id: doc.id, collection: 'blocks-fields', locale: 'en', - draft: false, + version: 'published', }) expect(enPublishedAfter.title).toBe('Published EN') @@ -3891,6 +3900,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Localization', id: originalPost.id, locale: 'es', fallbackLocale: 'en', + version: 'latest', }) expect(spanishPostWithEnglishFallback.text).toBe('Post EN') @@ -3900,6 +3910,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Localization', id: originalPost.id, locale: 'es', fallbackLocale: false, + version: 'latest', }) expect(spanishPostWithNoFallback?.selfRelation?.text).toBeUndefined() @@ -3969,6 +3980,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Localization', collection: allFieldsLocalizedSlug, id: doc.id, locale: 'all', + version: 'latest', }) // Verify simple localized fields have locale keys at top level @@ -4030,6 +4042,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Localization', payload, }) => { const doc = await payload.create({ + action: 'publish', collection: noLocalizedFieldsCollectionSlug, data: { text: 'title', @@ -4076,6 +4089,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Localization', locale: spanishLocale, id: doc.id, collection: allFieldsLocalizedSlug, + version: 'latest', }) expect(esDoc._status).toContain('draft') @@ -4114,7 +4128,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Localization', text: 'english draft 1', _status: 'draft', }, - draft: true, + action: 'saveDraft', locale: defaultLocale, }) // update english published 1 @@ -4136,7 +4150,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Localization', text: 'spanish draft 1', _status: 'draft', }, - draft: true, + action: 'saveDraft', locale: spanishLocale, }) // update spanish published 1 @@ -4157,7 +4171,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Localization', text: 'spanish draft 2', _status: 'draft', }, - draft: true, + action: 'saveDraft', locale: spanishLocale, }) @@ -4165,7 +4179,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Localization', collection: allFieldsLocalizedSlug, id: doc.id, locale: 'all', - draft: false, + version: 'published', }) expect(publishedDoc._status!.en).toBe('published') @@ -4176,7 +4190,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Localization', const latestVersionDoc = await payload.findByID({ collection: allFieldsLocalizedSlug, id: doc.id, - draft: true, + version: 'latest', locale: 'all', }) @@ -4202,7 +4216,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Localization', text: 'Localized Metadata ES', _status: 'draft', }, - draft: true, + action: 'saveDraft', locale: spanishLocale, }) @@ -4229,7 +4243,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Localization', const esDraft = await payload.find({ locale: spanishLocale, collection: allFieldsLocalizedSlug, - draft: true, + version: 'latest', where: { and: [ { @@ -4252,7 +4266,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Localization', const enPublished = await payload.find({ locale: defaultLocale, collection: allFieldsLocalizedSlug, - draft: true, + version: 'latest', where: { and: [ { @@ -4291,7 +4305,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Localization', text: 'en draft', _status: 'draft', }, - draft: true, + action: 'saveDraft', locale: defaultLocale, }) @@ -4309,7 +4323,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Localization', locale: 'all', id: doc.id, collection: allFieldsLocalizedSlug, - draft: false, + version: 'published', }) expect(mainDocument._status!.es).toBe('published') @@ -4321,7 +4335,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Localization', locale: 'all', id: doc.id, collection: allFieldsLocalizedSlug, - draft: true, + version: 'latest', }) expect(latestVersion._status!.es).toBe('published') @@ -4365,7 +4379,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Localization', locale: 'all', id: doc.id, collection: allFieldsLocalizedSlug, - draft: false, + version: 'published', }) expect(mainDocument._status!.en).toBe('published') @@ -4376,6 +4390,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Localization', await payload.update({ collection: allFieldsLocalizedSlug, id: doc.id, + action: 'unpublish', unpublishAllLocales: true, data: {}, }) @@ -4384,7 +4399,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Localization', locale: 'all', id: doc.id, collection: allFieldsLocalizedSlug, - draft: false, + version: 'latest', }) expect(unpublishedDocument._status!.en).toBe('draft') @@ -4406,7 +4421,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Localization', text: 'english draft 1', _status: 'draft', }, - draft: true, + action: 'saveDraft', locale: defaultLocale, }) // update english published 1 @@ -4426,7 +4441,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Localization', text: 'spanish draft 1', _status: 'draft', }, - draft: true, + action: 'saveDraft', locale: spanishLocale, }) // update spanish published 1 @@ -4445,14 +4460,14 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Localization', text: 'spanish draft 2', _status: 'draft', }, - draft: true, + action: 'saveDraft', locale: spanishLocale, }) const publishedDoc = await payload.findGlobal({ slug: globalWithDraftsSlug, locale: 'all', - draft: false, + version: 'published', }) expect(publishedDoc._status!.en).toBe('published') @@ -4462,7 +4477,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Localization', const latestVersionDoc = await payload.findGlobal({ slug: globalWithDraftsSlug, - draft: true, + version: 'latest', locale: 'all', }) @@ -4490,7 +4505,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Localization', text: 'en draft', _status: 'draft', }, - draft: true, + action: 'saveDraft', locale: defaultLocale, }) @@ -4506,7 +4521,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Localization', const mainDocument = await payload.findGlobal({ slug: globalWithDraftsSlug, locale: 'all', - draft: false, + version: 'published', }) expect(mainDocument._status!.es).toBe('published') @@ -4517,7 +4532,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Localization', const latestVersion = await payload.findGlobal({ slug: globalWithDraftsSlug, locale: 'all', - draft: true, + version: 'latest', }) expect(latestVersion._status!.es).toBe('published') @@ -4558,7 +4573,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Localization', const mainDocument = await payload.findGlobal({ slug: globalWithDraftsSlug, locale: 'all', - draft: false, + version: 'published', }) expect(mainDocument._status!.en).toBe('published') @@ -4568,6 +4583,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Localization', await payload.updateGlobal({ slug: globalWithDraftsSlug, + action: 'unpublish', unpublishAllLocales: true, data: {}, }) @@ -4575,7 +4591,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Localization', const unpublishedDocument = await payload.findGlobal({ slug: globalWithDraftsSlug, locale: 'all', - draft: false, + version: 'latest', }) expect(unpublishedDocument._status!.en).toBe('draft') @@ -4590,6 +4606,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Localization', test.beforeAll(async ({ payloadInstance: payload }) => { allFieldsPostWithLocalizedData = await payload.create({ collection: allFieldsLocalizedSlug, + action: 'publish', data: { text: englishTitle, }, @@ -4598,6 +4615,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Localization', await payload.update({ id: allFieldsPostWithLocalizedData.id, + action: 'publish', collection: allFieldsLocalizedSlug, data: { text: spanishTitle, @@ -4609,6 +4627,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Localization', test('should fallback to english translation when empty', async ({ payload }) => { await payload.update({ id: allFieldsPostWithLocalizedData.id, + action: 'publish', collection: allFieldsLocalizedSlug, data: { text: '', @@ -4640,6 +4659,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Localization', collection: allFieldsLocalizedSlug, locale: portugueseLocale, fallbackLocale: 'none', + version: 'latest', }) expect(localizedFallback.text).not.toBeDefined() diff --git a/test/locked-documents/bulk-delete.int.spec.ts b/test/locked-documents/bulk-delete.int.spec.ts index a6a21537bd6..12ed971da9b 100644 --- a/test/locked-documents/bulk-delete.int.spec.ts +++ b/test/locked-documents/bulk-delete.int.spec.ts @@ -27,6 +27,7 @@ test.suite({ config: './config.ts' })('Locked documents - bulk delete', () => { }) const lockedPost = await payload.create({ + action: 'publish', collection: postsSlug, data: { text: 'bulk delete locked post', @@ -34,6 +35,7 @@ test.suite({ config: './config.ts' })('Locked documents - bulk delete', () => { }) const unlockedPost = await payload.create({ + action: 'publish', collection: postsSlug, data: { text: 'bulk delete unlocked post', diff --git a/test/locked-documents/e2e.spec.ts b/test/locked-documents/e2e.spec.ts index 32ff476dc35..77a1d476e0c 100644 --- a/test/locked-documents/e2e.spec.ts +++ b/test/locked-documents/e2e.spec.ts @@ -270,7 +270,7 @@ describe('Locked Documents', () => { await page.locator('.list-selection__button[aria-label="Unpublish"]').click() await page.locator('#unpublish-posts [data-dialog-action="confirm"]').click() await expect(page.locator('.payload-toast-container .toast-success')).toHaveText( - 'Updated 10 Posts successfully.', + 'Updated 11 Posts successfully.', ) }) diff --git a/test/locked-documents/int.spec.ts b/test/locked-documents/int.spec.ts index 56305e9d031..9bb4d55bce9 100644 --- a/test/locked-documents/int.spec.ts +++ b/test/locked-documents/int.spec.ts @@ -44,6 +44,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Locked document }) post = await payload.create({ + action: 'publish', collection: postsSlug, data: { text: 'some post', @@ -51,6 +52,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Locked document }) await payload.create({ + action: 'publish', collection: pagesSlug, data: { text: 'some page', @@ -115,6 +117,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Locked document test('should allow update of stale locked document - collection', async ({ payload }) => { const newPost2 = await payload.create({ + action: 'publish', collection: postsSlug, data: { text: 'new post 2', @@ -236,6 +239,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Locked document test('should not allow update of locked document - collection', async ({ payload }) => { const newPost = await payload.create({ + action: 'publish', collection: postsSlug, data: { text: 'some post', @@ -319,6 +323,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Locked document // Try to delete locked document (collection) test('should not allow delete of locked document - collection', async ({ payload }) => { const newPost3 = await payload.create({ + action: 'publish', collection: postsSlug, data: { text: 'new post 3', @@ -364,6 +369,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Locked document test('should allow delete of stale locked document - collection', async ({ payload }) => { const newPost4 = await payload.create({ + action: 'publish', collection: postsSlug, data: { text: 'new post 4', @@ -430,6 +436,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Locked document payload, }) => { const newPost5 = await payload.create({ + action: 'publish', collection: postsSlug, data: { text: 'new post 5', @@ -539,6 +546,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Locked document payload, }) => { const newPost6 = await payload.create({ + action: 'publish', collection: postsSlug, data: { text: 'new post 6', @@ -600,6 +608,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Locked document payload, }) => { const newPost7 = await payload.create({ + action: 'publish', collection: postsSlug, data: { text: 'new post 7', diff --git a/test/locked-documents/seed.ts b/test/locked-documents/seed.ts index 19b13becfde..e3c0cc66d34 100644 --- a/test/locked-documents/seed.ts +++ b/test/locked-documents/seed.ts @@ -29,6 +29,7 @@ export const seed = async (_payload: Payload) => { }), () => _payload.create({ + action: 'publish', collection: pagesSlug, data: { text: 'example page', @@ -36,6 +37,7 @@ export const seed = async (_payload: Payload) => { }), () => _payload.create({ + action: 'publish', collection: postsSlug, data: { text: 'example post', @@ -51,6 +53,7 @@ export const seed = async (_payload: Payload) => { }), () => _payload.create({ + action: 'publish', collection: simpleWithVersionsSlug, data: { fieldA: 'Initial value A', diff --git a/test/plugin-cloud-storage/collections/TestMetadataDrafts.ts b/test/plugin-cloud-storage/collections/TestMetadataDrafts.ts new file mode 100644 index 00000000000..16b03a4abf3 --- /dev/null +++ b/test/plugin-cloud-storage/collections/TestMetadataDrafts.ts @@ -0,0 +1,31 @@ +import type { CollectionConfig } from 'payload' + +import { testMetadataDraftsSlug } from '../shared.js' + +export const TestMetadataDrafts: CollectionConfig = { + slug: testMetadataDraftsSlug, + access: { + create: () => true, + read: () => true, + update: () => true, + delete: () => true, + }, + fields: [ + { + name: 'testNote', + type: 'text', + }, + ], + upload: { + adminThumbnail: 'thumbnail', + imageSizes: [ + { + name: 'thumbnail', + width: 300, + }, + ], + }, + versions: { + drafts: true, + }, +} diff --git a/test/plugin-cloud-storage/shared.ts b/test/plugin-cloud-storage/shared.ts index e67f626111b..77a572011bf 100644 --- a/test/plugin-cloud-storage/shared.ts +++ b/test/plugin-cloud-storage/shared.ts @@ -5,6 +5,7 @@ export const mediaWithCustomURLSlug = 'media-with-custom-url' export const mediaWithGenerateFileURLSlug = 'media-with-generate-file-url' export const restrictedMediaSlug = 'restricted-media' export const testMetadataSlug = 'test-metadata' +export const testMetadataDraftsSlug = 'test-metadata-drafts' export const mediaWithThrowingHookSlug = 'media-with-throwing-hook' export const mediaWithOverwriteSlug = 'media-with-overwrite' export const prefix = 'test-prefix' diff --git a/test/plugin-ecommerce/seed/index.ts b/test/plugin-ecommerce/seed/index.ts index 56c7a9bc2e2..135118ae554 100644 --- a/test/plugin-ecommerce/seed/index.ts +++ b/test/plugin-ecommerce/seed/index.ts @@ -67,6 +67,7 @@ export const seed = async (payload: Payload): Promise => { ) const hoodieProduct = await payload.create({ + action: 'publish', collection: 'products', data: { name: 'Hoodie', @@ -76,6 +77,7 @@ export const seed = async (payload: Payload): Promise => { }) const hoodieSmallWhite = await payload.create({ + action: 'publish', collection: 'variants', data: { product: hoodieProduct.id, @@ -87,6 +89,7 @@ export const seed = async (payload: Payload): Promise => { }) const hoodieMediumWhite = await payload.create({ + action: 'publish', collection: 'variants', data: { product: hoodieProduct.id, @@ -98,6 +101,7 @@ export const seed = async (payload: Payload): Promise => { }) const hatProduct = await payload.create({ + action: 'publish', collection: 'products', data: { name: 'Hat', diff --git a/test/plugin-import-export/e2e.spec.ts b/test/plugin-import-export/e2e.spec.ts index 147f20294ed..32d218b71e5 100644 --- a/test/plugin-import-export/e2e.spec.ts +++ b/test/plugin-import-export/e2e.spec.ts @@ -688,6 +688,7 @@ test.describe('Import Export Plugin', () => { const importedDocs = await payload.find({ collection: 'pages', + version: 'published', where: { title: { contains: 'E2E Import Test' }, }, @@ -727,6 +728,7 @@ test.describe('Import Export Plugin', () => { const importedDocs = await payload.find({ collection: 'pages', + version: 'published', where: { title: { contains: 'E2E JSON Import' }, }, @@ -795,6 +797,7 @@ test.describe('Import Export Plugin', () => { test('should handle import with update mode', async () => { const existingDoc = await payload.create({ collection: 'pages', + action: 'publish', data: { excerpt: 'Original excerpt', title: 'E2E Update Test Original', @@ -841,6 +844,7 @@ test.describe('Import Export Plugin', () => { docs: [updatedDoc], } = await payload.find({ collection: 'pages', + version: 'published', where: { id: { equals: existingDoc.id, @@ -883,7 +887,7 @@ test.describe('Import Export Plugin', () => { const importedDocs = await payload.find({ collection: 'pages', - draft: false, + version: 'published', where: { title: { contains: 'E2E Published Status Test' }, }, @@ -927,7 +931,7 @@ test.describe('Import Export Plugin', () => { const draftDocs = await payload.find({ collection: 'pages', - draft: true, + version: 'latest', where: { title: { equals: 'E2E Explicit Draft Test' }, }, @@ -938,7 +942,7 @@ test.describe('Import Export Plugin', () => { const publishedDocs = await payload.find({ collection: 'pages', - draft: false, + version: 'published', where: { title: { equals: 'E2E Explicit Published Test' }, }, @@ -1104,6 +1108,7 @@ test.describe('Import Export Plugin', () => { const posts = await payload.find({ collection: postsWithS3Slug, + version: 'published', where: { title: { contains: 'S3 E2E Import' }, }, @@ -1115,10 +1120,12 @@ test.describe('Import Export Plugin', () => { test('should export to S3 via jobs queue and download file', async () => { await payload.create({ collection: postsWithS3Slug, + action: 'publish', data: { title: 'S3 E2E Export 1' }, }) await payload.create({ collection: postsWithS3Slug, + action: 'publish', data: { title: 'S3 E2E Export 2' }, }) @@ -1239,6 +1246,7 @@ test.describe('Import Export Plugin', () => { await payload.create({ collection: 'pages', + action: 'publish', data: { _status: 'published', customRelationship: userId, diff --git a/test/plugin-import-export/int.spec.ts b/test/plugin-import-export/int.spec.ts index a6aa08e6235..aab2faa923e 100644 --- a/test/plugin-import-export/int.spec.ts +++ b/test/plugin-import-export/int.spec.ts @@ -205,6 +205,7 @@ test.suite({ collection: 'pages', limit: 100, page: 1, + version: 'latest', }) const firstDocOnPage1 = pages.docs?.[0] @@ -240,6 +241,7 @@ test.suite({ collection: 'pages', limit: 100, page: 2, + version: 'latest', }) const firstDocOnPage2 = pages.docs?.[0] @@ -2119,6 +2121,7 @@ test.suite({ const updatedJson = { version: 2, data: 'updated', extra: [1, 2, 3] } const existingPage = await payload.create({ + action: 'publish', collection: 'pages', data: { title: 'JSON Update Mode Test', @@ -2183,6 +2186,7 @@ test.suite({ const updatedExistingJson = { id: 'existing', value: 150, modified: true } const existingPage = await payload.create({ + action: 'publish', collection: 'pages', data: { title: `JSON Upsert Existing ${timestamp}`, @@ -2319,6 +2323,7 @@ test.suite({ const jsonV3 = { version: 3, items: ['a', 'b', 'c'] } const page = await payload.create({ + action: 'publish', collection: 'pages', data: { title: 'Sequential Import Test', @@ -3191,6 +3196,7 @@ test.suite({ test('should update existing documents in update mode', async ({ payload }) => { const page1 = await payload.create({ + action: 'publish', collection: 'pages', data: { title: 'Update Test 1', @@ -3201,6 +3207,7 @@ test.suite({ }) const page2 = await payload.create({ + action: 'publish', collection: 'pages', data: { title: 'Update Test 2', @@ -3269,8 +3276,8 @@ test.suite({ test('should handle upsert mode correctly', async ({ payload }) => { const timestamp = Date.now() const existingPage = await payload.create({ + action: 'publish', collection: 'pages', - draft: false, data: { title: `Upsert Test ${timestamp}`, excerpt: 'existing', @@ -3329,7 +3336,7 @@ test.suite({ collection: 'pages', id: existingPage.id, depth: 0, - draft: false, // Get published version + version: 'published', // Get published version overrideAccess: true, }) @@ -3337,7 +3344,7 @@ test.suite({ collection: 'pages', id: existingPage.id, depth: 0, - draft: true, // Get draft version + version: 'latest', // Get draft version overrideAccess: true, }) @@ -3806,6 +3813,7 @@ test.suite({ const postId = posts.docs[0]?.id const existingPage = await payload.create({ + action: 'publish', collection: 'pages', data: { title: 'Original Title', @@ -4025,7 +4033,7 @@ test.suite({ where: { title: { contains: 'Draft Import ' }, }, - draft: true, + version: 'latest', }) expect(draftPages.docs).toHaveLength(2) @@ -4036,7 +4044,7 @@ test.suite({ where: { title: { contains: 'Published Import ' }, }, - draft: false, // Query for published documents only + version: 'published', // Query for published documents only }) expect(publishedPages.docs).toHaveLength(1) @@ -4085,7 +4093,7 @@ test.suite({ where: { title: { contains: 'Default Status Test ' }, }, - draft: false, // Query for published documents + version: 'published', // Query for published documents }) expect(pages.docs).toHaveLength(2) @@ -4225,7 +4233,7 @@ test.suite({ const validPage1 = await payload.find({ collection: 'pages', - draft: true, + version: 'latest', overrideAccess: true, where: { title: { equals: `Partial Valid ${timestamp}-1` }, @@ -4233,7 +4241,7 @@ test.suite({ }) const validPage2 = await payload.find({ collection: 'pages', - draft: true, + version: 'latest', overrideAccess: true, where: { title: { equals: `Partial Valid ${timestamp}-2` }, @@ -4250,7 +4258,7 @@ test.suite({ const allPages = await payload.find({ collection: 'pages', - draft: true, + version: 'latest', overrideAccess: true, limit: 100, }) @@ -5009,7 +5017,7 @@ test.suite({ where: { title: { contains: 'Default Status Test ' }, }, - draft: false, + version: 'published', }) expect(publishedPages.totalDocs).toBe(2) @@ -5063,7 +5071,7 @@ test.suite({ where: { title: { contains: 'Explicit Draft Test ' }, }, - draft: true, + version: 'latest', }) expect(draftPages.totalDocs).toBe(2) @@ -5118,7 +5126,7 @@ test.suite({ where: { title: { contains: 'Upsert New Published Test ' }, }, - draft: false, + version: 'published', }) expect(publishedPages.totalDocs).toBe(2) @@ -6160,6 +6168,7 @@ test.suite({ const importedDocs = await payload.find({ collection: 'posts-imports-only', + version: 'latest', where: { title: { contains: 'Sync Import Test' }, }, @@ -6256,7 +6265,7 @@ test.suite({ where: { title: { contains: 'Default Draft Config Test' }, }, - draft: true, + version: 'latest', }) expect(draftDocs.totalDocs).toBe(2) @@ -6269,7 +6278,7 @@ test.suite({ where: { title: { equals: 'Default Draft Config Override Test' }, }, - draft: false, + version: 'published', }) expect(publishedDocs.totalDocs).toBe(1) @@ -6558,6 +6567,7 @@ test.suite({ const unchangedPost = await payload.findByID({ collection: 'posts-imports-only', id: post.id, + version: 'latest', }) expect(previewResponse.status).toBe(400) @@ -7245,6 +7255,7 @@ test.suite({ const importedPage = await payload.find({ collection: 'pages', + version: 'latest', where: { title: { equals: 'Rich Text JSON Test' }, }, @@ -7813,6 +7824,7 @@ test.suite({ const importedPage = await payload.find({ collection: 'pages', + version: 'latest', where: { title: { equals: 'JSON Roundtrip Test' }, }, diff --git a/test/plugin-mcp/config.ts b/test/plugin-mcp/config.ts index e5b1ee92213..2aa11928a89 100644 --- a/test/plugin-mcp/config.ts +++ b/test/plugin-mcp/config.ts @@ -228,7 +228,6 @@ export default buildConfigWithDefaults({ user: req.user?.id, }, req, - draft: true, overrideAccess: authorizedMCP.overrideAccess, }) @@ -262,7 +261,6 @@ export default buildConfigWithDefaults({ user: req.user?.id, }, req, - draft: true, overrideAccess: false, }) @@ -300,7 +298,6 @@ export default buildConfigWithDefaults({ user: req.user?.id, }, req, - draft: true, overrideAccess: false, }) @@ -331,7 +328,6 @@ export default buildConfigWithDefaults({ user: req.user?.id, }, req, - draft: true, overrideAccess: false, }) diff --git a/test/plugin-mcp/int.spec.ts b/test/plugin-mcp/int.spec.ts index e285a026a78..cddfddafed4 100644 --- a/test/plugin-mcp/int.spec.ts +++ b/test/plugin-mcp/int.spec.ts @@ -14,9 +14,9 @@ import { it, itModern, test } from './helpers/mcpFixtures.js' const dirname = path.dirname(fileURLToPath(import.meta.url)) type CreateOneDocumentInput = { slug: string + action?: 'publish' | 'saveDraft' data: Record depth?: number - draft?: boolean fallbackLocale?: string file?: Record locale?: string @@ -33,6 +33,7 @@ const callCreateDocumentsWithOne = async ( ) => client.callTool({ arguments: { + action: 'publish', returning: true, ...options, documents: [{ data, ...(file ? { file } : {}) }], @@ -511,7 +512,8 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('@payloadcms/plu expect(createDocuments.inputSchema.properties.documents.items.required).toContain('data') expect(createDocuments.inputSchema.properties.documents.items.properties.file).toBeDefined() expect(createDocuments.inputSchema.properties.depth).toBeDefined() - expect(createDocuments.inputSchema.properties.draft).toBeDefined() + expect(createDocuments.inputSchema.properties.action.enum).toEqual(['saveDraft', 'publish']) + expect(createDocuments.inputSchema.properties.draft).toBeUndefined() expect(createDocuments.inputSchema.properties.fallbackLocale).toBeDefined() expect(createDocuments.inputSchema.properties.locale).toBeDefined() expect(createDocuments.inputSchema.properties.returning).toMatchObject({ @@ -526,6 +528,12 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('@payloadcms/plu expect(findDocuments.inputSchema.properties.id).toBeDefined() expect(findDocuments.inputSchema.properties.limit).toBeDefined() expect(findDocuments.inputSchema.properties.page).toBeDefined() + expect(findDocuments.inputSchema.properties.version.enum).toEqual([ + 'published', + 'latest', + 'draft', + ]) + expect(findDocuments.inputSchema.properties.draft).toBeUndefined() expect(findDocuments.inputSchema.properties.select).toBeDefined() expect(findDocuments.inputSchema.properties.select.type).toBe('object') expect(findDocuments.inputSchema.properties.where).toBeDefined() @@ -907,6 +915,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('@payloadcms/plu const client = await mcp.connect(apiKey) const callResponse = await client.callTool({ arguments: { + action: 'publish', slug: 'posts', documents: [ { @@ -944,7 +953,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('@payloadcms/plu arguments: { slug: 'posts', documents: [{ data: { content: 'Incomplete draft' } }], - draft: true, + action: 'saveDraft', returning: true, }, name: 'createDocuments', @@ -975,6 +984,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('@payloadcms/plu const callResponse = await client.callTool({ arguments: { slug: 'posts', + action: 'publish', documents: [ { data: { content: 'First bulk content', title: 'First bulk post' } }, { data: { content: 'Missing the required title' } }, @@ -1251,7 +1261,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('@payloadcms/plu _status: 'published', title: 'Published through MCP', }, - draft: false, + action: undefined, locale: 'en', }, }) @@ -1263,7 +1273,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('@payloadcms/plu const storedPost = await payload.findByID({ id: createdPost.id, collection: 'posts', - draft: false, + version: 'published', locale: 'all', }) expect(storedPost._status).toMatchObject({ en: 'published' }) @@ -1353,6 +1363,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('@payloadcms/plu it('should call findDocuments', async ({ mcp, getApiKey, payload }) => { const post = await payload.create({ collection: 'posts', + action: 'publish', data: { content: 'Content for test post.', title: 'Test Post for Finding', @@ -1388,6 +1399,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('@payloadcms/plu payload, }) => { await payload.create({ + action: 'publish', collection: 'posts', data: { content: 'Content that should be omitted', @@ -1531,6 +1543,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('@payloadcms/plu }) it('should call collection version tools', async ({ mcp, getApiKey, payload }) => { const post = await payload.create({ + action: 'publish', collection: 'posts', data: { content: 'Initial version content', @@ -1625,6 +1638,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('@payloadcms/plu userId, }) => { const post = await payload.create({ + action: 'publish', collection: 'posts', data: { author: userId, @@ -1671,6 +1685,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('@payloadcms/plu userId, }) => { const post = await payload.create({ + action: 'publish', collection: 'posts', data: { author: userId, @@ -1709,6 +1724,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('@payloadcms/plu }) it('should call updateDocument', async ({ mcp, getApiKey, payload }) => { const post = await payload.create({ + action: 'publish', collection: 'posts', data: { content: 'Content for test post to update.', @@ -1780,7 +1796,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('@payloadcms/plu data: { title: 'English draft title', }, - draft: true, + action: 'saveDraft', locale: 'en', }) try { @@ -1788,7 +1804,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('@payloadcms/plu id: post.id, collection: 'posts', data: { title: 'Spanish draft title' }, - draft: true, + action: 'saveDraft', locale: 'es', }) const apiKey = await getApiKey() @@ -1801,7 +1817,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('@payloadcms/plu _status: 'published', title: 'Published English title', }, - draft: false, + action: 'publish', locale: 'en', publishAllLocales: false, }, @@ -1810,13 +1826,13 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('@payloadcms/plu const publishedPost = await payload.findByID({ id: post.id, collection: 'posts', - draft: false, + version: 'published', locale: 'all', }) const spanishDraft = await payload.findByID({ id: post.id, collection: 'posts', - draft: true, + version: 'latest', locale: 'es', }) expect(callResponse).toBeDefined() @@ -1835,6 +1851,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('@payloadcms/plu payload, }) => { const post = await payload.create({ + action: 'publish', collection: 'posts', data: { content: 'Content to be cleared', @@ -1869,6 +1886,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('@payloadcms/plu userId, }) => { const post = await payload.create({ + action: 'publish', collection: 'posts', data: { title: 'Union Type Relationship Test', @@ -1902,6 +1920,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('@payloadcms/plu payload, }) => { const post = await payload.create({ + action: 'publish', collection: 'posts', data: { content: 'Original content', @@ -1945,6 +1964,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('@payloadcms/plu }) it('should call deleteDocuments', async ({ mcp, getApiKey, payload }) => { const post = await payload.create({ + action: 'publish', collection: 'posts', data: { content: 'Content for test post to delete.', @@ -1976,6 +1996,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('@payloadcms/plu payload, }) => { const matching = await payload.create({ + action: 'publish', collection: 'posts', data: { content: 'Original content', @@ -1983,6 +2004,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('@payloadcms/plu }, }) const excluded = await payload.create({ + action: 'publish', collection: 'posts', data: { content: 'Original content', @@ -2024,6 +2046,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('@payloadcms/plu payload, }) => { await payload.create({ + action: 'publish', collection: 'posts', data: { content: 'Content for object where delete.', @@ -2031,6 +2054,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('@payloadcms/plu }, }) await payload.create({ + action: 'publish', collection: 'posts', data: { content: 'Content for object where delete.', @@ -2109,6 +2133,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('@payloadcms/plu const apiKey = await getApiKey() const client = await mcp.connect(apiKey) const createdPost = await payload.create({ + action: 'publish', collection: 'posts', data: { location: [-118.2437, 34.0522], @@ -2338,6 +2363,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('@payloadcms/plu payload, }) => { const post = await payload.create({ + action: 'publish', collection: 'posts', data: { title: 'Virtual Field Update Test' }, }) @@ -2366,6 +2392,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('@payloadcms/plu payload, }) => { await payload.create({ + action: 'publish', collection: 'posts', data: { content: 'Content for test post.', @@ -2910,6 +2937,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('@payloadcms/plu payload, }) => { const doc = await payload.create({ + action: 'publish', collection: 'posts', data: { title: 'Minified JSON Test', @@ -2962,6 +2990,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('@payloadcms/plu payload, }) => { const doc = await payload.create({ + action: 'publish', collection: 'posts', data: { title: 'Minified JSON FindByID Test', @@ -3039,6 +3068,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('@payloadcms/plu it('should update post to add translation', async ({ mcp, getApiKey, payload }) => { // First create a post in English const englishPost = await payload.create({ + action: 'publish', collection: 'posts', data: { content: 'English Content', @@ -3069,6 +3099,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('@payloadcms/plu it('should find post in specific locale', async ({ mcp, getApiKey, payload }) => { // Create a post with English and Spanish translations const post = await payload.create({ + action: 'publish', collection: 'posts', data: { content: 'English Content', @@ -3104,6 +3135,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('@payloadcms/plu it('should find post with locale "all"', async ({ mcp, getApiKey, payload }) => { // Create a post with multiple translations const post = await payload.create({ + action: 'publish', collection: 'posts', data: { content: 'English Content', @@ -3156,6 +3188,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('@payloadcms/plu }) => { // Create a post only in English with explicit content const post = await payload.create({ + action: 'publish', collection: 'posts', data: { title: 'English Only Title', @@ -3170,6 +3203,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('@payloadcms/plu slug: 'posts', id: post.id, locale: 'fr', + version: 'latest', }, name: 'findDocuments', }) diff --git a/test/plugin-multi-tenant/e2e.spec.ts b/test/plugin-multi-tenant/e2e.spec.ts index 44bcd34e3e2..5b6f31daa90 100644 --- a/test/plugin-multi-tenant/e2e.spec.ts +++ b/test/plugin-multi-tenant/e2e.spec.ts @@ -658,6 +658,7 @@ test.describe('Multi Tenant', () => { const globalTenant = await getSelectedTenantFilterName({ page, payload }) const autosaveGlobal = await payload.find({ collection: autosaveGlobalSlug, + version: 'latest', where: { id: { equals: docID, diff --git a/test/plugin-nested-docs/e2e.spec.ts b/test/plugin-nested-docs/e2e.spec.ts index f6f80a13a24..5e821016173 100644 --- a/test/plugin-nested-docs/e2e.spec.ts +++ b/test/plugin-nested-docs/e2e.spec.ts @@ -38,6 +38,7 @@ describe('Nested Docs Plugin', () => { }: Partial): Promise { return payload.create({ collection: 'pages', + action: _status === 'published' ? 'publish' : 'saveDraft', data: { slug, _status, @@ -113,6 +114,7 @@ describe('Nested Docs Plugin', () => { // TODO: remove when error states are fixed const apiTabButton = page.getByRole('link', { name: 'API', exact: true }) await apiTabButton.click() + await page.locator('#field-draft').check() const breadcrumbs = page.locator('text=/parent-slug-draft').first() await expect(breadcrumbs).toBeVisible() @@ -128,6 +130,7 @@ describe('Nested Docs Plugin', () => { await page.goto(url.edit(draftChildID)) await apiTabButton.click() + await page.locator('#field-draft').check() const updatedBreadcrumbs = page.locator('text=/parent-slug-draft').first() await expect(updatedBreadcrumbs).toBeVisible() diff --git a/test/plugin-nested-docs/int.spec.ts b/test/plugin-nested-docs/int.spec.ts index 268baf52577..1b0ecfcd998 100644 --- a/test/plugin-nested-docs/int.spec.ts +++ b/test/plugin-nested-docs/int.spec.ts @@ -42,6 +42,7 @@ test.suite({ config: './config.ts' })('@payloadcms/plugin-nested-docs', () => { test('should update more than 10 (default limit) breadcrumbs', async ({ payload }) => { // create a parent doc const parentDoc = await payload.create({ + action: 'publish', collection: 'pages', data: { title: '11 children', @@ -97,6 +98,7 @@ test.suite({ config: './config.ts' })('@payloadcms/plugin-nested-docs', () => { test('should return breadcrumbs as an array of objects', async ({ payload }) => { const parentDoc = await payload.create({ + action: 'publish', collection: 'pages', data: { title: 'parent doc', @@ -129,6 +131,7 @@ test.suite({ config: './config.ts' })('@payloadcms/plugin-nested-docs', () => { payload, }) => { const parentDoc = await payload.create({ + action: 'publish', collection: 'pages', data: { title: 'parent doc', @@ -220,7 +223,7 @@ test.suite({ config: './config.ts' })('@payloadcms/plugin-nested-docs', () => { const initialPublished = await payload.findByID({ id: childDoc.id, collection: 'pages', - draft: false, + version: 'published', }) expect(initialPublished._status).toBe('published') expect(initialPublished.breadcrumbs).toHaveLength(2) @@ -232,7 +235,7 @@ test.suite({ config: './config.ts' })('@payloadcms/plugin-nested-docs', () => { data: { title: 'Version Child Draft Edit', }, - draft: true, + action: 'saveDraft', }) // Step 4: Re-publish the parent (triggers resaveChildren) @@ -250,7 +253,7 @@ test.suite({ config: './config.ts' })('@payloadcms/plugin-nested-docs', () => { const publishedChild = await payload.findByID({ id: childDoc.id, collection: 'pages', - draft: false, + version: 'published', }) expect(publishedChild).toBeDefined() @@ -262,7 +265,7 @@ test.suite({ config: './config.ts' })('@payloadcms/plugin-nested-docs', () => { const draftChild = await payload.findByID({ id: childDoc.id, collection: 'pages', - draft: true, + version: 'latest', }) expect(draftChild).toBeDefined() @@ -311,7 +314,7 @@ test.suite({ config: './config.ts' })('@payloadcms/plugin-nested-docs', () => { const updatedDraftChild = await payload.findByID({ id: draftChild.id, collection: 'pages', - draft: true, + version: 'latest', }) expect(updatedDraftChild.breadcrumbs).toHaveLength(2) @@ -349,7 +352,7 @@ test.suite({ config: './config.ts' })('@payloadcms/plugin-nested-docs', () => { data: { title: 'Breadcrumb Child Draft', }, - draft: true, + action: 'saveDraft', }) // Update parent slug @@ -366,7 +369,7 @@ test.suite({ config: './config.ts' })('@payloadcms/plugin-nested-docs', () => { const published = await payload.findByID({ id: child.id, collection: 'pages', - draft: false, + version: 'published', }) expect(published._status).toBe('published') @@ -376,7 +379,7 @@ test.suite({ config: './config.ts' })('@payloadcms/plugin-nested-docs', () => { const draft = await payload.findByID({ id: child.id, collection: 'pages', - draft: true, + version: 'latest', }) expect(draft._status).toBe('draft') @@ -395,7 +398,7 @@ test.suite({ config: './config.ts' })('@payloadcms/plugin-nested-docs', () => { title: 'Scheduled Page', slug: 'scheduled-page', }, - draft: true, + action: 'saveDraft', }) expect(draft._status).toBe('draft') @@ -425,7 +428,7 @@ test.suite({ config: './config.ts' })('@payloadcms/plugin-nested-docs', () => { const retrieved = await payload.findByID({ id: draft.id, collection: 'pages', - draft: false, + version: 'published', }) expect(retrieved._status).toBe('published') diff --git a/test/plugin-redirects/int.spec.ts b/test/plugin-redirects/int.spec.ts index ce2b49edc7e..583b7f9dab7 100644 --- a/test/plugin-redirects/int.spec.ts +++ b/test/plugin-redirects/int.spec.ts @@ -11,6 +11,7 @@ let page: Page test.suite({ config: './config.ts' })('@payloadcms/plugin-redirects', () => { test.beforeEach(async ({ payload }) => { page = await payload.create({ + action: 'publish', collection: 'pages', data: { title: 'Test', diff --git a/test/plugin-search/int.spec.ts b/test/plugin-search/int.spec.ts index 5ede0957cdb..9fdc20e1662 100644 --- a/test/plugin-search/int.spec.ts +++ b/test/plugin-search/int.spec.ts @@ -148,7 +148,7 @@ test.suite({ config: './config.ts' })('@payloadcms/plugin-search', () => { await payload.update({ collection: 'pages', id: publishedPage.id, - draft: true, + action: 'saveDraft', data: { _status: 'draft', title: 'Draft title!', @@ -169,6 +169,7 @@ test.suite({ config: './config.ts' })('@payloadcms/plugin-search', () => { expect(updatedResults).toHaveLength(1) await payload.update({ + action: 'unpublish', collection: 'pages', id: publishedPage.id, data: { diff --git a/test/relationships/int.spec.ts b/test/relationships/int.spec.ts index 9a5d9209028..b4333240243 100644 --- a/test/relationships/int.spec.ts +++ b/test/relationships/int.spec.ts @@ -2,6 +2,7 @@ import type { Payload, PayloadRequest } from 'payload' import { randomBytes, randomUUID } from 'crypto' import { Types } from 'mongoose' +import { wait } from 'payload/shared' import { fileURLToPath } from 'url' import { expect } from 'vitest' @@ -265,6 +266,7 @@ test.suite({ config: './config.ts' })('Relationships', () => { }) await payload.create({ + action: 'publish', collection: 'movies', data: { name: 'Pulp Fiction', @@ -272,6 +274,7 @@ test.suite({ config: './config.ts' })('Relationships', () => { }) await payload.create({ + action: 'publish', collection: 'movies', data: { name: 'Pulp Fiction', @@ -279,6 +282,7 @@ test.suite({ config: './config.ts' })('Relationships', () => { }) await payload.create({ + action: 'publish', collection: 'movies', data: { name: 'Harry Potter', @@ -286,6 +290,7 @@ test.suite({ config: './config.ts' })('Relationships', () => { }) await payload.create({ + action: 'publish', collection: 'movies', data: { name: 'Lord of the Rings is boring', @@ -329,6 +334,7 @@ test.suite({ config: './config.ts' })('Relationships', () => { }) const movie = await payload.create({ + action: 'publish', collection: 'movies', data: { director: director.id }, depth: 0, @@ -387,7 +393,11 @@ test.suite({ config: './config.ts' })('Relationships', () => { }) test('should allow querying within tabs-blocks-tabs', async ({ payload }) => { - const movie = await payload.create({ collection: 'movies', data: { name: 'Pulp Fiction' } }) + const movie = await payload.create({ + action: 'publish', + collection: 'movies', + data: { name: 'Pulp Fiction' }, + }) const { id } = await payload.create({ collection: 'deep-nested', @@ -419,7 +429,11 @@ test.suite({ config: './config.ts' })('Relationships', () => { }) test('should allow query hasMany select in relationship', async ({ payload }) => { - const movie = await payload.create({ collection: 'movies', data: { select: ['a', 'b'] } }) + const movie = await payload.create({ + action: 'publish', + collection: 'movies', + data: { select: ['a', 'b'] }, + }) const doc = await payload.create({ collection: 'directors', data: { name: 'Mega Director', movie }, @@ -462,6 +476,7 @@ test.suite({ config: './config.ts' })('Relationships', () => { test('should allow 4x deep querying', async ({ payload }) => { const movie_1 = await payload.create({ + action: 'publish', collection: 'movies', data: { name: 'random_movie_1' }, }) @@ -470,6 +485,7 @@ test.suite({ config: './config.ts' })('Relationships', () => { data: { name: 'random_director_1', movie: movie_1.id }, }) const movie_2 = await payload.create({ + action: 'publish', collection: 'movies', data: { name: 'random_movie_2', director: director_1.id }, }) @@ -493,6 +509,7 @@ test.suite({ config: './config.ts' })('Relationships', () => { 'should not duplicate IDs in $in when querying through a relationship', async ({ payload }) => { const movie = await payload.create({ + action: 'publish', collection: 'movies', data: { name: 'dup_test_movie' }, }) @@ -531,11 +548,13 @@ test.suite({ config: './config.ts' })('Relationships', () => { test.beforeEach(async ({ payload }) => { const recallsMovie = await payload.create({ + action: 'publish', collection: 'movies', data: { name: 'recalls', select: ['a'] }, }) const electricCarsMovie = await payload.create({ + action: 'publish', collection: 'movies', data: { name: 'electric-cars', select: ['a', 'b'] }, }) @@ -746,6 +765,7 @@ test.suite({ config: './config.ts' })('Relationships', () => { 'should support equals with a geospatial nested query', async ({ payload }) => { const nearbyMovie = await payload.create({ + action: 'publish', collection: 'movies', data: { name: 'nearby', location: [10, 20] }, }) @@ -772,11 +792,13 @@ test.suite({ config: './config.ts' })('Relationships', () => { payload, }) => { const alpha = await payload.create({ + action: 'publish', collection: 'movies', data: { name: 'Alpha' }, }) const beta = await payload.create({ + action: 'publish', collection: 'movies', data: { name: 'Beta' }, }) @@ -804,20 +826,24 @@ test.suite({ config: './config.ts' })('Relationships', () => { test('should retrieve totalDocs correctly with hasMany,', async ({ payload }) => { const movie1 = await payload.create({ + action: 'publish', collection: 'movies', data: {}, }) const movie2 = await payload.create({ + action: 'publish', collection: 'movies', data: {}, }) const movie3 = await payload.create({ + action: 'publish', collection: 'movies', data: { name: 'some-name' }, }) const movie4 = await payload.create({ + action: 'publish', collection: 'movies', data: { name: 'some-name' }, }) @@ -894,10 +920,12 @@ test.suite({ config: './config.ts' })('Relationships', () => { test('should query using "contains" by hasMany relationship field', async ({ payload }) => { const movie1 = await payload.create({ + action: 'publish', collection: 'movies', data: {}, }) const movie2 = await payload.create({ + action: 'publish', collection: 'movies', data: {}, }) @@ -944,7 +972,11 @@ test.suite({ config: './config.ts' })('Relationships', () => { test.options({ db: 'mongo' })( 'should treat an ObjectId as a relationship ID', async ({ payload }) => { - const movie = await payload.create({ collection: 'movies', data: {} }) + const movie = await payload.create({ + action: 'publish', + collection: 'movies', + data: {}, + }) const director = await payload.create({ collection: 'directors', @@ -973,10 +1005,12 @@ test.suite({ config: './config.ts' })('Relationships', () => { 'should query using "all" by hasMany relationship field', async ({ payload }) => { const movie1 = await payload.create({ + action: 'publish', collection: 'movies', data: {}, }) const movie2 = await payload.create({ + action: 'publish', collection: 'movies', data: {}, }) @@ -1093,12 +1127,14 @@ test.suite({ config: './config.ts' })('Relationships', () => { }) const movie_1 = await payload.create({ + action: 'publish', collection: 'movies', depth: 0, data: { director: director_1.id, name: 'Some Movie 1' }, }) const movie_2 = await payload.create({ + action: 'publish', collection: 'movies', depth: 0, data: { director: director_2.id, name: 'Some Movie 2' }, @@ -1122,17 +1158,17 @@ test.suite({ config: './config.ts' })('Relationships', () => { collection: 'movies', sort: '-director.name', depth: 0, - draft: true, + version: 'latest', }) const draft_res_2 = await payload.find({ collection: 'movies', sort: 'director.name', depth: 0, - draft: true, + version: 'latest', }) - expect(draft_res_1.docs).toStrictEqual([movie_2, movie_1]) - expect(draft_res_2.docs).toStrictEqual([movie_1, movie_2]) + expect(draft_res_1.docs.map((doc) => doc.id)).toEqual([movie_2.id, movie_1.id]) + expect(draft_res_2.docs.map((doc) => doc.id)).toEqual([movie_1.id, movie_2.id]) const localized_res_1 = await payload.find({ collection: 'movies', @@ -1157,6 +1193,7 @@ test.suite({ config: './config.ts' })('Relationships', () => { const director = await payload.create({ collection: 'directors', data: {} }) const movie = await payload.create({ + action: 'publish', collection: 'movies', data: { director: director.id, name: 'movie 1' }, }) @@ -1170,6 +1207,7 @@ test.suite({ config: './config.ts' })('Relationships', () => { const director_2 = await payload.create({ collection: 'directors', data: {} }) const movie_2 = await payload.create({ + action: 'publish', collection: 'movies', data: { director: director_2.id, name: 'movie 2' }, }) @@ -1201,15 +1239,18 @@ test.suite({ config: './config.ts' })('Relationships', () => { } as const const director_1 = await payload.create(createDirector) + await wait(10) const director_2 = await payload.create(createDirector) const movie_1 = await payload.create({ + action: 'publish', collection: 'movies', depth: 0, data: { director: director_1.id, name: 'Some Movie 1' }, }) const movie_2 = await payload.create({ + action: 'publish', collection: 'movies', depth: 0, data: { director: director_2.id, name: 'Some Movie 2' }, @@ -1232,6 +1273,7 @@ test.suite({ config: './config.ts' })('Relationships', () => { test('should sort by a property of a hasMany relationship', async ({ payload }) => { const movie1 = await payload.create({ + action: 'publish', collection: 'movies', data: { name: 'Pulp Fiction', @@ -1239,6 +1281,7 @@ test.suite({ config: './config.ts' })('Relationships', () => { }) const movie2 = await payload.create({ + action: 'publish', collection: 'movies', data: { name: 'Inception', @@ -1509,6 +1552,7 @@ test.suite({ config: './config.ts' })('Relationships', () => { payload, }) => { const movie = await payload.create({ + action: 'publish', collection: 'movies', data: { name: 'Jackie Brown' }, }) @@ -1723,6 +1767,7 @@ test.suite({ config: './config.ts' })('Relationships', () => { data: { name: 'direcotr' }, }) const movie = await payload.create({ + action: 'publish', collection: 'movies', data: { array: [{ polymorphic: { relationTo: 'directors', value: director.id } }] }, }) @@ -1741,6 +1786,7 @@ test.suite({ config: './config.ts' })('Relationships', () => { data: { name: 'Test Director1337' }, }) const movie = await payload.create({ + action: 'publish', collection: 'movies', data: { array: [{ director: [director.id] }] }, }) @@ -1772,6 +1818,7 @@ test.suite({ config: './config.ts' })('Relationships', () => { // 2. create a movie const movie = await payload.create({ + action: 'publish', collection: 'movies', data: { name: 'Pulp Fiction', @@ -1825,6 +1872,7 @@ test.suite({ config: './config.ts' })('Relationships', () => { await Promise.all( movieList.map(async (movie) => { return await payload.create({ + action: 'publish', collection: 'movies', data: { name: movie, @@ -1984,7 +2032,7 @@ test.suite({ config: './config.ts' })('Relationships', () => { test.describe('With passing an object', () => { test('should create with passing an object', async ({ payload }) => { - const movie = await payload.create({ collection: 'movies', data: {} }) + const movie = await payload.create({ action: 'publish', collection: 'movies', data: {} }) const result = await payload.create({ collection: 'object-writes', data: { @@ -2005,7 +2053,7 @@ test.suite({ config: './config.ts' })('Relationships', () => { }) test('should update with passing an object', async ({ payload }) => { - const movie = await payload.create({ collection: 'movies', data: {} }) + const movie = await payload.create({ action: 'publish', collection: 'movies', data: {} }) const { id } = await payload.create({ collection: 'object-writes', data: {} }) const result = await payload.update({ collection: 'object-writes', @@ -2060,6 +2108,7 @@ test.suite({ config: './config.ts' })('Relationships', () => { restClient, }) => { const movie = await payload.create({ + action: 'publish', collection: 'movies', data: { name: 'Pulp Fiction 2', @@ -2126,6 +2175,7 @@ test.suite({ config: './config.ts' })('Relationships', () => { 'should allow REST all querying on polymorphic relationships', async ({ payload, restClient }) => { const movie = await payload.create({ + action: 'publish', collection: 'movies', data: { name: 'Pulp Fiction 2', @@ -2161,6 +2211,7 @@ test.suite({ config: './config.ts' })('Relationships', () => { payload, }) => { const movie = await payload.create({ + action: 'publish', collection: 'movies', data: { name: 'Pulp Fiction 2', @@ -2209,6 +2260,7 @@ test.suite({ config: './config.ts' })('Relationships', () => { payload, }) => { const movie = await payload.create({ + action: 'publish', collection: 'movies', data: { name: 'Pulp Fiction 2', @@ -2247,6 +2299,7 @@ test.suite({ config: './config.ts' })('Relationships', () => { payload, }) => { const movie = await payload.create({ + action: 'publish', collection: 'movies', data: { name: 'Pulp Fiction 2', @@ -2283,6 +2336,7 @@ test.suite({ config: './config.ts' })('Relationships', () => { payload, }) => { const movie = await payload.create({ + action: 'publish', collection: 'movies', data: { name: 'Pulp Fiction 2', diff --git a/test/select/int.spec.ts b/test/select/int.spec.ts index e7e16283e1f..75eaffe66f8 100644 --- a/test/select/int.spec.ts +++ b/test/select/int.spec.ts @@ -1460,15 +1460,20 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Select', () => let postId: number | string test.beforeAll(async ({ payloadInstance: payload }) => { - post = await createVersionedPost({ payload }) - postId = post.id + const createdPost = await createVersionedPost({ payload }) + postId = createdPost.id + post = await payload.findByID({ + id: postId, + collection: 'versioned-posts', + version: 'latest', + }) }) test('should select only id as default', async ({ payload }) => { const res = await payload.findByID({ id: postId, collection: 'versioned-posts', - draft: true, + version: 'latest', select: {}, }) @@ -1481,7 +1486,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Select', () => const res = await payload.findByID({ id: postId, collection: 'versioned-posts', - draft: true, + version: 'latest', select: { number: true, }, @@ -1497,7 +1502,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Select', () => const res = await payload.findByID({ id: postId, collection: 'versioned-posts', - draft: true, + version: 'latest', select: { number: false, }, @@ -1513,7 +1518,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Select', () => const res = await payload.findByID({ id: postId, collection: 'versioned-posts', - draft: true, + version: 'latest', select: { number: true, text: true, @@ -1530,7 +1535,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Select', () => test('payload.find should select number and text', async ({ payload }) => { const res = await payload.find({ collection: 'versioned-posts', - draft: true, + version: 'latest', select: { number: true, text: true, @@ -1552,7 +1557,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Select', () => test('should select base id field inside of array', async ({ payload }) => { const res = await payload.find({ collection: 'versioned-posts', - draft: true, + version: 'latest', select: { array: {}, }, @@ -1572,7 +1577,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Select', () => test('should select base id field inside of blocks', async ({ payload }) => { const res = await payload.find({ collection: 'versioned-posts', - draft: true, + version: 'latest', select: { blocks: {}, }, @@ -1616,17 +1621,19 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Select', () => expect(doc.version.text).toBe(post.text) }) - test('should return a latest version with findByID and draft: true', async ({ payload }) => { + test("should return a latest version with findByID and version: 'latest'", async ({ + payload, + }) => { const doc = await payload.create({ collection: 'versioned-posts', data: { _status: 'draft', text: 'draft-post' }, - draft: true, + action: 'saveDraft', }) const res = await payload.findByID({ id: doc.id, collection: 'versioned-posts', - draft: true, + version: 'latest', select: { text: true }, }) expect(res.text).toBe('draft-post') @@ -1639,7 +1646,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Select', () => const res_2 = await payload.findByID({ id: doc.id, collection: 'versioned-posts', - draft: true, + version: 'latest', select: { text: true }, }) diff --git a/test/sort/int.spec.ts b/test/sort/int.spec.ts index c4bdcbc5254..c12b1b6013f 100644 --- a/test/sort/int.spec.ts +++ b/test/sort/int.spec.ts @@ -322,60 +322,59 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Sort', () => { const testData1 = await payload.create({ collection: 'drafts', data: { text: 'Post 1 draft', number: 10 }, - draft: true, + action: 'saveDraft', }) await payload.update({ collection: 'drafts', id: testData1.id, data: { text: 'Post 1 draft updated', number: 20 }, - draft: true, + action: 'saveDraft', }) await payload.update({ collection: 'drafts', id: testData1.id, data: { text: 'Post 1 draft updated', number: 30 }, - draft: true, + action: 'saveDraft', }) await payload.update({ collection: 'drafts', id: testData1.id, data: { text: 'Post 1 published', number: 15 }, - draft: false, + action: 'publish', }) const testData2 = await payload.create({ collection: 'drafts', data: { text: 'Post 2 draft', number: 1 }, - draft: true, + action: 'saveDraft', }) await payload.update({ collection: 'drafts', id: testData2.id, data: { text: 'Post 2 published', number: 2 }, - draft: false, + action: 'publish', }) await payload.update({ collection: 'drafts', id: testData2.id, data: { text: 'Post 2 newdraft', number: 100 }, - draft: true, + action: 'saveDraft', }) await payload.create({ collection: 'drafts', data: { text: 'Post 3 draft', number: 3 }, - draft: true, + action: 'saveDraft', }) }) - test('should sort latest without draft', async ({ payload }) => { + test('should sort published documents', async ({ payload }) => { const posts = await payload.find({ collection: 'drafts', sort: 'number', - draft: false, + version: 'published', }) expect(posts.docs.map((post) => post.text)).toEqual([ 'Post 2 published', // 2 - 'Post 3 draft', // 3 'Post 1 published', // 15 ]) }) @@ -384,7 +383,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Sort', () => { const posts = await payload.find({ collection: 'drafts', sort: 'number', - draft: true, + version: 'latest', }) expect(posts.docs.map((post) => post.text)).toEqual([ @@ -398,7 +397,6 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Sort', () => { const posts = await payload.findVersions({ collection: 'drafts', sort: 'version.number', - draft: false, }) expect(posts.docs.map((post) => post.version.text)).toEqual([ @@ -567,7 +565,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Sort', () => { const ordered = await payload.find({ collection: draftsSlug, - draft: true, + version: 'latest', where: { text: { contains: 'Orderable ', @@ -609,7 +607,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Sort', () => { data: { text: 'Published with newer draft - edited', }, - draft: true, + action: 'saveDraft', }) const beforeReorder = await payload.findByID({ diff --git a/test/trash/e2e.spec.ts b/test/trash/e2e.spec.ts index f3a75cb8137..5b60abb4b31 100644 --- a/test/trash/e2e.spec.ts +++ b/test/trash/e2e.spec.ts @@ -371,6 +371,7 @@ describe('Trash', () => { .poll(async () => { const { docs } = await payload.find({ collection: postsSlug, + version: 'latest', where: { title: { equals: 'Ready for restore' }, }, @@ -383,6 +384,7 @@ describe('Trash', () => { .poll(async () => { const { docs } = await payload.find({ collection: postsSlug, + version: 'latest', where: { title: { equals: 'Ready for restore' }, }, @@ -441,7 +443,7 @@ describe('Trash', () => { await expect(page.locator('.row-1 .cell-title')).toHaveText('Ready for restore') await expect(page.locator('.row-2 .cell-title')).toHaveText('Ready for restore') - // Check that restored docs have `_status = "draft"` + // Check that restored docs have `_status = "published"` await expect .poll(async () => { const { docs } = await payload.find({ @@ -810,6 +812,7 @@ describe('Trash', () => { .poll(async () => { const { docs } = await payload.find({ collection: postsSlug, + version: 'latest', where: { id: { equals: trashedPostDocOne.id }, }, @@ -822,6 +825,7 @@ describe('Trash', () => { .poll(async () => { const { docs } = await payload.find({ collection: postsSlug, + version: 'latest', where: { id: { equals: trashedPostDocOne.id }, }, @@ -1255,6 +1259,7 @@ describe('Trash', () => { const localizedFieldValueES = 'Localized Draft Content ES' const draftPost = await payload.create({ + action: 'saveDraft', collection: postsSlug, data: { _status: 'draft', @@ -1269,7 +1274,7 @@ describe('Trash', () => { _status: 'draft', localizedField: localizedFieldValueEN, }, - draft: true, + action: 'saveDraft', locale: 'en', }) @@ -1280,7 +1285,7 @@ describe('Trash', () => { _status: 'draft', localizedField: localizedFieldValueES, }, - draft: true, + action: 'saveDraft', locale: 'es', }) @@ -1318,6 +1323,7 @@ describe('Trash', () => { // Create a draft post without localized data initially const draftPost = await payload.create({ + action: 'saveDraft', collection: postsSlug, data: { _status: 'draft', @@ -1334,7 +1340,7 @@ describe('Trash', () => { _status: 'draft', localizedField: localizedFieldValueEN, }, - draft: true, + action: 'saveDraft', locale: 'en', }) @@ -1346,7 +1352,7 @@ describe('Trash', () => { _status: 'draft', localizedField: localizedFieldValueES, }, - draft: true, + action: 'saveDraft', locale: 'es', }) @@ -1399,6 +1405,7 @@ describe('Trash', () => { async function createPostDoc(data: RequiredDataFromCollectionSlug<'posts'>): Promise { return payload.create({ + action: 'publish', collection: postsSlug, data, }) as unknown as Promise @@ -1406,6 +1413,7 @@ async function createPostDoc(data: RequiredDataFromCollectionSlug<'posts'>): Pro async function createTrashedPostDoc(data: RequiredDataFromCollectionSlug<'posts'>): Promise { return payload.create({ + action: 'publish', collection: postsSlug, data: { ...data, diff --git a/test/trash/int.spec.ts b/test/trash/int.spec.ts index cceda93f33a..a9a64328173 100644 --- a/test/trash/int.spec.ts +++ b/test/trash/int.spec.ts @@ -37,6 +37,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('trash', () => { }) restrictedCollectionDoc = await payload.create({ + action: 'publish', collection: restrictedCollectionSlug as CollectionSlug, data: { title: 'With Access Control one', @@ -44,6 +45,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('trash', () => { }) postsDocOne = await payload.create({ + action: 'publish', collection: postsSlug, data: { title: 'Doc one', @@ -51,6 +53,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('trash', () => { }) postsDocTwo = await payload.create({ + action: 'publish', collection: postsSlug, data: { title: 'Doc two', @@ -162,6 +165,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('trash', () => { test('should allow regular user to trash (soft-delete) a document', async ({ payload }) => { // Create a document as admin const doc = await payload.create({ + action: 'publish', collection: differentiatedTrashCollectionSlug as CollectionSlug, data: { title: 'Regular user trash test' }, }) @@ -185,6 +189,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('trash', () => { test('should allow admin to trash (soft-delete) a document', async ({ payload }) => { // Create a document const doc = await payload.create({ + action: 'publish', collection: differentiatedTrashCollectionSlug as CollectionSlug, data: { title: 'Admin trash test' }, }) @@ -212,6 +217,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('trash', () => { }) => { // Create and trash a document const doc = await payload.create({ + action: 'publish', collection: differentiatedTrashCollectionSlug as CollectionSlug, data: { title: 'Regular user perm delete test', @@ -240,6 +246,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('trash', () => { test('should allow admin to permanently delete a trashed document', async ({ payload }) => { // Create and trash a document const doc = await payload.create({ + action: 'publish', collection: differentiatedTrashCollectionSlug as CollectionSlug, data: { title: 'Admin perm delete test', @@ -273,11 +280,13 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('trash', () => { test('should allow regular user to bulk trash documents', async ({ payload }) => { // Create multiple documents const doc1 = await payload.create({ + action: 'publish', collection: differentiatedTrashCollectionSlug as CollectionSlug, data: { title: 'Bulk trash test 1' }, }) const doc2 = await payload.create({ + action: 'publish', collection: differentiatedTrashCollectionSlug as CollectionSlug, data: { title: 'Bulk trash test 2' }, }) @@ -308,6 +317,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('trash', () => { }) => { // Create multiple trashed documents const doc1 = await payload.create({ + action: 'publish', collection: differentiatedTrashCollectionSlug as CollectionSlug, data: { title: 'Bulk perm delete test 1', @@ -316,6 +326,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('trash', () => { }) const doc2 = await payload.create({ + action: 'publish', collection: differentiatedTrashCollectionSlug as CollectionSlug, data: { title: 'Bulk perm delete test 2', @@ -363,6 +374,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('trash', () => { }) => { // Create multiple trashed documents const doc1 = await payload.create({ + action: 'publish', collection: differentiatedTrashCollectionSlug as CollectionSlug, data: { title: 'Admin bulk perm delete 1', @@ -371,6 +383,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('trash', () => { }) const doc2 = await payload.create({ + action: 'publish', collection: differentiatedTrashCollectionSlug as CollectionSlug, data: { title: 'Admin bulk perm delete 2', @@ -484,6 +497,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('trash', () => { }) => { // Add a duplicate title await payload.create({ + action: 'publish', collection: postsSlug, data: { title: 'Doc one' }, }) @@ -797,6 +811,36 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('trash', () => { }) test.describe('update operation', () => { + test('should restore a published trashed document as its first draft', async ({ + payload, + }) => { + const result = await payload.update({ + collection: postsSlug, + data: { + _status: 'draft', + deletedAt: null, + }, + trash: true, + where: { + id: { + equals: postsDocTwo.id, + }, + }, + }) + + expect(result.docs).toHaveLength(1) + + const restoredDraft = await payload.findByID({ + id: postsDocTwo.id, + collection: postsSlug, + trash: false, + version: 'latest', + }) + + expect(restoredDraft._status).toBe('draft') + expect(restoredDraft.deletedAt).toBeNull() + }) + test('should update only normal document when trash: false', async ({ payload }) => { const result = await payload.update({ collection: postsSlug, @@ -854,6 +898,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('trash', () => { payload, }) => { const docThree = await payload.create({ + action: 'publish', collection: postsSlug, data: { title: 'Doc three', @@ -944,7 +989,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('trash', () => { title: '', // Empty required field _status: 'draft', }, - draft: true, + action: 'saveDraft', }) expect(draftDoc.title).toBe('') @@ -957,6 +1002,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('trash', () => { data: { deletedAt: new Date().toISOString(), }, + action: 'saveDraft', }) expect(trashedDoc.deletedAt).toBeDefined() @@ -981,7 +1027,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('trash', () => { title: '', // Empty required field _status: 'draft', }, - draft: true, + action: 'saveDraft', }) // Trash it @@ -991,6 +1037,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('trash', () => { data: { deletedAt: new Date().toISOString(), }, + action: 'saveDraft', }) // Should be able to restore as draft without validation errors @@ -1026,7 +1073,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('trash', () => { title: '', // Empty required field _status: 'draft', }, - draft: true, + action: 'saveDraft', }) // Trash it @@ -1036,6 +1083,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('trash', () => { data: { deletedAt: new Date().toISOString(), }, + action: 'saveDraft', }) // Should NOT be able to restore as published - should fail validation @@ -1185,7 +1233,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('trash', () => { localizedField: localizedFieldValueEN, _status: 'draft', }, - draft: true, + action: 'saveDraft', }) await payload.update({ @@ -1196,7 +1244,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('trash', () => { localizedField: localizedFieldValueES, _status: 'draft', }, - draft: true, + action: 'saveDraft', }) // Bulk trash the document (simulates list view "Move to Trash") @@ -1206,6 +1254,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('trash', () => { data: { deletedAt: new Date().toISOString(), }, + action: 'saveDraft', where: { id: { equals: post.id, @@ -1221,7 +1270,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('trash', () => { collection: postsSlug, id: post.id, locale: 'en', - draft: true, + version: 'latest', trash: true, }) @@ -1229,7 +1278,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('trash', () => { collection: postsSlug, id: post.id, locale: 'es', - draft: true, + version: 'latest', trash: true, }) @@ -1484,6 +1533,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('trash', () => { const query = `?trash=true&where[deletedAt][exists]=true` const docThree = await payload.create({ + action: 'publish', collection: postsSlug, data: { title: 'Doc three', @@ -2288,6 +2338,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('trash', () => { }) => { // postsDocOne is non-trashed, postsDocTwo is trashed const page = await payload.create({ + action: 'publish', collection: pagesSlug, data: { title: 'Page with related posts', @@ -2313,6 +2364,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('trash', () => { payload, }) => { const page = await payload.create({ + action: 'publish', collection: pagesSlug, data: { title: 'Page with featured post', @@ -2332,6 +2384,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('trash', () => { test('should populate a non-trashed document in a single relationship', async ({ payload }) => { const page = await payload.create({ + action: 'publish', collection: pagesSlug, data: { title: 'Page with featured post', @@ -2352,6 +2405,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('trash', () => { test('should include trashed documents in relationship when depth=0', async ({ payload }) => { // At depth=0, relationships are returned as IDs - but trashed IDs should still be filtered const page = await payload.create({ + action: 'publish', collection: pagesSlug, data: { title: 'Page with related posts depth 0', diff --git a/test/trash/seed.ts b/test/trash/seed.ts index 15fc0b44cac..821bf092a2a 100644 --- a/test/trash/seed.ts +++ b/test/trash/seed.ts @@ -28,6 +28,7 @@ export const seed = async (payload: Payload) => { }) await payload.create({ + action: 'publish', collection: 'pages', depth: 0, select: {}, diff --git a/test/types/config.ts b/test/types/config.ts index 1217a0b9ff3..fa7d01c01e2 100644 --- a/test/types/config.ts +++ b/test/types/config.ts @@ -287,7 +287,6 @@ export default buildConfigWithDefaults({ typescript: { generateInputTypes: true, outputFile: path.resolve(dirname, 'payload-types.ts'), - strictDraftTypes: true, postProcess: [ ({ compiledTypes }) => { const genericType = `export type TestPluginGeneric = { value: T };` diff --git a/test/types/payload-types.ts b/test/types/payload-types.ts index 80b2337ff34..bf550208523 100644 --- a/test/types/payload-types.ts +++ b/test/types/payload-types.ts @@ -256,6 +256,8 @@ export interface Config { locale: null; widgets: { collections: CollectionsWidget; + 'collection-query': CollectionQueryWidget; + activity: ActivityWidget; }; collectionsInput: { posts: PostInput; @@ -276,7 +278,6 @@ export interface Config { menu: MenuInput; settings: SettingInput; }; - strictDraftTypes: true; user: FallbackUser | User; jobs: { tasks: unknown; @@ -867,6 +868,60 @@ export interface CollectionsWidget { }; width: 'full'; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "collection-query_widget". + */ +export interface CollectionQueryWidget { + data?: { + title?: string | null; + relatedCollection: + | 'posts' + | 'pages' + | 'pages-categories' + | 'draft-posts' + | 'media' + | 'gallery' + | 'fallback-users' + | 'input-types' + | 'users'; + where?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + sortField?: string | null; + sortDirection?: ('asc' | 'desc') | null; + limit?: number | null; + }; + width: 'x-small' | 'small' | 'medium' | 'large' | 'x-large' | 'full'; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "activity_widget". + */ +export interface ActivityWidget { + data?: { + excludedCollections?: + | ( + | 'posts' + | 'pages' + | 'pages-categories' + | 'draft-posts' + | 'media' + | 'gallery' + | 'fallback-users' + | 'input-types' + | 'users' + )[] + | null; + }; + width: 'x-small' | 'small' | 'medium' | 'large' | 'x-large' | 'full'; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "posts_input". diff --git a/test/types/types.spec.ts b/test/types/types.spec.ts index ca503bd301d..b9ac0daffc2 100644 --- a/test/types/types.spec.ts +++ b/test/types/types.spec.ts @@ -3,10 +3,18 @@ import type { useAuth } from '@payloadcms/ui' import type { AuthenticatedUser, BulkOperationResult, + CollectionAfterChangeHook, + CollectionAfterOperationHook, + CollectionAfterReadHook, + CollectionBeforeChangeHook, CollectionSlug, + CreateAction, CustomDocumentViewConfig, DefaultDocumentViewConfig, + FieldHook, GeneratedTypes, + GlobalAfterChangeHook, + GlobalBeforeChangeHook, Job, JobTaskStatus, JoinQuery, @@ -14,11 +22,14 @@ import type { PaginatedDocs, PayloadRequest, PayloadTypesShape, + RestoreAction, SelectType, TypedCollectionSelect, TypeWithVersion, UntypedPayloadTypes, + UpdateAction, Where, + WriteAction, } from 'payload' import { @@ -1302,6 +1313,365 @@ describe('Types testing', () => { }) expect(result).type.toBe>() }) + + describe('version and action types', () => { + test('should type required fields as optional for latest and draft reads', async () => { + const _sdk = new PayloadSDK({ baseURL: '' }) + const latest = await _sdk.find({ + collection: 'draft-posts', + version: 'latest', + }) + const draftOnly = await _sdk.find({ + collection: 'draft-posts', + version: 'draft', + }) + + expect(latest.docs[0]!.description).type.toBe() + expect(latest.docs[0]!.title).type.toBe() + expect(latest.docs[0]!.id).type.not.toBe() + + expect(draftOnly.docs[0]!.title).type.toBe() + }) + + test('should keep required fields required for published and omitted reads', async () => { + const _sdk = new PayloadSDK({ baseURL: '' }) + const omitted = await _sdk.find({ + collection: 'draft-posts', + }) + const published = await _sdk.find({ + collection: 'draft-posts', + version: 'published', + }) + + expect(omitted.docs[0]!.description).type.toBe() + expect(omitted.docs[0]!.title).type.toBe() + expect(published.docs[0]!.title).type.toBe() + }) + + test('should type latest and draft auth users with optional user fields', async () => { + type DraftAuthConfig = { + auth: { + 'draft-users': unknown + } + collections: { + 'draft-users': { + displayName: string + email: string + id: string + } + } + collectionsSelect: { + 'draft-users': Record + } + } & Omit + + const _sdk = new PayloadSDK({ baseURL: '' }) + const omitted = await _sdk.me({ collection: 'draft-users' }) + const published = await _sdk.me({ collection: 'draft-users', version: 'published' }) + const latest = await _sdk.me({ collection: 'draft-users', version: 'latest' }) + const draft = await _sdk.me({ collection: 'draft-users', version: 'draft' }) + + expect(omitted.user.email).type.toBe() + expect(published.user.email).type.toBe() + expect(latest.user.email).type.toBe() + expect(draft.user.displayName).type.toBe() + expect(draft.user.id).type.toBe() + }) + + test('should reject the old draft option', () => { + const _sdk = new PayloadSDK({ baseURL: '' }) + + expect(_sdk.find).type.not.toBeCallableWith({ collection: 'draft-posts', draft: true }) + expect(_sdk.findByID).type.not.toBeCallableWith({ + id: 1, + collection: 'draft-posts', + draft: true, + }) + expect(_sdk.create).type.not.toBeCallableWith({ + collection: 'draft-posts', + data: { + description: 'Description', + title: 'Test', + }, + draft: true, + }) + expect(_sdk.update).type.not.toBeCallableWith({ + id: 1, + collection: 'draft-posts', + data: { title: 'Test' }, + draft: true, + }) + expect(_sdk.restoreVersion).type.not.toBeCallableWith({ + id: 'id', + collection: 'draft-posts', + draft: true, + }) + expect(_sdk.delete).type.not.toBeCallableWith({ + id: 1, + collection: 'draft-posts', + draft: true, + }) + }) + + test('should allow partial create data with saveDraft regardless of status', () => { + const _sdk = new PayloadSDK({ baseURL: '' }) + + expect(_sdk.create).type.toBeCallableWith({ + action: 'saveDraft', + collection: 'draft-posts', + data: { + title: 'Test', + }, + }) + + expect(_sdk.create).type.toBeCallableWith({ + action: 'saveDraft', + collection: 'draft-posts', + data: { + _status: 'published', + title: 'Test', + }, + }) + }) + + test('should require all required create fields with publish regardless of status', () => { + const _sdk = new PayloadSDK({ baseURL: '' }) + + expect(_sdk.create).type.not.toBeCallableWith({ + action: 'publish', + collection: 'draft-posts', + data: { + title: 'Test', + }, + }) + + expect(_sdk.create).type.toBeCallableWith({ + action: 'publish', + collection: 'draft-posts', + data: { + description: 'Description', + title: 'Test', + }, + }) + }) + + test('should require all required create fields when action is omitted and status is published', () => { + const _sdk = new PayloadSDK({ baseURL: '' }) + + expect(_sdk.create).type.not.toBeCallableWith({ + collection: 'draft-posts', + data: { + _status: 'published', + title: 'Test', + }, + }) + + expect(_sdk.create).type.toBeCallableWith({ + collection: 'draft-posts', + data: { + _status: 'published', + description: 'Description', + title: 'Test', + }, + }) + }) + + test('should allow partial create data when action is omitted and status is draft-like', () => { + const _sdk = new PayloadSDK({ baseURL: '' }) + + expect(_sdk.create).type.toBeCallableWith({ + collection: 'draft-posts', + data: { + title: 'Test', + }, + }) + + expect(_sdk.create).type.toBeCallableWith({ + collection: 'draft-posts', + data: { + _status: 'draft', + title: 'Test', + }, + }) + + expect(_sdk.create).type.toBeCallableWith({ + collection: 'draft-posts', + data: { + _status: null, + title: 'Test', + }, + }) + }) + + test('should allow a complete fetched document when create action is omitted', () => { + const _sdk = new PayloadSDK({ baseURL: '' }) + const fetchedDoc = {} as DraftPost + + expect(_sdk.create).type.toBeCallableWith({ + collection: 'draft-posts', + data: fetchedDoc, + }) + }) + + test('should still accept _status in create data', () => { + const _sdk = new PayloadSDK({ baseURL: '' }) + + expect(_sdk.create).type.toBeCallableWith({ + action: 'publish', + collection: 'draft-posts', + data: { + _status: 'published', + description: 'Description', + title: 'Test', + }, + }) + }) + + test('should forbid draft-only create actions on non-draft collections', () => { + const _sdk = new PayloadSDK({ baseURL: '' }) + + expect(_sdk.create).type.not.toBeCallableWith({ + action: 'saveDraft', + collection: 'pages', + data: { + title: 'Test', + }, + }) + + expect(_sdk.create).type.toBeCallableWith({ + collection: 'pages', + data: { + title: 'Test', + }, + }) + + expect(_sdk.create).type.toBeCallableWith({ + action: 'publish', + collection: 'pages', + data: { + title: 'Test', + }, + }) + }) + + test('should reject version in find on non-draft collections', () => { + const _sdk = new PayloadSDK({ baseURL: '' }) + + expect(_sdk.find).type.not.toBeCallableWith({ collection: 'pages', version: 'latest' }) + expect(_sdk.find).type.toBeCallableWith({ collection: 'pages' }) + expect(_sdk.find).type.toBeCallableWith({ collection: 'draft-posts', version: 'latest' }) + expect(_sdk.find).type.toBeCallableWith({ + collection: 'draft-posts', + version: 'published', + }) + expect(_sdk.find).type.toBeCallableWith({ collection: 'draft-posts', version: 'draft' }) + }) + + test('should reject draft-only update actions on non-draft collections', () => { + const _sdk = new PayloadSDK({ baseURL: '' }) + + expect(_sdk.update).type.not.toBeCallableWith({ + id: 1, + action: 'saveDraft', + collection: 'pages', + data: { title: 'Test' }, + }) + expect(_sdk.update).type.not.toBeCallableWith({ + id: 1, + action: 'unpublish', + collection: 'pages', + data: { title: 'Test' }, + }) + expect(_sdk.update).type.toBeCallableWith({ + id: 1, + action: 'saveDraft', + collection: 'draft-posts', + data: { title: 'Test' }, + }) + expect(_sdk.update).type.toBeCallableWith({ + id: 1, + action: 'unpublish', + collection: 'draft-posts', + data: { title: 'Test' }, + }) + }) + + test('should allow saveDraft and publish but reject unpublish for restoreVersion', () => { + const _sdk = new PayloadSDK({ baseURL: '' }) + + expect(_sdk.restoreVersion).type.toBeCallableWith({ + id: 'id', + collection: 'draft-posts', + }) + expect(_sdk.restoreVersion).type.toBeCallableWith({ + id: 'id', + action: 'publish', + collection: 'draft-posts', + }) + expect(_sdk.restoreVersion).type.toBeCallableWith({ + id: 'id', + action: 'saveDraft', + collection: 'draft-posts', + }) + expect(_sdk.restoreVersion).type.not.toBeCallableWith({ + id: 'id', + action: 'unpublish', + collection: 'draft-posts', + }) + expect(_sdk.restoreVersion).type.not.toBeCallableWith({ + id: 'id', + action: 'saveDraft', + collection: 'pages', + }) + }) + + test('should reject version in global findOne on non-draft globals', () => { + const _sdk = new PayloadSDK({ baseURL: '' }) + + expect(_sdk.findGlobal).type.not.toBeCallableWith({ slug: 'menu', version: 'latest' }) + expect(_sdk.findGlobal).type.toBeCallableWith({ slug: 'menu' }) + expect(_sdk.findGlobal).type.toBeCallableWith({ slug: 'settings', version: 'latest' }) + }) + + test('should reject draft-only global update actions on non-draft globals', () => { + const _sdk = new PayloadSDK({ baseURL: '' }) + + expect(_sdk.updateGlobal).type.not.toBeCallableWith({ + slug: 'menu', + action: 'saveDraft', + data: {}, + }) + expect(_sdk.updateGlobal).type.toBeCallableWith({ + slug: 'settings', + action: 'saveDraft', + data: {}, + }) + }) + + test('should allow saveDraft and publish but reject unpublish for restoreGlobalVersion', () => { + const _sdk = new PayloadSDK({ baseURL: '' }) + + expect(_sdk.restoreGlobalVersion).type.toBeCallableWith({ + id: 'id', + slug: 'settings', + }) + expect(_sdk.restoreGlobalVersion).type.toBeCallableWith({ + id: 'id', + slug: 'settings', + action: 'saveDraft', + }) + expect(_sdk.restoreGlobalVersion).type.not.toBeCallableWith({ + id: 'id', + slug: 'settings', + action: 'unpublish', + }) + expect(_sdk.restoreGlobalVersion).type.not.toBeCallableWith({ + id: 'id', + slug: 'menu', + action: 'saveDraft', + }) + }) + }) }) describe('richText enforcement in local API and SDK', () => { @@ -1495,102 +1865,166 @@ describe('Types testing', () => { }) }) - describe('strictDraftTypes flag', () => { + describe('version and action types', () => { describe('query operations', () => { - test('draft find query returns optional required fields when flag is enabled', async () => { - const result = await payload.find({ + test('should type required fields as optional for latest and draft reads', async () => { + const latest = await payload.find({ collection: 'draft-posts', - draft: true, + version: 'latest', + }) + const draftOnly = await payload.find({ + collection: 'draft-posts', + version: 'draft', }) - const doc = result.docs[0]! - - // With strictDraftTypes enabled, user-defined required fields should be optional in draft queries - expect(doc.description).type.toBe() - expect(doc.title).type.toBe() + expect(latest.docs[0]!.description).type.toBe() + expect(latest.docs[0]!.title).type.toBe() + expect(latest.docs[0]!.id).type.not.toBe() + expect(latest.docs[0]!.createdAt).type.toBe() + expect(latest.docs[0]!.updatedAt).type.toBe() - // Only id is required in draft queries - other system fields are also optional - expect(doc.id).type.not.toBe() - expect(doc.createdAt).type.toBe() - expect(doc.updatedAt).type.toBe() + expect(draftOnly.docs[0]!.title).type.toBe() }) - test('non-draft find query returns required fields as required', async () => { - const result = await payload.find({ + test('should keep required fields required for published and omitted reads', async () => { + const omitted = await payload.find({ collection: 'draft-posts', }) + const published = await payload.find({ + collection: 'draft-posts', + version: 'published', + }) - const doc = result.docs[0]! + expect(omitted.docs[0]!.description).type.toBe() + expect(omitted.docs[0]!.title).type.toBe() + expect(omitted.docs[0]!.id).type.not.toBe() + expect(omitted.docs[0]!.createdAt).type.toBe() + expect(omitted.docs[0]!.updatedAt).type.toBe() - // Without draft mode, required fields should remain required - expect(doc.description).type.toBe() - expect(doc.title).type.toBe() + expect(published.docs[0]!.title).type.toBe() + }) - // System fields should also be present and required (not undefined) - expect(doc.id).type.not.toBe() - expect(doc.createdAt).type.toBe() - expect(doc.updatedAt).type.toBe() + test('should reject the old draft read option', () => { + expect(payload.find).type.not.toBeCallableWith({ collection: 'draft-posts', draft: true }) + expect(payload.findByID).type.not.toBeCallableWith({ + collection: 'draft-posts', + id: 1, + draft: true, + }) }) }) describe('create operations', () => { - test('create with draft:true on draft-enabled collection allows partial data', () => { + test('should allow partial create data with saveDraft regardless of status', () => { expect(payload.create).type.toBeCallableWith({ collection: 'draft-posts', + action: 'saveDraft', data: { - title: 'Test', // Only one required field + title: 'Test', + }, + }) + + expect(payload.create).type.toBeCallableWith({ + collection: 'draft-posts', + action: 'saveDraft', + data: { + _status: 'published', + title: 'Test', }, - draft: true, }) }) - test('create with draft:false on draft-enabled collection requires all required fields', () => { - // Missing description - should error + test('should require all required create fields with publish regardless of status', () => { expect(payload.create).type.not.toBeCallableWith({ collection: 'draft-posts', + action: 'publish', data: { title: 'Test', }, - draft: false, }) - // All required fields present - should not error expect(payload.create).type.toBeCallableWith({ collection: 'draft-posts', + action: 'publish', data: { title: 'Test', description: 'Description', }, - draft: false, }) }) - test('create without draft property on draft-enabled collection requires all required fields', () => { - // Missing description - should error + test('should require all required create fields when action is omitted and status is published', () => { expect(payload.create).type.not.toBeCallableWith({ collection: 'draft-posts', data: { + _status: 'published', + title: 'Test', + }, + }) + + expect(payload.create).type.toBeCallableWith({ + collection: 'draft-posts', + data: { + _status: 'published', + title: 'Test', + description: 'Description', + }, + }) + }) + + test('should allow partial create data when action is omitted and status is draft-like', () => { + expect(payload.create).type.toBeCallableWith({ + collection: 'draft-posts', + data: { + title: 'Test', + }, + }) + + expect(payload.create).type.toBeCallableWith({ + collection: 'draft-posts', + data: { + _status: 'draft', + title: 'Test', + }, + }) + + expect(payload.create).type.toBeCallableWith({ + collection: 'draft-posts', + data: { + _status: null, title: 'Test', }, }) + }) + + test('should allow a complete fetched document when create action is omitted', () => { + const fetchedDoc = {} as DraftPost - // All required fields present - should not error expect(payload.create).type.toBeCallableWith({ collection: 'draft-posts', + data: fetchedDoc, + }) + }) + + test('should still accept _status in create data', () => { + expect(payload.create).type.toBeCallableWith({ + collection: 'draft-posts', + action: 'publish', data: { + _status: 'published', title: 'Test', description: 'Description', }, }) }) - test('create on non-draft collection forbids draft property', () => { + test('should forbid version and draft-only create actions on non-draft collections', () => { expect(payload.create).type.not.toBeCallableWith({ collection: 'pages', + action: 'saveDraft', data: { title: 'Test', }, - draft: true, }) expect(payload.create).type.not.toBeCallableWith({ @@ -1598,40 +2032,58 @@ describe('Types testing', () => { data: { title: 'Test', }, - draft: false, + version: 'latest', }) - // Without draft property - should not error expect(payload.create).type.toBeCallableWith({ collection: 'pages', data: { title: 'Test', }, }) + + expect(payload.create).type.toBeCallableWith({ + collection: 'pages', + action: 'publish', + data: { + title: 'Test', + }, + }) + }) + + test('should reject the old draft write option', () => { + expect(payload.create).type.not.toBeCallableWith({ + collection: 'draft-posts', + data: { + title: 'Test', + description: 'Description', + }, + draft: true, + }) }) - test('create with invalid property should error regardless of draft mode', () => { + test('should reject invalid create properties regardless of action', () => { expect(payload.create).type.not.toBeCallableWith({ collection: 'draft-posts', + action: 'publish', data: { title: 'Test', description: 'Description', invalidProperty: 'should error', }, - draft: false, }) expect(payload.create).type.not.toBeCallableWith({ collection: 'draft-posts', + action: 'saveDraft', data: { title: 'Test', invalidProperty: 'should error', }, - draft: true, }) }) - test('create on pages (non-draft) collection with all fields should work', () => { + test('should create pages with all fields', () => { expect(payload.create).type.toBeCallableWith({ collection: 'pages', data: { @@ -1640,142 +2092,308 @@ describe('Types testing', () => { }) }) - test('create on pages (non-draft) with missing optional fields should work', () => { + test('should create pages without optional fields', () => { expect(payload.create).type.toBeCallableWith({ collection: 'pages', data: { title: 'Page Title', - // category is optional relationship, can be omitted }, }) }) + }) - // Additional operations tests - test('find with draft:true on non-draft collection should error', () => { - expect(payload.find).type.not.toBeCallableWith({ collection: 'pages', draft: true }) - }) - - test('find with draft:false on non-draft collection should error', () => { - expect(payload.find).type.not.toBeCallableWith({ collection: 'pages', draft: false }) - }) - - test('find with draft:true on draft-enabled collection should work', () => { - expect(payload.find).type.toBeCallableWith({ collection: 'draft-posts', draft: true }) - }) - - test('find with draft:false on draft-enabled collection should work', () => { - expect(payload.find).type.toBeCallableWith({ collection: 'draft-posts', draft: false }) - }) - - test('findByID with draft:true on non-draft collection should error', () => { - expect(payload.findByID).type.not.toBeCallableWith({ + describe('entity-aware version and action options', () => { + test('should reject version in find on non-draft collections', () => { + expect(payload.find).type.not.toBeCallableWith({ collection: 'pages', version: 'latest' }) + expect(payload.find).type.not.toBeCallableWith({ collection: 'pages', - id: 1, - draft: true, + version: 'published', }) + expect(payload.find).type.toBeCallableWith({ collection: 'pages' }) + expect(payload.find).type.toBeCallableWith({ collection: 'draft-posts', version: 'latest' }) + expect(payload.find).type.toBeCallableWith({ + collection: 'draft-posts', + version: 'published', + }) + expect(payload.find).type.toBeCallableWith({ collection: 'draft-posts', version: 'draft' }) }) - test('findByID with draft:false on non-draft collection should error', () => { + test('should reject version in findByID on non-draft collections', () => { expect(payload.findByID).type.not.toBeCallableWith({ collection: 'pages', id: 1, - draft: false, + version: 'latest', }) - }) - - test('findByID with draft:true on draft-enabled collection should work', () => { expect(payload.findByID).type.toBeCallableWith({ collection: 'draft-posts', id: 1, - draft: true, + version: 'draft', }) }) - test('update with draft:true on non-draft collection should error', () => { + test('should reject draft-only update actions on non-draft collections', () => { expect(payload.update).type.not.toBeCallableWith({ collection: 'pages', id: 1, data: { title: 'Test' }, - draft: true, + action: 'saveDraft', }) - }) - - test('update with draft:false on non-draft collection should error', () => { expect(payload.update).type.not.toBeCallableWith({ collection: 'pages', id: 1, data: { title: 'Test' }, - draft: false, + action: 'unpublish', }) - }) - - test('update with draft:true on draft-enabled collection should work', () => { expect(payload.update).type.toBeCallableWith({ collection: 'draft-posts', id: 1, data: { title: 'Test' }, - draft: true, + action: 'saveDraft', + }) + expect(payload.update).type.toBeCallableWith({ + collection: 'draft-posts', + id: 1, + data: { title: 'Test' }, + action: 'unpublish', }) }) - test('duplicate with draft:true on non-draft collection should error', () => { + test('should reject draft-only duplicate actions on non-draft collections', () => { expect(payload.duplicate).type.not.toBeCallableWith({ collection: 'pages', id: 1, - draft: true, + action: 'saveDraft', }) - }) - - test('duplicate with draft:false on non-draft collection should error', () => { - expect(payload.duplicate).type.not.toBeCallableWith({ + expect(payload.duplicate).type.toBeCallableWith({ collection: 'pages', id: 1, - draft: false, }) - }) - - test('duplicate with draft:true on draft-enabled collection should work', () => { expect(payload.duplicate).type.toBeCallableWith({ collection: 'draft-posts', id: 1, - draft: true, + action: 'saveDraft', }) }) - test('global findOne with draft:true on non-draft global should error', () => { - expect(payload.findGlobal).type.not.toBeCallableWith({ slug: 'menu', draft: true }) + test('should reject version in global findOne on non-draft globals', () => { + expect(payload.findGlobal).type.not.toBeCallableWith({ slug: 'menu', version: 'latest' }) + expect(payload.findGlobal).type.toBeCallableWith({ slug: 'menu' }) + expect(payload.findGlobal).type.toBeCallableWith({ slug: 'settings', version: 'latest' }) }) - test('global findOne with draft:false on non-draft global should error', () => { - expect(payload.findGlobal).type.not.toBeCallableWith({ slug: 'menu', draft: false }) + test('should reject draft-only global update actions on non-draft globals', () => { + expect(payload.updateGlobal).type.not.toBeCallableWith({ + slug: 'menu', + data: {}, + action: 'saveDraft', + }) + expect(payload.updateGlobal).type.not.toBeCallableWith({ + slug: 'menu', + data: {}, + action: 'unpublish', + }) + expect(payload.updateGlobal).type.toBeCallableWith({ + slug: 'settings', + data: {}, + action: 'saveDraft', + }) }) - test('global findOne with draft:true on draft-enabled global should work', () => { - expect(payload.findGlobal).type.toBeCallableWith({ slug: 'settings', draft: true }) + test('should allow saveDraft and publish but reject unpublish for restoreVersion', () => { + expect(payload.restoreVersion).type.toBeCallableWith({ + collection: 'draft-posts', + id: 'id', + }) + expect(payload.restoreVersion).type.toBeCallableWith({ + collection: 'draft-posts', + id: 'id', + action: 'publish', + }) + expect(payload.restoreVersion).type.toBeCallableWith({ + collection: 'draft-posts', + id: 'id', + action: 'saveDraft', + }) + expect(payload.restoreVersion).type.not.toBeCallableWith({ + collection: 'draft-posts', + id: 'id', + action: 'unpublish', + }) + expect(payload.restoreVersion).type.not.toBeCallableWith({ + collection: 'draft-posts', + id: 'id', + draft: true, + }) + expect(payload.restoreVersion).type.not.toBeCallableWith({ + collection: 'pages', + id: 'id', + action: 'saveDraft', + }) + expect(payload.restoreVersion).type.toBeCallableWith({ + collection: 'pages', + id: 'id', + action: 'publish', + }) }) - test('global update with draft:true on non-draft global should error', () => { - expect(payload.updateGlobal).type.not.toBeCallableWith({ + test('should allow saveDraft and publish but reject unpublish for restoreGlobalVersion', () => { + expect(payload.restoreGlobalVersion).type.toBeCallableWith({ + slug: 'settings', + id: 'id', + }) + expect(payload.restoreGlobalVersion).type.toBeCallableWith({ + slug: 'settings', + id: 'id', + action: 'publish', + }) + expect(payload.restoreGlobalVersion).type.toBeCallableWith({ + slug: 'settings', + id: 'id', + action: 'saveDraft', + }) + expect(payload.restoreGlobalVersion).type.not.toBeCallableWith({ + slug: 'settings', + id: 'id', + action: 'unpublish', + }) + expect(payload.restoreGlobalVersion).type.not.toBeCallableWith({ + slug: 'settings', + id: 'id', + draft: true, + }) + expect(payload.restoreGlobalVersion).type.not.toBeCallableWith({ slug: 'menu', - data: {}, + id: 'id', + action: 'saveDraft', + }) + expect(payload.restoreGlobalVersion).type.toBeCallableWith({ + slug: 'menu', + id: 'id', + action: 'publish', + }) + }) + + test('should reject draft, action, and version for delete', () => { + expect(payload.delete).type.not.toBeCallableWith({ + collection: 'draft-posts', + id: 1, + draft: true, + }) + expect(payload.delete).type.not.toBeCallableWith({ + collection: 'draft-posts', + id: 1, + action: 'saveDraft', + }) + expect(payload.delete).type.not.toBeCallableWith({ + collection: 'draft-posts', + id: 1, + version: 'latest', + }) + expect(payload.delete).type.not.toBeCallableWith({ + collection: 'draft-posts', + where: {}, draft: true, }) + expect(payload.delete).type.not.toBeCallableWith({ + collection: 'draft-posts', + where: {}, + action: 'publish', + }) + expect(payload.delete).type.not.toBeCallableWith({ + collection: 'draft-posts', + where: {}, + version: 'draft', + }) }) - test('global update with draft:false on non-draft global should error', () => { - expect(payload.updateGlobal).type.not.toBeCallableWith({ - slug: 'menu', - data: {}, - draft: false, + test('should reject draft, action, and version for findVersions', () => { + expect(payload.findVersions).type.not.toBeCallableWith({ + collection: 'draft-posts', + draft: true, + }) + expect(payload.findVersions).type.not.toBeCallableWith({ + collection: 'draft-posts', + action: 'saveDraft', + }) + expect(payload.findVersions).type.not.toBeCallableWith({ + collection: 'draft-posts', + version: 'latest', }) }) - test('global update with draft:true on draft-enabled global should work', () => { - expect(payload.updateGlobal).type.toBeCallableWith({ + test('should reject draft, action, and version for findVersionByID', () => { + expect(payload.findVersionByID).type.not.toBeCallableWith({ + collection: 'draft-posts', + id: 'id', + draft: true, + }) + expect(payload.findVersionByID).type.not.toBeCallableWith({ + collection: 'draft-posts', + id: 'id', + action: 'publish', + }) + expect(payload.findVersionByID).type.not.toBeCallableWith({ + collection: 'draft-posts', + id: 'id', + version: 'published', + }) + }) + + test('should reject draft, action, and version for findGlobalVersions', () => { + expect(payload.findGlobalVersions).type.not.toBeCallableWith({ slug: 'settings', - data: {}, draft: true, }) + expect(payload.findGlobalVersions).type.not.toBeCallableWith({ + slug: 'settings', + action: 'saveDraft', + }) + expect(payload.findGlobalVersions).type.not.toBeCallableWith({ + slug: 'settings', + version: 'latest', + }) + }) + + test('should reject draft, action, and version for findGlobalVersionByID', () => { + expect(payload.findGlobalVersionByID).type.not.toBeCallableWith({ + slug: 'settings', + id: 'id', + draft: true, + }) + expect(payload.findGlobalVersionByID).type.not.toBeCallableWith({ + slug: 'settings', + id: 'id', + action: 'unpublish', + }) + expect(payload.findGlobalVersionByID).type.not.toBeCallableWith({ + slug: 'settings', + id: 'id', + version: 'draft', + }) + }) + + test('should expose operation-appropriate actions to afterChange hooks', () => { + type AfterChangeArgs = Parameters[0] + type CreateAfterChangeAction = Extract['action'] + type UpdateAfterChangeAction = Extract['action'] + + expect().type.toBe() + expect().type.not.toBeAssignableTo<'unpublish'>() + expect<'unpublish'>().type.not.toBeAssignableTo() + expect().type.toBe() + expect().type.toBe<'publish' | 'saveDraft'>() + expect<'unpublish'>().type.not.toBeAssignableTo() + expect[0]['action']>().type.toBe< + RestoreAction | undefined | UpdateAction + >() + expect[0]['action']>().type.toBe() + }) + + test('should not add action to non-afterChange hooks', () => { + expect[0]>().type.not.toHaveProperty('action') + expect[0]>().type.not.toHaveProperty('action') + expect[0]>().type.not.toHaveProperty('action') + expect[0]>().type.not.toHaveProperty('action') }) }) }) diff --git a/test/uploads/seed.ts b/test/uploads/seed.ts index 610ed250e7e..1c7d7bb6944 100644 --- a/test/uploads/seed.ts +++ b/test/uploads/seed.ts @@ -47,6 +47,7 @@ export const seed = async (payload: Payload) => { await payload.create({ collection: mediaWithoutDeleteAccessSlug, data: {}, file: imageFile }) const { id: versionedImage } = await payload.create({ + action: 'publish', collection: versionSlug, data: { _status: 'published', @@ -56,6 +57,7 @@ export const seed = async (payload: Payload) => { }) await payload.create({ + action: 'publish', collection: relationSlug, data: { image: uploadedImage, @@ -74,6 +76,7 @@ export const seed = async (payload: Payload) => { }) await payload.create({ + action: 'publish', collection: versionSlug, data: { _status: 'published', @@ -92,6 +95,7 @@ export const seed = async (payload: Payload) => { }) await payload.create({ + action: 'publish', collection: versionSlug, data: { _status: 'published', diff --git a/test/v4/baseConfig.ts b/test/v4/baseConfig.ts index c54341d78c3..b2a51c6b65d 100644 --- a/test/v4/baseConfig.ts +++ b/test/v4/baseConfig.ts @@ -562,7 +562,7 @@ export const seed: NonNullable = async (payload) => { content: 'Initial content', title: 'Document With Many Versions', }, - draft: true, + action: 'saveDraft', }) for (let i = 0; i < 20; i++) { @@ -759,7 +759,7 @@ export const seed: NonNullable = async (payload) => { title: 'Designing Database Indexes for Search', track: 'backend', }, - draft: true, + action: 'saveDraft', }) // Seed drawers collection: a couple of docs linked via the diff --git a/test/versions/e2e.spec.ts b/test/versions/e2e.spec.ts index 6708f5566c0..b3a1f3288fa 100644 --- a/test/versions/e2e.spec.ts +++ b/test/versions/e2e.spec.ts @@ -157,6 +157,7 @@ describe('Versions', () => { test('collection — should show "has published version" status in list view when draft is saved after publish', async () => { // Create a published document const publishedDoc = await payload.create({ + action: 'publish', collection: draftCollectionSlug, data: { _status: 'published', @@ -332,6 +333,7 @@ describe('Versions', () => { test('should show currently published version status in versions view', async () => { const publishedDoc = await payload.create({ + action: 'publish', collection: draftCollectionSlug, data: { _status: 'published', @@ -345,8 +347,9 @@ describe('Versions', () => { await expect(page.locator('main.versions')).toContainText('Currently Published') }) - test('should show unpublished version status in versions view', async () => { + test('should show the current draft status after unpublishing', async () => { const publishedDoc = await payload.create({ + action: 'publish', collection: draftCollectionSlug, data: { _status: 'published', @@ -359,15 +362,15 @@ describe('Versions', () => { // Unpublish the document await payload.update({ id: publishedDoc.id, + action: 'unpublish', collection: draftCollectionSlug, data: { _status: 'draft', }, - draft: false, }) await page.goto(`${url.edit(publishedDoc.id)}/versions`) - await expect(page.locator('main.versions')).toContainText('Previously Published') + await expect(page.locator('main.versions')).toContainText('Current Draft') }) test('should show global versions view level action in globals versions view', async () => { @@ -510,6 +513,7 @@ describe('Versions', () => { }) const { id: docID } = await payload.create({ + action: 'publish', collection: autosaveCollectionSlug, data: { description: 'autosave description', @@ -529,7 +533,7 @@ describe('Versions', () => { // Important: assert that depth is 0 in this request formatAdminURL({ apiRoute: '/api', - path: `/autosave-posts/${docID}?autosave=true&depth=0&draft=true&fallback-locale=null&locale=en`, + path: `/autosave-posts/${docID}?action=saveDraft&autosave=true&depth=0&fallback-locale=null&locale=en`, serverURL, }), async () => { @@ -560,7 +564,7 @@ describe('Versions', () => { // This test checks that when we click "Create new" in the list view, it only creates 1 extra document and not more const { totalDocs: initialDocsCount } = await payload.find({ collection: autosaveCollectionSlug, - draft: true, + version: 'latest', }) await page.goto(autosaveURL.create) @@ -570,7 +574,7 @@ describe('Versions', () => { const { totalDocs: updatedDocsCount } = await payload.find({ collection: autosaveCollectionSlug, - draft: true, + version: 'latest', }) await expect(() => { @@ -588,7 +592,7 @@ describe('Versions', () => { const { totalDocs: latestDocsCount } = await payload.find({ collection: autosaveCollectionSlug, - draft: true, + version: 'latest', }) await expect(() => { @@ -759,6 +763,7 @@ describe('Versions', () => { test('collections — should hide publish button when access control prevents update', async () => { const publishedDoc = await payload.create({ + action: 'publish', collection: disablePublishSlug, data: { _status: 'published', @@ -794,6 +799,7 @@ describe('Versions', () => { test('collections — should hide unpublish button when access control prevents update', async () => { const publishedDoc = await payload.create({ + action: 'publish', collection: disablePublishSlug, data: { _status: 'published', @@ -811,6 +817,7 @@ describe('Versions', () => { test('collections — should show custom error message when unpublishing fails', async () => { const publishedDoc = await payload.create({ + action: 'publish', collection: errorOnUnpublishSlug, data: { _status: 'published', @@ -828,6 +835,7 @@ describe('Versions', () => { test('collections — should render custom unpublish button', async () => { const publishedDoc = await payload.create({ + action: 'publish', collection: draftWithCustomUnpublishSlug, data: { _status: 'published', @@ -869,6 +877,7 @@ describe('Versions', () => { test('collections — should not increment version count when unpublishing', async () => { const publishedDoc = await payload.create({ + action: 'publish', collection: draftCollectionSlug, data: { _status: 'published', @@ -898,12 +907,12 @@ describe('Versions', () => { test('should show documents title in relationship even if draft document', async () => { await payload.create({ + action: 'saveDraft', collection: autosaveCollectionSlug, data: { description: 'some description', title: 'some title', }, - draft: true, }) await page.goto(postURL.create) @@ -923,12 +932,12 @@ describe('Versions', () => { test('correctly increments version count', async () => { const createdDoc = await payload.create({ + action: 'saveDraft', collection: draftCollectionSlug, data: { description: 'some description', title: 'some title', }, - draft: true, }) await page.goto(url.edit(createdDoc.id)) @@ -969,12 +978,12 @@ describe('Versions', () => { test('collection — respects max number of versions', async () => { const maxOneCollection = await payload.create({ + action: 'saveDraft', collection: draftWithMaxCollectionSlug, data: { description: 'some description', title: 'initial title', }, - draft: true, }) const collection = new AdminUrlUtil(serverURL, draftWithMaxCollectionSlug) @@ -1148,6 +1157,7 @@ describe('Versions', () => { test('should keep published status after reuploading a file and saving as draft', async () => { const publishedDoc = await payload.create({ + action: 'publish', collection: draftWithUploadCollectionSlug, data: { _status: 'published', @@ -1185,6 +1195,7 @@ describe('Versions', () => { test('should create a draft version with the new file without altering the published doc', async () => { const publishedDoc = await payload.create({ + action: 'publish', collection: draftWithUploadCollectionSlug, data: { _status: 'published', @@ -1209,7 +1220,7 @@ describe('Versions', () => { await expect(async () => { const { docs: draftDocs } = await payload.find({ collection: draftWithUploadCollectionSlug, - draft: true, + version: 'latest', where: { id: { equals: publishedDoc.id } }, }) expect(draftDocs[0]!._status).toStrictEqual('draft') @@ -1225,6 +1236,7 @@ describe('Versions', () => { test('should create a draft when duplicating a published upload document', async () => { const publishedDoc = await payload.create({ + action: 'publish', collection: draftWithUploadCollectionSlug, data: { _status: 'published', @@ -1251,7 +1263,7 @@ describe('Versions', () => { await expect(async () => { const { docs: draftDocs } = await payload.find({ collection: draftWithUploadCollectionSlug, - draft: true, + version: 'latest', where: { id: { equals: duplicatedDocID } }, }) expect(draftDocs[0]!._status).toStrictEqual('draft') @@ -1260,7 +1272,7 @@ describe('Versions', () => { collection: draftWithUploadCollectionSlug, where: { id: { equals: duplicatedDocID } }, }) - expect(mainDocs[0]!._status).toStrictEqual('draft') + expect(mainDocs).toHaveLength(0) }).toPass({ timeout: POLL_TOPASS_TIMEOUT }) }) }) @@ -1796,6 +1808,7 @@ describe('Versions', () => { // Step 2: Add a block via API (simpler and more reliable than UI interaction) await payload.update({ id, + action: 'saveDraft', collection: localizedCollectionSlug, data: { blocks: [ @@ -1805,13 +1818,13 @@ describe('Versions', () => { }, ], }, - draft: true, locale: 'en', }) // Step 3: Publish specific locale (English) via API const published = await payload.update({ id, + action: 'publish', collection: localizedCollectionSlug, data: { _status: 'published', @@ -1823,7 +1836,6 @@ describe('Versions', () => { ], text: 'english text', }, - draft: false, locale: 'en', }) @@ -2261,6 +2273,7 @@ describe('Versions', () => { beforeEach(async () => { const newPost = await payload.create({ + action: 'publish', collection: draftCollectionSlug, data: { description: 'new description', @@ -2273,6 +2286,7 @@ describe('Versions', () => { await payload.update({ id: postID, + action: 'saveDraft', collection: draftCollectionSlug, data: { blocksField: [ @@ -2286,7 +2300,6 @@ describe('Versions', () => { title: 'current draft post title', }, depth: 0, - draft: true, }) const versions = await payload.findVersions({ @@ -2728,6 +2741,7 @@ describe('Versions', () => { depth: 0, limit: 3, sort: 'createdAt', + version: 'latest', }) await expect( @@ -3131,6 +3145,7 @@ describe('Versions', () => { test('correctly renders text fields containing HTML special characters', async () => { // Create a document with HTML special characters in a text field const doc = await payload.create({ + action: 'publish', collection: diffCollectionSlug, data: { _status: 'published', @@ -3181,6 +3196,7 @@ describe('Versions', () => { test('correctly renders JSON fields containing HTML special characters', async () => { // Create a document with HTML special characters in a JSON field const doc = await payload.create({ + action: 'publish', collection: diffCollectionSlug, data: { _status: 'published', diff --git a/test/versions/helpers.ts b/test/versions/helpers.ts index 08657796f12..5b7f7851a74 100644 --- a/test/versions/helpers.ts +++ b/test/versions/helpers.ts @@ -62,7 +62,7 @@ export async function createDraftDocument({ ...additionalData, }, depth: 0, - draft: true, + action: 'saveDraft', overrideAccess: true, }) } @@ -101,7 +101,7 @@ export async function createDocumentWithManyVersions({ collection, data: initialData, depth: 0, - draft, + action: draft ? 'saveDraft' : 'publish', overrideAccess: true, }) diff --git a/test/versions/int.spec.ts b/test/versions/int.spec.ts index 08a7e57b513..3b9c2f71425 100644 --- a/test/versions/int.spec.ts +++ b/test/versions/int.spec.ts @@ -70,6 +70,196 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = }) }) + test.describe('Version and action API surfaces', () => { + test('allows an unsaved versioned global to be initialized through a latest read', async ({ + payload, + }) => { + await expect( + payload.findGlobal({ + slug: draftGlobalSlug, + }), + ).rejects.toThrow('Not Found') + + const global = await payload.findGlobal({ + slug: draftGlobalSlug, + version: 'latest', + }) + + expect(global).toBeDefined() + expect(global._status).toBe('draft') + }) + + test('resolves local actions, _status, and read versions consistently', async ({ payload }) => { + const doc = await payload.create({ + collection: draftCollectionSlug, + data: { title: 'Local action API' }, + }) + + expect(doc._status).toBe('draft') + await expect( + payload.findByID({ + id: doc.id, + collection: draftCollectionSlug, + }), + ).rejects.toThrow('Not Found') + + const latestDraft = await payload.findByID({ + id: doc.id, + collection: draftCollectionSlug, + version: 'latest', + }) + expect(latestDraft.title).toBe('Local action API') + + const published = await payload.update({ + id: doc.id, + collection: draftCollectionSlug, + data: { description: 'Published', title: 'Local published' }, + }) + expect(published._status).toBe('published') + + await payload.update({ + id: doc.id, + collection: draftCollectionSlug, + data: { _status: 'draft', title: 'Status-selected draft' }, + }) + + const actionWins = await payload.update({ + id: doc.id, + action: 'publish', + collection: draftCollectionSlug, + data: { _status: 'draft', title: 'Action-selected publish' }, + }) + expect(actionWins._status).toBe('published') + + await expect( + payload.findByID({ + id: doc.id, + collection: draftCollectionSlug, + version: 'draft', + }), + ).rejects.toThrow('Not Found') + + await payload.delete({ collection: draftCollectionSlug, id: doc.id }) + }) + + test('accepts version and action through REST', async ({ payload, restClient }) => { + const doc = await payload.create({ + action: 'publish', + collection: draftCollectionSlug, + data: { description: 'Published', title: 'REST published' }, + }) + + await payload.update({ + id: doc.id, + action: 'saveDraft', + collection: draftCollectionSlug, + data: { title: 'REST draft' }, + }) + + const published = await restClient.GET(`/${draftCollectionSlug}/${doc.id}`) + expect((await published.json()).title).toBe('REST published') + + const latest = await restClient.GET(`/${draftCollectionSlug}/${doc.id}?version=latest`) + expect((await latest.json()).title).toBe('REST draft') + + const draft = await restClient.GET(`/${draftCollectionSlug}/${doc.id}?version=draft`) + expect((await draft.json()).title).toBe('REST draft') + + const republished = await restClient.PATCH( + `/${draftCollectionSlug}/${doc.id}?action=publish`, + { body: JSON.stringify({ description: 'Published again' }) }, + ) + expect((await republished.json()).doc._status).toBe('published') + + await payload.delete({ collection: draftCollectionSlug, id: doc.id }) + }) + + test('accepts version and action through GraphQL', async ({ payload, restClient }) => { + const create = `mutation { + createDraftPost( + action: saveDraft + data: { description: "Draft description", title: "GraphQL draft" } + ) { + id + title + _status + } + }` + const createResult = await restClient + .GRAPHQL_POST({ body: JSON.stringify({ query: create }) }) + .then((response) => response.json()) + expect(createResult.errors).toBeUndefined() + const doc = createResult.data.createDraftPost + + expect(doc._status).toBe('draft') + + const read = `query { + published: DraftPost(id: ${formatGraphQLID({ payload }, doc.id)}) { title } + latest: DraftPost(id: ${formatGraphQLID({ payload }, doc.id)}, version: latest) { title } + }` + const readResult = await restClient + .GRAPHQL_POST({ body: JSON.stringify({ query: read }) }) + .then((response) => response.json()) + + expect(readResult.data.published).toBeNull() + expect(readResult.data.latest.title).toBe('GraphQL draft') + + const publish = `mutation { + updateDraftPost( + id: ${formatGraphQLID({ payload }, doc.id)} + action: publish + data: { description: "Published", title: "GraphQL published" } + ) { _status } + }` + const publishResult = await restClient + .GRAPHQL_POST({ body: JSON.stringify({ query: publish }) }) + .then((response) => response.json()) + expect(publishResult.data.updateDraftPost._status).toBe('published') + + await payload.delete({ collection: draftCollectionSlug, id: doc.id }) + }) + + test('accepts version and action through the SDK', async ({ payload, sdk }) => { + const { token } = await sdk.login({ + collection: 'users', + data: devUser, + }) + const sdkAuth = { headers: { Authorization: `JWT ${token}` } } + + const doc = await sdk.create( + { + action: 'saveDraft', + collection: draftCollectionSlug, + data: { title: 'SDK draft' }, + }, + sdkAuth, + ) + + await expect( + sdk.findByID({ id: doc.id, collection: draftCollectionSlug }, sdkAuth), + ).rejects.toMatchObject({ status: 404 }) + + const latest = await sdk.findByID( + { id: doc.id, collection: draftCollectionSlug, version: 'latest' }, + sdkAuth, + ) + expect(latest.title).toBe('SDK draft') + + const published = await sdk.update( + { + id: doc.id, + action: 'publish', + collection: draftCollectionSlug, + data: { description: 'Published', title: 'SDK published' }, + }, + sdkAuth, + ) + expect(published._status).toBe('published') + + await payload.delete({ collection: draftCollectionSlug, id: doc.id }) + }) + }) + test.describe('Collections - Local', () => { test.describe('Create', () => { test('should allow creating a draft with missing required field data', async ({ @@ -81,7 +271,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = description: undefined, title: 'i have a title', }, - draft: true, + action: 'saveDraft', }) expect(draft.id).toBeDefined() @@ -101,6 +291,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = // Update to create a version await payload.update({ + action: 'saveDraft', id: autosavePost.id, collection: autosaveCollectionSlug, data: { @@ -121,6 +312,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = const updatedPost = await payload.findByID({ id: autosavePost.id, collection: autosaveCollectionSlug, + version: 'latest', }) expect(updatedPost.title).toBe(updatedTitle) expect(updatedPost._status).toStrictEqual('draft') @@ -255,7 +447,9 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = }) // https://github.com/payloadcms/payload/issues/4827 - test('should query drafts with relation', async ({ payload }) => { + test('should only query drafts with relation when requesting the latest version', async ({ + payload, + }) => { const draftPost = await payload.create({ collection: draftCollectionSlug, data: { @@ -282,9 +476,9 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = }, } const all = await payload.find(query) - const drafts = await payload.find({ ...query, draft: true }) + const drafts = await payload.find({ ...query, version: 'latest' }) - expect(all.docs).toHaveLength(1) + expect(all.docs).toHaveLength(0) expect(drafts.docs).toHaveLength(1) }) @@ -332,7 +526,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = const fromNonVersionsTable = await payload.findByID({ id: doc.id, collection: autosaveCollectionSlug, - draft: false, + version: 'published', }) // createdAt from non-versions should be the same as version_createdAt in versions @@ -360,7 +554,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = ], }, depth: 0, - draft: true, + action: 'saveDraft', }) expect(res.blocks[0]?.array[0]?.relationship).toEqual(post.id) const { @@ -377,7 +571,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = const post = await payload.create({ collection: 'autosave-posts', data: { _status: 'draft', description: 'description', title: 'post' }, - draft: true, + action: 'saveDraft', }) await payload.update({ @@ -385,7 +579,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = autosave: true, collection: 'autosave-posts', data: { title: 'autosave' }, - draft: true, + action: 'saveDraft', }) const getVersionsCount = async () => { @@ -407,7 +601,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = autosave: true, collection: 'autosave-posts', data: { title: 'post-updated-1' }, - draft: true, + action: 'saveDraft', }) expect(await getVersionsCount()).toBe(2) @@ -417,7 +611,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = autosave: true, collection: 'autosave-posts', data: { title: 'post-updated-2' }, - draft: true, + action: 'saveDraft', where: { id: { equals: post.id } }, }) expect(await getVersionsCount()).toBe(2) @@ -440,7 +634,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = autosave: true, collection: autosaveCollectionSlug, data: { title: 'Autosaved Title' }, - draft: true, + action: 'saveDraft', }) // Simulate page reload: read the latest draft version (what getLatestCollectionVersion does) @@ -474,7 +668,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = autosave: true, collection: autosaveCollectionSlug, data: { title: 'Change 1' }, - draft: true, + action: 'saveDraft', }) const countAfterFirst = await payload.countVersions({ @@ -488,7 +682,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = autosave: true, collection: autosaveCollectionSlug, data: { title: 'Change 2' }, - draft: true, + action: 'saveDraft', }) const countAfterSecond = await payload.countVersions({ @@ -504,7 +698,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = autosave: true, collection: autosaveCollectionSlug, data: { title: 'Change 3' }, - draft: true, + action: 'saveDraft', }) const countAfterThird = await payload.countVersions({ @@ -532,13 +726,14 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = const post = await payload.create({ collection, data: { description: 'description' }, - draft: true, + action: 'saveDraft', }) const docWithLocales = await payload.findByID({ collection, id: post.id, locale: 'all', + version: 'latest', }) const result = await saveVersion({ @@ -563,7 +758,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = title: 'Original Title', _status: 'published', }, - draft: false, + action: 'publish', }) const duplicatedDoc = await payload.create({ @@ -572,7 +767,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = data: { _status: 'draft', }, - draft: true, + action: 'saveDraft', }) expect(duplicatedDoc._status).toBe('draft') @@ -590,14 +785,14 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = title: 'Draft with partial data', _status: 'draft', }, - draft: true, + action: 'saveDraft', }) // description is required but missing — duplicate should still succeed as a draft const duplicatedDoc = await payload.duplicate({ id: originalDoc.id, collection: draftCollectionSlug, - draft: true, + action: 'saveDraft', }) expect(duplicatedDoc._status).toBe('draft') @@ -618,7 +813,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = title: 'REST draft partial', _status: 'draft', }, - draft: true, + action: 'saveDraft', }) // Mimics the admin UI: POST to /:collection/:id/duplicate @@ -664,13 +859,13 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = test('should query drafts with sort', async ({ payload }) => { const draftsAscending = await payload.find({ collection: draftCollectionSlug, - draft: true, + version: 'latest', sort: 'title', }) const draftsDescending = await payload.find({ collection: draftCollectionSlug, - draft: true, + version: 'latest', sort: '-title', }) @@ -684,14 +879,12 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = test('should `findVersions` with sort', async ({ payload }) => { const draftsAscending = await payload.findVersions({ collection: draftCollectionSlug, - draft: true, limit: 100, sort: 'createdAt', }) const draftsDescending = await payload.findVersions({ collection: draftCollectionSlug, - draft: true, limit: 100, sort: '-createdAt', }) @@ -765,7 +958,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = description: 'version description', title: 'version title', }, - draft: true, + action: 'saveDraft', }) let updatedPost = await payload.update({ @@ -781,7 +974,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = ], title: title2, }, - draft: true, + action: 'saveDraft', }) updatedPost = await payload.update({ @@ -799,7 +992,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = ], title: title2, }, - draft: true, + action: 'saveDraft', }) expect(updatedPost.title).toBe(title2) @@ -810,7 +1003,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = const draftFromUpdatedPost = await payload.findByID({ id: versionedPost.id, collection: draftCollectionSlug, - draft: true, + version: 'latest', }) expect(draftFromUpdatedPost.title).toBe(title2) expect(draftFromUpdatedPost.blocksField).toHaveLength(1) @@ -828,6 +1021,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = const versionToRestore = versions.docs[versions.docs.length - 1] // restore to previous version const restoredVersion = await payload.restoreVersion({ + action: 'saveDraft', id: versionToRestore!.id, collection: draftCollectionSlug, }) @@ -840,7 +1034,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = const latestDraft = await payload.findByID({ id: versionedPost.id, collection: draftCollectionSlug, - draft: true, + version: 'latest', }) expect(latestDraft).toMatchObject({ @@ -859,7 +1053,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = const target = await payload.create({ collection: draftCollectionSlug, data: { description: 'target', title: 'filter-options target' }, - draft: true, + action: 'saveDraft', }) const doc = await payload.create({ @@ -869,7 +1063,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = relationWithFilterOptions: [target.id], title: 'filter-options doc', }, - draft: true, + action: 'saveDraft', }) await payload.update({ @@ -879,7 +1073,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = relationWithFilterOptions: [target.id], title: 'filter-options doc updated', }, - draft: true, + action: 'saveDraft', }) const versions = await payload.findVersions({ @@ -892,7 +1086,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = // Mimics the admin UI restore button: POST /:collection/versions/:id const response = await restClient.POST( - `/${draftCollectionSlug}/versions/${versionToRestore!.id}`, + `/${draftCollectionSlug}/versions/${versionToRestore!.id}?action=saveDraft`, ) const body = await response.json() @@ -903,7 +1097,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = id: doc.id, collection: draftCollectionSlug, depth: 0, - draft: true, + version: 'latest', }) expect(restored.relationWithFilterOptions).toStrictEqual([target.id]) @@ -925,7 +1119,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = description: 'initial description', title: 'leak test', }, - draft: true, + action: 'saveDraft', }) const blockId = doc.blocksField?.[0]!.id @@ -948,7 +1142,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = select: ['test1'], title: 'leak test', }, - draft: true, + action: 'saveDraft', }) // Find versions and restore the original (oldest) version @@ -967,7 +1161,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = const restored = await payload.findByID({ id: doc.id, collection: draftCollectionSlug, - draft: true, + version: 'latest', }) // Top-level fields should NOT have leaked from the updated version @@ -999,7 +1193,6 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = _status: 'published', title: 'v2', }, - draft: true, }) // get the version id of the original draft @@ -1023,7 +1216,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = const latestDraft = await payload.findByID({ id: originalPost.id, collection: draftCollectionSlug, - draft: true, + version: 'latest', }) // assert it has the original post content @@ -1051,7 +1244,6 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = description: 'description v2', title: 'title v2 en', }, - draft: true, }) const versions = await payload.findVersions({ @@ -1137,7 +1329,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = _status: 'draft', title: patchedTitle, }, - draft: true, + action: 'saveDraft', locale: 'en', }) @@ -1151,7 +1343,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = _status: 'draft', title: spanishTitle, }, - draft: true, + action: 'saveDraft', locale: 'es', }) @@ -1163,7 +1355,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = const draftPost = await payload.findByID({ id: originalPublishedPost.id, collection: autosaveCollectionSlug, - draft: true, + version: 'latest', locale: 'all', }) @@ -1179,7 +1371,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = description: 'desc', title: 'title', }, - draft: true, + action: 'saveDraft', }) await wait(10) @@ -1190,7 +1382,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = data: { title: 'updated title', }, - draft: true, + action: 'saveDraft', }) const createdUpdatedAt = new Date(created.updatedAt) @@ -1208,7 +1400,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = description: 'desc', title: 'title', }, - draft: true, + action: 'saveDraft', }) await wait(10) @@ -1220,7 +1412,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = data: { title: 'updated title', }, - draft: true, + action: 'saveDraft', }) const createdUpdatedAt = new Date(created.updatedAt) @@ -1240,7 +1432,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = tag: firstDocTag, title: 'title 1', }, - draft: false, + action: 'publish', }) await payload.update({ id: doc.id, @@ -1250,7 +1442,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = tag: firstDocTag, title: 'title 2', }, - draft: true, + action: 'saveDraft', }) const doc2 = await payload.create({ @@ -1260,7 +1452,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = tag: ['blog'], title: 'title 1-2', }, - draft: false, + action: 'publish', }) await payload.update({ @@ -1271,7 +1463,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = tag: ['blog'], title: 'title 2-2', }, - draft: true, + action: 'saveDraft', }) await payload.update({ id: doc2.id, @@ -1281,7 +1473,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = tag: ['blog'], title: 'title 3-2', }, - draft: true, + action: 'saveDraft', }) const lastDocVersion = await payload.findVersions({ @@ -1315,7 +1507,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = data: { outer: [{ inner: [{ days: ['monday'] }] }], }, - draft: true, + action: 'saveDraft', }) expect(updated.outer?.[0]?.inner?.[0]?.days).toEqual(['monday']) @@ -1326,14 +1518,16 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = }) }) - test('should validate when publishing with the draft arg', async ({ payload }) => { + test('should validate when publishing from _status without an action', async ({ + payload, + }) => { // no title (not valid for publishing) const doc = await payload.create({ collection: draftCollectionSlug, data: { description: 'desc', }, - draft: true, + action: 'saveDraft', }) await expect( @@ -1341,7 +1535,6 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = id: doc.id, collection: draftCollectionSlug, data: { _status: 'published' }, - draft: true, }), ).rejects.toThrow(ValidationError) @@ -1349,7 +1542,6 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = const updateManyResult = await payload.update({ collection: draftCollectionSlug, data: { _status: 'published' }, - draft: true, where: { id: { equals: doc.id }, }, @@ -1366,7 +1558,6 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = const { id } = await payload.create({ collection: autosaveCollectionSlug, data: { _status: 'draft', description: 'some-description', title: 'my-title' }, - draft: true, }) // Autosave the same draft, calls db.updateVersion @@ -1377,7 +1568,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = data: { title: 'new-title', }, - draft: true, + action: 'saveDraft', }) const versionsCount = await payload.countVersions({ @@ -1397,7 +1588,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = data: { title: 'new-title-2', }, - draft: true, + action: 'saveDraft', }) const versionsCountAfter = await payload.countVersions({ @@ -1447,7 +1638,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = data: { title: 'updated title', }, - draft: true, + action: 'saveDraft', }) // bulk publish @@ -1457,7 +1648,6 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = _status: 'published', description: 'updated description', }, - draft: true, where: { id: { in: [doc.id], @@ -1546,7 +1736,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = description: 'A', title: 'A', }, - draft: true, + action: 'saveDraft', }) await payload.update({ @@ -1557,7 +1747,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = description: 'B', title: 'B', }, - draft: true, + action: 'saveDraft', }) await payload.update({ @@ -1568,13 +1758,13 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = description: 'C', title: 'C', }, - draft: true, + action: 'saveDraft', }) const mostRecentDraft = await payload.findByID({ id: originalDraft.id, collection: draftCollectionSlug, - draft: true, + version: 'latest', }) expect(mostRecentDraft.title).toStrictEqual('C') @@ -1621,6 +1811,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = expect(initialVersions.docs[0].version._status).toBe('published') const unpublished = await payload.update({ + action: 'unpublish', id: doc.id, collection: draftCollectionSlug, data: { _status: 'draft' }, @@ -1651,6 +1842,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = const initialCount = initialVersions.docs.length await payload.updateGlobal({ + action: 'unpublish', slug: draftGlobalSlug, data: { _status: 'draft' }, unpublishAllLocales: true, @@ -1677,6 +1869,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = }) await payload.update({ + action: 'unpublish', id: doc.id, collection: draftCollectionSlug, data: { _status: 'draft' }, @@ -1686,7 +1879,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = const found = await payload.findByID({ id: doc.id, collection: draftCollectionSlug, - draft: false, + version: 'latest', }) expect(found._status).toBe('draft') @@ -1706,6 +1899,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = }) const unpublished = await payload.update({ + action: 'unpublish', id: doc.id, collection: draftCollectionSlug, data: { _status: 'draft' }, @@ -1728,6 +1922,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = }) const unpublished = await payload.updateGlobal({ + action: 'unpublish', slug: draftGlobalSlug, data: { _status: 'draft' }, fallbackLocale: false, @@ -1750,15 +1945,15 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = }) test('should allow creating drafts without required fields', async ({ payload }) => { - // This test validates that when draft: true is set, required fields become optional + // This test validates that when action: 'saveDraft' is set, required fields become optional // TypeScript should not complain about missing 'description' field even though it's required const draft = await payload.create({ collection: draftCollectionSlug, data: { title: 'Draft without description', - // description is required but omitted - should work with draft: true + // description is required but omitted - should work with action: 'saveDraft' }, - draft: true, + action: 'saveDraft', }) expect(draft.title).toBe('Draft without description') @@ -1767,43 +1962,44 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = expect(draft._status).toBe('draft') }) - test('should require all required fields when draft is false', async ({ payload }) => { - // This validates that required fields are still enforced when draft is false + test("should require all required fields when action is 'publish'", async ({ payload }) => { + // Publishing still enforces required fields. await expect( - // @ts-expect-error - description is required when not creating a draft + // @ts-expect-error - description is required when publishing payload.create({ collection: draftCollectionSlug, data: { title: 'Published without description', }, - draft: false, + action: 'publish', }), ).rejects.toThrow(ValidationError) }) - test('should require all required fields when draft is not specified', async ({ + test('should default to saving a draft when action and _status are omitted', async ({ payload, }) => { - // This validates that required fields are still enforced when draft option is omitted - await expect( - // @ts-expect-error - description is required when draft option is not specified - payload.create({ - collection: draftCollectionSlug, - data: { - title: 'Post without description', - }, - }), - ).rejects.toThrow(ValidationError) + const draft = await payload.create({ + collection: draftCollectionSlug, + data: { + title: 'Post without description', + }, + }) + + expect(draft._status).toBe('draft') + expect(draft.description).toBeFalsy() }) - test('should allow all fields to be optional with draft: true', async ({ payload }) => { + test("should allow all fields to be optional with action: 'saveDraft'", async ({ + payload, + }) => { // Test that even fields nested in groups can be omitted const draft = await payload.create({ collection: draftCollectionSlug, data: { // Both title and description are required but omitted }, - draft: true, + action: 'saveDraft', }) expect(draft._status).toBe('draft') @@ -1936,7 +2132,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = id: doc.id, collection: draftCollectionSlug, data: {}, - draft: true, + action: 'saveDraft', }) .then(resolve) .catch(resolve) @@ -1978,7 +2174,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = const doc = await payload.create({ collection: autosaveCollectionSlug, data: { title: 'original', _status: 'draft' }, - draft: true, + action: 'saveDraft', }) // Establish an existing autosave version so updateLatestVersion has something to update @@ -1987,7 +2183,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = autosave: true, collection: autosaveCollectionSlug, data: { title: 'first autosave' }, - draft: true, + action: 'saveDraft', }) const spy = vi @@ -2000,7 +2196,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = autosave: true, collection: autosaveCollectionSlug, data: { title: 'second autosave' }, - draft: true, + action: 'saveDraft', }) spy.mockRestore() @@ -2027,7 +2223,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = const doc = await payload.create({ collection: autosaveCollectionSlug, data: { title: 'original', _status: 'draft' }, - draft: true, + action: 'saveDraft', }) const updateVersionSpy = vi @@ -2043,7 +2239,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = autosave: true, collection: autosaveCollectionSlug, data: { title: 'will fail' }, - draft: true, + action: 'saveDraft', }), ).rejects.toThrow('database connection lost') @@ -2107,7 +2303,6 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = _status: 'draft', alt: 'Updated in draft', }, - draft: true, file: draftImageFile, }) @@ -2119,7 +2314,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = const draftDoc = await payload.findByID({ id: publishedDoc.id, collection: draftWithUploadCollectionSlug, - draft: true, + version: 'latest', }) uploadedFilenames.push(draftDoc.filename) @@ -2166,14 +2361,13 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = _status: 'draft', alt: 'Draft with new file', }, - draft: true, file: draftImageFile, }) const draftDoc = await payload.findByID({ id: publishedDoc.id, collection: draftWithUploadCollectionSlug, - draft: true, + version: 'latest', }) uploadedFilenames.push(draftDoc.filename) @@ -2210,14 +2404,14 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = _status: 'draft', alt: 'Draft version', }, - draft: true, + action: 'saveDraft', file: draftImageFile, }) const draftDoc = await payload.findByID({ id: publishedDoc.id, collection: draftWithUploadCollectionSlug, - draft: true, + version: 'latest', }) uploadedFilenames.push(draftDoc.filename) @@ -2227,7 +2421,6 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = data: { _status: 'published', }, - draft: true, where: { id: { equals: publishedDoc.id }, }, @@ -2243,7 +2436,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = expect(republishedDoc.alt).toBe('Draft version') }) - test('should create a draft when duplicating a published upload document with draft: true', async ({ + test("should create a draft when duplicating a published upload document with action: 'saveDraft'", async ({ payload, }) => { const imageFile = await getFileByPath(path.resolve(dirname, './image.jpg')) @@ -2267,7 +2460,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = data: { alt: 'Duplicated draft', }, - draft: true, + action: 'saveDraft', duplicateFromID: publishedDoc.id, }) @@ -2315,7 +2508,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = _status: 'draft', alt: 'Updated in draft', }, - draft: true, + action: 'saveDraft', file: draftImageFile, }) @@ -2327,7 +2520,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = const draftDoc = await payload.findByID({ id: publishedDoc.id, collection: draftWithUploadCloudStorageCollectionSlug, - draft: true, + version: 'latest', }) expect(mainDoc._status).toBe('published') @@ -2366,7 +2559,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = _status: 'draft', alt: 'Updated in draft', }, - draft: true, + action: 'saveDraft', file: draftImageFile, }) @@ -2398,14 +2591,14 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = _status: 'draft', alt: 'Draft version', }, - draft: true, + action: 'saveDraft', file: draftImageFile, }) const draftDoc = await payload.findByID({ id: publishedDoc.id, collection: draftWithUploadCloudStorageCollectionSlug, - draft: true, + version: 'latest', }) const republishedDoc = await payload.update({ @@ -2414,7 +2607,6 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = data: { _status: 'published', }, - draft: true, }) expect(republishedDoc._status).toBe('published') @@ -2489,7 +2681,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = data: { title: updatedTitle1, }, - draft: true, + action: 'saveDraft', }) // This will be created in the `_draft-posts_versions` collection @@ -2500,7 +2692,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = data: { title: updatedTitle2, }, - draft: true, + action: 'saveDraft', }) } @@ -2515,17 +2707,20 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = }) }) - test('should allow querying a draft doc from main collection', async ({ payload }) => { + test('should query the newest draft when requesting the latest version', async ({ + payload, + }) => { const findResults = await payload.find({ collection: draftCollectionSlug, + version: 'latest', where: { title: { - equals: originalTitle, + equals: updatedTitle2, }, }, }) - expect(findResults.docs[0].title).toStrictEqual(originalTitle) + expect(findResults.docs[0].title).toStrictEqual(updatedTitle2) }) test('should return more than 10 `totalDocs`', async ({ payload }) => { @@ -2573,7 +2768,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = }) => { const draftFindResults = await payload.find({ collection: draftCollectionSlug, - draft: true, + version: 'latest', where: { title: { equals: updatedTitle1, @@ -2589,7 +2784,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = }) => { const draftFindResults = await payload.find({ collection: draftCollectionSlug, - draft: true, + version: 'latest', where: { title: { equals: updatedTitle2, @@ -2634,12 +2829,12 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = where: query, }) - expect(publishedFindResults.docs).toHaveLength(1) - expect(publishedFindResults.docs.find(({ id }) => id === matchingDraft.id)).toBeDefined() + expect(publishedFindResults.docs).toHaveLength(0) + expect(publishedFindResults.docs.find(({ id }) => id === matchingDraft.id)).toBeUndefined() const draftFindResults = await payload.find({ collection: draftCollectionSlug, - draft: true, + version: 'latest', where: query, }) @@ -2652,7 +2847,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = }) => { const draftFindResults = await payload.find({ collection: draftCollectionSlug, - draft: true, + version: 'latest', where: { title: { equals: originalTitle, @@ -2667,14 +2862,14 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = await createPostWithVersions({ payload }, { title: 'different document' }) const allDocs = await payload.find({ collection: draftCollectionSlug, - draft: true, + version: 'latest', }) expect(allDocs.docs).toHaveLength(2) const byID = await payload.find({ collection: draftCollectionSlug, - draft: true, + version: 'latest', where: { id: { equals: firstDraft.id, @@ -2691,7 +2886,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = await createPostWithVersions({ payload }, { title: 'title document 2' }) const allDocs = await payload.find({ collection: draftCollectionSlug, - draft: true, + version: 'latest', where: { title: { like: 'title', @@ -2703,7 +2898,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = const results = await payload.find({ collection: draftCollectionSlug, - draft: true, + version: 'latest', where: { and: [ { @@ -3177,6 +3372,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = test.beforeEach(async ({ payload }) => { const title2 = 'Here is an updated global title in EN' await payload.updateGlobal({ + action: 'saveDraft', slug: autoSaveGlobalSlug, data: { title: 'Test Global', @@ -3184,6 +3380,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = }) await payload.updateGlobal({ + action: 'saveDraft', slug: autoSaveGlobalSlug, data: { title: title2, @@ -3201,6 +3398,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = const title2 = 'Here is an updated global title in EN' const updatedGlobal = await payload.findGlobal({ slug: autoSaveGlobalSlug, + version: 'latest', }) expect(updatedGlobal.title).toBe(title2) expect(updatedGlobal._status).toStrictEqual('draft') @@ -3214,7 +3412,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = _status: 'draft', title: 'Draft', }, - draft: true, + action: 'saveDraft', }) expect(draftVersion.title).toStrictEqual('Draft') expect(draftVersion._status).toStrictEqual('draft') @@ -3225,7 +3423,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = _status: 'published', title: 'Published', }, - draft: false, + action: 'publish', }) expect(publishedVersion.title).toStrictEqual('Published') expect(publishedVersion._status).toStrictEqual('published') @@ -3268,7 +3466,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = const fromNonVersionsTable = await payload.findGlobal({ slug: autoSaveGlobalSlug, - draft: false, + version: 'published', }) // createdAt from non-versions should be the same as version_createdAt in versions @@ -3398,7 +3596,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = data: { title: 'title', }, - draft: true, + action: 'saveDraft', }) await wait(10) @@ -3408,7 +3606,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = data: { title: 'updated title', }, - draft: true, + action: 'saveDraft', }) const createdUpdatedAt = new Date(created.updatedAt) @@ -3425,7 +3623,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = data: { title: 'title', }, - draft: true, + action: 'saveDraft', }) await wait(10) @@ -3435,7 +3633,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = data: { title: 'updated title', }, - draft: true, + action: 'saveDraft', autosave: true, }) @@ -3463,7 +3661,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = // Make sure it was updated correctly const foundUpdatedGlobal = await payload.findGlobal({ slug: autoSaveGlobalSlug, - draft: true, + version: 'latest', }) expect(foundUpdatedGlobal.title).toBe(title2) @@ -3480,7 +3678,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = const restoredGlobal = await payload.findGlobal({ slug: autoSaveGlobalSlug, - draft: true, + version: 'latest', }) expect(restoredGlobal.title).toBe(restore.version.title.en) @@ -3503,7 +3701,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = const publishedGlobal = await payload.findGlobal({ slug: autoSaveGlobalSlug, - draft: true, + version: 'latest', }) const updatedTitle2 = 'Here is a draft global with a patched title' @@ -3514,7 +3712,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = _status: 'draft', title: updatedTitle2, }, - draft: true, + action: 'saveDraft', locale: 'en', }) @@ -3524,13 +3722,13 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = _status: 'draft', title: updatedTitle2, }, - draft: true, + action: 'saveDraft', locale: 'es', }) const updatedGlobal = await payload.findGlobal({ slug: autoSaveGlobalSlug, - draft: true, + version: 'latest', locale: 'all', }) @@ -3548,7 +3746,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = _status: 'draft', title: originalTitle, }, - draft: true, + action: 'saveDraft', }) const updatedTitle2 = 'Now try to publish' @@ -3571,7 +3769,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = async function createAndSetVersionID({ restClient }: { restClient: NextRESTClient }) { const update = `mutation { - updateAutosaveGlobal(draft: true, data: { + updateAutosaveGlobal(action: saveDraft, data: { title: "${globalGraphQLOriginalTitle}" }) { _status @@ -3660,7 +3858,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = // Update it const update = `mutation { - updateAutosaveGlobal(draft: true, data: { + updateAutosaveGlobal(action: saveDraft, data: { title: "${updatedTitle}" }) { title @@ -3704,7 +3902,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = description: 'hello', title: 'my doc to publish in the future', }, - draft: true, + action: 'saveDraft', }) expect(draft._status).toStrictEqual('draft') @@ -3729,7 +3927,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = const retrieved = await payload.findByID({ id: draft.id, collection: draftCollectionSlug, - draft: false, + version: 'published', }) expect(retrieved._status).toStrictEqual('published') @@ -3748,7 +3946,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = restrictedToUpdate: true, title: 'my doc to publish in the future', }, - draft: true, + action: 'saveDraft', }) expect(draft._status).toStrictEqual('draft') @@ -3776,6 +3974,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = const retrieved = await payload.findByID({ id: draft.id, collection: draftCollectionSlug, + version: 'latest', }) expect(retrieved._status).toStrictEqual('draft') @@ -3819,6 +4018,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = const retrieved = await payload.findByID({ id: published.id, collection: draftCollectionSlug, + version: 'latest', }) expect(retrieved._status).toStrictEqual('draft') @@ -3836,7 +4036,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = description: 'hello', title: 'my doc to publish in the future', }, - draft: true, + action: 'saveDraft', }) expect(draft._status).toStrictEqual('draft') @@ -3886,7 +4086,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = description: 'hello', title: 'my doc to publish in the future', }, - draft: true, + action: 'saveDraft', }) expect(draft._status).toStrictEqual('draft') @@ -3934,7 +4134,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = _status: 'draft', title: 'i will publish', }, - draft: true, + action: 'saveDraft', }) expect(draft._status).toStrictEqual('draft') @@ -3955,6 +4155,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = const retrieved = await payload.findGlobal({ slug: draftGlobalSlug, + version: 'latest', }) expect(retrieved._status).toStrictEqual('published') @@ -3989,6 +4190,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = const retrieved = await payload.findGlobal({ slug: draftGlobalSlug, + version: 'latest', }) expect(retrieved._status).toStrictEqual('draft') @@ -4003,7 +4205,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = _status: 'draft', title: 'draft only', }, - draft: true, + action: 'saveDraft', }) expect(draft._status).toStrictEqual('draft') @@ -4017,6 +4219,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = slug: draftGlobalSlug, overrideAccess: false, req, + version: 'latest', }) // Should return empty object, not {_status: 'draft'} @@ -4238,7 +4441,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = data: { text: 'Spanish draft', }, - draft: true, + action: 'saveDraft', locale: 'es', }) @@ -4250,7 +4453,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = description: 'My English description', text: 'English draft', }, - draft: true, + action: 'saveDraft', locale: 'en', }) @@ -4261,7 +4464,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = data: { text: 'German draft', }, - draft: true, + action: 'saveDraft', locale: 'de', }) @@ -4273,7 +4476,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = _status: 'published', text: 'English published 1', }, - draft: false, + action: 'publish', locale: 'en', }) @@ -4294,7 +4497,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = const docWithSpanishDraft1 = await payload.findByID({ id: draft1.id, collection: localizedCollectionSlug, - draft: true, + version: 'latest', locale: 'all', }) @@ -4311,7 +4514,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = _status: 'published', text: 'English published 2', }, - draft: false, + action: 'publish', locale: 'en', }) @@ -4336,14 +4539,14 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = _status: 'draft', text: 'German draft 1', }, - draft: true, + action: 'saveDraft', locale: 'de', }) const docWithGermanDraft = await payload.findByID({ id: draft1.id, collection: localizedCollectionSlug, - draft: true, + version: 'latest', locale: 'all', }) @@ -4362,7 +4565,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = _status: 'published', text: 'German published 1', }, - draft: false, + action: 'publish', locale: 'de', }) @@ -4373,7 +4576,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = _status: 'published', text: 'English published 3', }, - draft: false, + action: 'publish', locale: 'en', }) @@ -4391,7 +4594,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = const finalDraft = await payload.findByID({ id: draft1.id, collection: localizedCollectionSlug, - draft: true, + version: 'latest', locale: 'all', }) @@ -4410,7 +4613,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = const finalPublished = await payload.findByID({ id: draft1.id, collection: localizedCollectionSlug, - draft: true, + version: 'latest', locale: 'all', }) @@ -4425,7 +4628,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = data: { text: 'Spanish draft', }, - draft: true, + action: 'saveDraft', locale: 'es', }) @@ -4436,7 +4639,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = _status: 'published', text: 'English publish', }, - draft: false, + action: 'publish', }) const publishedOnlyEN = await payload.findByID({ @@ -4457,7 +4660,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = data: { text: 'Spanish draft', }, - draft: true, + action: 'saveDraft', locale: 'es', }) @@ -4468,7 +4671,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = _status: 'published', text: 'English publish', }, - draft: false, + action: 'publish', }) const publishedOnlyEN = await payload.findByID({ @@ -4486,7 +4689,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = data: { _status: 'published', }, - draft: false, + action: 'publish', publishAllLocales: true, }) @@ -4506,7 +4709,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = data: { text: 'Spanish draft', }, - draft: true, + action: 'saveDraft', locale: 'es', }) @@ -4517,7 +4720,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = _status: 'published', text: 'German publish', }, - draft: false, + action: 'publish', locale: 'de', }) @@ -4538,7 +4741,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = data: { text: 'Spanish draft', }, - draft: true, + action: 'saveDraft', locale: 'es', }) @@ -4549,7 +4752,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = _status: 'published', text: 'English publish', }, - draft: false, + action: 'publish', }) const publishedOnlyEN = await payload.findByID({ @@ -4582,7 +4785,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = data: { text: 'English draft', }, - draft: true, + action: 'saveDraft', locale: 'en', }) @@ -4599,7 +4802,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = ], text: 'English with blocks', }, - draft: true, + action: 'saveDraft', locale: 'en', }) @@ -4617,7 +4820,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = ], text: 'English published with blocks', }, - draft: false, + action: 'publish', locale: 'en', }) @@ -4666,7 +4869,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = content: 'Spanish draft content', title: 'Spanish draft', }, - draft: true, + action: 'saveDraft', locale: 'es', }) @@ -4698,7 +4901,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = data: { title: 'Another spanish draft', }, - draft: true, + action: 'saveDraft', locale: 'es', }) @@ -4709,7 +4912,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = _status: 'published', title: 'Eng published', }, - draft: false, + action: 'publish', locale: 'en', }) @@ -4733,7 +4936,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = content: 'Spanish draft content', title: 'Spanish draft', }, - draft: true, + action: 'saveDraft', locale: 'es', }) @@ -4780,7 +4983,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = content: 'Test span draft content', title: 'Test span draft', }, - draft: true, + action: 'saveDraft', locale: 'es', }) @@ -4812,7 +5015,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = content: 'New spanish draft content', title: 'New spanish draft', }, - draft: true, + action: 'saveDraft', locale: 'es', }) @@ -4823,7 +5026,7 @@ test.suite({ config: './config.ts', resetBetweenTests: false })('Versions', () = _status: 'published', title: 'New eng', }, - draft: false, + action: 'publish', }) const allVersions = await payload.findGlobalVersions({ diff --git a/test/versions/seed.ts b/test/versions/seed.ts index 568503b63e9..8e6d4b9be88 100644 --- a/test/versions/seed.ts +++ b/test/versions/seed.ts @@ -74,6 +74,7 @@ export async function seed(_payload: Payload, parallel: boolean = false) { [ () => _payload.create({ + action: 'saveDraft', collection: draftCollectionSlug, data: { blocksField, @@ -83,13 +84,13 @@ export async function seed(_payload: Payload, parallel: boolean = false) { }, depth: 0, overrideAccess: true, - draft: true, }), ], parallel, ) const { id: manyDraftsID } = await _payload.create({ + action: 'saveDraft', collection: draftCollectionSlug, data: { blocksField, @@ -99,7 +100,6 @@ export async function seed(_payload: Payload, parallel: boolean = false) { }, depth: 0, overrideAccess: true, - draft: true, }) for (let i = 0; i < 10; i++) { @@ -115,6 +115,7 @@ export async function seed(_payload: Payload, parallel: boolean = false) { } const draft2 = await _payload.create({ + action: 'publish', collection: draftCollectionSlug, data: { _status: 'published', @@ -125,10 +126,10 @@ export async function seed(_payload: Payload, parallel: boolean = false) { }, depth: 0, overrideAccess: true, - draft: false, }) const draft3 = await _payload.create({ + action: 'publish', collection: draftCollectionSlug, data: { _status: 'published', @@ -139,10 +140,10 @@ export async function seed(_payload: Payload, parallel: boolean = false) { }, depth: 0, overrideAccess: true, - draft: false, }) await _payload.create({ + action: 'publish', collection: autosaveWithDraftValidateSlug, data: { title: 'Initial seeded title',