From 71d912cf4faceb917700686ea84e59c6cf4d706b Mon Sep 17 00:00:00 2001 From: Khurs Pavel Date: Wed, 29 Apr 2026 18:01:02 +0300 Subject: [PATCH 01/16] refactor(content-releases): remove status from default table columns --- .../src/__tests__/collections/releaseItems.test.ts | 7 +++++++ .../src/__tests__/collections/releases.test.ts | 10 ++++++++++ .../src/collections/releaseItems.ts | 2 +- .../src/collections/releases.ts | 2 +- 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/payload-plugin-content-releases/src/__tests__/collections/releaseItems.test.ts b/packages/payload-plugin-content-releases/src/__tests__/collections/releaseItems.test.ts index a1d3755..236a9ea 100644 --- a/packages/payload-plugin-content-releases/src/__tests__/collections/releaseItems.test.ts +++ b/packages/payload-plugin-content-releases/src/__tests__/collections/releaseItems.test.ts @@ -73,3 +73,10 @@ describe("release-items collection", () => { expect(field.type).toBe("text"); }); }); + +describe("buildReleaseItemsCollection — admin.defaultColumns", () => { + it("does not include 'status' in defaultColumns", () => { + const config = buildReleaseItemsCollection(["pages"]); + expect(config.admin?.defaultColumns).not.toContain("status"); + }); +}); diff --git a/packages/payload-plugin-content-releases/src/__tests__/collections/releases.test.ts b/packages/payload-plugin-content-releases/src/__tests__/collections/releases.test.ts index cc6c5fc..0d792c2 100644 --- a/packages/payload-plugin-content-releases/src/__tests__/collections/releases.test.ts +++ b/packages/payload-plugin-content-releases/src/__tests__/collections/releases.test.ts @@ -62,3 +62,13 @@ describe("releases collection", () => { expect(col.access?.read).toBe(customAccess.read); }); }); + +describe("buildReleasesCollection — items join defaultColumns", () => { + it("does not include 'status' in items join defaultColumns", () => { + const config = buildReleasesCollection(); + const itemsField = config.fields.find( + (f) => "name" in f && f.name === "items", + ) as any; + expect(itemsField.admin.defaultColumns).not.toContain("status"); + }); +}); diff --git a/packages/payload-plugin-content-releases/src/collections/releaseItems.ts b/packages/payload-plugin-content-releases/src/collections/releaseItems.ts index c8cea12..79be06c 100644 --- a/packages/payload-plugin-content-releases/src/collections/releaseItems.ts +++ b/packages/payload-plugin-content-releases/src/collections/releaseItems.ts @@ -28,7 +28,7 @@ export function buildReleaseItemsCollection( plural: "Release Items", }, admin: { - defaultColumns: ["targetCollection", "targetDoc", "action", "status"], + defaultColumns: ["targetCollection", "targetDoc", "action"], hidden: true, }, access: options?.access, diff --git a/packages/payload-plugin-content-releases/src/collections/releases.ts b/packages/payload-plugin-content-releases/src/collections/releases.ts index 57485d1..ffc4aec 100644 --- a/packages/payload-plugin-content-releases/src/collections/releases.ts +++ b/packages/payload-plugin-content-releases/src/collections/releases.ts @@ -84,7 +84,7 @@ export function buildReleasesCollection( collection: RELEASE_ITEMS_SLUG, on: "release", admin: { - defaultColumns: ["targetCollection", "targetDoc", "action", "status"], + defaultColumns: ["targetCollection", "targetDoc", "action"], }, }, { From ccad5ac5d77f7d47ae846a5fa25fc58b93fecc4b Mon Sep 17 00:00:00 2001 From: Khurs Pavel Date: Wed, 29 Apr 2026 18:12:30 +0300 Subject: [PATCH 02/16] feat(content-releases): pill display for release status field Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/collections/releases.test.ts | 19 +++++++++ .../admin/components/ReleaseStatusField.tsx | 40 +++++++++++++++++++ .../src/client.ts | 1 + .../src/collections/releases.ts | 4 ++ 4 files changed, 64 insertions(+) create mode 100644 packages/payload-plugin-content-releases/src/admin/components/ReleaseStatusField.tsx diff --git a/packages/payload-plugin-content-releases/src/__tests__/collections/releases.test.ts b/packages/payload-plugin-content-releases/src/__tests__/collections/releases.test.ts index 0d792c2..b90ce85 100644 --- a/packages/payload-plugin-content-releases/src/__tests__/collections/releases.test.ts +++ b/packages/payload-plugin-content-releases/src/__tests__/collections/releases.test.ts @@ -72,3 +72,22 @@ describe("buildReleasesCollection — items join defaultColumns", () => { expect(itemsField.admin.defaultColumns).not.toContain("status"); }); }); + +describe("buildReleasesCollection — status field display", () => { + it("registers a custom Field component for status", () => { + const config = buildReleasesCollection(); + const statusField = config.fields.find( + (f) => "name" in f && f.name === "status", + ) as any; + expect(statusField.admin?.components?.Field).toContain("ReleaseStatusField"); + }); + + it("keeps status field type as select for validation", () => { + const config = buildReleasesCollection(); + const statusField = config.fields.find( + (f) => "name" in f && f.name === "status", + ) as any; + expect(statusField.type).toBe("select"); + expect(statusField.required).toBe(true); + }); +}); diff --git a/packages/payload-plugin-content-releases/src/admin/components/ReleaseStatusField.tsx b/packages/payload-plugin-content-releases/src/admin/components/ReleaseStatusField.tsx new file mode 100644 index 0000000..896a769 --- /dev/null +++ b/packages/payload-plugin-content-releases/src/admin/components/ReleaseStatusField.tsx @@ -0,0 +1,40 @@ +"use client"; + +import React from "react"; +import { Pill, useField } from "@payloadcms/ui"; +import type { ReleaseStatus } from "../../types"; + +const PILL_STYLE_BY_STATUS: Record = { + draft: "light-gray", + scheduled: "dark", + publishing: "warning", + published: "success", + reverting: "warning", + reverted: "light", + failed: "error", + cancelled: "light-gray", +}; + +function formatLabel(status: string) { + return status.charAt(0).toUpperCase() + status.slice(1); +} + +interface ReleaseStatusFieldProps { + path: string; + label?: string; +} + +export function ReleaseStatusField({ path, label }: ReleaseStatusFieldProps) { + const { value } = useField({ path }); + const status = (value ?? "draft") as ReleaseStatus; + const pillStyle = PILL_STYLE_BY_STATUS[status] ?? "light-gray"; + + return ( +
+ +
+ {formatLabel(status)} +
+
+ ); +} diff --git a/packages/payload-plugin-content-releases/src/client.ts b/packages/payload-plugin-content-releases/src/client.ts index 9586886..823a8ed 100644 --- a/packages/payload-plugin-content-releases/src/client.ts +++ b/packages/payload-plugin-content-releases/src/client.ts @@ -1,3 +1,4 @@ export { ReleaseSidebarField } from "./admin/components/ReleaseSidebarField"; export { ReleaseActionsField } from "./admin/components/ReleaseActionsField"; export { TargetDocCell } from "./admin/components/TargetDocCell"; +export { ReleaseStatusField } from "./admin/components/ReleaseStatusField"; diff --git a/packages/payload-plugin-content-releases/src/collections/releases.ts b/packages/payload-plugin-content-releases/src/collections/releases.ts index ffc4aec..78f4c5a 100644 --- a/packages/payload-plugin-content-releases/src/collections/releases.ts +++ b/packages/payload-plugin-content-releases/src/collections/releases.ts @@ -3,6 +3,7 @@ import { RELEASES_SLUG, RELEASE_ITEMS_SLUG, RELEASE_STATUSES, + PACKAGE_NAME, } from "../constants"; interface BuildReleasesCollectionOptions { @@ -58,6 +59,9 @@ export function buildReleasesCollection( })), admin: { position: "sidebar", + components: { + Field: `${PACKAGE_NAME}/client#ReleaseStatusField`, + }, }, }, { From b4f40b2ab071412b52d8f1fab8204a0bcceea52a Mon Sep 17 00:00:00 2001 From: Khurs Pavel Date: Wed, 29 Apr 2026 18:13:33 +0300 Subject: [PATCH 03/16] fix(content-releases): drop unused React import in ReleaseStatusField Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/admin/components/ReleaseStatusField.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/payload-plugin-content-releases/src/admin/components/ReleaseStatusField.tsx b/packages/payload-plugin-content-releases/src/admin/components/ReleaseStatusField.tsx index 896a769..0944aba 100644 --- a/packages/payload-plugin-content-releases/src/admin/components/ReleaseStatusField.tsx +++ b/packages/payload-plugin-content-releases/src/admin/components/ReleaseStatusField.tsx @@ -1,6 +1,5 @@ "use client"; -import React from "react"; import { Pill, useField } from "@payloadcms/ui"; import type { ReleaseStatus } from "../../types"; From 97a8d90467ae115b39a39eefc73fe1b1045b8313 Mon Sep 17 00:00:00 2001 From: Khurs Pavel Date: Wed, 29 Apr 2026 18:15:34 +0300 Subject: [PATCH 04/16] feat(content-releases): pill display for release-item status field Co-Authored-By: Claude Opus 4.7 (1M context) --- .../collections/releaseItems.test.ts | 18 ++++++++++ .../components/ReleaseItemStatusField.tsx | 35 +++++++++++++++++++ .../src/client.ts | 1 + .../src/collections/releaseItems.ts | 3 ++ 4 files changed, 57 insertions(+) create mode 100644 packages/payload-plugin-content-releases/src/admin/components/ReleaseItemStatusField.tsx diff --git a/packages/payload-plugin-content-releases/src/__tests__/collections/releaseItems.test.ts b/packages/payload-plugin-content-releases/src/__tests__/collections/releaseItems.test.ts index 236a9ea..f506f3a 100644 --- a/packages/payload-plugin-content-releases/src/__tests__/collections/releaseItems.test.ts +++ b/packages/payload-plugin-content-releases/src/__tests__/collections/releaseItems.test.ts @@ -80,3 +80,21 @@ describe("buildReleaseItemsCollection — admin.defaultColumns", () => { expect(config.admin?.defaultColumns).not.toContain("status"); }); }); + +describe("buildReleaseItemsCollection — status field display", () => { + it("registers a custom Field component for status", () => { + const config = buildReleaseItemsCollection(["pages"]); + const statusField = config.fields.find( + (f) => "name" in f && f.name === "status", + ) as any; + expect(statusField.admin?.components?.Field).toContain("ReleaseItemStatusField"); + }); + + it("keeps status field type as select for validation", () => { + const config = buildReleaseItemsCollection(["pages"]); + const statusField = config.fields.find( + (f) => "name" in f && f.name === "status", + ) as any; + expect(statusField.type).toBe("select"); + }); +}); diff --git a/packages/payload-plugin-content-releases/src/admin/components/ReleaseItemStatusField.tsx b/packages/payload-plugin-content-releases/src/admin/components/ReleaseItemStatusField.tsx new file mode 100644 index 0000000..1d442ab --- /dev/null +++ b/packages/payload-plugin-content-releases/src/admin/components/ReleaseItemStatusField.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { Pill, useField } from "@payloadcms/ui"; +import type { ReleaseItemStatus } from "../../types"; + +const PILL_STYLE_BY_STATUS: Record = { + pending: "light-gray", + published: "success", + failed: "error", + skipped: "light", +}; + +function formatLabel(status: string) { + return status.charAt(0).toUpperCase() + status.slice(1); +} + +interface ReleaseItemStatusFieldProps { + path: string; + label?: string; +} + +export function ReleaseItemStatusField({ path, label }: ReleaseItemStatusFieldProps) { + const { value } = useField({ path }); + const status = (value ?? "pending") as ReleaseItemStatus; + const pillStyle = PILL_STYLE_BY_STATUS[status] ?? "light-gray"; + + return ( +
+ +
+ {formatLabel(status)} +
+
+ ); +} diff --git a/packages/payload-plugin-content-releases/src/client.ts b/packages/payload-plugin-content-releases/src/client.ts index 823a8ed..5f635f4 100644 --- a/packages/payload-plugin-content-releases/src/client.ts +++ b/packages/payload-plugin-content-releases/src/client.ts @@ -2,3 +2,4 @@ export { ReleaseSidebarField } from "./admin/components/ReleaseSidebarField"; export { ReleaseActionsField } from "./admin/components/ReleaseActionsField"; export { TargetDocCell } from "./admin/components/TargetDocCell"; export { ReleaseStatusField } from "./admin/components/ReleaseStatusField"; +export { ReleaseItemStatusField } from "./admin/components/ReleaseItemStatusField"; diff --git a/packages/payload-plugin-content-releases/src/collections/releaseItems.ts b/packages/payload-plugin-content-releases/src/collections/releaseItems.ts index 79be06c..90bb3df 100644 --- a/packages/payload-plugin-content-releases/src/collections/releaseItems.ts +++ b/packages/payload-plugin-content-releases/src/collections/releaseItems.ts @@ -81,6 +81,9 @@ export function buildReleaseItemsCollection( })), admin: { position: "sidebar", + components: { + Field: `${PACKAGE_NAME}/client#ReleaseItemStatusField`, + }, }, }, { From 4d36414980d2e2cccb7dbe31a7293515ed9fe7c5 Mon Sep 17 00:00:00 2001 From: Khurs Pavel Date: Wed, 29 Apr 2026 18:16:58 +0300 Subject: [PATCH 05/16] feat(content-releases): rename action label to 'Release action' Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/__tests__/collections/releaseItems.test.ts | 10 ++++++++++ .../src/collections/releaseItems.ts | 1 + 2 files changed, 11 insertions(+) diff --git a/packages/payload-plugin-content-releases/src/__tests__/collections/releaseItems.test.ts b/packages/payload-plugin-content-releases/src/__tests__/collections/releaseItems.test.ts index f506f3a..9cba6ea 100644 --- a/packages/payload-plugin-content-releases/src/__tests__/collections/releaseItems.test.ts +++ b/packages/payload-plugin-content-releases/src/__tests__/collections/releaseItems.test.ts @@ -98,3 +98,13 @@ describe("buildReleaseItemsCollection — status field display", () => { expect(statusField.type).toBe("select"); }); }); + +describe("buildReleaseItemsCollection — action field label", () => { + it("uses 'Release action' as the action field label", () => { + const config = buildReleaseItemsCollection(["pages"]); + const actionField = config.fields.find( + (f) => "name" in f && f.name === "action", + ) as any; + expect(actionField.label).toBe("Release action"); + }); +}); diff --git a/packages/payload-plugin-content-releases/src/collections/releaseItems.ts b/packages/payload-plugin-content-releases/src/collections/releaseItems.ts index 90bb3df..7c8be6a 100644 --- a/packages/payload-plugin-content-releases/src/collections/releaseItems.ts +++ b/packages/payload-plugin-content-releases/src/collections/releaseItems.ts @@ -63,6 +63,7 @@ export function buildReleaseItemsCollection( { name: "action", type: "select", + label: "Release action", required: true, defaultValue: "publish", options: RELEASE_ITEM_ACTIONS.map((a) => ({ From b6ddcc25094e450f8c07d45b1a9604283e3420ae Mon Sep 17 00:00:00 2001 From: Khurs Pavel Date: Wed, 29 Apr 2026 18:34:01 +0300 Subject: [PATCH 06/16] feat(content-releases): pill cell for release-item action column Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/__tests__/collections/releaseItems.test.ts | 10 ++++++++++ .../src/admin/components/ReleaseActionCell.tsx | 14 ++++++++++++++ .../payload-plugin-content-releases/src/client.ts | 1 + .../src/collections/releaseItems.ts | 5 +++++ 4 files changed, 30 insertions(+) create mode 100644 packages/payload-plugin-content-releases/src/admin/components/ReleaseActionCell.tsx diff --git a/packages/payload-plugin-content-releases/src/__tests__/collections/releaseItems.test.ts b/packages/payload-plugin-content-releases/src/__tests__/collections/releaseItems.test.ts index 9cba6ea..dfa3f44 100644 --- a/packages/payload-plugin-content-releases/src/__tests__/collections/releaseItems.test.ts +++ b/packages/payload-plugin-content-releases/src/__tests__/collections/releaseItems.test.ts @@ -108,3 +108,13 @@ describe("buildReleaseItemsCollection — action field label", () => { expect(actionField.label).toBe("Release action"); }); }); + +describe("buildReleaseItemsCollection — action field Cell", () => { + it("registers a custom Cell for the action field", () => { + const config = buildReleaseItemsCollection(["pages"]); + const actionField = config.fields.find( + (f) => "name" in f && f.name === "action", + ) as any; + expect(actionField.admin?.components?.Cell).toContain("ReleaseActionCell"); + }); +}); diff --git a/packages/payload-plugin-content-releases/src/admin/components/ReleaseActionCell.tsx b/packages/payload-plugin-content-releases/src/admin/components/ReleaseActionCell.tsx new file mode 100644 index 0000000..2cca2d0 --- /dev/null +++ b/packages/payload-plugin-content-releases/src/admin/components/ReleaseActionCell.tsx @@ -0,0 +1,14 @@ +"use client"; + +import type { DefaultCellComponentProps } from "payload"; +import { Pill } from "@payloadcms/ui"; + +export function ReleaseActionCell({ cellData }: DefaultCellComponentProps) { + const value = typeof cellData === "string" ? cellData : ""; + if (!value) return ; + + const label = value.charAt(0).toUpperCase() + value.slice(1); + const pillStyle = value === "unpublish" ? "warning" : "success"; + + return {label}; +} diff --git a/packages/payload-plugin-content-releases/src/client.ts b/packages/payload-plugin-content-releases/src/client.ts index 5f635f4..f3f246c 100644 --- a/packages/payload-plugin-content-releases/src/client.ts +++ b/packages/payload-plugin-content-releases/src/client.ts @@ -3,3 +3,4 @@ export { ReleaseActionsField } from "./admin/components/ReleaseActionsField"; export { TargetDocCell } from "./admin/components/TargetDocCell"; export { ReleaseStatusField } from "./admin/components/ReleaseStatusField"; export { ReleaseItemStatusField } from "./admin/components/ReleaseItemStatusField"; +export { ReleaseActionCell } from "./admin/components/ReleaseActionCell"; diff --git a/packages/payload-plugin-content-releases/src/collections/releaseItems.ts b/packages/payload-plugin-content-releases/src/collections/releaseItems.ts index 7c8be6a..b7f0180 100644 --- a/packages/payload-plugin-content-releases/src/collections/releaseItems.ts +++ b/packages/payload-plugin-content-releases/src/collections/releaseItems.ts @@ -70,6 +70,11 @@ export function buildReleaseItemsCollection( label: a.charAt(0).toUpperCase() + a.slice(1), value: a, })), + admin: { + components: { + Cell: `${PACKAGE_NAME}/client#ReleaseActionCell`, + }, + }, }, { name: "status", From eba1c7067741ba81d8e2225983065273765874ff Mon Sep 17 00:00:00 2001 From: Khurs Pavel Date: Wed, 29 Apr 2026 18:35:37 +0300 Subject: [PATCH 07/16] feat(content-releases): label items join field as 'Resources' Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/__tests__/collections/releases.test.ts | 10 ++++++++++ .../src/collections/releases.ts | 1 + 2 files changed, 11 insertions(+) diff --git a/packages/payload-plugin-content-releases/src/__tests__/collections/releases.test.ts b/packages/payload-plugin-content-releases/src/__tests__/collections/releases.test.ts index b90ce85..f4a632e 100644 --- a/packages/payload-plugin-content-releases/src/__tests__/collections/releases.test.ts +++ b/packages/payload-plugin-content-releases/src/__tests__/collections/releases.test.ts @@ -91,3 +91,13 @@ describe("buildReleasesCollection — status field display", () => { expect(statusField.required).toBe(true); }); }); + +describe("buildReleasesCollection — items join field", () => { + it("uses 'Resources' as the items field label", () => { + const config = buildReleasesCollection(); + const itemsField = config.fields.find( + (f) => "name" in f && f.name === "items", + ) as any; + expect(itemsField.label).toBe("Resources"); + }); +}); diff --git a/packages/payload-plugin-content-releases/src/collections/releases.ts b/packages/payload-plugin-content-releases/src/collections/releases.ts index 78f4c5a..38e5a4e 100644 --- a/packages/payload-plugin-content-releases/src/collections/releases.ts +++ b/packages/payload-plugin-content-releases/src/collections/releases.ts @@ -85,6 +85,7 @@ export function buildReleasesCollection( { name: "items", type: "join", + label: "Resources", collection: RELEASE_ITEMS_SLUG, on: "release", admin: { From d34a2dffa57914621bcad356ce3d32f9d8c153f9 Mon Sep 17 00:00:00 2001 From: Khurs Pavel Date: Wed, 29 Apr 2026 18:36:16 +0300 Subject: [PATCH 08/16] test(content-releases): nested snapshot depth coverage for executePublish Co-Authored-By: Claude Opus 4.7 (1M context) --- .../executePublish.nested-snapshots.test.ts | 192 ++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 packages/payload-plugin-content-releases/src/__tests__/publish/executePublish.nested-snapshots.test.ts diff --git a/packages/payload-plugin-content-releases/src/__tests__/publish/executePublish.nested-snapshots.test.ts b/packages/payload-plugin-content-releases/src/__tests__/publish/executePublish.nested-snapshots.test.ts new file mode 100644 index 0000000..fffe8ed --- /dev/null +++ b/packages/payload-plugin-content-releases/src/__tests__/publish/executePublish.nested-snapshots.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect, vi } from "vitest"; +import { executePublish } from "../../publish/executePublish"; + +function makePayload(findByIdResult: any = {}) { + return { + findByID: vi.fn().mockResolvedValue(findByIdResult), + update: vi.fn().mockResolvedValue({}), + }; +} + +describe("executePublish — nested snapshot depth", () => { + it("preserves deeply nested blocks array (form-builder-style)", async () => { + const payload = makePayload({ id: "doc-0", updatedAt: "2026-01-01" }); + + const deepSnapshot = { + title: "Form page", + sections: [ + { + blockType: "form", + fields: [ + { + blockType: "text", + name: "email", + required: true, + validation: { pattern: "^.+@.+$" }, + }, + { + blockType: "select", + name: "country", + options: [ + { label: "Germany", value: "de" }, + { label: "France", value: "fr" }, + ], + }, + ], + }, + ], + }; + + const items = [{ + id: "item-0", + targetCollection: "pages", + targetDoc: "doc-0", + action: "publish", + snapshot: deepSnapshot, + baseVersion: null, + }]; + + await executePublish({ + items: items as any, + payload: payload as any, + conflictStrategy: "fail", + batchSize: 20, + }); + + expect(payload.update).toHaveBeenCalledTimes(1); + const updateArg = payload.update.mock.calls[0]![0]; + expect(updateArg.data).toEqual({ + ...deepSnapshot, + _status: "published", + }); + expect(updateArg.data.sections[0].fields[1].options[1].value).toBe("fr"); + }); + + it("preserves populated relationship objects in snapshot", async () => { + const payload = makePayload({ id: "doc-0", updatedAt: "2026-01-01" }); + + const snapshotWithRel = { + title: "Page with refs", + featuredMedia: { id: "media-1", filename: "hero.jpg", alt: "Hero" }, + relatedPosts: [ + { id: "post-1", title: "Post 1" }, + { id: "post-2", title: "Post 2" }, + ], + }; + + const items = [{ + id: "item-0", + targetCollection: "pages", + targetDoc: "doc-0", + action: "publish", + snapshot: snapshotWithRel, + baseVersion: null, + }]; + + await executePublish({ + items: items as any, + payload: payload as any, + conflictStrategy: "fail", + batchSize: 20, + }); + + const updateArg = payload.update.mock.calls[0]![0]; + expect(updateArg.data.featuredMedia).toEqual({ + id: "media-1", + filename: "hero.jpg", + alt: "Hero", + }); + expect(updateArg.data.relatedPosts).toHaveLength(2); + expect(updateArg.data.relatedPosts[0].id).toBe("post-1"); + }); + + it("does not mutate the snapshot object passed in", async () => { + const payload = makePayload({ id: "doc-0", updatedAt: "2026-01-01" }); + + const original = { + title: "Immutable", + nested: { array: [{ leaf: "value" }] }, + }; + const snapshot = JSON.parse(JSON.stringify(original)); + + const items = [{ + id: "item-0", + targetCollection: "pages", + targetDoc: "doc-0", + action: "publish", + snapshot, + baseVersion: null, + }]; + + await executePublish({ + items: items as any, + payload: payload as any, + conflictStrategy: "fail", + batchSize: 20, + }); + + expect(snapshot).toEqual(original); + }); + + it("captures previous state for deeply nested rollback snapshot", async () => { + const previousState = { + id: "doc-0", + updatedAt: "2026-01-01", + sections: [ + { blockType: "hero", title: "Old hero" }, + { blockType: "form", fields: [{ name: "old-field" }] }, + ], + }; + const payload = makePayload(previousState); + + const items = [{ + id: "item-0", + targetCollection: "pages", + targetDoc: "doc-0", + action: "publish", + snapshot: { sections: [{ blockType: "hero", title: "New hero" }] }, + baseVersion: null, + }]; + + const result = await executePublish({ + items: items as any, + payload: payload as any, + conflictStrategy: "fail", + batchSize: 20, + }); + + expect(result.rollbackSnapshot).toHaveLength(1); + expect(result.rollbackSnapshot[0]!.previousState).toEqual(previousState); + const captured = result.rollbackSnapshot[0]!.previousState as any; + expect(captured.sections[1].fields[0].name).toBe("old-field"); + }); + + it("handles unpublish without leaking snapshot fields", async () => { + const payload = makePayload({ + id: "doc-0", + updatedAt: "2026-01-01", + _status: "published", + sections: [{ blockType: "form", fields: [{ name: "foo" }] }], + }); + + const items = [{ + id: "item-0", + targetCollection: "pages", + targetDoc: "doc-0", + action: "unpublish", + snapshot: { whatever: true }, + baseVersion: null, + }]; + + await executePublish({ + items: items as any, + payload: payload as any, + conflictStrategy: "fail", + batchSize: 20, + }); + + const updateArg = payload.update.mock.calls[0]![0]; + expect(updateArg.data).toEqual({ _status: "draft" }); + expect(updateArg.data.whatever).toBeUndefined(); + }); +}); From 610ad6011f7053e30dc0cacb41a45d2906c6de03 Mon Sep 17 00:00:00 2001 From: Khurs Pavel Date: Wed, 29 Apr 2026 18:53:40 +0300 Subject: [PATCH 09/16] refactor(content-releases): remove errorLog field per single-release decision Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/__tests__/collections/releases.test.ts | 7 +++---- .../src/collections/releases.ts | 7 ------- .../src/publish/orchestratePublish.ts | 6 ++---- 3 files changed, 5 insertions(+), 15 deletions(-) diff --git a/packages/payload-plugin-content-releases/src/__tests__/collections/releases.test.ts b/packages/payload-plugin-content-releases/src/__tests__/collections/releases.test.ts index f4a632e..a127632 100644 --- a/packages/payload-plugin-content-releases/src/__tests__/collections/releases.test.ts +++ b/packages/payload-plugin-content-releases/src/__tests__/collections/releases.test.ts @@ -50,10 +50,9 @@ describe("releases collection", () => { expect(field.type).toBe("json"); }); - it("should have errorLog json field", () => { - const field = collection.fields.find((f: any) => f.name === "errorLog") as any; - expect(field).toBeDefined(); - expect(field.type).toBe("json"); + it("should not have errorLog field (removed per single-release decision)", () => { + const field = collection.fields.find((f: any) => f.name === "errorLog"); + expect(field).toBeUndefined(); }); it("should apply custom access if provided", () => { diff --git a/packages/payload-plugin-content-releases/src/collections/releases.ts b/packages/payload-plugin-content-releases/src/collections/releases.ts index 38e5a4e..066eda9 100644 --- a/packages/payload-plugin-content-releases/src/collections/releases.ts +++ b/packages/payload-plugin-content-releases/src/collections/releases.ts @@ -106,13 +106,6 @@ export function buildReleasesCollection( hidden: true, }, }, - { - name: "errorLog", - type: "json", - admin: { - readOnly: true, - }, - }, ], }; } diff --git a/packages/payload-plugin-content-releases/src/publish/orchestratePublish.ts b/packages/payload-plugin-content-releases/src/publish/orchestratePublish.ts index 82f123d..92b7d89 100644 --- a/packages/payload-plugin-content-releases/src/publish/orchestratePublish.ts +++ b/packages/payload-plugin-content-releases/src/publish/orchestratePublish.ts @@ -37,7 +37,7 @@ export async function orchestratePublish( await payload.update({ collection: RELEASES_SLUG as any, id: releaseId, - data: { status: "failed", errorLog: [{ error: "No items in release" }] } as any, + data: { status: "failed" } as any, }); return { status: "failed", published: 0, failed: 0, errors: [] }; } @@ -80,9 +80,7 @@ export async function orchestratePublish( id: releaseId, data: { status: finalStatus, - ...(hasFailures - ? { errorLog: result.failed } - : { publishedAt: new Date().toISOString() }), + ...(hasFailures ? {} : { publishedAt: new Date().toISOString() }), rollbackSnapshot: result.rollbackSnapshot, } as any, }); From 686e3f7aade1f1ab988018f4f3b1ea1d35c672b0 Mon Sep 17 00:00:00 2001 From: Khurs Pavel Date: Wed, 29 Apr 2026 18:58:51 +0300 Subject: [PATCH 10/16] chore: regenerate importMap and add missing dev deps - Adds libsql and @eslint/eslintrc to apps/dev (required for sqlite + eslint config). - Regenerated importMap to register new ReleaseStatusField, ReleaseItemStatusField, ReleaseActionCell. - Regenerated payload-types after removing errorLog field. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/dev/package.json | 4 +- apps/dev/src/app/(payload)/admin/importMap.js | 6 ++ apps/dev/src/payload-types.ts | 10 --- bun.lock | 62 ++++++++++++++----- 4 files changed, 56 insertions(+), 26 deletions(-) diff --git a/apps/dev/package.json b/apps/dev/package.json index 853a6ec..0abd720 100644 --- a/apps/dev/package.json +++ b/apps/dev/package.json @@ -16,10 +16,11 @@ "start": "cross-env NODE_OPTIONS=--no-deprecation next start" }, "dependencies": { + "@eslint/eslintrc": "^3.3.5", "@focus-reactive/payload-plugin-ab": "workspace:*", - "@focus-reactive/payload-plugin-presets": "workspace:*", "@focus-reactive/payload-plugin-comments": "workspace:*", "@focus-reactive/payload-plugin-content-releases": "workspace:*", + "@focus-reactive/payload-plugin-presets": "workspace:*", "@focus-reactive/payload-plugin-scheduling": "workspace:*", "@payloadcms/db-sqlite": "3.79.0", "@payloadcms/live-preview-react": "3.79.0", @@ -29,6 +30,7 @@ "cross-env": "7.0.3", "dotenv": "16.4.7", "graphql": "16.8.1", + "libsql": "^0.5.29", "next": "15.4.11", "payload": "3.79.0", "react": "19.2.1", diff --git a/apps/dev/src/app/(payload)/admin/importMap.js b/apps/dev/src/app/(payload)/admin/importMap.js index da1ec21..e4c4d5e 100644 --- a/apps/dev/src/app/(payload)/admin/importMap.js +++ b/apps/dev/src/app/(payload)/admin/importMap.js @@ -5,8 +5,11 @@ import { VariantsField as VariantsField_1e5bf2338fb8c7d4f6284f3e67c93951 } from import { ReleaseSidebarField as ReleaseSidebarField_01ef71babab0d155f9b7e71fc3fd8245 } from '@focus-reactive/payload-plugin-content-releases/client' import { PresetAdminComponentPreview as PresetAdminComponentPreview_f0a4a6f21f15d606fa328a5e35f17d11 } from '@focus-reactive/payload-plugin-presets/client' import { PresetAdminComponentCell as PresetAdminComponentCell_f0a4a6f21f15d606fa328a5e35f17d11 } from '@focus-reactive/payload-plugin-presets/client' +import { ReleaseStatusField as ReleaseStatusField_01ef71babab0d155f9b7e71fc3fd8245 } from '@focus-reactive/payload-plugin-content-releases/client' import { ReleaseActionsField as ReleaseActionsField_01ef71babab0d155f9b7e71fc3fd8245 } from '@focus-reactive/payload-plugin-content-releases/client' import { TargetDocCell as TargetDocCell_01ef71babab0d155f9b7e71fc3fd8245 } from '@focus-reactive/payload-plugin-content-releases/client' +import { ReleaseActionCell as ReleaseActionCell_01ef71babab0d155f9b7e71fc3fd8245 } from '@focus-reactive/payload-plugin-content-releases/client' +import { ReleaseItemStatusField as ReleaseItemStatusField_01ef71babab0d155f9b7e71fc3fd8245 } from '@focus-reactive/payload-plugin-content-releases/client' import { CommentsHeaderButton as CommentsHeaderButton_30d38dd40c31eff500900a16a2792204 } from '@focus-reactive/payload-plugin-comments/components/CommentsHeaderButton' import { CommentsProviderWrapper as CommentsProviderWrapper_bc62ec20ac2037360812e296d7662f4a } from '@focus-reactive/payload-plugin-comments/providers/CommentsProviderWrapper' import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc' @@ -19,8 +22,11 @@ export const importMap = { "@focus-reactive/payload-plugin-content-releases/client#ReleaseSidebarField": ReleaseSidebarField_01ef71babab0d155f9b7e71fc3fd8245, "@focus-reactive/payload-plugin-presets/client#PresetAdminComponentPreview": PresetAdminComponentPreview_f0a4a6f21f15d606fa328a5e35f17d11, "@focus-reactive/payload-plugin-presets/client#PresetAdminComponentCell": PresetAdminComponentCell_f0a4a6f21f15d606fa328a5e35f17d11, + "@focus-reactive/payload-plugin-content-releases/client#ReleaseStatusField": ReleaseStatusField_01ef71babab0d155f9b7e71fc3fd8245, "@focus-reactive/payload-plugin-content-releases/client#ReleaseActionsField": ReleaseActionsField_01ef71babab0d155f9b7e71fc3fd8245, "@focus-reactive/payload-plugin-content-releases/client#TargetDocCell": TargetDocCell_01ef71babab0d155f9b7e71fc3fd8245, + "@focus-reactive/payload-plugin-content-releases/client#ReleaseActionCell": ReleaseActionCell_01ef71babab0d155f9b7e71fc3fd8245, + "@focus-reactive/payload-plugin-content-releases/client#ReleaseItemStatusField": ReleaseItemStatusField_01ef71babab0d155f9b7e71fc3fd8245, "@focus-reactive/payload-plugin-comments/components/CommentsHeaderButton#CommentsHeaderButton": CommentsHeaderButton_30d38dd40c31eff500900a16a2792204, "@focus-reactive/payload-plugin-comments/providers/CommentsProviderWrapper#CommentsProviderWrapper": CommentsProviderWrapper_bc62ec20ac2037360812e296d7662f4a, "@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 diff --git a/apps/dev/src/payload-types.ts b/apps/dev/src/payload-types.ts index 61bfd06..3ec231f 100644 --- a/apps/dev/src/payload-types.ts +++ b/apps/dev/src/payload-types.ts @@ -334,15 +334,6 @@ export interface Release { | number | boolean | null; - errorLog?: - | { - [k: string]: unknown; - } - | unknown[] - | string - | number - | boolean - | null; updatedAt: string; createdAt: string; } @@ -698,7 +689,6 @@ export interface ReleasesSelect { items?: T; rollbackSnapshot?: T; rollbackSkipped?: T; - errorLog?: T; updatedAt?: T; createdAt?: T; } diff --git a/bun.lock b/bun.lock index 6f71a0f..344f328 100644 --- a/bun.lock +++ b/bun.lock @@ -19,6 +19,7 @@ "name": "dev", "version": "1.0.0", "dependencies": { + "@eslint/eslintrc": "^3.3.5", "@focus-reactive/payload-plugin-ab": "workspace:*", "@focus-reactive/payload-plugin-comments": "workspace:*", "@focus-reactive/payload-plugin-content-releases": "workspace:*", @@ -32,6 +33,7 @@ "cross-env": "7.0.3", "dotenv": "16.4.7", "graphql": "16.8.1", + "libsql": "^0.5.29", "next": "15.4.11", "payload": "3.79.0", "react": "19.2.1", @@ -97,7 +99,7 @@ }, "packages/payload-plugin-comments": { "name": "@focus-reactive/payload-plugin-comments", - "version": "1.6.0", + "version": "1.6.1", "dependencies": { "@radix-ui/react-dropdown-menu": "^2.1.16", "@tanstack/react-query": "5.0.0", @@ -164,7 +166,7 @@ }, "packages/payload-plugin-presets": { "name": "@focus-reactive/payload-plugin-presets", - "version": "0.9.1", + "version": "0.10.5", "dependencies": { "@radix-ui/react-popover": "1.1.15", }, @@ -514,9 +516,9 @@ "@libsql/core": ["@libsql/core@0.14.0", "", { "dependencies": { "js-base64": "^3.7.5" } }, "sha512-nhbuXf7GP3PSZgdCY2Ecj8vz187ptHlZQ0VRc751oB2C1W8jQUXKKklvt7t1LJiUTQBVJuadF628eUk+3cRi4Q=="], - "@libsql/darwin-arm64": ["@libsql/darwin-arm64@0.4.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-yOL742IfWUlUevnI5PdnIT4fryY3LYTdLm56bnY0wXBw7dhFcnjuA7jrH3oSVz2mjZTHujxoITgAE7V6Z+eAbg=="], + "@libsql/darwin-arm64": ["@libsql/darwin-arm64@0.5.29", "", { "os": "darwin", "cpu": "arm64" }, "sha512-K+2RIB1OGFPYQbfay48GakLhqf3ArcbHqPFu7EZiaUcRgFcdw8RoltsMyvbj5ix2fY0HV3Q3Ioa/ByvQdaSM0A=="], - "@libsql/darwin-x64": ["@libsql/darwin-x64@0.4.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-ezc7V75+eoyyH07BO9tIyJdqXXcRfZMbKcLCeF8+qWK5nP8wWuMcfOVywecsXGRbT99zc5eNra4NEx6z5PkSsA=="], + "@libsql/darwin-x64": ["@libsql/darwin-x64@0.5.29", "", { "os": "darwin", "cpu": "x64" }, "sha512-OtT+KFHsKFy1R5FVadr8FJ2Bb1mghtXTyJkxv0trocq7NuHntSki1eUbxpO5ezJesDvBlqFjnWaYYY516QNLhQ=="], "@libsql/hrana-client": ["@libsql/hrana-client@0.7.0", "", { "dependencies": { "@libsql/isomorphic-fetch": "^0.3.1", "@libsql/isomorphic-ws": "^0.1.5", "js-base64": "^3.7.5", "node-fetch": "^3.3.2" } }, "sha512-OF8fFQSkbL7vJY9rfuegK1R7sPgQ6kFMkDamiEccNUvieQ+3urzfDFI616oPl8V7T9zRmnTkSjMOImYCAVRVuw=="], @@ -524,15 +526,19 @@ "@libsql/isomorphic-ws": ["@libsql/isomorphic-ws@0.1.5", "", { "dependencies": { "@types/ws": "^8.5.4", "ws": "^8.13.0" } }, "sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg=="], - "@libsql/linux-arm64-gnu": ["@libsql/linux-arm64-gnu@0.4.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-WlX2VYB5diM4kFfNaYcyhw5y+UJAI3xcMkEUJZPtRDEIu85SsSFrQ+gvoKfcVh76B//ztSeEX2wl9yrjF7BBCA=="], + "@libsql/linux-arm-gnueabihf": ["@libsql/linux-arm-gnueabihf@0.5.29", "", { "os": "linux", "cpu": "arm" }, "sha512-CD4n4zj7SJTHso4nf5cuMoWoMSS7asn5hHygsDuhRl8jjjCTT3yE+xdUvI4J7zsyb53VO5ISh4cwwOtf6k2UhQ=="], - "@libsql/linux-arm64-musl": ["@libsql/linux-arm64-musl@0.4.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-6kK9xAArVRlTCpWeqnNMCoXW1pe7WITI378n4NpvU5EJ0Ok3aNTIC2nRPRjhro90QcnmLL1jPcrVwO4WD1U0xw=="], + "@libsql/linux-arm-musleabihf": ["@libsql/linux-arm-musleabihf@0.5.29", "", { "os": "linux", "cpu": "arm" }, "sha512-2Z9qBVpEJV7OeflzIR3+l5yAd4uTOLxklScYTwpZnkm2vDSGlC1PRlueLaufc4EFITkLKXK2MWBpexuNJfMVcg=="], - "@libsql/linux-x64-gnu": ["@libsql/linux-x64-gnu@0.4.7", "", { "os": "linux", "cpu": "x64" }, "sha512-CMnNRCmlWQqqzlTw6NeaZXzLWI8bydaXDke63JTUCvu8R+fj/ENsLrVBtPDlxQ0wGsYdXGlrUCH8Qi9gJep0yQ=="], + "@libsql/linux-arm64-gnu": ["@libsql/linux-arm64-gnu@0.5.29", "", { "os": "linux", "cpu": "arm64" }, "sha512-gURBqaiXIGGwFNEaUj8Ldk7Hps4STtG+31aEidCk5evMMdtsdfL3HPCpvys+ZF/tkOs2MWlRWoSq7SOuCE9k3w=="], - "@libsql/linux-x64-musl": ["@libsql/linux-x64-musl@0.4.7", "", { "os": "linux", "cpu": "x64" }, "sha512-nI6tpS1t6WzGAt1Kx1n1HsvtBbZ+jHn0m7ogNNT6pQHZQj7AFFTIMeDQw/i/Nt5H38np1GVRNsFe99eSIMs9XA=="], + "@libsql/linux-arm64-musl": ["@libsql/linux-arm64-musl@0.5.29", "", { "os": "linux", "cpu": "arm64" }, "sha512-fwgYZ0H8mUkyVqXZHF3mT/92iIh1N94Owi/f66cPVNsk9BdGKq5gVpoKO+7UxaNzuEH1roJp2QEwsCZMvBLpqg=="], - "@libsql/win32-x64-msvc": ["@libsql/win32-x64-msvc@0.4.7", "", { "os": "win32", "cpu": "x64" }, "sha512-7pJzOWzPm6oJUxml+PCDRzYQ4A1hTMHAciTAHfFK4fkbDZX33nWPVG7Y3vqdKtslcwAzwmrNDc6sXy2nwWnbiw=="], + "@libsql/linux-x64-gnu": ["@libsql/linux-x64-gnu@0.5.29", "", { "os": "linux", "cpu": "x64" }, "sha512-y14V0vY0nmMC6G0pHeJcEarcnGU2H6cm21ZceRkacWHvQAEhAG0latQkCtoS2njFOXiYIg+JYPfAoWKbi82rkg=="], + + "@libsql/linux-x64-musl": ["@libsql/linux-x64-musl@0.5.29", "", { "os": "linux", "cpu": "x64" }, "sha512-gquqwA/39tH4pFl+J9n3SOMSymjX+6kZ3kWgY3b94nXFTwac9bnFNMffIomgvlFaC4ArVqMnOZD3nuJ3H3VO1w=="], + + "@libsql/win32-x64-msvc": ["@libsql/win32-x64-msvc@0.5.29", "", { "os": "win32", "cpu": "x64" }, "sha512-4/0CvEdhi6+KjMxMaVbFM2n2Z44escBRoEYpR+gZg64DdetzGnYm8mcNLcoySaDJZNaBd6wz5DNdgRmcI4hXcg=="], "@manypkg/find-root": ["@manypkg/find-root@1.1.0", "", { "dependencies": { "@babel/runtime": "^7.5.5", "@types/node": "^12.7.1", "find-up": "^4.1.0", "fs-extra": "^8.1.0" } }, "sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA=="], @@ -1308,7 +1314,7 @@ "detect-indent": ["detect-indent@6.1.0", "", {}, "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA=="], - "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="], "detect-newline": ["detect-newline@3.1.0", "", {}, "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA=="], @@ -1802,7 +1808,7 @@ "lib0": ["lib0@0.2.117", "", { "dependencies": { "isomorphic.js": "^0.2.4" }, "bin": { "0serve": "bin/0serve.js", "0gentesthtml": "bin/gentesthtml.js", "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js" } }, "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw=="], - "libsql": ["libsql@0.4.7", "", { "dependencies": { "@neon-rs/load": "^0.0.4", "detect-libc": "2.0.2" }, "optionalDependencies": { "@libsql/darwin-arm64": "0.4.7", "@libsql/darwin-x64": "0.4.7", "@libsql/linux-arm64-gnu": "0.4.7", "@libsql/linux-arm64-musl": "0.4.7", "@libsql/linux-x64-gnu": "0.4.7", "@libsql/linux-x64-musl": "0.4.7", "@libsql/win32-x64-msvc": "0.4.7" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-T9eIRCs6b0J1SHKYIvD8+KCJMcWZ900iZyxdnSCdqxN12Z1ijzT+jY5nrk72Jw4B0HGzms2NgpryArlJqvc3Lw=="], + "libsql": ["libsql@0.5.29", "", { "dependencies": { "@neon-rs/load": "^0.0.4", "detect-libc": "2.0.2" }, "optionalDependencies": { "@libsql/darwin-arm64": "0.5.29", "@libsql/darwin-x64": "0.5.29", "@libsql/linux-arm-gnueabihf": "0.5.29", "@libsql/linux-arm-musleabihf": "0.5.29", "@libsql/linux-arm64-gnu": "0.5.29", "@libsql/linux-arm64-musl": "0.5.29", "@libsql/linux-x64-gnu": "0.5.29", "@libsql/linux-x64-musl": "0.5.29", "@libsql/win32-x64-msvc": "0.5.29" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "arm", "x64", "arm64", ] }, "sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg=="], "lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="], @@ -2396,7 +2402,7 @@ "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], - "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], "strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="], @@ -2658,8 +2664,6 @@ "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], - "@eslint/eslintrc/strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], - "@focus-reactive/payload-plugin-ab/prettier": ["prettier@3.0.0", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-zBf5eHpwHOGPC47h0zrPyNn+eAEIdEzfywMoYn2XPi0P44Zp0tSq64rq0xAREh4auw2cJZHo9QUob+NqCQky4g=="], "@focus-reactive/payload-plugin-ab/typescript": ["typescript@5.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-wVORMBGO/FAs/++blGNeAVdbNKtIh1rbBL2EyQ1+J9lClJ93KiiKe8PmFIVdXhHcyv44SL9oglmfeSsndo0jRw=="], @@ -2694,6 +2698,8 @@ "@lexical/react/react-error-boundary": ["react-error-boundary@6.1.1", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-BrYwPOdXi5mqkk5lw+Uvt0ThHx32rCt3BkukS4X23A2AIWDPSGX6iaWTc0y9TU/mHDA/6qOSGel+B2ERkOvD1w=="], + "@libsql/client/libsql": ["libsql@0.4.7", "", { "dependencies": { "@neon-rs/load": "^0.0.4", "detect-libc": "2.0.2" }, "optionalDependencies": { "@libsql/darwin-arm64": "0.4.7", "@libsql/darwin-x64": "0.4.7", "@libsql/linux-arm64-gnu": "0.4.7", "@libsql/linux-arm64-musl": "0.4.7", "@libsql/linux-x64-gnu": "0.4.7", "@libsql/linux-x64-musl": "0.4.7", "@libsql/win32-x64-msvc": "0.4.7" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-T9eIRCs6b0J1SHKYIvD8+KCJMcWZ900iZyxdnSCdqxN12Z1ijzT+jY5nrk72Jw4B0HGzms2NgpryArlJqvc3Lw=="], + "@manypkg/find-root/@types/node": ["@types/node@12.20.55", "", {}, "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ=="], "@manypkg/find-root/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], @@ -2706,6 +2712,8 @@ "@next/eslint-plugin-next/fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="], + "@parcel/watcher/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "@payloadcms/db-sqlite/uuid": ["uuid@9.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg=="], "@payloadcms/drizzle/uuid": ["uuid@9.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg=="], @@ -2874,7 +2882,7 @@ "json-schema-to-typescript/prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], - "libsql/detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="], + "lightningcss/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "load-json-file/parse-json": ["parse-json@4.0.0", "", { "dependencies": { "error-ex": "^1.3.1", "json-parse-better-errors": "^1.0.1" } }, "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw=="], @@ -3216,6 +3224,8 @@ "postcss-load-config/yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], + "rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], + "react-datepicker/date-fns": ["date-fns@3.6.0", "", {}, "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="], "react-promise-suspense/fast-deep-equal": ["fast-deep-equal@2.0.1", "", {}, "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w=="], @@ -3248,6 +3258,8 @@ "semantic-release/yargs": ["yargs@18.0.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="], + "sharp/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "signale/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], "signale/figures": ["figures@2.0.0", "", { "dependencies": { "escape-string-regexp": "^1.0.5" } }, "sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA=="], @@ -3354,6 +3366,20 @@ "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "@libsql/client/libsql/@libsql/darwin-arm64": ["@libsql/darwin-arm64@0.4.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-yOL742IfWUlUevnI5PdnIT4fryY3LYTdLm56bnY0wXBw7dhFcnjuA7jrH3oSVz2mjZTHujxoITgAE7V6Z+eAbg=="], + + "@libsql/client/libsql/@libsql/darwin-x64": ["@libsql/darwin-x64@0.4.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-ezc7V75+eoyyH07BO9tIyJdqXXcRfZMbKcLCeF8+qWK5nP8wWuMcfOVywecsXGRbT99zc5eNra4NEx6z5PkSsA=="], + + "@libsql/client/libsql/@libsql/linux-arm64-gnu": ["@libsql/linux-arm64-gnu@0.4.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-WlX2VYB5diM4kFfNaYcyhw5y+UJAI3xcMkEUJZPtRDEIu85SsSFrQ+gvoKfcVh76B//ztSeEX2wl9yrjF7BBCA=="], + + "@libsql/client/libsql/@libsql/linux-arm64-musl": ["@libsql/linux-arm64-musl@0.4.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-6kK9xAArVRlTCpWeqnNMCoXW1pe7WITI378n4NpvU5EJ0Ok3aNTIC2nRPRjhro90QcnmLL1jPcrVwO4WD1U0xw=="], + + "@libsql/client/libsql/@libsql/linux-x64-gnu": ["@libsql/linux-x64-gnu@0.4.7", "", { "os": "linux", "cpu": "x64" }, "sha512-CMnNRCmlWQqqzlTw6NeaZXzLWI8bydaXDke63JTUCvu8R+fj/ENsLrVBtPDlxQ0wGsYdXGlrUCH8Qi9gJep0yQ=="], + + "@libsql/client/libsql/@libsql/linux-x64-musl": ["@libsql/linux-x64-musl@0.4.7", "", { "os": "linux", "cpu": "x64" }, "sha512-nI6tpS1t6WzGAt1Kx1n1HsvtBbZ+jHn0m7ogNNT6pQHZQj7AFFTIMeDQw/i/Nt5H38np1GVRNsFe99eSIMs9XA=="], + + "@libsql/client/libsql/@libsql/win32-x64-msvc": ["@libsql/win32-x64-msvc@0.4.7", "", { "os": "win32", "cpu": "x64" }, "sha512-7pJzOWzPm6oJUxml+PCDRzYQ4A1hTMHAciTAHfFK4fkbDZX33nWPVG7Y3vqdKtslcwAzwmrNDc6sXy2nwWnbiw=="], + "@manypkg/find-root/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], "@manypkg/find-root/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], @@ -3594,6 +3620,8 @@ "next/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + "next/sharp/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "npm/minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], "npm/minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], @@ -3794,6 +3822,8 @@ "@focus-reactive/payload-plugin-comments/next/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + "@focus-reactive/payload-plugin-comments/next/sharp/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "@focus-reactive/payload-plugin-content-releases/next/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], "@focus-reactive/payload-plugin-content-releases/next/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], @@ -3836,6 +3866,8 @@ "@focus-reactive/payload-plugin-content-releases/next/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + "@focus-reactive/payload-plugin-content-releases/next/sharp/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "@focus-reactive/payload-plugin-content-releases/tsup/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], "@focus-reactive/payload-plugin-content-releases/tsup/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q=="], From b1ff35a18956771593c5539a9de3daf73ff5aaf3 Mon Sep 17 00:00:00 2001 From: Khurs Pavel Date: Wed, 29 Apr 2026 19:28:46 +0300 Subject: [PATCH 11/16] feat(content-releases): UX polish + rollback updates item status - ReleaseSidebarField: tighter gap between sidebar buttons (margin=false on Button removes default 24px vertical margin). - ReleaseDrawer: 'Create New Release' wrapped in
so it gets auto width instead of stretching across the drawer. - VersionPickerDrawer/ReleaseSidebarField/ReleaseDrawer: drop unused React imports (auto JSX runtime). - releases.ts: scheduledAt validate function rejects past dates, but allows unchanged value (compares against options.previousValue) so re-saving an old draft doesn't trigger. - releases.ts items join: admin.allowCreate = false hides the misleading 'Add new'/'Create new Release Item' buttons; admin.description now clarifies that resources are added from the document sidebar. - constants/types: add 'reverted' to RELEASE_ITEM_STATUSES + ReleaseItemStatus. - ReleaseItemStatusField: pillStyle 'dark' for reverted. - orchestrateRollback: after each successful restore, look up the matching release-item and set its status to 'reverted'. - releaseItemsBeforeChange: extend the 'publishing' status-only-update bypass to also cover 'reverting', so the orchestrator can set item.status during rollback. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/dev/src/payload-types.ts | 5 +++- .../endpoints/rollbackRelease.test.ts | 1 + .../src/admin/components/ReleaseDrawer.tsx | 10 ++++---- .../components/ReleaseItemStatusField.tsx | 1 + .../admin/components/ReleaseSidebarField.tsx | 8 +++++-- .../admin/components/VersionPickerDrawer.tsx | 2 +- .../src/collections/releases.ts | 16 +++++++++++++ .../src/constants.ts | 1 + .../src/hooks/releaseItemsBeforeChange.ts | 7 ++++-- .../src/rollback/orchestrateRollback.ts | 24 ++++++++++++++++++- .../src/types.ts | 2 +- 11 files changed, 65 insertions(+), 12 deletions(-) diff --git a/apps/dev/src/payload-types.ts b/apps/dev/src/payload-types.ts index 3ec231f..77174bb 100644 --- a/apps/dev/src/payload-types.ts +++ b/apps/dev/src/payload-types.ts @@ -311,6 +311,9 @@ export interface Release { status: 'draft' | 'scheduled' | 'publishing' | 'published' | 'reverting' | 'reverted' | 'failed' | 'cancelled'; scheduledAt?: string | null; publishedAt?: string | null; + /** + * Resources are added from the sidebar of any document — open a page and use 'Add Current State to Release' or 'Add Version to Release'. + */ items?: { docs?: (number | ReleaseItem)[]; hasNextPage?: boolean; @@ -347,7 +350,7 @@ export interface ReleaseItem { targetCollection: 'pages'; targetDoc: string; action: 'publish' | 'unpublish'; - status: 'pending' | 'published' | 'failed' | 'skipped'; + status: 'pending' | 'published' | 'failed' | 'skipped' | 'reverted'; snapshot: | { [k: string]: unknown; diff --git a/packages/payload-plugin-content-releases/src/__tests__/endpoints/rollbackRelease.test.ts b/packages/payload-plugin-content-releases/src/__tests__/endpoints/rollbackRelease.test.ts index 0dbc525..c31ec23 100644 --- a/packages/payload-plugin-content-releases/src/__tests__/endpoints/rollbackRelease.test.ts +++ b/packages/payload-plugin-content-releases/src/__tests__/endpoints/rollbackRelease.test.ts @@ -24,6 +24,7 @@ function makeReq({ payload: { findByID: vi.fn().mockResolvedValue(releaseData), findVersions: vi.fn().mockResolvedValue({ docs: [] }), + find: vi.fn().mockResolvedValue({ docs: [{ id: "item-1" }] }), update: vi.fn().mockResolvedValue({}), }, }; diff --git a/packages/payload-plugin-content-releases/src/admin/components/ReleaseDrawer.tsx b/packages/payload-plugin-content-releases/src/admin/components/ReleaseDrawer.tsx index 5091562..3989f9a 100644 --- a/packages/payload-plugin-content-releases/src/admin/components/ReleaseDrawer.tsx +++ b/packages/payload-plugin-content-releases/src/admin/components/ReleaseDrawer.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { createPortal } from "react-dom"; import { Button, Drawer, Pill, toast, useModal, DatePicker } from "@payloadcms/ui"; @@ -192,9 +192,11 @@ export function ReleaseDrawer({
{/* Create New Release */} {!showCreateForm ? ( - +
+ +
) : (
= { published: "success", failed: "error", skipped: "light", + reverted: "dark", }; function formatLabel(status: string) { diff --git a/packages/payload-plugin-content-releases/src/admin/components/ReleaseSidebarField.tsx b/packages/payload-plugin-content-releases/src/admin/components/ReleaseSidebarField.tsx index 67311ae..9e811ec 100644 --- a/packages/payload-plugin-content-releases/src/admin/components/ReleaseSidebarField.tsx +++ b/packages/payload-plugin-content-releases/src/admin/components/ReleaseSidebarField.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { Button, Pill, ShimmerEffect, useDocumentInfo, useForm, useModal } from "@payloadcms/ui"; import { ReleaseDrawer } from "./ReleaseDrawer"; import { VersionPickerDrawer } from "./VersionPickerDrawer"; @@ -97,10 +97,12 @@ export function ReleaseSidebarField() { )} -
+
+ {canResetToDraft && ( + + )} {status === "published" && }
); From 07a613c69f325f2c265cd4e53771055156e3357e Mon Sep 17 00:00:00 2001 From: Khurs Pavel Date: Thu, 30 Apr 2026 00:01:43 +0300 Subject: [PATCH 15/16] fix(content-releases): sync release-item baseVersion on rollback restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reproduced via end-to-end API flow: 1. publish release → page.updatedAt = T_publish 2. rollback → executeRollback restores previousState via payload.update, which generates a NEW page.updatedAt (T_rollback). item.baseVersion was left at T_staging. 3. reset to draft + republish → executePublish detected page.updatedAt T_rollback != baseVersion T_staging and marked the item as a conflict ("Conflict: document modified since staging…"), leaving the release in 'failed' state with no clean recovery path. Root cause: rollback was not the user touching the page, so its own write should not appear as an external modification to the release. baseVersion is meant to track 'the version we are aware of'; after a rollback we are aware of the new updatedAt because we just wrote it. Fix: • executeRollback now returns newUpdatedAt for each restored entry (read off the doc returned by payload.update). • orchestrateRollback updates the matching release-item with both status='reverted' and baseVersion=newUpdatedAt. The releaseItemsBeforeChange hook already allows status-bearing updates while the release is in 'reverting'. • executeRollback test updated to assert the new shape. Verified via the same end-to-end script used to surface the bug: republish after rollback now returns 1 published / 0 failed and the release lands back in 'published' instead of 'failed'. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/__tests__/rollback/executeRollback.test.ts | 8 ++++++-- .../src/rollback/executeRollback.ts | 8 +++++--- .../src/rollback/orchestrateRollback.ts | 4 +++- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/payload-plugin-content-releases/src/__tests__/rollback/executeRollback.test.ts b/packages/payload-plugin-content-releases/src/__tests__/rollback/executeRollback.test.ts index a3000bb..b0c52f9 100644 --- a/packages/payload-plugin-content-releases/src/__tests__/rollback/executeRollback.test.ts +++ b/packages/payload-plugin-content-releases/src/__tests__/rollback/executeRollback.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi } from "vitest"; import { executeRollback } from "../../rollback/executeRollback"; import type { RollbackEntry } from "../../rollback/previewRollback"; -function makePayload({ updateResult = {} as any } = {}) { +function makePayload({ updateResult = { updatedAt: "2026-02-01T00:00:00.000Z" } as any } = {}) { return { update: vi.fn().mockResolvedValue(updateResult) }; } @@ -30,7 +30,11 @@ describe("executeRollback", () => { const result = await executeRollback({ eligible: [entry], payload: payload as any }); expect(result.restored).toHaveLength(1); - expect(result.restored[0]).toEqual({ collection: "pages", docId: "doc-1" }); + expect(result.restored[0]).toEqual({ + collection: "pages", + docId: "doc-1", + newUpdatedAt: "2026-02-01T00:00:00.000Z", + }); expect(result.failed).toHaveLength(0); }); diff --git a/packages/payload-plugin-content-releases/src/rollback/executeRollback.ts b/packages/payload-plugin-content-releases/src/rollback/executeRollback.ts index de11411..09afd30 100644 --- a/packages/payload-plugin-content-releases/src/rollback/executeRollback.ts +++ b/packages/payload-plugin-content-releases/src/rollback/executeRollback.ts @@ -10,6 +10,7 @@ interface RollbackResult { restored: Array<{ collection: string; docId: string; + newUpdatedAt: string | null; }>; failed: Array<{ collection: string; @@ -22,7 +23,7 @@ export async function executeRollback( options: ExecuteRollbackOptions, ): Promise { const { eligible, payload } = options; - const restored: Array<{ collection: string; docId: string }> = []; + const restored: RollbackResult["restored"] = []; const failed: Array<{ collection: string; docId: string; error: string }> = []; @@ -39,15 +40,16 @@ export async function executeRollback( const { id, createdAt, updatedAt, ...strippedState } = entry.previousState; try { - await payload.update({ + const updatedDoc = (await payload.update({ collection: entry.collection, id: entry.docId, data: strippedState, - }); + })) as { updatedAt?: string }; restored.push({ collection: entry.collection, docId: entry.docId, + newUpdatedAt: updatedDoc?.updatedAt ?? null, }); } catch (error) { const errorMessage = diff --git a/packages/payload-plugin-content-releases/src/rollback/orchestrateRollback.ts b/packages/payload-plugin-content-releases/src/rollback/orchestrateRollback.ts index 6da0650..95ea5b8 100644 --- a/packages/payload-plugin-content-releases/src/rollback/orchestrateRollback.ts +++ b/packages/payload-plugin-content-releases/src/rollback/orchestrateRollback.ts @@ -72,10 +72,12 @@ export async function orchestrateRollback( }); const itemId = items.docs[0]?.id; if (itemId) { + const itemUpdate: Record = { status: "reverted" }; + if (r.newUpdatedAt) itemUpdate.baseVersion = r.newUpdatedAt; await payload.update({ collection: RELEASE_ITEMS_SLUG as any, id: itemId, - data: { status: "reverted" } as any, + data: itemUpdate as any, }); } } From d866fb91544d626b6e8df2bd2f1d4761636ecece Mon Sep 17 00:00:00 2001 From: Khurs Pavel Date: Thu, 30 Apr 2026 13:00:07 +0300 Subject: [PATCH 16/16] =?UTF-8?q?feat(content-releases):=20conflict=20reco?= =?UTF-8?q?very=20=E2=80=94=20failed=20banner=20with=20refresh-snapshot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously a release that landed in 'failed' (conflict between staged snapshot and the document's current state) had no recovery path from the admin UI. Reset to draft + republish would just hit the same conflict again because item.baseVersion / item.snapshot were stale. This adds: 1. New endpoint POST /api/content-releases/items/:itemId/refresh-snapshot It re-reads the target document via Local API, drops db-managed keys, and writes the current state back to the item as the new snapshot + baseVersion, while resetting item.status to 'pending'. 2. releaseItemsBeforeChange now respects a context flag ({ contentReleasesBypass: true }) so the refresh endpoint can update an item even when the parent release is 'failed' or 'reverted'. The hook still gates all other write paths. 3. ReleaseActionsField fetches release-items with status='failed' when the release itself is 'failed' and renders an error Banner above the action buttons. The banner explains the typical cause and exposes a per-item 'Refresh snapshot' button. After refreshing, item.status flips to 'pending' and the banner item disappears; the user can then 'Reset to draft' → 'Publish Now' and the release republishes cleanly. Verified via chrome-devtools MCP on a real conflict scenario: publish (ok) → manual page edit → republish (failed) → banner shows 1 failed item → click 'Refresh snapshot' → item becomes pending → reset to draft → publish now → published. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../admin/components/ReleaseActionsField.tsx | 152 +++++++++++++++--- .../src/endpoints/refreshItemSnapshot.ts | 63 ++++++++ .../src/hooks/releaseItemsBeforeChange.ts | 4 +- .../src/plugin.ts | 6 + 4 files changed, 199 insertions(+), 26 deletions(-) create mode 100644 packages/payload-plugin-content-releases/src/endpoints/refreshItemSnapshot.ts diff --git a/packages/payload-plugin-content-releases/src/admin/components/ReleaseActionsField.tsx b/packages/payload-plugin-content-releases/src/admin/components/ReleaseActionsField.tsx index c25490f..c00e389 100644 --- a/packages/payload-plugin-content-releases/src/admin/components/ReleaseActionsField.tsx +++ b/packages/payload-plugin-content-releases/src/admin/components/ReleaseActionsField.tsx @@ -1,22 +1,59 @@ "use client"; -import { useState } from "react"; -import { Button, toast, useDocumentInfo } from "@payloadcms/ui"; +import { useCallback, useEffect, useState } from "react"; +import { Banner, Button, toast, useDocumentInfo } from "@payloadcms/ui"; import { useRouter } from "next/navigation"; import { ReleaseStatus } from "../../types"; import { isValidTransition } from "../../validation/statusTransitions"; import { getPublishButtonProps } from "./getPublishButtonProps"; import { RollbackButton } from "./RollbackButton"; +interface FailedItem { + id: string; + targetCollection: string; + targetDoc: string; +} + export function ReleaseActionsField() { const { id, data } = useDocumentInfo(); const router = useRouter(); const [publishing, setPublishing] = useState(false); const [resetting, setResetting] = useState(false); + const [failedItems, setFailedItems] = useState([]); + const [refreshingItem, setRefreshingItem] = useState(null); + + const status = data?.status as ReleaseStatus; + + const fetchFailedItems = useCallback(async () => { + if (!id) return; + if (status !== "failed") { + setFailedItems([]); + return; + } + try { + const res = await fetch( + `/api/release-items?where[release][equals]=${id}&where[status][equals]=failed&limit=100&depth=0`, + ); + if (!res.ok) return; + const json = await res.json(); + setFailedItems( + (json.docs ?? []).map((d: any) => ({ + id: String(d.id), + targetCollection: d.targetCollection, + targetDoc: String(d.targetDoc), + })), + ); + } catch { + // non-fatal + } + }, [id, status]); + + useEffect(() => { + void fetchFailedItems(); + }, [fetchFailedItems]); if (!id) return null; - const status = data?.status as ReleaseStatus; const { disabled, tooltip } = getPublishButtonProps(status); const canResetToDraft = !!status && isValidTransition(status, "draft"); @@ -43,6 +80,28 @@ export function ReleaseActionsField() { } }; + const handleRefreshItem = async (itemId: string) => { + setRefreshingItem(itemId); + try { + const res = await fetch( + `/api/content-releases/items/${itemId}/refresh-snapshot`, + { method: "POST" }, + ); + const result = await res.json(); + if (!res.ok) { + toast.error(result?.error ?? "Failed to refresh snapshot"); + return; + } + toast.success("Snapshot refreshed"); + router.refresh(); + void fetchFailedItems(); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to refresh snapshot"); + } finally { + setRefreshingItem(null); + } + }; + const handleResetToDraft = async () => { setResetting(true); try { @@ -68,32 +127,75 @@ export function ReleaseActionsField() { }; return ( -
- - {canResetToDraft && ( +
+ {status === "failed" && failedItems.length > 0 && ( + +
+
+ + {failedItems.length} item{failedItems.length === 1 ? "" : "s"} failed to publish. + {" "} + Usually this means the document was modified after the snapshot + was staged. Refresh the snapshot to use the document’s + current state, then reset to draft and republish. +
+
    + {failedItems.map((item) => ( +
  • + + {item.targetCollection} / {item.targetDoc} + + +
  • + ))} +
+
+
+ )} + +
- )} - {status === "published" && } + {canResetToDraft && ( + + )} + {status === "published" && } +
); } diff --git a/packages/payload-plugin-content-releases/src/endpoints/refreshItemSnapshot.ts b/packages/payload-plugin-content-releases/src/endpoints/refreshItemSnapshot.ts new file mode 100644 index 0000000..e88e20f --- /dev/null +++ b/packages/payload-plugin-content-releases/src/endpoints/refreshItemSnapshot.ts @@ -0,0 +1,63 @@ +import type { PayloadHandler } from "payload"; +import { RELEASE_ITEMS_SLUG } from "../constants"; + +export function createRefreshItemSnapshotHandler(): PayloadHandler { + return async (req) => { + if (!req.user) { + return Response.json( + { error: "Authentication required" }, + { status: 401 }, + ); + } + + const itemId = (req.routeParams as any)?.itemId as string | undefined; + if (!itemId) { + return Response.json({ error: "Missing item ID" }, { status: 400 }); + } + + try { + const item = (await req.payload.findByID({ + collection: RELEASE_ITEMS_SLUG as any, + id: itemId, + })) as unknown as { + targetCollection: string; + targetDoc: string; + }; + + if (!item) { + return Response.json({ error: "Item not found" }, { status: 404 }); + } + + const currentDoc = (await req.payload.findByID({ + collection: item.targetCollection as any, + id: item.targetDoc, + draft: true, + })) as Record & { updatedAt?: string }; + + const { + id: _id, + createdAt: _createdAt, + updatedAt: _updatedAt, + ...snapshot + } = currentDoc; + + await req.payload.update({ + collection: RELEASE_ITEMS_SLUG as any, + id: itemId, + data: { + snapshot, + baseVersion: currentDoc.updatedAt ?? null, + status: "pending", + } as any, + context: { contentReleasesBypass: true } as any, + }); + + return Response.json({ ok: true }); + } catch (err) { + return Response.json( + { error: err instanceof Error ? err.message : "Internal server error" }, + { status: 500 }, + ); + } + }; +} diff --git a/packages/payload-plugin-content-releases/src/hooks/releaseItemsBeforeChange.ts b/packages/payload-plugin-content-releases/src/hooks/releaseItemsBeforeChange.ts index ba08088..5a99ab0 100644 --- a/packages/payload-plugin-content-releases/src/hooks/releaseItemsBeforeChange.ts +++ b/packages/payload-plugin-content-releases/src/hooks/releaseItemsBeforeChange.ts @@ -2,7 +2,9 @@ import type { CollectionBeforeChangeHook } from "payload"; import { RELEASES_SLUG, RELEASE_ITEMS_SLUG } from "../constants"; export function buildReleaseItemsBeforeChange(): CollectionBeforeChangeHook { - return async ({ data, originalDoc, operation, req }) => { + return async ({ context, data, originalDoc, operation, req }) => { + if ((context as any)?.contentReleasesBypass === true) return data; + const releaseId = data.release as string; if (!releaseId) return data; diff --git a/packages/payload-plugin-content-releases/src/plugin.ts b/packages/payload-plugin-content-releases/src/plugin.ts index 8601f07..6b5789b 100644 --- a/packages/payload-plugin-content-releases/src/plugin.ts +++ b/packages/payload-plugin-content-releases/src/plugin.ts @@ -18,6 +18,7 @@ import { createCheckConflictsHandler } from "./endpoints/checkConflicts"; import { createRunScheduledHandler } from "./endpoints/runScheduled"; import { createPreviewRollbackHandler } from "./endpoints/previewRollback"; import { createRollbackReleaseHandler } from "./endpoints/rollbackRelease"; +import { createRefreshItemSnapshotHandler } from "./endpoints/refreshItemSnapshot"; import { checkScheduledReleases } from "./scheduler/checkScheduledReleases"; export function contentReleasesPlugin( @@ -80,6 +81,11 @@ export function contentReleasesPlugin( method: "post", handler: createRollbackReleaseHandler({ hooks: options.hooks }), }, + { + path: "/content-releases/items/:itemId/refresh-snapshot", + method: "post", + handler: createRefreshItemSnapshotHandler(), + }, ]; if (options.schedulerSecret) {