From 0816d5b57164bb3070da6a9878c1db039983b5e5 Mon Sep 17 00:00:00 2001 From: hmoreras <31667212+hmoreras@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:55:56 -0600 Subject: [PATCH 1/3] fix(publishing-queue): tighten delete asset button and unify remove/delete terminology - Delete asset button in select-bundle dialog: compact 32x32 with 18px icon (was uneven with default PrimeNG padding and mis-sized material-symbols). - Remove asset confirmation: drop danger styling on accept, use primary. - Terminology: use "Remove" consistently for the bundle remove flow (header now "Remove bundles?", accept "Remove") and "Delete" consistently for the asset delete flow (header "Delete asset from bundle?", accept "Delete"). - Table copy-button size bump w-4/h-4 -> w-5/h-5 for parity with hover target. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dot-publishing-queue-asset-list-dialog.component.ts | 2 +- ...-publishing-queue-select-bundle-dialog.component.html | 4 ++-- ...ot-publishing-queue-select-bundle-dialog.component.ts | 9 +++++---- .../dot-publishing-queue-table.component.html | 2 +- .../src/main/webapp/WEB-INF/messages/Language.properties | 6 ++++-- 5 files changed, 13 insertions(+), 10 deletions(-) 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 @@

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-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..cb4de0b0271b 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 @@ -150,7 +150,7 @@ Date: Fri, 7 Aug 2026 09:48:24 -0600 Subject: [PATCH 2/3] fix(publishing-queue): apply design polish to items link, bundles button and status chips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design review of the Angular publishing-queue portlet asked for three changes, plus one defect surfaced while implementing them. Items column: the asset count rendered as a `p-tag` chip, which read as a passive badge — users did not discover it opens the bundle's contents. It is now a "20 items" text link that underlines on hover, keeping the row's normal text color. Empty bundles have nothing to open, so they stay plain text. Add Bundle button: relabelled "Bundles (N)", where N is the number of unsent draft bundles, and the same count is repeated next to the "Select Bundle" menu row. No endpoint returns that total — /api/v1/publishing reads publish_audit, which holds no drafts, and /api/bundle/getunsendbundles reports numRows as the size of the requested page. The store therefore requests the draft list unbounded and counts it. That costs the database nothing extra: SELECT_UNSEND_BUNDLES carries no SQL LIMIT and DotConnect trims rows in Java afterwards, so only the JSON grows. The count is user-scoped by the backend (admins see every user's drafts) and renders as a bare "Bundles" while unknown rather than claiming "(0)". Status chips: the severity bucket used 'warning', but PrimeNG derives its class as `p-tag-{severity}` and only accepts 'warn'. Five statuses (Bundling, Sending, Publishing, Received, Success with warnings) matched no `p-tag-*` class and fell back to the base solid primary fill, rendering as blue chips that looked like buttons. Corrected to 'warn' with no change to any status's bucket; a guard test now walks every PublishAuditStatus and asserts the bucket is one PrimeNG actually renders. Co-Authored-By: Claude Opus 5 (1M context) --- ...ot-publishing-queue-toolbar.component.html | 27 +++++- ...publishing-queue-toolbar.component.spec.ts | 96 ++++++++++++++++++- .../dot-publishing-queue-toolbar.component.ts | 37 ++++++- ...t-publishing-status-chip.component.spec.ts | 25 +++-- .../dot-publishing-status-chip.component.ts | 18 ++-- ...t-publishing-queue-shell.component.spec.ts | 9 +- .../dot-publishing-queue-table.component.html | 32 +++---- ...t-publishing-queue-table.component.spec.ts | 66 ++++++++++++- .../dot-publishing-queue-table.component.ts | 14 ++- .../store/dot-publishing-queue.store.spec.ts | 72 +++++++++++++- .../lib/store/dot-publishing-queue.store.ts | 84 +++++++++++++++- .../WEB-INF/messages/Language.properties | 3 + 12 files changed, 432 insertions(+), 51 deletions(-) diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-toolbar/dot-publishing-queue-toolbar.component.html b/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-toolbar/dot-publishing-queue-toolbar.component.html index a152a04cd8d3..a1f27bdd7f76 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-toolbar/dot-publishing-queue-toolbar.component.html +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-toolbar/dot-publishing-queue-toolbar.component.html @@ -50,13 +50,32 @@ appendTo="body" showTransitionOptions="0ms" hideTransitionOptions="0ms" - data-testid="pq-add-bundle-menu" /> + data-testid="pq-add-bundle-menu"> + + + + {{ item.label }} + @if (showsDraftCount(item)) { + + {{ store.draftBundlesTotal() }} + + } + + + - - {{ '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..5a831b667595 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,33 @@ 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). */ + /** "Bundles (12)" — the trigger label carries the number of drafts waiting to + * be sent, so the user knows there is something behind the button without + * opening it. Falls back to a bare "Bundles" while the count is unknown + * (in flight, or the request failed) rather than claiming "(0)". */ + 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)); + }); + + /** 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). + * + * `id` is what the template's item template keys off to decide which row + * gets the trailing draft count — the count is read from the store in the + * template so this array can stay a stable, built-once reference (PrimeNG + * re-processes `[model]` on every identity change, which makes the menu + * swallow the first click). + * + * `SELECT_BUNDLE_ITEM_ID` is exported so the spec can assert the wiring + * without hardcoding the string in two places. */ readonly addBundleItems: MenuItem[] = [ { + id: SELECT_BUNDLE_ITEM_ID, label: this.#dotMessageService.get('publishing-queue.add-bundle.select'), command: () => this.$selectBundleClick.emit() }, @@ -83,4 +108,10 @@ export class DotPublishingQueueToolbarComponent { onBulkRetry(): void { this.store.retryBundles({ bundleIds: this.store.bundlesSelectedIds() }); } + + /** True for the one menu row that shows the draft count. Kept as a method so + * the item template stays readable and the id lives in one constant. */ + 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/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 cb4de0b0271b..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 @@ - - + + @@ -169,30 +169,26 @@ {{ row.filterName || row.filterKey || '—' }} - + + @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 01b908fd192d..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 @@ -3844,6 +3846,7 @@ 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 From cf1e939883d12e91ae8218af579fa1f40377b87e Mon Sep 17 00:00:00 2001 From: hmoreras <31667212+hmoreras@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:57:32 -0600 Subject: [PATCH 3/3] chore(publishing-queue): trim redundant comments in the toolbar Keeps only the two facts the code does not already state: why the item template reuses PrimeNG's own classes, and that `addBundleItems` must keep a stable identity or the menu swallows the first click. The rest restated the code, the portlet conventions already in libs/portlets/CLAUDE.md, or PrimeNG internals nobody edits from here. Co-Authored-By: Claude Opus 5 (1M context) --- ...ot-publishing-queue-toolbar.component.html | 8 ++----- .../dot-publishing-queue-toolbar.component.ts | 22 ++++--------------- 2 files changed, 6 insertions(+), 24 deletions(-) diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-toolbar/dot-publishing-queue-toolbar.component.html b/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-toolbar/dot-publishing-queue-toolbar.component.html index a1f27bdd7f76..09e7ae798864 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-toolbar/dot-publishing-queue-toolbar.component.html +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-toolbar/dot-publishing-queue-toolbar.component.html @@ -51,12 +51,8 @@ showTransitionOptions="0ms" hideTransitionOptions="0ms" data-testid="pq-add-bundle-menu"> - + {{ item.label }} 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 5a831b667595..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 @@ -59,10 +59,8 @@ 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); - /** "Bundles (12)" — the trigger label carries the number of drafts waiting to - * be sent, so the user knows there is something behind the button without - * opening it. Falls back to a bare "Bundles" while the count is unknown - * (in flight, or the request failed) rather than claiming "(0)". */ + /** Bare "Bundles" while the count is unknown — "(0)" would read as "you + * have no drafts". */ protected readonly $bundlesLabel = computed(() => { const total = this.store.draftBundlesTotal(); @@ -71,18 +69,8 @@ export class DotPublishingQueueToolbarComponent { : this.#dotMessageService.get('publishing-queue.bundles.count', String(total)); }); - /** 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). - * - * `id` is what the template's item template keys off to decide which row - * gets the trailing draft count — the count is read from the store in the - * template so this array can stay a stable, built-once reference (PrimeNG - * re-processes `[model]` on every identity change, which makes the menu - * swallow the first click). - * - * `SELECT_BUNDLE_ITEM_ID` is exported so the spec can assert the wiring - * without hardcoding the string in two places. */ + /** 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, @@ -109,8 +97,6 @@ export class DotPublishingQueueToolbarComponent { this.store.retryBundles({ bundleIds: this.store.bundlesSelectedIds() }); } - /** True for the one menu row that shows the draft count. Kept as a method so - * the item template stays readable and the id lives in one constant. */ protected showsDraftCount(item: MenuItem): boolean { return item.id === SELECT_BUNDLE_ITEM_ID && this.store.draftBundlesTotal() !== null; }