-
- {{ 'publishing-queue.add-bundle' | dm }}
+
+ {{ $bundlesLabel() }}
{
const bundlesSelectedIds = signal([]);
const bundlesTotal = signal(0);
+ const draftBundlesTotal = signal(null);
function makeStoreStub() {
return {
@@ -24,6 +28,7 @@ describe('DotPublishingQueueToolbarComponent', () => {
refresh: jest.fn(),
bundlesSelectedIds,
bundlesTotal,
+ draftBundlesTotal,
retryBundles: jest.fn()
};
}
@@ -51,7 +56,11 @@ describe('DotPublishingQueueToolbarComponent', () => {
'publishing-queue.upload-bundle': 'Upload Bundle',
'publishing-queue.retry-send': 'Retry Send',
'publishing-queue.delete-bundles': 'Remove',
- 'publishing-queue.selected': 'selected'
+ 'publishing-queue.selected': 'selected',
+ 'publishing-queue.bundles': 'Bundles',
+ 'publishing-queue.bundles.count': 'Bundles ({0})',
+ 'publishing-queue.add-bundle.select': 'Select Bundle',
+ 'publishing-queue.add-bundle.upload': 'Upload'
})
}
],
@@ -62,6 +71,7 @@ describe('DotPublishingQueueToolbarComponent', () => {
jest.useFakeTimers();
bundlesSelectedIds.set([]);
bundlesTotal.set(0);
+ draftBundlesTotal.set(null);
spectator = createComponent();
store = spectator.inject(DotPublishingQueueStore, true) as unknown as ReturnType<
typeof makeStoreStub
@@ -82,7 +92,89 @@ describe('DotPublishingQueueToolbarComponent', () => {
});
});
+ describe('Bundles (N) trigger', () => {
+ it('shows the draft count in the button label', () => {
+ draftBundlesTotal.set(22);
+ spectator.detectChanges();
+
+ expect(spectator.query(byTestId('pq-add-bundle-btn-label'))?.textContent?.trim()).toBe(
+ 'Bundles (22)'
+ );
+ });
+
+ it('shows a bare "Bundles" while the count is unknown, never "(0)"', () => {
+ draftBundlesTotal.set(null);
+ spectator.detectChanges();
+
+ expect(spectator.query(byTestId('pq-add-bundle-btn-label'))?.textContent?.trim()).toBe(
+ 'Bundles'
+ );
+ });
+
+ it('shows "(0)" when the user genuinely has no drafts', () => {
+ draftBundlesTotal.set(0);
+ spectator.detectChanges();
+
+ expect(spectator.query(byTestId('pq-add-bundle-btn-label'))?.textContent?.trim()).toBe(
+ 'Bundles (0)'
+ );
+ });
+
+ it('keeps the aria-label in sync with the visible count', () => {
+ draftBundlesTotal.set(7);
+ spectator.detectChanges();
+
+ expect(spectator.query(byTestId('pq-add-bundle-btn'))?.getAttribute('aria-label')).toBe(
+ 'Bundles (7)'
+ );
+ });
+ });
+
describe('Add Bundle dropdown', () => {
+ /** The menu is `appendTo="body"`, so its rows live outside the fixture. */
+ const queryMenu = (selector: string) => document.body.querySelector(selector);
+
+ function openMenu() {
+ spectator.click(
+ spectator.query(byTestId('pq-add-bundle-btn'))?.querySelector('button') ??
+ (spectator.query(byTestId('pq-add-bundle-btn')) as HTMLElement)
+ );
+ spectator.detectChanges();
+ }
+
+ it('repeats the draft count next to Select Bundle so it matches the button', () => {
+ draftBundlesTotal.set(22);
+ spectator.detectChanges();
+ openMenu();
+
+ expect(queryMenu('[data-testid="pq-select-bundle-count"]')?.textContent?.trim()).toBe(
+ '22'
+ );
+ });
+
+ it('omits the count next to Select Bundle while it is unknown', () => {
+ draftBundlesTotal.set(null);
+ spectator.detectChanges();
+ openMenu();
+
+ expect(queryMenu('[data-testid="pq-select-bundle-count"]')).toBeNull();
+ });
+
+ it('does not put a count on the Upload row', () => {
+ draftBundlesTotal.set(22);
+ spectator.detectChanges();
+ openMenu();
+
+ expect(
+ document.body.querySelectorAll('[data-testid="pq-select-bundle-count"]').length
+ ).toBe(1);
+ });
+
+ it('tags the Select Bundle row so the item template can add the count', () => {
+ expect(spectator.component.addBundleItems[0].id).toBe(SELECT_BUNDLE_ITEM_ID);
+ expect(spectator.component.addBundleItems[1].id).toBeUndefined();
+ });
+
it('exposes two menu items: Select Bundle + Upload', () => {
expect(spectator.component.addBundleItems.length).toBe(2);
expect(spectator.component.addBundleItems[0].label).toBeTruthy();
diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-toolbar/dot-publishing-queue-toolbar.component.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-toolbar/dot-publishing-queue-toolbar.component.ts
index 768a1bab7c76..8eb441619b4d 100644
--- a/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-toolbar/dot-publishing-queue-toolbar.component.ts
+++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-toolbar/dot-publishing-queue-toolbar.component.ts
@@ -27,6 +27,9 @@ import { DotMessagePipe } from '@dotcms/ui';
import { DotPublishingQueueStore } from '../../store/dot-publishing-queue.store';
import { DotPublishingQueueStatusFilterComponent } from '../dot-publishing-queue-status-filter/dot-publishing-queue-status-filter.component';
+/** Identifies the menu row that renders the trailing draft-bundle count. */
+export const SELECT_BUNDLE_ITEM_ID = 'select-bundle';
+
@Component({
selector: 'dot-publishing-queue-toolbar',
imports: [
@@ -56,11 +59,21 @@ export class DotPublishingQueueToolbarComponent {
/** Bulk actions appear only when the user has explicitly checked one or more rows. */
protected readonly $hasBulkActions = computed(() => this.store.bundlesSelectedIds().length > 0);
- /** "Add Bundle" split-menu items. The commands emit outputs instead of
- * calling services directly so the shell owns dialog orchestration
- * (component ↔ dialog separation per libs/portlets/CLAUDE.md). */
+ /** Bare "Bundles" while the count is unknown — "(0)" would read as "you
+ * have no drafts". */
+ protected readonly $bundlesLabel = computed(() => {
+ const total = this.store.draftBundlesTotal();
+
+ return total === null
+ ? this.#dotMessageService.get('publishing-queue.bundles')
+ : this.#dotMessageService.get('publishing-queue.bundles.count', String(total));
+ });
+
+ /** Built once, never recomputed: PrimeNG re-processes `[model]` on every
+ * identity change and the menu then swallows the first click. */
readonly addBundleItems: MenuItem[] = [
{
+ id: SELECT_BUNDLE_ITEM_ID,
label: this.#dotMessageService.get('publishing-queue.add-bundle.select'),
command: () => this.$selectBundleClick.emit()
},
@@ -83,4 +96,8 @@ export class DotPublishingQueueToolbarComponent {
onBulkRetry(): void {
this.store.retryBundles({ bundleIds: this.store.bundlesSelectedIds() });
}
+
+ protected showsDraftCount(item: MenuItem): boolean {
+ return item.id === SELECT_BUNDLE_ITEM_ID && this.store.draftBundlesTotal() !== null;
+ }
}
diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-status-chip/dot-publishing-status-chip.component.spec.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-status-chip/dot-publishing-status-chip.component.spec.ts
index babb4a0a67b0..7b18f47b0a9f 100644
--- a/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-status-chip/dot-publishing-status-chip.component.spec.ts
+++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-status-chip/dot-publishing-status-chip.component.spec.ts
@@ -10,11 +10,11 @@ import {
} from './dot-publishing-status-chip.component';
describe('publishingStatusBucket (pure fn)', () => {
- const cases: Array<[PublishAuditStatus, 'success' | 'danger' | 'warning' | 'info']> = [
+ const cases: Array<[PublishAuditStatus, 'success' | 'danger' | 'warn' | 'info']> = [
[PublishAuditStatus.SUCCESS, 'success'],
[PublishAuditStatus.BUNDLE_SENT_SUCCESSFULLY, 'success'],
[PublishAuditStatus.BUNDLE_SAVED_SUCCESSFULLY, 'success'],
- [PublishAuditStatus.SUCCESS_WITH_WARNINGS, 'warning'],
+ [PublishAuditStatus.SUCCESS_WITH_WARNINGS, 'warn'],
[PublishAuditStatus.FAILED_TO_SEND_TO_ALL_GROUPS, 'danger'],
[PublishAuditStatus.FAILED_TO_SEND_TO_SOME_GROUPS, 'danger'],
[PublishAuditStatus.FAILED_TO_BUNDLE, 'danger'],
@@ -26,10 +26,10 @@ describe('publishingStatusBucket (pure fn)', () => {
[PublishAuditStatus.WAITING_FOR_PUBLISHING, 'info'],
[PublishAuditStatus.BUNDLE_REQUESTED, 'info'],
[PublishAuditStatus.SCHEDULED, 'info'],
- [PublishAuditStatus.BUNDLING, 'warning'],
- [PublishAuditStatus.SENDING_TO_ENDPOINTS, 'warning'],
- [PublishAuditStatus.PUBLISHING_BUNDLE, 'warning'],
- [PublishAuditStatus.RECEIVED_BUNDLE, 'warning']
+ [PublishAuditStatus.BUNDLING, 'warn'],
+ [PublishAuditStatus.SENDING_TO_ENDPOINTS, 'warn'],
+ [PublishAuditStatus.PUBLISHING_BUNDLE, 'warn'],
+ [PublishAuditStatus.RECEIVED_BUNDLE, 'warn']
];
it('covers every value of PublishAuditStatus', () => {
@@ -87,10 +87,19 @@ describe('DotPublishingStatusChipComponent', () => {
expect(spectator.component.$bucket()).toBe('danger');
});
- it('exposes warning severity for BUNDLING status (in-flight)', () => {
+ it('exposes warn severity for BUNDLING status (in-flight)', () => {
spectator = createComponent({ props: { status: PublishAuditStatus.BUNDLING } });
spectator.detectChanges();
- expect(spectator.component.$bucket()).toBe('warning');
+ expect(spectator.component.$bucket()).toBe('warn');
+ });
+
+ it('only ever emits severities PrimeNG renders — an unknown one falls back to the solid primary fill', () => {
+ // `p-tag` derives `p-tag-{severity}`; a value outside this set produces no
+ // class at all and the tag renders like a primary button.
+ const valid = new Set(['success', 'secondary', 'info', 'warn', 'danger', 'contrast']);
+ for (const status of Object.values(PublishAuditStatus)) {
+ expect(valid).toContain(publishingStatusBucket(status as PublishAuditStatus));
+ }
});
it('exposes info severity for WAITING_FOR_PUBLISHING status', () => {
diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-status-chip/dot-publishing-status-chip.component.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-status-chip/dot-publishing-status-chip.component.ts
index 128a01ec6357..04baf241d5b8 100644
--- a/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-status-chip/dot-publishing-status-chip.component.ts
+++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-status-chip/dot-publishing-status-chip.component.ts
@@ -5,7 +5,9 @@ import { TagModule } from 'primeng/tag';
import { PublishAuditStatus } from '@dotcms/dotcms-models';
import { DotMessagePipe } from '@dotcms/ui';
-type StatusBucket = 'success' | 'danger' | 'warning' | 'info';
+/** `warn` (not `warning`) is PrimeNG's spelling — any other value produces no
+ * `p-tag-*` class and the tag falls back to the solid primary fill. */
+type StatusBucket = 'success' | 'danger' | 'warn' | 'info';
const BUCKETS: Record = {
// success: bundle reached its target
@@ -13,8 +15,8 @@ const BUCKETS: Record = {
[PublishAuditStatus.BUNDLE_SENT_SUCCESSFULLY]: 'success',
[PublishAuditStatus.BUNDLE_SAVED_SUCCESSFULLY]: 'success',
- // warning: shipped but with non-fatal issues
- [PublishAuditStatus.SUCCESS_WITH_WARNINGS]: 'warning',
+ // warn: shipped but with non-fatal issues
+ [PublishAuditStatus.SUCCESS_WITH_WARNINGS]: 'warn',
// danger: anything that failed
[PublishAuditStatus.FAILED_TO_SEND_TO_ALL_GROUPS]: 'danger',
@@ -32,11 +34,11 @@ const BUCKETS: Record = {
// info: future-dated bundle, not yet picked up by the publisher job
[PublishAuditStatus.SCHEDULED]: 'info',
- // warning: actively being packed/sent (in-flight)
- [PublishAuditStatus.BUNDLING]: 'warning',
- [PublishAuditStatus.SENDING_TO_ENDPOINTS]: 'warning',
- [PublishAuditStatus.PUBLISHING_BUNDLE]: 'warning',
- [PublishAuditStatus.RECEIVED_BUNDLE]: 'warning'
+ // warn: actively being packed/sent (in-flight)
+ [PublishAuditStatus.BUNDLING]: 'warn',
+ [PublishAuditStatus.SENDING_TO_ENDPOINTS]: 'warn',
+ [PublishAuditStatus.PUBLISHING_BUNDLE]: 'warn',
+ [PublishAuditStatus.RECEIVED_BUNDLE]: 'warn'
};
/** Pure mapping function — exported for direct testing without component instantiation. */
diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-asset-list-dialog/dot-publishing-queue-asset-list-dialog.component.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-asset-list-dialog/dot-publishing-queue-asset-list-dialog.component.ts
index 2e5d0b2d8781..20dbcdd5cc5d 100644
--- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-asset-list-dialog/dot-publishing-queue-asset-list-dialog.component.ts
+++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-asset-list-dialog/dot-publishing-queue-asset-list-dialog.component.ts
@@ -205,7 +205,7 @@ export class DotPublishingQueueAssetListDialogComponent {
'publishing-queue.asset-list.remove-confirm.message',
asset.title || asset.asset
),
- acceptLabel: this.#dotMessageService.get('publishing-queue.remove'),
+ acceptLabel: this.#dotMessageService.get('publishing-queue.delete'),
rejectLabel: this.#dotMessageService.get('publishing-queue.cancel'),
acceptButtonStyleClass: 'p-button-danger',
rejectButtonStyleClass: 'p-button-text',
diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-select-bundle-dialog/dot-publishing-queue-select-bundle-dialog.component.html b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-select-bundle-dialog/dot-publishing-queue-select-bundle-dialog.component.html
index 23bfea61bdd4..a1b3b2ed9acd 100644
--- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-select-bundle-dialog/dot-publishing-queue-select-bundle-dialog.component.html
+++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-select-bundle-dialog/dot-publishing-queue-select-bundle-dialog.component.html
@@ -338,7 +338,7 @@
delete
diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-select-bundle-dialog/dot-publishing-queue-select-bundle-dialog.component.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-select-bundle-dialog/dot-publishing-queue-select-bundle-dialog.component.ts
index dd95b1180a4d..c3aaed7c8387 100644
--- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-select-bundle-dialog/dot-publishing-queue-select-bundle-dialog.component.ts
+++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-select-bundle-dialog/dot-publishing-queue-select-bundle-dialog.component.ts
@@ -404,9 +404,8 @@ export class DotPublishingQueueSelectBundleDialogComponent implements OnInit {
'publishing-queue.asset-list.remove-confirm.message',
asset.title || asset.asset
),
- acceptLabel: this.#dotMessageService.get('publishing-queue.remove'),
+ acceptLabel: this.#dotMessageService.get('publishing-queue.delete'),
rejectLabel: this.#dotMessageService.get('publishing-queue.cancel'),
- acceptButtonStyleClass: 'p-button-danger',
rejectButtonStyleClass: 'p-button-text',
defaultFocus: 'reject',
closable: true,
@@ -434,12 +433,14 @@ export class DotPublishingQueueSelectBundleDialogComponent implements OnInit {
}
this.$validationWarningKey.set(null);
this.#confirmationService.confirm({
- header: this.#dotMessageService.get('publishing-queue.delete.confirm.header'),
+ header: this.#dotMessageService.get(
+ 'publishing-queue.select-bundle.remove.confirm.header'
+ ),
message: this.#dotMessageService.get(
'publishing-queue.select-bundle.remove.confirm.message',
String(ids.length)
),
- acceptLabel: this.#dotMessageService.get('publishing-queue.history.kebab.delete'),
+ acceptLabel: this.#dotMessageService.get('publishing-queue.remove'),
rejectLabel: this.#dotMessageService.get('publishing-queue.cancel'),
acceptButtonStyleClass: 'p-button-primary',
rejectButtonStyleClass: 'p-button-text',
diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.spec.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.spec.ts
index 57a3bd75242b..8b20c932a83a 100644
--- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.spec.ts
+++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.spec.ts
@@ -9,6 +9,7 @@ import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog';
/* eslint-disable @nx/enforce-module-boundaries */
import {
+ DotCurrentUserService,
DotGlobalMessageService,
DotHttpErrorManagerService,
DotMessageDisplayService,
@@ -55,7 +56,13 @@ describe('DotPublishingQueueShellComponent', () => {
getBundleAssets: jest.fn().mockReturnValue(of([])),
getPublishingJobDetails: jest.fn().mockReturnValue(of({})),
probeBundleDownload: jest.fn().mockReturnValue(of(true)),
- probeBundleManifest: jest.fn().mockReturnValue(of(true))
+ probeBundleManifest: jest.fn().mockReturnValue(of(true)),
+ getUnsendBundles: jest
+ .fn()
+ .mockReturnValue(of({ identifier: 'id', label: 'name', items: [], numRows: 0 }))
+ }),
+ mockProvider(DotCurrentUserService, {
+ getCurrentUser: jest.fn().mockReturnValue(of({ userId: 'user-1' }))
}),
mockProvider(DotHttpErrorManagerService),
mockProvider(DotGlobalMessageService, { error: jest.fn() }),
diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-table/dot-publishing-queue-table.component.html b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-table/dot-publishing-queue-table.component.html
index 70158cc26f73..54825e20bfde 100644
--- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-table/dot-publishing-queue-table.component.html
+++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-table/dot-publishing-queue-table.component.html
@@ -36,8 +36,8 @@
{{ 'publishing-queue.column.filter' | dm }}
|
{{ 'publishing-queue.column.items' | dm }}
|
@@ -99,8 +99,8 @@
-
-
+
+
|
@@ -150,7 +150,7 @@
|
-
+ |
+
@if ((row.assetCount ?? 0) > 0) {
} @else {
-
+ {{ itemsLabel(0) }}
}
|
{
})
],
providers: [
- { provide: DotMessageService, useValue: new MockDotMessageService({}) },
+ {
+ provide: DotMessageService,
+ useValue: new MockDotMessageService({
+ 'publishing-queue.asset-list.items-count.singular': '{0} item',
+ 'publishing-queue.asset-list.items-count.plural': '{0} items',
+ 'publishing-queue.column.items.view': 'View items'
+ })
+ },
mockProvider(DotGlobalMessageService, { error: jest.fn() }),
mockProvider(DotPushPublishDialogService, { open: jest.fn() }),
mockProvider(DotDownloadBundleDialogService, { open: jest.fn() }),
@@ -143,6 +150,63 @@ describe('DotPublishingQueueTableComponent', () => {
expect(spectator.query(byTestId('pq-bundles-col-status'))).toBeTruthy();
});
+ describe('Items column', () => {
+ it('renders the count as a "N items" link', () => {
+ bundlesRows.set([{ ...row('b1'), assetCount: 20 }]);
+ spectator.detectChanges();
+
+ const link = spectator.query(byTestId('pq-bundles-items-btn'));
+ expect(link?.textContent?.trim()).toBe('20 items');
+ });
+
+ it('underlines on hover only, so the column stays quiet at rest', () => {
+ bundlesRows.set([{ ...row('b1'), assetCount: 20 }]);
+ spectator.detectChanges();
+
+ const link = spectator.query(byTestId('pq-bundles-items-btn'));
+ expect(link?.classList).toContain('hover:underline');
+ expect(link?.classList).not.toContain('underline');
+ });
+
+ it('carries a "View items" tooltip and aria-label so the click target reads as an action', () => {
+ bundlesRows.set([{ ...row('b1'), assetCount: 20 }]);
+ spectator.detectChanges();
+
+ expect(
+ spectator.query(byTestId('pq-bundles-items-btn'))?.getAttribute('aria-label')
+ ).toBe('View items');
+ });
+
+ it('opens the asset list without also triggering the row-detail dialog', () => {
+ bundlesRows.set([{ ...row('b1'), assetCount: 20 }]);
+ spectator.detectChanges();
+
+ spectator.click(byTestId('pq-bundles-items-btn'));
+
+ expect(store.openAssetList).toHaveBeenCalledWith('b1');
+ expect(store.openDetail).not.toHaveBeenCalled();
+ });
+
+ it('uses the singular wording for exactly one item', () => {
+ bundlesRows.set([{ ...row('b1'), assetCount: 1 }]);
+ spectator.detectChanges();
+
+ expect(spectator.query(byTestId('pq-bundles-items-btn'))?.textContent?.trim()).toBe(
+ '1 item'
+ );
+ });
+
+ it('renders an empty bundle as plain text — there is nothing to open', () => {
+ bundlesRows.set([{ ...row('b1'), assetCount: 0 }]);
+ spectator.detectChanges();
+
+ expect(spectator.query(byTestId('pq-bundles-items-btn'))).toBeFalsy();
+ expect(spectator.query(byTestId('pq-bundles-items'))?.textContent?.trim()).toBe(
+ '0 items'
+ );
+ });
+ });
+
it('row click opens the detail dialog', () => {
spectator.component.onRowClick(row('b1'));
expect(store.openDetail).toHaveBeenCalledWith('b1');
diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-table/dot-publishing-queue-table.component.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-table/dot-publishing-queue-table.component.ts
index 2fdd9ebf1baf..fad6c4a786e4 100644
--- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-table/dot-publishing-queue-table.component.ts
+++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-table/dot-publishing-queue-table.component.ts
@@ -15,7 +15,6 @@ import { ContextMenu, ContextMenuModule } from 'primeng/contextmenu';
import { MenuModule } from 'primeng/menu';
import { SkeletonModule } from 'primeng/skeleton';
import { TableLazyLoadEvent, TableModule } from 'primeng/table';
-import { TagModule } from 'primeng/tag';
import { TooltipModule } from 'primeng/tooltip';
/* eslint-disable @nx/enforce-module-boundaries */
@@ -72,7 +71,6 @@ const ACTIVE_STATUSES = new Set([
MenuModule,
SkeletonModule,
TableModule,
- TagModule,
TooltipModule,
DotEmptyContainerComponent,
DotMessagePipe,
@@ -249,6 +247,18 @@ export class DotPublishingQueueTableComponent {
}
}
+ /** "1 item" / "20 items" for the Items column link. Reuses the asset-list
+ * dialog's count keys so the link and the dialog it opens word the same
+ * number identically. */
+ itemsLabel(count: number): string {
+ return this.#dotMessageService.get(
+ count === 1
+ ? 'publishing-queue.asset-list.items-count.singular'
+ : 'publishing-queue.asset-list.items-count.plural',
+ String(count)
+ );
+ }
+
truncateBundleId(bundleId: string): string {
if (!bundleId || bundleId.length <= BUNDLE_ID_DISPLAY_MAX) {
return bundleId;
diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/store/dot-publishing-queue.store.spec.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/store/dot-publishing-queue.store.spec.ts
index 51088d453444..3cb8a9d8f90a 100644
--- a/core-web/libs/portlets/dot-publishing-queue/src/lib/store/dot-publishing-queue.store.spec.ts
+++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/store/dot-publishing-queue.store.spec.ts
@@ -2,6 +2,7 @@ import { createServiceFactory, mockProvider, SpectatorService } from '@openng/sp
import { of, throwError } from 'rxjs';
import {
+ DotCurrentUserService,
DotHttpErrorManagerService,
DotMessageDisplayService,
DotMessageService,
@@ -15,7 +16,8 @@ import {
PublishingJobDetailView,
PublishingJobsResponse,
PublishingJobView,
- RetryBundleResultView
+ RetryBundleResultView,
+ UnsentBundlesResponse
} from '@dotcms/dotcms-models';
import { MockDotMessageService } from '@dotcms/utils-testing';
@@ -44,6 +46,21 @@ const BUNDLES_RESPONSE: PublishingJobsResponse = {
pagination: { currentPage: 1, perPage: 10, totalEntries: 2 }
};
+/** Shape returned by the legacy `/api/bundle/getunsendbundles` endpoint. Three
+ * drafts, so `draftBundlesTotal` should settle on 3 — note `numRows` is
+ * deliberately wrong here (the real BE reports the page size, not the total) to
+ * pin that the store counts `items` rather than trusting `numRows`. */
+const UNSENT_BUNDLES_RESPONSE: UnsentBundlesResponse = {
+ identifier: 'id',
+ label: 'name',
+ items: [
+ { id: 'draft-1', name: 'Draft One' },
+ { id: 'draft-2', name: 'Draft Two' },
+ { id: 'draft-3', name: 'Draft Three' }
+ ],
+ numRows: 1
+};
+
const MOCK_ASSETS: BundleAssetView[] = [
{ asset: 'a1', title: 'Asset 1', type: 'contentlet' },
{ asset: 'a2', title: 'Asset 2', type: 'template' }
@@ -73,6 +90,7 @@ describe('DotPublishingQueueStore', () => {
let spectator: SpectatorService>;
let store: InstanceType;
let service: jest.Mocked;
+ let currentUserService: jest.Mocked;
let httpErrorManager: jest.Mocked;
let messageDisplay: jest.Mocked;
@@ -91,7 +109,11 @@ describe('DotPublishingQueueStore', () => {
retryBundles: jest.fn().mockReturnValue(of([])),
deleteBundle: jest.fn().mockReturnValue(of({ message: 'ok' })),
deleteBundles: jest.fn().mockReturnValue(of({ entity: 'ok' })),
- purgeBundles: jest.fn().mockReturnValue(of({ entity: { message: 'ok' } }))
+ purgeBundles: jest.fn().mockReturnValue(of({ entity: { message: 'ok' } })),
+ getUnsendBundles: jest.fn().mockReturnValue(of(UNSENT_BUNDLES_RESPONSE))
+ }),
+ mockProvider(DotCurrentUserService, {
+ getCurrentUser: jest.fn().mockReturnValue(of({ userId: 'user-1' }))
}),
mockProvider(DotHttpErrorManagerService),
mockProvider(DotMessageDisplayService, {
@@ -116,6 +138,9 @@ describe('DotPublishingQueueStore', () => {
service = spectator.inject(
DotPublishingQueueService
) as jest.Mocked;
+ currentUserService = spectator.inject(
+ DotCurrentUserService
+ ) as jest.Mocked;
httpErrorManager = spectator.inject(
DotHttpErrorManagerService
) as jest.Mocked;
@@ -214,6 +239,49 @@ describe('DotPublishingQueueStore', () => {
store.refresh();
expect(service.listPublishingJobs).toHaveBeenCalledTimes(1);
});
+
+ it('also reloads the draft count — pushing/deleting moves rows in and out of the unsent set', () => {
+ (service.getUnsendBundles as jest.Mock).mockClear();
+ store.refresh();
+ expect(service.getUnsendBundles).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('loadDraftBundlesCount', () => {
+ it('counts the drafts on init, resolving the user and asking for the list unbounded', () => {
+ expect(currentUserService.getCurrentUser).toHaveBeenCalled();
+ // count = -1 → BundleResource forwards it as an unlimited `maxRows`,
+ // so the response holds every draft and `items.length` is the total.
+ expect(service.getUnsendBundles).toHaveBeenCalledWith('user-1', '*', 0, -1);
+ expect(store.draftBundlesTotal()).toBe(3);
+ });
+
+ it('counts `items` rather than the BE `numRows` (which is only the page size)', () => {
+ // UNSENT_BUNDLES_RESPONSE carries numRows: 1 with 3 items on purpose.
+ expect(store.draftBundlesTotal()).toBe(3);
+ });
+
+ it('reuses the cached user id instead of re-resolving the current user', () => {
+ (currentUserService.getCurrentUser as jest.Mock).mockClear();
+ store.loadDraftBundlesCount();
+ expect(currentUserService.getCurrentUser).not.toHaveBeenCalled();
+ expect(service.getUnsendBundles).toHaveBeenCalledWith('user-1', '*', 0, -1);
+ });
+
+ it('keeps the last known count and shows no toast when a refresh fails', () => {
+ expect(store.draftBundlesTotal()).toBe(3);
+ (httpErrorManager.handle as jest.Mock).mockClear();
+ (service.getUnsendBundles as jest.Mock).mockReturnValueOnce(
+ throwError(() => new Error('boom'))
+ );
+
+ store.loadDraftBundlesCount();
+
+ // A failed count must not blank out a good number, and must not
+ // nag the user about decoration they never asked for.
+ expect(store.draftBundlesTotal()).toBe(3);
+ expect(httpErrorManager.handle).not.toHaveBeenCalled();
+ });
});
describe('openAssetList / closeAssetList', () => {
diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/store/dot-publishing-queue.store.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/store/dot-publishing-queue.store.ts
index 4423178c6544..2d50b9585e21 100644
--- a/core-web/libs/portlets/dot-publishing-queue/src/lib/store/dot-publishing-queue.store.ts
+++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/store/dot-publishing-queue.store.ts
@@ -1,11 +1,12 @@
import { patchState, signalStore, withHooks, withMethods, withState } from '@ngrx/signals';
-import { EMPTY } from 'rxjs';
+import { EMPTY, of } from 'rxjs';
import { DestroyRef, effect, inject, untracked } from '@angular/core';
-import { catchError, take } from 'rxjs/operators';
+import { catchError, map, switchMap, take, tap } from 'rxjs/operators';
import {
+ DotCurrentUserService,
DotHttpErrorManagerService,
DotMessageDisplayService,
DotMessageService,
@@ -46,6 +47,11 @@ export const PURGE_FAILED_STATUSES: readonly PublishAuditStatus[] = [
const POLL_INTERVAL_MS = 15000;
+/** `count` sent to `/api/bundle/getunsendbundles` when we only want a total.
+ * `BundleResource` forwards it as the factory's `limit`, and `DotConnect` treats
+ * any `maxRows <= 0` as "no limit" — see `loadDraftBundlesCount`. */
+const DRAFT_COUNT_UNBOUNDED = -1;
+
interface DotPublishingQueueState {
bundlesRows: PublishingJobView[];
bundlesPage: number;
@@ -55,6 +61,15 @@ interface DotPublishingQueueState {
bundlesSortDirection: PublishingSortDirection;
bundlesSelectedIds: string[];
+ /** Number of unsent ("draft") bundles — the ones the Select Bundle dialog
+ * lists. Shown on the toolbar's "Bundles (N)" button and next to its
+ * "Select Bundle" entry.
+ *
+ * `null` = unknown (not fetched yet, or the fetch failed). Consumers must
+ * render the label without a number in that case rather than showing `0`,
+ * which would read as "you have no drafts". */
+ draftBundlesTotal: number | null;
+
rowsPerPage: number;
search: string;
/** Status chips checked in the toolbar filter. Empty = no filter (all statuses). */
@@ -90,6 +105,7 @@ const initialState: DotPublishingQueueState = {
bundlesSort: null,
bundlesSortDirection: 'desc',
bundlesSelectedIds: [],
+ draftBundlesTotal: null,
rowsPerPage: 20,
search: '',
@@ -110,6 +126,7 @@ export const DotPublishingQueueStore = signalStore(
withState(initialState),
withMethods((store) => {
const service = inject(DotPublishingQueueService);
+ const currentUserService = inject(DotCurrentUserService);
const httpErrorManager = inject(DotHttpErrorManagerService);
const messageDisplay = inject(DotMessageDisplayService);
const dotMessageService = inject(DotMessageService);
@@ -117,6 +134,10 @@ export const DotPublishingQueueStore = signalStore(
let pollHandle: ReturnType | null = null;
+ /** Cached so the draft count doesn't re-fetch the current user on every
+ * refresh. The portlet can't outlive a session change. */
+ let currentUserId: string | null = null;
+
/**
* Fetches the bundles list.
*
@@ -173,6 +194,55 @@ export const DotPublishingQueueStore = signalStore(
});
}
+ /**
+ * Refreshes `draftBundlesTotal` — the count the toolbar shows on its
+ * "Bundles (N)" button and next to the "Select Bundle" entry.
+ *
+ * There is no count endpoint for drafts. `/api/v1/publishing` reports a
+ * real total but reads `publish_audit`, which does not contain drafts at
+ * all (they live only in `publishing_bundle`). The one endpoint that
+ * surfaces drafts — `GET /api/bundle/getunsendbundles/userid/{id}` —
+ * returns `numRows = bundles.size()` of the page it was asked for, not a
+ * total (`BundleResource#getUnsendBundles`). So we ask for the list
+ * unbounded and count it here.
+ *
+ * That is cheaper than it looks: `SELECT_UNSEND_BUNDLES{,_ADMIN}` carry
+ * no SQL `LIMIT` and `DotConnect` trims rows in Java afterwards, so the
+ * database does identical work whether we request one page or all of
+ * them. Only the JSON grows, and each row is just `{id, name}`.
+ *
+ * The count is user-scoped by the backend: a regular user sees only
+ * their own drafts, a CMS Administrator sees everyone's. Two users
+ * legitimately see different numbers on the same button.
+ *
+ * Errors are swallowed rather than surfaced: the count is decoration on
+ * a button, and `null` already renders as a plain "Bundles" label. A
+ * toast here would punish the user for something they didn't ask for and
+ * cannot act on.
+ */
+ function loadDraftBundlesCount() {
+ const userId$ = currentUserId
+ ? of(currentUserId)
+ : currentUserService.getCurrentUser().pipe(
+ map((user) => user.userId),
+ tap((userId) => (currentUserId = userId))
+ );
+
+ userId$
+ .pipe(
+ switchMap((userId) =>
+ service.getUnsendBundles(userId, '*', 0, DRAFT_COUNT_UNBOUNDED)
+ ),
+ take(1),
+ catchError(() => EMPTY)
+ )
+ .subscribe((response) => {
+ patchState(store, {
+ draftBundlesTotal: response.items?.length ?? response.numRows ?? 0
+ });
+ });
+ }
+
function loadAssets() {
const bundleId = store.selectedBundleId();
if (!bundleId) {
@@ -259,8 +329,16 @@ export const DotPublishingQueueStore = signalStore(
});
}
+ /** User-visible "give me the latest" entry point — the toolbar's Refresh
+ * button and the post-action reload every mutation triggers. Pulls the
+ * draft count too, since pushing / deleting / purging bundles all move
+ * rows in and out of the unsent set.
+ *
+ * Deliberately NOT what the 15 s poll calls (it calls `loadBundles(true)`
+ * directly) so background ticks don't add a second request. */
function refresh() {
loadBundles();
+ loadDraftBundlesCount();
}
// Fires a silent refresh as soon as the tab comes back into focus so
@@ -303,6 +381,7 @@ export const DotPublishingQueueStore = signalStore(
return {
loadBundles,
+ loadDraftBundlesCount,
loadAssets,
loadDetail,
refresh,
@@ -544,6 +623,7 @@ export const DotPublishingQueueStore = signalStore(
untracked(() => store.loadBundles());
});
+ store.loadDraftBundlesCount();
store.startPolling();
}
};
diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties
index 3b830907a426..93cceaa768f7 100644
--- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties
+++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties
@@ -3807,6 +3807,8 @@ publishing-queue.search.placeholder=Search bundles
publishing-queue.refresh=Refresh
publishing-queue.upload-bundle=Upload Bundle
publishing-queue.add-bundle=Add Bundle
+publishing-queue.bundles=Bundles
+publishing-queue.bundles.count=Bundles ({0})
publishing-queue.add-bundle.select=Select Bundle
publishing-queue.add-bundle.upload=Upload
publishing-queue.select-bundle.title=Select Bundle
@@ -3838,11 +3840,13 @@ publishing-queue.select-bundle.bundle-count.plural={0} bundles
publishing-queue.select-bundle.back-to-list=Back to list
publishing-queue.select-bundle.send=Send
publishing-queue.select-bundle.send-partial-fail={0} of {1} bundles failed to push.
+publishing-queue.select-bundle.remove.confirm.header=Remove bundles?
publishing-queue.select-bundle.remove.confirm.message=Are you sure you want to remove {0} bundle(s)? This action cannot be undone.
publishing-queue.column.name=Name
publishing-queue.column.type=Type
publishing-queue.column.filter=Filter
publishing-queue.column.items=Items
+publishing-queue.column.items.view=View items
publishing-queue.column.bundle-id=Id
publishing-queue.column.bundle-name=Name
publishing-queue.column.date-entered=Date Entered
@@ -3859,8 +3863,8 @@ publishing-queue.asset-list.items-count.singular={0} item
publishing-queue.asset-list.items-count.plural={0} items
publishing-queue.asset-list.empty=No items in this bundle.
publishing-queue.asset-list.remove=Remove from bundle
-publishing-queue.asset-list.remove-confirm.header=Remove asset from bundle?
-publishing-queue.asset-list.remove-confirm.message=Are you sure you want to remove "{0}" from this bundle?
+publishing-queue.asset-list.remove-confirm.header=Delete asset from bundle?
+publishing-queue.asset-list.remove-confirm.message=Are you sure you want to delete "{0}" from this bundle?
publishing-queue.send=Send
publishing-queue.status.BUNDLE_REQUESTED=Pending
publishing-queue.status.BUNDLING=Bundling
@@ -3888,6 +3892,7 @@ publishing-queue.accept=Accept
publishing-queue.cancel=Cancel
publishing-queue.close=Close
publishing-queue.remove=Remove
+publishing-queue.delete=Delete
publishing-queue.retry=Retry
publishing-queue.retry-send=Retry Send
publishing-queue.retry.success.plural={0} bundles queued for retry.
|