feat(content-drive): add Action Center for bulk workflow actions - #36821
feat(content-drive): add Action Center for bulk workflow actions#36821rjvelazco wants to merge 14 commits into
Conversation
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 finished @rjvelazco's task in 5m 18s —— View job Code ReviewReviewed the diff against the Action Center feature. One correctness issue stands out, plus a couple of smaller items. New Issues
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 ( 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. |
…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>
…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>
|
@claude core review this PR and propose some unit test |
|
Claude finished @rjvelazco's task in 6m 31s —— View job Review: Action Center for bulk workflow actionsTodo list
Status of the earlier High findingStill open. New/confirmed findings
Test coverage gapsThe new specs (
Proposed unit tests1. Regression test for the eligibility bug (add to 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. 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 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 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 |
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()onDotWorkflowsActionsService— wrapsPOST /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. NewDotBulkActionViewmodels indotcms-models.DotContentDriveActionCenterComponent— the dialog, with two sections: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).wfstep. Single-expand; one action selected at a time.DIALOG_TYPE.ACTION_CENTERis 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.content-drive.action-center.*keys.Checklist
action-center.spec.tsfor the pure mapping/eligibility logic, plus the component spec). Full portlet suite green: 872/872, lint clean.Language.properties; other locales not translated.WorkflowActionUtils#filterBulkActions). So a listed action can still fail per item at fire time, which the backend reports infails[]and the dialog surfaces. Enforcement stays server-side; the counts here are advisory, never an authorization decision.Additional Info
Scope limits, all deliberate:
workflow_taskhas aUNIQUE (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.DotWorkflowEventHandlerService; out of scope here.≤ N. WhenconditionPresentis true the backend does not evaluate the action's Velocity condition while aggregating, so the count is an upper bound.full_unlock_list) that loops server-side, and add-to-bundle through the legacyRemotePublishAjaxActionservlet.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 nativeEventSourcecannot POST a body.Known follow-ups (backend asks):
_bulklock/_bulkunlockREST endpoints (the_batchUnlocklogic already exists to port).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/#1D4ED8values are mapped onto the theme tokenstailwindcss-primeuiexposes (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