Skip to content

Content Drive: Add Lock and Unlock quick actions - #36921

Open
rjvelazco wants to merge 7 commits into
mainfrom
issue-36844-content-drive-action-center-add-bulk-lock-and-unlock-quick-actions
Open

Content Drive: Add Lock and Unlock quick actions#36921
rjvelazco wants to merge 7 commits into
mainfrom
issue-36844-content-drive-action-center-add-bulk-lock-and-unlock-quick-actions

Conversation

@rjvelazco

@rjvelazco rjvelazco commented Aug 6, 2026

Copy link
Copy Markdown
Member

Closes #36844

Adds Lock and Unlock to the Content Drive Action Center's Quick Actions, and — because firing them exposed two rough edges in the surrounding dialog — routes every quick action through the preview screen and moves execution out of the dialog so a run survives the dialog closing.

Built TDD: the first commit is tests only (Red), the rest make them pass.


Route decision (AC #1)

Lock/Unlock become SystemAction values, fired through the existing multi-contentlet endpoint POST /api/v1/workflow/actions/default/fire/{systemAction}. No new _bulklock / _bulkunlock endpoint, and no shipped workflow action.

That works because SystemActionApiFireCommandFactory already provides a direct-API fallback for a system action with no workflow mapping — the same mechanism that lets PUBLISH work on a content type with no scheme. Locking is per user rather than a workflow transition, so LOCK/UNLOCK deliberately have no actionlet and nothing to map to; mapping them to a workflow action has no effect, and the enum documents that.

The alternative — a pair of bulk endpoints — would have duplicated a request/response shape the multi-contentlet fire endpoint already has ({results, summary}), for no behaviour the fallback does not already give us.

Proposed Changes

Backend

  • SystemAction gains LOCK and UNLOCK, documented as the two members with no actionlet, served by the API-call fallback.
  • SystemActionApiFireCommandFactory gains fire commands for both, calling ContentletAPI#lock / #unlock directly.
  • Permission is enforced server-side, independent of what the UI offered.
  • openapi.yaml regenerated from the annotations.

Quick Actions

  • Lock counts selected contentlets that are not locked; Unlock counts those that are. Neither is listed at a count of zero, and folders are excluded — consistent with the rest of the dialog.
  • New warnWhen / warningCount / warningHint on a quick action. Unlock uses it to flag locks held by other users, detected from the row's contentEditable flag (the server's answer to "is this locked by me?").
  • Every locked row is still fired. Only a CMS Administrator may release someone else's lock and the client cannot know whether the caller is one, so failures are reported rather than pre-filtered.
  • Result counts now come from summary.successCount / summary.failCount on the response instead of the number of inodes sent. The endpoint answers 200 with per-item failures inside, so this corrects every quick action, not just these two.

Quick actions now use the preview screen

Previously only workflow actions drilled into the preview; a quick action fired straight from its row. Clicking Publish (12) published twelve items with no chance to drop one. The set was knowable but not changeable — which matters most on Unlock, where the row warns that some locks belong to other users and unchecking those rows is the only way to act on the warning.

Execution moved into the store

  • New withActionExecution store feature owns both fire paths, the in-flight state and the result. Holding the subscription in the store makes surviving a dialog close a deliberate property rather than a leaked subscription — and gives a reopened dialog truthful state, which is what stops the same rows being fired twice.
  • Firing hands off immediately: the dialog closes, the toolbar reports Applying <action> to N item(s)…, and a toast reports the outcome. Partial failure downgrades the toast to a warning.
  • The toast moved to the shell, which owns <p-toast> and outlives every dialog, so it can report a result whose originating dialog is already gone. It also refreshes the grid and clears the selection.
  • The folders-ignored notice became a plain element instead of p-message: PrimeNG 21 animates a Message's height from zero over 300ms with no way to opt out (motionOptions is declared but never consumed, the duration is not a design token, there is no global config), which read as the notice arriving late and shoving the action list down as it expanded.

Scope note for reviewers

The last two sections go beyond #36844's acceptance criteria. They are here because Unlock is what surfaced them: it is the first quick action where the user needs to edit the set before firing, and the first where a partial failure is the expected outcome rather than an error. Happy to split them out if you would rather review them separately.

Checklist

  • Tests
  • Translations — new keys in Language.properties: content-drive.action-center.unlock.locked-by-others, content-drive.action-center.applying
  • Security Implications Contemplated

Permission for lock/unlock is enforced server-side in the fire command, not only in the UI, and integration tests cover the allowed and denied paths. Releasing another user's lock stays restricted to CMS Administrators — the client offers the attempt and reports the failure, it does not bypass the check.

Additional Info

Testing

  • Integration (WorkflowResourceLockUnlockIntegrationTest, 8 tests): single and bulk happy paths; a lock held by another user reported per item with the batch not refused and the lock surviving; mixed ownership yielding a partial result rather than all-or-nothing; server-side permission enforcement.
  • Frontend: 1,003 tests passing in portlets-content-drive, covering eligibility counts, the warning count, the execute path, delta accumulation and the toast's three outcomes.
  • pnpm nx build dotcms-ui succeeds.

One thing worth knowing: tsconfig.spec.json sets isolatedModules: true, so Jest transpiles per-file with no cross-file type checking. A store-composition type error passed 1,000 green tests and only failed at nx build. Green tests are not a typecheck for anything touching signalStore composition.

Follow-up, not in this PR: the same progress/result contract would let bulk publish/unpublish/archive report per-item outcomes, but those run on DotConcurrentFactory rather than the job queue and have no jobId to monitor — see #36894.

Screenshots

Original Updated
** original screenshot ** ** updated screenshot **

Tests only — no implementation yet. Every assertion here is expected to
fail; they are the Red step for issue #36844.

Route decided: expose Lock/Unlock as SystemAction values so the existing
multi-contentlet endpoint POST /api/v1/workflow/actions/default/fire/
{systemAction} can fire them. No new endpoint and no workflow action are
needed — SystemActionApiFireCommandFactory already supplies a direct-API
fallback for system actions with no workflow mapping, which is how PUBLISH
works on a content type with no scheme.

Frontend (18 failing assertions):
- Lock counts unlocked rows, Unlock counts locked rows, both excluded from
  folders and non-selectable at a zero count.
- New warningCount/warningHint on a quick action, driven by a warnWhen
  predicate. Unlock uses it to flag locks held by other users, detected via
  the row's contentEditable flag (the server's "is this locked by me?").
- Every locked row is still fired: only a CMS Administrator may release
  someone else's lock, and the client cannot know whether the caller is one.
  Failures are reported, not pre-filtered.
- The result toast reports summary.successCount/failCount from the response
  instead of the number of inodes sent. This corrects every quick action,
  not just these two.

Backend (8 integration tests, currently failing to compile on the two
missing enum constants — the only errors in the module):
- Single and bulk lock/unlock happy paths.
- Denied path: a lock held by another user is reported per item, the batch
  is not refused, and the lock survives.
- Mixed ownership yields a partial result rather than all-or-nothing.
- Permission is enforced server-side, independent of what the UI offered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rjvelazco and others added 2 commits August 6, 2026 13:36
Turns the failing tests from the previous commit green. Lock and Unlock are
exposed as SystemAction values so the existing multi-contentlet endpoint
fires them in one request — no new endpoint, no workflow action, no DB
migration.

Backend:
- SystemAction gains LOCK and UNLOCK, documented as the two members that
  deliberately have no actionlet and no mappable workflow action.
- SystemActionApiFireCommandFactory gains two commands calling
  ContentletAPI.lock/unlock. Registered in commandMap only, not in
  systemActionHasActionletHandlerMap, so they always win instead of
  deferring to a workflow action. Both ignore needSave: a lock is per-user
  state on the version info, not a step transition.
- checkContentletState rejects LOCK/UNLOCK on a new contentlet, so the
  caller gets a clear bad request instead of a blank-inode failure deeper
  in lock().
- Swagger allowableValues on the three fire endpoints, with openapi.yaml
  regenerated. The PATCH merge and scheme-mapping lookup endpoints are
  deliberately left out: a merge would silently discard body fields, and
  mapping these two to a workflow action has no effect.

Frontend:
- Lock and Unlock lead the Quick Actions list. Lock counts unlocked rows,
  Unlock counts locked rows; both exclude archived content, which is a dead
  end until unarchived and where a stray lock would make the item
  undeletable by anyone but the lock holder.
- Generic warnWhen predicate produces warningCount/warningHint. Unlock uses
  it to flag locks held by other users via the row's contentEditable flag,
  which the drive search already returns but the model never declared.
  Those items are still fired: only a CMS Administrator can release
  someone else's lock and the client cannot know whether the caller is one.
- fireDefaultAction's return type corrected to DotFireDefaultActionResult.
  It claimed Observable<DotCMSContentlet[]> while the endpoint actually
  sends { results, summary }.
- The result toast now reports summary.successCount/failCount and drops to
  warn severity on any failure, instead of reporting the number of inodes
  sent as successes. This corrects every quick action, not just these two.

Tests: 9 integration tests pass, covering single and bulk paths, the
denied path, mixed ownership, and server-side permission enforcement. The
denied-path test asserts the failure message mentions locking — without
that guard it passed on a content-type permission rejection and never
reached canLock. Frontend: 979 content-drive and 752 data-access specs pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Quick actions fired straight from the row click, so the preview table was
reachable only from a workflow action. Both sections now commit the same
way: clicking an action opens the preview, and nothing is sent until
Execute.

The old reasoning for the split was that a quick action's count is derived
from the rows themselves, so "which items is this about to touch?" already
had an obvious answer. That confused knowing the answer with being able to
change it. The set was knowable but not adjustable: clicking Publish (12)
published twelve items with no chance to drop one.

It matters most on Unlock. The row warns that N locks belong to other
users and will fail, and until now there was nothing the user could do
with that warning. In the preview those rows can be unchecked.

- $pendingQuickAction discriminates the preview screen: which rows it
  lists, what Execute fires, and whether the partial-match warning applies.
- The confirmation prompt moved from the row click to Execute. That is the
  commit point; opening a preview changes nothing, so prompting there asked
  the user to confirm a decision they had not made yet.
- The partial-match warning stays workflow-only. A quick action's count and
  its rows are the same client-side filter, so it cannot apply to fewer
  items than it lists.
- onContinueToPreview clears $pendingQuickAction before reading
  $previewItems, which is discriminated on it — reading first would resolve
  the rows against a stale quick action.

Tests: 9 new preview specs, and the 16 existing quick-action specs rewired
to the two-step path. 988 content-drive specs pass, lint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Claude finished @rjvelazco's task in 2m 30s —— View job


Claude finished @rjvelazco's task in 3m 51s —— View job


Code Review — Angular anti-patterns, defects, bugs, flaky tests

  • Gather context (diff vs origin/main, changed files)
  • Review store/state changes (withActionExecution.ts, dot-content-drive.store.ts)
  • Review component changes (action-center, toolbar, shell)
  • Review utils (action-center.ts, workflow-actions.ts) and models
  • Recheck prior review findings against latest commit
  • Look for flaky test patterns in spec files
  • Post findings

The latest commit (1f374ad) addressed three of the prior findings. Below is the current state — what's fixed, and what still stands.

New Issues

  • 🟡 Medium: dotCMS/src/main/java/com/dotcms/rest/api/v1/workflow/SystemActionApiFireCommandFactory.java:651LockSystemActionApiFireCommandImpl.fire has no archived guard. The client's eligibleWhen: (item) => !item.locked && !item.archived (action-center.ts:108) documents that locking an archived item makes it undeletable by anyone but the lock holder, because deleteContentlets honours canLock. But that invariant now lives only in the UI. ContentletAPI#lock checks a blank inode and canLock, never archived state, so a direct POST /api/v1/workflow/actions/default/fire/LOCK on an archived inode reaches exactly the dead end the comment warns against — no UI involved. Since this PR is what turns LOCK into a fireable SystemAction, the check should be mirrored server-side (in the fire command or checkContentletState). Fix this →

Existing (still open from prior review)

  • 🟡 Medium: dotCMS/src/main/java/com/dotcms/rest/api/v1/workflow/WorkflowResource.java:3961fireMergeActionDefault (PATCH /actions/default/fire/{systemAction}) still advertises allowableValues without LOCK/UNLOCK. The other three fire endpoints (3420, 3653, 4552) list them; this one was missed. The PATCH path routes through the same factory fallback, so it accepts both at runtime — the effect is that the generated openapi.yaml documents the shared enum inconsistently (missing at lines 18552 and its request variant) and a generated client cannot call this path with the new values. Fix this →

  • 🟡 Medium: dot-content-drive-action-center.component.html:308 — the preview still renders no per-row lock-owner marker. The PR justifies routing quick actions through the preview by saying that on Unlock "unchecking those rows is the only way to act on the warning," but the preview shows the same dot-contentlet-status-badge for every row with no lock indicator. The user sees "Unlock (10) ⚠3", drills in, and cannot tell which 3 rows to uncheck — the capability the preview exists to provide is unreachable. This is a design decision (reuse DotFolderListViewComponent, or add a marker to the preview table) rather than a one-line fix; flagging so it's a conscious call before merge.

  • 🟡 Medium: dot-content-drive-shell.component.ts:481 — the toast ladder is failCount > 0skippedCount > 0 → plain, so skips and fails are mutually exclusive in the output even though a bulk workflow fire can return both (mixed-type selection where some contentlets' schemes don't own the action and some are locked). Only the failure copy shows, attributing the whole shortfall to permissions/locks and sending the user's next move (go unlock) the wrong way. Compose the detail from the non-zero parts, or add a both-present message. Fix this →

  • 🟡 Medium: dot-content-drive-shell.component.ts:514closeDialog() in the result effect is a no-op on the common path, but the dialog is [closable]="true", so a user can reopen the Action Center while a run is still in flight. When the run settles this closes it out from under them — and while reopened it is useless anyway ($executing greys out every row and Done). Either block reopening while a run is in flight, or gate this closeDialog() on the dialog being the one that fired.

Resolved

  • dotcms-integration/.../MainSuite2a.java:72WorkflowResourceLockUnlockIntegrationTest is now registered next to its sibling, so the 8 server-side tests run in CI.
  • action-center.ts:135 — the Unlock warning no longer asserts an outcome it can't know for admins. Copy is now "may require administrator permission," and the two known false positives (CMS Admin, content-type-level EDIT) are documented at warnWhen along with what removing them would take.
  • withActionExecution.ts:132 — a missing summary no longer reports every item as a success. ?? inodes.length is gone; onUnknownOutcome now routes a body with no summary through httpErrorManagerService rather than fabricating a count.

Checked and clean

  • Flaky tests: no setTimeout/real-timer races, Math.random, or wall-clock in the new/changed specs — they use flushEffects() and whenStable() deterministically. No flaky patterns found.
  • withActionExecution guards (:109, :159): the store.actionExecution() re-entrancy guard plus take(1) correctly prevent a double-fire over the same rows, which is the replay-safety concern for a double-click / reopen.
  • DI at store level: DotWorkflowActionsFireService / DotHttpErrorManagerService both resolve from providers.ts.
  • Enum documentation (WorkflowAPI.java:1239): LOCK/UNLOCK are documented as the two members with no actionlet, served by the API-call fallback — accurate.

Reviewed by Claude (Claude Code) on this synchronize. The three Resolved items land as expected; the four Medium items above are non-blocking but worth a decision before merge — the backend archived guard and the PATCH allowableValues are the two with the smallest fix-to-value ratio.
issue-36844-content-drive-action-center-add-bulk-lock-and-unlock-quick-actions

@github-actions github-actions Bot added Area : Backend PR changes Java/Maven backend code Area : Frontend PR changes Angular/TypeScript frontend code labels Aug 6, 2026
…loses

Execution moves out of the Action Center dialog and into the store, so a run
outlives the dialog that started it. Firing now hands off immediately: the
dialog closes, the toolbar reports progress, and a toast reports the outcome.

- `withActionExecution` owns both fire paths (`fireDefaultAction`, `bulkFire`),
  the in-flight state and the result. Holding the subscription in the store
  makes surviving the dialog close a deliberate property rather than a leaked
  subscription, and gives a reopened dialog truthful state — which is what
  stops the same rows being fired twice.
- The completion toast moves to the shell, which owns `<p-toast>` and is never
  destroyed while the portlet is open, so it can report a result whose
  originating dialog is already gone. It also refreshes the grid and closes the
  dialog.
- The toolbar shows "Applying <action> to N item(s)…" while a run is in flight
  — the only progress signal once the dialog is closed.
- The folders-ignored notice becomes a plain element instead of `p-message`.
  PrimeNG 21 animates a Message's height from zero over 300ms with no way to
  opt out (`motionOptions` is declared but never consumed, the duration is not
  a design token, and there is no global config), which read as the notice
  arriving late and shoving the action list down as it expanded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rjvelazco rjvelazco changed the title Add failing tests for bulk Lock and Unlock quick actions Content Drive: Add Lock and Unlock quick actions Aug 7, 2026
@rjvelazco

Copy link
Copy Markdown
Member Author

@claude Review this code and look for: Angular anti-patterns, defects, possible bugs, flaky tests, etc. For each issue found, also propose the correct way to fix it.

@claude

claude Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Code Review — Angular anti-patterns, defects, bugs, flaky tests

  • Gather context (diff vs origin/main, changed files)
  • Review store/state changes (withActionExecution.ts, dot-content-drive.store.ts)
  • Review component changes (action-center, toolbar, shell)
  • Review utils (action-center.ts, workflow-actions.ts) and models
  • Look for flaky test patterns in spec files
  • Post findings

Working on this now.

@zJaaal zJaaal left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of the full diff (26 files): the backend SystemAction additions, the withActionExecution store feature, the Action Center preview rework and the toolbar/shell reporting.

The route decision holds up. SystemActionApiFireCommandFactory's direct-API fallback really does cover a system action with no workflow mapping, so adding LOCK/UNLOCK there rather than shipping _bulklock endpoints is the right call, and the enum documenting why those two have no actionlet is the sort of thing the next reader needs.

Eight findings, seven of them inline. Two I would fix before merge:

  • The integration tests never run. WorkflowResourceLockUnlockIntegrationTest is not registered in any MainSuite, so the whole server-side proof of the contract is inert. Inline on the class declaration.
  • The Unlock warning is wrong for exactly the users it is about. contentEditable does not know about the CMS Administrator role. Inline on action-center.ts.

Plus one design gap worth a decision rather than a fix: the preview was added so the user could uncheck locks held by others, but the preview cannot show which rows those are. Suggestion inline on the Action Center template.


Not inline (line falls outside the diff hunks)

WorkflowResource.java:3961-3966fireMergeActionDefault still advertises allowableValues without LOCK/UNLOCK.

The other three fire endpoints were updated (3420, 3653, 4552); this one was missed. PATCH /actions/default/fire/{systemAction} routes through the same fireTransactionalAction -> mergeContentlet -> factory fallback, so it does accept both at runtime. The result is that the generated openapi.yaml documents the same enum inconsistently across the four paths that share it, and a client generated from it cannot call this one with the new values.


Checked and clean

Ruled these out rather than assuming them, in case it saves anyone re-treading:

  • DI after the move into the store: DotWorkflowActionsFireService and DotHttpErrorManagerService are both in apps/dotcms-ui/src/app/providers.ts, so injecting them at store level resolves. The dot-folder-list-context-menu spec was correctly updated for the real store.
  • DotHttpErrorManagerService.handle() is eager (side effects before of(result)), so not subscribing to it inside catchError is fine.
  • The shell effect cannot loop: loadItems / closeDialog / clearActionExecutionResult are all inside untracked, and loadItems does set LOADING and clear selectedItems as its comment claims.
  • contentEditable is genuinely hydrated onto drive rows (BrowserAPIImpl.hydrate), so it is not an undefined field that would make every Unlock row warn.
  • Contentlet.isLocked() reads through VersionableAPI rather than the property map, so unlock's guard works on the detached copy createContentlet builds.
  • fireDefaultAction's return-type change has exactly one caller, and entity = {results, summary} matches what ResponseUtil's streaming wrapper emits.
  • The no-enter-motion CSS override does win: PrimeNG 21 puts styleClass on the p-message host alongside animate.enter, and the theme rule is animation: p-animate-message-enter, so animation: none at higher specificity beats it.
  • content-drive.context-menu.lock / .unlock already exist in Language.properties; the non-ASCII in that file is pre-existing.

Reviewed by Claude (Claude Code), posted from @zJaaal's account.

*
* @see SystemActionApiFireCommandFactory
*/
public class WorkflowResourceLockUnlockIntegrationTest {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium — these 8 tests will never run.

This class is not registered in any suite:

$ git grep -n LockUnlockIntegrationTest -- dotcms-integration
dotcms-integration/.../WorkflowResourceLockUnlockIntegrationTest.java:55

One match, the file itself. Its sibling is wired up — MainSuite2a.java:71 lists com.dotcms.rest.api.v1.workflow.WorkflowResourceIntegrationTest.class.

Integration tests only execute through the MainSuite* classes, so as it stands the entire server-side proof of the contract this PR introduces — per-item failure reporting, mixed ownership yielding a partial result, canLock permission enforcement — does not run in CI. A regression in LockSystemActionApiFireCommandImpl or UnlockSystemActionApiFireCommandImpl would land green.

Adding the class next to its sibling in MainSuite2a should be all it takes.

// grid has no idea whether the current user holds that role. So these items are counted,
// fired, and reported on rather than filtered out: `contentEditable` is false on a locked
// row the current user does not hold.
warnWhen: (item) => !item.contentEditable,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium — this warns the one user for whom it is false.

The hint says "Only an administrator can unlock those, and they will be reported as failed", but contentEditable cannot tell you whether the caller is that administrator.

contentEditable is derived in BrowserAPIImpl.WfData (BrowserAPIImpl.java:2636) as essentially lockedUserId.equals(user.getUserId()) — it never consults the CMS Administrator role. The server-side gate is ESContentletAPIImpl.canLock (:10358), which returns true immediately for a CMS Admin.

So: an admin selects 5 locked rows, 3 held by other users. The row reads "3 … will be reported as failed", they fire it, and all 5 unlock. The warning was pure noise for the only role that can act on it.

Second, narrower false positive: a user who holds the lock but has EDIT at the content-type level rather than on the contentlet. contentEditable only checks contentlet WRITE, while canLock accepts either.

The comment above is honest that "the grid has no idea whether the current user holds that role" — the gap is that the copy then asserts an outcome anyway. Two ways out: soften the hint to something conditional ("may require administrator permission"), or plumb the caller's admin-ness down so warnWhen can return false outright for admins. The second also fixes the preview marker (see the template comment).

@if ($selectedAction(); as action) {
<!-- Workflow actions only. A quick action's count and its rows come from the same
client-side filter, so it can never apply to fewer items than it lists. -->
@if (!$pendingQuickAction() && $selectedAction(); as action) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium — the preview cannot show the thing it was added for.

The PR body's justification for routing quick actions through the preview is that on Unlock "unchecking those rows is the only way to act on the warning". But the preview renders no per-row lock-owner information: this @if is gated on !$pendingQuickAction(), and dot-content-drive-action-preview.component.html has no lock marker at all — every row gets the same dot-contentlet-status-badge.

So the user sees "Unlock (10) ⚠3", drills in, and has no way to tell which 3 rows to uncheck. The capability the preview exists to provide is not reachable.

Suggestion: use Content Drive's own table instead of a second hand-rolled one.

DotFolderListViewComponent (@dotcms/portlets/content-drive/ui, exported from ui/src/index.ts) already renders the lock state per row:

<span data-testId="lock-hint" class="text-gray-700">
    @if (!isFolder) {
        @if (item.locked) {
            <i class="pi pi-lock" data-testId="lock-icon"></i>
        } @else {
            <i class="pi pi-lock-open" data-testId="lock-open-icon"></i>
        }
    }
</span>

…alongside the checkbox column, dot-content-thumbnail, dot-contentlet-status-badge, type and language. The preview component's own header comment says "Column markup follows the main grid (dot-folder-list-view) so a row reads the same in both places" — it is deliberately reproducing that component, and the one column it dropped is the column this finding needs. Reusing it gets the marker for free and removes the drift risk between the two tables.

Two honest caveats, both small and both in the ui lib:

  1. Selection is not an input yet. Today it is [(selection)]="selectedItems" against an untyped internal field (selectedItems = [], :189), with an effect at :308 that clears it whenever $items() changes. The preview needs the parent to own the included set, so this wants a selection input (and a disabled input for the in-flight state) added to DotFolderListViewComponent. That effect would also need to not stomp a caller-provided selection.
  2. item.locked alone is not the right signal. It says "locked", not "locked by someone else", so the marker has to key off whatever warnWhen settles on in the sibling comment above — otherwise the icon flags all 10 rows instead of the 3 that matter.

If that is more than you want in this PR, the smaller version is to add the marker to the existing preview table and keep the two in sync; but given the preview is already a copy of dot-folder-list-view, reusing it looks like the cheaper end state.

// Archived content is a dead end until unarchived, and `deleteContentlets` honours
// `canLock`, so locking an archived item would quietly make it undeletable by anyone but
// the lock holder.
eligibleWhen: (item) => !item.locked && !item.archived

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low/Medium — this guard is client-side only, and the state it prevents is now reachable over the API.

The comment is right about the hazard: deleteContentlets honours canLock, so a locked archived item becomes undeletable by anyone but the holder. The problem is that !item.archived is the only thing enforcing it.

ESContentletAPIImpl.lock (:3924) checks for a blank inode and canLock, and never rejects archived content. WorkflowResource.checkContentletState only guards isNew(). Now that LOCK is a fireable SystemAction, a direct POST /api/v1/workflow/actions/default/fire/LOCK on an archived inode produces exactly the dead end the comment says must be avoided — no UI needed.

Worth mirroring the check in LockSystemActionApiFireCommandImpl (or in checkContentletState) so the invariant lives where it is actually enforceable. Pre-existing for the context-menu path, but this PR is what makes it addressable without the UI.

.subscribe((result) =>
onSettled({
actionName,
successCount: result?.summary?.successCount ?? inodes.length,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low — ?? inodes.length undoes the fix the surrounding comment describes.

The PR body is explicit that counts must come from summary rather than the number of inodes sent, because the endpoint answers 200 with per-item failures inside. This fallback reinstates the old behaviour for any response that parses but arrives without a summary.

That is not hypothetical: printResponseEntityViewResult (WorkflowResource.java ~4200) swallows IOException and can abort mid-stream, and isFail can throw NoSuchElementException out of the loop on an empty result map. Either way the client can get a body with results and no summary, and then reports "all N succeeded, 0 failed" with a green toast — the most reassuring possible message for the case where nothing is known to have succeeded.

?? 0 would be consistent with the intent; treating a missing summary as an error and routing it through httpErrorManagerService would be better still. Note failCount below already defaults to 0, so the two halves currently disagree about what a missing summary means.

const { actionName, successCount, skippedCount, failCount } = result;

const detail =
failCount > 0

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low — a run that both skips and fails never reports the skips.

The ladder is failCount > 0skippedCount > 0 → plain, so the two are mutually exclusive in the output even though the backend can return both. A bulk workflow fire over a mixed-type selection where some contentlets' schemes do not own the action (skipped) and some are locked (failed) shows only the failure copy.

That is worse than dropping a number: executed-with-fails reads as "these N could not be actioned" and attributes the whole shortfall to permissions or locks, when part of it was items that were merely on the wrong step. The user's next move (go unlock things) is then wrong.

Either add a both-present message, or compose the detail from the non-zero parts instead of picking one branch.

this.#store.loadItems();
// A no-op when the user already closed the dialog, which is the common path now that
// firing hands off to the toolbar.
this.#store.closeDialog();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low — not a no-op in the other reachable case.

The comment covers the common path, but there is a second one: firing hands off and closes the dialog, and the dialog is [closable]="true", so the user can reopen the Action Center while a run is still in flight. When the run settles, this line closes it out from under them.

And the reopened dialog is not useful in the meantime — $executing disables every quick-action row and the Done button (dot-content-drive-action-center.component.ts:222). So the sequence is: reopen, find everything greyed out, then have the window vanish on its own.

The store already knows it is executing, which is the ingredient needed either way: block reopening while a run is in flight, or gate this closeDialog() on the dialog being the one that fired. The PR body frames truthful state in a reopened dialog as a feature of moving execution into the store, so this looks like a gap in that story rather than a disagreement with it.

…mary

Three findings from @zJaaal's review of #36921.

1. The lock/unlock integration tests never ran in CI.
WorkflowResourceLockUnlockIntegrationTest was in no MainSuite, so the entire
server-side proof of this PR's contract — per-item failure reporting, mixed
ownership, canLock enforcement — was inert and a regression in either fire
command would have landed green. Registered next to its sibling in
MainSuite2a.

2. The Unlock warning asserted an outcome it cannot know.
The hint said locks held by others "will be reported as failed", but
contentEditable is lockedBy == currentUser and never consults the CMS
Administrator role, while canLock returns true for an admin before it looks
at the owner. An admin was warned about rows they would go on to unlock
successfully. The copy now says "may require administrator permission"
rather than predicting the outcome. The two known false positives —
administrators, and EDIT held at content-type rather than contentlet level —
are documented at warnWhen, along with what removing them would take
(plumbing DotCurrentUser.admin), since that is the same plumbing the preview
needs to mark the offending rows.

3. A missing summary reported every item as a success.
successCount fell back to inodes.length while failCount fell back to 0, so a
200 whose body carried no summary produced "all N succeeded" with a green
toast — the most reassuring possible message for the case where nothing is
known to have succeeded. That body is reachable: the endpoint streams results
then summary, and the writer swallows an IOException mid-stream. Neither
fallback is honest, since inodes.length and 0 both invent a number the server
never sent, so an absent summary now routes through the same error path as a
transport failure and publishes no result at all. A summary that genuinely
reports zero successes is still a fact and still reaches the toast, which is
pinned by its own test.

Left for a separate decision: the preview still cannot show which rows hold
foreign locks, which is what the preview was added to enable.

Tests: 1032 content-drive specs pass, dotcms-integration compiles, lint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI: Safe To Rollback Area : Backend PR changes Java/Maven backend code Area : Frontend PR changes Angular/TypeScript frontend code

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

Content Drive Action Center: add bulk Lock and Unlock quick actions

3 participants