feat(content-drive): gate quick actions on the workflow default action mapping - #37075
feat(content-drive): gate quick actions on the workflow default action mapping#37075rjvelazco wants to merge 4 commits into
Conversation
A quick action fires `POST /v1/workflow/actions/default/fire/{systemAction}`,
which the backend resolves through the scheme's Default Actions mapping. When
nothing resolves, `SystemActionApiFireCommandFactory` performs the raw API
operation instead: the content changes state, the workflow never moves, no
actionlet runs. Until now the dialog offered those rows anyway, filtered only
on content state — its own comment admitted the filter was cosmetic ("UX
filter only... Server still allows it"). A content type with a strict
Draft/QA/Publish scheme and no PUBLISH mapping still showed Publish, and the
backend still published.
Quick actions are now narrowed per contentlet to the content types whose
workflow actually maps the system action.
Resolution mirrors `findActionMappedBySystemActionContentlet` and is a
two-gate rule:
- Gate 1, mapped: content-type mappings win outright, else the scheme
mappings, with the System Workflow breaking ties between schemes. Both
halves matter — most installs carry no content-type-level mapping at all,
so the `systemActionMappings` already on the content type payload would
report nothing mapped for practically everything.
- Gate 2, reachable: the mapped action must appear in the bulk-actions lookup
the dialog already runs, with a non-zero count. That is the server's own
answer to what these rows can run now, standing in for `isActionAvailable`,
which no client can compute.
Narrowing is per contentlet rather than all-or-nothing: on a mixed selection
where PUBLISH is mapped for Blog but not Banner, Publish fires on the Blogs
and reports the Banners as excluded. Refusing the whole selection would be
the safer-looking choice and the less useful one.
Lock, Unlock and Add to Bundle are exempt and sit under their own heading.
The first two are per-user version-info state with no actionlet and can never
be mapped; the third is not a SystemAction. Left in one list they would make
the section heading's claim false for three of its rows.
Fails closed throughout: rows stay disabled while the lookup is pending and
if it errors, and the row hint distinguishes "doesn't apply to your
selection" from "your workflow doesn't map this" — same greyed row, entirely
different fix.
Costs one request per content type plus one per scheme. A backend endpoint
returning the *effective* mapping per content type, precedence and
availability already applied, would collapse that to one call and remove the
risk of the two implementations drifting.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
❌ Issue Linking RequiredThis PR could not be linked to an issue. All PRs must be linked to an issue for tracking purposes. How to fix this:Option 1: Add keyword to PR body (Recommended - auto-removes this comment)
Option 2: Link via GitHub UI (Note: won't clear the failed check)
Why is this required?Issue linking ensures proper tracking, documentation, and helps maintain project history. It connects your code changes to the problem they solve. |
Code Review — Content Drive quick actions workflow gateReviewed the diff against A few things worth a look before merge — none blocking. New Issues
No correctness bugs found in the fire path, the fail-closed gating, or the i18n keys (all four new keys — View job run · |
…gatable rows Two fixes from testing. **One list, ungatable rows first.** The gated and exempt rows were split into two groups under separate headings. They are back in a single list, with Lock, Unlock and Add to Bundle leading it — the three rows whose availability no mapping can change, so the top of the list stays put as the selection and content types move underneath it. The info icon on the section heading carries the explanation instead of a second heading. **Gated rows no longer filter on row state.** Publish was hidden for live content, Delete shown only for archived, and so on. Those filters assumed a system action's only effect is the state change in its label, and a mapping makes that false: `PUBLISH` mapped to "Send for Review" moves the workflow step, assigns a reviewer and runs that action's actionlets, all of which is worth doing to a live item. What a gated row means is now decided by the mapping alone, so the mapping alone decides whether it appears — a gated row is offered wherever it is mapped, over every mapped item in the selection. The exempt rows keep their filters, because there the label really is the whole effect: Unlock on unlocked content has nothing to do, and no mapping can change that. `eligibleWhen` is optional now; omitting it means "every contentlet", which is what all five gated rows do. Two notes for manual QA, where the server is stricter than the row is: - `ESContentletAPIImpl` refuses to delete unarchived content that has more than one language version. Those rows now reach the fire and come back as per-item failures in the result toast rather than being filtered out up front. - Archive keeps its danger styling whatever it maps to. When the mapped action carries no archive actionlet, `SystemActionApiFireCommandFactory` forces the archive anyway, so the destructive intent holds even where the mapping does not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Pushed 1. One list, ungatable rows leadThe two-group split is gone. Lock, Unlock and Add to Bundle now head a single list, followed by the gated rows. They are the three whose availability no mapping can change, so the top of the list stays put while everything below it moves with the selection and the content types. The info icon on the section heading carries the explanation instead of a second heading. 2. Gated rows no longer filter on row statePublish was hidden for live content, Delete shown only for archived, and so on. Those filters assumed a system action's only effect is the state change named in its label — and a mapping makes that false. A gated row is now offered wherever it is mapped, over every mapped item in the selection. The exempt rows keep their filters — there the label really is the whole effect, and no mapping can change that Unlock on unlocked content has nothing to do. One correction to the reasoning, worth recordingThe premise was that Publish might not publish. It always does: when the mapped action carries no publish actionlet, The filter was still wrong, for a different reason — publishing is no longer the only effect, so "already live" stopped meaning "nothing to do". Same conclusion, different mechanism. Archive keeps its danger styling on the same logic: the forced fallback means the destructive intent holds even where the mapping does not carry it. Two places the server is stricter than the row now is
Tests: 1269 passed, 32 suites. Lint and |
The excluded-items lock icon is gone. It put a second tooltip on the same row as the row's own, and the two overlapped each other on hover — visible in testing. The row tooltip already names the count via `content-drive.action-center.partly-mapped`, so `unmappedCount` still does its job; only the extra marker goes. Its message key (`content-drive.action-center.unmapped-items`) goes with it, having never shipped. The docs info link beside the Quick Actions heading picked up the default anchor underline, which read as a typo under an uppercase label. Now `no-underline`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`no-underline` alone lost to a higher-specificity global anchor style, so the underline was still rendering. Uses the Tailwind 4 suffix form (`no-underline!`), matching `text-[20px]!` elsewhere in this template. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
video.mov |
The problem
A quick action fires
POST /v1/workflow/actions/default/fire/{systemAction}. The backend resolves that through the scheme's Default Actions mapping — and when nothing resolves,SystemActionApiFireCommandFactoryperforms the raw API operation instead: the content changes state, the workflow never moves, no actionlet runs, no history is written.Until now the dialog offered those rows anyway, filtered only on content state. The file said so itself:
So a content type with a strict Draft → QA → Publish scheme and no
PUBLISHmapping still showed Publish, and the backend still published it. That is the bypass this PR closes.What changed
Quick actions are now narrowed per contentlet to the content types whose workflow actually maps the system action.
The gate is the backend's own rule, in two parts
Resolution mirrors
WorkflowAPIImpl#findActionMappedBySystemActionContentlet:GET /contenttypes/{var}/system/actions+GET /schemes/{id}/system/actions. Content-type mappings win outright; otherwise scheme mappings, with the System Workflow breaking ties between schemes.getBulkActionsresponse the dialog already fetches. The mapped action id must appear with a non-zero count.Gate 2 is the free one. The backend also requires the action to be available from the contentlet's current step and permitted for the user (
isActionAvailable), which no client can compute. The bulk lookup is the server's own answer to "what can these rows run right now", so it stands in.Why both halves of gate 1 are needed
systemActionMappingsis already on the content type payload — and it is the wrong data on its own.ContentTypesPaginatorbuilds it from:Content-type-level only.
Task05175AssignDefaultActionsToTheSystemWorkflowships the System Workflow mapped at scheme level and runs withforceRun() == true, so on a stock install almost no content type carries an override. Gating on that field alone would report "nothing mapped" for practically everything — precisely backwards.Narrowing is per contentlet, not all-or-nothing
On a mixed selection where
PUBLISHis mapped for Blog but not for Banner, Publish fires on the Blogs and marks the Banners excluded. Refusing the whole selection would be the safer-looking choice and the less useful one: the Blogs can be published perfectly legitimately, and a row that just says "no" gives the user nothing to act on.Lock, Unlock and Add to Bundle are exempt — and sit apart
They have no mapping to gate on. Lock/Unlock are per-user state on the version info with no actionlet (
WorkflowAPI.SystemActiondocuments that mapping them has no effect); Add to Bundle is not aSystemActionand posts to the legacy bundle servlet.They now render under their own Always available heading. Left in one list they would make the section heading's claim false for three of its rows on day one.
Fails closed
Gated rows stay disabled while the lookup is pending and if it errors. Treating "not known yet" as permissive would flash a live Publish for the moment before the answer lands — the exact bypass being closed. A mapping failure does not break the dialog: workflow actions are unaffected and still usable.
The row says which problem it has
Same greyed row, entirely different fix:
Info icon
Added on the Quick Actions heading with the agreed copy — "Quick actions only run when mapped to a workflow action — this prevents bypassing your workflow rules." The link is a placeholder (marked
TODOin the source) pointing at the current workflow docs until the Default Actions page exists, so it is never dead but does not yet land on the right anchor.Cost, and the backend follow-up
One request per content type plus one per distinct scheme. Fine for the typical one-or-two-type selection, and it means this MVP is not blocked on backend work — both endpoints already exist.
It is still client-side duplication of what the backend resolves in a single query. A
GETreturning the effective mapping per content type — precedence and availability already applied — would collapse it to one call and remove the risk of the two implementations drifting.loadSystemActionMappingsis the shape to replace when that lands; the comment there says so.Testing
pnpm nx test portlets-content-drive— 1267 passed, 32 suitespnpm nx test data-access— 757 passedpnpm nx run-many -t lint -p portlets-content-drive data-access— cleanpnpm nx format:check --base=main— cleanNew coverage: precedence (scheme-only, content-type override, override-whose-action-is-unavailable does not fall back, System Workflow tiebreak), gate 2 rejection, per-content-type narrowing on a mixed selection, the exemptions, pending and error states, the group split, and the info link.
Not done here
Manual QA against a real instance. The gate's entire value is that it behaves correctly against real mapping data, so this is the testing that matters most. Worth covering:
PUBLISHmapping → Publish disabled, hint names the mappingPUBLISH→ Publish liveComparison with #37073
🤖 Generated with Claude Code