From b45fff14ea754be432e5400f4da3ceb36d0d128e Mon Sep 17 00:00:00 2001 From: rjvelazco Date: Wed, 29 Jul 2026 16:50:46 -0400 Subject: [PATCH 01/16] feat(content-drive): add bulk workflow actions model and service 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) --- .../dot-workflows-actions.service.ts | 25 +++++ core-web/libs/dotcms-models/src/index.ts | 1 + .../src/lib/dot-bulk-actions.model.ts | 105 ++++++++++++++++++ 3 files changed, 131 insertions(+) create mode 100644 core-web/libs/dotcms-models/src/lib/dot-bulk-actions.model.ts diff --git a/core-web/libs/data-access/src/lib/dot-workflows-actions/dot-workflows-actions.service.ts b/core-web/libs/data-access/src/lib/dot-workflows-actions/dot-workflows-actions.service.ts index e4ae1d6e1f33..3d933a3b4d0c 100644 --- a/core-web/libs/data-access/src/lib/dot-workflows-actions/dot-workflows-actions.service.ts +++ b/core-web/libs/data-access/src/lib/dot-workflows-actions/dot-workflows-actions.service.ts @@ -6,6 +6,8 @@ import { Injectable, inject } from '@angular/core'; import { map } from 'rxjs/operators'; import { + DotBulkActionRequest, + DotBulkActionView, DotCMSContentletWorkflowActions, DotCMSResponse, DotCMSWorkflow, @@ -80,6 +82,29 @@ export class DotWorkflowsActionsService { ); } + /** + * 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. + * + * Backing endpoint: `POST /api/v1/workflow/contentlet/actions/bulk`. Supply either + * `contentletIds` (contentlet **inodes**, despite the property name) or a Lucene `query` for + * selections that span pages. + * + * Note that an action's `count` is an upper bound when `conditionPresent` is true — the backend + * does not evaluate the action's Velocity condition while aggregating. + * + * @param {DotBulkActionRequest} request + * @returns {Observable} + * @memberof DotWorkflowsActionsService + */ + getBulkActions(request: DotBulkActionRequest): Observable { + return this.httpClient + .post< + DotCMSResponse + >(`${this.BASE_URL}/contentlet/actions/bulk`, request) + .pipe(map((response) => response?.entity ?? { schemes: [] })); + } + private getWorkFlowId(workflow: DotCMSWorkflow): string { return workflow && workflow.id; } diff --git a/core-web/libs/dotcms-models/src/index.ts b/core-web/libs/dotcms-models/src/index.ts index 34fae60e213d..04990e76be8d 100644 --- a/core-web/libs/dotcms-models/src/index.ts +++ b/core-web/libs/dotcms-models/src/index.ts @@ -11,6 +11,7 @@ export * from './lib/dot-asset-create-options.model'; export * from './lib/dot-block-editor.model'; export * from './lib/dot-block-editor-custom-blocks.util'; export * from './lib/unknown-block.util'; +export * from './lib/dot-bulk-actions.model'; export * from './lib/dot-bundle'; export * from './lib/dot-categories.model'; export * from './lib/dot-container.model'; diff --git a/core-web/libs/dotcms-models/src/lib/dot-bulk-actions.model.ts b/core-web/libs/dotcms-models/src/lib/dot-bulk-actions.model.ts new file mode 100644 index 000000000000..6cc70f4ac875 --- /dev/null +++ b/core-web/libs/dotcms-models/src/lib/dot-bulk-actions.model.ts @@ -0,0 +1,105 @@ +import { DotCMSWorkflowAction } from './dot-workflow-action.model'; + +/** + * Request body for `POST /api/v1/workflow/contentlet/actions/bulk`. + * + * Either `contentletIds` or `query` must be supplied. Note that despite the property name, + * `contentletIds` holds contentlet **inodes**, not identifiers — the backend documents this + * explicitly on the endpoint. + */ +export interface DotBulkActionRequest { + /** Contentlet **inodes** (not identifiers, despite the name). */ + contentletIds?: string[]; + /** Lucene query, used when the selection spans pages ("select all"). */ + query?: string; +} + +/** + * A workflow step with the number of selected contentlets currently sitting in it. + */ +export interface DotCountWorkflowStep { + count: number; + workflowStep: { + id: string; + name: string; + schemeId: string; + }; +} + +/** + * A workflow action with the number of selected contentlets it applies to. + * + * `count` is the number of selected contentlets currently in a step that exposes this action. + * It is already summed across every step of the scheme by the backend, so flattening + * `steps[] -> actions[]` does not double-count. + * + * `count` is an **upper bound** when `conditionPresent` is true: the backend does not evaluate + * the action's Velocity condition when aggregating, because there is no per-contentlet + * permissionable at that point. + */ +export interface DotCountWorkflowAction { + count: number; + workflowAction: DotCMSWorkflowAction; + /** Action has a push-publish actionlet — needs environment/date inputs before firing. */ + pushPublish: boolean; + /** Action has a move actionlet with no path — needs a target path before firing. */ + moveable: boolean; + /** Action has a Velocity condition that was NOT evaluated when computing `count`. */ + conditionPresent: boolean; +} + +/** + * One workflow scheme and its steps, as returned inside {@link DotBulkActionView}. + */ +export interface DotBulkWorkflowSchemeView { + scheme: { + id: string; + name: string; + archived?: boolean; + description?: string; + }; + steps: { + step: DotCountWorkflowStep; + actions: DotCountWorkflowAction[]; + }[]; +} + +/** + * Response entity of `POST /api/v1/workflow/contentlet/actions/bulk`. + * + * Actions are deduped per scheme server-side and filtered to those flagged `showOn: LISTING`. + * Archived schemes are excluded. + */ +export interface DotBulkActionView { + schemes: DotBulkWorkflowSchemeView[]; +} + +/** + * UI-facing shape: one scheme with its steps flattened into a single action list. + * + * This is what the Action Center renders — the nested step grouping is a backend implementation + * detail that the dialog does not surface. + */ +export interface DotActionCenterScheme { + id: string; + name: string; + /** Number of selected contentlets that sit somewhere in this scheme. */ + count: number; + actions: DotActionCenterWorkflowAction[]; +} + +/** + * A single selectable workflow action in the Action Center. + */ +export interface DotActionCenterWorkflowAction { + id: string; + name: string; + count: number; + /** + * True when the action cannot be fired from the dialog without collecting extra input + * (assign/comment, push-publish settings, or a move target path). Disabled in v1. + */ + requiresInput: boolean; + /** True when `count` is an upper bound because a Velocity condition was not evaluated. */ + approximateCount: boolean; +} From 45be44d4474e2656522e12aa198065c3603d102a Mon Sep 17 00:00:00 2001 From: rjvelazco Date: Wed, 29 Jul 2026 16:51:37 -0400 Subject: [PATCH 02/16] feat(content-drive): add Action Center bulk actions dialog 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) --- ...content-drive-action-center.component.html | 164 +++++++++ ...tent-drive-action-center.component.spec.ts | 327 ++++++++++++++++++ ...t-content-drive-action-center.component.ts | 306 ++++++++++++++++ .../dot-content-drive-toolbar.component.html | 18 +- .../dot-content-drive-toolbar.component.ts | 24 ++ .../dot-content-drive-shell.component.html | 3 + .../dot-content-drive-shell.component.ts | 7 +- .../portlet/src/lib/shared/constants.ts | 3 +- .../src/lib/utils/action-center.spec.ts | 279 +++++++++++++++ .../portlet/src/lib/utils/action-center.ts | 209 +++++++++++ .../WEB-INF/messages/Language.properties | 21 ++ 11 files changed, 1355 insertions(+), 6 deletions(-) create mode 100644 core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-action-center/dot-content-drive-action-center.component.html create mode 100644 core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-action-center/dot-content-drive-action-center.component.spec.ts create mode 100644 core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-action-center/dot-content-drive-action-center.component.ts create mode 100644 core-web/libs/portlets/dot-content-drive/portlet/src/lib/utils/action-center.spec.ts create mode 100644 core-web/libs/portlets/dot-content-drive/portlet/src/lib/utils/action-center.ts diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-action-center/dot-content-drive-action-center.component.html b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-action-center/dot-content-drive-action-center.component.html new file mode 100644 index 000000000000..cf62b5277b4a --- /dev/null +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-action-center/dot-content-drive-action-center.component.html @@ -0,0 +1,164 @@ +
+

+ {{ + 'content-drive.action-center.items-selected' + | dm: [$contentletCount().toString()] + }} +

+ + @if ($ignoredFolderCount() > 0) { + + {{ + 'content-drive.action-center.folders-ignored' + | dm: [$ignoredFolderCount().toString()] + }} + + } + + +
+

+ {{ 'content-drive.action-center.quick-actions' | dm }} +

+ + @if ($quickActions().length) { +
+ @for (quickAction of $quickActions(); track quickAction.id) { + + + + {{ quickAction.name | dm }} + + + + + + + } +
+ } @else { + + {{ 'content-drive.action-center.no-quick-actions' | dm }} + + } +
+ + +
+

+ {{ 'content-drive.action-center.workflow-actions' | dm }} +

+ + @if ($loadingSchemes()) { +
+ @for (placeholder of [1, 2, 3]; track placeholder) { + + } +
+ } @else if ($schemesError()) { + + {{ 'content-drive.action-center.workflow-actions.error' | dm }} + + } @else if (!$schemes().length) { + + {{ 'content-drive.action-center.no-workflow-actions' | dm }} + + } @else { + + @for (scheme of $schemes(); track scheme.id) { + + + + {{ scheme.name }} + ({{ scheme.count }}) + @if (schemeOwnsSelection(scheme)) { + + } + + + + +
+ @for (action of scheme.actions; track action.id) { + + } +
+ +
+ +
+
+
+ } +
+ } +
+ +
+ + {{ 'content-drive.action-center.one-at-a-time' | dm }} + + +
+
diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-action-center/dot-content-drive-action-center.component.spec.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-action-center/dot-content-drive-action-center.component.spec.ts new file mode 100644 index 000000000000..cfd8fbd3698c --- /dev/null +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-action-center/dot-content-drive-action-center.component.spec.ts @@ -0,0 +1,327 @@ +import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { createComponentFactory, mockProvider, Spectator, SpyObject } from '@openng/spectator/jest'; +import { of, throwError } from 'rxjs'; + +import { provideHttpClient } from '@angular/common/http'; +import { signal } from '@angular/core'; + +import { MessageService } from 'primeng/api'; + +import { + DotMessageService, + DotWorkflowActionsFireService, + DotWorkflowsActionsService +} from '@dotcms/data-access'; +import { DotBulkActionView, DotContentDriveItem } from '@dotcms/dotcms-models'; + +import { DotContentDriveActionCenterComponent } from './dot-content-drive-action-center.component'; + +import { DotContentDriveStore } from '../../../store/dot-content-drive.store'; + +const contentlet = ( + overrides: Partial & { inode: string } +): DotContentDriveItem => + ({ + baseType: 'CONTENT', + live: false, + working: true, + archived: false, + locked: false, + ...overrides + }) as DotContentDriveItem; + +const folder = (inode: string): DotContentDriveItem => + ({ type: 'folder', inode, identifier: inode }) as unknown as DotContentDriveItem; + +const BULK_ACTIONS_RESPONSE = { + schemes: [ + { + scheme: { id: 'editorial', name: 'Editorial Workflow' }, + steps: [ + { + step: { + count: 2, + workflowStep: { id: 'step-1', name: 'Draft', schemeId: 'editorial' } + }, + actions: [ + { + count: 2, + pushPublish: false, + moveable: false, + conditionPresent: false, + workflowAction: { + id: 'action-review', + name: 'Send for Review', + assignable: false, + commentable: false + } + }, + { + count: 2, + pushPublish: true, + moveable: false, + conditionPresent: false, + workflowAction: { + id: 'action-pp', + name: 'Push Publish', + assignable: false, + commentable: false + } + } + ] + } + ] + } + ] +} as DotBulkActionView; + +describe('DotContentDriveActionCenterComponent', () => { + let spectator: Spectator; + let store: SpyObject>; + let messageService: SpyObject; + let workflowsActionsService: SpyObject; + let fireService: SpyObject; + + const mockSelectedItems = signal([]); + + const createComponent = createComponentFactory({ + component: DotContentDriveActionCenterComponent, + providers: [ + provideHttpClient(), + mockProvider(DotContentDriveStore, { + selectedItems: mockSelectedItems, + loadItems: jest.fn(), + setStatus: jest.fn(), + setSelectedItems: jest.fn(), + closeDialog: jest.fn() + }), + mockProvider(MessageService, { add: jest.fn() }), + mockProvider(DotMessageService, { + get: jest.fn().mockImplementation((key: string) => key) + }) + ], + detectChanges: false + }); + + beforeEach(() => { + mockSelectedItems.set([ + contentlet({ inode: 'inode-1' }), + contentlet({ inode: 'inode-2', live: true }) + ]); + + spectator = createComponent(); + + store = spectator.inject(DotContentDriveStore, true); + messageService = spectator.inject(MessageService, true); + workflowsActionsService = spectator.inject(DotWorkflowsActionsService, true); + fireService = spectator.inject(DotWorkflowActionsFireService, true); + + jest.spyOn(workflowsActionsService, 'getBulkActions').mockReturnValue( + of(BULK_ACTIONS_RESPONSE) + ); + jest.spyOn(fireService, 'fireDefaultAction').mockReturnValue(of([])); + jest.spyOn(fireService, 'bulkFire').mockReturnValue( + of({ successCount: 2, skippedCount: 0, fails: [] }) + ); + jest.spyOn(store, 'closeDialog'); + jest.spyOn(store, 'loadItems'); + jest.spyOn(messageService, 'add'); + }); + + afterEach(() => { + // The store mock is shared across tests; without this, call counts accumulate and + // "should not have been called" assertions see calls from earlier tests. + jest.clearAllMocks(); + }); + + describe('loading the available actions', () => { + it('should request bulk actions with the selected inodes', () => { + spectator.detectChanges(); + + expect(workflowsActionsService.getBulkActions).toHaveBeenCalledWith({ + contentletIds: ['inode-1', 'inode-2'] + }); + }); + + it('should exclude folders from the request', () => { + mockSelectedItems.set([contentlet({ inode: 'inode-1' }), folder('folder-1')]); + + spectator.detectChanges(); + + expect(workflowsActionsService.getBulkActions).toHaveBeenCalledWith({ + contentletIds: ['inode-1'] + }); + }); + + it('should not call the endpoint when the selection is folders only', () => { + mockSelectedItems.set([folder('folder-1'), folder('folder-2')]); + + spectator.detectChanges(); + + expect(workflowsActionsService.getBulkActions).not.toHaveBeenCalled(); + }); + + it('should render one panel per scheme', () => { + spectator.detectChanges(); + + expect(spectator.query('[data-testid="workflow-schemes"]')).toBeTruthy(); + expect(spectator.query('[data-testid="no-workflow-actions"]')).toBeFalsy(); + }); + + it('should show the empty state when no scheme exposes actions', () => { + jest.spyOn(workflowsActionsService, 'getBulkActions').mockReturnValue( + of({ schemes: [] }) + ); + + spectator.detectChanges(); + + expect(spectator.query('[data-testid="no-workflow-actions"]')).toBeTruthy(); + }); + + it('should show an inline error when the lookup fails', () => { + jest.spyOn(workflowsActionsService, 'getBulkActions').mockReturnValue( + throwError(() => new Error('boom')) + ); + + spectator.detectChanges(); + + expect(spectator.query('[data-testid="workflow-actions-error"]')).toBeTruthy(); + }); + }); + + describe('folders in the selection', () => { + it('should warn that folders are ignored', () => { + mockSelectedItems.set([contentlet({ inode: 'inode-1' }), folder('folder-1')]); + + spectator.detectChanges(); + + expect(spectator.query('[data-testid="folders-ignored-message"]')).toBeTruthy(); + }); + + it('should not warn when the selection has no folders', () => { + spectator.detectChanges(); + + expect(spectator.query('[data-testid="folders-ignored-message"]')).toBeFalsy(); + }); + }); + + describe('quick actions', () => { + it('should render a quick action for the eligible subset', () => { + spectator.detectChanges(); + + // inode-1 is not live, so Publish applies to exactly one item. + expect(spectator.query('[data-testid="quick-action-PUBLISH"]')).toBeTruthy(); + }); + + it('should fire the system action over the selection', () => { + spectator.detectChanges(); + + spectator.click('[data-testid="quick-action-PUBLISH"]'); + + expect(fireService.fireDefaultAction).toHaveBeenCalledWith({ + action: 'PUBLISH', + inodes: ['inode-1', 'inode-2'] + }); + }); + + it('should refresh the grid and close the dialog on success', () => { + spectator.detectChanges(); + + spectator.click('[data-testid="quick-action-PUBLISH"]'); + + expect(store.loadItems).toHaveBeenCalled(); + expect(store.closeDialog).toHaveBeenCalled(); + }); + + it('should report an error without closing the dialog', () => { + jest.spyOn(fireService, 'fireDefaultAction').mockReturnValue( + throwError(() => new Error('boom')) + ); + + spectator.detectChanges(); + spectator.click('[data-testid="quick-action-PUBLISH"]'); + + expect(messageService.add).toHaveBeenCalledWith( + expect.objectContaining({ severity: 'error' }) + ); + expect(store.closeDialog).not.toHaveBeenCalled(); + }); + }); + + describe('workflow actions', () => { + it('should keep Execute disabled until an action is selected', () => { + spectator.detectChanges(); + + // PrimeNG puts `disabled` on the inner + } + + } @else { + + {{ 'content-drive.action-center.no-quick-actions' | dm }} + + } + - {{ action.name }} + +
+

+ {{ 'content-drive.action-center.workflow-actions' | dm }} +

- @if (action.requiresInput) { - - } + @if ($loadingSchemes()) { +
+ @for (placeholder of [1, 2, 3]; track placeholder) { + + } +
+ } @else if ($schemesError()) { + + {{ 'content-drive.action-center.workflow-actions.error' | dm }} + + } @else if (!$schemes().length) { + + {{ 'content-drive.action-center.no-workflow-actions' | dm }} + + } @else { + + @for (scheme of $schemes(); track scheme.id) { + + + + + {{ scheme.name }} + + ({{ scheme.count }}) + @if (schemeOwnsSelection(scheme)) { + + } + + - - @if (action.approximateCount) { + +
+ @for (action of scheme.actions; track action.id) { + - } -
-
- -
-
-
- } -
- } -
+ + @if (action.approximateCount) { + + ≤ {{ action.count }} + } @else { + ({{ action.count }}) + } + + + } + + +
+ +
+ + + } + + } + + -
- - {{ 'content-drive.action-center.one-at-a-time' | dm }} - - -
- + +
+ + {{ 'content-drive.action-center.one-at-a-time' | dm }} + + +
+
+ diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-action-center/dot-content-drive-action-center.component.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-action-center/dot-content-drive-action-center.component.ts index 35eb7e9012ce..47d3e9b35746 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-action-center/dot-content-drive-action-center.component.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-action-center/dot-content-drive-action-center.component.ts @@ -12,6 +12,7 @@ import { AccordionModule } from 'primeng/accordion'; import { MessageService } from 'primeng/api'; import { BadgeModule } from 'primeng/badge'; import { ButtonModule } from 'primeng/button'; +import { DialogModule } from 'primeng/dialog'; import { MessageModule } from 'primeng/message'; import { RadioButtonModule } from 'primeng/radiobutton'; import { SkeletonModule } from 'primeng/skeleton'; @@ -69,6 +70,7 @@ import { AccordionModule, BadgeModule, ButtonModule, + DialogModule, DotMessagePipe, FormsModule, MessageModule, @@ -192,6 +194,16 @@ export class DotContentDriveActionCenterComponent implements OnInit { this.#store.closeDialog(); } + /** + * Propagates a user-driven close (X / ESC / mask) to the store, which is what actually unmounts + * this component. + */ + protected onVisibleChange(visible: boolean): void { + if (!visible) { + this.#store.closeDialog(); + } + } + /** * Builds the bulk-fire payload. * diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.html b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.html index a37b6a83d5bf..9922069daa70 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.html +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.html @@ -67,6 +67,12 @@ + +@if ($actionCenterVisible()) { + +} + @if ($contextMenuData()?.showAddToBundle) { } } - @case (DIALOG_TYPE.ACTION_CENTER) { - - } } diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts index fe828d643c54..8e8f80d58b9a 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts @@ -185,14 +185,27 @@ export class DotContentDriveShellComponent { switch (this.$activeDialog()?.type) { case DIALOG_TYPE.CONTENT_TYPE_SELECTOR: return 'w-152 max-w-[92vw] px-0! pt-0 pb-4'; - // Action Center: narrower than a form dialog — it is a list of actions, not a form. - case DIALOG_TYPE.ACTION_CENTER: - return 'w-[44rem] max-w-[92vw] pt-0 p-4'; default: return 'w-175 pt-0 p-4'; } }); + /** + * The Action Center renders its own `p-dialog` (custom header + footer, body-only scrolling), + * so it is routed out of the shared dialog rather than into its content switch. + */ + readonly $isActionCenter = computed( + () => this.$activeDialog()?.type === DIALOG_TYPE.ACTION_CENTER + ); + + /** Shared dialog: every type except the ones owning their own dialog. */ + readonly $sharedDialogVisible = computed( + () => this.$dialogVisible() && !this.$isActionCenter() + ); + + /** Mounts the Action Center, which then shows itself. */ + readonly $actionCenterVisible = computed(() => this.$dialogVisible() && this.$isActionCenter()); + /** * Syncs the dialog open/close state from the store. Opening sets the body and visibility * together (no blank-frame flash); closing flips visibility off but leaves the body mounted @@ -420,7 +433,9 @@ export class DotContentDriveShellComponent { * mask) emits `false`; propagate it to the store so the dialog state stays consistent. */ protected onVisibleChange(visible: boolean) { - if (!visible) { + // Ignore the shared dialog reporting itself hidden while the Action Center is the active + // dialog — that is this component switching which dialog renders, not the user closing one. + if (!visible && !this.$isActionCenter()) { this.#store.closeDialog(); } } diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/utils/action-center.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/utils/action-center.ts index eccd303768e5..d87ead019415 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/utils/action-center.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/utils/action-center.ts @@ -18,6 +18,7 @@ export interface DotActionCenterQuickAction { id: WORKFLOW_ACTION_ID; /** i18n key for the label. */ name: string; + /** Material Symbols glyph name, rendered inside the row's icon chip. */ icon: string; count: number; /** Destructive action — rendered with the danger severity, as in the design. */ @@ -47,31 +48,31 @@ const QUICK_ACTIONS: { }[] = [ { id: WORKFLOW_ACTION_ID.PUBLISH, - icon: 'pi pi-upload', + icon: 'publish', danger: false, eligibleWhen: (item) => !item.live && !item.archived }, { id: WORKFLOW_ACTION_ID.UNPUBLISH, - icon: 'pi pi-eye-slash', + icon: 'visibility_off', danger: false, eligibleWhen: (item) => !!item.live && !item.archived }, { id: WORKFLOW_ACTION_ID.ARCHIVE, - icon: 'pi pi-inbox', + icon: 'archive', danger: true, eligibleWhen: (item) => !item.archived }, { id: WORKFLOW_ACTION_ID.UNARCHIVE, - icon: 'pi pi-undo', + icon: 'unarchive', danger: false, eligibleWhen: (item) => !!item.archived }, { id: WORKFLOW_ACTION_ID.DELETE, - icon: 'pi pi-trash', + icon: 'delete', danger: true, eligibleWhen: (item) => !!item.archived } From 24ed91d1bc1bf6e5cdde4bbbc877e77c66bd2541 Mon Sep 17 00:00:00 2001 From: rjvelazco Date: Thu, 30 Jul 2026 11:23:59 -0400 Subject: [PATCH 04/16] style(content-drive): align Action Center with the design prototype MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ...content-drive-action-center.component.html | 119 ++++++++++-------- ...t-content-drive-action-center.component.ts | 25 ++++ 2 files changed, 93 insertions(+), 51 deletions(-) diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-action-center/dot-content-drive-action-center.component.html b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-action-center/dot-content-drive-action-center.component.html index 8115ff1b0700..8f8b65c2f064 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-action-center/dot-content-drive-action-center.component.html +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-action-center/dot-content-drive-action-center.component.html @@ -1,12 +1,11 @@
- + {{ 'content-drive.action-center.header' | dm }} - + {{ 'content-drive.action-center.items-selected' | dm: [$contentletCount().toString()] @@ -34,8 +36,7 @@
- -
+
@if ($ignoredFolderCount() > 0) { {{ @@ -46,44 +47,49 @@ } -
-

+
+

{{ 'content-drive.action-center.quick-actions' | dm }}

@if ($quickActions().length) {
+ class="divide-y divide-surface-100 overflow-hidden rounded-xl border border-surface-100 bg-surface-50"> @for (quickAction of $quickActions(); track quickAction.id) { }
@@ -95,15 +101,15 @@

-
-

+
+

{{ 'content-drive.action-center.workflow-actions' | dm }}

@if ($loadingSchemes()) { -
+
@for (placeholder of [1, 2, 3]; track placeholder) { - + }
} @else if ($schemesError()) { @@ -115,36 +121,44 @@

{{ 'content-drive.action-center.no-workflow-actions' | dm }} } @else { + + styleClass="flex flex-col gap-3" + data-testid="workflow-schemes" + [value]="$openSchemeId()" + (valueChange)="onOpenSchemeChange($event)"> @for (scheme of $schemes(); track scheme.id) { + styleClass="rounded-xl border border-surface-100 bg-surface-50 overflow-hidden"> - - + + {{ scheme.name }} - ({{ scheme.count }}) + + ({{ scheme.count }}) + @if (schemeOwnsSelection(scheme)) { - + + {{ 'content-drive.action-center.one-selected' | dm }} + } -
+
@for (action of scheme.actions; track action.id) {
-
+
- + {{ 'content-drive.action-center.one-at-a-time' | dm }} (false); /** The single workflow action currently selected, across every scheme. */ protected readonly $selectedActionId = signal(null); + /** + * The expanded scheme panel. Single-expand, matching the prototype: opening one scheme collapses + * the others, which also keeps the "one action per execute" rule visually obvious. + */ + protected readonly $openSchemeId = signal(undefined); /** True while an action is being fired; disables the whole dialog. */ protected readonly $executing = signal(false); @@ -189,6 +194,26 @@ export class DotContentDriveActionCenterComponent implements OnInit { }); } + /** + * Tracks which scheme panel is expanded, and clears the pending action when a different scheme + * takes over so the Execute button can't stay armed for a panel the user has collapsed. + */ + protected onOpenSchemeChange(value: string | number | string[] | number[] | undefined): void { + const openId = Array.isArray(value) ? value[0]?.toString() : value?.toString(); + + this.$openSchemeId.set(openId); + + const selectedId = this.$selectedActionId(); + const stillVisible = this.$schemes().some( + (scheme) => + scheme.id === openId && scheme.actions.some((action) => action.id === selectedId) + ); + + if (!stillVisible) { + this.$selectedActionId.set(null); + } + } + /** Closes the dialog without firing anything. */ protected onDone(): void { this.#store.closeDialog(); From 249f059937a141867527a48fc6842068dd538fbb Mon Sep 17 00:00:00 2001 From: rjvelazco Date: Thu, 30 Jul 2026 11:38:13 -0400 Subject: [PATCH 05/16] fix(content-drive): collapsed accordion panels reserved full height 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) --- ...content-drive-action-center.component.html | 7 +++-- ...t-content-drive-action-center.component.ts | 26 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-action-center/dot-content-drive-action-center.component.html b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-action-center/dot-content-drive-action-center.component.html index 8f8b65c2f064..ded33314b6a3 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-action-center/dot-content-drive-action-center.component.html +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-action-center/dot-content-drive-action-center.component.html @@ -125,6 +125,9 @@

@for (scheme of $schemes(); track scheme.id) { @@ -158,7 +161,7 @@

@for (action of scheme.actions; track action.id) {