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 1 commit 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. |
|
Claude finished @rjvelazco's task in 3m 40s —— View job Code Review — PR #37075 (Content Drive quick-action gating)Reviewed the two-gate resolution ( New Issues
Notes (non-blocking)
Everything else — the exemption modeling, the fail-closed pending/error states, gate 2 as the availability proxy, the template row extraction, and the info-icon a11y ( · branch |
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