Skip to content

feat(content-drive): gate quick actions on the workflow default action mapping - #37075

Draft
rjvelazco wants to merge 1 commit into
mainfrom
spike/content-drive-quick-actions-workflow-gated-v2
Draft

feat(content-drive): gate quick actions on the workflow default action mapping#37075
rjvelazco wants to merge 1 commit into
mainfrom
spike/content-drive-quick-actions-workflow-gated-v2

Conversation

@rjvelazco

Copy link
Copy Markdown
Member

Draft / spike — not for merge as-is. Route 2 of two options for how Content Drive's Action Center should relate to workflow rules. Route 1 is #37073. Opened as a draft so both can be tested side by side. No linked issue yet.

Route 1 (#37073) deletes the state actions. This one keeps them and gates them.

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, SystemActionApiFireCommandFactory performs 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:

{ id: PUBLISH, eligibleWhen: (item) => !item.live && !item.archived }
// "UX filter only… Server still allows it."

So a content type with a strict Draft → QA → Publish scheme and no PUBLISH mapping 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:

Gate Question Source
1 — mapped Does a mapping resolve? 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.
2 — reachable Can the selection run it now? The getBulkActions response 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

systemActionMappings is already on the content type payload — and it is the wrong data on its own. ContentTypesPaginator builds it from:

select * from workflow_action_mappings where scheme_or_content_type in (%s)  -- content type variables

Content-type-level only. Task05175AssignDefaultActionsToTheSystemWorkflow ships the System Workflow mapped at scheme level and runs with forceRun() == 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 PUBLISH is 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.SystemAction documents that mapping them has no effect); Add to Bundle is not a SystemAction and 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:

  • "Doesn't apply to any of the selected items" → change the selection
  • "None of the selected content types map this action to a workflow action" → change the scheme, or ask someone who can
  • Partly mapped → the row stays live, with a lock icon and a count of what was excluded

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 TODO in 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 GET returning 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. loadSystemActionMappings is the shape to replace when that lands; the comment there says so.

Testing

  • pnpm nx test portlets-content-drive1267 passed, 32 suites
  • pnpm nx test data-access757 passed
  • pnpm nx run-many -t lint -p portlets-content-drive data-access — clean
  • pnpm nx format:check --base=main — clean

New 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.

nx affected also flags block-editor / dotcms-block-editor as failing (37 tests, plus lint errors). Verified identical on clean main — pre-existing, pulled in only because data-access changed.

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:

  • a content type on a custom scheme with no PUBLISH mapping → Publish disabled, hint names the mapping
  • the same content type after mapping PUBLISH → Publish live
  • a mixed selection where only one type is mapped → partial count plus the exclusion marker
  • content sitting on a step the mapped action is not reachable from → still disabled (gate 2)
  • Lock / Unlock / Add to Bundle unaffected throughout

Comparison with #37073

#37073 (route 1) This PR (route 2)
Publish/Unpublish/Archive/Unarchive/Delete removed from Quick Actions kept, gated on the mapping
Reaching them Workflow Actions section only either, when mapped
Closes the bypass yes, by removal yes, by gating
New requests per open none 1 per content type + 1 per scheme
Diff size −298 / +285 +903 / −82
Backend follow-up none optional: resolved-mapping endpoint

🤖 Generated with Claude Code

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>
@github-actions

Copy link
Copy Markdown
Contributor

❌ Issue Linking Required

This 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)
Edit this PR description and add one of these lines:

  • Fixes #123 or Closes #123 — same-repo issue
  • Fixes org/repo#123 or Closes org/repo#123 — cross-repo or private issue
  • Closes https://github.com/org/repo/issues/123 — full GitHub URL
  • Other supported keywords: fix, fixed, close, closed, resolve, resolved

Option 2: Link via GitHub UI (Note: won't clear the failed check)

  1. Go to the PR → Development section (right sidebar)

  2. Click "Link issue" and select an existing issue

  3. Push a new commit or re-run the workflow to clear the failed check
    Option 3: Use branch naming
    Create a new branch with one of these patterns:

  • 123-feature-description (number at start)

  • issue-123-feature-description (issue-number at start)

  • feature-issue-123 (issue-number anywhere)

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.
---This comment was automatically generated by the issue linking workflow

@github-actions github-actions Bot added the Area : Frontend PR changes Angular/TypeScript frontend code label Aug 14, 2026
@claude

claude Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

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


Code Review — PR #37075 (Content Drive quick-action gating)

Reviewed the two-gate resolution (resolvedSystemActions, availableWorkflowActionIds), the per-contentlet narrowing in getQuickActions, the component wiring in loadSystemActionMappings, the template split, and the new i18n keys. This is a draft/spike, but one correctness issue in the mixed-selection path is worth calling out because it defeats the PR's headline behavior.

New Issues

  • 🟠 High: dot-content-drive-action-center.component.ts:1057 (loadSystemActionMappings) — Scheme mappings are pooled globally, so on a mixed selection a legitimately-mapped quick action is hidden for all-but-one content type. schemeMappings = schemes.flat() collapses every scheme's mappings across the whole selection, and that single pool is passed to resolvedSystemActions for every content type. Inside resolvedSystemActions (action-center.ts:154), bySystemAction is a single Map<systemAction, workflowActionId> — it can only remember one workflow action id per system action. So when two selected content types map the same system action to different workflow actions (Blog on scheme S1 → publish-S1, News on scheme S2 → publish-S2), the last write wins and only one action id survives. Gate 2 (availableWorkflowActionIds, per content type) then clears only the content type whose action id survived; the other resolves as unmapped. Result: PUBLISH shows disabled / partly-mapped with a wrong exclusion count for a content type that legitimately maps it.

    This is the exact "PUBLISH mapped for both types on their own schemes" case the PR's per-contentlet-narrowing claim rests on. It fails closed (it over-restricts, never re-opens the bypass), so it's not a security regression — but it's user-visible wrong gating on a realistic mixed selection (two content types each on their own custom workflow), and the stock all-on-System-Workflow case masks it because every type then shares the same action id.

    The tests don't catch it: resolvedSystemActions is only exercised from a single content type's perspective, and the component's "fire a gated action on the mapped content type only" test narrows via gate 2 (differing bulk-action counts) with both types sharing one scheme mapping — so distinct-action-per-scheme is never covered. Root cause is that mergeActionCenterSchemes produces a flat scheme list with no content-type association (action-center.ts:452), so loadSystemActionMappings has nothing to scope the pool by. A fix needs per-content-type scheme grouping so each content type resolves against only its own schemes' mappings. Fix this →

Notes (non-blocking)

  • 🟡 Medium: dot-content-drive-action-center.component.ts:1064 — if results is ever empty, forkJoin({ contentTypes: forkJoin([]), ... }) completes without emitting next, so $mappedSystemActions stays undefined and every gated row stays disabled (the intended fail-closed default). In practice results mirrors the bulk-action groups and won't be empty when there are contentlets, so this is latent rather than live — worth a guard if that invariant ever loosens.
  • The docs URL (component.ts:187) is a TODO placeholder by design — noted, not a defect.
  • i18n: all four hint keys (not-mapped, partly-mapped, mapping-lookup-failed, unmapped-items) plus always-available and gate-explainer are present in Language.properties. Passing unmappedCount to | dm for keys without a {0} placeholder is harmless. ✅

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 (aria-label + tooltip) — reads correctly.

· branch spike/content-drive-quick-actions-workflow-gated-v2

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI: Safe To Rollback Area : Frontend PR changes Angular/TypeScript frontend code

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

1 participant