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 a1d37556..236a9ea1 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 cc6c5fcf..0d792c29 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 c8cea12e..79be06ce 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 57485d19..ffc4aecd 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 0d792c29..b90ce85d 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 00000000..896a769f --- /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 95868863..823a8ed5 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 ffc4aecd..78f4c5a3 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 896a769f..0944aba7 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 236a9ea1..f506f3ab 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 00000000..1d442ab8 --- /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 823a8ed5..5f635f42 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 79be06ce..90bb3dfc 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 f506f3ab..9cba6ea6 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 90bb3dfc..7c8be6ad 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 9cba6ea6..dfa3f445 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 00000000..2cca2d05 --- /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 5f635f42..f3f246cb 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 7c8be6ad..b7f0180e 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 b90ce85d..f4a632e2 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 78f4c5a3..38e5a4ec 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 00000000..fffe8edb --- /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 f4a632e2..a127632b 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 38e5a4ec..066eda94 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 82f123d2..92b7d89b 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 853a6ec6..0abd7208 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 da1ec21f..e4c4d5ea 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 61bfd065..3ec231f9 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 6f71a0f6..344f3285 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 3ec231f9..77174bb1 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 0dbc5259..c31ec23c 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 50915628..3989f9a0 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 67311ae5..9e811ecd 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 a3000bb5..b0c52f9e 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 de11411d..09afd30d 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 6da0650e..95ea5b86 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 c25490f9..c00e3897 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 00000000..e88e20f0 --- /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 ba080887..5a99ab07 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 8601f075..6b5789bd 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) {