Skip to content
Merged
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 @@ -50,13 +50,28 @@
appendTo="body"
showTransitionOptions="0ms"
hideTransitionOptions="0ms"
data-testid="pq-add-bundle-menu" />
data-testid="pq-add-bundle-menu">
<!-- Classes mirror PrimeNG's default item markup so padding
and hover stay identical. -->
<ng-template pTemplate="item" let-item>
<span class="p-menu-item-link">
<span class="p-menu-item-label">{{ item.label }}</span>
@if (showsDraftCount(item)) {
<span
class="text-color-secondary ml-auto pl-4 text-sm"
data-testid="pq-select-bundle-count">
{{ store.draftBundlesTotal() }}
</span>
}
</span>
</ng-template>
</p-menu>
<p-button
(onClick)="addBundleMenu.toggle($event)"
[attr.aria-label]="'publishing-queue.add-bundle' | dm"
[attr.aria-label]="$bundlesLabel()"
data-testid="pq-add-bundle-btn">
<span class="p-button-label">
{{ 'publishing-queue.add-bundle' | dm }}
<span class="p-button-label" data-testid="pq-add-bundle-btn-label">
{{ $bundlesLabel() }}
</span>
<span
class="material-symbols-rounded -my-1 w-5 leading-none!"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import { CUSTOM_ELEMENTS_SCHEMA, signal } from '@angular/core';
import { DotMessageService } from '@dotcms/data-access';
import { MockDotMessageService } from '@dotcms/utils-testing';

import { DotPublishingQueueToolbarComponent } from './dot-publishing-queue-toolbar.component';
import {
DotPublishingQueueToolbarComponent,
SELECT_BUNDLE_ITEM_ID
} from './dot-publishing-queue-toolbar.component';

import { DotPublishingQueueStore } from '../../store/dot-publishing-queue.store';
import { DotPublishingQueueStatusFilterComponent } from '../dot-publishing-queue-status-filter/dot-publishing-queue-status-filter.component';
Expand All @@ -16,6 +19,7 @@ describe('DotPublishingQueueToolbarComponent', () => {

const bundlesSelectedIds = signal<string[]>([]);
const bundlesTotal = signal<number>(0);
const draftBundlesTotal = signal<number | null>(null);

function makeStoreStub() {
return {
Expand All @@ -24,6 +28,7 @@ describe('DotPublishingQueueToolbarComponent', () => {
refresh: jest.fn(),
bundlesSelectedIds,
bundlesTotal,
draftBundlesTotal,
retryBundles: jest.fn()
};
}
Expand Down Expand Up @@ -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'
})
}
],
Expand All @@ -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
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -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()
},
Expand All @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,18 @@ 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<PublishAuditStatus, StatusBucket> = {
// success: bundle reached its target
[PublishAuditStatus.SUCCESS]: 'success',
[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',
Expand All @@ -32,11 +34,11 @@ const BUCKETS: Record<PublishAuditStatus, StatusBucket> = {
// 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. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ <h2 class="m-0 text-xl font-bold text-color" data-testid="pq-select-bundle-title
[rounded]="true"
size="small"
severity="danger"
styleClass="opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-opacity"
styleClass="opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-opacity h-8! w-8! min-w-0! p-0!"
[pTooltip]="
'publishing-queue.select-bundle.delete-tooltip'
| dm
Expand All @@ -351,7 +351,7 @@ <h2 class="m-0 text-xl font-bold text-color" data-testid="pq-select-bundle-title
"
data-testid="pq-select-bundle-asset-delete">
<span
class="material-symbols-rounded w-6 text-center"
class="material-symbols-rounded text-lg! leading-none!"
aria-hidden="true">
delete
</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog';
/* eslint-disable @nx/enforce-module-boundaries */

import {
DotCurrentUserService,
DotGlobalMessageService,
DotHttpErrorManagerService,
DotMessageDisplayService,
Expand Down Expand Up @@ -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() }),
Expand Down
Loading
Loading