Skip to content

feat(content-drive): add Action Center for bulk workflow actions - #36821

Open
rjvelazco wants to merge 14 commits into
mainfrom
issue-36817-content-drive-action-center-bulk-workflow-actions-with-one-action-per-execution
Open

feat(content-drive): add Action Center for bulk workflow actions#36821
rjvelazco wants to merge 14 commits into
mainfrom
issue-36817-content-drive-action-center-bulk-workflow-actions-with-one-action-per-execution

Conversation

@rjvelazco

@rjvelazco rjvelazco commented Jul 30, 2026

Copy link
Copy Markdown
Member

Draft implementation of the Content Drive Action Center — a dialog for acting on a multi-item selection.

Opening as a draft: this is a working v1 to demo and discuss with the team, with a few deliberate scope limits documented below.

Fixes #36817

Proposed Changes

  • getBulkActions() on DotWorkflowsActionsService — wraps POST /api/v1/workflow/contentlet/actions/bulk, which returns the workflow actions available for a set of contentlets, grouped by scheme and step, each with the number of selected contentlets it applies to. New DotBulkActionView models in dotcms-models.
  • DotContentDriveActionCenterComponent — the dialog, with two sections:
    • Quick Actions — Publish / Unpublish / Archive / Unarchive / Delete, each fired over the whole eligible selection in one request via POST /api/v1/workflow/actions/default/fire/{systemAction}. Counts are derived client-side from row state (live, archived), and an action that applies to nothing is omitted rather than shown as (0).
    • Workflow Actions — one collapsible panel per workflow scheme, with real per-action eligibility counts from the backend's Elasticsearch aggregation on wfstep. Single-expand; one action selected at a time.
  • Toolbar — once more than one contentlet is selected, the flat action buttons are replaced by a single "Action Center" button. Folders are excluded from the count and from every payload, since the bulk endpoints take contentlet inodes and folders carry no workflow step.
  • ShellDIALOG_TYPE.ACTION_CENTER is routed out of the shared dialog's content switch and mounted as a sibling, because this dialog owns a custom header and footer (see Additional Info). The store stays the single source of open/close state.
  • i18n — 19 content-drive.action-center.* keys.

Checklist

  • Tests — 37 new (action-center.spec.ts for the pure mapping/eligibility logic, plus the component spec). Full portlet suite green: 872/872, lint clean.
  • Translations — English keys added to Language.properties; other locales not translated.
  • Security Implications Contemplated — no new endpoints and no new permissions surface; this consumes existing authenticated REST endpoints. Worth knowing for review: the bulk-actions endpoint filters by action-level role permission only and deliberately lets actions with special workflow roles through, because there is no per-contentlet permissionable at aggregation time (WorkflowActionUtils#filterBulkActions). So a listed action can still fail per item at fire time, which the backend reports in fails[] and the dialog surfaces. Enforcement stays server-side; the counts here are advisory, never an authorization decision.

Additional Info

Scope limits, all deliberate:

  • One action per execute. No endpoint fires several different actions in one call, and this isn't just a missing endpoint — workflow_task has a UNIQUE (webasset, language_id) constraint, so a contentlet+language sits on exactly one step, owned by exactly one scheme. Firing action A moves it to a new step, which invalidates the other actions' preconditions. The legacy JSP dialog has the same constraint. A useful consequence: the scheme counts partition the selection rather than overlapping.
  • Actions needing extra input are disabled with a tooltip — push-publish settings, a move target path, or an assign/comment prompt. Wiring those means reusing DotWorkflowEventHandlerService; out of scope here.
  • Conditional counts are shown as ≤ N. When conditionPresent is true the backend does not evaluate the action's Velocity condition while aggregating, so the count is an upper bound.
  • Lock / Unlock and Add to Bundle are omitted — neither has a bulk REST endpoint. Legacy drives unlock through a Struts command (full_unlock_list) that loops server-side, and add-to-bundle through the legacy RemotePublishAjaxAction servlet.
  • Fires synchronously via bulkFire. The legacy dialog uses the SSE endpoint (_bulkfire) for live progress counters, which is the better path for large batches but needs an SSE shim since native EventSource cannot POST a body.
  • System Workflow stays visible. Hiding it would remove bulk Copy outright — Copy has no system-action mapping, so it is only reachable through the workflow list — and would empty the section on Community, where System Workflow is the only scheme that can exist.

Known follow-ups (backend asks):

  • _bulklock / _bulkunlock REST endpoints (the _batchUnlock logic already exists to port).
  • An endpoint returning eligible inodes per action — the only thing that unblocks the design's per-item drill-down, which needs to know which items an action applies to. The API returns aggregate counts only.
  • A REST replacement for the legacy add-to-bundle AJAX action.

Deviations from the design prototype, both following from the above: workflow steps use radio semantics rather than checkboxes (multi-select would need sequential fires with re-validation between each), and there are no per-step icon chips (the API returns dotCMS icon names that don't map to Material Symbols).

Styling note: the prototype's raw slate-* / #1D1B4B / #1D4ED8 values are mapped onto the theme tokens tailwindcss-primeui exposes (surface-*, primary-*), so the dialog follows the active PrimeNG theme instead of pinning one palette. If that navy is meant to be a real brand token, that's a theme-level change rather than something hardcoded here.

Video

video.mov

rjvelazco and others added 8 commits July 29, 2026 16:50
Adds the client side of POST /api/v1/workflow/contentlet/actions/bulk,
which returns the workflow actions available for a set of contentlets
grouped by scheme and step, each with the number of selected contentlets
it applies to.

The models capture two properties of the response that matter to callers:

- An action's `count` is already summed across every step of its scheme,
  so flattening steps into one list per scheme does not double-count.
- `conditionPresent` means the count is an upper bound. The backend does
  not evaluate the action's Velocity condition while aggregating, since
  there is no per-contentlet permissionable at that point.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Draft of the Action Center: a dialog for acting on a multi-item selection,
opened from the toolbar once more than one contentlet is selected. Built
with PrimeNG (accordion, radiobutton, badge, message, skeleton) and
Tailwind for layout only.

Quick Actions fires system actions over the whole eligible selection in
one request. Counts are derived client-side from row state, and an action
that applies to nothing is omitted rather than shown as "(0)".

Workflow Actions renders one collapsible panel per scheme from the bulk
actions endpoint, with real per-action eligibility counts. Steps are
flattened into a single list per scheme, since the step grouping is a
backend detail the dialog does not need to surface.

Scope limits, all deliberate:

- One action per execute. No endpoint fires several different actions in
  one call, and firing one moves contentlets to a new step, which
  invalidates the other counts. The legacy JSP dialog works the same way.
- Actions needing extra input (push publish, move path, assign/comment)
  are disabled with a tooltip rather than reimplementing the params
  dialog. Conditional counts render as "<= N".
- Lock/Unlock and Add to Bundle are omitted: neither has a bulk REST
  endpoint. Legacy drives unlock through a Struts command that loops
  server-side, and add-to-bundle through a legacy AJAX servlet.
- Fires synchronously. Legacy uses the SSE endpoint for live progress,
  which is the better path for large batches but needs an SSE shim.
- Folders are excluded from every payload, matching the endpoints.

System Workflow is intentionally left visible: hiding it would remove
bulk Copy entirely (it has no system-action mapping) and would empty the
section on Community, where it is the only scheme that can exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Addresses design feedback on the dialog shell.

The Action Center now owns its own p-dialog instead of rendering inside
the shell's shared one. That shared dialog gives a title and a scrolling
content area; this dialog needs a custom header, a custom footer, and a
body that is the only scrollable region. Two constraints forced the split:

- PrimeNG queries `#header` / `#footer` with `descendants: false`, so the
  templates must be direct children of `p-dialog` and cannot sit inside
  the shell's `@switch` over dialog types.
- Providing a `#footer` template on the shared dialog would render an
  empty footer for the folder and content-type dialogs.

The shell routes ACTION_CENTER out of the shared dialog's content switch
and mounts the component as a sibling instead, keeping the store as the
single source of open/close state.

Layout changes:

- Header carries the title and the selected-contentlet count.
- Footer carries the "one at a time" hint and Done, pinned.
- Content area is `p-0 overflow-hidden` via `pt`; an inner div owns
  `max-h-[60vh] overflow-y-auto`, so only the body scrolls.
- Quick action rows are plain buttons styled with Tailwind to match the
  design: a Material Symbols icon chip, left-aligned label, count and
  chevron trailing, red chip and label for destructive actions. They were
  p-buttons before, which centered their content and could not express
  the chip.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Restyles the dialog against the prototype in
remix_-content-drive/components/ActionCenterDialog.tsx.

The prototype's raw palette is mapped onto the theme tokens that
tailwindcss-primeui exposes — `surface-*`, `primary-*`, `border-surface`
— rather than copying its `slate-*` and hardcoded `#1D1B4B` / `#1D4ED8`
values, so the dialog follows the active PrimeNG theme.

- Fixed-height flex column (`80vh`, `42rem` wide) with the content area
  flexing and scrolling. Replaces the `max-h-[60vh]` guess on an inner
  div; this is how the prototype does it and it keeps header and footer
  pinned without a second scroll container.
- Card treatment: sections sit on `surface-50` inside a rounded-xl
  `surface-100` border, and rows lift to `surface-0` on hover. This was
  inverted before (white card, grey hover).
- Row metrics from the prototype: `gap-4`, `py-3.5`, 20px icon glyphs,
  `size-9` chips with `shadow-sm`, 10px bold section labels.
- Scheme panels are now single-expand, matching the prototype: opening one
  collapses the rest, and the expanded scheme's name takes the primary
  colour. Collapsing a panel clears its pending action so Execute cannot
  stay armed for a hidden panel.

Two deliberate deviations from the prototype, both carried over from the
endpoint analysis:

- Steps use radio semantics, not checkboxes. The prototype multi-selects
  steps, but no endpoint fires several actions in one call and firing one
  moves contentlets to a new step, invalidating the other counts.
- No per-step icon chips. The prototype hand-picks a glyph per step; the
  API returns dotCMS icon names that do not map to Material Symbols, so
  there is nothing to render faithfully yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A collapsed scheme panel in the Action Center still took up the height of
its expanded content, leaving a large blank gap under the header.

PrimeNG 21 collapses accordion content by animating the motion wrapper's
`grid-template-rows` to `0fr`, but that wrapper is configured with
`hideStrategy: 'visibility'` and `unmountOnLeave: false`, so it stays
mounted and keeps its layout box. Without `overflow: hidden` the content
simply overflows the zero-height grid row and the panel keeps its full
height. PrimeNG's own stylesheet only ever sets `grid-template-rows: 1fr`
on `.p-accordioncontent .p-motion`, so nothing clips it by default.

Fixed with the same `pt` override already used by
dot-page-scanner-a11y-report:

    { motion: { root: { style: { overflow: 'hidden' } } } }

Also adopts that component's `dt` content-padding reset so the rows own
their padding, which restores the design's full-bleed dividers, and makes
`[multiple]="false"` explicit rather than relying on the default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The scheme panels rendered as borderless grey cards. A `border` utility on
the panel had no effect: the theme ships `accordion.panel.border.width` as
`0 0 1px 0` — inter-panel dividers, which suit one stacked accordion — and
its runtime-injected CSS wins over a utility class.

Overriding the design token instead gives each panel a real border, which
is what the design shows: the schemes are separate cards separated by a
gap, not a stack.

    panel: { borderWidth: '1px', borderColor: '{surface.200}' }

The content section also gets a top border so an expanded header is
separated from its rows, and its background moves to the token rather than
a utility on an inner div, keeping one source of truth per surface.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Gives the scheme panels the same radius and background treatment as the
Quick Actions list, and fixes the reason they did not match.

`p-accordion-panel` exposes only a `value` input — it has no `styleClass`
— so the `rounded-xl bg-surface-50 overflow-hidden` attribute on it was
inert. It has been doing nothing since the panels were introduced; the
grey came from the theme's header background, not from that class. Panel
styling now goes through the `panel` pass-through section, where
`overflow: hidden` is also what clips the header and content corners to
the panel radius.

This also corrects the reasoning in 2a4ebf1: the panel border did not
lose a specificity fight with the theme, the class was never applied at
all. Overriding `accordion.panel.border.*` remains the right fix, since
the theme's `0 0 1px 0` default is meant for a stacked accordion.

Header backgrounds now come from tokens and mirror the Quick Actions rows:
`surface-50` at rest, white on hover, and white while expanded so the open
scheme reads as the active card.

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

Applies the scheme panel's radius via `p-accordion-panel`'s `pt.root`
instead of the parent accordion's `pt.panel`, keeping the override on the
component it targets. Also drops a stray unused `PanelClasses` import.

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

The Action Center button was replacing the flat workflow action buttons
once more than one contentlet was selected, so the quick per-item actions
disappeared exactly when a multi-selection was made.

They are now offered together: the workflow action buttons stay visible for
any selection and keep their place first in the toolbar row, with the
Action Center button appended after them. The dialog adds what the flat
buttons cannot express — per-action eligibility counts and the workflow
actions grouped by scheme — rather than standing in for them.

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

claude Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Claude finished @rjvelazco's task in 5m 18s —— View job


Code Review

Reviewed the diff against the Action Center feature. One correctness issue stands out, plus a couple of smaller items.

New Issues

  • 🟠 High: core-web/.../dot-content-drive-action-center.component.ts:222,249,253Quick actions fire over the whole selection, not the eligible subset. The displayed count is derived per-action from eligibleWhen (action-center.ts:173contentlets.filter(quickAction.eligibleWhen).length), but onExecuteQuickAction builds inodes from toContentletInodes(this.$selectedItems()) — every contentlet, regardless of eligibility — and fireQuickAction fires the system action over all of them. The component's own spec proves the mismatch: with inode-1 (not live) + inode-2 (live: true), Publish renders (1) yet fireDefaultAction is asserted to be called with ['inode-1', 'inode-2'] (spec lines 308–317). Consequences:

    • The success toast reports inodes.length (fireQuickActiononExecuteSuccess(..., inodes.length)), so it says "Published (2)" when the UI advertised (1).
    • For a mixed selection this fires lifecycle actions on ineligible items — e.g. 1 archived + 1 live, Delete shows (1) but fires DELETE over the live inode too. fireDefaultAction returns DotCMSContentlet[] with no per-item fails[], so an ineligible item can fail the whole call (or mutate state you didn't intend), unlike the workflow-action path which reports skippedCount.
      This contradicts the PR description ("each fired over the whole eligible selection"). The fix is to carry eligibleWhen onto the rendered DotActionCenterQuickAction (or recompute it at fire time) and fire only the eligible inodes, then report that count in the toast. Fix this →
  • 🟡 Medium: core-web/.../dot-content-drive-action-center.component.ts:437-445 — Error handling uses a bare MessageService toast + console.error instead of DotHttpErrorManagerService.handle(error). The portlet guide (libs/portlets/CLAUDE.md) lists custom error UI as an anti-pattern and prescribes DotHttpErrorManagerService for HTTP errors — which also correctly handles 401/403/404 rather than showing a generic "error" toast for, say, a permission failure at fire time (exactly the case the PR notes can happen server-side). Assumption: the fire endpoints surface auth/permission failures as HTTP errors here; What to verify: whether a per-item permission failure comes back as a 4xx (→ handled better by the error manager) vs. inside a 200 fails[] body.

  • 🟡 Medium: core-web/.../dot-content-drive-action-center.component.ts:184-186$quickActions is computed from getQuickActions(this.$selectedItems()) while $contentlets/$contentletCount use excludeFolders(...). getQuickActions re-runs excludeFolders internally, so folders are handled correctly, but the two computeds walk the selection separately. Minor — not a bug, just redundant work; deriving $quickActions from $contentlets() would keep one source of truth.

No security issues found — this consumes existing authenticated endpoints and enforcement stays server-side, consistent with the PR's security note. Folder exclusion from payloads is applied consistently (toContentletInodes, excludeFolders).


The High item is the one worth resolving before merge; it's a real display-vs-behavior divergence that the existing test locks in rather than catches.

rjvelazco and others added 2 commits July 30, 2026 16:20
…tlet

Drops the Action Center button's threshold from more than one contentlet to
one or more, so it is available alongside the flat action buttons for a
single selection.

This widens the original scope, which specced the button as appearing only
for a multi-selection. It holds up: the dialog's value is the per-action
eligibility counts and the workflow actions grouped by scheme, and neither
depends on the selection having more than one item. The bulk endpoints take
an inode list, so a single inode needs no special handling.

Folders still do not count — a folder-only selection offers no Action
Center, since every bulk endpoint ignores folders.

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

Two pieces of design feedback.

**Renamed to "Workflow Center"** and dropped the toolbar button's icon. The
button and the dialog header share one message key, so the label change
covers both and they stay consistent.

**Quick actions are no longer filtered out when they apply to nothing.**
Every action is always listed; a count of 0 now means "does not apply to
this selection" and the row renders non-selectable, with a tooltip saying
so. Previously such rows were dropped, which made the list shift as the
selection changed — rows appearing and disappearing under the pointer, and
no indication that an action existed but was unavailable. An empty result
still means there are no contentlets at all, where nothing could apply.

Note this makes the count honest rather than decorative: a disabled row is
the one place the UI admits an action is unavailable for the whole
selection, which the previous behaviour hid entirely.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rjvelazco and others added 2 commits July 31, 2026 13:08
…x the order

Adds Add to Bundle to the quick actions, always non-selectable with a hint
explaining why, and pins the display order to Publish, Unpublish, Archive,
Delete, Add to Bundle. Rows hold that position whether or not they are
selectable, so the list never reshuffles as the selection changes.

Add to Bundle is not blocked on an endpoint: `POST /api/v1/bundles/assets`
takes a list of asset **identifiers** (note: identifiers, unlike the
workflow bulk endpoints, which take inodes). What it needs is a target
bundle, so a picker step — `DotAddToBundleComponent` accepts a single
identifier and still posts to the legacy AJAX servlet — plus an
enterprise-license gate. That is a shared-component change and is tracked
separately, hence the disabled row rather than a half-wired one.

A `pendingHint` on the action models "no working implementation yet",
distinct from a zero count meaning "does not apply to this selection". The
hint takes precedence, so the row explains itself rather than blaming the
selection, and the execute handler guards on it as well as on the disabled
attribute.

This drops **Unarchive**, which is not in the design's quick-action set. It
is a valid SystemAction and was working, so re-adding it is a one-line
change if that set is revisited.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Puts Unarchive back, positioned after Delete so the two archived-only
actions sit together and Add to Bundle stays last.

Without it, archiving was a one-way trip: Archive shows for items that are
not archived and Delete for items that are, but nothing in the dialog could
un-archive. Unarchive is a valid SystemAction the multi-contentlet fire
endpoint accepts, so it needs no extra plumbing.

Order is now Publish, Unpublish, Archive, Delete, Unarchive, Add to Bundle,
asserted by the fixed-order test.

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

Removes Publish, Unpublish, Archive, Unarchive and Delete from the
toolbar's flat action buttons. They are offered by the Workflow Center's
Quick Actions, which shows how many of the selected items each one applies
to — something a flat button cannot express. Keeping both meant the same
action appeared twice, reached by two different code paths.

The toolbar keeps what the dialog does not cover: Edit Content, Edit Page,
Rename, Save as Draft and Download.

Two consequences handled rather than shipped:

- **Quick action labels no longer borrow from the toolbar list.** They were
  looked up in `DEFAULT_WORKFLOW_ACTIONS` by id, so trimming that array
  would have rendered raw ids ("PUBLISH"). Each quick action now carries its
  own `nameKey`, reusing the same `Default-Action-*` message keys.
- **Bulk Delete keeps its confirmation.** The toolbar's Delete prompted
  before firing; moving it would have left the only delete path unguarded.
  The Action Center now confirms any quick action carrying a
  `confirmMessage`, which is Delete alone, matching the previous behaviour.

Test changes: the toolbar spec used Publish and Archive as stand-ins for
"a workflow action" in its execute, toast and error tests — those are
repointed to Save as Draft, which fires through the same path. The two tests
whose subject was specifically a removed action are gone, and Delete's
confirm-then-fire flow is now covered in the Action Center spec, including
the dismissal case. The toolbar's confirm branch is left in place as the
mechanism a future confirmable action would use.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rjvelazco
rjvelazco marked this pull request as ready for review July 31, 2026 18:00
@rjvelazco

Copy link
Copy Markdown
Member Author

@claude core review this PR and propose some unit test

@claude

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Claude finished @rjvelazco's task in 6m 31s —— View job


Review: Action Center for bulk workflow actions

Todo list

  • Gather context (diff, changed files, related utils/component/service code)
  • Review core logic: action-center.ts, the dialog component, DotWorkflowsActionsService, toolbar/shell wiring
  • Check status of previously-flagged High issue (quick actions firing over ineligible items)
  • Propose additional unit tests
  • Post final review

Status of the earlier High finding

Still open. onExecuteQuickAction (dot-content-drive-action-center.component.ts:222) builds inodes from toContentletInodes(this.$selectedItems()) — the whole selection — while the displayed count (action-center.ts:173) is contentlets.filter(quickAction.eligibleWhen).length. The component's own spec still locks in the mismatch: inode-1 (not live) + inode-2 (live: true) → Publish renders (1) but fireDefaultAction is asserted called with ['inode-1', 'inode-2'] (spec lines 308–317). Firing a lifecycle action (e.g. DELETE) over an item that doesn't satisfy eligibleWhen contradicts the PR description ("fired over the whole eligible selection") and can mutate items the UI never advertised as affected. Worth fixing before merge — see the proposed regression test below.

New/confirmed findings

  • 🟡 Mediumdot-content-drive-action-center.component.ts:437-445 (onExecuteError): custom MessageService toast + console.error instead of DotHttpErrorManagerService.handle(error). libs/portlets/CLAUDE.md's anti-pattern table calls this out explicitly ("Custom error dialogs" → "Use DotHttpErrorManagerService.handle(error) everywhere"), and it's the pattern already used elsewhere in this same portlet (e.g. dot-content-drive-navigation.service.ts). A generic "Action failed" toast also masks 401/403 differently than the error manager would, which matters here specifically since the PR notes a listed action can fail per-item on a permission check at fire time.
  • 🔵 Minordot-content-drive-action-center.component.ts:184-186: $quickActions computes from getQuickActions(this.$selectedItems()) while $contentlets() already holds excludeFolders(this.$selectedItems()). getQuickActions re-derives the same folder exclusion internally, so behavior is correct, just redundant — getQuickActions could take this.$contentlets() instead of walking the raw selection twice. Fix this →
  • No security regressions found beyond what's already noted in the PR description — enforcement stays server-side, folder exclusion is applied consistently across every payload (toContentletInodes, excludeFolders), and the confirm-before-destructive-action behavior (Delete) matches the toolbar's prior behavior.

Test coverage gaps

The new specs (action-center.spec.ts — pure mapping, and the component spec) are solid for the mapping/eligibility logic and the dialog's own behavior, but a few things introduced or changed by this PR aren't exercised anywhere:

  1. DotWorkflowsActionsService.getBulkActions (dot-workflows-actions.service.ts:100) has no test. dot-workflows-actions.service.spec.ts covers every other method on this service via createHttpFactory/expectOne but wasn't updated for the new one.
  2. Toolbar → Action Center wiring (dot-content-drive-toolbar.component.ts:287-299, $displayActionCenter / onOpenActionCenter) has no test in dot-content-drive-toolbar.component.spec.ts.
  3. Shell routing of DIALOG_TYPE.ACTION_CENTER (dot-content-drive-shell.component.ts:197-207, the $isActionCenter/$sharedDialogVisible/$actionCenterVisible split) isn't covered by dot-content-drive-shell.component.spec.ts.
  4. The eligibility bug above has no regression test once fixed.

Proposed unit tests

1. Regression test for the eligibility bug (add to dot-content-drive-action-center.component.spec.ts, once the fix lands):

it('should fire the quick action only over eligible inodes', () => {
    // inode-1 is not live (eligible for Publish); inode-2 is already live (not eligible)
    spectator.detectChanges();

    spectator.click('[data-testid="quick-action-PUBLISH"]');

    expect(fireService.fireDefaultAction).toHaveBeenCalledWith({
        action: 'PUBLISH',
        inodes: ['inode-1'] // NOT inode-2
    });
});

it('should report the eligible count in the success toast, not the full selection size', () => {
    spectator.detectChanges();
    spectator.click('[data-testid="quick-action-PUBLISH"]');

    expect(messageService.add).toHaveBeenCalledWith(
        expect.objectContaining({
            detail: expect.stringContaining('1') // eligible count, not 2
        })
    );
});

2. DotWorkflowsActionsService.getBulkActions (add to dot-workflows-actions.service.spec.ts):

it('should post contentletIds and unwrap the entity', (done) => {
    const request = { contentletIds: ['inode-1', 'inode-2'] };
    const mockResponse = { schemes: [] };

    spectator.service.getBulkActions(request).subscribe((res) => {
        expect(res).toEqual(mockResponse);
        done();
    });

    spectator
        .expectOne('/api/v1/workflow/contentlet/actions/bulk', HttpMethod.POST)
        .flush({ entity: mockResponse });
});

it('should fall back to an empty schemes array when entity is missing', (done) => {
    spectator.service.getBulkActions({ contentletIds: ['a'] }).subscribe((res) => {
        expect(res).toEqual({ schemes: [] });
        done();
    });

    spectator
        .expectOne('/api/v1/workflow/contentlet/actions/bulk', HttpMethod.POST)
        .flush({});
});

3. Toolbar → Action Center gating (add to dot-content-drive-toolbar.component.spec.ts):

it('should hide the Action Center button for a folder-only selection', () => {
    mockSelectedItems.set([{ type: 'folder', inode: 'f1' } as DotContentDriveItem]);
    spectator.detectChanges();

    expect(spectator.query('[data-testid="action-center-button"]')).toBeFalsy();
});

it('should show the Action Center button once a contentlet is selected', () => {
    mockSelectedItems.set([{ baseType: 'CONTENT', inode: 'c1' } as DotContentDriveItem]);
    spectator.detectChanges();

    expect(spectator.query('[data-testid="action-center-button"]')).toBeTruthy();
});

it('should open the ACTION_CENTER dialog on click', () => {
    mockSelectedItems.set([{ baseType: 'CONTENT', inode: 'c1' } as DotContentDriveItem]);
    spectator.detectChanges();

    spectator.click('[data-testid="action-center-button"]');

    expect(store.setDialog).toHaveBeenCalledWith(
        expect.objectContaining({ type: DIALOG_TYPE.ACTION_CENTER })
    );
});

4. Shell dialog routing (add to dot-content-drive-shell.component.spec.ts):

it('should mount the Action Center as a sibling, not inside the shared dialog switch', () => {
    mockDialog.set({ type: DIALOG_TYPE.ACTION_CENTER, header: 'x' });
    spectator.detectChanges();

    expect(spectator.query('dot-content-drive-action-center')).toBeTruthy();

    const sharedDialog = spectator.query('[data-testId="dialog"]');
    expect(sharedDialog?.getAttribute('ng-reflect-visible')).not.toBe('true');
});

it('should route FOLDER and CONTENT_TYPE_SELECTOR through the shared dialog switch, unaffected', () => {
    mockDialog.set({ type: DIALOG_TYPE.FOLDER, header: 'x' });
    spectator.detectChanges();

    expect(spectator.query('dot-content-drive-action-center')).toBeFalsy();
    expect(spectator.query('dot-content-drive-dialog-folder')).toBeTruthy();
});

(Test 4's exact assertions on visible/attribute names may need adjusting to match how the shell spec's existing mocks expose the dialog — I didn't find that spec's helper setup in this pass, so the above should be read as intent rather than drop-in code.)


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

Labels

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: bulk workflow actions with one action per execution

1 participant