Skip to content

feat(content-releases): finalize plugin#7

Open
owarpero wants to merge 17 commits into
mainfrom
feature/releases-plugin
Open

feat(content-releases): finalize plugin#7
owarpero wants to merge 17 commits into
mainfrom
feature/releases-plugin

Conversation

@owarpero
Copy link
Copy Markdown
Contributor

Summary

Closes the action items from the Payload Guild Sync 2026-04-23 for the content-releases plugin.

  • Status fields are now informational Pill displays. Both releases.status and release-items.status keep their select data semantics for validation, but the dropdown UI is replaced with a custom Field component (ReleaseStatusField, ReleaseItemStatusField) that renders a Payload-native Pill. Status is no longer editable from admin — driven by hooks/orchestrator only.
  • Removed status columns from the items table (release detail page) — both at the join field's defaultColumns and the collection's own defaultColumns.
  • Renamed action → "Release action" (label) and added a Pill Cell (ReleaseActionCell) for that column — success for publish, warning for unpublish.
  • Labeled the items join field "Resources" so the section reads as part of the form, not a separate table.
  • Removed errorLog field entirely (per the meeting's "no concurrent releases → no error logging" decision). Cleaned up the writes in orchestratePublish.
  • Snapshot depth coverage — added 5 unit tests against executePublish covering deeply nested blocks (form-builder-style), populated relationship objects, snapshot immutability, deep rollback capture, and unpublish isolation. All pass against the current implementation — no production change needed.

UI components used

All new components are built on @payloadcms/ui exports (Pill, useField) — no custom fonts, no inline hex colors, no extraneous styling. The Pill colors come from Payload theme tokens; the font is inherited from body.

Verified via Chrome DevTools MCP

On a live apps/dev instance:

  • Status sidebar field renders as <div class="pill pill--style-light-gray pill--size-medium"><span class="pill__label">Draft</span></div> — no <select>, <input> or react-select in the field container.
  • Items table headers: Target Collection / Target Doc / Release action. No Status column.
  • Action column cell: <div class="pill pill--style-success pill--size-medium">Publish</div>.
  • Items join section heading: <h4>Resources</h4>.
  • No Error Log field rendered on the form.
  • No console errors from the plugin.
  • Pill font-family matches body (inherited, not overridden).

Test plan

  • cd packages/payload-plugin-content-releases && bun run test → 124 passed (21 files)
  • bun run build (turbo) — plugin package builds clean; dist/client.{js,d.ts} includes ReleaseStatusField, ReleaseItemStatusField, ReleaseActionCell
  • Manual UI verification per docs/superpowers/plans/2026-04-29-content-releases-finalization.md

Notes

Bumped apps/dev deps: added libsql (sqlite runtime, was missing) and @eslint/eslintrc (required by the existing eslint config). Regenerated importMap.js and payload-types.ts.

extpavelkhurs and others added 16 commits April 29, 2026 18:01
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…lish

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…decision

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- 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) <noreply@anthropic.com>
- ReleaseSidebarField: tighter gap between sidebar buttons (margin=false on Button removes default 24px vertical margin).
- ReleaseDrawer: 'Create New Release' wrapped in <div> 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) <noreply@anthropic.com>
When a release has a scheduledAt date set, its status should reflect
'scheduled' rather than 'draft' so the scheduler picks it up. Likewise,
clearing scheduledAt should bring the release back to 'draft'.

Behaviour:
- create with scheduledAt → status='scheduled' (otherwise 'draft' as before)
- update draft → add scheduledAt → status flips to 'scheduled'
- update scheduled → clear scheduledAt → status flips back to 'draft'
- editing other fields (description, name) without touching scheduledAt
  leaves status untouched
- explicit status changes (e.g. from orchestrator) still go through the
  existing transition validator

Covered by 5 new vitest cases for releasesBeforeChange.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ecovery path

Root cause analysis (systematic-debugging):

1) Frontend (ReleaseActionsField) used a 3-state blacklist
   (publishing/published/failed → disabled, default → enabled). This
   left 'reverting', 'reverted', and 'cancelled' rendering an active
   button even though only 3 states actually allow the
   'X → publishing' transition.

2) The publish endpoint mirrored the same incomplete blacklist, so
   a click on 'reverted' fell through the guard into the orchestrator,
   which called payload.update({status: 'publishing'}) and triggered
   releasesBeforeChange to throw 'Invalid status transition'. Endpoint
   caught the throw and returned a 500 with a confusing message.

3) Once a release reached 'reverted' (especially via a skip-only
   rollback where nothing was actually reverted), there was no path
   back to 'draft' — VALID_TRANSITIONS.reverted was [].

Fix:

- Extract getPublishButtonProps to its own module so it can be unit-tested
  without pulling @payloadcms/ui (which transitively imports CSS).
- Whitelist via isValidTransition(status, 'publishing') — single source
  of truth.
- Endpoint guard rewritten to use the same isValidTransition check —
  returns a clean 400 with status name in the error message.
- VALID_TRANSITIONS.reverted = ['draft'] gives reverted releases a
  recovery path; statusTransitions tests cover the new edge.
- New tests:
  - getPublishButtonProps: 11 cases (3 enabled, 5 disabled, undefined)
  - publishRelease handler: rejection set extended with 'reverting' and
    'reverted'.

Verified via chrome-devtools MCP: 'Reverted' release shows the button
disabled with tooltip 'Release has been reverted'; POST /publish returns
400 with 'Cannot publish a release with status "reverted"'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…eleases

Adds a recovery action next to 'Publish Now' that PATCHes the release
back to 'draft' so it can be edited and republished. The button is
visible only when isValidTransition(status, 'draft') is true — i.e.
'failed' (failed → draft) and 'reverted' (reverted → draft, added
in the previous commit).

This closes the UX trap where, after a rollback (especially the
skip-only case where nothing was actually reverted), the release was
stuck in a terminal state with no path forward from the admin UI.

The handler hits the standard /api/releases/{id} endpoint with
{status: 'draft'}; releasesBeforeChange validates the transition and
the existing auto-flip stays out of the way (it only kicks in for
draft/scheduled).

Verified via chrome-devtools MCP: clicking 'Reset to draft' on a
'failed' release transitioned it to 'draft', cleared the reset
button, and re-enabled 'Publish Now'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…h-snapshot

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) <noreply@anthropic.com>
@owarpero owarpero changed the title feat(content-releases): finalize plugin per Payload Guild sync 2026-04-23 feat(content-releases): finalize plugin Apr 30, 2026
# Conflicts:
#	apps/dev/package.json
#	apps/dev/src/app/(payload)/admin/importMap.js
#	apps/dev/src/payload-types.ts
#	bun.lock
#	packages/payload-plugin-content-releases/src/__tests__/collections/releaseItems.test.ts
#	packages/payload-plugin-content-releases/src/__tests__/collections/releases.test.ts
#	packages/payload-plugin-content-releases/src/__tests__/endpoints/publishRelease.test.ts
#	packages/payload-plugin-content-releases/src/__tests__/endpoints/rollbackRelease.test.ts
#	packages/payload-plugin-content-releases/src/__tests__/hooks/releasesBeforeChange.test.ts
#	packages/payload-plugin-content-releases/src/__tests__/rollback/executeRollback.test.ts
#	packages/payload-plugin-content-releases/src/__tests__/validation/statusTransitions.test.ts
#	packages/payload-plugin-content-releases/src/admin/components/ReleaseActionsField.tsx
#	packages/payload-plugin-content-releases/src/admin/components/ReleaseDrawer.tsx
#	packages/payload-plugin-content-releases/src/admin/components/ReleaseSidebarField.tsx
#	packages/payload-plugin-content-releases/src/admin/components/VersionPickerDrawer.tsx
#	packages/payload-plugin-content-releases/src/client.ts
#	packages/payload-plugin-content-releases/src/collections/releaseItems.ts
#	packages/payload-plugin-content-releases/src/collections/releases.ts
#	packages/payload-plugin-content-releases/src/constants.ts
#	packages/payload-plugin-content-releases/src/endpoints/publishRelease.ts
#	packages/payload-plugin-content-releases/src/hooks/releaseItemsBeforeChange.ts
#	packages/payload-plugin-content-releases/src/hooks/releasesBeforeChange.ts
#	packages/payload-plugin-content-releases/src/plugin.ts
#	packages/payload-plugin-content-releases/src/publish/orchestratePublish.ts
#	packages/payload-plugin-content-releases/src/rollback/executeRollback.ts
#	packages/payload-plugin-content-releases/src/rollback/orchestrateRollback.ts
#	packages/payload-plugin-content-releases/src/types.ts
#	packages/payload-plugin-content-releases/src/validation/statusTransitions.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants