diff --git a/core-web/libs/data-access/src/lib/dot-workflows-actions/dot-workflows-actions.service.spec.ts b/core-web/libs/data-access/src/lib/dot-workflows-actions/dot-workflows-actions.service.spec.ts index 38675a88932b..c4fdf83514ac 100644 --- a/core-web/libs/data-access/src/lib/dot-workflows-actions/dot-workflows-actions.service.spec.ts +++ b/core-web/libs/data-access/src/lib/dot-workflows-actions/dot-workflows-actions.service.spec.ts @@ -111,4 +111,50 @@ describe('DotWorkflowsActionsService', () => { entity: null }); }); + + describe('getBulkActions', () => { + const BULK_ACTIONS_URL = '/api/v1/workflow/contentlet/actions/bulk'; + + it('should post the contentlet inodes and unwrap the entity', (done) => { + const view = { + schemes: [ + { + scheme: { id: 'scheme-1', name: 'Editorial Workflow' }, + steps: [] + } + ] + }; + + spectator.service.getBulkActions({ contentletIds: ['inode-1'] }).subscribe((res) => { + expect(res).toEqual(view); + done(); + }); + + const req = spectator.expectOne(BULK_ACTIONS_URL, HttpMethod.POST); + + expect(req.request.body).toEqual({ contentletIds: ['inode-1'] }); + req.flush({ entity: view }); + }); + + it('should support the query variant for selections spanning pages', (done) => { + spectator.service + .getBulkActions({ query: '+contentType:Blog' }) + .subscribe(() => done()); + + const req = spectator.expectOne(BULK_ACTIONS_URL, HttpMethod.POST); + + expect(req.request.body).toEqual({ query: '+contentType:Blog' }); + req.flush({ entity: { schemes: [] } }); + }); + + it('should fall back to an empty scheme list when the entity is missing', (done) => { + // Keeps callers from having to null-check before mapping over `schemes`. + spectator.service.getBulkActions({ contentletIds: ['inode-1'] }).subscribe((res) => { + expect(res).toEqual({ schemes: [] }); + done(); + }); + + spectator.expectOne(BULK_ACTIONS_URL, HttpMethod.POST).flush({ entity: null }); + }); + }); }); 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; +} 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..6ab8711998a0 --- /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,251 @@ + + + +
+ + {{ 'content-drive.action-center.header' | dm }} + + + {{ + '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) { + + + } +
+ } @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)) { + + {{ 'content-drive.action-center.one-selected' | dm }} + + } + + + + +
+ @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..df7f30be672e --- /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,460 @@ +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 { HttpErrorResponse, provideHttpClient } from '@angular/common/http'; +import { signal } from '@angular/core'; + +import { ConfirmationService, MessageService } from 'primeng/api'; + +import { + DotHttpErrorManagerService, + 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; + let confirmationService: SpyObject; + let httpErrorManager: 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) + }), + mockProvider(DotHttpErrorManagerService, { + handle: jest.fn() + }) + ], + 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); + confirmationService = spectator.inject(ConfirmationService, true); + httpErrorManager = spectator.inject(DotHttpErrorManagerService); + + 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'); + // Records the call without accepting, so tests opt in to the accept path explicitly. + jest.spyOn(confirmationService, 'confirm').mockReturnValue(confirmationService); + }); + + 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 render actions that apply to nothing as non-selectable', () => { + // Nothing archived, so Delete applies to no item — the row stays, disabled. + spectator.detectChanges(); + + const remove = spectator.query( + '[data-testid="quick-action-DELETE"]' + ) as HTMLButtonElement; + + expect(remove).toBeTruthy(); + expect(remove.disabled).toBe(true); + }); + + it('should keep applicable actions selectable', () => { + spectator.detectChanges(); + + const publish = spectator.query( + '[data-testid="quick-action-PUBLISH"]' + ) as HTMLButtonElement; + + expect(publish.disabled).toBe(false); + }); + + it('should render Add to Bundle but keep it non-selectable', () => { + spectator.detectChanges(); + + const addToBundle = spectator.query( + '[data-testid="quick-action-ADD_TO_BUNDLE"]' + ) as HTMLButtonElement; + + expect(addToBundle).toBeTruthy(); + expect(addToBundle.disabled).toBe(true); + }); + + it('should not fire Add to Bundle even if its row is clicked', () => { + spectator.detectChanges(); + + spectator.click('[data-testid="quick-action-ADD_TO_BUNDLE"]'); + + expect(fireService.fireDefaultAction).not.toHaveBeenCalled(); + }); + + it('should confirm before firing Delete, then fire on accept', () => { + // Delete only applies to archived items, and only it carries a confirmMessage. + mockSelectedItems.set([contentlet({ inode: 'inode-1', archived: true })]); + jest.spyOn(confirmationService, 'confirm').mockImplementation((config) => { + config.accept?.(); + + return confirmationService; + }); + + spectator.detectChanges(); + spectator.click('[data-testid="quick-action-DELETE"]'); + + expect(confirmationService.confirm).toHaveBeenCalled(); + expect(fireService.fireDefaultAction).toHaveBeenCalledWith({ + action: 'DELETE', + inodes: ['inode-1'] + }); + }); + + it('should not fire Delete when the confirmation is dismissed', () => { + mockSelectedItems.set([contentlet({ inode: 'inode-1', archived: true })]); + // Default mock records the call without invoking `accept`. + spectator.detectChanges(); + + spectator.click('[data-testid="quick-action-DELETE"]'); + + expect(confirmationService.confirm).toHaveBeenCalled(); + expect(fireService.fireDefaultAction).not.toHaveBeenCalled(); + }); + + it('should fire non-destructive actions without confirming', () => { + spectator.detectChanges(); + + spectator.click('[data-testid="quick-action-PUBLISH"]'); + + expect(confirmationService.confirm).not.toHaveBeenCalled(); + expect(fireService.fireDefaultAction).toHaveBeenCalled(); + }); + + it('should not fire an action that applies to nothing', () => { + spectator.detectChanges(); + + spectator.click('[data-testid="quick-action-DELETE"]'); + + expect(fireService.fireDefaultAction).not.toHaveBeenCalled(); + }); + + it('should fire only the inodes the action applies to, not the whole selection', () => { + // Selection is inode-1 (not live) and inode-2 (live). Publish applies to inode-1 only. + spectator.detectChanges(); + + spectator.click('[data-testid="quick-action-PUBLISH"]'); + + expect(fireService.fireDefaultAction).toHaveBeenCalledWith({ + action: 'PUBLISH', + inodes: ['inode-1'] + }); + }); + + it('should fire exactly as many inodes as the row advertises', () => { + // Guards the count/payload pair against drifting apart again: whatever number the row + // shows must equal the number of inodes sent. + spectator.detectChanges(); + + const row = spectator.query('[data-testid="quick-action-PUBLISH"]'); + const advertised = Number(row?.textContent?.match(/\((\d+)\)/)?.[1]); + + spectator.click('[data-testid="quick-action-PUBLISH"]'); + + const fired = (fireService.fireDefaultAction as unknown as jest.Mock).mock + .calls[0][0] as { inodes: string[] }; + + expect(advertised).toBe(1); + expect(fired.inodes).toHaveLength(advertised); + }); + + it('should fire only archived items for Delete', () => { + mockSelectedItems.set([ + contentlet({ inode: 'archived-1', archived: true }), + contentlet({ inode: 'live-1', live: true }) + ]); + jest.spyOn(confirmationService, 'confirm').mockImplementation((config) => { + config.accept?.(); + + return confirmationService; + }); + + spectator.detectChanges(); + spectator.click('[data-testid="quick-action-DELETE"]'); + + expect(fireService.fireDefaultAction).toHaveBeenCalledWith({ + action: 'DELETE', + inodes: ['archived-1'] + }); + }); + + 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 hand errors to the http error manager without closing the dialog', () => { + const error = new HttpErrorResponse({ status: 403 }); + jest.spyOn(fireService, 'fireDefaultAction').mockReturnValue(throwError(() => error)); + + spectator.detectChanges(); + spectator.click('[data-testid="quick-action-PUBLISH"]'); + + expect(httpErrorManager.handle).toHaveBeenCalledWith(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