Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import { createHttpFactory, HttpMethod, SpectatorHttp } from '@openng/spectator/

import { HttpHeaders } from '@angular/common/http';

import { DotActionBulkRequestOptions, DotActionBulkResult } from '@dotcms/dotcms-models';
import {
DotActionBulkRequestOptions,
DotActionBulkResult,
DotFireDefaultActionResult
} from '@dotcms/dotcms-models';
import { dotcmsContentletMock } from '@dotcms/utils-testing';

import { DotWorkflowActionsFireService } from './dot-workflow-actions-fire.service';
Expand Down Expand Up @@ -346,6 +350,31 @@ describe('DotWorkflowActionsFireService', () => {
});
});

it('should fire a default system action over multiple inodes and return its summary', (done) => {
// The endpoint streams one entry per contentlet plus a summary. The summary is the only
// honest source of success/fail counts: individual items can fail while the request is 200.
const mockResult: DotFireDefaultActionResult = {
results: [],
summary: { affected: 2, successCount: 1, failCount: 1, time: 12 }
};

spectator.service
.fireDefaultAction({ action: 'UNLOCK', inodes: ['1', '2'] })
.subscribe((res) => {
expect(res).toEqual(mockResult);
done();
});

const req = spectator.expectOne(
'/api/v1/workflow/actions/default/fire/UNLOCK?indexPolicy=WAIT_FOR',
HttpMethod.POST
);

expect(req.request.body).toEqual({ contentlet: [{ inode: '1' }, { inode: '2' }] });

req.flush({ entity: mockResult });
});

afterEach(() => {
spectator.controller.verify();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import { map, take } from 'rxjs/operators';
import {
DotActionBulkRequestOptions,
DotCMSContentlet,
DotActionBulkResult
DotActionBulkResult,
DotFireDefaultActionResult
} from '@dotcms/dotcms-models';

export interface DotActionRequestOptions {
Expand Down Expand Up @@ -81,13 +82,20 @@ export class DotWorkflowActionsFireService {
}

/**
* Fire a default workflow action over one or multiple contentlets
* Fire a default workflow action over one or multiple contentlets.
*
* Resolves to the endpoint's `{ results, summary }` entity — **not** a contentlet list, which is
* what this used to claim. Individual contentlets can fail while the request itself succeeds
* with a 200, so callers reporting an outcome must read `summary.successCount` /
* `summary.failCount` rather than assume every inode they sent was acted on.
*
* @param {DotFireDefaultActionOptions} options
* @return {*} {Observable<DotCMSContentlet[]>}
* @return {*} {Observable<DotFireDefaultActionResult>}
* @memberof DotWorkflowActionsFireService
*/
fireDefaultAction(options: DotFireDefaultActionOptions): Observable<DotCMSContentlet[]> {
fireDefaultAction(
options: DotFireDefaultActionOptions
): Observable<DotFireDefaultActionResult> {
const { action, inodes } = options;
const url = `${this.BASE_URL}/actions/default/fire/${action}`;
const urlParams = new HttpParams().set('indexPolicy', 'WAIT_FOR');
Expand All @@ -96,7 +104,7 @@ export class DotWorkflowActionsFireService {
};

return this.httpClient
.post<{ entity: DotCMSContentlet[] }>(url, body, {
.post<{ entity: DotFireDefaultActionResult }>(url, body, {
headers: this.defaultHeaders,
params: urlParams
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,28 @@ export interface DotActionBulkResult {
action?: string;
}

/**
* Response of `POST /api/v1/workflow/actions/default/fire/{systemAction}`, the multi-contentlet
* system-action endpoint.
*
* Streamed rather than assembled, which is why the shape differs from {@link DotActionBulkResult}:
* one entry per contentlet keyed by its id, then a summary. A per-item failure does **not** fail the
* request — the status stays 200 and the item is counted in {@link summary.failCount} — so the
* summary is the only honest source of what actually happened.
*/
export interface DotFireDefaultActionResult {
/** One entry per contentlet, keyed by identifier. A failed item holds an error payload. */
results: Record<string, unknown>[];
summary: {
/** Number of contentlets the request was asked to act on. */
affected: number;
successCount: number;
failCount: number;
/** Server-side duration in ms. */
time: number;
};
}

// optional attrs because api is not consistent
export interface DotBulkFailItem {
errorMessage: string;
Expand Down
9 changes: 9 additions & 0 deletions core-web/libs/dotcms-models/src/lib/dot-contentlet.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ export interface DotCMSContentlet {
binaryContentAsset?: string;
binaryVersion?: string;
contentType: string;
/**
* Whether the current user may edit the contentlet *right now* — stamped by the browser/drive
* search, not by the contentlet itself.
*
* True only when the caller has WRITE permission **and** holds the lock, so on a `locked` row a
* `false` means the lock belongs to somebody else. That makes it the cheapest available answer
* to "can I unlock this?", short of a per-item `_canlock` call.
*/
contentEditable?: boolean;
file?: string;
folder: string;
hasLiveVersion?: boolean;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,11 @@
class="flex min-h-0 flex-1 flex-col gap-8 overflow-y-auto px-6 py-4"
data-testid="action-center">
@if ($ignoredFolderCount() > 0) {
<p-message severity="info" data-testid="folders-ignored-message">
<!-- `no-enter-motion` renders this notice statically; see the component's `styles`. -->
<p-message
severity="info"
styleClass="no-enter-motion"
data-testid="folders-ignored-message">
{{
'content-drive.action-center.folders-ignored'
| dm: [$ignoredFolderCount().toString()]
Expand All @@ -51,7 +55,8 @@ <h3 class="mb-3 text-[10px] font-bold tracking-widest text-surface-400 uppercase
@for (quickAction of $quickActions(); track quickAction.id) {
<!-- Rows with a zero count do not apply to this selection. They stay
visible but non-selectable, so the action list is stable as the
selection changes. -->
selection changes. Clicking opens the preview; nothing fires from
here. -->
<button
type="button"
class="group flex w-full items-center gap-4 px-4 py-3.5 text-left transition-all enabled:hover:cursor-pointer enabled:hover:bg-surface-0 disabled:cursor-not-allowed disabled:opacity-50"
Expand All @@ -62,7 +67,7 @@ <h3 class="mb-3 text-[10px] font-bold tracking-widest text-surface-400 uppercase
"
[pTooltip]="quickActionHint(quickAction) | dm"
[attr.data-testid]="'quick-action-' + quickAction.id"
(click)="onExecuteQuickAction(quickAction)">
(click)="onSelectQuickAction(quickAction)">
<span
class="flex size-9 shrink-0 items-center justify-center rounded-lg transition-colors"
[class]="
Expand All @@ -85,6 +90,23 @@ <h3 class="mb-3 text-[10px] font-bold tracking-widest text-surface-400 uppercase
{{ quickAction.name | dm }}
</span>

<!-- Items the action will still be fired over, but which are
expected to fail — a lock held by another user. Flagged rather
than subtracted from the count, since only the server can
decide. -->
@if (quickAction.warningCount > 0) {
<i
class="pi pi-exclamation-triangle text-xs text-orange-500"
[pTooltip]="
quickAction.warningHint
| dm: [quickAction.warningCount.toString()]
"
[attr.data-testid]="
'quick-action-warning-' + quickAction.id
"
aria-hidden="true"></i>
}

<span class="text-xs font-medium text-surface-400">
({{ quickAction.count }})
</span>
Expand Down Expand Up @@ -281,7 +303,9 @@ <h3 class="mb-3 text-[10px] font-bold tracking-widest text-surface-400 uppercase
paginator sits below the scroll region rather than at the bottom of a scrolled page.
-->
<div class="flex min-h-0 flex-1 flex-col" data-testid="action-preview-body">
@if ($selectedAction(); as action) {
<!-- Workflow actions only. A quick action's count and its rows come from the same
client-side filter, so it can never apply to fewer items than it lists. -->
@if (!$pendingQuickAction() && $selectedAction(); as action) {
<!-- The rows are already narrowed to content types this action's scheme covers, but
eligibility is also per workflow step: two contentlets of one type can sit on
different steps and only one may expose the action. `count` is the backend's true
Expand Down Expand Up @@ -327,7 +351,7 @@ <h3 class="mb-3 text-[10px] font-bold tracking-widest text-surface-400 uppercase
[disabled]="!$includedCount() || $executing()"
[loading]="$executing()"
data-testid="action-preview-execute"
(click)="onExecuteWorkflowAction()" />
(click)="onExecutePreview()" />
</div>
}

Expand Down
Loading
Loading