From 063b740203c9bcd34c707ae5992a6813b0b86fb5 Mon Sep 17 00:00:00 2001 From: hmoreras <31667212+hmoreras@users.noreply.github.com> Date: Mon, 8 Jun 2026 16:28:21 -0600 Subject: [PATCH 01/43] feat(publishing-queue) #36040: Angular portlet shell, Queue tab, Asset list modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slice of the Publishing Queue Dojo → Angular migration. Lands the foundation end-to-end so subsequent slices (History tab, Configure & send, Upload Bundle, kebab actions, Bundle details modal) layer on the same shell + store + data-access surface. Frontend - New Nx library libs/portlets/dot-publishing-queue (shell + page + reusable list + toolbar + asset list dialog + SignalStore) - Path alias @dotcms/portlets/dot-publishing-queue/portlet in tsconfig.base.json; route registered in apps/dotcms-ui PORTLETS_ANGULAR - Top bar: search (300ms debounce), Refresh, Upload Bundle (disabled + tooltip), site selector (disabled + tooltip — backend filter pending) - Queue tab: two-column grid (Ready to Send + In Progress) with counts, status chips, skeleton/empty/error states, paginator per column - Row click on either column opens the Asset list modal (Name/Type/State) - 40 Jest+Spectator tests, 98.7% coverage Data access + models - DotPublishingQueueService at libs/data-access/.../dot-publishing-queue covers GET /v1/publishing and GET /bundle/{id}/assets - New models: PublishingJobView, AssetPreviewView, PublishingJobsResponse, PublishAuditStatus enum (mirrors the 18-value Java enum), READY_STATUSES / IN_PROGRESS_STATUSES constants, BundleAssetView Backend wiring - portlet.xml: existing JSP entry renamed to publishing-queue-legacy; new Angular publishing-queue entry (com.dotcms.spring.portlet.PortletController) in the Angular Portlets section — admins can roll back without redeploy by flipping the two values - PortletID enum: PUBLISHING_QUEUE_LEGACY("publishing-queue-legacy") - Language.properties: publishing-queue-legacy title + 35 new UI keys - Resource-level requiredPortlet("publishing-queue") gates unchanged — the portlet name string is identical, only the class flipped Co-Authored-By: Claude Opus 4.7 (1M context) --- core-web/apps/dotcms-ui/src/app/app.routes.ts | 10 + core-web/libs/data-access/src/index.ts | 1 + .../dot-publishing-queue.service.spec.ts | 97 ++++++++ .../dot-publishing-queue.service.ts | 58 +++++ core-web/libs/dotcms-models/src/index.ts | 3 + .../src/lib/bundle-asset-view.model.ts | 21 ++ .../src/lib/publishing-job.model.ts | 44 ++++ .../src/lib/publishing-status.model.ts | 41 ++++ .../dot-publishing-queue/.eslintrc.json | 18 ++ .../dot-publishing-queue/jest.config.ts | 21 ++ .../dot-publishing-queue/project.json | 21 ++ .../dot-publishing-queue/src/index.ts | 1 + ...ot-publishing-queue-toolbar.component.html | 40 ++++ ...publishing-queue-toolbar.component.spec.ts | 103 ++++++++ .../dot-publishing-queue-toolbar.component.ts | 53 +++++ ...ing-queue-asset-list-dialog.component.html | 30 +++ ...-queue-asset-list-dialog.component.spec.ts | 74 ++++++ ...shing-queue-asset-list-dialog.component.ts | 19 ++ .../dot-publishing-queue-list.component.html | 72 ++++++ ...ot-publishing-queue-list.component.spec.ts | 152 ++++++++++++ .../dot-publishing-queue-list.component.ts | 93 ++++++++ .../dot-publishing-queue-page.component.html | 26 +++ ...ot-publishing-queue-page.component.spec.ts | 103 ++++++++ .../dot-publishing-queue-page.component.ts | 58 +++++ .../store/dot-publishing-queue.store.spec.ts | 219 ++++++++++++++++++ .../store/dot-publishing-queue.store.ts | 198 ++++++++++++++++ ...t-publishing-queue-shell.component.spec.ts | 46 ++++ .../dot-publishing-queue-shell.component.ts | 21 ++ .../src/lib/lib.routes.ts | 10 + .../dot-publishing-queue/src/test-setup.ts | 6 + .../dot-publishing-queue/tsconfig.json | 31 +++ .../dot-publishing-queue/tsconfig.lib.json | 13 ++ .../dot-publishing-queue/tsconfig.spec.json | 13 ++ core-web/package.json | 2 + core-web/tsconfig.base.json | 6 +- core-web/yarn.lock | 7 + .../java/com/dotmarketing/util/PortletID.java | 3 +- .../WEB-INF/messages/Language.properties | 35 +++ dotCMS/src/main/webapp/WEB-INF/portlet.xml | 10 +- 39 files changed, 1775 insertions(+), 4 deletions(-) create mode 100644 core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.spec.ts create mode 100644 core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.ts create mode 100644 core-web/libs/dotcms-models/src/lib/bundle-asset-view.model.ts create mode 100644 core-web/libs/dotcms-models/src/lib/publishing-job.model.ts create mode 100644 core-web/libs/dotcms-models/src/lib/publishing-status.model.ts create mode 100644 core-web/libs/portlets/dot-publishing-queue/.eslintrc.json create mode 100644 core-web/libs/portlets/dot-publishing-queue/jest.config.ts create mode 100644 core-web/libs/portlets/dot-publishing-queue/project.json create mode 100644 core-web/libs/portlets/dot-publishing-queue/src/index.ts create mode 100644 core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-toolbar/dot-publishing-queue-toolbar.component.html create mode 100644 core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-toolbar/dot-publishing-queue-toolbar.component.spec.ts create mode 100644 core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-toolbar/dot-publishing-queue-toolbar.component.ts create mode 100644 core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-asset-list-dialog/dot-publishing-queue-asset-list-dialog.component.html create mode 100644 core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-asset-list-dialog/dot-publishing-queue-asset-list-dialog.component.spec.ts create mode 100644 core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-asset-list-dialog/dot-publishing-queue-asset-list-dialog.component.ts create mode 100644 core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-list/dot-publishing-queue-list.component.html create mode 100644 core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-list/dot-publishing-queue-list.component.spec.ts create mode 100644 core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-list/dot-publishing-queue-list.component.ts create mode 100644 core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/dot-publishing-queue-page.component.html create mode 100644 core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/dot-publishing-queue-page.component.spec.ts create mode 100644 core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/dot-publishing-queue-page.component.ts create mode 100644 core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/store/dot-publishing-queue.store.spec.ts create mode 100644 core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/store/dot-publishing-queue.store.ts create mode 100644 core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.spec.ts create mode 100644 core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.ts create mode 100644 core-web/libs/portlets/dot-publishing-queue/src/lib/lib.routes.ts create mode 100644 core-web/libs/portlets/dot-publishing-queue/src/test-setup.ts create mode 100644 core-web/libs/portlets/dot-publishing-queue/tsconfig.json create mode 100644 core-web/libs/portlets/dot-publishing-queue/tsconfig.lib.json create mode 100644 core-web/libs/portlets/dot-publishing-queue/tsconfig.spec.json diff --git a/core-web/apps/dotcms-ui/src/app/app.routes.ts b/core-web/apps/dotcms-ui/src/app/app.routes.ts index beba61a2f00c..88f49afc866d 100644 --- a/core-web/apps/dotcms-ui/src/app/app.routes.ts +++ b/core-web/apps/dotcms-ui/src/app/app.routes.ts @@ -170,6 +170,16 @@ const PORTLETS_ANGULAR: Route[] = [ data: { reuseRoute: false }, loadChildren: () => import('@dotcms/portlets/dot-tags/portlet').then((m) => m.dotTagsRoutes) }, + { + path: 'publishing-queue', + canActivate: [MenuGuardService], + canActivateChild: [MenuGuardService], + data: { reuseRoute: false }, + loadChildren: () => + import('@dotcms/portlets/dot-publishing-queue/portlet').then( + (m) => m.dotPublishingQueueRoutes + ) + }, { path: 'query-tool', canActivate: [MenuGuardService], diff --git a/core-web/libs/data-access/src/index.ts b/core-web/libs/data-access/src/index.ts index 8d38994d332f..0543a871f882 100644 --- a/core-web/libs/data-access/src/index.ts +++ b/core-web/libs/data-access/src/index.ts @@ -45,6 +45,7 @@ export * from './lib/dot-page-workflows-actions/dot-page-workflows-actions.servi export * from './lib/dot-personalize/dot-personalize.service'; export * from './lib/dot-personas/dot-personas.service'; export * from './lib/dot-properties/dot-properties.service'; +export * from './lib/dot-publishing-queue/dot-publishing-queue.service'; export * from './lib/dot-push-publish-filters/dot-push-publish-filters.service'; export * from './lib/dot-resource-links/dot-resource-links.service'; export * from './lib/dot-roles/dot-roles.service'; diff --git a/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.spec.ts b/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.spec.ts new file mode 100644 index 000000000000..131b00c433ee --- /dev/null +++ b/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.spec.ts @@ -0,0 +1,97 @@ +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { + BundleAssetView, + IN_PROGRESS_STATUSES, + PublishAuditStatus, + PublishingJobsResponse, + READY_STATUSES +} from '@dotcms/dotcms-models'; + +import { DotPublishingQueueService } from './dot-publishing-queue.service'; + +describe('DotPublishingQueueService', () => { + let service: DotPublishingQueueService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting(), DotPublishingQueueService] + }); + service = TestBed.inject(DotPublishingQueueService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + describe('listPublishingJobs', () => { + it('joins statuses with comma and forwards pagination params', () => { + const mockResponse: PublishingJobsResponse = { + entity: [], + pagination: { currentPage: 1, perPage: 10, totalEntries: 0 } + }; + + service + .listPublishingJobs({ + statuses: READY_STATUSES, + page: 2, + perPage: 25, + filter: 'bundle-x' + }) + .subscribe((response) => { + expect(response).toEqual(mockResponse); + }); + + const req = httpMock.expectOne((request) => request.url === '/api/v1/publishing'); + expect(req.request.method).toBe('GET'); + expect(req.request.params.get('status')).toBe( + `${PublishAuditStatus.WAITING_FOR_PUBLISHING},${PublishAuditStatus.BUNDLE_REQUESTED}` + ); + expect(req.request.params.get('page')).toBe('2'); + expect(req.request.params.get('per_page')).toBe('25'); + expect(req.request.params.get('filter')).toBe('bundle-x'); + req.flush(mockResponse); + }); + + it('omits optional params when not provided', () => { + service.listPublishingJobs({ statuses: IN_PROGRESS_STATUSES }).subscribe(); + + const req = httpMock.expectOne((request) => request.url === '/api/v1/publishing'); + expect(req.request.params.has('page')).toBe(false); + expect(req.request.params.has('per_page')).toBe(false); + expect(req.request.params.has('filter')).toBe(false); + req.flush({ entity: [], pagination: { currentPage: 1, perPage: 50, totalEntries: 0 } }); + }); + + it('omits filter when empty string', () => { + service.listPublishingJobs({ statuses: READY_STATUSES, filter: '' }).subscribe(); + + const req = httpMock.expectOne((request) => request.url === '/api/v1/publishing'); + expect(req.request.params.has('filter')).toBe(false); + req.flush({ entity: [], pagination: { currentPage: 1, perPage: 50, totalEntries: 0 } }); + }); + }); + + describe('getBundleAssets', () => { + it('hits /api/bundle/{bundleId}/assets with limit=-1', () => { + const mockAssets: BundleAssetView[] = [ + { id: 'a1', title: 'Asset 1', type: 'contentlet' } + ]; + + service.getBundleAssets('bundle-123').subscribe((assets) => { + expect(assets).toEqual(mockAssets); + }); + + const req = httpMock.expectOne( + (request) => request.url === '/api/bundle/bundle-123/assets' + ); + expect(req.request.method).toBe('GET'); + expect(req.request.params.get('limit')).toBe('-1'); + req.flush(mockAssets); + }); + }); +}); diff --git a/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.ts b/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.ts new file mode 100644 index 000000000000..d90d4d23e605 --- /dev/null +++ b/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.ts @@ -0,0 +1,58 @@ +import { Observable } from 'rxjs'; + +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; + +import { BundleAssetView, PublishAuditStatus, PublishingJobsResponse } from '@dotcms/dotcms-models'; + +export interface ListPublishingJobsParams { + statuses: readonly PublishAuditStatus[]; + page?: number; + perPage?: number; + filter?: string; +} + +/** + * Backs the Publishing Queue Angular portlet. Wraps the v1 publishing endpoints + * the new UI needs in this slice. + * + * Endpoints covered: + * - `GET /api/v1/publishing` (`PublishingResource#listPublishingJobs`) + * - `GET /api/bundle/{bundleId}/assets` (`BundleResource#getPublishQueueElements`) + * + * Future slices add `getPublishingJobDetails`, `pushBundle`, `retryBundles`, etc. + */ +@Injectable({ + providedIn: 'root' +}) +export class DotPublishingQueueService { + private http = inject(HttpClient); + + listPublishingJobs(params: ListPublishingJobsParams): Observable { + let httpParams = new HttpParams().set('status', params.statuses.join(',')); + + if (params.page !== undefined) { + httpParams = httpParams.set('page', params.page); + } + + if (params.perPage !== undefined) { + httpParams = httpParams.set('per_page', params.perPage); + } + + if (params.filter && params.filter.length > 0) { + httpParams = httpParams.set('filter', params.filter); + } + + return this.http.get('/api/v1/publishing', { + params: httpParams + }); + } + + getBundleAssets(bundleId: string): Observable { + const params = new HttpParams().set('limit', -1); + + return this.http.get(`/api/bundle/${bundleId}/assets`, { + params + }); + } +} diff --git a/core-web/libs/dotcms-models/src/index.ts b/core-web/libs/dotcms-models/src/index.ts index 2395fb265339..4b0d5036c704 100644 --- a/core-web/libs/dotcms-models/src/index.ts +++ b/core-web/libs/dotcms-models/src/index.ts @@ -61,6 +61,9 @@ export * from './lib/dot-persona.model'; export * from './lib/dot-personalize.model'; export * from './lib/dot-push-publish-data.model'; export * from './lib/dot-push-publish-dialog-data.model'; +export * from './lib/publishing-job.model'; +export * from './lib/publishing-status.model'; +export * from './lib/bundle-asset-view.model'; export * from './lib/dot-rendered-page-state.model'; export * from './lib/dot-rendered-page.model'; export * from './lib/dot-request-response.model'; diff --git a/core-web/libs/dotcms-models/src/lib/bundle-asset-view.model.ts b/core-web/libs/dotcms-models/src/lib/bundle-asset-view.model.ts new file mode 100644 index 000000000000..99c9a41062c7 --- /dev/null +++ b/core-web/libs/dotcms-models/src/lib/bundle-asset-view.model.ts @@ -0,0 +1,21 @@ +/** + * Shape of a single row in the Asset list modal. + * + * Backed by `GET /api/bundle/{bundleId}/assets` which returns a `List>` + * (`com.dotcms.rest.BundleResource#getPublishQueueElements`) produced by + * `PublishQueueElementTransformer`. The transformer emits keys like `type`, `title`, + * `inode`, `content_type_name`, `language_code`, `country_code`, `operation`, `asset`. + * + * This interface narrows that loose map to the fields the UI actually renders. + */ +export interface BundleAssetView { + id: string; + title: string; + type: string; + state?: string; + inode?: string; + contentTypeName?: string; + languageCode?: string; + countryCode?: string; + operation?: number; +} diff --git a/core-web/libs/dotcms-models/src/lib/publishing-job.model.ts b/core-web/libs/dotcms-models/src/lib/publishing-job.model.ts new file mode 100644 index 000000000000..b9ddc9d3d4df --- /dev/null +++ b/core-web/libs/dotcms-models/src/lib/publishing-job.model.ts @@ -0,0 +1,44 @@ +import { PublishAuditStatus } from './publishing-status.model'; + +/** + * Preview of an asset inside a publishing bundle (max 3 per bundle). + * Mirrors `com.dotcms.rest.api.v1.publishing.AbstractAssetPreviewView`. + */ +export interface AssetPreviewView { + id: string; + title: string; + type: string; +} + +/** + * Publishing job combining audit status and bundle metadata. + * Mirrors `com.dotcms.rest.api.v1.publishing.AbstractPublishingJobView`. + * + * Returned by `GET /api/v1/publishing` as the `entity[]` of the envelope. + */ +export interface PublishingJobView { + bundleId: string; + bundleName: string | null; + status: PublishAuditStatus; + filterName: string | null; + filterKey: string | null; + assetCount: number; + assetPreview: AssetPreviewView[]; + environmentCount: number; + createDate: string; + statusUpdated: string | null; + numTries: number; +} + +/** Pagination envelope returned alongside `entity` on `/api/v1/publishing`. */ +export interface PublishingJobsPagination { + currentPage: number; + perPage: number; + totalEntries: number; +} + +/** Full envelope returned by `GET /api/v1/publishing`. */ +export interface PublishingJobsResponse { + entity: PublishingJobView[]; + pagination: PublishingJobsPagination; +} diff --git a/core-web/libs/dotcms-models/src/lib/publishing-status.model.ts b/core-web/libs/dotcms-models/src/lib/publishing-status.model.ts new file mode 100644 index 000000000000..710613061ad3 --- /dev/null +++ b/core-web/libs/dotcms-models/src/lib/publishing-status.model.ts @@ -0,0 +1,41 @@ +/** + * Push-Publish audit status values. + * + * Mirror of `com.dotcms.publisher.business.PublishAuditStatus.Status` (Java enum + * source of truth — see `dotCMS/src/main/java/com/dotcms/publisher/business/PublishAuditStatus.java`). + * If the backend enum changes, update this file in lockstep. + */ +export enum PublishAuditStatus { + BUNDLE_REQUESTED = 'BUNDLE_REQUESTED', + BUNDLING = 'BUNDLING', + SENDING_TO_ENDPOINTS = 'SENDING_TO_ENDPOINTS', + FAILED_TO_SEND_TO_ALL_GROUPS = 'FAILED_TO_SEND_TO_ALL_GROUPS', + FAILED_TO_SEND_TO_SOME_GROUPS = 'FAILED_TO_SEND_TO_SOME_GROUPS', + FAILED_TO_BUNDLE = 'FAILED_TO_BUNDLE', + FAILED_TO_SENT = 'FAILED_TO_SENT', + FAILED_TO_PUBLISH = 'FAILED_TO_PUBLISH', + SUCCESS = 'SUCCESS', + BUNDLE_SENT_SUCCESSFULLY = 'BUNDLE_SENT_SUCCESSFULLY', + RECEIVED_BUNDLE = 'RECEIVED_BUNDLE', + PUBLISHING_BUNDLE = 'PUBLISHING_BUNDLE', + WAITING_FOR_PUBLISHING = 'WAITING_FOR_PUBLISHING', + BUNDLE_SAVED_SUCCESSFULLY = 'BUNDLE_SAVED_SUCCESSFULLY', + INVALID_TOKEN = 'INVALID_TOKEN', + LICENSE_REQUIRED = 'LICENSE_REQUIRED', + SUCCESS_WITH_WARNINGS = 'SUCCESS_WITH_WARNINGS', + FAILED_INTEGRITY_CHECK = 'FAILED_INTEGRITY_CHECK' +} + +/** Bundles authored but not yet sent — populate the Queue tab's READY TO SEND column. */ +export const READY_STATUSES: readonly PublishAuditStatus[] = [ + PublishAuditStatus.WAITING_FOR_PUBLISHING, + PublishAuditStatus.BUNDLE_REQUESTED +] as const; + +/** Bundles being packed or shipped — populate the Queue tab's IN PROGRESS column. */ +export const IN_PROGRESS_STATUSES: readonly PublishAuditStatus[] = [ + PublishAuditStatus.BUNDLING, + PublishAuditStatus.SENDING_TO_ENDPOINTS, + PublishAuditStatus.PUBLISHING_BUNDLE, + PublishAuditStatus.RECEIVED_BUNDLE +] as const; diff --git a/core-web/libs/portlets/dot-publishing-queue/.eslintrc.json b/core-web/libs/portlets/dot-publishing-queue/.eslintrc.json new file mode 100644 index 000000000000..ef536cdfaf37 --- /dev/null +++ b/core-web/libs/portlets/dot-publishing-queue/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["../../../.eslintrc.base.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/core-web/libs/portlets/dot-publishing-queue/jest.config.ts b/core-web/libs/portlets/dot-publishing-queue/jest.config.ts new file mode 100644 index 000000000000..084450cae468 --- /dev/null +++ b/core-web/libs/portlets/dot-publishing-queue/jest.config.ts @@ -0,0 +1,21 @@ +export default { + displayName: 'portlets-dot-publishing-queue-portlet', + preset: '../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../../coverage/libs/portlets/dot-publishing-queue', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$' + } + ] + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment' + ] +}; diff --git a/core-web/libs/portlets/dot-publishing-queue/project.json b/core-web/libs/portlets/dot-publishing-queue/project.json new file mode 100644 index 000000000000..2bc80520bb14 --- /dev/null +++ b/core-web/libs/portlets/dot-publishing-queue/project.json @@ -0,0 +1,21 @@ +{ + "name": "portlets-dot-publishing-queue-portlet", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/portlets/dot-publishing-queue/src", + "prefix": "dot", + "projectType": "library", + "tags": ["type:feature", "scope:dotcms-ui", "portlet:publishing-queue"], + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/portlets/dot-publishing-queue/jest.config.ts", + "tsConfig": "libs/portlets/dot-publishing-queue/tsconfig.spec.json" + } + }, + "lint": { + "executor": "@nx/eslint:lint" + } + } +} diff --git a/core-web/libs/portlets/dot-publishing-queue/src/index.ts b/core-web/libs/portlets/dot-publishing-queue/src/index.ts new file mode 100644 index 000000000000..44c9365302f3 --- /dev/null +++ b/core-web/libs/portlets/dot-publishing-queue/src/index.ts @@ -0,0 +1 @@ +export * from './lib/lib.routes'; 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 new file mode 100644 index 000000000000..d57246ad5b6c --- /dev/null +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-toolbar/dot-publishing-queue-toolbar.component.html @@ -0,0 +1,40 @@ + +
+ + + + + +
+ + + +
+
+
diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-toolbar/dot-publishing-queue-toolbar.component.spec.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-toolbar/dot-publishing-queue-toolbar.component.spec.ts new file mode 100644 index 000000000000..cdeb7cfa7ae7 --- /dev/null +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-toolbar/dot-publishing-queue-toolbar.component.spec.ts @@ -0,0 +1,103 @@ +import { byTestId, createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; + +import { DotMessageService } from '@dotcms/data-access'; +import { MockDotMessageService } from '@dotcms/utils-testing'; + +import { DotPublishingQueueToolbarComponent } from './dot-publishing-queue-toolbar.component'; + +import { DotPublishingQueueStore } from '../../dot-publishing-queue-page/store/dot-publishing-queue.store'; + +describe('DotPublishingQueueToolbarComponent', () => { + let spectator: Spectator; + let store: InstanceType; + + const createComponent = createComponentFactory({ + component: DotPublishingQueueToolbarComponent, + componentProviders: [ + mockProvider(DotPublishingQueueStore, { + search: jest.fn().mockReturnValue(''), + setSearch: jest.fn(), + refresh: jest.fn() + }) + ], + providers: [ + { + provide: DotMessageService, + useValue: new MockDotMessageService({ + 'publishing-queue.search.placeholder': 'Search bundles', + 'publishing-queue.refresh': 'Refresh', + 'publishing-queue.upload-bundle': 'Upload Bundle', + 'publishing-queue.upload-bundle.coming-soon': 'Coming soon', + 'publishing-queue.site-selector.placeholder': 'Site', + 'publishing-queue.site-selector.coming-soon': 'Coming soon' + }) + } + ] + }); + + beforeEach(() => { + jest.useFakeTimers(); + spectator = createComponent(); + store = spectator.inject(DotPublishingQueueStore, true); + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('layout', () => { + it('renders search, refresh, upload (disabled), site selector (disabled)', () => { + expect(spectator.query(byTestId('pq-search-input'))).toBeTruthy(); + expect(spectator.query(byTestId('pq-refresh-btn'))).toBeTruthy(); + expect(spectator.query(byTestId('pq-upload-btn'))).toBeTruthy(); + expect(spectator.query(byTestId('pq-site-selector'))).toBeTruthy(); + }); + + it('upload button is disabled', () => { + const uploadBtn = spectator.query(byTestId('pq-upload-btn'))?.querySelector('button'); + expect(uploadBtn?.disabled).toBe(true); + }); + }); + + describe('search debounce', () => { + it('calls store.setSearch only after 300ms', () => { + spectator.component.onSearch('hello'); + jest.advanceTimersByTime(299); + expect(store.setSearch).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(1); + expect(store.setSearch).toHaveBeenCalledWith('hello'); + }); + + it('coalesces rapid typing', () => { + spectator.component.onSearch('a'); + jest.advanceTimersByTime(100); + spectator.component.onSearch('ab'); + jest.advanceTimersByTime(100); + spectator.component.onSearch('abc'); + jest.advanceTimersByTime(300); + + expect(store.setSearch).toHaveBeenCalledTimes(1); + expect(store.setSearch).toHaveBeenCalledWith('abc'); + }); + + it('skips duplicate values (distinctUntilChanged)', () => { + spectator.component.onSearch('x'); + jest.advanceTimersByTime(300); + spectator.component.onSearch('x'); + jest.advanceTimersByTime(300); + + expect(store.setSearch).toHaveBeenCalledTimes(1); + }); + }); + + describe('refresh', () => { + it('clicking the refresh button calls store.refresh', () => { + const refreshBtn = spectator.query(byTestId('pq-refresh-btn'))?.querySelector('button'); + expect(refreshBtn).toBeTruthy(); + spectator.click(refreshBtn as HTMLButtonElement); + expect(store.refresh).toHaveBeenCalled(); + }); + }); +}); 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 new file mode 100644 index 000000000000..1afa6410269a --- /dev/null +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-toolbar/dot-publishing-queue-toolbar.component.ts @@ -0,0 +1,53 @@ +import { Subject } from 'rxjs'; + +import { ChangeDetectionStrategy, Component, DestroyRef, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormsModule } from '@angular/forms'; + +import { ButtonModule } from 'primeng/button'; +import { IconFieldModule } from 'primeng/iconfield'; +import { InputIconModule } from 'primeng/inputicon'; +import { InputTextModule } from 'primeng/inputtext'; +import { SelectModule } from 'primeng/select'; +import { ToolbarModule } from 'primeng/toolbar'; +import { TooltipModule } from 'primeng/tooltip'; + +import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; + +import { DotMessagePipe } from '@dotcms/ui'; + +import { DotPublishingQueueStore } from '../../dot-publishing-queue-page/store/dot-publishing-queue.store'; + +@Component({ + selector: 'dot-publishing-queue-toolbar', + standalone: true, + imports: [ + FormsModule, + ButtonModule, + IconFieldModule, + InputIconModule, + InputTextModule, + SelectModule, + ToolbarModule, + TooltipModule, + DotMessagePipe + ], + templateUrl: './dot-publishing-queue-toolbar.component.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DotPublishingQueueToolbarComponent { + readonly store = inject(DotPublishingQueueStore); + + private readonly destroyRef = inject(DestroyRef); + private searchSubject = new Subject(); + + constructor() { + this.searchSubject + .pipe(debounceTime(300), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) + .subscribe((value) => this.store.setSearch(value)); + } + + onSearch(value: string): void { + this.searchSubject.next(value); + } +} 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.html b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-asset-list-dialog/dot-publishing-queue-asset-list-dialog.component.html new file mode 100644 index 000000000000..3067262580de --- /dev/null +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-asset-list-dialog/dot-publishing-queue-asset-list-dialog.component.html @@ -0,0 +1,30 @@ +
+ @if (store.assetListStatus() === 'loading') { +
+ + + +
+ } @else if (store.selectedAssets().length === 0) { +
+ {{ 'publishing-queue.asset-list.empty' | dm }} +
+ } @else { + + + + {{ 'publishing-queue.column.name' | dm }} + {{ 'publishing-queue.column.type' | dm }} + {{ 'publishing-queue.column.state' | dm }} + + + + + {{ asset.title }} + {{ asset.type }} + {{ asset.state || '—' }} + + + + } +
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.spec.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.spec.ts new file mode 100644 index 000000000000..7e6670b553ed --- /dev/null +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-asset-list-dialog/dot-publishing-queue-asset-list-dialog.component.spec.ts @@ -0,0 +1,74 @@ +import { byTestId, createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; + +import { signal } from '@angular/core'; + +import { DotMessageService } from '@dotcms/data-access'; +import { BundleAssetView } from '@dotcms/dotcms-models'; +import { MockDotMessageService } from '@dotcms/utils-testing'; + +import { DotPublishingQueueAssetListDialogComponent } from './dot-publishing-queue-asset-list-dialog.component'; + +import { DotPublishingQueueStore } from '../../dot-publishing-queue-page/store/dot-publishing-queue.store'; + +const ASSETS: BundleAssetView[] = [ + { id: 'a1', title: 'Asset 1', type: 'contentlet', state: 'PUBLISH' }, + { id: 'a2', title: 'Asset 2', type: 'template' } +]; + +describe('DotPublishingQueueAssetListDialogComponent', () => { + let spectator: Spectator; + + const selectedAssets = signal([]); + const assetListStatus = signal<'init' | 'loading' | 'loaded' | 'error'>('loading'); + + const createComponent = createComponentFactory({ + component: DotPublishingQueueAssetListDialogComponent, + providers: [ + mockProvider(DotPublishingQueueStore, { + selectedAssets, + assetListStatus + }), + { + provide: DotMessageService, + useValue: new MockDotMessageService({ + 'publishing-queue.column.name': 'Name', + 'publishing-queue.column.type': 'Type', + 'publishing-queue.column.state': 'State', + 'publishing-queue.asset-list.empty': 'No items' + }) + } + ] + }); + + beforeEach(() => { + selectedAssets.set([]); + assetListStatus.set('loading'); + spectator = createComponent(); + }); + + it('shows loading skeleton when status is loading', () => { + expect(spectator.query(byTestId('pq-asset-list-loading'))).toBeTruthy(); + }); + + it('shows empty state when loaded with no assets', () => { + assetListStatus.set('loaded'); + spectator.detectChanges(); + expect(spectator.query(byTestId('pq-asset-list-empty'))).toBeTruthy(); + }); + + it('renders rows when assets are present', () => { + selectedAssets.set(ASSETS); + assetListStatus.set('loaded'); + spectator.detectChanges(); + const rows = spectator.queryAll(byTestId('pq-asset-list-row')); + expect(rows.length).toBe(2); + }); + + it('renders "—" for missing state', () => { + selectedAssets.set([ASSETS[1]]); + assetListStatus.set('loaded'); + spectator.detectChanges(); + const stateCell = spectator.query(byTestId('pq-asset-state')); + expect(stateCell?.textContent?.trim()).toBe('—'); + }); +}); 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 new file mode 100644 index 000000000000..923556d65b60 --- /dev/null +++ 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 @@ -0,0 +1,19 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; + +import { SkeletonModule } from 'primeng/skeleton'; +import { TableModule } from 'primeng/table'; + +import { DotMessagePipe } from '@dotcms/ui'; + +import { DotPublishingQueueStore } from '../../dot-publishing-queue-page/store/dot-publishing-queue.store'; + +@Component({ + selector: 'dot-publishing-queue-asset-list-dialog', + standalone: true, + imports: [TableModule, SkeletonModule, DotMessagePipe], + templateUrl: './dot-publishing-queue-asset-list-dialog.component.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DotPublishingQueueAssetListDialogComponent { + readonly store = inject(DotPublishingQueueStore); +} diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-list/dot-publishing-queue-list.component.html b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-list/dot-publishing-queue-list.component.html new file mode 100644 index 000000000000..5d1fd0859a6f --- /dev/null +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-list/dot-publishing-queue-list.component.html @@ -0,0 +1,72 @@ +
+
+

+ {{ headerKey() | dm }} + ({{ total() }}) +

+
+ +
+ @if (status() === 'loading' && rows().length === 0) { + @for (_ of skeletonRows; track $index) { +
+ + +
+ } + } @else if (rows().length === 0) { +
+ +

{{ emptyKey() | dm }}

+
+ } @else { + @for (job of rows(); track job.bundleId) { +
+
+ + {{ job.bundleName || job.bundleId }} + + + {{ job.assetCount }} assets · {{ job.environmentCount }} env + +
+ + @if (mode() === 'ready') { + + } +
+ } + } +
+ + @if (total() > rowsPerPage()) { + + } +
diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-list/dot-publishing-queue-list.component.spec.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-list/dot-publishing-queue-list.component.spec.ts new file mode 100644 index 000000000000..232725165b60 --- /dev/null +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-list/dot-publishing-queue-list.component.spec.ts @@ -0,0 +1,152 @@ +import { byTestId, createComponentFactory, Spectator } from '@ngneat/spectator/jest'; + +import { DotMessageService } from '@dotcms/data-access'; +import { PublishAuditStatus, PublishingJobView } from '@dotcms/dotcms-models'; +import { MockDotMessageService } from '@dotcms/utils-testing'; + +import { DotPublishingQueueListComponent } from './dot-publishing-queue-list.component'; + +const job = (overrides: Partial = {}): PublishingJobView => ({ + bundleId: 'bundle-1', + bundleName: 'Bundle One', + status: PublishAuditStatus.WAITING_FOR_PUBLISHING, + filterName: null, + filterKey: null, + assetCount: 3, + assetPreview: [], + environmentCount: 2, + createDate: '2026-06-08T10:00:00Z', + statusUpdated: null, + numTries: 0, + ...overrides +}); + +describe('DotPublishingQueueListComponent', () => { + let spectator: Spectator; + + const createComponent = createComponentFactory({ + component: DotPublishingQueueListComponent, + providers: [{ provide: DotMessageService, useValue: new MockDotMessageService({}) }], + detectChanges: false + }); + + const defaultInputs = { + mode: 'ready' as const, + rows: [job()], + status: 'loaded' as const, + total: 1, + page: 1, + rowsPerPage: 10, + headerKey: 'publishing-queue.ready.title', + emptyKey: 'publishing-queue.empty.ready' + }; + + beforeEach(() => { + spectator = createComponent({ props: defaultInputs }); + spectator.detectChanges(); + }); + + it('renders the header with the count', () => { + const header = spectator.query(byTestId('pq-list-header')); + expect(header?.textContent).toContain('(1)'); + }); + + it('renders a row per job', () => { + const rows = spectator.queryAll(byTestId('pq-list-row')); + expect(rows.length).toBe(1); + }); + + it('renders the Send button in ready mode (disabled)', () => { + const sendBtn = spectator.query(byTestId('pq-row-send-btn'))?.querySelector('button'); + expect(sendBtn).toBeTruthy(); + expect(sendBtn?.disabled).toBe(true); + }); + + it('hides the Send button in progress mode', () => { + spectator.setInput('mode', 'progress'); + spectator.setInput('rows', [job({ status: PublishAuditStatus.BUNDLING })]); + spectator.detectChanges(); + expect(spectator.query(byTestId('pq-row-send-btn'))).toBeFalsy(); + }); + + it('shows skeletons while loading and no rows yet', () => { + spectator.setInput('rows', []); + spectator.setInput('status', 'loading'); + spectator.detectChanges(); + expect(spectator.queryAll(byTestId('pq-list-skeleton')).length).toBeGreaterThan(0); + }); + + it('shows empty state when not loading and zero rows', () => { + spectator.setInput('rows', []); + spectator.setInput('status', 'loaded'); + spectator.setInput('total', 0); + spectator.detectChanges(); + expect(spectator.query(byTestId('pq-list-empty'))).toBeTruthy(); + }); + + it('emits rowClick on row click', () => { + let emitted: PublishingJobView | undefined; + spectator.output('rowClick').subscribe((j) => (emitted = j as PublishingJobView)); + + const row = spectator.query(byTestId('pq-list-row')); + spectator.click(row as HTMLElement); + + expect(emitted?.bundleId).toBe('bundle-1'); + }); + + it('emits rowClick on Enter keydown', () => { + let emitted: PublishingJobView | undefined; + spectator.output('rowClick').subscribe((j) => (emitted = j as PublishingJobView)); + + const row = spectator.query(byTestId('pq-list-row')); + spectator.dispatchKeyboardEvent(row as HTMLElement, 'keydown', 'Enter'); + + expect(emitted?.bundleId).toBe('bundle-1'); + }); + + describe('statusSeverity', () => { + it('returns success for SUCCESS', () => { + expect(spectator.component.statusSeverity(PublishAuditStatus.SUCCESS)).toBe('success'); + }); + + it('returns danger for FAILED_TO_PUBLISH', () => { + expect(spectator.component.statusSeverity(PublishAuditStatus.FAILED_TO_PUBLISH)).toBe( + 'danger' + ); + }); + + it('returns info for WAITING_FOR_PUBLISHING', () => { + expect( + spectator.component.statusSeverity(PublishAuditStatus.WAITING_FOR_PUBLISHING) + ).toBe('info'); + }); + + it('returns warn for in-progress like BUNDLING', () => { + expect(spectator.component.statusSeverity(PublishAuditStatus.BUNDLING)).toBe('warn'); + }); + }); + + describe('pagination', () => { + it('shows paginator only when total exceeds page size', () => { + spectator.setInput('total', 5); + spectator.setInput('rowsPerPage', 10); + spectator.detectChanges(); + expect(spectator.query(byTestId('pq-list-paginator'))).toBeFalsy(); + + spectator.setInput('total', 30); + spectator.detectChanges(); + expect(spectator.query(byTestId('pq-list-paginator'))).toBeTruthy(); + }); + + it('emits pageChange with 1-based page when paginator fires', () => { + spectator.setInput('total', 100); + spectator.detectChanges(); + let emitted = 0; + spectator.output('pageChange').subscribe((p) => (emitted = p as number)); + + spectator.component.onPaginate({ first: 20, rows: 10, page: 2, pageCount: 10 }); + + expect(emitted).toBe(3); + }); + }); +}); diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-list/dot-publishing-queue-list.component.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-list/dot-publishing-queue-list.component.ts new file mode 100644 index 000000000000..075556a700cd --- /dev/null +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-list/dot-publishing-queue-list.component.ts @@ -0,0 +1,93 @@ +import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; + +import { ButtonModule } from 'primeng/button'; +import { PaginatorModule, PaginatorState } from 'primeng/paginator'; +import { SkeletonModule } from 'primeng/skeleton'; +import { TagModule } from 'primeng/tag'; + +import { PublishAuditStatus, PublishingJobView } from '@dotcms/dotcms-models'; +import { DotMessagePipe } from '@dotcms/ui'; + +type LoadStatus = 'init' | 'loading' | 'loaded' | 'error'; +type Mode = 'ready' | 'progress'; +type ChipSeverity = 'success' | 'info' | 'warn' | 'danger' | 'secondary'; + +const SUCCESS_STATUSES = new Set([ + PublishAuditStatus.SUCCESS, + PublishAuditStatus.BUNDLE_SENT_SUCCESSFULLY, + PublishAuditStatus.BUNDLE_SAVED_SUCCESSFULLY, + PublishAuditStatus.SUCCESS_WITH_WARNINGS +]); + +const FAILURE_STATUSES = new Set([ + PublishAuditStatus.FAILED_TO_SEND_TO_ALL_GROUPS, + PublishAuditStatus.FAILED_TO_SEND_TO_SOME_GROUPS, + PublishAuditStatus.FAILED_TO_BUNDLE, + PublishAuditStatus.FAILED_TO_SENT, + PublishAuditStatus.FAILED_TO_PUBLISH, + PublishAuditStatus.FAILED_INTEGRITY_CHECK, + PublishAuditStatus.INVALID_TOKEN, + PublishAuditStatus.LICENSE_REQUIRED +]); + +const READY_STATUSES_SET = new Set([ + PublishAuditStatus.BUNDLE_REQUESTED, + PublishAuditStatus.WAITING_FOR_PUBLISHING +]); + +@Component({ + selector: 'dot-publishing-queue-list', + standalone: true, + imports: [ButtonModule, PaginatorModule, SkeletonModule, TagModule, DotMessagePipe], + templateUrl: './dot-publishing-queue-list.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'flex flex-col min-h-0 h-full' } +}) +export class DotPublishingQueueListComponent { + readonly mode = input.required(); + readonly rows = input.required(); + readonly status = input.required(); + readonly total = input.required(); + readonly page = input.required(); + readonly rowsPerPage = input.required(); + readonly headerKey = input.required(); + readonly emptyKey = input.required(); + + readonly rowClick = output(); + readonly pageChange = output(); + + readonly first = computed(() => (this.page() - 1) * this.rowsPerPage()); + + readonly skeletonRows = Array.from({ length: 5 }); + + statusSeverity(status: PublishAuditStatus): ChipSeverity { + if (SUCCESS_STATUSES.has(status)) { + return 'success'; + } + if (FAILURE_STATUSES.has(status)) { + return 'danger'; + } + if (READY_STATUSES_SET.has(status)) { + return 'info'; + } + return 'warn'; + } + + statusLabelKey(status: PublishAuditStatus): string { + return `publishing-queue.status.${status}`; + } + + onRowKeyDown(event: KeyboardEvent, job: PublishingJobView): void { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + this.rowClick.emit(job); + } + } + + onPaginate(event: PaginatorState): void { + const newRows = event.rows ?? this.rowsPerPage(); + const newFirst = event.first ?? 0; + const newPage = Math.floor(newFirst / newRows) + 1; + this.pageChange.emit(newPage); + } +} diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/dot-publishing-queue-page.component.html b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/dot-publishing-queue-page.component.html new file mode 100644 index 000000000000..b5c13adf5395 --- /dev/null +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/dot-publishing-queue-page.component.html @@ -0,0 +1,26 @@ +
+ + +
diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/dot-publishing-queue-page.component.spec.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/dot-publishing-queue-page.component.spec.ts new file mode 100644 index 000000000000..bbba13c7d946 --- /dev/null +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/dot-publishing-queue-page.component.spec.ts @@ -0,0 +1,103 @@ +import { byTestId, createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; +import { Subject } from 'rxjs'; + +import { signal } from '@angular/core'; + +import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog'; + +import { DotMessageService } from '@dotcms/data-access'; +import { MockDotMessageService } from '@dotcms/utils-testing'; + +import { DotPublishingQueuePageComponent } from './dot-publishing-queue-page.component'; +import { DotPublishingQueueStore } from './store/dot-publishing-queue.store'; + +describe('DotPublishingQueuePageComponent', () => { + let spectator: Spectator; + let dialogService: jest.Mocked; + let store: InstanceType; + + const selectedBundleId = signal(null); + const onCloseSubject = new Subject(); + const dialogRefStub = { + close: jest.fn(), + onClose: onCloseSubject + } as unknown as DynamicDialogRef; + + const createComponent = createComponentFactory({ + component: DotPublishingQueuePageComponent, + componentProviders: [ + mockProvider(DotPublishingQueueStore, { + readyRows: jest.fn().mockReturnValue([]), + readyStatus: jest.fn().mockReturnValue('loaded'), + readyTotal: jest.fn().mockReturnValue(0), + readyPage: jest.fn().mockReturnValue(1), + rowsPerPage: jest.fn().mockReturnValue(10), + progressRows: jest.fn().mockReturnValue([]), + progressStatus: jest.fn().mockReturnValue('loaded'), + progressTotal: jest.fn().mockReturnValue(0), + progressPage: jest.fn().mockReturnValue(1), + selectedBundleId: selectedBundleId, + openAssetList: jest.fn(), + setReadyPage: jest.fn(), + setProgressPage: jest.fn(), + closeAssetList: jest.fn() + }) + ], + providers: [ + mockProvider(DialogService, { open: jest.fn().mockReturnValue(dialogRefStub) }), + { + provide: DotMessageService, + useValue: new MockDotMessageService({ + 'publishing-queue.asset-list.title': 'Bundle Assets', + 'publishing-queue.ready.title': 'Ready', + 'publishing-queue.in-progress.title': 'In Progress', + 'publishing-queue.empty.ready': 'Empty', + 'publishing-queue.empty.in-progress': 'Nothing in progress' + }) + } + ] + }); + + beforeEach(() => { + selectedBundleId.set(null); + spectator = createComponent(); + dialogService = spectator.inject(DialogService, true) as jest.Mocked; + store = spectator.inject(DotPublishingQueueStore, true); + jest.clearAllMocks(); + (dialogRefStub.close as jest.Mock).mockClear(); + }); + + it('renders both ready and progress list slots', () => { + expect(spectator.query(byTestId('pq-ready-list'))).toBeTruthy(); + expect(spectator.query(byTestId('pq-progress-list'))).toBeTruthy(); + }); + + it('opens dialog when selectedBundleId becomes set', () => { + selectedBundleId.set('bundle-7'); + spectator.detectChanges(); + + expect(dialogService.open).toHaveBeenCalled(); + const config = (dialogService.open as jest.Mock).mock.calls[0][1]; + expect(config.width).toBe('700px'); + expect(config.closable).toBe(true); + expect(config.closeOnEscape).toBe(true); + }); + + it('calls store.closeAssetList when dialog emits onClose', () => { + selectedBundleId.set('bundle-7'); + spectator.detectChanges(); + + onCloseSubject.next(undefined); + + expect(store.closeAssetList).toHaveBeenCalled(); + }); + + it('closes the open dialog when selectedBundleId becomes null', () => { + selectedBundleId.set('bundle-7'); + spectator.detectChanges(); + selectedBundleId.set(null); + spectator.detectChanges(); + + expect(dialogRefStub.close).toHaveBeenCalled(); + }); +}); diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/dot-publishing-queue-page.component.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/dot-publishing-queue-page.component.ts new file mode 100644 index 000000000000..a443cf88c351 --- /dev/null +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/dot-publishing-queue-page.component.ts @@ -0,0 +1,58 @@ +import { ChangeDetectionStrategy, Component, effect, inject, untracked } from '@angular/core'; + +import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog'; + +import { take } from 'rxjs/operators'; + +import { DotMessageService } from '@dotcms/data-access'; + +import { DotPublishingQueueStore } from './store/dot-publishing-queue.store'; + +import { DotPublishingQueueAssetListDialogComponent } from '../dialogs/dot-publishing-queue-asset-list-dialog/dot-publishing-queue-asset-list-dialog.component'; +import { DotPublishingQueueListComponent } from '../dot-publishing-queue-list/dot-publishing-queue-list.component'; + +@Component({ + selector: 'dot-publishing-queue-page', + standalone: true, + imports: [DotPublishingQueueListComponent], + templateUrl: './dot-publishing-queue-page.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'flex min-h-0 flex-1' } +}) +export class DotPublishingQueuePageComponent { + readonly store = inject(DotPublishingQueueStore); + + private readonly dialogService = inject(DialogService); + private readonly dotMessageService = inject(DotMessageService); + private dialogRef: DynamicDialogRef | null = null; + + constructor() { + effect(() => { + const bundleId = this.store.selectedBundleId(); + untracked(() => { + if (bundleId && !this.dialogRef) { + this.openAssetListDialog(); + } else if (!bundleId && this.dialogRef) { + this.dialogRef.close(); + this.dialogRef = null; + } + }); + }); + } + + private openAssetListDialog(): void { + this.dialogRef = this.dialogService.open(DotPublishingQueueAssetListDialogComponent, { + header: this.dotMessageService.get('publishing-queue.asset-list.title'), + width: '700px', + closable: true, + closeOnEscape: true, + draggable: false, + position: 'center' + }); + + this.dialogRef.onClose.pipe(take(1)).subscribe(() => { + this.dialogRef = null; + this.store.closeAssetList(); + }); + } +} diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/store/dot-publishing-queue.store.spec.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/store/dot-publishing-queue.store.spec.ts new file mode 100644 index 000000000000..5534513a4574 --- /dev/null +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/store/dot-publishing-queue.store.spec.ts @@ -0,0 +1,219 @@ +import { createServiceFactory, mockProvider, SpectatorService } from '@ngneat/spectator/jest'; +import { of, throwError } from 'rxjs'; + +import { DotHttpErrorManagerService, DotPublishingQueueService } from '@dotcms/data-access'; +import { + BundleAssetView, + IN_PROGRESS_STATUSES, + PublishAuditStatus, + PublishingJobsResponse, + PublishingJobView, + READY_STATUSES +} from '@dotcms/dotcms-models'; + +import { DotPublishingQueueStore } from './dot-publishing-queue.store'; + +const buildJob = (overrides: Partial = {}): PublishingJobView => ({ + bundleId: 'bundle-1', + bundleName: 'Bundle One', + status: PublishAuditStatus.WAITING_FOR_PUBLISHING, + filterName: null, + filterKey: null, + assetCount: 5, + assetPreview: [], + environmentCount: 1, + createDate: '2026-06-08T10:00:00Z', + statusUpdated: null, + numTries: 0, + ...overrides +}); + +const READY_RESPONSE: PublishingJobsResponse = { + entity: [buildJob({ bundleId: 'ready-1' })], + pagination: { currentPage: 1, perPage: 10, totalEntries: 1 } +}; + +const PROGRESS_RESPONSE: PublishingJobsResponse = { + entity: [ + buildJob({ bundleId: 'progress-1', status: PublishAuditStatus.BUNDLING }), + buildJob({ bundleId: 'progress-2', status: PublishAuditStatus.SENDING_TO_ENDPOINTS }) + ], + pagination: { currentPage: 1, perPage: 10, totalEntries: 2 } +}; + +const MOCK_ASSETS: BundleAssetView[] = [ + { id: 'a1', title: 'Asset 1', type: 'contentlet' }, + { id: 'a2', title: 'Asset 2', type: 'template' } +]; + +describe('DotPublishingQueueStore', () => { + let spectator: SpectatorService>; + let store: InstanceType; + let service: jest.Mocked; + let httpErrorManager: jest.Mocked; + + const createService = createServiceFactory({ + service: DotPublishingQueueStore, + providers: [ + mockProvider(DotPublishingQueueService, { + listPublishingJobs: jest + .fn() + .mockImplementation(({ statuses }) => + of(statuses === READY_STATUSES ? READY_RESPONSE : PROGRESS_RESPONSE) + ), + getBundleAssets: jest.fn().mockReturnValue(of(MOCK_ASSETS)) + }), + mockProvider(DotHttpErrorManagerService) + ] + }); + + beforeEach(() => { + spectator = createService(); + store = spectator.service; + service = spectator.inject( + DotPublishingQueueService + ) as jest.Mocked; + httpErrorManager = spectator.inject( + DotHttpErrorManagerService + ) as jest.Mocked; + // onInit effect kicks off loadReady + loadProgress + spectator.flushEffects(); + }); + + describe('onInit', () => { + it('loads ready + progress columns on init', () => { + expect(service.listPublishingJobs).toHaveBeenCalledTimes(2); + expect(service.listPublishingJobs).toHaveBeenCalledWith({ + statuses: READY_STATUSES, + page: 1, + perPage: 10, + filter: undefined + }); + expect(service.listPublishingJobs).toHaveBeenCalledWith({ + statuses: IN_PROGRESS_STATUSES, + page: 1, + perPage: 10, + filter: undefined + }); + expect(store.readyRows()).toEqual(READY_RESPONSE.entity); + expect(store.readyTotal()).toBe(1); + expect(store.readyStatus()).toBe('loaded'); + expect(store.progressRows()).toEqual(PROGRESS_RESPONSE.entity); + expect(store.progressTotal()).toBe(2); + expect(store.progressStatus()).toBe('loaded'); + }); + }); + + describe('setSearch', () => { + it('updates search, resets both pages to 1, and triggers reload', () => { + store.setReadyPage(3); + store.setProgressPage(2); + spectator.flushEffects(); + (service.listPublishingJobs as jest.Mock).mockClear(); + + store.setSearch('bundle-name'); + spectator.flushEffects(); + + expect(store.search()).toBe('bundle-name'); + expect(store.readyPage()).toBe(1); + expect(store.progressPage()).toBe(1); + expect(service.listPublishingJobs).toHaveBeenCalledWith( + expect.objectContaining({ filter: 'bundle-name' }) + ); + }); + }); + + describe('setReadyPage / setProgressPage', () => { + it('reloads ready when ready page changes (not progress)', () => { + (service.listPublishingJobs as jest.Mock).mockClear(); + + store.setReadyPage(2); + spectator.flushEffects(); + + // Effect re-runs both because it reads multiple signals; + // the assertion verifies the new page param was forwarded. + expect(service.listPublishingJobs).toHaveBeenCalledWith( + expect.objectContaining({ statuses: READY_STATUSES, page: 2 }) + ); + }); + + it('reloads progress when progress page changes', () => { + (service.listPublishingJobs as jest.Mock).mockClear(); + + store.setProgressPage(4); + spectator.flushEffects(); + + expect(service.listPublishingJobs).toHaveBeenCalledWith( + expect.objectContaining({ statuses: IN_PROGRESS_STATUSES, page: 4 }) + ); + }); + }); + + describe('refresh', () => { + it('re-fires both list calls', () => { + (service.listPublishingJobs as jest.Mock).mockClear(); + + store.refresh(); + + expect(service.listPublishingJobs).toHaveBeenCalledTimes(2); + }); + }); + + describe('openAssetList / loadAssets / closeAssetList', () => { + it('opens, sets selectedBundleId, and loads assets', () => { + store.openAssetList('bundle-X'); + + expect(store.selectedBundleId()).toBe('bundle-X'); + expect(service.getBundleAssets).toHaveBeenCalledWith('bundle-X'); + expect(store.selectedAssets()).toEqual(MOCK_ASSETS); + expect(store.assetListStatus()).toBe('loaded'); + }); + + it('closeAssetList clears state', () => { + store.openAssetList('bundle-X'); + store.closeAssetList(); + + expect(store.selectedBundleId()).toBeNull(); + expect(store.selectedAssets()).toEqual([]); + expect(store.assetListStatus()).toBe('init'); + }); + + it('loadAssets is a no-op when no bundle is selected', () => { + (service.getBundleAssets as jest.Mock).mockClear(); + store.loadAssets(); + expect(service.getBundleAssets).not.toHaveBeenCalled(); + }); + }); + + describe('error handling', () => { + it('loadReady error → httpErrorManager.handle called, status = error', () => { + const error = new Error('boom'); + (service.listPublishingJobs as jest.Mock).mockReturnValueOnce(throwError(() => error)); + + store.loadReady(); + + expect(httpErrorManager.handle).toHaveBeenCalledWith(error); + expect(store.readyStatus()).toBe('error'); + }); + + it('loadProgress error → httpErrorManager.handle called, status = error', () => { + const error = new Error('boom'); + (service.listPublishingJobs as jest.Mock).mockReturnValueOnce(throwError(() => error)); + + store.loadProgress(); + + expect(httpErrorManager.handle).toHaveBeenCalledWith(error); + expect(store.progressStatus()).toBe('error'); + }); + + it('loadAssets error → httpErrorManager.handle called, status = loaded', () => { + const error = new Error('boom'); + (service.getBundleAssets as jest.Mock).mockReturnValueOnce(throwError(() => error)); + + store.openAssetList('bundle-Y'); + + expect(httpErrorManager.handle).toHaveBeenCalledWith(error); + expect(store.assetListStatus()).toBe('loaded'); + }); + }); +}); diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/store/dot-publishing-queue.store.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/store/dot-publishing-queue.store.ts new file mode 100644 index 000000000000..3fe59c53e1c4 --- /dev/null +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/store/dot-publishing-queue.store.ts @@ -0,0 +1,198 @@ +import { patchState, signalStore, withHooks, withMethods, withState } from '@ngrx/signals'; +import { EMPTY } from 'rxjs'; + +import { effect, inject, untracked } from '@angular/core'; + +import { catchError, take } from 'rxjs/operators'; + +import { DotHttpErrorManagerService, DotPublishingQueueService } from '@dotcms/data-access'; +import { + BundleAssetView, + IN_PROGRESS_STATUSES, + PublishingJobView, + READY_STATUSES +} from '@dotcms/dotcms-models'; + +type LoadStatus = 'init' | 'loading' | 'loaded' | 'error'; + +interface DotPublishingQueueState { + readyRows: PublishingJobView[]; + readyPage: number; + readyTotal: number; + readyStatus: LoadStatus; + + progressRows: PublishingJobView[]; + progressPage: number; + progressTotal: number; + progressStatus: LoadStatus; + + rowsPerPage: number; + search: string; + + selectedBundleId: string | null; + selectedAssets: BundleAssetView[]; + assetListStatus: LoadStatus; +} + +const initialState: DotPublishingQueueState = { + readyRows: [], + readyPage: 1, + readyTotal: 0, + readyStatus: 'init', + + progressRows: [], + progressPage: 1, + progressTotal: 0, + progressStatus: 'init', + + rowsPerPage: 10, + search: '', + + selectedBundleId: null, + selectedAssets: [], + assetListStatus: 'init' +}; + +export const DotPublishingQueueStore = signalStore( + withState(initialState), + withMethods((store) => { + const service = inject(DotPublishingQueueService); + const httpErrorManager = inject(DotHttpErrorManagerService); + + function loadReady() { + patchState(store, { readyStatus: 'loading' }); + service + .listPublishingJobs({ + statuses: READY_STATUSES, + page: store.readyPage(), + perPage: store.rowsPerPage(), + filter: store.search() || undefined + }) + .pipe( + take(1), + catchError((error) => { + httpErrorManager.handle(error); + patchState(store, { readyStatus: 'error' }); + + return EMPTY; + }) + ) + .subscribe((response) => { + patchState(store, { + readyRows: response.entity, + readyTotal: response.pagination?.totalEntries ?? 0, + readyStatus: 'loaded' + }); + }); + } + + function loadProgress() { + patchState(store, { progressStatus: 'loading' }); + service + .listPublishingJobs({ + statuses: IN_PROGRESS_STATUSES, + page: store.progressPage(), + perPage: store.rowsPerPage(), + filter: store.search() || undefined + }) + .pipe( + take(1), + catchError((error) => { + httpErrorManager.handle(error); + patchState(store, { progressStatus: 'error' }); + + return EMPTY; + }) + ) + .subscribe((response) => { + patchState(store, { + progressRows: response.entity, + progressTotal: response.pagination?.totalEntries ?? 0, + progressStatus: 'loaded' + }); + }); + } + + function loadAssets() { + const bundleId = store.selectedBundleId(); + if (!bundleId) { + return; + } + + patchState(store, { assetListStatus: 'loading', selectedAssets: [] }); + service + .getBundleAssets(bundleId) + .pipe( + take(1), + catchError((error) => { + httpErrorManager.handle(error); + patchState(store, { assetListStatus: 'loaded' }); + + return EMPTY; + }) + ) + .subscribe((assets) => { + patchState(store, { + selectedAssets: assets, + assetListStatus: 'loaded' + }); + }); + } + + return { + loadReady, + loadProgress, + loadAssets, + + setSearch(search: string) { + patchState(store, { search, readyPage: 1, progressPage: 1 }); + }, + + setReadyPage(page: number) { + patchState(store, { readyPage: page }); + }, + + setProgressPage(page: number) { + patchState(store, { progressPage: page }); + }, + + refresh() { + loadReady(); + loadProgress(); + }, + + openAssetList(bundleId: string) { + patchState(store, { + selectedBundleId: bundleId, + selectedAssets: [], + assetListStatus: 'init' + }); + loadAssets(); + }, + + closeAssetList() { + patchState(store, { + selectedBundleId: null, + selectedAssets: [], + assetListStatus: 'init' + }); + } + }; + }), + withHooks((store) => { + return { + onInit() { + effect(() => { + store.search(); + store.readyPage(); + store.progressPage(); + + untracked(() => { + store.loadReady(); + store.loadProgress(); + }); + }); + } + }; + }) +); 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 new file mode 100644 index 000000000000..675beb6433ee --- /dev/null +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.spec.ts @@ -0,0 +1,46 @@ +import { createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; +import { of } from 'rxjs'; + +import { provideHttpClient } from '@angular/common/http'; + +import { + DotHttpErrorManagerService, + DotMessageService, + DotPublishingQueueService +} from '@dotcms/data-access'; +import { MockDotMessageService } from '@dotcms/utils-testing'; + +import { DotPublishingQueueShellComponent } from './dot-publishing-queue-shell.component'; + +describe('DotPublishingQueueShellComponent', () => { + let spectator: Spectator; + + const createComponent = createComponentFactory({ + component: DotPublishingQueueShellComponent, + providers: [ + provideHttpClient(), + mockProvider(DotPublishingQueueService, { + listPublishingJobs: jest + .fn() + .mockReturnValue( + of({ + entity: [], + pagination: { currentPage: 1, perPage: 10, totalEntries: 0 } + }) + ), + getBundleAssets: jest.fn().mockReturnValue(of([])) + }), + mockProvider(DotHttpErrorManagerService), + { provide: DotMessageService, useValue: new MockDotMessageService({}) } + ] + }); + + beforeEach(() => { + spectator = createComponent(); + }); + + it('renders toolbar and page', () => { + expect(spectator.query('dot-publishing-queue-toolbar')).toBeTruthy(); + expect(spectator.query('dot-publishing-queue-page')).toBeTruthy(); + }); +}); diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.ts new file mode 100644 index 000000000000..4857987caed8 --- /dev/null +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.ts @@ -0,0 +1,21 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; + +import { DialogService } from 'primeng/dynamicdialog'; + +import { DotPublishingQueueToolbarComponent } from '../components/dot-publishing-queue-toolbar/dot-publishing-queue-toolbar.component'; +import { DotPublishingQueuePageComponent } from '../dot-publishing-queue-page/dot-publishing-queue-page.component'; +import { DotPublishingQueueStore } from '../dot-publishing-queue-page/store/dot-publishing-queue.store'; + +@Component({ + selector: 'dot-publishing-queue-shell', + standalone: true, + imports: [DotPublishingQueueToolbarComponent, DotPublishingQueuePageComponent], + providers: [DotPublishingQueueStore, DialogService], + template: ` + + + `, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'flex flex-col h-full min-h-0 block' } +}) +export class DotPublishingQueueShellComponent {} diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/lib.routes.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/lib.routes.ts new file mode 100644 index 000000000000..b72f2ffb9c0b --- /dev/null +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/lib.routes.ts @@ -0,0 +1,10 @@ +import { Route } from '@angular/router'; + +import { DotPublishingQueueShellComponent } from './dot-publishing-queue-shell/dot-publishing-queue-shell.component'; + +export const dotPublishingQueueRoutes: Route[] = [ + { + path: '', + component: DotPublishingQueueShellComponent + } +]; diff --git a/core-web/libs/portlets/dot-publishing-queue/src/test-setup.ts b/core-web/libs/portlets/dot-publishing-queue/src/test-setup.ts new file mode 100644 index 000000000000..b13563bb93c0 --- /dev/null +++ b/core-web/libs/portlets/dot-publishing-queue/src/test-setup.ts @@ -0,0 +1,6 @@ +import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone'; + +setupZoneTestEnv({ + errorOnUnknownElements: true, + errorOnUnknownProperties: true +}); diff --git a/core-web/libs/portlets/dot-publishing-queue/tsconfig.json b/core-web/libs/portlets/dot-publishing-queue/tsconfig.json new file mode 100644 index 000000000000..8e2e06fb8ff3 --- /dev/null +++ b/core-web/libs/portlets/dot-publishing-queue/tsconfig.json @@ -0,0 +1,31 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "target": "es2022", + "moduleResolution": "bundler", + "useDefineForClassFields": false, + "forceConsistentCasingInFileNames": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "module": "preserve", + "lib": ["dom", "dom.iterable", "es2022"] + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "typeCheckHostBindings": true, + "strictTemplates": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/core-web/libs/portlets/dot-publishing-queue/tsconfig.lib.json b/core-web/libs/portlets/dot-publishing-queue/tsconfig.lib.json new file mode 100644 index 000000000000..a9e4700b8708 --- /dev/null +++ b/core-web/libs/portlets/dot-publishing-queue/tsconfig.lib.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [], + "moduleResolution": "bundler" + }, + "exclude": ["src/**/*.spec.ts", "src/test-setup.ts", "jest.config.ts", "src/**/*.test.ts"], + "include": ["src/**/*.ts"] +} diff --git a/core-web/libs/portlets/dot-publishing-queue/tsconfig.spec.json b/core-web/libs/portlets/dot-publishing-queue/tsconfig.spec.json new file mode 100644 index 000000000000..48633a8d638e --- /dev/null +++ b/core-web/libs/portlets/dot-publishing-queue/tsconfig.spec.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "module": "commonjs", + "target": "es2016", + "types": ["jest", "node"], + "moduleResolution": "bundler", + "isolatedModules": true + }, + "files": ["src/test-setup.ts"], + "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/core-web/package.json b/core-web/package.json index 2cea242d7a7a..b4fbd85a45e9 100644 --- a/core-web/package.json +++ b/core-web/package.json @@ -213,6 +213,7 @@ "@stylistic/eslint-plugin": "5.2.2", "@swc-node/register": "1.11.1", "@swc/core": "1.15.8", + "@swc/helpers": "~0.5.18", "@testing-library/jest-dom": "6.6.3", "@testing-library/react": "16.1.0", "@testing-library/react-hooks": "8.0.1", @@ -257,6 +258,7 @@ "jest-html-reporters": "3.1.5", "jest-junit": "16.0.0", "jest-preset-angular": "16.0.0", + "jest-util": "^30.0.2", "jiti": "2.4.2", "jsdom": "28.1.0", "jsonc-eslint-parser": "2.4.0", diff --git a/core-web/tsconfig.base.json b/core-web/tsconfig.base.json index 779e2f34044b..f4fbdc0f60cb 100644 --- a/core-web/tsconfig.base.json +++ b/core-web/tsconfig.base.json @@ -73,6 +73,9 @@ ], "@dotcms/portlets/dot-plugins/portlet": ["libs/portlets/dot-plugins/src/index.ts"], "@dotcms/portlets/dot-es-search/portlet": ["libs/portlets/dot-es-search/src/index.ts"], + "@dotcms/portlets/dot-publishing-queue/portlet": [ + "libs/portlets/dot-publishing-queue/src/index.ts" + ], "@dotcms/portlets/dot-query-tool/portlet": [ "libs/portlets/dot-query-tool/src/index.ts" ], @@ -102,7 +105,8 @@ "@services/*": ["apps/dotcms-ui/src/app/api/services/*"], "@shared/*": ["apps/dotcms-ui/src/app/shared/*"], "@tests/*": ["apps/dotcms-ui/src/app/test/*"], - "sdk-create-app": ["libs/sdk/create-app/src/index.ts"] + "sdk-create-app": ["libs/sdk/create-app/src/index.ts"], + "portlet": ["libs/portlets/dot-publishing-queue/src/index.ts"] } }, "exclude": ["node_modules", "tmp"] diff --git a/core-web/yarn.lock b/core-web/yarn.lock index c41ca078d8ef..f8b7e56748f6 100644 --- a/core-web/yarn.lock +++ b/core-web/yarn.lock @@ -7571,6 +7571,13 @@ dependencies: tslib "^2.8.0" +"@swc/helpers@~0.5.18": + version "0.5.23" + resolved "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz#19287d0d86d962b111376039a50c792902c9a86a" + integrity sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw== + dependencies: + tslib "^2.8.0" + "@swc/types@^0.1.25": version "0.1.26" resolved "https://registry.npmjs.org/@swc/types/-/types-0.1.26.tgz#2a976a1870caef1992316dda1464150ee36968b5" diff --git a/dotCMS/src/main/java/com/dotmarketing/util/PortletID.java b/dotCMS/src/main/java/com/dotmarketing/util/PortletID.java index 9191ba93c09c..f44d2a670799 100644 --- a/dotCMS/src/main/java/com/dotmarketing/util/PortletID.java +++ b/dotCMS/src/main/java/com/dotmarketing/util/PortletID.java @@ -32,7 +32,8 @@ public enum PortletID { MAINTENANCE, MY_ACCOUNT, PERSONAS, - PUBLISHING_QUEUE, + PUBLISHING_QUEUE, + PUBLISHING_QUEUE_LEGACY("publishing-queue-legacy"), QUERY_TOOL, QUERY_TOOL_LEGACY("query-tool-legacy"), TAGS, diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 952cf7c40288..5ccbc9ed8209 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -689,6 +689,7 @@ com.dotcms.repackage.javax.portlet.title.maintenance=Maintenance com.dotcms.repackage.javax.portlet.title.NetworkPortlet=Network com.dotcms.repackage.javax.portlet.title.personas=Personas com.dotcms.repackage.javax.portlet.title.publishing-queue=Publishing Queue +com.dotcms.repackage.javax.portlet.title.publishing-queue-legacy=Publishing Queue Legacy com.dotcms.repackage.javax.portlet.title.query-tool=Query Tool com.dotcms.repackage.javax.portlet.title.query-tool-legacy=Query Tool Legacy com.dotcms.repackage.javax.portlet.title.reports=Reports @@ -3772,6 +3773,40 @@ publisher_Unpushed_Bundles_Upload=Upload Bundle publisher_Unpushed_Bundles=Bundles publisher_upload=Upload Bundle publisher=Publisher +publishing-queue.ready.title=Ready to Send +publishing-queue.in-progress.title=In Progress +publishing-queue.search.placeholder=Search bundles, content, or environments +publishing-queue.refresh=Refresh +publishing-queue.upload-bundle=Upload Bundle +publishing-queue.upload-bundle.coming-soon=Coming soon +publishing-queue.site-selector.placeholder=Site +publishing-queue.site-selector.coming-soon=Site filter coming soon +publishing-queue.column.name=Name +publishing-queue.column.type=Type +publishing-queue.column.state=State +publishing-queue.empty.ready=Your bundle's empty. Add content and it'll be ready to send. +publishing-queue.empty.in-progress=Nothing in progress. +publishing-queue.asset-list.title=Bundle Assets +publishing-queue.asset-list.empty=No items in this bundle. +publishing-queue.send=Send +publishing-queue.status.BUNDLE_REQUESTED=Requested +publishing-queue.status.BUNDLING=Bundling +publishing-queue.status.SENDING_TO_ENDPOINTS=Sending +publishing-queue.status.FAILED_TO_SEND_TO_ALL_GROUPS=Failed (all) +publishing-queue.status.FAILED_TO_SEND_TO_SOME_GROUPS=Failed (some) +publishing-queue.status.FAILED_TO_BUNDLE=Bundle failed +publishing-queue.status.FAILED_TO_SENT=Send failed +publishing-queue.status.FAILED_TO_PUBLISH=Publish failed +publishing-queue.status.SUCCESS=Sent +publishing-queue.status.BUNDLE_SENT_SUCCESSFULLY=Sent +publishing-queue.status.RECEIVED_BUNDLE=Received +publishing-queue.status.PUBLISHING_BUNDLE=Publishing +publishing-queue.status.WAITING_FOR_PUBLISHING=Waiting +publishing-queue.status.BUNDLE_SAVED_SUCCESSFULLY=Saved +publishing-queue.status.INVALID_TOKEN=Auth error +publishing-queue.status.LICENSE_REQUIRED=License required +publishing-queue.status.SUCCESS_WITH_WARNINGS=Sent (warnings) +publishing-queue.status.FAILED_INTEGRITY_CHECK=Integrity failed PUBLISHING-NOT-LICENSED=Push Publishing is a dotCMS Enterprise Professional and Enterprise Prime only feature. It allows you to:
  • Create, delete, and publish content, Content Types, Pages, and Templates from one dotCMS environment and push them to another
  • Schedule publishing/removal of content through Workflows
  • Batch migrate content to different environments
  • Publish to multiple remote environments simultaneously
  • Publish static content to an AWS S3 content store (with a Platform License)
push-assets-could-not-be-deleted=Pushed assets could not be deleted. push_publish_integrity_cms_roles_conflicts=Roles/User Roles Conflicts diff --git a/dotCMS/src/main/webapp/WEB-INF/portlet.xml b/dotCMS/src/main/webapp/WEB-INF/portlet.xml index 77c2b0c8987c..8047924a829b 100644 --- a/dotCMS/src/main/webapp/WEB-INF/portlet.xml +++ b/dotCMS/src/main/webapp/WEB-INF/portlet.xml @@ -210,8 +210,8 @@ - publishing-queue - Content Publishing Tools + publishing-queue-legacy + Content Publishing Tools (Legacy) com.liferay.portlet.JSPPortlet view-jsp @@ -517,4 +517,10 @@ /categories + + publishing-queue + Content Publishing Tools + com.dotcms.spring.portlet.PortletController + + From b01ed436130840c994058c879cdee7503f36cd18 Mon Sep 17 00:00:00 2001 From: hmoreras <31667212+hmoreras@users.noreply.github.com> Date: Mon, 8 Jun 2026 16:53:58 -0600 Subject: [PATCH 02/43] fix(publishing-queue) #36045: enforce publishing-queue portlet gate on v1 destructive ops The legacy /bundle/* and /v1/publishqueue destructive ops correctly required the publishing-queue portlet, but the equivalent v1 ops (DELETE /v1/publishing/{bundleId}, DELETE /v1/publishing/purge) did not. A backend user without portlet access could therefore delete bundles via the v1 endpoints even though the UI was hidden from them. Adds requiredPortlet("publishing-queue") to both methods, matching the gating on PublishQueueResource and BundleResource. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../com/dotcms/rest/api/v1/publishing/PublishingResource.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/publishing/PublishingResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/publishing/PublishingResource.java index 634164d36f6b..c03707c89edc 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/publishing/PublishingResource.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/publishing/PublishingResource.java @@ -450,6 +450,7 @@ public Response deletePublishingJob( .requiredFrontendUser(false) .requestAndResponse(request, response) .rejectWhenNoUser(true) + .requiredPortlet("publishing-queue") .init(); final User user = initData.getUser(); @@ -914,6 +915,7 @@ public ResponseEntityPurgeView purgePublishingJobs( .requiredFrontendUser(false) .requestAndResponse(request, response) .rejectWhenNoUser(true) + .requiredPortlet("publishing-queue") .init(); final User user = initData.getUser(); From 8c05085a000953335c134b6104c2e881ad570242 Mon Sep 17 00:00:00 2001 From: hmoreras <31667212+hmoreras@users.noreply.github.com> Date: Mon, 8 Jun 2026 16:54:24 -0600 Subject: [PATCH 03/43] feat(publishing-queue) #36040: History tab, modals (Details, Configure & send, Upload), kebab/Send/Retry, polling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lands the full Publishing Queue surface on top of the foundation slice: History tab, all modals, row actions, auto-refresh polling, and the site filter wiring. Backend security gate (#36045) shipped in a separate commit on this branch. History tab (`p-tabs` shell, switched from p-tabView to PrimeNG v20 API) - Sortable Bundle / Status / Modified columns (three-state sort cycle) - p-table with selection, bulk select, pagination - Bulk action bar: Retry Send + Remove (with confirm dialog) - Sent / Failed chips; row click opens Bundle Details modal Bundle Details modal - 9-field metadata definition list (bundle start/end + publish start/end via the existing AbstractTimestampsView — #36044 already covered BE-side) - Endpoints-by-environment table with per-endpoint status chip - Conditional Download button for completed bundles Configure & send modal - Push / Remove / Push+Remove action cards - Send now / Schedule segmented control with timezone display - ISO 8601 + timezone-offset date serialization - Searchable environment dropdown + filter dropdown - FE maps design operations (push/remove/pushremove) → backend PushBundleForm operations (publish/expire/publishexpire) per the spike's recommendation; no BE rename required Upload Bundle dialog - p-fileUpload basic mode for .tar.gz - POST /api/bundle/sync (licensed); progress + error surface Per-row actions - READY rows: primary Send button + p-menu kebab with Configure & send / Generate & download / Remove from queue - IN PROGRESS failed rows: inline Retry button - Confirm-remove dialog for destructive actions SignalStore expansion - New state: activeTab, historyRows/page/total/status/sort/sortDirection/ selectedIds, detail*, environments, pushBundleTarget, pushInFlight, uploadInFlight/Progress, siteId - New methods: loadHistory, loadDetail, loadEnvironments, openDetail/closeDetail, openConfigureSend/closeConfigureSend/submitPush, retryBundles, deleteBundle, deleteBundlesBulk (loops per-id until #36046 lands), generateBundle, uploadBundle, startPolling/stopPolling, setSiteId, setHistoryPage/cycleHistorySort/setHistorySelection - onInit effect splits queue vs history loads by activeTab; polling fires every 15s for IN PROGRESS (paused when document.hidden) Data-access service - Adds getPublishingJobDetails, pushBundle, retryBundles, deleteBundle, deleteBundles, generateBundle, uploadBundle, getBundleDownloadUrl, getEnvironments - Adds PublishingJobDetailView, EnvironmentDetailView, EndpointDetailView, TimestampsView, RetryBundleResultView, PushBundleResultView models Site filter - Toolbar now hosts the existing DotSiteSelectorDirective on a p-select - Site selection flows through store.setSiteId; backend ignores the field today, FE is ready to forward it once #36043 expands the filter scope Tests - 89 Jest+Spectator tests, all green - New specs for History, Bundle Details, Configure & Send, Upload, plus expanded store + page coverage Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dot-publishing-queue.service.ts | 130 +++++- core-web/libs/dotcms-models/src/index.ts | 1 + .../src/lib/publishing-job-detail.model.ts | 76 ++++ ...ot-publishing-queue-toolbar.component.html | 15 +- ...publishing-queue-toolbar.component.spec.ts | 18 +- .../dot-publishing-queue-toolbar.component.ts | 16 +- ...queue-bundle-details-dialog.component.html | 127 ++++++ ...ue-bundle-details-dialog.component.spec.ts | 114 +++++ ...g-queue-bundle-details-dialog.component.ts | 72 +++ ...queue-configure-send-dialog.component.html | 218 +++++++++ ...ue-configure-send-dialog.component.spec.ts | 142 ++++++ ...g-queue-configure-send-dialog.component.ts | 162 +++++++ ...lishing-queue-upload-dialog.component.html | 44 ++ ...hing-queue-upload-dialog.component.spec.ts | 82 ++++ ...ublishing-queue-upload-dialog.component.ts | 46 ++ ...ot-publishing-queue-history.component.html | 117 +++++ ...publishing-queue-history.component.spec.ts | 138 ++++++ .../dot-publishing-queue-history.component.ts | 114 +++++ .../dot-publishing-queue-list.component.html | 25 +- ...ot-publishing-queue-list.component.spec.ts | 27 +- .../dot-publishing-queue-list.component.ts | 16 +- .../dot-publishing-queue-page.component.html | 8 +- ...ot-publishing-queue-page.component.spec.ts | 117 ++--- .../dot-publishing-queue-page.component.ts | 91 ++-- .../store/dot-publishing-queue.store.spec.ts | 291 +++++++++--- .../store/dot-publishing-queue.store.ts | 415 +++++++++++++++++- .../dot-publishing-queue-shell.component.html | 26 ++ ...t-publishing-queue-shell.component.spec.ts | 94 +++- .../dot-publishing-queue-shell.component.ts | 150 ++++++- .../dot-publishing-queue/src/test-setup.ts | 12 + 30 files changed, 2680 insertions(+), 224 deletions(-) create mode 100644 core-web/libs/dotcms-models/src/lib/publishing-job-detail.model.ts create mode 100644 core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.html create mode 100644 core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.spec.ts create mode 100644 core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.ts create mode 100644 core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-configure-send-dialog/dot-publishing-queue-configure-send-dialog.component.html create mode 100644 core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-configure-send-dialog/dot-publishing-queue-configure-send-dialog.component.spec.ts create mode 100644 core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-configure-send-dialog/dot-publishing-queue-configure-send-dialog.component.ts create mode 100644 core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-upload-dialog/dot-publishing-queue-upload-dialog.component.html create mode 100644 core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-upload-dialog/dot-publishing-queue-upload-dialog.component.spec.ts create mode 100644 core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-upload-dialog/dot-publishing-queue-upload-dialog.component.ts create mode 100644 core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.html create mode 100644 core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.spec.ts create mode 100644 core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.ts create mode 100644 core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.html diff --git a/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.ts b/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.ts index d90d4d23e605..a0f47d71fabd 100644 --- a/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.ts +++ b/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.ts @@ -3,24 +3,66 @@ import { Observable } from 'rxjs'; import { HttpClient, HttpParams } from '@angular/common/http'; import { Injectable, inject } from '@angular/core'; -import { BundleAssetView, PublishAuditStatus, PublishingJobsResponse } from '@dotcms/dotcms-models'; +import { map } from 'rxjs/operators'; + +import { + BundleAssetView, + DotCMSResponse, + DotEnvironment, + PublishAuditStatus, + PublishingJobDetailView, + PublishingJobsResponse, + PushBundleResultView, + RetryBundleResultView +} from '@dotcms/dotcms-models'; + +export type PublishingSortField = 'bundle_name' | 'status' | 'created' | 'modified'; +export type PublishingSortDirection = 'asc' | 'desc'; export interface ListPublishingJobsParams { statuses: readonly PublishAuditStatus[]; page?: number; perPage?: number; filter?: string; + sort?: PublishingSortField; + sortDirection?: PublishingSortDirection; +} + +export type PushOperation = 'publish' | 'expire' | 'publishexpire'; +export type RetryDeliveryStrategy = 'ALL_ENDPOINTS' | 'FAILED_ENDPOINTS'; + +export interface PushBundlePayload { + operation: PushOperation; + publishDate?: string; + expireDate?: string; + environments: string[]; + filterKey: string; +} + +export interface RetryBundlesPayload { + bundleIds: string[]; + forcePush?: boolean; + deliveryStrategy?: RetryDeliveryStrategy; } /** - * Backs the Publishing Queue Angular portlet. Wraps the v1 publishing endpoints - * the new UI needs in this slice. + * Backs the Publishing Queue Angular portlet. * - * Endpoints covered: - * - `GET /api/v1/publishing` (`PublishingResource#listPublishingJobs`) - * - `GET /api/bundle/{bundleId}/assets` (`BundleResource#getPublishQueueElements`) + * v1 endpoints (`com.dotcms.rest.api.v1.publishing.PublishingResource`): + * - `GET /api/v1/publishing` — list bundles by status (sort/filter via params) + * - `GET /api/v1/publishing/{bundleId}` — full bundle detail with endpoints + * - `POST /api/v1/publishing/push/{bundleId}` — push bundle to environments + * - `POST /api/v1/publishing/retry` — retry bundles (bulk) + * - `DELETE /api/v1/publishing/{bundleId}` — delete single bundle + * - `DELETE /api/v1/publishing` — bulk delete by id (added by #36046) * - * Future slices add `getPublishingJobDetails`, `pushBundle`, `retryBundles`, etc. + * Legacy endpoints (`com.dotcms.rest.BundleResource`) still used until #36048 lands: + * - `GET /api/bundle/{bundleId}/assets` — asset list inside a bundle + * - `POST /api/bundle/_generate` — async tar.gz generation + * - `POST /api/bundle/sync` — synchronous .tar.gz upload (licensed) + * - `GET /api/bundle/_download/{bundleId}` — bundle download (URL only) + * + * Environment list comes from `EnvironmentResource` (`GET /api/environment`). */ @Injectable({ providedIn: 'root' @@ -43,11 +85,75 @@ export class DotPublishingQueueService { httpParams = httpParams.set('filter', params.filter); } + if (params.sort) { + const order = params.sortDirection === 'desc' ? '-' : ''; + httpParams = httpParams.set('sort', `${order}${params.sort}`); + } + return this.http.get('/api/v1/publishing', { params: httpParams }); } + getPublishingJobDetails(bundleId: string): Observable { + return this.http + .get>(`/api/v1/publishing/${bundleId}`) + .pipe(map((response) => response.entity)); + } + + pushBundle(bundleId: string, payload: PushBundlePayload): Observable { + return this.http + .post>( + `/api/v1/publishing/push/${bundleId}`, + payload + ) + .pipe(map((response) => response.entity)); + } + + retryBundles(payload: RetryBundlesPayload): Observable { + return this.http + .post>('/api/v1/publishing/retry', payload) + .pipe(map((response) => response.entity)); + } + + deleteBundle(bundleId: string): Observable<{ message: string }> { + return this.http.delete<{ message: string }>(`/api/v1/publishing/${bundleId}`); + } + + /** + * Bulk delete bundles by id (BE endpoint added in #36046). Falls back to per-id loops + * on the consumer side if the endpoint returns 404. + */ + deleteBundles(bundleIds: string[]): Observable<{ message: string; deleted: string[] }> { + return this.http.request<{ message: string; deleted: string[] }>( + 'DELETE', + '/api/v1/publishing', + { body: { bundleIds } } + ); + } + + generateBundle( + bundleId: string, + filterKey: string, + operation: PushOperation = 'publish' + ): Observable { + return this.http.post('/api/bundle/_generate', { bundleId, filterKey, operation }); + } + + uploadBundle(file: File): Observable<{ bundleName: string; status: string }> { + const formData = new FormData(); + formData.append('file', file, file.name); + return this.http.post<{ bundleName: string; status: string }>( + '/api/bundle/sync', + formData + ); + } + + /** Builds the absolute download URL for a bundle's `.tar.gz`. */ + getBundleDownloadUrl(bundleId: string): string { + return `/api/bundle/_download/${bundleId}`; + } + getBundleAssets(bundleId: string): Observable { const params = new HttpParams().set('limit', -1); @@ -55,4 +161,14 @@ export class DotPublishingQueueService { params }); } + + /** + * Lists Push-Publish environments visible to the current user. + * Backed by `EnvironmentResource#loadAllEnvironments` (role-filtered for non-admins). + */ + getEnvironments(): Observable { + return this.http + .get>('/api/environment') + .pipe(map((response) => response.entity)); + } } diff --git a/core-web/libs/dotcms-models/src/index.ts b/core-web/libs/dotcms-models/src/index.ts index 4b0d5036c704..dd5389084192 100644 --- a/core-web/libs/dotcms-models/src/index.ts +++ b/core-web/libs/dotcms-models/src/index.ts @@ -62,6 +62,7 @@ export * from './lib/dot-personalize.model'; export * from './lib/dot-push-publish-data.model'; export * from './lib/dot-push-publish-dialog-data.model'; export * from './lib/publishing-job.model'; +export * from './lib/publishing-job-detail.model'; export * from './lib/publishing-status.model'; export * from './lib/bundle-asset-view.model'; export * from './lib/dot-rendered-page-state.model'; diff --git a/core-web/libs/dotcms-models/src/lib/publishing-job-detail.model.ts b/core-web/libs/dotcms-models/src/lib/publishing-job-detail.model.ts new file mode 100644 index 000000000000..28dd7cfc10c4 --- /dev/null +++ b/core-web/libs/dotcms-models/src/lib/publishing-job-detail.model.ts @@ -0,0 +1,76 @@ +import { PublishAuditStatus } from './publishing-status.model'; + +/** + * One endpoint inside an environment, with its publish status. + * Mirrors `com.dotcms.rest.api.v1.publishing.AbstractEndpointDetailView`. + */ +export interface EndpointDetailView { + id: string; + serverName: string; + address: string; + port: string; + protocol: string; + status: PublishAuditStatus | null; + statusMessage: string | null; + stackTrace: string | null; +} + +/** + * One environment with its endpoints. + * Mirrors `com.dotcms.rest.api.v1.publishing.AbstractEnvironmentDetailView`. + */ +export interface EnvironmentDetailView { + id: string; + name: string; + endpoints: EndpointDetailView[]; +} + +/** + * Bundle/publish phase timestamps. + * Mirrors `com.dotcms.rest.api.v1.publishing.AbstractTimestampsView`. + */ +export interface TimestampsView { + bundleStart: string | null; + bundleEnd: string | null; + publishStart: string | null; + publishEnd: string | null; + createDate: string; + statusUpdated: string | null; +} + +/** + * Full bundle detail returned by `GET /api/v1/publishing/{bundleId}`. + * Mirrors `com.dotcms.rest.api.v1.publishing.AbstractPublishingJobDetailView`. + */ +export interface PublishingJobDetailView { + bundleId: string; + bundleName: string | null; + status: PublishAuditStatus; + filterName: string | null; + filterKey: string | null; + assetCount: number; + environments: EnvironmentDetailView[]; + timestamps: TimestampsView; + numTries: number; +} + +/** Result of a single bundle retry. */ +export interface RetryBundleResultView { + bundleId: string; + success: boolean; + message: string; + forcePush: boolean | null; + operation: 'PUBLISH' | 'UNPUBLISH' | null; + deliveryStrategy: 'ALL_ENDPOINTS' | 'FAILED_ENDPOINTS'; + assetCount: number | null; +} + +/** Result of pushing a bundle to environments. */ +export interface PushBundleResultView { + bundleId: string; + operation: string; + publishDate: string | null; + expireDate: string | null; + environments: string[]; + filterKey: string; +} 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 d57246ad5b6c..0bf612f6ab39 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 @@ -23,18 +23,19 @@ + class="w-56" /> diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-toolbar/dot-publishing-queue-toolbar.component.spec.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-toolbar/dot-publishing-queue-toolbar.component.spec.ts index cdeb7cfa7ae7..4b2f1ab4759e 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-toolbar/dot-publishing-queue-toolbar.component.spec.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-toolbar/dot-publishing-queue-toolbar.component.spec.ts @@ -1,6 +1,7 @@ import { byTestId, createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; +import { of } from 'rxjs'; -import { DotMessageService } from '@dotcms/data-access'; +import { DotEventsService, DotMessageService, DotSiteService } from '@dotcms/data-access'; import { MockDotMessageService } from '@dotcms/utils-testing'; import { DotPublishingQueueToolbarComponent } from './dot-publishing-queue-toolbar.component'; @@ -21,15 +22,17 @@ describe('DotPublishingQueueToolbarComponent', () => { }) ], providers: [ + mockProvider(DotEventsService, { listen: jest.fn().mockReturnValue(of({})) }), + mockProvider(DotSiteService, { + getSites: jest.fn().mockReturnValue(of({ sites: [], total: 0 })) + }), { provide: DotMessageService, useValue: new MockDotMessageService({ 'publishing-queue.search.placeholder': 'Search bundles', 'publishing-queue.refresh': 'Refresh', 'publishing-queue.upload-bundle': 'Upload Bundle', - 'publishing-queue.upload-bundle.coming-soon': 'Coming soon', - 'publishing-queue.site-selector.placeholder': 'Site', - 'publishing-queue.site-selector.coming-soon': 'Coming soon' + 'publishing-queue.site-selector.placeholder': 'Site' }) } ] @@ -54,9 +57,12 @@ describe('DotPublishingQueueToolbarComponent', () => { expect(spectator.query(byTestId('pq-site-selector'))).toBeTruthy(); }); - it('upload button is disabled', () => { + it('upload button click emits uploadClick', () => { + const emit = jest.fn(); + spectator.component.uploadClick.subscribe(emit); const uploadBtn = spectator.query(byTestId('pq-upload-btn'))?.querySelector('button'); - expect(uploadBtn?.disabled).toBe(true); + spectator.click(uploadBtn as HTMLButtonElement); + expect(emit).toHaveBeenCalled(); }); }); 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 1afa6410269a..a363c2661da6 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 @@ -1,6 +1,6 @@ import { Subject } from 'rxjs'; -import { ChangeDetectionStrategy, Component, DestroyRef, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, DestroyRef, inject, output } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormsModule } from '@angular/forms'; @@ -8,13 +8,13 @@ import { ButtonModule } from 'primeng/button'; import { IconFieldModule } from 'primeng/iconfield'; import { InputIconModule } from 'primeng/inputicon'; import { InputTextModule } from 'primeng/inputtext'; -import { SelectModule } from 'primeng/select'; +import { SelectChangeEvent, SelectModule } from 'primeng/select'; import { ToolbarModule } from 'primeng/toolbar'; -import { TooltipModule } from 'primeng/tooltip'; import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; -import { DotMessagePipe } from '@dotcms/ui'; +import { DotSite } from '@dotcms/dotcms-models'; +import { DotMessagePipe, DotSiteSelectorDirective } from '@dotcms/ui'; import { DotPublishingQueueStore } from '../../dot-publishing-queue-page/store/dot-publishing-queue.store'; @@ -29,7 +29,7 @@ import { DotPublishingQueueStore } from '../../dot-publishing-queue-page/store/d InputTextModule, SelectModule, ToolbarModule, - TooltipModule, + DotSiteSelectorDirective, DotMessagePipe ], templateUrl: './dot-publishing-queue-toolbar.component.html', @@ -37,6 +37,7 @@ import { DotPublishingQueueStore } from '../../dot-publishing-queue-page/store/d }) export class DotPublishingQueueToolbarComponent { readonly store = inject(DotPublishingQueueStore); + readonly uploadClick = output(); private readonly destroyRef = inject(DestroyRef); private searchSubject = new Subject(); @@ -50,4 +51,9 @@ export class DotPublishingQueueToolbarComponent { onSearch(value: string): void { this.searchSubject.next(value); } + + onSiteChange(event: SelectChangeEvent): void { + const site = event.value as DotSite | null; + this.store.setSiteId(site?.identifier ?? null); + } } diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.html b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.html new file mode 100644 index 000000000000..2f5edd02f5f1 --- /dev/null +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.html @@ -0,0 +1,127 @@ +
+ @if (store.detailStatus() === 'loading') { + + + + } @else if (store.detail(); as detail) { +
+
+
{{ 'publishing-queue.detail.title' | dm }}
+
{{ detail.bundleName || '—' }}
+
+
+
{{ 'publishing-queue.detail.status' | dm }}
+
+ +
+
+
+
{{ 'publishing-queue.detail.bundle-id' | dm }}
+
{{ detail.bundleId }}
+
+
+
+ {{ 'publishing-queue.detail.bundle-start' | dm }} +
+
{{ (detail.timestamps.bundleStart | date: 'medium') || '—' }}
+
+
+
+ {{ 'publishing-queue.detail.bundle-end' | dm }} +
+
{{ (detail.timestamps.bundleEnd | date: 'medium') || '—' }}
+
+
+
+ {{ 'publishing-queue.detail.publish-start' | dm }} +
+
{{ (detail.timestamps.publishStart | date: 'medium') || '—' }}
+
+
+
+ {{ 'publishing-queue.detail.publish-end' | dm }} +
+
{{ (detail.timestamps.publishEnd | date: 'medium') || '—' }}
+
+
+
{{ 'publishing-queue.detail.filter' | dm }}
+
{{ detail.filterName || detail.filterKey || '—' }}
+
+
+
{{ 'publishing-queue.detail.assets' | dm }}
+
{{ detail.assetCount }}
+
+
+ +
+

+ {{ 'publishing-queue.detail.endpoints' | dm }} +

+ @if (detail.environments.length === 0) { +

+ {{ 'publishing-queue.detail.no-endpoints' | dm }} +

+ } @else { + @for (env of detail.environments; track env.id) { +
+
+ {{ env.name }} +
+ + + + {{ 'publishing-queue.detail.endpoint' | dm }} + {{ 'publishing-queue.detail.address' | dm }} + {{ 'publishing-queue.detail.status' | dm }} + {{ 'publishing-queue.detail.message' | dm }} + + + + + {{ endpoint.serverName }} + + {{ endpoint.protocol }}://{{ endpoint.address }}:{{ + endpoint.port + }} + + + @if (endpoint.status) { + + } @else { + + } + + + {{ endpoint.statusMessage || '—' }} + + + + +
+ } + } +
+ + @if (canDownload()) { + + } + } @else if (store.detailStatus() === 'error') { +
+ {{ 'publishing-queue.detail.load-error' | dm }} +
+ } +
diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.spec.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.spec.ts new file mode 100644 index 000000000000..c17b44adbba4 --- /dev/null +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.spec.ts @@ -0,0 +1,114 @@ +import { byTestId, createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; + +import { signal } from '@angular/core'; + +import { DotMessageService, DotPublishingQueueService } from '@dotcms/data-access'; +import { PublishAuditStatus, PublishingJobDetailView } from '@dotcms/dotcms-models'; +import { MockDotMessageService } from '@dotcms/utils-testing'; + +import { DotPublishingQueueBundleDetailsDialogComponent } from './dot-publishing-queue-bundle-details-dialog.component'; + +import { DotPublishingQueueStore } from '../../dot-publishing-queue-page/store/dot-publishing-queue.store'; + +const detailFixture = ( + overrides: Partial = {} +): PublishingJobDetailView => ({ + bundleId: 'b-1', + bundleName: 'My Bundle', + status: PublishAuditStatus.SUCCESS, + filterName: 'Default', + filterKey: 'default.yml', + assetCount: 2, + environments: [ + { + id: 'env-1', + name: 'Prod', + endpoints: [ + { + id: 'ep-1', + serverName: 'srv1', + address: '127.0.0.1', + port: '443', + protocol: 'https', + status: PublishAuditStatus.SUCCESS, + statusMessage: 'ok', + stackTrace: null + } + ] + } + ], + timestamps: { + bundleStart: '2026-06-08T10:00:00Z', + bundleEnd: '2026-06-08T10:01:00Z', + publishStart: '2026-06-08T10:01:00Z', + publishEnd: '2026-06-08T10:02:00Z', + createDate: '2026-06-08T10:00:00Z', + statusUpdated: '2026-06-08T10:02:00Z' + }, + numTries: 1, + ...overrides +}); + +describe('DotPublishingQueueBundleDetailsDialogComponent', () => { + let spectator: Spectator; + + const detail = signal(null); + const detailStatus = signal<'init' | 'loading' | 'loaded' | 'error'>('loading'); + + const createComponent = createComponentFactory({ + component: DotPublishingQueueBundleDetailsDialogComponent, + providers: [ + mockProvider(DotPublishingQueueStore, { detail, detailStatus }), + mockProvider(DotPublishingQueueService, { + getBundleDownloadUrl: jest.fn((id: string) => `/api/bundle/_download/${id}`) + }), + { provide: DotMessageService, useValue: new MockDotMessageService({}) } + ] + }); + + beforeEach(() => { + detail.set(null); + detailStatus.set('loading'); + spectator = createComponent(); + }); + + it('shows skeletons when loading', () => { + expect(spectator.query(byTestId('pq-detail-meta'))).toBeFalsy(); + }); + + it('renders metadata + endpoints once loaded', () => { + detail.set(detailFixture()); + detailStatus.set('loaded'); + spectator.detectChanges(); + expect(spectator.query(byTestId('pq-detail-meta'))).toBeTruthy(); + expect(spectator.queryAll(byTestId('pq-detail-endpoint-row')).length).toBe(1); + }); + + it('shows download button only for completed bundles', () => { + detail.set(detailFixture({ status: PublishAuditStatus.SUCCESS })); + detailStatus.set('loaded'); + spectator.detectChanges(); + expect(spectator.query(byTestId('pq-detail-download-btn'))).toBeTruthy(); + }); + + it('hides download button for failed bundles', () => { + detail.set(detailFixture({ status: PublishAuditStatus.FAILED_TO_PUBLISH })); + detailStatus.set('loaded'); + spectator.detectChanges(); + expect(spectator.query(byTestId('pq-detail-download-btn'))).toBeFalsy(); + }); + + it('shows empty-endpoints message when environments is empty', () => { + detail.set(detailFixture({ environments: [] })); + detailStatus.set('loaded'); + spectator.detectChanges(); + expect(spectator.query(byTestId('pq-detail-endpoints-empty'))).toBeTruthy(); + }); + + it('shows error state', () => { + detail.set(null); + detailStatus.set('error'); + spectator.detectChanges(); + expect(spectator.query(byTestId('pq-detail-error'))).toBeTruthy(); + }); +}); diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.ts new file mode 100644 index 000000000000..6662ee2d0528 --- /dev/null +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.ts @@ -0,0 +1,72 @@ +import { DatePipe } from '@angular/common'; +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; + +import { ButtonModule } from 'primeng/button'; +import { SkeletonModule } from 'primeng/skeleton'; +import { TableModule } from 'primeng/table'; +import { TagModule } from 'primeng/tag'; + +import { DotPublishingQueueService } from '@dotcms/data-access'; +import { PublishAuditStatus } from '@dotcms/dotcms-models'; +import { DotMessagePipe } from '@dotcms/ui'; + +import { DotPublishingQueueStore } from '../../dot-publishing-queue-page/store/dot-publishing-queue.store'; + +type ChipSeverity = 'success' | 'info' | 'warn' | 'danger' | 'secondary'; + +const SUCCESS_STATUSES = new Set([ + PublishAuditStatus.SUCCESS, + PublishAuditStatus.BUNDLE_SENT_SUCCESSFULLY, + PublishAuditStatus.BUNDLE_SAVED_SUCCESSFULLY, + PublishAuditStatus.SUCCESS_WITH_WARNINGS +]); + +const FAILURE_STATUSES = new Set([ + PublishAuditStatus.FAILED_TO_SEND_TO_ALL_GROUPS, + PublishAuditStatus.FAILED_TO_SEND_TO_SOME_GROUPS, + PublishAuditStatus.FAILED_TO_BUNDLE, + PublishAuditStatus.FAILED_TO_SENT, + PublishAuditStatus.FAILED_TO_PUBLISH, + PublishAuditStatus.FAILED_INTEGRITY_CHECK, + PublishAuditStatus.INVALID_TOKEN, + PublishAuditStatus.LICENSE_REQUIRED +]); + +@Component({ + selector: 'dot-publishing-queue-bundle-details-dialog', + standalone: true, + imports: [DatePipe, ButtonModule, SkeletonModule, TableModule, TagModule, DotMessagePipe], + templateUrl: './dot-publishing-queue-bundle-details-dialog.component.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DotPublishingQueueBundleDetailsDialogComponent { + readonly store = inject(DotPublishingQueueStore); + + private readonly publishingService = inject(DotPublishingQueueService); + + readonly canDownload = computed(() => { + const status = this.store.detail()?.status; + return status ? SUCCESS_STATUSES.has(status) : false; + }); + + downloadHref(bundleId: string): string { + return this.publishingService.getBundleDownloadUrl(bundleId); + } + + statusLabelKey(status: PublishAuditStatus): string { + return `publishing-queue.status.${status}`; + } + + statusSeverity(status: PublishAuditStatus | null): ChipSeverity { + if (!status) { + return 'secondary'; + } + if (SUCCESS_STATUSES.has(status)) { + return 'success'; + } + if (FAILURE_STATUSES.has(status)) { + return 'danger'; + } + return 'info'; + } +} diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-configure-send-dialog/dot-publishing-queue-configure-send-dialog.component.html b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-configure-send-dialog/dot-publishing-queue-configure-send-dialog.component.html new file mode 100644 index 000000000000..d509551d8736 --- /dev/null +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-configure-send-dialog/dot-publishing-queue-configure-send-dialog.component.html @@ -0,0 +1,218 @@ +
+ @if (bundle(); as bundle) { +

+ {{ bundle.bundleName || bundle.bundleId }} +

+ } + +
+ + {{ 'publishing-queue.configure-send.action' | dm }} + +
+ + + +
+
+ +
+ + @if (operation() === 'remove') { + {{ 'publishing-queue.configure-send.when-to-remove' | dm }} + } @else { + {{ 'publishing-queue.configure-send.when-to-publish' | dm }} + } + +
+ + +
+

+ {{ timezoneLabel() }} +

+
+ + @if (showPublishDate()) { +
+ + +
+ } + + @if (showExpireDateScheduled()) { +
+ + +
+ } + + @if (operation() === 'push') { + + + @if (setExpire()) { +
+ + +
+ } + } + + @if (operation() === 'pushremove') { +
+ + +
+ } + +
+ + + @if (selectedEnvironments().length === 0) { + + {{ 'publishing-queue.configure-send.env-required' | dm }} + + } +
+ + @if (showFilter()) { +
+ + +
+ } + + + +
+ + +
+
diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-configure-send-dialog/dot-publishing-queue-configure-send-dialog.component.spec.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-configure-send-dialog/dot-publishing-queue-configure-send-dialog.component.spec.ts new file mode 100644 index 000000000000..21fccea43f84 --- /dev/null +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-configure-send-dialog/dot-publishing-queue-configure-send-dialog.component.spec.ts @@ -0,0 +1,142 @@ +import { createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; +import { of } from 'rxjs'; + +import { signal } from '@angular/core'; + +import { DynamicDialogRef } from 'primeng/dynamicdialog'; + +import { DotMessageService, DotPushPublishFiltersService } from '@dotcms/data-access'; +import { PublishAuditStatus, PublishingJobView } from '@dotcms/dotcms-models'; +import { MockDotMessageService } from '@dotcms/utils-testing'; + +import { DotPublishingQueueConfigureSendDialogComponent } from './dot-publishing-queue-configure-send-dialog.component'; + +import { DotPublishingQueueStore } from '../../dot-publishing-queue-page/store/dot-publishing-queue.store'; + +const bundleFixture: PublishingJobView = { + bundleId: 'b-1', + bundleName: 'Bundle 1', + status: PublishAuditStatus.WAITING_FOR_PUBLISHING, + filterName: null, + filterKey: null, + assetCount: 1, + assetPreview: [], + environmentCount: 0, + createDate: '2026-06-08T10:00:00Z', + statusUpdated: null, + numTries: 0 +}; + +describe('DotPublishingQueueConfigureSendDialogComponent', () => { + let spectator: Spectator; + let store: ReturnType; + let dialogRef: jest.Mocked; + + const pushBundleTarget = signal(null); + const pushInFlight = signal(false); + const environments = signal([ + { id: 'env-1', name: 'Prod' }, + { id: 'env-2', name: 'Staging' } + ]); + + function makeStoreStub() { + return { + pushBundleTarget, + pushInFlight, + environments, + submitPush: jest.fn((_id, _payload, cb: () => void) => cb()) + }; + } + + const createComponent = createComponentFactory({ + component: DotPublishingQueueConfigureSendDialogComponent, + providers: [ + mockProvider(DotPublishingQueueStore, makeStoreStub()), + mockProvider(DotPushPublishFiltersService, { + get: jest.fn().mockReturnValue( + of([ + { defaultFilter: true, key: 'default.yml', title: 'Default' }, + { defaultFilter: false, key: 'force.yml', title: 'Force Push' } + ]) + ) + }), + mockProvider(DynamicDialogRef, { close: jest.fn() }), + { provide: DotMessageService, useValue: new MockDotMessageService({}) } + ] + }); + + beforeEach(() => { + pushBundleTarget.set(bundleFixture); + pushInFlight.set(false); + spectator = createComponent(); + store = spectator.inject(DotPublishingQueueStore) as unknown as ReturnType< + typeof makeStoreStub + >; + dialogRef = spectator.inject(DynamicDialogRef) as jest.Mocked; + jest.clearAllMocks(); + }); + + it('starts with operation=push and scheduleMode=now', () => { + expect(spectator.component.operation()).toBe('push'); + expect(spectator.component.scheduleMode()).toBe('now'); + }); + + it('canSubmit is false with no env selected', () => { + expect(spectator.component.canSubmit()).toBe(false); + }); + + it('canSubmit becomes true with an env and a filter selected', () => { + spectator.component.selectedEnvironments.set(['env-1']); + spectator.component.selectedFilterKey.set('default.yml'); + expect(spectator.component.canSubmit()).toBe(true); + }); + + it('hides filter dropdown for remove operation, and submits without filter', () => { + spectator.component.setOperation('remove'); + spectator.component.selectedEnvironments.set(['env-1']); + spectator.component.scheduleMode.set('schedule'); + spectator.component.expireDate.set(new Date('2026-07-01T00:00:00')); + expect(spectator.component.canSubmit()).toBe(true); + + spectator.component.onSubmit(); + expect(store.submitPush).toHaveBeenCalledWith( + 'b-1', + expect.objectContaining({ + operation: 'expire', + publishDate: undefined, + filterKey: expect.any(String) + }), + expect.any(Function) + ); + }); + + it('maps design operation push → publish on submit', () => { + spectator.component.selectedEnvironments.set(['env-1']); + spectator.component.selectedFilterKey.set('default.yml'); + spectator.component.onSubmit(); + expect(store.submitPush).toHaveBeenCalledWith( + 'b-1', + expect.objectContaining({ operation: 'publish' }), + expect.any(Function) + ); + }); + + it('maps pushremove → publishexpire', () => { + spectator.component.setOperation('pushremove'); + spectator.component.selectedEnvironments.set(['env-1']); + spectator.component.selectedFilterKey.set('default.yml'); + spectator.component.expireDate.set(new Date('2026-08-01T00:00:00')); + spectator.component.onSubmit(); + expect(store.submitPush).toHaveBeenCalledWith( + 'b-1', + expect.objectContaining({ operation: 'publishexpire' }), + expect.any(Function) + ); + }); + + it('cancel closes the dialog without submitting', () => { + spectator.component.onCancel(); + expect(dialogRef.close).toHaveBeenCalled(); + expect(store.submitPush).not.toHaveBeenCalled(); + }); +}); diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-configure-send-dialog/dot-publishing-queue-configure-send-dialog.component.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-configure-send-dialog/dot-publishing-queue-configure-send-dialog.component.ts new file mode 100644 index 000000000000..724012bae149 --- /dev/null +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-configure-send-dialog/dot-publishing-queue-configure-send-dialog.component.ts @@ -0,0 +1,162 @@ +import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { FormsModule } from '@angular/forms'; + +import { ButtonModule } from 'primeng/button'; +import { DatePickerModule } from 'primeng/datepicker'; +import { DynamicDialogRef } from 'primeng/dynamicdialog'; +import { MessageModule } from 'primeng/message'; +import { SelectModule } from 'primeng/select'; + +import { + DotMessageService, + DotPushPublishFiltersService, + PushOperation +} from '@dotcms/data-access'; +import { DotMessagePipe } from '@dotcms/ui'; + +import { DotPublishingQueueStore } from '../../dot-publishing-queue-page/store/dot-publishing-queue.store'; + +type DesignOperation = 'push' | 'remove' | 'pushremove'; +type ScheduleMode = 'now' | 'schedule'; + +const OPERATION_MAP: Record = { + push: 'publish', + remove: 'expire', + pushremove: 'publishexpire' +}; + +@Component({ + selector: 'dot-publishing-queue-configure-send-dialog', + standalone: true, + imports: [ + FormsModule, + ButtonModule, + DatePickerModule, + MessageModule, + SelectModule, + DotMessagePipe + ], + templateUrl: './dot-publishing-queue-configure-send-dialog.component.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DotPublishingQueueConfigureSendDialogComponent { + readonly store = inject(DotPublishingQueueStore); + readonly dialogRef = inject(DynamicDialogRef); + private readonly dotMessageService = inject(DotMessageService); + private readonly filtersService = inject(DotPushPublishFiltersService); + + readonly operation = signal('push'); + readonly scheduleMode = signal('now'); + readonly publishDate = signal(null); + readonly expireDate = signal(null); + readonly setExpire = signal(false); + readonly selectedEnvironments = signal([]); + readonly selectedFilterKey = signal(null); + + readonly filters = toSignal(this.filtersService.get(), { initialValue: [] }); + + readonly bundle = computed(() => this.store.pushBundleTarget()); + readonly envOptions = computed(() => this.store.environments()); + readonly envOptionLabel = 'name'; + readonly envOptionValue = 'id'; + + readonly showFilter = computed(() => this.operation() !== 'remove'); + readonly showExpireDate = computed( + () => this.operation() === 'pushremove' || (this.operation() === 'push' && this.setExpire()) + ); + readonly showPublishDate = computed( + () => this.operation() !== 'remove' && this.scheduleMode() === 'schedule' + ); + readonly showExpireDateScheduled = computed( + () => this.operation() === 'remove' && this.scheduleMode() === 'schedule' + ); + + readonly canSubmit = computed(() => { + if (this.selectedEnvironments().length === 0) { + return false; + } + if (this.showFilter() && !this.selectedFilterKey()) { + return false; + } + return true; + }); + + onSubmit(): void { + const bundle = this.bundle(); + if (!bundle || !this.canSubmit()) { + return; + } + + const apiOperation = OPERATION_MAP[this.operation()]; + const payload = { + operation: apiOperation, + environments: this.selectedEnvironments(), + filterKey: this.selectedFilterKey() ?? 'ForcePush.yml', + publishDate: this.publishDateIso(), + expireDate: this.expireDateIso() + }; + + this.store.submitPush(bundle.bundleId, payload, () => this.dialogRef.close()); + } + + onCancel(): void { + this.dialogRef.close(); + } + + /** Click-handler for the action cards; signature kept terse for template inlining. */ + setOperation(op: DesignOperation): void { + this.operation.set(op); + if (op === 'remove') { + this.selectedFilterKey.set(null); + } + } + + setSchedule(mode: ScheduleMode): void { + this.scheduleMode.set(mode); + } + + toggleExpire(value: boolean): void { + this.setExpire.set(value); + } + + private publishDateIso(): string | undefined { + if (this.scheduleMode() !== 'schedule' || this.operation() === 'remove') { + return undefined; + } + const d = this.publishDate(); + return d ? this.toOffsetIso(d) : undefined; + } + + private expireDateIso(): string | undefined { + const isExpireRequired = this.operation() === 'pushremove' || this.operation() === 'remove'; + const wantsExpire = isExpireRequired || (this.operation() === 'push' && this.setExpire()); + if (!wantsExpire) { + return undefined; + } + const d = this.expireDate(); + return d ? this.toOffsetIso(d) : undefined; + } + + /** ISO 8601 with timezone offset — matches what `PushBundleForm.validateDateFormat` expects. */ + private toOffsetIso(d: Date): string { + const pad = (n: number) => n.toString().padStart(2, '0'); + const tzOffsetMin = -d.getTimezoneOffset(); + const sign = tzOffsetMin >= 0 ? '+' : '-'; + const offHrs = pad(Math.floor(Math.abs(tzOffsetMin) / 60)); + const offMin = pad(Math.abs(tzOffsetMin) % 60); + return ( + `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` + + `T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` + + `${sign}${offHrs}:${offMin}` + ); + } + + timezoneLabel(): string { + return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; + } + + msg(key: string, ...args: string[]): string { + return this.dotMessageService.get(key, ...args); + } +} diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-upload-dialog/dot-publishing-queue-upload-dialog.component.html b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-upload-dialog/dot-publishing-queue-upload-dialog.component.html new file mode 100644 index 000000000000..8383d7ab4cd2 --- /dev/null +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-upload-dialog/dot-publishing-queue-upload-dialog.component.html @@ -0,0 +1,44 @@ +
+ + + + + @if (selectedFile(); as file) { +
+ {{ file.name }} + + {{ (file.size / 1024 / 1024).toFixed(2) }} MB + +
+ } + + @if (store.uploadInFlight()) { + + } + +
+ + +
+
diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-upload-dialog/dot-publishing-queue-upload-dialog.component.spec.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-upload-dialog/dot-publishing-queue-upload-dialog.component.spec.ts new file mode 100644 index 000000000000..0bb647cc25a9 --- /dev/null +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-upload-dialog/dot-publishing-queue-upload-dialog.component.spec.ts @@ -0,0 +1,82 @@ +import { createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; + +import { signal } from '@angular/core'; + +import { DynamicDialogRef } from 'primeng/dynamicdialog'; + +import { DotMessageService } from '@dotcms/data-access'; +import { MockDotMessageService } from '@dotcms/utils-testing'; + +import { DotPublishingQueueUploadDialogComponent } from './dot-publishing-queue-upload-dialog.component'; + +import { DotPublishingQueueStore } from '../../dot-publishing-queue-page/store/dot-publishing-queue.store'; + +describe('DotPublishingQueueUploadDialogComponent', () => { + let spectator: Spectator; + let dialogRef: jest.Mocked; + let store: ReturnType; + + const uploadInFlight = signal(false); + + function storeFactory() { + return { + uploadInFlight, + uploadBundle: jest.fn((_file: File, cb?: () => void) => cb?.()) + }; + } + + const createComponent = createComponentFactory({ + component: DotPublishingQueueUploadDialogComponent, + providers: [ + mockProvider(DotPublishingQueueStore, storeFactory()), + mockProvider(DynamicDialogRef, { close: jest.fn() }), + { provide: DotMessageService, useValue: new MockDotMessageService({}) } + ] + }); + + beforeEach(() => { + uploadInFlight.set(false); + spectator = createComponent(); + dialogRef = spectator.inject(DynamicDialogRef) as jest.Mocked; + store = spectator.inject(DotPublishingQueueStore) as unknown as ReturnType< + typeof storeFactory + >; + jest.clearAllMocks(); + }); + + it('disables submit until a file is selected', () => { + expect(spectator.component.selectedFile()).toBeNull(); + }); + + it('stores the selected file', () => { + const file = new File(['x'], 'bundle.tar.gz', { type: 'application/gzip' }); + spectator.component.onSelect({ files: [file] } as never); + expect(spectator.component.selectedFile()).toBe(file); + }); + + it('clears file on onClear', () => { + spectator.component.onSelect({ + files: [new File(['x'], 'b.tar.gz')] + } as never); + spectator.component.onClear(); + expect(spectator.component.selectedFile()).toBeNull(); + }); + + it('submit calls store.uploadBundle + closes the dialog with uploaded:true', () => { + const file = new File(['x'], 'bundle.tar.gz'); + spectator.component.onSelect({ files: [file] } as never); + spectator.component.onSubmit(); + expect(store.uploadBundle).toHaveBeenCalledWith(file, expect.any(Function)); + expect(dialogRef.close).toHaveBeenCalledWith({ uploaded: true }); + }); + + it('submit is a no-op when no file is selected', () => { + spectator.component.onSubmit(); + expect(store.uploadBundle).not.toHaveBeenCalled(); + }); + + it('cancel closes the dialog', () => { + spectator.component.onCancel(); + expect(dialogRef.close).toHaveBeenCalled(); + }); +}); diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-upload-dialog/dot-publishing-queue-upload-dialog.component.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-upload-dialog/dot-publishing-queue-upload-dialog.component.ts new file mode 100644 index 000000000000..ba7044e9d118 --- /dev/null +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-upload-dialog/dot-publishing-queue-upload-dialog.component.ts @@ -0,0 +1,46 @@ +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; + +import { ButtonModule } from 'primeng/button'; +import { DynamicDialogRef } from 'primeng/dynamicdialog'; +import { FileSelectEvent, FileUploadModule } from 'primeng/fileupload'; +import { MessageModule } from 'primeng/message'; +import { ProgressBarModule } from 'primeng/progressbar'; + +import { DotMessagePipe } from '@dotcms/ui'; + +import { DotPublishingQueueStore } from '../../dot-publishing-queue-page/store/dot-publishing-queue.store'; + +@Component({ + selector: 'dot-publishing-queue-upload-dialog', + standalone: true, + imports: [ButtonModule, FileUploadModule, MessageModule, ProgressBarModule, DotMessagePipe], + templateUrl: './dot-publishing-queue-upload-dialog.component.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DotPublishingQueueUploadDialogComponent { + readonly store = inject(DotPublishingQueueStore); + readonly dialogRef = inject(DynamicDialogRef); + + readonly selectedFile = signal(null); + + onSelect(event: FileSelectEvent): void { + const file = event.files?.[0] ?? null; + this.selectedFile.set(file); + } + + onClear(): void { + this.selectedFile.set(null); + } + + onSubmit(): void { + const file = this.selectedFile(); + if (!file) { + return; + } + this.store.uploadBundle(file, () => this.dialogRef.close({ uploaded: true })); + } + + onCancel(): void { + this.dialogRef.close(); + } +} diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.html b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.html new file mode 100644 index 000000000000..2175c68d2f5f --- /dev/null +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.html @@ -0,0 +1,117 @@ +
+ @if (hasSelection()) { +
+ + {{ store.historySelectedIds().length }} + {{ 'publishing-queue.selected' | dm }} + +
+ + +
+
+ } + +
+ + + + + + + + {{ 'publishing-queue.column.bundle' | dm }} + + + + {{ 'publishing-queue.column.status' | dm }} + + + + {{ 'publishing-queue.column.modified' | dm }} + + + + + + + @if (store.historyStatus() === 'loading') { + + + + + + + } @else { + + + + + + {{ row.bundleName || row.bundleId }} + + + + + + {{ row.statusUpdated || row.createDate }} + + + } + + + + + +
+ +

+ {{ 'publishing-queue.history.empty' | dm }} +

+
+ + +
+
+
+
+ diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.spec.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.spec.ts new file mode 100644 index 000000000000..cbb3c46a9f97 --- /dev/null +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.spec.ts @@ -0,0 +1,138 @@ +import { byTestId, createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; + +import { signal } from '@angular/core'; + +import { ConfirmationService } from 'primeng/api'; + +import { DotMessageService } from '@dotcms/data-access'; +import { PublishAuditStatus, PublishingJobView } from '@dotcms/dotcms-models'; +import { MockDotMessageService } from '@dotcms/utils-testing'; + +import { DotPublishingQueueHistoryComponent } from './dot-publishing-queue-history.component'; + +import { DotPublishingQueueStore } from '../dot-publishing-queue-page/store/dot-publishing-queue.store'; + +const row = ( + bundleId: string, + status: PublishAuditStatus = PublishAuditStatus.SUCCESS +): PublishingJobView => ({ + bundleId, + bundleName: `Bundle ${bundleId}`, + status, + filterName: null, + filterKey: null, + assetCount: 1, + assetPreview: [], + environmentCount: 1, + createDate: '2026-06-08T10:00:00Z', + statusUpdated: null, + numTries: 1 +}); + +describe('DotPublishingQueueHistoryComponent', () => { + let spectator: Spectator; + let store: ReturnType; + let confirmationService: jest.Mocked; + + const historyRows = signal([]); + const historyStatus = signal<'init' | 'loading' | 'loaded' | 'error'>('loaded'); + const historyTotal = signal(0); + const historyPage = signal(1); + const historySort = signal(null); + const historySortDirection = signal<'asc' | 'desc'>('desc'); + const historySelectedIds = signal([]); + const rowsPerPage = signal(10); + + function makeStoreStub() { + return { + historyRows, + historyStatus, + historyTotal, + historyPage, + historySort, + historySortDirection, + historySelectedIds, + rowsPerPage, + setHistoryPage: jest.fn((p: number) => historyPage.set(p)), + cycleHistorySort: jest.fn(), + setHistorySelection: jest.fn((ids: string[]) => historySelectedIds.set(ids)), + clearHistorySelection: jest.fn(() => historySelectedIds.set([])), + openDetail: jest.fn(), + retryBundles: jest.fn(), + deleteBundlesBulk: jest.fn() + }; + } + + const createComponent = createComponentFactory({ + component: DotPublishingQueueHistoryComponent, + componentProviders: [mockProvider(DotPublishingQueueStore, makeStoreStub())], + providers: [ + ConfirmationService, + { provide: DotMessageService, useValue: new MockDotMessageService({}) } + ] + }); + + beforeEach(() => { + historyRows.set([row('b1'), row('b2', PublishAuditStatus.FAILED_TO_PUBLISH)]); + historyStatus.set('loaded'); + historyTotal.set(2); + historyPage.set(1); + historySelectedIds.set([]); + rowsPerPage.set(10); + spectator = createComponent(); + store = spectator.inject(DotPublishingQueueStore, true) as unknown as ReturnType< + typeof makeStoreStub + >; + confirmationService = spectator.inject( + ConfirmationService + ) as jest.Mocked; + jest.spyOn(confirmationService, 'confirm').mockImplementation((cfg) => { + cfg.accept?.(); + return confirmationService; + }); + jest.clearAllMocks(); + }); + + it('renders the table', () => { + expect(spectator.query(byTestId('pq-history-table'))).toBeTruthy(); + }); + + it('renders rows with status chips', () => { + const tags = spectator.queryAll(byTestId('pq-history-status')); + expect(tags.length).toBe(2); + }); + + it('shows the bulk action bar only when there is a selection', () => { + expect(spectator.query(byTestId('pq-history-bulk-bar'))).toBeFalsy(); + + historySelectedIds.set(['b1']); + spectator.detectChanges(); + + expect(spectator.query(byTestId('pq-history-bulk-bar'))).toBeTruthy(); + }); + + it('row click opens the detail dialog', () => { + spectator.component.onRowClick(row('b1')); + expect(store.openDetail).toHaveBeenCalledWith('b1'); + }); + + it('bulk retry calls retryBundles with the selected ids', () => { + historySelectedIds.set(['b1', 'b2']); + spectator.component.onBulkRetry(); + expect(store.retryBundles).toHaveBeenCalledWith({ bundleIds: ['b1', 'b2'] }); + }); + + it('bulk remove opens confirmation, then calls deleteBundlesBulk on accept', () => { + historySelectedIds.set(['b1', 'b2']); + spectator.component.onBulkRemove(); + expect(confirmationService.confirm).toHaveBeenCalled(); + expect(store.deleteBundlesBulk).toHaveBeenCalledWith(['b1', 'b2']); + }); + + it('statusSeverity maps SUCCESS → success and failures → danger', () => { + expect(spectator.component.statusSeverity(PublishAuditStatus.SUCCESS)).toBe('success'); + expect(spectator.component.statusSeverity(PublishAuditStatus.FAILED_TO_PUBLISH)).toBe( + 'danger' + ); + }); +}); diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.ts new file mode 100644 index 000000000000..3c90e6c8d138 --- /dev/null +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.ts @@ -0,0 +1,114 @@ +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; + +import { ConfirmationService } from 'primeng/api'; +import { ButtonModule } from 'primeng/button'; +import { ConfirmDialogModule } from 'primeng/confirmdialog'; +import { SkeletonModule } from 'primeng/skeleton'; +import { TableLazyLoadEvent, TableModule } from 'primeng/table'; +import { TagModule } from 'primeng/tag'; + +import { DotMessageService, PublishingSortField } from '@dotcms/data-access'; +import { PublishAuditStatus, PublishingJobView } from '@dotcms/dotcms-models'; +import { DotMessagePipe } from '@dotcms/ui'; + +import { DotPublishingQueueStore } from '../dot-publishing-queue-page/store/dot-publishing-queue.store'; + +type ChipSeverity = 'success' | 'info' | 'warn' | 'danger' | 'secondary'; + +const SUCCESS_STATUSES = new Set([ + PublishAuditStatus.SUCCESS, + PublishAuditStatus.BUNDLE_SENT_SUCCESSFULLY, + PublishAuditStatus.BUNDLE_SAVED_SUCCESSFULLY, + PublishAuditStatus.SUCCESS_WITH_WARNINGS +]); + +@Component({ + selector: 'dot-publishing-queue-history', + standalone: true, + imports: [ + ButtonModule, + ConfirmDialogModule, + SkeletonModule, + TableModule, + TagModule, + DotMessagePipe + ], + providers: [ConfirmationService], + templateUrl: './dot-publishing-queue-history.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'flex flex-col h-full min-h-0 flex-1' } +}) +export class DotPublishingQueueHistoryComponent { + readonly store = inject(DotPublishingQueueStore); + private readonly confirmationService = inject(ConfirmationService); + private readonly dotMessageService = inject(DotMessageService); + + readonly first = computed(() => (this.store.historyPage() - 1) * this.store.rowsPerPage()); + + readonly selectedRows = computed(() => { + const selectedIds = new Set(this.store.historySelectedIds()); + return this.store.historyRows().filter((row) => selectedIds.has(row.bundleId)); + }); + + readonly hasSelection = computed(() => this.store.historySelectedIds().length > 0); + + statusSeverity(status: PublishAuditStatus): ChipSeverity { + return SUCCESS_STATUSES.has(status) ? 'success' : 'danger'; + } + + statusLabelKey(status: PublishAuditStatus): string { + return `publishing-queue.status.${status}`; + } + + onLazyLoad(event: TableLazyLoadEvent): void { + const rows = (event.rows as number) ?? this.store.rowsPerPage(); + const first = (event.first as number) ?? 0; + const page = Math.floor(first / rows) + 1; + if (page !== this.store.historyPage()) { + this.store.setHistoryPage(page); + } + + if (event.sortField) { + const field = ( + Array.isArray(event.sortField) ? event.sortField[0] : event.sortField + ) as PublishingSortField; + if ( + field !== this.store.historySort() || + (event.sortOrder === 1 ? 'asc' : 'desc') !== this.store.historySortDirection() + ) { + this.store.cycleHistorySort(field); + } + } + } + + onSelectionChange(rows: PublishingJobView[]): void { + this.store.setHistorySelection(rows.map((r) => r.bundleId)); + } + + onRowClick(row: PublishingJobView): void { + this.store.openDetail(row.bundleId); + } + + onBulkRetry(): void { + this.store.retryBundles({ bundleIds: this.store.historySelectedIds() }); + } + + onBulkRemove(): void { + const count = this.store.historySelectedIds().length; + this.confirmationService.confirm({ + header: this.dotMessageService.get('publishing-queue.history.bulk-remove.header'), + message: this.dotMessageService.get( + 'publishing-queue.history.bulk-remove.message', + `${count}` + ), + acceptLabel: this.dotMessageService.get('publishing-queue.remove'), + rejectLabel: this.dotMessageService.get('publishing-queue.cancel'), + acceptButtonStyleClass: 'p-button-danger', + rejectButtonStyleClass: 'p-button-text', + defaultFocus: 'reject', + closable: true, + closeOnEscape: true, + accept: () => this.store.deleteBundlesBulk(this.store.historySelectedIds()) + }); + } +} diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-list/dot-publishing-queue-list.component.html b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-list/dot-publishing-queue-list.component.html index 5d1fd0859a6f..e584d81ee8d0 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-list/dot-publishing-queue-list.component.html +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-list/dot-publishing-queue-list.component.html @@ -52,9 +52,30 @@

+ + + } @else if (isRetryable(job.status)) { + } } diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-list/dot-publishing-queue-list.component.spec.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-list/dot-publishing-queue-list.component.spec.ts index 232725165b60..55e70f5ca959 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-list/dot-publishing-queue-list.component.spec.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-list/dot-publishing-queue-list.component.spec.ts @@ -59,14 +59,37 @@ describe('DotPublishingQueueListComponent', () => { it('renders the Send button in ready mode (disabled)', () => { const sendBtn = spectator.query(byTestId('pq-row-send-btn'))?.querySelector('button'); expect(sendBtn).toBeTruthy(); - expect(sendBtn?.disabled).toBe(true); + expect(sendBtn?.disabled).toBe(false); + expect(spectator.query(byTestId('pq-row-kebab-btn'))).toBeTruthy(); }); - it('hides the Send button in progress mode', () => { + it('emits sendClick when Send is clicked', () => { + let emitted: PublishingJobView | undefined; + spectator.output('sendClick').subscribe((j) => (emitted = j as PublishingJobView)); + const sendBtn = spectator.query(byTestId('pq-row-send-btn'))?.querySelector('button'); + spectator.click(sendBtn as HTMLButtonElement); + expect(emitted?.bundleId).toBe('bundle-1'); + }); + + it('hides Send + kebab in progress mode', () => { spectator.setInput('mode', 'progress'); spectator.setInput('rows', [job({ status: PublishAuditStatus.BUNDLING })]); spectator.detectChanges(); expect(spectator.query(byTestId('pq-row-send-btn'))).toBeFalsy(); + expect(spectator.query(byTestId('pq-row-kebab-btn'))).toBeFalsy(); + }); + + it('shows Retry button on failed progress rows + emits retryClick', () => { + spectator.setInput('mode', 'progress'); + spectator.setInput('rows', [job({ status: PublishAuditStatus.FAILED_TO_PUBLISH })]); + spectator.detectChanges(); + const retry = spectator.query(byTestId('pq-row-retry-btn')); + expect(retry).toBeTruthy(); + + let emitted: PublishingJobView | undefined; + spectator.output('retryClick').subscribe((j) => (emitted = j as PublishingJobView)); + spectator.click(retry?.querySelector('button') as HTMLButtonElement); + expect(emitted?.bundleId).toBe('bundle-1'); }); it('shows skeletons while loading and no rows yet', () => { diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-list/dot-publishing-queue-list.component.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-list/dot-publishing-queue-list.component.ts index 075556a700cd..ca7663817218 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-list/dot-publishing-queue-list.component.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-list/dot-publishing-queue-list.component.ts @@ -1,6 +1,8 @@ import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; +import { MenuItem } from 'primeng/api'; import { ButtonModule } from 'primeng/button'; +import { MenuModule } from 'primeng/menu'; import { PaginatorModule, PaginatorState } from 'primeng/paginator'; import { SkeletonModule } from 'primeng/skeleton'; import { TagModule } from 'primeng/tag'; @@ -38,7 +40,7 @@ const READY_STATUSES_SET = new Set([ @Component({ selector: 'dot-publishing-queue-list', standalone: true, - imports: [ButtonModule, PaginatorModule, SkeletonModule, TagModule, DotMessagePipe], + imports: [ButtonModule, MenuModule, PaginatorModule, SkeletonModule, TagModule, DotMessagePipe], templateUrl: './dot-publishing-queue-list.component.html', changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'flex flex-col min-h-0 h-full' } @@ -52,8 +54,12 @@ export class DotPublishingQueueListComponent { readonly rowsPerPage = input.required(); readonly headerKey = input.required(); readonly emptyKey = input.required(); + /** Builder for the per-row kebab menu items. Only used in ready mode. */ + readonly kebabBuilder = input<(job: PublishingJobView) => MenuItem[] | null>(() => null); readonly rowClick = output(); + readonly sendClick = output(); + readonly retryClick = output(); readonly pageChange = output(); readonly first = computed(() => (this.page() - 1) * this.rowsPerPage()); @@ -77,6 +83,14 @@ export class DotPublishingQueueListComponent { return `publishing-queue.status.${status}`; } + isRetryable(status: PublishAuditStatus): boolean { + return FAILURE_STATUSES.has(status); + } + + kebabFor(job: PublishingJobView): MenuItem[] { + return this.kebabBuilder()(job) ?? []; + } + onRowKeyDown(event: KeyboardEvent, job: PublishingJobView): void { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/dot-publishing-queue-page.component.html b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/dot-publishing-queue-page.component.html index b5c13adf5395..76ebabf7f99a 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/dot-publishing-queue-page.component.html +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/dot-publishing-queue-page.component.html @@ -6,9 +6,11 @@ [total]="store.readyTotal()" [page]="store.readyPage()" [rowsPerPage]="store.rowsPerPage()" + [kebabBuilder]="readyKebabFor.bind(this)" headerKey="publishing-queue.ready.title" emptyKey="publishing-queue.empty.ready" - (rowClick)="store.openAssetList($event.bundleId)" + (rowClick)="onRowClick($event, 'ready')" + (sendClick)="onSend($event)" (pageChange)="store.setReadyPage($event)" data-testid="pq-ready-list" /> + diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/dot-publishing-queue-page.component.spec.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/dot-publishing-queue-page.component.spec.ts index bbba13c7d946..73c8febfa035 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/dot-publishing-queue-page.component.spec.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/dot-publishing-queue-page.component.spec.ts @@ -1,70 +1,75 @@ import { byTestId, createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; -import { Subject } from 'rxjs'; import { signal } from '@angular/core'; -import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog'; +import { ConfirmationService } from 'primeng/api'; import { DotMessageService } from '@dotcms/data-access'; +import { PublishAuditStatus, PublishingJobView } from '@dotcms/dotcms-models'; import { MockDotMessageService } from '@dotcms/utils-testing'; import { DotPublishingQueuePageComponent } from './dot-publishing-queue-page.component'; import { DotPublishingQueueStore } from './store/dot-publishing-queue.store'; +const buildJob = (overrides: Partial = {}): PublishingJobView => ({ + bundleId: 'b1', + bundleName: 'Bundle 1', + status: PublishAuditStatus.WAITING_FOR_PUBLISHING, + filterName: null, + filterKey: null, + assetCount: 3, + assetPreview: [], + environmentCount: 1, + createDate: '2026-06-08T10:00:00Z', + statusUpdated: null, + numTries: 0, + ...overrides +}); + describe('DotPublishingQueuePageComponent', () => { let spectator: Spectator; - let dialogService: jest.Mocked; let store: InstanceType; - const selectedBundleId = signal(null); - const onCloseSubject = new Subject(); - const dialogRefStub = { - close: jest.fn(), - onClose: onCloseSubject - } as unknown as DynamicDialogRef; + const readyRows = signal([buildJob()]); + const progressRows = signal([ + buildJob({ bundleId: 'p1', status: PublishAuditStatus.FAILED_TO_PUBLISH }) + ]); const createComponent = createComponentFactory({ component: DotPublishingQueuePageComponent, componentProviders: [ mockProvider(DotPublishingQueueStore, { - readyRows: jest.fn().mockReturnValue([]), + readyRows, + progressRows, readyStatus: jest.fn().mockReturnValue('loaded'), - readyTotal: jest.fn().mockReturnValue(0), - readyPage: jest.fn().mockReturnValue(1), - rowsPerPage: jest.fn().mockReturnValue(10), - progressRows: jest.fn().mockReturnValue([]), progressStatus: jest.fn().mockReturnValue('loaded'), - progressTotal: jest.fn().mockReturnValue(0), + readyTotal: jest.fn().mockReturnValue(1), + progressTotal: jest.fn().mockReturnValue(1), + readyPage: jest.fn().mockReturnValue(1), progressPage: jest.fn().mockReturnValue(1), - selectedBundleId: selectedBundleId, + rowsPerPage: jest.fn().mockReturnValue(10), openAssetList: jest.fn(), + openDetail: jest.fn(), + openConfigureSend: jest.fn(), + retryBundles: jest.fn(), + deleteBundle: jest.fn(), + generateBundle: jest.fn(), setReadyPage: jest.fn(), - setProgressPage: jest.fn(), - closeAssetList: jest.fn() - }) + setProgressPage: jest.fn() + }), + ConfirmationService ], - providers: [ - mockProvider(DialogService, { open: jest.fn().mockReturnValue(dialogRefStub) }), - { - provide: DotMessageService, - useValue: new MockDotMessageService({ - 'publishing-queue.asset-list.title': 'Bundle Assets', - 'publishing-queue.ready.title': 'Ready', - 'publishing-queue.in-progress.title': 'In Progress', - 'publishing-queue.empty.ready': 'Empty', - 'publishing-queue.empty.in-progress': 'Nothing in progress' - }) - } - ] + providers: [{ provide: DotMessageService, useValue: new MockDotMessageService({}) }] }); beforeEach(() => { - selectedBundleId.set(null); + readyRows.set([buildJob()]); + progressRows.set([ + buildJob({ bundleId: 'p1', status: PublishAuditStatus.FAILED_TO_PUBLISH }) + ]); spectator = createComponent(); - dialogService = spectator.inject(DialogService, true) as jest.Mocked; store = spectator.inject(DotPublishingQueueStore, true); jest.clearAllMocks(); - (dialogRefStub.close as jest.Mock).mockClear(); }); it('renders both ready and progress list slots', () => { @@ -72,32 +77,32 @@ describe('DotPublishingQueuePageComponent', () => { expect(spectator.query(byTestId('pq-progress-list'))).toBeTruthy(); }); - it('opens dialog when selectedBundleId becomes set', () => { - selectedBundleId.set('bundle-7'); - spectator.detectChanges(); - - expect(dialogService.open).toHaveBeenCalled(); - const config = (dialogService.open as jest.Mock).mock.calls[0][1]; - expect(config.width).toBe('700px'); - expect(config.closable).toBe(true); - expect(config.closeOnEscape).toBe(true); + it('ready row click opens the asset list', () => { + spectator.component.onRowClick(buildJob({ bundleId: 'B-X' }), 'ready'); + expect(store.openAssetList).toHaveBeenCalledWith('B-X'); }); - it('calls store.closeAssetList when dialog emits onClose', () => { - selectedBundleId.set('bundle-7'); - spectator.detectChanges(); - - onCloseSubject.next(undefined); + it('progress row click opens the detail dialog', () => { + spectator.component.onRowClick(buildJob({ bundleId: 'B-Y' }), 'progress'); + expect(store.openDetail).toHaveBeenCalledWith('B-Y'); + }); - expect(store.closeAssetList).toHaveBeenCalled(); + it('Send opens Configure & send for the bundle', () => { + const job = buildJob({ bundleId: 'B-Z' }); + spectator.component.onSend(job); + expect(store.openConfigureSend).toHaveBeenCalledWith(job); }); - it('closes the open dialog when selectedBundleId becomes null', () => { - selectedBundleId.set('bundle-7'); - spectator.detectChanges(); - selectedBundleId.set(null); - spectator.detectChanges(); + it('Retry calls retryBundles with the single bundle id', () => { + const job = buildJob({ bundleId: 'B-R', status: PublishAuditStatus.FAILED_TO_PUBLISH }); + spectator.component.onRetry(job); + expect(store.retryBundles).toHaveBeenCalledWith({ bundleIds: ['B-R'] }); + }); - expect(dialogRefStub.close).toHaveBeenCalled(); + it('builds 4 kebab items for a READY row (configure, generate, sep, remove)', () => { + const items = spectator.component.readyKebabFor(buildJob()); + expect(items.length).toBe(4); + expect(items[2].separator).toBe(true); + expect(items[3].styleClass).toContain('danger'); }); }); diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/dot-publishing-queue-page.component.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/dot-publishing-queue-page.component.ts index a443cf88c351..1ee0cc428a01 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/dot-publishing-queue-page.component.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/dot-publishing-queue-page.component.ts @@ -1,20 +1,21 @@ -import { ChangeDetectionStrategy, Component, effect, inject, untracked } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; -import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog'; - -import { take } from 'rxjs/operators'; +import { ConfirmationService, MenuItem } from 'primeng/api'; +import { ConfirmDialogModule } from 'primeng/confirmdialog'; +import { MenuModule } from 'primeng/menu'; import { DotMessageService } from '@dotcms/data-access'; +import { PublishingJobView } from '@dotcms/dotcms-models'; import { DotPublishingQueueStore } from './store/dot-publishing-queue.store'; -import { DotPublishingQueueAssetListDialogComponent } from '../dialogs/dot-publishing-queue-asset-list-dialog/dot-publishing-queue-asset-list-dialog.component'; import { DotPublishingQueueListComponent } from '../dot-publishing-queue-list/dot-publishing-queue-list.component'; @Component({ selector: 'dot-publishing-queue-page', standalone: true, - imports: [DotPublishingQueueListComponent], + imports: [ConfirmDialogModule, MenuModule, DotPublishingQueueListComponent], + providers: [ConfirmationService], templateUrl: './dot-publishing-queue-page.component.html', changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'flex min-h-0 flex-1' } @@ -22,37 +23,63 @@ import { DotPublishingQueueListComponent } from '../dot-publishing-queue-list/do export class DotPublishingQueuePageComponent { readonly store = inject(DotPublishingQueueStore); - private readonly dialogService = inject(DialogService); + private readonly confirmationService = inject(ConfirmationService); private readonly dotMessageService = inject(DotMessageService); - private dialogRef: DynamicDialogRef | null = null; - - constructor() { - effect(() => { - const bundleId = this.store.selectedBundleId(); - untracked(() => { - if (bundleId && !this.dialogRef) { - this.openAssetListDialog(); - } else if (!bundleId && this.dialogRef) { - this.dialogRef.close(); - this.dialogRef = null; - } - }); - }); + + readyKebabFor(job: PublishingJobView): MenuItem[] { + return [ + { + label: this.dotMessageService.get('publishing-queue.kebab.configure-send'), + icon: 'pi pi-send', + command: () => this.store.openConfigureSend(job) + }, + { + label: this.dotMessageService.get('publishing-queue.kebab.generate-download'), + icon: 'pi pi-download', + command: () => + this.store.generateBundle(job.bundleId, job.filterKey || 'ForcePush.yml') + }, + { separator: true }, + { + label: this.dotMessageService.get('publishing-queue.kebab.remove'), + icon: 'pi pi-trash', + styleClass: 'p-menuitem-danger', + command: () => this.confirmRemove(job) + } + ]; } - private openAssetListDialog(): void { - this.dialogRef = this.dialogService.open(DotPublishingQueueAssetListDialogComponent, { - header: this.dotMessageService.get('publishing-queue.asset-list.title'), - width: '700px', + onRowClick(row: PublishingJobView, mode: 'ready' | 'progress'): void { + if (mode === 'ready') { + this.store.openAssetList(row.bundleId); + } else { + this.store.openDetail(row.bundleId); + } + } + + onSend(job: PublishingJobView): void { + this.store.openConfigureSend(job); + } + + onRetry(job: PublishingJobView): void { + this.store.retryBundles({ bundleIds: [job.bundleId] }); + } + + private confirmRemove(job: PublishingJobView): void { + this.confirmationService.confirm({ + header: this.dotMessageService.get('publishing-queue.confirm-remove.header'), + message: this.dotMessageService.get( + 'publishing-queue.confirm-remove.message', + job.bundleName || job.bundleId + ), + acceptLabel: this.dotMessageService.get('publishing-queue.remove'), + rejectLabel: this.dotMessageService.get('publishing-queue.cancel'), + acceptButtonStyleClass: 'p-button-danger', + rejectButtonStyleClass: 'p-button-text', + defaultFocus: 'reject', closable: true, closeOnEscape: true, - draggable: false, - position: 'center' - }); - - this.dialogRef.onClose.pipe(take(1)).subscribe(() => { - this.dialogRef = null; - this.store.closeAssetList(); + accept: () => this.store.deleteBundle(job.bundleId) }); } } diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/store/dot-publishing-queue.store.spec.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/store/dot-publishing-queue.store.spec.ts index 5534513a4574..f342577b038d 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/store/dot-publishing-queue.store.spec.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/store/dot-publishing-queue.store.spec.ts @@ -6,6 +6,7 @@ import { BundleAssetView, IN_PROGRESS_STATUSES, PublishAuditStatus, + PublishingJobDetailView, PublishingJobsResponse, PublishingJobView, READY_STATUSES @@ -41,11 +42,35 @@ const PROGRESS_RESPONSE: PublishingJobsResponse = { pagination: { currentPage: 1, perPage: 10, totalEntries: 2 } }; +const HISTORY_RESPONSE: PublishingJobsResponse = { + entity: [buildJob({ bundleId: 'hist-1', status: PublishAuditStatus.SUCCESS })], + pagination: { currentPage: 1, perPage: 10, totalEntries: 1 } +}; + const MOCK_ASSETS: BundleAssetView[] = [ { id: 'a1', title: 'Asset 1', type: 'contentlet' }, { id: 'a2', title: 'Asset 2', type: 'template' } ]; +const MOCK_DETAIL: PublishingJobDetailView = { + bundleId: 'ready-1', + bundleName: 'Bundle One', + status: PublishAuditStatus.SUCCESS, + filterName: null, + filterKey: null, + assetCount: 1, + environments: [], + timestamps: { + bundleStart: null, + bundleEnd: null, + publishStart: null, + publishEnd: null, + createDate: '2026-06-08T10:00:00Z', + statusUpdated: null + }, + numTries: 1 +}; + describe('DotPublishingQueueStore', () => { let spectator: SpectatorService>; let store: InstanceType; @@ -56,12 +81,36 @@ describe('DotPublishingQueueStore', () => { service: DotPublishingQueueStore, providers: [ mockProvider(DotPublishingQueueService, { - listPublishingJobs: jest + listPublishingJobs: jest.fn().mockImplementation(({ statuses }) => { + if (statuses === READY_STATUSES) return of(READY_RESPONSE); + if (statuses === IN_PROGRESS_STATUSES) return of(PROGRESS_RESPONSE); + return of(HISTORY_RESPONSE); + }), + getBundleAssets: jest.fn().mockReturnValue(of(MOCK_ASSETS)), + getPublishingJobDetails: jest.fn().mockReturnValue(of(MOCK_DETAIL)), + getEnvironments: jest.fn().mockReturnValue( + of([ + { id: 'env-1', name: 'Prod' }, + { id: 'env-2', name: 'Stage' } + ]) + ), + pushBundle: jest .fn() - .mockImplementation(({ statuses }) => - of(statuses === READY_STATUSES ? READY_RESPONSE : PROGRESS_RESPONSE) + .mockReturnValue( + of({ + bundleId: 'b', + operation: 'publish', + environments: [], + filterKey: 'k' + }) ), - getBundleAssets: jest.fn().mockReturnValue(of(MOCK_ASSETS)) + retryBundles: jest.fn().mockReturnValue(of([])), + deleteBundle: jest.fn().mockReturnValue(of({ message: 'ok' })), + deleteBundles: jest.fn().mockReturnValue(of({ message: 'ok', deleted: [] })), + generateBundle: jest.fn().mockReturnValue(of({})), + uploadBundle: jest + .fn() + .mockReturnValue(of({ bundleName: 'b', status: 'BUNDLE_REQUESTED' })) }), mockProvider(DotHttpErrorManagerService) ] @@ -76,144 +125,240 @@ describe('DotPublishingQueueStore', () => { httpErrorManager = spectator.inject( DotHttpErrorManagerService ) as jest.Mocked; - // onInit effect kicks off loadReady + loadProgress spectator.flushEffects(); }); + afterEach(() => { + store.stopPolling(); + }); + describe('onInit', () => { - it('loads ready + progress columns on init', () => { + it('loads queue tab columns on init', () => { expect(service.listPublishingJobs).toHaveBeenCalledTimes(2); - expect(service.listPublishingJobs).toHaveBeenCalledWith({ - statuses: READY_STATUSES, - page: 1, - perPage: 10, - filter: undefined - }); - expect(service.listPublishingJobs).toHaveBeenCalledWith({ - statuses: IN_PROGRESS_STATUSES, - page: 1, - perPage: 10, - filter: undefined - }); - expect(store.readyRows()).toEqual(READY_RESPONSE.entity); - expect(store.readyTotal()).toBe(1); expect(store.readyStatus()).toBe('loaded'); - expect(store.progressRows()).toEqual(PROGRESS_RESPONSE.entity); - expect(store.progressTotal()).toBe(2); expect(store.progressStatus()).toBe('loaded'); }); }); describe('setSearch', () => { - it('updates search, resets both pages to 1, and triggers reload', () => { + it('resets pages and clears history selection', () => { store.setReadyPage(3); store.setProgressPage(2); + store.setHistoryPage(4); + store.setHistorySelection(['x']); spectator.flushEffects(); (service.listPublishingJobs as jest.Mock).mockClear(); - store.setSearch('bundle-name'); + store.setSearch('term'); spectator.flushEffects(); - expect(store.search()).toBe('bundle-name'); + expect(store.search()).toBe('term'); expect(store.readyPage()).toBe(1); expect(store.progressPage()).toBe(1); - expect(service.listPublishingJobs).toHaveBeenCalledWith( - expect.objectContaining({ filter: 'bundle-name' }) - ); + expect(store.historyPage()).toBe(1); + expect(store.historySelectedIds()).toEqual([]); }); }); - describe('setReadyPage / setProgressPage', () => { - it('reloads ready when ready page changes (not progress)', () => { + describe('setActiveTab', () => { + it('switching to history triggers loadHistory', () => { (service.listPublishingJobs as jest.Mock).mockClear(); - - store.setReadyPage(2); + store.setActiveTab('history'); spectator.flushEffects(); - - // Effect re-runs both because it reads multiple signals; - // the assertion verifies the new page param was forwarded. expect(service.listPublishingJobs).toHaveBeenCalledWith( - expect.objectContaining({ statuses: READY_STATUSES, page: 2 }) + expect.objectContaining({ + sort: undefined, + sortDirection: 'desc' + }) ); }); + }); - it('reloads progress when progress page changes', () => { - (service.listPublishingJobs as jest.Mock).mockClear(); + describe('cycleHistorySort', () => { + it('cycles asc → desc → off for the same field', () => { + store.setActiveTab('history'); + store.cycleHistorySort('bundle_name'); + expect(store.historySort()).toBe('bundle_name'); + expect(store.historySortDirection()).toBe('asc'); - store.setProgressPage(4); - spectator.flushEffects(); + store.cycleHistorySort('bundle_name'); + expect(store.historySortDirection()).toBe('desc'); - expect(service.listPublishingJobs).toHaveBeenCalledWith( - expect.objectContaining({ statuses: IN_PROGRESS_STATUSES, page: 4 }) - ); + store.cycleHistorySort('bundle_name'); + expect(store.historySort()).toBeNull(); + }); + + it('switching field starts asc again', () => { + store.setActiveTab('history'); + store.cycleHistorySort('bundle_name'); + store.cycleHistorySort('status'); + expect(store.historySort()).toBe('status'); + expect(store.historySortDirection()).toBe('asc'); }); }); describe('refresh', () => { - it('re-fires both list calls', () => { + it('reloads queue when active tab is queue', () => { (service.listPublishingJobs as jest.Mock).mockClear(); - store.refresh(); - expect(service.listPublishingJobs).toHaveBeenCalledTimes(2); }); - }); - describe('openAssetList / loadAssets / closeAssetList', () => { - it('opens, sets selectedBundleId, and loads assets', () => { - store.openAssetList('bundle-X'); + it('reloads history when active tab is history', () => { + store.setActiveTab('history'); + spectator.flushEffects(); + (service.listPublishingJobs as jest.Mock).mockClear(); + store.refresh(); + expect(service.listPublishingJobs).toHaveBeenCalledTimes(1); + }); + }); - expect(store.selectedBundleId()).toBe('bundle-X'); - expect(service.getBundleAssets).toHaveBeenCalledWith('bundle-X'); + describe('openAssetList / closeAssetList', () => { + it('opens and loads assets', () => { + store.openAssetList('B-X'); + expect(store.selectedBundleId()).toBe('B-X'); + expect(service.getBundleAssets).toHaveBeenCalledWith('B-X'); expect(store.selectedAssets()).toEqual(MOCK_ASSETS); expect(store.assetListStatus()).toBe('loaded'); }); - it('closeAssetList clears state', () => { - store.openAssetList('bundle-X'); + it('closes clears state', () => { + store.openAssetList('B-X'); store.closeAssetList(); - expect(store.selectedBundleId()).toBeNull(); expect(store.selectedAssets()).toEqual([]); - expect(store.assetListStatus()).toBe('init'); + }); + }); + + describe('openDetail / loadDetail / closeDetail', () => { + it('loads details when opened', () => { + store.openDetail('B-Y'); + expect(service.getPublishingJobDetails).toHaveBeenCalledWith('B-Y'); + expect(store.detail()).toEqual(MOCK_DETAIL); + expect(store.detailStatus()).toBe('loaded'); }); - it('loadAssets is a no-op when no bundle is selected', () => { - (service.getBundleAssets as jest.Mock).mockClear(); - store.loadAssets(); - expect(service.getBundleAssets).not.toHaveBeenCalled(); + it('closeDetail clears state', () => { + store.openDetail('B-Y'); + store.closeDetail(); + expect(store.detailBundleId()).toBeNull(); + expect(store.detail()).toBeNull(); + }); + }); + + describe('openConfigureSend / submitPush / closeConfigureSend', () => { + it('loads environments + sets target on open', () => { + store.openConfigureSend(buildJob({ bundleId: 'B-Z' })); + expect(store.pushBundleTarget()?.bundleId).toBe('B-Z'); + expect(service.getEnvironments).toHaveBeenCalled(); + expect(store.environments().length).toBeGreaterThan(0); + }); + + it('submitPush calls the service and clears the target on success', () => { + const onDone = jest.fn(); + store.openConfigureSend(buildJob({ bundleId: 'B-Z' })); + store.submitPush( + 'B-Z', + { + operation: 'publish', + environments: ['env-1'], + filterKey: 'k' + }, + onDone + ); + expect(service.pushBundle).toHaveBeenCalled(); + expect(store.pushBundleTarget()).toBeNull(); + expect(onDone).toHaveBeenCalled(); + }); + + it('closeConfigureSend clears the target', () => { + store.openConfigureSend(buildJob()); + store.closeConfigureSend(); + expect(store.pushBundleTarget()).toBeNull(); + }); + }); + + describe('retryBundles / deleteBundle / deleteBundlesBulk / generateBundle', () => { + it('retryBundles calls service and refreshes', () => { + const onDone = jest.fn(); + store.retryBundles({ bundleIds: ['x'] }, onDone); + expect(service.retryBundles).toHaveBeenCalledWith({ bundleIds: ['x'] }); + expect(onDone).toHaveBeenCalled(); + }); + + it('deleteBundle calls service', () => { + store.deleteBundle('x'); + expect(service.deleteBundle).toHaveBeenCalledWith('x'); + }); + + it('deleteBundlesBulk loops per-id (until #36046 lands) and clears selection', () => { + store.setHistorySelection(['a', 'b']); + store.deleteBundlesBulk(['a', 'b']); + expect(service.deleteBundle).toHaveBeenCalledWith('a'); + expect(service.deleteBundle).toHaveBeenCalledWith('b'); + expect(store.historySelectedIds()).toEqual([]); + }); + + it('generateBundle calls service with bundleId + filterKey', () => { + const onDone = jest.fn(); + store.generateBundle('x', 'force.yml', onDone); + expect(service.generateBundle).toHaveBeenCalledWith('x', 'force.yml'); + expect(onDone).toHaveBeenCalled(); + }); + }); + + describe('uploadBundle', () => { + it('toggles uploadInFlight and refreshes on success', () => { + const onDone = jest.fn(); + const file = new File(['x'], 'b.tar.gz'); + store.uploadBundle(file, onDone); + expect(service.uploadBundle).toHaveBeenCalledWith(file); + expect(store.uploadInFlight()).toBe(false); + expect(onDone).toHaveBeenCalled(); + }); + }); + + describe('polling', () => { + it('startPolling / stopPolling do not throw', () => { + store.stopPolling(); + store.startPolling(); + store.stopPolling(); }); }); describe('error handling', () => { - it('loadReady error → httpErrorManager.handle called, status = error', () => { + it('loadReady error → handle + status=error', () => { const error = new Error('boom'); (service.listPublishingJobs as jest.Mock).mockReturnValueOnce(throwError(() => error)); - store.loadReady(); - expect(httpErrorManager.handle).toHaveBeenCalledWith(error); expect(store.readyStatus()).toBe('error'); }); - it('loadProgress error → httpErrorManager.handle called, status = error', () => { + it('loadHistory error → handle + status=error', () => { const error = new Error('boom'); (service.listPublishingJobs as jest.Mock).mockReturnValueOnce(throwError(() => error)); - - store.loadProgress(); - + store.loadHistory(); expect(httpErrorManager.handle).toHaveBeenCalledWith(error); - expect(store.progressStatus()).toBe('error'); + expect(store.historyStatus()).toBe('error'); }); - it('loadAssets error → httpErrorManager.handle called, status = loaded', () => { + it('loadDetail error → handle + status=error', () => { const error = new Error('boom'); - (service.getBundleAssets as jest.Mock).mockReturnValueOnce(throwError(() => error)); - - store.openAssetList('bundle-Y'); + (service.getPublishingJobDetails as jest.Mock).mockReturnValueOnce( + throwError(() => error) + ); + store.openDetail('y'); + expect(httpErrorManager.handle).toHaveBeenCalledWith(error); + expect(store.detailStatus()).toBe('error'); + }); + it('uploadBundle error → handle + uploadInFlight reset', () => { + const error = new Error('boom'); + (service.uploadBundle as jest.Mock).mockReturnValueOnce(throwError(() => error)); + store.uploadBundle(new File(['x'], 'b.tar.gz')); expect(httpErrorManager.handle).toHaveBeenCalledWith(error); - expect(store.assetListStatus()).toBe('loaded'); + expect(store.uploadInFlight()).toBe(false); }); }); }); diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/store/dot-publishing-queue.store.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/store/dot-publishing-queue.store.ts index 3fe59c53e1c4..f7b73079c6f7 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/store/dot-publishing-queue.store.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/store/dot-publishing-queue.store.ts @@ -1,21 +1,52 @@ import { patchState, signalStore, withHooks, withMethods, withState } from '@ngrx/signals'; -import { EMPTY } from 'rxjs'; +import { EMPTY, forkJoin } from 'rxjs'; -import { effect, inject, untracked } from '@angular/core'; +import { DestroyRef, effect, inject, untracked } from '@angular/core'; import { catchError, take } from 'rxjs/operators'; -import { DotHttpErrorManagerService, DotPublishingQueueService } from '@dotcms/data-access'; +import { + DotHttpErrorManagerService, + DotPublishingQueueService, + PublishingSortDirection, + PublishingSortField, + PushBundlePayload, + RetryBundlesPayload +} from '@dotcms/data-access'; import { BundleAssetView, + DotEnvironment, IN_PROGRESS_STATUSES, + PublishAuditStatus, + PublishingJobDetailView, PublishingJobView, READY_STATUSES } from '@dotcms/dotcms-models'; type LoadStatus = 'init' | 'loading' | 'loaded' | 'error'; +const HISTORY_STATUSES: readonly PublishAuditStatus[] = [ + PublishAuditStatus.SUCCESS, + PublishAuditStatus.SUCCESS_WITH_WARNINGS, + PublishAuditStatus.BUNDLE_SENT_SUCCESSFULLY, + PublishAuditStatus.BUNDLE_SAVED_SUCCESSFULLY, + PublishAuditStatus.FAILED_TO_SEND_TO_ALL_GROUPS, + PublishAuditStatus.FAILED_TO_SEND_TO_SOME_GROUPS, + PublishAuditStatus.FAILED_TO_BUNDLE, + PublishAuditStatus.FAILED_TO_SENT, + PublishAuditStatus.FAILED_TO_PUBLISH, + PublishAuditStatus.FAILED_INTEGRITY_CHECK, + PublishAuditStatus.INVALID_TOKEN, + PublishAuditStatus.LICENSE_REQUIRED +]; + +const POLL_INTERVAL_MS = 15000; + +export type ActiveTab = 'queue' | 'history'; + interface DotPublishingQueueState { + activeTab: ActiveTab; + readyRows: PublishingJobView[]; readyPage: number; readyTotal: number; @@ -26,15 +57,39 @@ interface DotPublishingQueueState { progressTotal: number; progressStatus: LoadStatus; + historyRows: PublishingJobView[]; + historyPage: number; + historyTotal: number; + historyStatus: LoadStatus; + historySort: PublishingSortField | null; + historySortDirection: PublishingSortDirection; + historySelectedIds: string[]; + rowsPerPage: number; search: string; + siteId: string | null; selectedBundleId: string | null; selectedAssets: BundleAssetView[]; assetListStatus: LoadStatus; + + detailBundleId: string | null; + detail: PublishingJobDetailView | null; + detailStatus: LoadStatus; + + environments: DotEnvironment[]; + environmentsStatus: LoadStatus; + + pushBundleTarget: PublishingJobView | null; + pushInFlight: boolean; + + uploadInFlight: boolean; + uploadProgress: number; } const initialState: DotPublishingQueueState = { + activeTab: 'queue', + readyRows: [], readyPage: 1, readyTotal: 0, @@ -45,12 +100,34 @@ const initialState: DotPublishingQueueState = { progressTotal: 0, progressStatus: 'init', + historyRows: [], + historyPage: 1, + historyTotal: 0, + historyStatus: 'init', + historySort: null, + historySortDirection: 'desc', + historySelectedIds: [], + rowsPerPage: 10, search: '', + siteId: null, selectedBundleId: null, selectedAssets: [], - assetListStatus: 'init' + assetListStatus: 'init', + + detailBundleId: null, + detail: null, + detailStatus: 'init', + + environments: [], + environmentsStatus: 'init', + + pushBundleTarget: null, + pushInFlight: false, + + uploadInFlight: false, + uploadProgress: 0 }; export const DotPublishingQueueStore = signalStore( @@ -58,6 +135,9 @@ export const DotPublishingQueueStore = signalStore( withMethods((store) => { const service = inject(DotPublishingQueueService); const httpErrorManager = inject(DotHttpErrorManagerService); + const destroyRef = inject(DestroyRef); + + let pollHandle: ReturnType | null = null; function loadReady() { patchState(store, { readyStatus: 'loading' }); @@ -113,6 +193,35 @@ export const DotPublishingQueueStore = signalStore( }); } + function loadHistory() { + patchState(store, { historyStatus: 'loading' }); + service + .listPublishingJobs({ + statuses: HISTORY_STATUSES, + page: store.historyPage(), + perPage: store.rowsPerPage(), + filter: store.search() || undefined, + sort: store.historySort() ?? undefined, + sortDirection: store.historySortDirection() + }) + .pipe( + take(1), + catchError((error) => { + httpErrorManager.handle(error); + patchState(store, { historyStatus: 'error' }); + + return EMPTY; + }) + ) + .subscribe((response) => { + patchState(store, { + historyRows: response.entity, + historyTotal: response.pagination?.totalEntries ?? 0, + historyStatus: 'loaded' + }); + }); + } + function loadAssets() { const bundleId = store.selectedBundleId(); if (!bundleId) { @@ -139,13 +248,120 @@ export const DotPublishingQueueStore = signalStore( }); } + function loadDetail() { + const bundleId = store.detailBundleId(); + if (!bundleId) { + return; + } + + patchState(store, { detailStatus: 'loading', detail: null }); + service + .getPublishingJobDetails(bundleId) + .pipe( + take(1), + catchError((error) => { + httpErrorManager.handle(error); + patchState(store, { detailStatus: 'error' }); + + return EMPTY; + }) + ) + .subscribe((detail) => { + patchState(store, { detail, detailStatus: 'loaded' }); + }); + } + + function loadEnvironments() { + if ( + store.environmentsStatus() === 'loading' || + store.environmentsStatus() === 'loaded' + ) { + return; + } + patchState(store, { environmentsStatus: 'loading' }); + service + .getEnvironments() + .pipe( + take(1), + catchError((error) => { + httpErrorManager.handle(error); + patchState(store, { environmentsStatus: 'error' }); + + return EMPTY; + }) + ) + .subscribe((environments) => { + patchState(store, { environments, environmentsStatus: 'loaded' }); + }); + } + + function refresh() { + const tab = store.activeTab(); + if (tab === 'queue') { + loadReady(); + loadProgress(); + } else { + loadHistory(); + } + } + + function refreshProgressOnly() { + if (store.activeTab() === 'queue') { + loadProgress(); + } + } + + function startPolling() { + stopPolling(); + pollHandle = setInterval(() => { + if (document.hidden) { + return; + } + refreshProgressOnly(); + }, POLL_INTERVAL_MS); + } + + function stopPolling() { + if (pollHandle !== null) { + clearInterval(pollHandle); + pollHandle = null; + } + } + + destroyRef.onDestroy(() => stopPolling()); + return { loadReady, loadProgress, + loadHistory, loadAssets, + loadDetail, + loadEnvironments, + refresh, + startPolling, + stopPolling, + + setActiveTab(tab: ActiveTab) { + patchState(store, { activeTab: tab }); + }, setSearch(search: string) { - patchState(store, { search, readyPage: 1, progressPage: 1 }); + patchState(store, { + search, + readyPage: 1, + progressPage: 1, + historyPage: 1, + historySelectedIds: [] + }); + }, + + setSiteId(siteId: string | null) { + patchState(store, { + siteId, + readyPage: 1, + progressPage: 1, + historyPage: 1 + }); }, setReadyPage(page: number) { @@ -156,9 +372,38 @@ export const DotPublishingQueueStore = signalStore( patchState(store, { progressPage: page }); }, - refresh() { - loadReady(); - loadProgress(); + setHistoryPage(page: number) { + patchState(store, { historyPage: page }); + }, + + cycleHistorySort(field: PublishingSortField) { + const current = store.historySort(); + const dir = store.historySortDirection(); + if (current !== field) { + patchState(store, { + historySort: field, + historySortDirection: 'asc', + historyPage: 1 + }); + return; + } + if (dir === 'asc') { + patchState(store, { historySortDirection: 'desc', historyPage: 1 }); + return; + } + patchState(store, { + historySort: null, + historySortDirection: 'desc', + historyPage: 1 + }); + }, + + setHistorySelection(ids: string[]) { + patchState(store, { historySelectedIds: ids }); + }, + + clearHistorySelection() { + patchState(store, { historySelectedIds: [] }); }, openAssetList(bundleId: string) { @@ -176,6 +421,137 @@ export const DotPublishingQueueStore = signalStore( selectedAssets: [], assetListStatus: 'init' }); + }, + + openDetail(bundleId: string) { + patchState(store, { + detailBundleId: bundleId, + detail: null, + detailStatus: 'init' + }); + loadDetail(); + }, + + closeDetail() { + patchState(store, { + detailBundleId: null, + detail: null, + detailStatus: 'init' + }); + }, + + openConfigureSend(bundle: PublishingJobView) { + patchState(store, { pushBundleTarget: bundle }); + loadEnvironments(); + }, + + closeConfigureSend() { + patchState(store, { pushBundleTarget: null }); + }, + + submitPush(bundleId: string, payload: PushBundlePayload, onDone: () => void) { + patchState(store, { pushInFlight: true }); + service + .pushBundle(bundleId, payload) + .pipe( + take(1), + catchError((error) => { + httpErrorManager.handle(error); + patchState(store, { pushInFlight: false }); + return EMPTY; + }) + ) + .subscribe(() => { + patchState(store, { pushInFlight: false, pushBundleTarget: null }); + refresh(); + onDone(); + }); + }, + + retryBundles(payload: RetryBundlesPayload, onDone?: () => void) { + service + .retryBundles(payload) + .pipe( + take(1), + catchError((error) => { + httpErrorManager.handle(error); + return EMPTY; + }) + ) + .subscribe(() => { + refresh(); + onDone?.(); + }); + }, + + deleteBundle(bundleId: string, onDone?: () => void) { + service + .deleteBundle(bundleId) + .pipe( + take(1), + catchError((error) => { + httpErrorManager.handle(error); + return EMPTY; + }) + ) + .subscribe(() => { + refresh(); + onDone?.(); + }); + }, + + deleteBundlesBulk(bundleIds: string[], onDone?: () => void) { + // Fans out per-id until BE adds the bulk DELETE in #36046. + // Uses forkJoin so we refresh once after every id resolves (success or skip). + forkJoin( + bundleIds.map((id) => + service.deleteBundle(id).pipe( + take(1), + catchError((error) => { + httpErrorManager.handle(error); + return EMPTY; + }) + ) + ) + ) + .pipe(take(1)) + .subscribe(() => { + patchState(store, { historySelectedIds: [] }); + refresh(); + onDone?.(); + }); + }, + + generateBundle(bundleId: string, filterKey: string, onDone?: () => void) { + service + .generateBundle(bundleId, filterKey) + .pipe( + take(1), + catchError((error) => { + httpErrorManager.handle(error); + return EMPTY; + }) + ) + .subscribe(() => onDone?.()); + }, + + uploadBundle(file: File, onDone?: () => void) { + patchState(store, { uploadInFlight: true, uploadProgress: 0 }); + service + .uploadBundle(file) + .pipe( + take(1), + catchError((error) => { + httpErrorManager.handle(error); + patchState(store, { uploadInFlight: false, uploadProgress: 0 }); + return EMPTY; + }) + ) + .subscribe(() => { + patchState(store, { uploadInFlight: false, uploadProgress: 100 }); + refresh(); + onDone?.(); + }); } }; }), @@ -183,15 +559,24 @@ export const DotPublishingQueueStore = signalStore( return { onInit() { effect(() => { + const tab = store.activeTab(); store.search(); - store.readyPage(); - store.progressPage(); - - untracked(() => { - store.loadReady(); - store.loadProgress(); - }); + if (tab === 'queue') { + store.readyPage(); + store.progressPage(); + untracked(() => { + store.loadReady(); + store.loadProgress(); + }); + } else { + store.historyPage(); + store.historySort(); + store.historySortDirection(); + untracked(() => store.loadHistory()); + } }); + + store.startPolling(); } }; }) diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.html b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.html new file mode 100644 index 000000000000..6c46f525807a --- /dev/null +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.html @@ -0,0 +1,26 @@ + + + + + + {{ 'publishing-queue.tab.queue' | dm }} + + + {{ 'publishing-queue.tab.history' | dm }} + + + + + + + + + + + + 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 675beb6433ee..c0a39c8fe6b7 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 @@ -1,24 +1,42 @@ import { createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; -import { of } from 'rxjs'; +import { Subject, of } from 'rxjs'; -import { provideHttpClient } from '@angular/common/http'; +import { CUSTOM_ELEMENTS_SCHEMA, NO_ERRORS_SCHEMA } from '@angular/core'; + +import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog'; import { + DotEventsService, DotHttpErrorManagerService, DotMessageService, - DotPublishingQueueService + DotPublishingQueueService, + DotPushPublishFiltersService, + DotSiteService } from '@dotcms/data-access'; import { MockDotMessageService } from '@dotcms/utils-testing'; import { DotPublishingQueueShellComponent } from './dot-publishing-queue-shell.component'; +import { DotPublishingQueueStore } from '../dot-publishing-queue-page/store/dot-publishing-queue.store'; + describe('DotPublishingQueueShellComponent', () => { let spectator: Spectator; + let dialogService: jest.Mocked; + let store: InstanceType; + + const onCloseSubject = new Subject(); + const dialogRef = { + close: jest.fn(), + onClose: onCloseSubject + } as unknown as DynamicDialogRef; const createComponent = createComponentFactory({ component: DotPublishingQueueShellComponent, + componentProviders: [ + DotPublishingQueueStore, + mockProvider(DialogService, { open: jest.fn().mockReturnValue(dialogRef) }) + ], providers: [ - provideHttpClient(), mockProvider(DotPublishingQueueService, { listPublishingJobs: jest .fn() @@ -28,19 +46,79 @@ describe('DotPublishingQueueShellComponent', () => { pagination: { currentPage: 1, perPage: 10, totalEntries: 0 } }) ), - getBundleAssets: jest.fn().mockReturnValue(of([])) + getBundleAssets: jest.fn().mockReturnValue(of([])), + getPublishingJobDetails: jest.fn().mockReturnValue(of({})), + getEnvironments: jest.fn().mockReturnValue(of([])) }), mockProvider(DotHttpErrorManagerService), + mockProvider(DotPushPublishFiltersService, { get: jest.fn().mockReturnValue(of([])) }), + mockProvider(DotEventsService, { listen: jest.fn().mockReturnValue(of({})) }), + mockProvider(DotSiteService, { + getSites: jest.fn().mockReturnValue(of({ sites: [], total: 0 })) + }), { provide: DotMessageService, useValue: new MockDotMessageService({}) } - ] + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA, NO_ERRORS_SCHEMA] }); beforeEach(() => { + jest.clearAllMocks(); spectator = createComponent(); + dialogService = spectator.inject(DialogService, true) as jest.Mocked; + store = spectator.inject(DotPublishingQueueStore, true); }); - it('renders toolbar and page', () => { + it('renders the toolbar', () => { expect(spectator.query('dot-publishing-queue-toolbar')).toBeTruthy(); - expect(spectator.query('dot-publishing-queue-page')).toBeTruthy(); + }); + + describe('asset list dialog sync', () => { + it('opens dialog when selectedBundleId becomes set', () => { + store.openAssetList('B-1'); + spectator.detectChanges(); + expect(dialogService.open).toHaveBeenCalled(); + }); + + it('calls store.closeAssetList when dialog closes', () => { + store.openAssetList('B-1'); + spectator.detectChanges(); + onCloseSubject.next(undefined); + expect(store.selectedBundleId()).toBeNull(); + }); + }); + + describe('detail dialog sync', () => { + it('opens dialog when detailBundleId becomes set', () => { + store.openDetail('B-2'); + spectator.detectChanges(); + expect(dialogService.open).toHaveBeenCalled(); + }); + }); + + describe('configure & send dialog sync', () => { + it('opens dialog when pushBundleTarget becomes set', () => { + store.openConfigureSend({ + bundleId: 'B-3', + bundleName: 'Bundle 3' + } as Parameters[0]); + spectator.detectChanges(); + expect(dialogService.open).toHaveBeenCalled(); + }); + }); + + describe('upload', () => { + it('opens dialog when openUpload is called', () => { + spectator.component.openUpload(); + expect(dialogService.open).toHaveBeenCalled(); + }); + }); + + describe('tab change', () => { + it('forwards value to setActiveTab', () => { + spectator.component.onTabChange('history'); + expect(store.activeTab()).toBe('history'); + spectator.component.onTabChange('queue'); + expect(store.activeTab()).toBe('queue'); + }); }); }); diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.ts index 4857987caed8..e8d62075ec54 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.ts @@ -1,21 +1,155 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component, effect, inject, untracked } from '@angular/core'; -import { DialogService } from 'primeng/dynamicdialog'; +import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog'; +import { TabsModule } from 'primeng/tabs'; + +import { take } from 'rxjs/operators'; + +import { DotMessageService } from '@dotcms/data-access'; +import { DotMessagePipe } from '@dotcms/ui'; import { DotPublishingQueueToolbarComponent } from '../components/dot-publishing-queue-toolbar/dot-publishing-queue-toolbar.component'; +import { DotPublishingQueueAssetListDialogComponent } from '../dialogs/dot-publishing-queue-asset-list-dialog/dot-publishing-queue-asset-list-dialog.component'; +import { DotPublishingQueueBundleDetailsDialogComponent } from '../dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component'; +import { DotPublishingQueueConfigureSendDialogComponent } from '../dialogs/dot-publishing-queue-configure-send-dialog/dot-publishing-queue-configure-send-dialog.component'; +import { DotPublishingQueueUploadDialogComponent } from '../dialogs/dot-publishing-queue-upload-dialog/dot-publishing-queue-upload-dialog.component'; +import { DotPublishingQueueHistoryComponent } from '../dot-publishing-queue-history/dot-publishing-queue-history.component'; import { DotPublishingQueuePageComponent } from '../dot-publishing-queue-page/dot-publishing-queue-page.component'; import { DotPublishingQueueStore } from '../dot-publishing-queue-page/store/dot-publishing-queue.store'; @Component({ selector: 'dot-publishing-queue-shell', standalone: true, - imports: [DotPublishingQueueToolbarComponent, DotPublishingQueuePageComponent], + imports: [ + TabsModule, + DotPublishingQueueToolbarComponent, + DotPublishingQueuePageComponent, + DotPublishingQueueHistoryComponent, + DotMessagePipe + ], providers: [DotPublishingQueueStore, DialogService], - template: ` - - - `, + templateUrl: './dot-publishing-queue-shell.component.html', changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'flex flex-col h-full min-h-0 block' } }) -export class DotPublishingQueueShellComponent {} +export class DotPublishingQueueShellComponent { + readonly store = inject(DotPublishingQueueStore); + + private readonly dialogService = inject(DialogService); + private readonly dotMessageService = inject(DotMessageService); + + private detailRef: DynamicDialogRef | null = null; + private configureRef: DynamicDialogRef | null = null; + private uploadRef: DynamicDialogRef | null = null; + private assetListRef: DynamicDialogRef | null = null; + + readonly TABS = ['queue', 'history'] as const; + + constructor() { + effect(() => { + const bundleId = this.store.selectedBundleId(); + untracked(() => this.syncAssetList(bundleId)); + }); + + effect(() => { + const bundleId = this.store.detailBundleId(); + untracked(() => this.syncDetail(bundleId)); + }); + + effect(() => { + const target = this.store.pushBundleTarget(); + untracked(() => this.syncConfigure(target !== null)); + }); + } + + onTabChange(value: string | number): void { + this.store.setActiveTab(value === 'history' ? 'history' : 'queue'); + } + + openUpload(): void { + if (this.uploadRef) { + return; + } + this.uploadRef = this.dialogService.open(DotPublishingQueueUploadDialogComponent, { + header: this.dotMessageService.get('publishing-queue.upload.title'), + width: '700px', + contentStyle: { height: '460px' }, + closable: true, + closeOnEscape: true, + draggable: false, + position: 'center' + }); + this.uploadRef.onClose.pipe(take(1)).subscribe(() => { + this.uploadRef = null; + }); + } + + private syncAssetList(bundleId: string | null): void { + if (bundleId && !this.assetListRef) { + this.assetListRef = this.dialogService.open( + DotPublishingQueueAssetListDialogComponent, + { + header: this.dotMessageService.get('publishing-queue.asset-list.title'), + width: '700px', + closable: true, + closeOnEscape: true, + draggable: false, + position: 'center' + } + ); + this.assetListRef.onClose.pipe(take(1)).subscribe(() => { + this.assetListRef = null; + this.store.closeAssetList(); + }); + } else if (!bundleId && this.assetListRef) { + this.assetListRef.close(); + this.assetListRef = null; + } + } + + private syncDetail(bundleId: string | null): void { + if (bundleId && !this.detailRef) { + this.detailRef = this.dialogService.open( + DotPublishingQueueBundleDetailsDialogComponent, + { + header: this.dotMessageService.get('publishing-queue.detail.title'), + width: '780px', + closable: true, + closeOnEscape: true, + draggable: false, + position: 'center' + } + ); + this.detailRef.onClose.pipe(take(1)).subscribe(() => { + this.detailRef = null; + this.store.closeDetail(); + }); + } else if (!bundleId && this.detailRef) { + this.detailRef.close(); + this.detailRef = null; + } + } + + private syncConfigure(open: boolean): void { + if (open && !this.configureRef) { + this.configureRef = this.dialogService.open( + DotPublishingQueueConfigureSendDialogComponent, + { + header: this.dotMessageService.get('publishing-queue.configure-send.title'), + width: '720px', + closable: true, + closeOnEscape: true, + draggable: false, + position: 'center' + } + ); + this.configureRef.onClose.pipe(take(1)).subscribe(() => { + this.configureRef = null; + this.store.closeConfigureSend(); + }); + } else if (!open && this.configureRef) { + this.configureRef.close(); + this.configureRef = null; + } + } +} diff --git a/core-web/libs/portlets/dot-publishing-queue/src/test-setup.ts b/core-web/libs/portlets/dot-publishing-queue/src/test-setup.ts index b13563bb93c0..29b4a8b073cf 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/test-setup.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/test-setup.ts @@ -4,3 +4,15 @@ setupZoneTestEnv({ errorOnUnknownElements: true, errorOnUnknownProperties: true }); + +class MockResizeObserver { + observe = jest.fn(); + unobserve = jest.fn(); + disconnect = jest.fn(); +} + +Object.defineProperty(window, 'ResizeObserver', { + writable: true, + configurable: true, + value: MockResizeObserver +}); From 4037903fa7ca642f5ca25effcf54c9210e230578 Mon Sep 17 00:00:00 2001 From: hmoreras <31667212+hmoreras@users.noreply.github.com> Date: Fri, 12 Jun 2026 18:26:27 -0600 Subject: [PATCH 04/43] =?UTF-8?q?feat(publishing-queue)=20#36040:=20UX=20i?= =?UTF-8?q?teration=20=E2=80=94=20empty=20state,=20status=20chip,=20Histor?= =?UTF-8?q?y=20column=20rework,=20hover-reveal=20copy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round of design + correctness improvements after testing the slice locally. Key fixes: READY TO SEND now hits the correct endpoint - Switched from GET /v1/publishing?status=WAITING_FOR_PUBLISHING,… to GET /api/bundle/getunsendbundles/userid/{userId}. The v1 list reads publish_audit (bundles already in the queue), but the design expects user-owned drafts which only live in publishing_bundle. Confirmed via openapi.json: no v1 endpoint exists for drafts today (legacy /bundle/* migration tracked under #36048). - Store caches the userId via DotCurrentUserService on first load - PublishingJobView.status widened to PublishAuditStatus | null so the same row type can represent drafts (no audit row → no status) Tab order + default - History tab is now first and the default open tab - Queue tab loads lazily on switch (saves the initial double fetch) History table rework - Five new columns per dev feedback: Bundle Id (first), Filter, Status, Data Entered, Last Update - Bundle Id renders the full id in monospace with a copy-to-clipboard button that fades in on row hover (group-hover/opacity pattern from es-search), reuses DotCopyButtonComponent for the canonical clipboard + "Copied!" tooltip feedback - Dates formatted with the DatePipe medium preset New dot-publishing-status-chip component - Lives at libs/portlets/dot-publishing-queue/src/lib/components/ (portlet-local, not promoted to libs/ui yet — only one consumer) - Mirrors the project standard set by dot-contentlet-status-chip: p-chip with bg-{c}-100! text-{c}-700! border-{c}-100! text-xs - Centralises the 18-status → 4-bucket mapping (success / danger / warning / info). Replaces three duplicate severity functions and three duplicate constant Sets across list / history / details - Exports publishingStatusBucket() as a pure fn for direct testing Empty states standardised - Replaced the hand-rolled empty-state markup in list + history with the canonical DotEmptyContainerComponent (folder icon + bold title + lighter subtitle, hideContactUsLink=true). Same pattern that dot-query-tool / dot-analytics / dot-velocity-playground use - Filed dotCMS/core#36111 to migrate dot-tags to the same pattern Site selector dropped - Removed from the toolbar entirely. Bundles are scoped by owner, not by site (confirmed in BE: /v1/publishing has no site param, Dojo JSPs never filtered by site). The global admin chrome already ships a site selector for everything else i18n keys backfill - Audit caught 51 referenced-but-missing keys (tab labels, configure & send modal, bundle details, kebab, upload dialog, confirm dialogs, generic actions). Diffed grep output for every publishing-queue.* reference vs the properties file — 79 referenced, 79 defined, no orphans left Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dot-publishing-queue.service.spec.ts | 33 ++++++ .../dot-publishing-queue.service.ts | 44 +++++-- .../src/lib/publishing-job.model.ts | 16 ++- ...ot-publishing-queue-toolbar.component.html | 11 -- ...publishing-queue-toolbar.component.spec.ts | 14 +-- .../dot-publishing-queue-toolbar.component.ts | 11 +- .../dot-publishing-status-chip.component.html | 28 +++++ ...t-publishing-status-chip.component.spec.ts | 105 +++++++++++++++++ .../dot-publishing-status-chip.component.ts | 67 +++++++++++ ...queue-bundle-details-dialog.component.html | 9 +- ...g-queue-bundle-details-dialog.component.ts | 41 ++----- ...ot-publishing-queue-history.component.html | 63 ++++++---- ...publishing-queue-history.component.spec.ts | 29 ++++- .../dot-publishing-queue-history.component.ts | 44 ++++--- .../dot-publishing-queue-list.component.html | 24 ++-- ...ot-publishing-queue-list.component.spec.ts | 33 +++--- .../dot-publishing-queue-list.component.ts | 51 +++----- .../dot-publishing-queue-page.component.html | 4 +- .../dot-publishing-queue-page.component.ts | 13 ++ .../store/dot-publishing-queue.store.spec.ts | 111 +++++++++++++----- .../store/dot-publishing-queue.store.ts | 78 ++++++++---- .../dot-publishing-queue-shell.component.html | 12 +- ...t-publishing-queue-shell.component.spec.ts | 17 +-- .../WEB-INF/messages/Language.properties | 73 +++++++++++- 24 files changed, 661 insertions(+), 270 deletions(-) create mode 100644 core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-status-chip/dot-publishing-status-chip.component.html create mode 100644 core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-status-chip/dot-publishing-status-chip.component.spec.ts create mode 100644 core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-status-chip/dot-publishing-status-chip.component.ts diff --git a/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.spec.ts b/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.spec.ts index 131b00c433ee..0eba8a63209a 100644 --- a/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.spec.ts +++ b/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.spec.ts @@ -94,4 +94,37 @@ describe('DotPublishingQueueService', () => { req.flush(mockAssets); }); }); + + describe('getUnsendBundles', () => { + it('hits /api/bundle/getunsendbundles/userid/{userId} with name + start + count', () => { + const mockResponse = { + identifier: 'id', + label: 'name', + items: [{ id: 'b1', name: 'My Bundle' }], + numRows: 1 + }; + + service.getUnsendBundles('dotcms.org.1', '*term*', 0, 50).subscribe((response) => { + expect(response).toEqual(mockResponse); + }); + + const req = httpMock.expectOne( + (request) => request.url === '/api/bundle/getunsendbundles/userid/dotcms.org.1' + ); + expect(req.request.method).toBe('GET'); + expect(req.request.params.get('name')).toBe('*term*'); + expect(req.request.params.get('start')).toBe('0'); + expect(req.request.params.get('count')).toBe('50'); + req.flush(mockResponse); + }); + + it('falls back to wildcard when filter is empty', () => { + service.getUnsendBundles('u1', '').subscribe(); + const req = httpMock.expectOne( + (request) => request.url === '/api/bundle/getunsendbundles/userid/u1' + ); + expect(req.request.params.get('name')).toBe('*'); + req.flush({ identifier: 'id', label: 'name', items: [], numRows: 0 }); + }); + }); }); diff --git a/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.ts b/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.ts index a0f47d71fabd..0f3cd0ac98fd 100644 --- a/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.ts +++ b/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.ts @@ -13,7 +13,8 @@ import { PublishingJobDetailView, PublishingJobsResponse, PushBundleResultView, - RetryBundleResultView + RetryBundleResultView, + UnsentBundlesResponse } from '@dotcms/dotcms-models'; export type PublishingSortField = 'bundle_name' | 'status' | 'created' | 'modified'; @@ -103,10 +104,9 @@ export class DotPublishingQueueService { pushBundle(bundleId: string, payload: PushBundlePayload): Observable { return this.http - .post>( - `/api/v1/publishing/push/${bundleId}`, - payload - ) + .post< + DotCMSResponse + >(`/api/v1/publishing/push/${bundleId}`, payload) .pipe(map((response) => response.entity)); } @@ -143,10 +143,7 @@ export class DotPublishingQueueService { uploadBundle(file: File): Observable<{ bundleName: string; status: string }> { const formData = new FormData(); formData.append('file', file, file.name); - return this.http.post<{ bundleName: string; status: string }>( - '/api/bundle/sync', - formData - ); + return this.http.post<{ bundleName: string; status: string }>('/api/bundle/sync', formData); } /** Builds the absolute download URL for a bundle's `.tar.gz`. */ @@ -154,6 +151,35 @@ export class DotPublishingQueueService { return `/api/bundle/_download/${bundleId}`; } + /** + * Lists unsent (draft) bundles owned by the given user. + * + * Backed by the legacy endpoint `GET /api/bundle/getunsendbundles/userid/{userId}` + * (`BundleResource#getUnsendBundles`). The newer v1 `/api/v1/publishing` reads + * from `publish_audit` and does NOT include drafts — drafts live in + * `publishing_bundle` only. This is the only endpoint that surfaces them + * until #36048 (legacy → v1 consolidation) lands. + * + * Response shape: `{ identifier, label, items: [{ id, name }, ...], numRows }`. + * Caller is responsible for mapping `items` to whatever row shape the UI needs. + */ + getUnsendBundles( + userId: string, + filter = '*', + start = 0, + count = 50 + ): Observable { + const query = new HttpParams() + .set('name', filter || '*') + .set('start', start) + .set('count', count); + + return this.http.get( + `/api/bundle/getunsendbundles/userid/${userId}`, + { params: query } + ); + } + getBundleAssets(bundleId: string): Observable { const params = new HttpParams().set('limit', -1); diff --git a/core-web/libs/dotcms-models/src/lib/publishing-job.model.ts b/core-web/libs/dotcms-models/src/lib/publishing-job.model.ts index b9ddc9d3d4df..2801ee9166e5 100644 --- a/core-web/libs/dotcms-models/src/lib/publishing-job.model.ts +++ b/core-web/libs/dotcms-models/src/lib/publishing-job.model.ts @@ -15,11 +15,17 @@ export interface AssetPreviewView { * Mirrors `com.dotcms.rest.api.v1.publishing.AbstractPublishingJobView`. * * Returned by `GET /api/v1/publishing` as the `entity[]` of the envelope. + * + * Note: `status` is null when the row represents an unsent draft bundle + * (sourced from `GET /api/bundle/getunsendbundles/userid/{userId}`) — drafts + * live in `publishing_bundle` only and don't have a `publish_audit` entry yet. + * Backend `AbstractPublishingJobView` always sets it; FE-side this is widened + * to `| null` to reuse the same row type for both sources. */ export interface PublishingJobView { bundleId: string; bundleName: string | null; - status: PublishAuditStatus; + status: PublishAuditStatus | null; filterName: string | null; filterKey: string | null; assetCount: number; @@ -30,6 +36,14 @@ export interface PublishingJobView { numTries: number; } +/** Raw legacy `getunsendbundles` response payload. */ +export interface UnsentBundlesResponse { + identifier: string; + label: string; + items: { id: string; name: string }[]; + numRows: number; +} + /** Pagination envelope returned alongside `entity` on `/api/v1/publishing`. */ export interface PublishingJobsPagination { currentPage: number; 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 0bf612f6ab39..44ca4cc2e4cc 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 @@ -25,17 +25,6 @@ icon="pi pi-upload" (onClick)="uploadClick.emit()" data-testid="pq-upload-btn" /> - diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-toolbar/dot-publishing-queue-toolbar.component.spec.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-toolbar/dot-publishing-queue-toolbar.component.spec.ts index 4b2f1ab4759e..e9af38af968f 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-toolbar/dot-publishing-queue-toolbar.component.spec.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-toolbar/dot-publishing-queue-toolbar.component.spec.ts @@ -1,7 +1,6 @@ import { byTestId, createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; -import { of } from 'rxjs'; -import { DotEventsService, DotMessageService, DotSiteService } from '@dotcms/data-access'; +import { DotMessageService } from '@dotcms/data-access'; import { MockDotMessageService } from '@dotcms/utils-testing'; import { DotPublishingQueueToolbarComponent } from './dot-publishing-queue-toolbar.component'; @@ -22,17 +21,12 @@ describe('DotPublishingQueueToolbarComponent', () => { }) ], providers: [ - mockProvider(DotEventsService, { listen: jest.fn().mockReturnValue(of({})) }), - mockProvider(DotSiteService, { - getSites: jest.fn().mockReturnValue(of({ sites: [], total: 0 })) - }), { provide: DotMessageService, useValue: new MockDotMessageService({ 'publishing-queue.search.placeholder': 'Search bundles', 'publishing-queue.refresh': 'Refresh', - 'publishing-queue.upload-bundle': 'Upload Bundle', - 'publishing-queue.site-selector.placeholder': 'Site' + 'publishing-queue.upload-bundle': 'Upload Bundle' }) } ] @@ -50,11 +44,11 @@ describe('DotPublishingQueueToolbarComponent', () => { }); describe('layout', () => { - it('renders search, refresh, upload (disabled), site selector (disabled)', () => { + it('renders search, refresh, upload', () => { expect(spectator.query(byTestId('pq-search-input'))).toBeTruthy(); expect(spectator.query(byTestId('pq-refresh-btn'))).toBeTruthy(); expect(spectator.query(byTestId('pq-upload-btn'))).toBeTruthy(); - expect(spectator.query(byTestId('pq-site-selector'))).toBeTruthy(); + expect(spectator.query(byTestId('pq-site-selector'))).toBeFalsy(); }); it('upload button click emits uploadClick', () => { 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 a363c2661da6..2f3827ba592b 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 @@ -8,13 +8,11 @@ import { ButtonModule } from 'primeng/button'; import { IconFieldModule } from 'primeng/iconfield'; import { InputIconModule } from 'primeng/inputicon'; import { InputTextModule } from 'primeng/inputtext'; -import { SelectChangeEvent, SelectModule } from 'primeng/select'; import { ToolbarModule } from 'primeng/toolbar'; import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; -import { DotSite } from '@dotcms/dotcms-models'; -import { DotMessagePipe, DotSiteSelectorDirective } from '@dotcms/ui'; +import { DotMessagePipe } from '@dotcms/ui'; import { DotPublishingQueueStore } from '../../dot-publishing-queue-page/store/dot-publishing-queue.store'; @@ -27,9 +25,7 @@ import { DotPublishingQueueStore } from '../../dot-publishing-queue-page/store/d IconFieldModule, InputIconModule, InputTextModule, - SelectModule, ToolbarModule, - DotSiteSelectorDirective, DotMessagePipe ], templateUrl: './dot-publishing-queue-toolbar.component.html', @@ -51,9 +47,4 @@ export class DotPublishingQueueToolbarComponent { onSearch(value: string): void { this.searchSubject.next(value); } - - onSiteChange(event: SelectChangeEvent): void { - const site = event.value as DotSite | null; - this.store.setSiteId(site?.identifier ?? null); - } } diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-status-chip/dot-publishing-status-chip.component.html b/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-status-chip/dot-publishing-status-chip.component.html new file mode 100644 index 000000000000..4f0fc3c4fca2 --- /dev/null +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-status-chip/dot-publishing-status-chip.component.html @@ -0,0 +1,28 @@ +@if (status()) { + @switch (bucket()) { + @case ('success') { + + } + @case ('danger') { + + } + @case ('warning') { + + } + @case ('info') { + + } + } +} 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 new file mode 100644 index 000000000000..8c9b842e994c --- /dev/null +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-status-chip/dot-publishing-status-chip.component.spec.ts @@ -0,0 +1,105 @@ +import { byTestId, createComponentFactory, Spectator } from '@ngneat/spectator/jest'; + +import { DotMessageService } from '@dotcms/data-access'; +import { PublishAuditStatus } from '@dotcms/dotcms-models'; +import { MockDotMessageService } from '@dotcms/utils-testing'; + +import { + DotPublishingStatusChipComponent, + publishingStatusBucket +} from './dot-publishing-status-chip.component'; + +describe('publishingStatusBucket (pure fn)', () => { + const cases: Array<[PublishAuditStatus, 'success' | 'danger' | 'warning' | 'info']> = [ + [PublishAuditStatus.SUCCESS, 'success'], + [PublishAuditStatus.BUNDLE_SENT_SUCCESSFULLY, 'success'], + [PublishAuditStatus.BUNDLE_SAVED_SUCCESSFULLY, 'success'], + [PublishAuditStatus.SUCCESS_WITH_WARNINGS, 'warning'], + [PublishAuditStatus.FAILED_TO_SEND_TO_ALL_GROUPS, 'danger'], + [PublishAuditStatus.FAILED_TO_SEND_TO_SOME_GROUPS, 'danger'], + [PublishAuditStatus.FAILED_TO_BUNDLE, 'danger'], + [PublishAuditStatus.FAILED_TO_SENT, 'danger'], + [PublishAuditStatus.FAILED_TO_PUBLISH, 'danger'], + [PublishAuditStatus.FAILED_INTEGRITY_CHECK, 'danger'], + [PublishAuditStatus.INVALID_TOKEN, 'danger'], + [PublishAuditStatus.LICENSE_REQUIRED, 'danger'], + [PublishAuditStatus.WAITING_FOR_PUBLISHING, 'info'], + [PublishAuditStatus.BUNDLE_REQUESTED, 'info'], + [PublishAuditStatus.BUNDLING, 'warning'], + [PublishAuditStatus.SENDING_TO_ENDPOINTS, 'warning'], + [PublishAuditStatus.PUBLISHING_BUNDLE, 'warning'], + [PublishAuditStatus.RECEIVED_BUNDLE, 'warning'] + ]; + + it('covers every value of PublishAuditStatus', () => { + const allValues = Object.values(PublishAuditStatus); + const mapped = new Set(cases.map(([s]) => s)); + for (const v of allValues) { + expect(mapped.has(v as PublishAuditStatus)).toBe(true); + } + }); + + it.each(cases)('maps %s → %s', (status, bucket) => { + expect(publishingStatusBucket(status)).toBe(bucket); + }); +}); + +describe('DotPublishingStatusChipComponent', () => { + let spectator: Spectator; + + const createComponent = createComponentFactory({ + component: DotPublishingStatusChipComponent, + providers: [ + { + provide: DotMessageService, + useValue: new MockDotMessageService({ + 'publishing-queue.status.SUCCESS': 'Sent', + 'publishing-queue.status.FAILED_TO_PUBLISH': 'Publish failed', + 'publishing-queue.status.BUNDLING': 'Bundling', + 'publishing-queue.status.WAITING_FOR_PUBLISHING': 'Waiting' + }) + } + ], + detectChanges: false + }); + + it('renders nothing when status is null', () => { + spectator = createComponent({ props: { status: null } }); + spectator.detectChanges(); + expect(spectator.query(byTestId('pq-status-chip'))).toBeFalsy(); + }); + + it('renders green classes for success bucket', () => { + spectator = createComponent({ props: { status: PublishAuditStatus.SUCCESS } }); + spectator.detectChanges(); + const chip = spectator.query(byTestId('pq-status-chip')); + expect(chip?.classList.contains('bg-green-100!')).toBe(true); + expect(chip?.classList.contains('text-green-700!')).toBe(true); + expect(chip?.textContent?.trim()).toContain('Sent'); + }); + + it('renders red classes for danger bucket', () => { + spectator = createComponent({ + props: { status: PublishAuditStatus.FAILED_TO_PUBLISH } + }); + spectator.detectChanges(); + const chip = spectator.query(byTestId('pq-status-chip')); + expect(chip?.classList.contains('bg-red-100!')).toBe(true); + }); + + it('renders yellow classes for warning bucket (in-flight)', () => { + spectator = createComponent({ props: { status: PublishAuditStatus.BUNDLING } }); + spectator.detectChanges(); + const chip = spectator.query(byTestId('pq-status-chip')); + expect(chip?.classList.contains('bg-yellow-100!')).toBe(true); + }); + + it('renders blue classes for info bucket (waiting)', () => { + spectator = createComponent({ + props: { status: PublishAuditStatus.WAITING_FOR_PUBLISHING } + }); + spectator.detectChanges(); + const chip = spectator.query(byTestId('pq-status-chip')); + expect(chip?.classList.contains('bg-blue-100!')).toBe(true); + }); +}); 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 new file mode 100644 index 000000000000..5cd0b0b4c801 --- /dev/null +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-status-chip/dot-publishing-status-chip.component.ts @@ -0,0 +1,67 @@ +import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; + +import { ChipModule } from 'primeng/chip'; + +import { PublishAuditStatus } from '@dotcms/dotcms-models'; +import { DotMessagePipe } from '@dotcms/ui'; + +type StatusBucket = 'success' | 'danger' | 'warning' | 'info'; + +const BUCKETS: Record = { + // 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', + + // danger: anything that failed + [PublishAuditStatus.FAILED_TO_SEND_TO_ALL_GROUPS]: 'danger', + [PublishAuditStatus.FAILED_TO_SEND_TO_SOME_GROUPS]: 'danger', + [PublishAuditStatus.FAILED_TO_BUNDLE]: 'danger', + [PublishAuditStatus.FAILED_TO_SENT]: 'danger', + [PublishAuditStatus.FAILED_TO_PUBLISH]: 'danger', + [PublishAuditStatus.FAILED_INTEGRITY_CHECK]: 'danger', + [PublishAuditStatus.INVALID_TOKEN]: 'danger', + [PublishAuditStatus.LICENSE_REQUIRED]: 'danger', + + // info: in the queue, waiting to start + [PublishAuditStatus.WAITING_FOR_PUBLISHING]: 'info', + [PublishAuditStatus.BUNDLE_REQUESTED]: 'info', + + // warning: actively being packed/sent (in-flight) + [PublishAuditStatus.BUNDLING]: 'warning', + [PublishAuditStatus.SENDING_TO_ENDPOINTS]: 'warning', + [PublishAuditStatus.PUBLISHING_BUNDLE]: 'warning', + [PublishAuditStatus.RECEIVED_BUNDLE]: 'warning' +}; + +/** Pure mapping function — exported for direct testing without component instantiation. */ +export function publishingStatusBucket(status: PublishAuditStatus): StatusBucket { + return BUCKETS[status] ?? 'info'; +} + +/** + * Renders a coloured chip for a `PublishAuditStatus`, following the project standard + * (same pattern as `dot-contentlet-status-chip`): `p-chip` with `bg-{c}-100! text-{c}-700!` + * Tailwind classes. Centralises the 18-status → 4-bucket mapping so consumers don't + * duplicate the severity logic. + */ +@Component({ + selector: 'dot-publishing-status-chip', + standalone: true, + imports: [ChipModule, DotMessagePipe], + templateUrl: './dot-publishing-status-chip.component.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DotPublishingStatusChipComponent { + status = input(null); + + readonly bucket = computed(() => { + const s = this.status(); + return s ? publishingStatusBucket(s) : null; + }); + + readonly labelKey = computed(() => `publishing-queue.status.${this.status()}`); +} diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.html b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.html index 2f5edd02f5f1..30994a14268b 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.html +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.html @@ -12,9 +12,7 @@
{{ 'publishing-queue.detail.status' | dm }}
- +
@@ -89,9 +87,8 @@

@if (endpoint.status) { - + } @else { } diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.ts index 6662ee2d0528..a70d54cab226 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.ts @@ -4,16 +4,14 @@ import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/c import { ButtonModule } from 'primeng/button'; import { SkeletonModule } from 'primeng/skeleton'; import { TableModule } from 'primeng/table'; -import { TagModule } from 'primeng/tag'; import { DotPublishingQueueService } from '@dotcms/data-access'; import { PublishAuditStatus } from '@dotcms/dotcms-models'; import { DotMessagePipe } from '@dotcms/ui'; +import { DotPublishingStatusChipComponent } from '../../components/dot-publishing-status-chip/dot-publishing-status-chip.component'; import { DotPublishingQueueStore } from '../../dot-publishing-queue-page/store/dot-publishing-queue.store'; -type ChipSeverity = 'success' | 'info' | 'warn' | 'danger' | 'secondary'; - const SUCCESS_STATUSES = new Set([ PublishAuditStatus.SUCCESS, PublishAuditStatus.BUNDLE_SENT_SUCCESSFULLY, @@ -21,21 +19,17 @@ const SUCCESS_STATUSES = new Set([ PublishAuditStatus.SUCCESS_WITH_WARNINGS ]); -const FAILURE_STATUSES = new Set([ - PublishAuditStatus.FAILED_TO_SEND_TO_ALL_GROUPS, - PublishAuditStatus.FAILED_TO_SEND_TO_SOME_GROUPS, - PublishAuditStatus.FAILED_TO_BUNDLE, - PublishAuditStatus.FAILED_TO_SENT, - PublishAuditStatus.FAILED_TO_PUBLISH, - PublishAuditStatus.FAILED_INTEGRITY_CHECK, - PublishAuditStatus.INVALID_TOKEN, - PublishAuditStatus.LICENSE_REQUIRED -]); - @Component({ selector: 'dot-publishing-queue-bundle-details-dialog', standalone: true, - imports: [DatePipe, ButtonModule, SkeletonModule, TableModule, TagModule, DotMessagePipe], + imports: [ + DatePipe, + ButtonModule, + SkeletonModule, + TableModule, + DotMessagePipe, + DotPublishingStatusChipComponent + ], templateUrl: './dot-publishing-queue-bundle-details-dialog.component.html', changeDetection: ChangeDetectionStrategy.OnPush }) @@ -52,21 +46,4 @@ export class DotPublishingQueueBundleDetailsDialogComponent { downloadHref(bundleId: string): string { return this.publishingService.getBundleDownloadUrl(bundleId); } - - statusLabelKey(status: PublishAuditStatus): string { - return `publishing-queue.status.${status}`; - } - - statusSeverity(status: PublishAuditStatus | null): ChipSeverity { - if (!status) { - return 'secondary'; - } - if (SUCCESS_STATUSES.has(status)) { - return 'success'; - } - if (FAILURE_STATUSES.has(status)) { - return 'danger'; - } - return 'info'; - } } diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.html b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.html index 2175c68d2f5f..f2439569cd1e 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.html +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.html @@ -48,16 +48,22 @@ - - {{ 'publishing-queue.column.bundle' | dm }} - + + {{ 'publishing-queue.column.bundle-id' | dm }} - + + {{ 'publishing-queue.column.filter' | dm }} + + {{ 'publishing-queue.column.status' | dm }} - - {{ 'publishing-queue.column.modified' | dm }} + + {{ 'publishing-queue.column.data-entered' | dm }} + + + + {{ 'publishing-queue.column.last-update' | dm }} @@ -70,26 +76,38 @@ + + } @else { - - {{ row.bundleName || row.bundleId }} + +
+ {{ row.bundleId }} + +
+ + + {{ row.filterName || row.filterKey || '—' }} + + + - - + + {{ (row.createDate | date: 'medium') || '—' }} - - {{ row.statusUpdated || row.createDate }} + + {{ (row.statusUpdated || row.createDate | date: 'medium') || '—' }} } @@ -97,16 +115,13 @@ - +
- -

- {{ 'publishing-queue.history.empty' | dm }} -

+
diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.spec.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.spec.ts index cbb3c46a9f97..7562a4755c08 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.spec.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.spec.ts @@ -97,11 +97,32 @@ describe('DotPublishingQueueHistoryComponent', () => { expect(spectator.query(byTestId('pq-history-table'))).toBeTruthy(); }); + it('renders all five column headers (Filter, Bundle Id, Status, Data Entered, Last Update)', () => { + expect(spectator.query(byTestId('pq-history-col-filter'))).toBeTruthy(); + expect(spectator.query(byTestId('pq-history-col-bundle-id'))).toBeTruthy(); + expect(spectator.query(byTestId('pq-history-col-status'))).toBeTruthy(); + expect(spectator.query(byTestId('pq-history-col-created'))).toBeTruthy(); + expect(spectator.query(byTestId('pq-history-col-modified'))).toBeTruthy(); + }); + it('renders rows with status chips', () => { const tags = spectator.queryAll(byTestId('pq-history-status')); expect(tags.length).toBe(2); }); + it('renders Filter cell falling back to "—" when filterName + filterKey are null', () => { + const cells = spectator.queryAll(byTestId('pq-history-filter')); + expect(cells.length).toBe(2); + expect(cells[0].textContent?.trim()).toBe('—'); + }); + + it('renders Bundle Id cell with the full id (no truncation) + copy button', () => { + const cells = spectator.queryAll(byTestId('pq-history-bundle-id')); + expect(cells.length).toBe(2); + expect(cells[0].textContent).toContain('b1'); + expect(cells[0].querySelector('[data-testid="pq-history-bundle-id-copy"]')).toBeTruthy(); + }); + it('shows the bulk action bar only when there is a selection', () => { expect(spectator.query(byTestId('pq-history-bulk-bar'))).toBeFalsy(); @@ -129,10 +150,8 @@ describe('DotPublishingQueueHistoryComponent', () => { expect(store.deleteBundlesBulk).toHaveBeenCalledWith(['b1', 'b2']); }); - it('statusSeverity maps SUCCESS → success and failures → danger', () => { - expect(spectator.component.statusSeverity(PublishAuditStatus.SUCCESS)).toBe('success'); - expect(spectator.component.statusSeverity(PublishAuditStatus.FAILED_TO_PUBLISH)).toBe( - 'danger' - ); + it('renders a dot-publishing-status-chip per row', () => { + const chips = spectator.queryAll('dot-publishing-status-chip'); + expect(chips.length).toBe(2); }); }); diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.ts index 3c90e6c8d138..9d604a50f959 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.ts @@ -1,3 +1,4 @@ +import { DatePipe } from '@angular/common'; import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; import { ConfirmationService } from 'primeng/api'; @@ -5,33 +6,32 @@ import { ButtonModule } from 'primeng/button'; import { ConfirmDialogModule } from 'primeng/confirmdialog'; import { SkeletonModule } from 'primeng/skeleton'; import { TableLazyLoadEvent, TableModule } from 'primeng/table'; -import { TagModule } from 'primeng/tag'; import { DotMessageService, PublishingSortField } from '@dotcms/data-access'; -import { PublishAuditStatus, PublishingJobView } from '@dotcms/dotcms-models'; -import { DotMessagePipe } from '@dotcms/ui'; - +import { PublishingJobView } from '@dotcms/dotcms-models'; +import { + DotCopyButtonComponent, + DotEmptyContainerComponent, + DotMessagePipe, + PrincipalConfiguration +} from '@dotcms/ui'; + +import { DotPublishingStatusChipComponent } from '../components/dot-publishing-status-chip/dot-publishing-status-chip.component'; import { DotPublishingQueueStore } from '../dot-publishing-queue-page/store/dot-publishing-queue.store'; -type ChipSeverity = 'success' | 'info' | 'warn' | 'danger' | 'secondary'; - -const SUCCESS_STATUSES = new Set([ - PublishAuditStatus.SUCCESS, - PublishAuditStatus.BUNDLE_SENT_SUCCESSFULLY, - PublishAuditStatus.BUNDLE_SAVED_SUCCESSFULLY, - PublishAuditStatus.SUCCESS_WITH_WARNINGS -]); - @Component({ selector: 'dot-publishing-queue-history', standalone: true, imports: [ + DatePipe, ButtonModule, ConfirmDialogModule, SkeletonModule, TableModule, - TagModule, - DotMessagePipe + DotCopyButtonComponent, + DotEmptyContainerComponent, + DotMessagePipe, + DotPublishingStatusChipComponent ], providers: [ConfirmationService], templateUrl: './dot-publishing-queue-history.component.html', @@ -45,6 +45,12 @@ export class DotPublishingQueueHistoryComponent { readonly first = computed(() => (this.store.historyPage() - 1) * this.store.rowsPerPage()); + readonly historyEmpty: PrincipalConfiguration = { + icon: 'pi-history', + title: this.dotMessageService.get('publishing-queue.empty.history.title'), + subtitle: this.dotMessageService.get('publishing-queue.empty.history.subtitle') + }; + readonly selectedRows = computed(() => { const selectedIds = new Set(this.store.historySelectedIds()); return this.store.historyRows().filter((row) => selectedIds.has(row.bundleId)); @@ -52,14 +58,6 @@ export class DotPublishingQueueHistoryComponent { readonly hasSelection = computed(() => this.store.historySelectedIds().length > 0); - statusSeverity(status: PublishAuditStatus): ChipSeverity { - return SUCCESS_STATUSES.has(status) ? 'success' : 'danger'; - } - - statusLabelKey(status: PublishAuditStatus): string { - return `publishing-queue.status.${status}`; - } - onLazyLoad(event: TableLazyLoadEvent): void { const rows = (event.rows as number) ?? this.store.rowsPerPage(); const first = (event.first as number) ?? 0; diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-list/dot-publishing-queue-list.component.html b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-list/dot-publishing-queue-list.component.html index e584d81ee8d0..bab931c34da1 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-list/dot-publishing-queue-list.component.html +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-list/dot-publishing-queue-list.component.html @@ -19,11 +19,8 @@

} } @else if (rows().length === 0) { -
- -

{{ emptyKey() | dm }}

+
+
} @else { @for (job of rows(); track job.bundleId) { @@ -40,14 +37,17 @@

data-testid="pq-row-name"> {{ job.bundleName || job.bundleId }} - - {{ job.assetCount }} assets · {{ job.environmentCount }} env - + @if (job.assetCount > 0 || job.environmentCount > 0) { + + {{ job.assetCount }} assets · {{ job.environmentCount }} env + + }

- + @if (job.status) { + + } @if (mode() === 'ready') { { page: 1, rowsPerPage: 10, headerKey: 'publishing-queue.ready.title', - emptyKey: 'publishing-queue.empty.ready' + emptyConfig: { + icon: 'pi-folder-open', + title: "Your bundle's empty", + subtitle: 'Add content here' + } }; beforeEach(() => { @@ -127,25 +131,18 @@ describe('DotPublishingQueueListComponent', () => { expect(emitted?.bundleId).toBe('bundle-1'); }); - describe('statusSeverity', () => { - it('returns success for SUCCESS', () => { - expect(spectator.component.statusSeverity(PublishAuditStatus.SUCCESS)).toBe('success'); - }); - - it('returns danger for FAILED_TO_PUBLISH', () => { - expect(spectator.component.statusSeverity(PublishAuditStatus.FAILED_TO_PUBLISH)).toBe( - 'danger' - ); - }); - - it('returns info for WAITING_FOR_PUBLISHING', () => { - expect( - spectator.component.statusSeverity(PublishAuditStatus.WAITING_FOR_PUBLISHING) - ).toBe('info'); + describe('status chip', () => { + it('renders the chip when row.status is set', () => { + spectator.setInput('mode', 'progress'); + spectator.setInput('rows', [job({ status: PublishAuditStatus.BUNDLING })]); + spectator.detectChanges(); + expect(spectator.query(byTestId('pq-row-status'))).toBeTruthy(); }); - it('returns warn for in-progress like BUNDLING', () => { - expect(spectator.component.statusSeverity(PublishAuditStatus.BUNDLING)).toBe('warn'); + it('skips the chip when row.status is null (drafts)', () => { + spectator.setInput('rows', [job({ status: null })]); + spectator.detectChanges(); + expect(spectator.query(byTestId('pq-row-status'))).toBeFalsy(); }); }); diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-list/dot-publishing-queue-list.component.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-list/dot-publishing-queue-list.component.ts index ca7663817218..234225c7e66d 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-list/dot-publishing-queue-list.component.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-list/dot-publishing-queue-list.component.ts @@ -5,21 +5,14 @@ import { ButtonModule } from 'primeng/button'; import { MenuModule } from 'primeng/menu'; import { PaginatorModule, PaginatorState } from 'primeng/paginator'; import { SkeletonModule } from 'primeng/skeleton'; -import { TagModule } from 'primeng/tag'; import { PublishAuditStatus, PublishingJobView } from '@dotcms/dotcms-models'; -import { DotMessagePipe } from '@dotcms/ui'; +import { DotEmptyContainerComponent, DotMessagePipe, PrincipalConfiguration } from '@dotcms/ui'; + +import { DotPublishingStatusChipComponent } from '../components/dot-publishing-status-chip/dot-publishing-status-chip.component'; type LoadStatus = 'init' | 'loading' | 'loaded' | 'error'; type Mode = 'ready' | 'progress'; -type ChipSeverity = 'success' | 'info' | 'warn' | 'danger' | 'secondary'; - -const SUCCESS_STATUSES = new Set([ - PublishAuditStatus.SUCCESS, - PublishAuditStatus.BUNDLE_SENT_SUCCESSFULLY, - PublishAuditStatus.BUNDLE_SAVED_SUCCESSFULLY, - PublishAuditStatus.SUCCESS_WITH_WARNINGS -]); const FAILURE_STATUSES = new Set([ PublishAuditStatus.FAILED_TO_SEND_TO_ALL_GROUPS, @@ -32,15 +25,18 @@ const FAILURE_STATUSES = new Set([ PublishAuditStatus.LICENSE_REQUIRED ]); -const READY_STATUSES_SET = new Set([ - PublishAuditStatus.BUNDLE_REQUESTED, - PublishAuditStatus.WAITING_FOR_PUBLISHING -]); - @Component({ selector: 'dot-publishing-queue-list', standalone: true, - imports: [ButtonModule, MenuModule, PaginatorModule, SkeletonModule, TagModule, DotMessagePipe], + imports: [ + ButtonModule, + MenuModule, + PaginatorModule, + SkeletonModule, + DotEmptyContainerComponent, + DotMessagePipe, + DotPublishingStatusChipComponent + ], templateUrl: './dot-publishing-queue-list.component.html', changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'flex flex-col min-h-0 h-full' } @@ -53,7 +49,7 @@ export class DotPublishingQueueListComponent { readonly page = input.required(); readonly rowsPerPage = input.required(); readonly headerKey = input.required(); - readonly emptyKey = input.required(); + readonly emptyConfig = input.required(); /** Builder for the per-row kebab menu items. Only used in ready mode. */ readonly kebabBuilder = input<(job: PublishingJobView) => MenuItem[] | null>(() => null); @@ -66,25 +62,8 @@ export class DotPublishingQueueListComponent { readonly skeletonRows = Array.from({ length: 5 }); - statusSeverity(status: PublishAuditStatus): ChipSeverity { - if (SUCCESS_STATUSES.has(status)) { - return 'success'; - } - if (FAILURE_STATUSES.has(status)) { - return 'danger'; - } - if (READY_STATUSES_SET.has(status)) { - return 'info'; - } - return 'warn'; - } - - statusLabelKey(status: PublishAuditStatus): string { - return `publishing-queue.status.${status}`; - } - - isRetryable(status: PublishAuditStatus): boolean { - return FAILURE_STATUSES.has(status); + isRetryable(status: PublishAuditStatus | null): boolean { + return status !== null && FAILURE_STATUSES.has(status); } kebabFor(job: PublishingJobView): MenuItem[] { diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/dot-publishing-queue-page.component.html b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/dot-publishing-queue-page.component.html index 76ebabf7f99a..0bea9dec0f2b 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/dot-publishing-queue-page.component.html +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/dot-publishing-queue-page.component.html @@ -8,7 +8,7 @@ [rowsPerPage]="store.rowsPerPage()" [kebabBuilder]="readyKebabFor.bind(this)" headerKey="publishing-queue.ready.title" - emptyKey="publishing-queue.empty.ready" + [emptyConfig]="readyEmpty" (rowClick)="onRowClick($event, 'ready')" (sendClick)="onSend($event)" (pageChange)="store.setReadyPage($event)" @@ -21,7 +21,7 @@ [page]="store.progressPage()" [rowsPerPage]="store.rowsPerPage()" headerKey="publishing-queue.in-progress.title" - emptyKey="publishing-queue.empty.in-progress" + [emptyConfig]="progressEmpty" (rowClick)="onRowClick($event, 'progress')" (retryClick)="onRetry($event)" (pageChange)="store.setProgressPage($event)" diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/dot-publishing-queue-page.component.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/dot-publishing-queue-page.component.ts index 1ee0cc428a01..a31a1a478110 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/dot-publishing-queue-page.component.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/dot-publishing-queue-page.component.ts @@ -6,6 +6,7 @@ import { MenuModule } from 'primeng/menu'; import { DotMessageService } from '@dotcms/data-access'; import { PublishingJobView } from '@dotcms/dotcms-models'; +import { PrincipalConfiguration } from '@dotcms/ui'; import { DotPublishingQueueStore } from './store/dot-publishing-queue.store'; @@ -26,6 +27,18 @@ export class DotPublishingQueuePageComponent { private readonly confirmationService = inject(ConfirmationService); private readonly dotMessageService = inject(DotMessageService); + readonly readyEmpty: PrincipalConfiguration = { + icon: 'pi-folder-open', + title: this.dotMessageService.get('publishing-queue.empty.ready.title'), + subtitle: this.dotMessageService.get('publishing-queue.empty.ready.subtitle') + }; + + readonly progressEmpty: PrincipalConfiguration = { + icon: 'pi-hourglass', + title: this.dotMessageService.get('publishing-queue.empty.in-progress.title'), + subtitle: this.dotMessageService.get('publishing-queue.empty.in-progress.subtitle') + }; + readyKebabFor(job: PublishingJobView): MenuItem[] { return [ { diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/store/dot-publishing-queue.store.spec.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/store/dot-publishing-queue.store.spec.ts index f342577b038d..21f105750344 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/store/dot-publishing-queue.store.spec.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/store/dot-publishing-queue.store.spec.ts @@ -1,7 +1,11 @@ import { createServiceFactory, mockProvider, SpectatorService } from '@ngneat/spectator/jest'; import { of, throwError } from 'rxjs'; -import { DotHttpErrorManagerService, DotPublishingQueueService } from '@dotcms/data-access'; +import { + DotCurrentUserService, + DotHttpErrorManagerService, + DotPublishingQueueService +} from '@dotcms/data-access'; import { BundleAssetView, IN_PROGRESS_STATUSES, @@ -9,7 +13,7 @@ import { PublishingJobDetailView, PublishingJobsResponse, PublishingJobView, - READY_STATUSES + UnsentBundlesResponse } from '@dotcms/dotcms-models'; import { DotPublishingQueueStore } from './dot-publishing-queue.store'; @@ -29,9 +33,11 @@ const buildJob = (overrides: Partial = {}): PublishingJobView ...overrides }); -const READY_RESPONSE: PublishingJobsResponse = { - entity: [buildJob({ bundleId: 'ready-1' })], - pagination: { currentPage: 1, perPage: 10, totalEntries: 1 } +const UNSENT_RESPONSE: UnsentBundlesResponse = { + identifier: 'id', + label: 'name', + items: [{ id: 'ready-1', name: 'Ready Bundle' }], + numRows: 1 }; const PROGRESS_RESPONSE: PublishingJobsResponse = { @@ -82,10 +88,10 @@ describe('DotPublishingQueueStore', () => { providers: [ mockProvider(DotPublishingQueueService, { listPublishingJobs: jest.fn().mockImplementation(({ statuses }) => { - if (statuses === READY_STATUSES) return of(READY_RESPONSE); if (statuses === IN_PROGRESS_STATUSES) return of(PROGRESS_RESPONSE); return of(HISTORY_RESPONSE); }), + getUnsendBundles: jest.fn().mockReturnValue(of(UNSENT_RESPONSE)), getBundleAssets: jest.fn().mockReturnValue(of(MOCK_ASSETS)), getPublishingJobDetails: jest.fn().mockReturnValue(of(MOCK_DETAIL)), getEnvironments: jest.fn().mockReturnValue( @@ -94,16 +100,14 @@ describe('DotPublishingQueueStore', () => { { id: 'env-2', name: 'Stage' } ]) ), - pushBundle: jest - .fn() - .mockReturnValue( - of({ - bundleId: 'b', - operation: 'publish', - environments: [], - filterKey: 'k' - }) - ), + pushBundle: jest.fn().mockReturnValue( + of({ + bundleId: 'b', + operation: 'publish', + environments: [], + filterKey: 'k' + }) + ), retryBundles: jest.fn().mockReturnValue(of([])), deleteBundle: jest.fn().mockReturnValue(of({ message: 'ok' })), deleteBundles: jest.fn().mockReturnValue(of({ message: 'ok', deleted: [] })), @@ -112,7 +116,12 @@ describe('DotPublishingQueueStore', () => { .fn() .mockReturnValue(of({ bundleName: 'b', status: 'BUNDLE_REQUESTED' })) }), - mockProvider(DotHttpErrorManagerService) + mockProvider(DotHttpErrorManagerService), + mockProvider(DotCurrentUserService, { + getCurrentUser: jest + .fn() + .mockReturnValue(of({ userId: 'dotcms.org.1', email: 'admin@dotcms.com' })) + }) ] }); @@ -133,11 +142,41 @@ describe('DotPublishingQueueStore', () => { }); describe('onInit', () => { - it('loads queue tab columns on init', () => { - expect(service.listPublishingJobs).toHaveBeenCalledTimes(2); + it('defaults activeTab to history and loads only the history list', () => { + expect(store.activeTab()).toBe('history'); + // History uses listPublishingJobs with HISTORY_STATUSES; + // Queue columns stay idle until the user switches tabs. + expect(service.listPublishingJobs).toHaveBeenCalledTimes(1); + expect(service.getUnsendBundles).not.toHaveBeenCalled(); + expect(store.historyStatus()).toBe('loaded'); + }); + + it('loads queue columns when switching to the Queue tab', () => { + store.setActiveTab('queue'); + spectator.flushEffects(); + + expect(service.getUnsendBundles).toHaveBeenCalledTimes(1); expect(store.readyStatus()).toBe('loaded'); expect(store.progressStatus()).toBe('loaded'); }); + + it('maps unsent bundle items to PublishingJobView rows with status=null', () => { + store.setActiveTab('queue'); + spectator.flushEffects(); + + const row = store.readyRows()[0]; + expect(row.bundleId).toBe('ready-1'); + expect(row.bundleName).toBe('Ready Bundle'); + expect(row.status).toBeNull(); + expect(row.assetCount).toBe(0); + expect(row.environmentCount).toBe(0); + }); + + it('caches userId from DotCurrentUserService once Queue tab is opened', () => { + store.setActiveTab('queue'); + spectator.flushEffects(); + expect(store.userId()).toBe('dotcms.org.1'); + }); }); describe('setSearch', () => { @@ -148,6 +187,7 @@ describe('DotPublishingQueueStore', () => { store.setHistorySelection(['x']); spectator.flushEffects(); (service.listPublishingJobs as jest.Mock).mockClear(); + (service.getUnsendBundles as jest.Mock).mockClear(); store.setSearch('term'); spectator.flushEffects(); @@ -158,18 +198,29 @@ describe('DotPublishingQueueStore', () => { expect(store.historyPage()).toBe(1); expect(store.historySelectedIds()).toEqual([]); }); + + it('wildcards the search term when reloading READY via getUnsendBundles', () => { + store.setActiveTab('queue'); + spectator.flushEffects(); + (service.getUnsendBundles as jest.Mock).mockClear(); + + store.setSearch('term'); + spectator.flushEffects(); + + expect(service.getUnsendBundles).toHaveBeenCalledWith('dotcms.org.1', '*term*', 0, 10); + }); }); describe('setActiveTab', () => { - it('switching to history triggers loadHistory', () => { + it('switching to queue triggers loadReady + loadProgress', () => { (service.listPublishingJobs as jest.Mock).mockClear(); - store.setActiveTab('history'); + (service.getUnsendBundles as jest.Mock).mockClear(); + store.setActiveTab('queue'); spectator.flushEffects(); + + expect(service.getUnsendBundles).toHaveBeenCalledTimes(1); expect(service.listPublishingJobs).toHaveBeenCalledWith( - expect.objectContaining({ - sort: undefined, - sortDirection: 'desc' - }) + expect.objectContaining({ statuses: IN_PROGRESS_STATUSES }) ); }); }); @@ -198,10 +249,14 @@ describe('DotPublishingQueueStore', () => { }); describe('refresh', () => { - it('reloads queue when active tab is queue', () => { + it('reloads queue when active tab is queue (ready via getUnsendBundles, progress via listPublishingJobs)', () => { + store.setActiveTab('queue'); + spectator.flushEffects(); (service.listPublishingJobs as jest.Mock).mockClear(); + (service.getUnsendBundles as jest.Mock).mockClear(); store.refresh(); - expect(service.listPublishingJobs).toHaveBeenCalledTimes(2); + expect(service.getUnsendBundles).toHaveBeenCalledTimes(1); + expect(service.listPublishingJobs).toHaveBeenCalledTimes(1); }); it('reloads history when active tab is history', () => { @@ -329,7 +384,7 @@ describe('DotPublishingQueueStore', () => { describe('error handling', () => { it('loadReady error → handle + status=error', () => { const error = new Error('boom'); - (service.listPublishingJobs as jest.Mock).mockReturnValueOnce(throwError(() => error)); + (service.getUnsendBundles as jest.Mock).mockReturnValueOnce(throwError(() => error)); store.loadReady(); expect(httpErrorManager.handle).toHaveBeenCalledWith(error); expect(store.readyStatus()).toBe('error'); diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/store/dot-publishing-queue.store.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/store/dot-publishing-queue.store.ts index f7b73079c6f7..8287d9ff10e0 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/store/dot-publishing-queue.store.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/store/dot-publishing-queue.store.ts @@ -1,11 +1,12 @@ import { patchState, signalStore, withHooks, withMethods, withState } from '@ngrx/signals'; -import { EMPTY, forkJoin } from 'rxjs'; +import { EMPTY, Observable, forkJoin, of } from 'rxjs'; import { DestroyRef, effect, inject, untracked } from '@angular/core'; -import { catchError, take } from 'rxjs/operators'; +import { catchError, switchMap, take, tap } from 'rxjs/operators'; import { + DotCurrentUserService, DotHttpErrorManagerService, DotPublishingQueueService, PublishingSortDirection, @@ -19,8 +20,7 @@ import { IN_PROGRESS_STATUSES, PublishAuditStatus, PublishingJobDetailView, - PublishingJobView, - READY_STATUSES + PublishingJobView } from '@dotcms/dotcms-models'; type LoadStatus = 'init' | 'loading' | 'loaded' | 'error'; @@ -67,7 +67,9 @@ interface DotPublishingQueueState { rowsPerPage: number; search: string; - siteId: string | null; + + /** Cached id of the logged-in user; required by the legacy unsent-bundles endpoint. */ + userId: string | null; selectedBundleId: string | null; selectedAssets: BundleAssetView[]; @@ -88,7 +90,7 @@ interface DotPublishingQueueState { } const initialState: DotPublishingQueueState = { - activeTab: 'queue', + activeTab: 'history', readyRows: [], readyPage: 1, @@ -110,7 +112,8 @@ const initialState: DotPublishingQueueState = { rowsPerPage: 10, search: '', - siteId: null, + + userId: null, selectedBundleId: null, selectedAssets: [], @@ -134,21 +137,41 @@ export const DotPublishingQueueStore = signalStore( withState(initialState), withMethods((store) => { const service = inject(DotPublishingQueueService); + const currentUserService = inject(DotCurrentUserService); const httpErrorManager = inject(DotHttpErrorManagerService); const destroyRef = inject(DestroyRef); let pollHandle: ReturnType | null = null; + /** Resolves the current user id, fetching once and caching it in store state. */ + function resolveUserId(): Observable { + const cached = store.userId(); + if (cached) { + return of(cached); + } + return currentUserService.getCurrentUser().pipe( + tap((user) => patchState(store, { userId: user.userId })), + switchMap((user) => of(user.userId)) + ); + } + + /** + * READY TO SEND = drafts owned by the current user. Drafts live in + * `publishing_bundle` only and have no `publish_audit` row, so we hit + * the legacy `getunsendbundles` endpoint. Maps the slim `{id, name}` + * payload to `PublishingJobView` with `status: null` + zero counts; + * the list template hides the chip / meta line when those are absent. + */ function loadReady() { patchState(store, { readyStatus: 'loading' }); - service - .listPublishingJobs({ - statuses: READY_STATUSES, - page: store.readyPage(), - perPage: store.rowsPerPage(), - filter: store.search() || undefined - }) + const offset = (store.readyPage() - 1) * store.rowsPerPage(); + const filter = store.search() ? `*${store.search()}*` : '*'; + + resolveUserId() .pipe( + switchMap((userId) => + service.getUnsendBundles(userId, filter, offset, store.rowsPerPage()) + ), take(1), catchError((error) => { httpErrorManager.handle(error); @@ -158,9 +181,23 @@ export const DotPublishingQueueStore = signalStore( }) ) .subscribe((response) => { + const rows: PublishingJobView[] = response.items.map((item) => ({ + bundleId: item.id, + bundleName: item.name, + status: null, + filterName: null, + filterKey: null, + assetCount: 0, + assetPreview: [], + environmentCount: 0, + createDate: '', + statusUpdated: null, + numTries: 0 + })); + patchState(store, { - readyRows: response.entity, - readyTotal: response.pagination?.totalEntries ?? 0, + readyRows: rows, + readyTotal: response.numRows ?? rows.length, readyStatus: 'loaded' }); }); @@ -355,15 +392,6 @@ export const DotPublishingQueueStore = signalStore( }); }, - setSiteId(siteId: string | null) { - patchState(store, { - siteId, - readyPage: 1, - progressPage: 1, - historyPage: 1 - }); - }, - setReadyPage(page: number) { patchState(store, { readyPage: page }); }, diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.html b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.html index 6c46f525807a..cef3abf60a9b 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.html +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.html @@ -7,20 +7,20 @@ styleClass="h-full flex flex-col" data-testid="pq-tabs"> - - {{ 'publishing-queue.tab.queue' | dm }} - {{ 'publishing-queue.tab.history' | dm }} + + {{ 'publishing-queue.tab.queue' | dm }} + - - - + + + 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 c0a39c8fe6b7..32f5aa3af1d6 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 @@ -6,12 +6,11 @@ import { CUSTOM_ELEMENTS_SCHEMA, NO_ERRORS_SCHEMA } from '@angular/core'; import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog'; import { - DotEventsService, + DotCurrentUserService, DotHttpErrorManagerService, DotMessageService, DotPublishingQueueService, - DotPushPublishFiltersService, - DotSiteService + DotPushPublishFiltersService } from '@dotcms/data-access'; import { MockDotMessageService } from '@dotcms/utils-testing'; @@ -46,16 +45,20 @@ describe('DotPublishingQueueShellComponent', () => { pagination: { currentPage: 1, perPage: 10, totalEntries: 0 } }) ), + getUnsendBundles: jest + .fn() + .mockReturnValue( + of({ identifier: 'id', label: 'name', items: [], numRows: 0 }) + ), getBundleAssets: jest.fn().mockReturnValue(of([])), getPublishingJobDetails: jest.fn().mockReturnValue(of({})), getEnvironments: jest.fn().mockReturnValue(of([])) }), + mockProvider(DotCurrentUserService, { + getCurrentUser: jest.fn().mockReturnValue(of({ userId: 'dotcms.org.1' })) + }), mockProvider(DotHttpErrorManagerService), mockProvider(DotPushPublishFiltersService, { get: jest.fn().mockReturnValue(of([])) }), - mockProvider(DotEventsService, { listen: jest.fn().mockReturnValue(of({})) }), - mockProvider(DotSiteService, { - getSites: jest.fn().mockReturnValue(of({ sites: [], total: 0 })) - }), { provide: DotMessageService, useValue: new MockDotMessageService({}) } ], schemas: [CUSTOM_ELEMENTS_SCHEMA, NO_ERRORS_SCHEMA] diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 5ccbc9ed8209..70cf0d8a03db 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -3778,14 +3778,20 @@ publishing-queue.in-progress.title=In Progress publishing-queue.search.placeholder=Search bundles, content, or environments publishing-queue.refresh=Refresh publishing-queue.upload-bundle=Upload Bundle -publishing-queue.upload-bundle.coming-soon=Coming soon -publishing-queue.site-selector.placeholder=Site -publishing-queue.site-selector.coming-soon=Site filter coming soon publishing-queue.column.name=Name publishing-queue.column.type=Type publishing-queue.column.state=State -publishing-queue.empty.ready=Your bundle's empty. Add content and it'll be ready to send. -publishing-queue.empty.in-progress=Nothing in progress. +publishing-queue.column.filter=Filter +publishing-queue.column.bundle-id=Bundle Id +publishing-queue.column.data-entered=Data Entered +publishing-queue.column.last-update=Last Update +publishing-queue.copy-bundle-id=Copy Bundle Id +publishing-queue.empty.ready.title=Your bundle's empty +publishing-queue.empty.ready.subtitle=Add content here and it'll be ready to send to your environments. +publishing-queue.empty.in-progress.title=Nothing in progress +publishing-queue.empty.in-progress.subtitle=Bundles being packed or shipped will show up here. +publishing-queue.empty.history.title=No bundles sent yet +publishing-queue.empty.history.subtitle=Once a bundle ships, you'll see its status here. publishing-queue.asset-list.title=Bundle Assets publishing-queue.asset-list.empty=No items in this bundle. publishing-queue.send=Send @@ -3807,6 +3813,63 @@ publishing-queue.status.INVALID_TOKEN=Auth error publishing-queue.status.LICENSE_REQUIRED=License required publishing-queue.status.SUCCESS_WITH_WARNINGS=Sent (warnings) publishing-queue.status.FAILED_INTEGRITY_CHECK=Integrity failed +publishing-queue.tab.queue=Queue +publishing-queue.tab.history=History +publishing-queue.column.status=Status +publishing-queue.accept=Accept +publishing-queue.cancel=Cancel +publishing-queue.remove=Remove +publishing-queue.retry=Retry +publishing-queue.retry-send=Retry Send +publishing-queue.selected=selected +publishing-queue.row.actions=Row actions +publishing-queue.kebab.configure-send=Configure & send +publishing-queue.kebab.generate-download=Generate / download +publishing-queue.kebab.remove=Remove from queue +publishing-queue.confirm-remove.header=Remove bundle? +publishing-queue.confirm-remove.message=Are you sure you want to remove "{0}"? This action cannot be undone. +publishing-queue.history.bulk-remove.header=Remove {0} bundles? +publishing-queue.history.bulk-remove.message=This will permanently remove {0} bundles from the history. This action cannot be undone. +publishing-queue.configure-send.title=Configure & send +publishing-queue.configure-send.action=Action +publishing-queue.configure-send.push=Push +publishing-queue.configure-send.remove=Remove +publishing-queue.configure-send.pushremove=Push & Remove +publishing-queue.configure-send.when-to-publish=When to publish +publishing-queue.configure-send.when-to-remove=When to remove +publishing-queue.configure-send.send-now=Send now +publishing-queue.configure-send.remove-now=Remove now +publishing-queue.configure-send.schedule=Schedule +publishing-queue.configure-send.publish-date=Publish date +publishing-queue.configure-send.remove-date=Remove date +publishing-queue.configure-send.expire-date=Expire date +publishing-queue.configure-send.set-expire-date=Also set an expire date +publishing-queue.configure-send.environments=Environments +publishing-queue.configure-send.select-environment=Select an environment +publishing-queue.configure-send.env-required=Select at least one environment. +publishing-queue.configure-send.filter=Filter +publishing-queue.configure-send.select-filter=Select a filter +publishing-queue.configure-send.auto-retry-hint=Failed pushes will be auto-retried up to 3 times. +publishing-queue.detail.title=Bundle details +publishing-queue.detail.status=Status +publishing-queue.detail.bundle-id=Bundle Id +publishing-queue.detail.bundle-start=Bundle start +publishing-queue.detail.bundle-end=Bundle end +publishing-queue.detail.publish-start=Publish start +publishing-queue.detail.publish-end=Publish end +publishing-queue.detail.filter=Filter +publishing-queue.detail.assets=Assets +publishing-queue.detail.endpoints=Endpoints +publishing-queue.detail.endpoint=Endpoint +publishing-queue.detail.address=Address +publishing-queue.detail.message=Message +publishing-queue.detail.no-endpoints=This bundle has not been sent to any environment yet. +publishing-queue.detail.download=Download +publishing-queue.detail.load-error=Could not load bundle details. +publishing-queue.upload.title=Upload Bundle +publishing-queue.upload.hint=Upload a previously generated bundle as a .tar.gz file. +publishing-queue.upload.choose=Choose file +publishing-queue.upload.submit=Upload PUBLISHING-NOT-LICENSED=Push Publishing is a dotCMS Enterprise Professional and Enterprise Prime only feature. It allows you to:
  • Create, delete, and publish content, Content Types, Pages, and Templates from one dotCMS environment and push them to another
  • Schedule publishing/removal of content through Workflows
  • Batch migrate content to different environments
  • Publish to multiple remote environments simultaneously
  • Publish static content to an AWS S3 content store (with a Platform License)
push-assets-could-not-be-deleted=Pushed assets could not be deleted. push_publish_integrity_cms_roles_conflicts=Roles/User Roles Conflicts From 1fba21e8cfc29bbe052c81e4e1c6f41f2dfcf125 Mon Sep 17 00:00:00 2001 From: hmoreras <31667212+hmoreras@users.noreply.github.com> Date: Mon, 15 Jun 2026 18:15:40 -0600 Subject: [PATCH 05/43] refactor(publishing-queue) #36040: reuse project-wide push publish + download bundle dialogs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the custom Configure & Send dialog with the canonical `DotPushPublishDialogService.open({ assetIdentifier, title, isBundle: true })` (same service used by templates, containers, content, content types and pages — mounted globally in `main-legacy.component.html`). Replace `store.generateBundle` with `DotDownloadBundleDialogService.open(bundleId)` following the same global-singleton pattern. Companion store / service / model / i18n cleanup: - Drop store state and methods: `pushBundleTarget`, `pushInFlight`, `environments`, `environmentsStatus`, `loadEnvironments`, `openConfigureSend`, `closeConfigureSend`, `submitPush`, `generateBundle` - Drop service methods: `pushBundle`, `generateBundle`, `getEnvironments` - Drop `PushBundleResultView` / `PushOperation` / `PushBundlePayload` - Drop the `publishing-queue.configure-send.*` i18n keys and `upload-bundle.coming-soon` - Drop the configure-send sync effect from the shell - Delete the custom Configure & Send dialog (component + spec + template) UX polish that surfaced fixing the wiring (same kebab still drives both Configure & Send and Generate / Download): - Make `readyKebabFor` an arrow-function class property (stable reference) so the list component's `kebabMenus` memoization works — fixes the first-click-only-closes-the-menu thrash in `` reported by users - Add `showTransitionOptions="0ms"` / `hideTransitionOptions="0ms"` and `(mousedown)="$event.stopPropagation()"` on the kebab toggle - Remove icons from kebab menu items (per user request) - Memoize per-bundle kebab `MenuItem[]` in `kebabMenus` computed Asset list dialog gains a hover-revealed per-row delete button that calls the new `service.removeAssetsFromBundle` + `store.removeBundleAsset` endpoint, with a fixed `h-96` container plus PrimeNG `[loading]` + `loadingbody` skeleton template to prevent the dialog from shrinking and re-expanding while the row reloads. A conditional `` search input appears when the bundle has > 10 assets, plus a "no matches" empty state. The `State` column is removed from both asset tables — the backend transformer never returns it, so it was always "—". Bundle Details modal gains an Assets section under Endpoints (per user ordering preference), with the same fixed-height + skeleton + conditional search pattern. Lazy-loaded via the new `store.loadDetailAssets` and reset when the dialog is reused for a different bundle (`detailAssetsStatus`). Model rename: `BundleAssetView.id` → `asset` to match the backend transformer's universal key (`BundleResource.java`). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dot-publishing-queue.service.spec.ts | 24 +- .../dot-publishing-queue.service.ts | 58 ++--- .../src/lib/bundle-asset-view.model.ts | 9 +- .../src/lib/publishing-job-detail.model.ts | 17 +- ...ing-queue-asset-list-dialog.component.html | 88 +++++-- ...-queue-asset-list-dialog.component.spec.ts | 164 +++++++++++-- ...shing-queue-asset-list-dialog.component.ts | 114 ++++++++- ...queue-bundle-details-dialog.component.html | 75 ++++++ ...ue-bundle-details-dialog.component.spec.ts | 140 ++++++++++- ...g-queue-bundle-details-dialog.component.ts | 78 ++++++- ...queue-configure-send-dialog.component.html | 218 ------------------ ...ue-configure-send-dialog.component.spec.ts | 142 ------------ ...g-queue-configure-send-dialog.component.ts | 162 ------------- .../dot-publishing-queue-list.component.html | 3 + .../dot-publishing-queue-list.component.ts | 21 +- .../dot-publishing-queue-page.component.html | 2 +- ...ot-publishing-queue-page.component.spec.ts | 54 ++++- .../dot-publishing-queue-page.component.ts | 73 ++++-- .../store/dot-publishing-queue.store.spec.ts | 106 ++++----- .../store/dot-publishing-queue.store.ts | 129 +++++------ ...t-publishing-queue-shell.component.spec.ts | 36 +-- .../dot-publishing-queue-shell.component.ts | 30 --- .../WEB-INF/messages/Language.properties | 26 +-- 23 files changed, 932 insertions(+), 837 deletions(-) delete mode 100644 core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-configure-send-dialog/dot-publishing-queue-configure-send-dialog.component.html delete mode 100644 core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-configure-send-dialog/dot-publishing-queue-configure-send-dialog.component.spec.ts delete mode 100644 core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-configure-send-dialog/dot-publishing-queue-configure-send-dialog.component.ts diff --git a/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.spec.ts b/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.spec.ts index 0eba8a63209a..5075bd550a7c 100644 --- a/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.spec.ts +++ b/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.spec.ts @@ -79,7 +79,7 @@ describe('DotPublishingQueueService', () => { describe('getBundleAssets', () => { it('hits /api/bundle/{bundleId}/assets with limit=-1', () => { const mockAssets: BundleAssetView[] = [ - { id: 'a1', title: 'Asset 1', type: 'contentlet' } + { asset: 'a1', title: 'Asset 1', type: 'contentlet' } ]; service.getBundleAssets('bundle-123').subscribe((assets) => { @@ -95,6 +95,28 @@ describe('DotPublishingQueueService', () => { }); }); + describe('removeAssetsFromBundle', () => { + it('DELETEs /api/v1/bundles/{bundleId}/assets with assetIds body and unwraps entity', () => { + const results = [ + { assetId: 'a1', success: true, message: 'ok' }, + { assetId: 'a2', success: true, message: 'ok' } + ]; + + service + .removeAssetsFromBundle('bundle-123', ['a1', 'a2']) + .subscribe((response) => { + expect(response).toEqual(results); + }); + + const req = httpMock.expectOne( + (request) => request.url === '/api/v1/bundles/bundle-123/assets' + ); + expect(req.request.method).toBe('DELETE'); + expect(req.request.body).toEqual({ assetIds: ['a1', 'a2'] }); + req.flush({ entity: results }); + }); + }); + describe('getUnsendBundles', () => { it('hits /api/bundle/getunsendbundles/userid/{userId} with name + start + count', () => { const mockResponse = { diff --git a/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.ts b/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.ts index 0f3cd0ac98fd..f7c548257d62 100644 --- a/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.ts +++ b/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.ts @@ -8,11 +8,10 @@ import { map } from 'rxjs/operators'; import { BundleAssetView, DotCMSResponse, - DotEnvironment, PublishAuditStatus, PublishingJobDetailView, PublishingJobsResponse, - PushBundleResultView, + RemoveAssetResultView, RetryBundleResultView, UnsentBundlesResponse } from '@dotcms/dotcms-models'; @@ -29,17 +28,8 @@ export interface ListPublishingJobsParams { sortDirection?: PublishingSortDirection; } -export type PushOperation = 'publish' | 'expire' | 'publishexpire'; export type RetryDeliveryStrategy = 'ALL_ENDPOINTS' | 'FAILED_ENDPOINTS'; -export interface PushBundlePayload { - operation: PushOperation; - publishDate?: string; - expireDate?: string; - environments: string[]; - filterKey: string; -} - export interface RetryBundlesPayload { bundleIds: string[]; forcePush?: boolean; @@ -52,18 +42,25 @@ export interface RetryBundlesPayload { * v1 endpoints (`com.dotcms.rest.api.v1.publishing.PublishingResource`): * - `GET /api/v1/publishing` — list bundles by status (sort/filter via params) * - `GET /api/v1/publishing/{bundleId}` — full bundle detail with endpoints - * - `POST /api/v1/publishing/push/{bundleId}` — push bundle to environments * - `POST /api/v1/publishing/retry` — retry bundles (bulk) * - `DELETE /api/v1/publishing/{bundleId}` — delete single bundle * - `DELETE /api/v1/publishing` — bulk delete by id (added by #36046) * * Legacy endpoints (`com.dotcms.rest.BundleResource`) still used until #36048 lands: * - `GET /api/bundle/{bundleId}/assets` — asset list inside a bundle - * - `POST /api/bundle/_generate` — async tar.gz generation * - `POST /api/bundle/sync` — synchronous .tar.gz upload (licensed) * - `GET /api/bundle/_download/{bundleId}` — bundle download (URL only) * - * Environment list comes from `EnvironmentResource` (`GET /api/environment`). + * Push-to-environment flow is delegated to the project-wide push publish dialog + * (`DotPushPublishDialogService.open(...)` from `@dotcms/dotcms-js`), which hits + * the legacy `/DotAjaxDirector/.../cmd/pushBundle` endpoint via `PushPublishService`. + * No bespoke push endpoint lives here. + * + * Bundle generate-and-download is delegated to the project-wide download dialog + * (`DotDownloadBundleDialogService.open(bundleId)` from + * `@services/dot-download-bundle-dialog/...`), which posts to `/api/bundle/_generate` + * with the user-picked filter + operation and triggers a browser download. + * No bespoke generate endpoint lives here either. */ @Injectable({ providedIn: 'root' @@ -102,14 +99,6 @@ export class DotPublishingQueueService { .pipe(map((response) => response.entity)); } - pushBundle(bundleId: string, payload: PushBundlePayload): Observable { - return this.http - .post< - DotCMSResponse - >(`/api/v1/publishing/push/${bundleId}`, payload) - .pipe(map((response) => response.entity)); - } - retryBundles(payload: RetryBundlesPayload): Observable { return this.http .post>('/api/v1/publishing/retry', payload) @@ -132,14 +121,6 @@ export class DotPublishingQueueService { ); } - generateBundle( - bundleId: string, - filterKey: string, - operation: PushOperation = 'publish' - ): Observable { - return this.http.post('/api/bundle/_generate', { bundleId, filterKey, operation }); - } - uploadBundle(file: File): Observable<{ bundleName: string; status: string }> { const formData = new FormData(); formData.append('file', file, file.name); @@ -189,12 +170,21 @@ export class DotPublishingQueueService { } /** - * Lists Push-Publish environments visible to the current user. - * Backed by `EnvironmentResource#loadAllEnvironments` (role-filtered for non-admins). + * Removes one or more assets from an unsent bundle. + * Backed by `BundleManagementResource#removeAssetsFromBundle` + * (`DELETE /api/v1/bundles/{bundleId}/assets`). + * + * Backend returns 409 if the bundle is already in progress. + * Per-asset results live inside `entity[]` — some may fail while others succeed. */ - getEnvironments(): Observable { + removeAssetsFromBundle( + bundleId: string, + assetIds: string[] + ): Observable { return this.http - .get>('/api/environment') + .request< + DotCMSResponse + >('DELETE', `/api/v1/bundles/${bundleId}/assets`, { body: { assetIds } }) .pipe(map((response) => response.entity)); } } diff --git a/core-web/libs/dotcms-models/src/lib/bundle-asset-view.model.ts b/core-web/libs/dotcms-models/src/lib/bundle-asset-view.model.ts index 99c9a41062c7..b564902bf97f 100644 --- a/core-web/libs/dotcms-models/src/lib/bundle-asset-view.model.ts +++ b/core-web/libs/dotcms-models/src/lib/bundle-asset-view.model.ts @@ -1,18 +1,21 @@ /** - * Shape of a single row in the Asset list modal. + * Shape of a single row in the Asset list / Bundle Details modal. * * Backed by `GET /api/bundle/{bundleId}/assets` which returns a `List>` * (`com.dotcms.rest.BundleResource#getPublishQueueElements`) produced by * `PublishQueueElementTransformer`. The transformer emits keys like `type`, `title`, * `inode`, `content_type_name`, `language_code`, `country_code`, `operation`, `asset`. * + * `asset` is the universal identifier — always set by the transformer for every row + * type (contentlet, language, template, container, etc.). This is the id we pass to + * `DELETE /v1/bundles/{bundleId}/assets` when removing an asset from a bundle. + * * This interface narrows that loose map to the fields the UI actually renders. */ export interface BundleAssetView { - id: string; + asset: string; title: string; type: string; - state?: string; inode?: string; contentTypeName?: string; languageCode?: string; diff --git a/core-web/libs/dotcms-models/src/lib/publishing-job-detail.model.ts b/core-web/libs/dotcms-models/src/lib/publishing-job-detail.model.ts index 28dd7cfc10c4..5949da01b070 100644 --- a/core-web/libs/dotcms-models/src/lib/publishing-job-detail.model.ts +++ b/core-web/libs/dotcms-models/src/lib/publishing-job-detail.model.ts @@ -54,6 +54,13 @@ export interface PublishingJobDetailView { numTries: number; } +/** Per-asset result of `DELETE /v1/bundles/{bundleId}/assets`. */ +export interface RemoveAssetResultView { + assetId: string; + success: boolean; + message: string; +} + /** Result of a single bundle retry. */ export interface RetryBundleResultView { bundleId: string; @@ -64,13 +71,3 @@ export interface RetryBundleResultView { deliveryStrategy: 'ALL_ENDPOINTS' | 'FAILED_ENDPOINTS'; assetCount: number | null; } - -/** Result of pushing a bundle to environments. */ -export interface PushBundleResultView { - bundleId: string; - operation: string; - publishDate: string | null; - expireDate: string | null; - environments: string[]; - filterKey: string; -} 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.html b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-asset-list-dialog/dot-publishing-queue-asset-list-dialog.component.html index 3067262580de..e4e6e5c2c401 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.html +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-asset-list-dialog/dot-publishing-queue-asset-list-dialog.component.html @@ -1,30 +1,86 @@ -
- @if (store.assetListStatus() === 'loading') { -
- - - +
+ @if (showAssetSearch()) { +
+ + + +
- } @else if (store.selectedAssets().length === 0) { -
- {{ 'publishing-queue.asset-list.empty' | dm }} -
- } @else { - + } + +
+ {{ 'publishing-queue.column.name' | dm }} {{ 'publishing-queue.column.type' | dm }} - {{ 'publishing-queue.column.state' | dm }} + + + + @for (_ of assetSkeletonRows; track $index) { + + + + + + } + + - + {{ asset.title }} {{ asset.type }} - {{ asset.state || '—' }} + + + + + + + + + + @if (hasNoMatches()) { + + {{ 'publishing-queue.detail.assets-no-matches' | dm }} + + } @else { + + {{ 'publishing-queue.asset-list.empty' | dm }} + + } + - } +
+ 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.spec.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.spec.ts index 7e6670b553ed..10926d353611 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.spec.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.spec.ts @@ -2,6 +2,8 @@ import { byTestId, createComponentFactory, mockProvider, Spectator } from '@ngne import { signal } from '@angular/core'; +import { ConfirmationService } from 'primeng/api'; + import { DotMessageService } from '@dotcms/data-access'; import { BundleAssetView } from '@dotcms/dotcms-models'; import { MockDotMessageService } from '@dotcms/utils-testing'; @@ -11,30 +13,48 @@ import { DotPublishingQueueAssetListDialogComponent } from './dot-publishing-que import { DotPublishingQueueStore } from '../../dot-publishing-queue-page/store/dot-publishing-queue.store'; const ASSETS: BundleAssetView[] = [ - { id: 'a1', title: 'Asset 1', type: 'contentlet', state: 'PUBLISH' }, - { id: 'a2', title: 'Asset 2', type: 'template' } + { asset: 'a1', title: 'Asset 1', type: 'contentlet' }, + { asset: 'a2', title: 'Asset 2', type: 'template' } ]; describe('DotPublishingQueueAssetListDialogComponent', () => { let spectator: Spectator; + let store: ReturnType; + let confirmationService: jest.Mocked; const selectedAssets = signal([]); const assetListStatus = signal<'init' | 'loading' | 'loaded' | 'error'>('loading'); + const selectedBundleId = signal(null); + + function storeStub() { + return { + selectedAssets, + assetListStatus, + selectedBundleId, + removeBundleAsset: jest.fn() + }; + } const createComponent = createComponentFactory({ component: DotPublishingQueueAssetListDialogComponent, + componentProviders: [mockProvider(DotPublishingQueueStore, storeStub())], providers: [ - mockProvider(DotPublishingQueueStore, { - selectedAssets, - assetListStatus - }), + ConfirmationService, { provide: DotMessageService, useValue: new MockDotMessageService({ 'publishing-queue.column.name': 'Name', 'publishing-queue.column.type': 'Type', - 'publishing-queue.column.state': 'State', - 'publishing-queue.asset-list.empty': 'No items' + 'publishing-queue.asset-list.empty': 'No items', + '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.detail.search-assets': 'Search assets', + 'publishing-queue.detail.assets-no-matches': 'No assets match your search.', + 'publishing-queue.remove': 'Remove', + 'publishing-queue.cancel': 'Cancel' }) } ] @@ -43,17 +63,39 @@ describe('DotPublishingQueueAssetListDialogComponent', () => { beforeEach(() => { selectedAssets.set([]); assetListStatus.set('loading'); + selectedBundleId.set(null); spectator = createComponent(); + store = spectator.inject(DotPublishingQueueStore, true) as unknown as ReturnType< + typeof storeStub + >; + confirmationService = spectator.inject( + ConfirmationService + ) as jest.Mocked; + jest.spyOn(confirmationService, 'confirm').mockImplementation((cfg) => { + cfg.accept?.(); + return confirmationService; + }); + jest.clearAllMocks(); + }); + + it('reserves a fixed-height shell + always-mounted table header (no resize jank)', () => { + // Shell + table render in every state so the dialog doesn't shrink/expand. + expect(spectator.query(byTestId('pq-asset-list-shell'))).toBeTruthy(); + expect(spectator.query(byTestId('pq-asset-list-table'))).toBeTruthy(); }); - it('shows loading skeleton when status is loading', () => { - expect(spectator.query(byTestId('pq-asset-list-loading'))).toBeTruthy(); + it('shows skeleton rows when status is loading', () => { + const skeletons = spectator.queryAll(byTestId('pq-asset-list-skeleton')); + expect(skeletons.length).toBe(8); + expect(spectator.query(byTestId('pq-asset-list-row'))).toBeFalsy(); + expect(spectator.query(byTestId('pq-asset-list-empty'))).toBeFalsy(); }); - it('shows empty state when loaded with no assets', () => { + it('shows empty state inside the table when loaded with no assets', () => { assetListStatus.set('loaded'); spectator.detectChanges(); expect(spectator.query(byTestId('pq-asset-list-empty'))).toBeTruthy(); + expect(spectator.query(byTestId('pq-asset-list-row'))).toBeFalsy(); }); it('renders rows when assets are present', () => { @@ -62,13 +104,105 @@ describe('DotPublishingQueueAssetListDialogComponent', () => { spectator.detectChanges(); const rows = spectator.queryAll(byTestId('pq-asset-list-row')); expect(rows.length).toBe(2); + expect(spectator.query(byTestId('pq-asset-list-skeleton'))).toBeFalsy(); }); - it('renders "—" for missing state', () => { - selectedAssets.set([ASSETS[1]]); + it('renders Name + Type columns plus an action column for the trash button', () => { + selectedAssets.set(ASSETS); assetListStatus.set('loaded'); spectator.detectChanges(); - const stateCell = spectator.query(byTestId('pq-asset-state')); - expect(stateCell?.textContent?.trim()).toBe('—'); + const headers = spectator.queryAll('th'); + // 2 visible (Name, Type) + 1 empty (action column for the trash icon) = 3 + expect(headers.length).toBe(3); + }); + + describe('per-row remove asset', () => { + beforeEach(() => { + selectedAssets.set(ASSETS); + assetListStatus.set('loaded'); + spectator.detectChanges(); + }); + + it('renders a trash button per row', () => { + const buttons = spectator.queryAll(byTestId('pq-asset-remove-btn')); + expect(buttons.length).toBe(2); + }); + + it('confirms before removing, then calls store.removeBundleAsset with the asset id', () => { + spectator.component.onRemoveAsset(ASSETS[0]); + expect(confirmationService.confirm).toHaveBeenCalled(); + expect(store.removeBundleAsset).toHaveBeenCalledWith('a1'); + }); + }); + + describe('search (visible only when selectedAssets.length > 10)', () => { + const manyAssets: BundleAssetView[] = Array.from({ length: 15 }, (_, i) => ({ + asset: `a${i}`, + title: i % 2 === 0 ? `Homepage ${i}` : `Template ${i}`, + type: i % 2 === 0 ? 'contentlet' : 'template' + })); + + it('hides the search input when there are 10 or fewer assets', () => { + selectedAssets.set(ASSETS); + assetListStatus.set('loaded'); + spectator.detectChanges(); + expect(spectator.query(byTestId('pq-asset-list-search'))).toBeFalsy(); + expect(spectator.query(byTestId('pq-asset-list-search-bar'))).toBeFalsy(); + }); + + it('renders the search input when there are more than 10 assets', () => { + selectedAssets.set(manyAssets); + assetListStatus.set('loaded'); + spectator.detectChanges(); + expect(spectator.query(byTestId('pq-asset-list-search-bar'))).toBeTruthy(); + expect(spectator.query(byTestId('pq-asset-list-search'))).toBeTruthy(); + }); + + it('filters rows by title or type when search is set', () => { + jest.useFakeTimers(); + try { + selectedAssets.set(manyAssets); + assetListStatus.set('loaded'); + spectator.detectChanges(); + expect(spectator.queryAll(byTestId('pq-asset-list-row')).length).toBe(15); + + spectator.component.onSearch('template'); + jest.advanceTimersByTime(300); + spectator.detectChanges(); + + // Half the assets have type 'template' (every odd index) — 7 of 15 + const rows = spectator.queryAll(byTestId('pq-asset-list-row')); + expect(rows.length).toBe(7); + } finally { + jest.useRealTimers(); + } + }); + + it('shows the "no matches" message when search returns zero but bundle has assets', () => { + selectedAssets.set(manyAssets); + assetListStatus.set('loaded'); + spectator.detectChanges(); + + spectator.component.assetSearch.set('something-that-doesnt-exist'); + spectator.detectChanges(); + + expect(spectator.query(byTestId('pq-asset-list-no-matches'))).toBeTruthy(); + expect(spectator.query(byTestId('pq-asset-list-empty'))).toBeFalsy(); + }); + + it('resets search when the dialog is reused for a different bundle', () => { + selectedBundleId.set('A'); + selectedAssets.set(manyAssets); + assetListStatus.set('loaded'); + spectator.detectChanges(); + + spectator.component.assetSearch.set('homepage'); + spectator.detectChanges(); + expect(spectator.component.assetSearch()).toBe('homepage'); + + selectedBundleId.set('B'); + spectator.detectChanges(); + expect(spectator.component.assetSearch()).toBe(''); + }); }); }); 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 923556d65b60..789924d2bbe9 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 @@ -1,19 +1,129 @@ -import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { Subject } from 'rxjs'; +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + computed, + effect, + inject, + signal, + untracked +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormsModule } from '@angular/forms'; + +import { ConfirmationService } from 'primeng/api'; +import { ButtonModule } from 'primeng/button'; +import { ConfirmDialogModule } from 'primeng/confirmdialog'; +import { IconFieldModule } from 'primeng/iconfield'; +import { InputIconModule } from 'primeng/inputicon'; +import { InputTextModule } from 'primeng/inputtext'; import { SkeletonModule } from 'primeng/skeleton'; import { TableModule } from 'primeng/table'; +import { TooltipModule } from 'primeng/tooltip'; + +import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; +import { DotMessageService } from '@dotcms/data-access'; +import { BundleAssetView } from '@dotcms/dotcms-models'; import { DotMessagePipe } from '@dotcms/ui'; import { DotPublishingQueueStore } from '../../dot-publishing-queue-page/store/dot-publishing-queue.store'; +/** Show the search input only when the bundle is big enough that scrolling alone is painful. */ +const ASSET_SEARCH_THRESHOLD = 10; + @Component({ selector: 'dot-publishing-queue-asset-list-dialog', standalone: true, - imports: [TableModule, SkeletonModule, DotMessagePipe], + imports: [ + FormsModule, + ButtonModule, + ConfirmDialogModule, + IconFieldModule, + InputIconModule, + InputTextModule, + SkeletonModule, + TableModule, + TooltipModule, + DotMessagePipe + ], + providers: [ConfirmationService], templateUrl: './dot-publishing-queue-asset-list-dialog.component.html', changeDetection: ChangeDetectionStrategy.OnPush }) export class DotPublishingQueueAssetListDialogComponent { readonly store = inject(DotPublishingQueueStore); + + private readonly confirmationService = inject(ConfirmationService); + private readonly dotMessageService = inject(DotMessageService); + private readonly destroyRef = inject(DestroyRef); + + /** Skeleton rows for the loading state. Length chosen so the placeholder fills + * the reserved 384px (h-96) and the dialog stays stable on load + after deletes. */ + readonly assetSkeletonRows = Array.from({ length: 8 }); + + readonly assetSearch = signal(''); + private readonly searchSubject = new Subject(); + + /** Search input only shows when the loaded asset list has more than ASSET_SEARCH_THRESHOLD items. */ + readonly showAssetSearch = computed( + () => this.store.selectedAssets().length > ASSET_SEARCH_THRESHOLD + ); + + /** Client-side filter over title + type. Case-insensitive. */ + readonly filteredAssets = computed(() => { + const query = this.assetSearch().trim().toLowerCase(); + const assets = this.store.selectedAssets(); + if (!query) { + return assets; + } + return assets.filter( + (a) => a.title.toLowerCase().includes(query) || a.type.toLowerCase().includes(query) + ); + }); + + /** True when search is active but returns nothing — distinct UX from "bundle is empty". */ + readonly hasNoMatches = computed( + () => + this.assetSearch().trim().length > 0 && + this.store.assetListStatus() === 'loaded' && + this.filteredAssets().length === 0 && + this.store.selectedAssets().length > 0 + ); + + constructor() { + this.searchSubject + .pipe(debounceTime(300), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) + .subscribe((value) => this.assetSearch.set(value)); + + // Reset the input every time the dialog is reused for a different bundle. + effect(() => { + this.store.selectedBundleId(); + untracked(() => this.assetSearch.set('')); + }); + } + + onSearch(value: string): void { + this.searchSubject.next(value); + } + + onRemoveAsset(asset: BundleAssetView): void { + this.confirmationService.confirm({ + header: this.dotMessageService.get('publishing-queue.asset-list.remove-confirm.header'), + message: this.dotMessageService.get( + 'publishing-queue.asset-list.remove-confirm.message', + asset.title || asset.asset + ), + acceptLabel: this.dotMessageService.get('publishing-queue.remove'), + rejectLabel: this.dotMessageService.get('publishing-queue.cancel'), + acceptButtonStyleClass: 'p-button-danger', + rejectButtonStyleClass: 'p-button-text', + defaultFocus: 'reject', + closable: true, + closeOnEscape: true, + accept: () => this.store.removeBundleAsset(asset.asset) + }); + } } diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.html b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.html index 30994a14268b..57cf310b7884 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.html +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.html @@ -104,6 +104,81 @@

} +
+
+

+ {{ 'publishing-queue.detail.assets' | dm }} + ({{ detail.assetCount }}) +

+ @if (showAssetSearch()) { + + + + + } +
+ +
+ + + + {{ 'publishing-queue.column.name' | dm }} + {{ 'publishing-queue.column.type' | dm }} + + + + @for (_ of assetSkeletonRows; track $index) { + + + + + } + + + + {{ asset.title }} + {{ asset.type }} + + + + + + @if (hasNoMatches()) { + + {{ 'publishing-queue.detail.assets-no-matches' | dm }} + + } @else { + + {{ 'publishing-queue.asset-list.empty' | dm }} + + } + + + + +
+
+ @if (canDownload()) {
{ const detail = signal(null); const detailStatus = signal<'init' | 'loading' | 'loaded' | 'error'>('loading'); + const detailAssets = signal([]); + const detailAssetsStatus = signal<'init' | 'loading' | 'loaded' | 'error'>('loaded'); + const detailBundleId = signal(null); const createComponent = createComponentFactory({ component: DotPublishingQueueBundleDetailsDialogComponent, providers: [ - mockProvider(DotPublishingQueueStore, { detail, detailStatus }), + mockProvider(DotPublishingQueueStore, { + detail, + detailStatus, + detailAssets, + detailAssetsStatus, + detailBundleId + }), mockProvider(DotPublishingQueueService, { getBundleDownloadUrl: jest.fn((id: string) => `/api/bundle/_download/${id}`) }), @@ -69,6 +82,9 @@ describe('DotPublishingQueueBundleDetailsDialogComponent', () => { beforeEach(() => { detail.set(null); detailStatus.set('loading'); + detailAssets.set([]); + detailAssetsStatus.set('loaded'); + detailBundleId.set(null); spectator = createComponent(); }); @@ -111,4 +127,124 @@ describe('DotPublishingQueueBundleDetailsDialogComponent', () => { spectator.detectChanges(); expect(spectator.query(byTestId('pq-detail-error'))).toBeTruthy(); }); + + describe('assets section', () => { + it('reserves space (shell + table header) even while loading so the dialog does not jump', () => { + detail.set(detailFixture()); + detailStatus.set('loaded'); + detailAssetsStatus.set('loading'); + spectator.detectChanges(); + expect(spectator.query(byTestId('pq-detail-assets-shell'))).toBeTruthy(); + expect(spectator.query(byTestId('pq-detail-assets-table'))).toBeTruthy(); + expect(spectator.queryAll(byTestId('pq-detail-asset-skeleton')).length).toBe(5); + expect(spectator.query(byTestId('pq-detail-asset-row'))).toBeFalsy(); + expect(spectator.query(byTestId('pq-detail-assets-empty'))).toBeFalsy(); + }); + + it('renders the asset rows when items are loaded', () => { + detail.set(detailFixture()); + detailStatus.set('loaded'); + detailAssets.set([ + { asset: 'a1', title: 'Page 1', type: 'contentlet' }, + { asset: 'a2', title: 'Template 1', type: 'template' } + ]); + detailAssetsStatus.set('loaded'); + spectator.detectChanges(); + expect(spectator.query(byTestId('pq-detail-assets-shell'))).toBeTruthy(); + expect(spectator.queryAll(byTestId('pq-detail-asset-row')).length).toBe(2); + expect(spectator.query(byTestId('pq-detail-asset-skeleton'))).toBeFalsy(); + }); + + it('shows the empty placeholder inside the shell when loaded with no assets', () => { + detail.set(detailFixture()); + detailStatus.set('loaded'); + detailAssets.set([]); + detailAssetsStatus.set('loaded'); + spectator.detectChanges(); + expect(spectator.query(byTestId('pq-detail-assets-shell'))).toBeTruthy(); + expect(spectator.query(byTestId('pq-detail-assets-empty'))).toBeTruthy(); + expect(spectator.query(byTestId('pq-detail-asset-row'))).toBeFalsy(); + }); + }); + + describe('assets search (visible only when assetCount > 10)', () => { + const manyAssets = Array.from({ length: 15 }, (_, i) => ({ + id: `a${i}`, + title: i % 2 === 0 ? `Homepage ${i}` : `Template ${i}`, + type: i % 2 === 0 ? 'contentlet' : 'template' + })); + + it('hides the search input when assetCount <= 10', () => { + detail.set(detailFixture({ assetCount: 4 })); + detailStatus.set('loaded'); + detailAssets.set([{ asset: 'a1', title: 'Asset 1', type: 'contentlet' }]); + detailAssetsStatus.set('loaded'); + spectator.detectChanges(); + expect(spectator.query(byTestId('pq-detail-assets-search'))).toBeFalsy(); + }); + + it('renders the search input when assetCount > 10', () => { + detail.set(detailFixture({ assetCount: 15 })); + detailStatus.set('loaded'); + detailAssets.set(manyAssets); + detailAssetsStatus.set('loaded'); + spectator.detectChanges(); + expect(spectator.query(byTestId('pq-detail-assets-search'))).toBeTruthy(); + }); + + it('filters rows by title or type when search is set', () => { + jest.useFakeTimers(); + try { + detail.set(detailFixture({ assetCount: 15 })); + detailStatus.set('loaded'); + detailAssets.set(manyAssets); + detailAssetsStatus.set('loaded'); + spectator.detectChanges(); + expect(spectator.queryAll(byTestId('pq-detail-asset-row')).length).toBe(15); + + spectator.component.onSearch('template'); + jest.advanceTimersByTime(300); + spectator.detectChanges(); + + // Half the assets have type 'template' (every odd index) — 7 of 15 + const rows = spectator.queryAll(byTestId('pq-detail-asset-row')); + expect(rows.length).toBe(7); + } finally { + jest.useRealTimers(); + } + }); + + it('shows the "no matches" message when search returns zero but bundle has assets', () => { + detail.set(detailFixture({ assetCount: 15 })); + detailStatus.set('loaded'); + detailAssets.set(manyAssets); + detailAssetsStatus.set('loaded'); + spectator.detectChanges(); + + spectator.component.assetSearch.set('something-that-doesnt-exist'); + spectator.detectChanges(); + + expect(spectator.query(byTestId('pq-detail-assets-no-matches'))).toBeTruthy(); + expect(spectator.query(byTestId('pq-detail-assets-empty'))).toBeFalsy(); + }); + + it('resets search when the dialog is reused for a different bundle', () => { + // Open the dialog on bundle A and let the init effect settle. + detailBundleId.set('A'); + detail.set(detailFixture({ bundleId: 'A', assetCount: 15 })); + detailStatus.set('loaded'); + detailAssetsStatus.set('loaded'); + spectator.detectChanges(); + + // Set the search AFTER the effect has run with 'A'. + spectator.component.assetSearch.set('homepage'); + spectator.detectChanges(); + expect(spectator.component.assetSearch()).toBe('homepage'); + + // Switching to a different bundle id triggers the reset effect. + detailBundleId.set('B'); + spectator.detectChanges(); + expect(spectator.component.assetSearch()).toBe(''); + }); + }); }); diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.ts index a70d54cab226..795b992fdfe0 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.ts @@ -1,10 +1,28 @@ +import { Subject } from 'rxjs'; + import { DatePipe } from '@angular/common'; -import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + computed, + effect, + inject, + signal, + untracked +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormsModule } from '@angular/forms'; import { ButtonModule } from 'primeng/button'; +import { IconFieldModule } from 'primeng/iconfield'; +import { InputIconModule } from 'primeng/inputicon'; +import { InputTextModule } from 'primeng/inputtext'; import { SkeletonModule } from 'primeng/skeleton'; import { TableModule } from 'primeng/table'; +import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; + import { DotPublishingQueueService } from '@dotcms/data-access'; import { PublishAuditStatus } from '@dotcms/dotcms-models'; import { DotMessagePipe } from '@dotcms/ui'; @@ -12,6 +30,9 @@ import { DotMessagePipe } from '@dotcms/ui'; import { DotPublishingStatusChipComponent } from '../../components/dot-publishing-status-chip/dot-publishing-status-chip.component'; import { DotPublishingQueueStore } from '../../dot-publishing-queue-page/store/dot-publishing-queue.store'; +/** Show the search input only when the bundle is big enough that scrolling alone is painful. */ +const ASSET_SEARCH_THRESHOLD = 10; + const SUCCESS_STATUSES = new Set([ PublishAuditStatus.SUCCESS, PublishAuditStatus.BUNDLE_SENT_SUCCESSFULLY, @@ -24,7 +45,11 @@ const SUCCESS_STATUSES = new Set([ standalone: true, imports: [ DatePipe, + FormsModule, ButtonModule, + IconFieldModule, + InputIconModule, + InputTextModule, SkeletonModule, TableModule, DotMessagePipe, @@ -37,12 +62,63 @@ export class DotPublishingQueueBundleDetailsDialogComponent { readonly store = inject(DotPublishingQueueStore); private readonly publishingService = inject(DotPublishingQueueService); + private readonly destroyRef = inject(DestroyRef); + + /** Placeholder rows for the assets table's loading state. Length chosen so + * the skeleton fills the reserved 192px (h-48) and the dialog opens at its + * final size — no jump when the assets endpoint resolves. */ + readonly assetSkeletonRows = Array.from({ length: 5 }); + + readonly assetSearch = signal(''); + private readonly searchSubject = new Subject(); + + /** Search input only shows when bundle has > ASSET_SEARCH_THRESHOLD assets. */ + readonly showAssetSearch = computed( + () => (this.store.detail()?.assetCount ?? 0) > ASSET_SEARCH_THRESHOLD + ); + + /** Client-side filter over title + type. Case-insensitive. */ + readonly filteredAssets = computed(() => { + const query = this.assetSearch().trim().toLowerCase(); + const assets = this.store.detailAssets(); + if (!query) { + return assets; + } + return assets.filter( + (a) => a.title.toLowerCase().includes(query) || a.type.toLowerCase().includes(query) + ); + }); + + /** True when search is active but returns nothing — distinct UX from "bundle is empty". */ + readonly hasNoMatches = computed( + () => + this.assetSearch().trim().length > 0 && + this.store.detailAssetsStatus() === 'loaded' && + this.filteredAssets().length === 0 && + this.store.detailAssets().length > 0 + ); readonly canDownload = computed(() => { const status = this.store.detail()?.status; return status ? SUCCESS_STATUSES.has(status) : false; }); + constructor() { + this.searchSubject + .pipe(debounceTime(300), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) + .subscribe((value) => this.assetSearch.set(value)); + + // Reset the input every time the dialog is reused for a different bundle. + effect(() => { + this.store.detailBundleId(); + untracked(() => this.assetSearch.set('')); + }); + } + + onSearch(value: string): void { + this.searchSubject.next(value); + } + downloadHref(bundleId: string): string { return this.publishingService.getBundleDownloadUrl(bundleId); } diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-configure-send-dialog/dot-publishing-queue-configure-send-dialog.component.html b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-configure-send-dialog/dot-publishing-queue-configure-send-dialog.component.html deleted file mode 100644 index d509551d8736..000000000000 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-configure-send-dialog/dot-publishing-queue-configure-send-dialog.component.html +++ /dev/null @@ -1,218 +0,0 @@ -
- @if (bundle(); as bundle) { -

- {{ bundle.bundleName || bundle.bundleId }} -

- } - -
- - {{ 'publishing-queue.configure-send.action' | dm }} - -
- - - -
-
- -
- - @if (operation() === 'remove') { - {{ 'publishing-queue.configure-send.when-to-remove' | dm }} - } @else { - {{ 'publishing-queue.configure-send.when-to-publish' | dm }} - } - -
- - -
-

- {{ timezoneLabel() }} -

-
- - @if (showPublishDate()) { -
- - -
- } - - @if (showExpireDateScheduled()) { -
- - -
- } - - @if (operation() === 'push') { - - - @if (setExpire()) { -
- - -
- } - } - - @if (operation() === 'pushremove') { -
- - -
- } - -
- - - @if (selectedEnvironments().length === 0) { - - {{ 'publishing-queue.configure-send.env-required' | dm }} - - } -
- - @if (showFilter()) { -
- - -
- } - - - -
- - -
-
diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-configure-send-dialog/dot-publishing-queue-configure-send-dialog.component.spec.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-configure-send-dialog/dot-publishing-queue-configure-send-dialog.component.spec.ts deleted file mode 100644 index 21fccea43f84..000000000000 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-configure-send-dialog/dot-publishing-queue-configure-send-dialog.component.spec.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; -import { of } from 'rxjs'; - -import { signal } from '@angular/core'; - -import { DynamicDialogRef } from 'primeng/dynamicdialog'; - -import { DotMessageService, DotPushPublishFiltersService } from '@dotcms/data-access'; -import { PublishAuditStatus, PublishingJobView } from '@dotcms/dotcms-models'; -import { MockDotMessageService } from '@dotcms/utils-testing'; - -import { DotPublishingQueueConfigureSendDialogComponent } from './dot-publishing-queue-configure-send-dialog.component'; - -import { DotPublishingQueueStore } from '../../dot-publishing-queue-page/store/dot-publishing-queue.store'; - -const bundleFixture: PublishingJobView = { - bundleId: 'b-1', - bundleName: 'Bundle 1', - status: PublishAuditStatus.WAITING_FOR_PUBLISHING, - filterName: null, - filterKey: null, - assetCount: 1, - assetPreview: [], - environmentCount: 0, - createDate: '2026-06-08T10:00:00Z', - statusUpdated: null, - numTries: 0 -}; - -describe('DotPublishingQueueConfigureSendDialogComponent', () => { - let spectator: Spectator; - let store: ReturnType; - let dialogRef: jest.Mocked; - - const pushBundleTarget = signal(null); - const pushInFlight = signal(false); - const environments = signal([ - { id: 'env-1', name: 'Prod' }, - { id: 'env-2', name: 'Staging' } - ]); - - function makeStoreStub() { - return { - pushBundleTarget, - pushInFlight, - environments, - submitPush: jest.fn((_id, _payload, cb: () => void) => cb()) - }; - } - - const createComponent = createComponentFactory({ - component: DotPublishingQueueConfigureSendDialogComponent, - providers: [ - mockProvider(DotPublishingQueueStore, makeStoreStub()), - mockProvider(DotPushPublishFiltersService, { - get: jest.fn().mockReturnValue( - of([ - { defaultFilter: true, key: 'default.yml', title: 'Default' }, - { defaultFilter: false, key: 'force.yml', title: 'Force Push' } - ]) - ) - }), - mockProvider(DynamicDialogRef, { close: jest.fn() }), - { provide: DotMessageService, useValue: new MockDotMessageService({}) } - ] - }); - - beforeEach(() => { - pushBundleTarget.set(bundleFixture); - pushInFlight.set(false); - spectator = createComponent(); - store = spectator.inject(DotPublishingQueueStore) as unknown as ReturnType< - typeof makeStoreStub - >; - dialogRef = spectator.inject(DynamicDialogRef) as jest.Mocked; - jest.clearAllMocks(); - }); - - it('starts with operation=push and scheduleMode=now', () => { - expect(spectator.component.operation()).toBe('push'); - expect(spectator.component.scheduleMode()).toBe('now'); - }); - - it('canSubmit is false with no env selected', () => { - expect(spectator.component.canSubmit()).toBe(false); - }); - - it('canSubmit becomes true with an env and a filter selected', () => { - spectator.component.selectedEnvironments.set(['env-1']); - spectator.component.selectedFilterKey.set('default.yml'); - expect(spectator.component.canSubmit()).toBe(true); - }); - - it('hides filter dropdown for remove operation, and submits without filter', () => { - spectator.component.setOperation('remove'); - spectator.component.selectedEnvironments.set(['env-1']); - spectator.component.scheduleMode.set('schedule'); - spectator.component.expireDate.set(new Date('2026-07-01T00:00:00')); - expect(spectator.component.canSubmit()).toBe(true); - - spectator.component.onSubmit(); - expect(store.submitPush).toHaveBeenCalledWith( - 'b-1', - expect.objectContaining({ - operation: 'expire', - publishDate: undefined, - filterKey: expect.any(String) - }), - expect.any(Function) - ); - }); - - it('maps design operation push → publish on submit', () => { - spectator.component.selectedEnvironments.set(['env-1']); - spectator.component.selectedFilterKey.set('default.yml'); - spectator.component.onSubmit(); - expect(store.submitPush).toHaveBeenCalledWith( - 'b-1', - expect.objectContaining({ operation: 'publish' }), - expect.any(Function) - ); - }); - - it('maps pushremove → publishexpire', () => { - spectator.component.setOperation('pushremove'); - spectator.component.selectedEnvironments.set(['env-1']); - spectator.component.selectedFilterKey.set('default.yml'); - spectator.component.expireDate.set(new Date('2026-08-01T00:00:00')); - spectator.component.onSubmit(); - expect(store.submitPush).toHaveBeenCalledWith( - 'b-1', - expect.objectContaining({ operation: 'publishexpire' }), - expect.any(Function) - ); - }); - - it('cancel closes the dialog without submitting', () => { - spectator.component.onCancel(); - expect(dialogRef.close).toHaveBeenCalled(); - expect(store.submitPush).not.toHaveBeenCalled(); - }); -}); diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-configure-send-dialog/dot-publishing-queue-configure-send-dialog.component.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-configure-send-dialog/dot-publishing-queue-configure-send-dialog.component.ts deleted file mode 100644 index 724012bae149..000000000000 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-configure-send-dialog/dot-publishing-queue-configure-send-dialog.component.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; -import { FormsModule } from '@angular/forms'; - -import { ButtonModule } from 'primeng/button'; -import { DatePickerModule } from 'primeng/datepicker'; -import { DynamicDialogRef } from 'primeng/dynamicdialog'; -import { MessageModule } from 'primeng/message'; -import { SelectModule } from 'primeng/select'; - -import { - DotMessageService, - DotPushPublishFiltersService, - PushOperation -} from '@dotcms/data-access'; -import { DotMessagePipe } from '@dotcms/ui'; - -import { DotPublishingQueueStore } from '../../dot-publishing-queue-page/store/dot-publishing-queue.store'; - -type DesignOperation = 'push' | 'remove' | 'pushremove'; -type ScheduleMode = 'now' | 'schedule'; - -const OPERATION_MAP: Record = { - push: 'publish', - remove: 'expire', - pushremove: 'publishexpire' -}; - -@Component({ - selector: 'dot-publishing-queue-configure-send-dialog', - standalone: true, - imports: [ - FormsModule, - ButtonModule, - DatePickerModule, - MessageModule, - SelectModule, - DotMessagePipe - ], - templateUrl: './dot-publishing-queue-configure-send-dialog.component.html', - changeDetection: ChangeDetectionStrategy.OnPush -}) -export class DotPublishingQueueConfigureSendDialogComponent { - readonly store = inject(DotPublishingQueueStore); - readonly dialogRef = inject(DynamicDialogRef); - private readonly dotMessageService = inject(DotMessageService); - private readonly filtersService = inject(DotPushPublishFiltersService); - - readonly operation = signal('push'); - readonly scheduleMode = signal('now'); - readonly publishDate = signal(null); - readonly expireDate = signal(null); - readonly setExpire = signal(false); - readonly selectedEnvironments = signal([]); - readonly selectedFilterKey = signal(null); - - readonly filters = toSignal(this.filtersService.get(), { initialValue: [] }); - - readonly bundle = computed(() => this.store.pushBundleTarget()); - readonly envOptions = computed(() => this.store.environments()); - readonly envOptionLabel = 'name'; - readonly envOptionValue = 'id'; - - readonly showFilter = computed(() => this.operation() !== 'remove'); - readonly showExpireDate = computed( - () => this.operation() === 'pushremove' || (this.operation() === 'push' && this.setExpire()) - ); - readonly showPublishDate = computed( - () => this.operation() !== 'remove' && this.scheduleMode() === 'schedule' - ); - readonly showExpireDateScheduled = computed( - () => this.operation() === 'remove' && this.scheduleMode() === 'schedule' - ); - - readonly canSubmit = computed(() => { - if (this.selectedEnvironments().length === 0) { - return false; - } - if (this.showFilter() && !this.selectedFilterKey()) { - return false; - } - return true; - }); - - onSubmit(): void { - const bundle = this.bundle(); - if (!bundle || !this.canSubmit()) { - return; - } - - const apiOperation = OPERATION_MAP[this.operation()]; - const payload = { - operation: apiOperation, - environments: this.selectedEnvironments(), - filterKey: this.selectedFilterKey() ?? 'ForcePush.yml', - publishDate: this.publishDateIso(), - expireDate: this.expireDateIso() - }; - - this.store.submitPush(bundle.bundleId, payload, () => this.dialogRef.close()); - } - - onCancel(): void { - this.dialogRef.close(); - } - - /** Click-handler for the action cards; signature kept terse for template inlining. */ - setOperation(op: DesignOperation): void { - this.operation.set(op); - if (op === 'remove') { - this.selectedFilterKey.set(null); - } - } - - setSchedule(mode: ScheduleMode): void { - this.scheduleMode.set(mode); - } - - toggleExpire(value: boolean): void { - this.setExpire.set(value); - } - - private publishDateIso(): string | undefined { - if (this.scheduleMode() !== 'schedule' || this.operation() === 'remove') { - return undefined; - } - const d = this.publishDate(); - return d ? this.toOffsetIso(d) : undefined; - } - - private expireDateIso(): string | undefined { - const isExpireRequired = this.operation() === 'pushremove' || this.operation() === 'remove'; - const wantsExpire = isExpireRequired || (this.operation() === 'push' && this.setExpire()); - if (!wantsExpire) { - return undefined; - } - const d = this.expireDate(); - return d ? this.toOffsetIso(d) : undefined; - } - - /** ISO 8601 with timezone offset — matches what `PushBundleForm.validateDateFormat` expects. */ - private toOffsetIso(d: Date): string { - const pad = (n: number) => n.toString().padStart(2, '0'); - const tzOffsetMin = -d.getTimezoneOffset(); - const sign = tzOffsetMin >= 0 ? '+' : '-'; - const offHrs = pad(Math.floor(Math.abs(tzOffsetMin) / 60)); - const offMin = pad(Math.abs(tzOffsetMin) % 60); - return ( - `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` + - `T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` + - `${sign}${offHrs}:${offMin}` - ); - } - - timezoneLabel(): string { - return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; - } - - msg(key: string, ...args: string[]): string { - return this.dotMessageService.get(key, ...args); - } -} diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-list/dot-publishing-queue-list.component.html b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-list/dot-publishing-queue-list.component.html index bab931c34da1..d7d7b9bfc649 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-list/dot-publishing-queue-list.component.html +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-list/dot-publishing-queue-list.component.html @@ -59,10 +59,13 @@

[model]="kebabFor(job)" [popup]="true" appendTo="body" + showTransitionOptions="0ms" + hideTransitionOptions="0ms" data-testid="pq-row-kebab-menu" />

diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.html b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.html index f2439569cd1e..d078f0f9430f 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.html +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.html @@ -1,132 +1,160 @@ -
- @if (hasSelection()) { -
- - {{ store.historySelectedIds().length }} - {{ 'publishing-queue.selected' | dm }} - -
- - -
-
- } - +@if (hasSelection()) {
- - - - - - - - {{ 'publishing-queue.column.bundle-id' | dm }} - - - {{ 'publishing-queue.column.filter' | dm }} - - - {{ 'publishing-queue.column.status' | dm }} - - - - {{ 'publishing-queue.column.data-entered' | dm }} - - - - {{ 'publishing-queue.column.last-update' | dm }} - - - - + class="flex items-center gap-2 border-b border-surface-200 px-3 py-2" + data-testid="pq-history-bulk-bar"> + + {{ store.historySelectedIds().length }} + {{ 'publishing-queue.selected' | dm }} + +
+ + +
+
+} - - @if (store.historyStatus() === 'loading') { - - - - - - - - - } @else { - - - - - -
- {{ row.bundleId }} - -
- - - {{ row.filterName || row.filterKey || '—' }} - - - - - - {{ (row.createDate | date: 'medium') || '—' }} - - - {{ (row.statusUpdated || row.createDate | date: 'medium') || '—' }} - - - } -
+ +
+ + + + + + + + {{ 'publishing-queue.column.bundle-id' | dm }} + + + {{ 'publishing-queue.column.filter' | dm }} + + + {{ 'publishing-queue.column.status' | dm }} + + + + {{ 'publishing-queue.column.data-entered' | dm }} + + + + {{ 'publishing-queue.column.last-update' | dm }} + + + + - - - -
- + + @if (store.historyStatus() === 'loading') { + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + } @else { + + + + + +
+ {{ row.bundleId }} +
+ + {{ row.filterName || row.filterKey || '—' }} + + + + + + {{ (row.createDate | date: 'medium') || '—' }} + + + {{ (row.statusUpdated || row.createDate | date: 'medium') || '—' }} + -
- -
+ } +
+ + + + +
+ +
+ + +
+
+ diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.spec.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.spec.ts index 7562a4755c08..5de280724cf3 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.spec.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.spec.ts @@ -123,13 +123,15 @@ describe('DotPublishingQueueHistoryComponent', () => { expect(cells[0].querySelector('[data-testid="pq-history-bundle-id-copy"]')).toBeTruthy(); }); - it('shows the bulk action bar only when there is a selection', () => { - expect(spectator.query(byTestId('pq-history-bulk-bar'))).toBeFalsy(); + it('shows the bulk action buttons only when there is a selection', () => { + expect(spectator.query(byTestId('pq-history-bulk-retry'))).toBeFalsy(); + expect(spectator.query(byTestId('pq-history-bulk-remove'))).toBeFalsy(); historySelectedIds.set(['b1']); spectator.detectChanges(); - expect(spectator.query(byTestId('pq-history-bulk-bar'))).toBeTruthy(); + expect(spectator.query(byTestId('pq-history-bulk-retry'))).toBeTruthy(); + expect(spectator.query(byTestId('pq-history-bulk-remove'))).toBeTruthy(); }); it('row click opens the detail dialog', () => { diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.ts index 9d604a50f959..0ade6db33474 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.ts @@ -45,6 +45,20 @@ export class DotPublishingQueueHistoryComponent { readonly first = computed(() => (this.store.historyPage() - 1) * this.store.rowsPerPage()); + /** Pass-through config so the table fills 100% height when empty/loading, + * matching the dot-tags pattern (no rounded card, table flows edge-to-edge). */ + readonly $ptConfig = computed(() => ({ + table: { + style: { + 'table-layout': 'fixed' as const, + ...(this.store.historyRows().length === 0 && { + height: '100%', + width: '100%' + }) + } + } + })); + readonly historyEmpty: PrincipalConfiguration = { icon: 'pi-history', title: this.dotMessageService.get('publishing-queue.empty.history.title'), diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.html b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.html index cef3abf60a9b..e02cac673c71 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.html +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.html @@ -15,11 +15,11 @@ - - + + - + diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.ts index 87df7265a1d9..17eb974d5d5d 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.ts @@ -43,6 +43,11 @@ export class DotPublishingQueueShellComponent { readonly TABS = ['queue', 'history'] as const; + /** Zero-out PrimeNG's default tabpanel padding so portlet content goes flush + * edge-to-edge, matching the dot-tags / dot-query-tool layout. */ + readonly tabPanelsPt = { root: { class: 'flex-1 min-h-0 p-0!' } }; + readonly tabPanelPt = { root: { class: 'h-full p-0! flex flex-col min-h-0' } }; + constructor() { effect(() => { const bundleId = this.store.selectedBundleId(); From a33928b5b43f76aadb6db60f2322e3c562370624 Mon Sep 17 00:00:00 2001 From: hmoreras <31667212+hmoreras@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:30:21 -0600 Subject: [PATCH 07/43] fix(publishing-queue) #36040: align history status labels with legacy JSP Customers shouldn't see different status text in the new Angular portlet vs the legacy Push Publishing JSP. The status chip was resolving against `publishing-queue.status.*` (the alt-compact label set: "Sent", "Bundling", etc.) while the legacy JSP uses `publisher_status_*` ("Success", "Bundle sent", etc.). Switch the chip's labelKey to the JSP-matching `publisher_status_*` pattern and plug the one missing entry: `publisher_status_FAILED_INTEGRITY_CHECK` (JSP itself had no key for this status; new portlet would have rendered the raw i18n key). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dot-publishing-status-chip.component.spec.ts | 10 +++++----- .../dot-publishing-status-chip.component.ts | 2 +- .../main/webapp/WEB-INF/messages/Language.properties | 1 + 3 files changed, 7 insertions(+), 6 deletions(-) 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 8c9b842e994c..684ca752349a 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 @@ -53,10 +53,10 @@ describe('DotPublishingStatusChipComponent', () => { { provide: DotMessageService, useValue: new MockDotMessageService({ - 'publishing-queue.status.SUCCESS': 'Sent', - 'publishing-queue.status.FAILED_TO_PUBLISH': 'Publish failed', - 'publishing-queue.status.BUNDLING': 'Bundling', - 'publishing-queue.status.WAITING_FOR_PUBLISHING': 'Waiting' + 'publisher_status_SUCCESS': 'Success', + 'publisher_status_FAILED_TO_PUBLISH': 'Failed to Publish', + 'publisher_status_BUNDLING': 'Bundling', + 'publisher_status_WAITING_FOR_PUBLISHING': 'Waiting for Publishing' }) } ], @@ -75,7 +75,7 @@ describe('DotPublishingStatusChipComponent', () => { const chip = spectator.query(byTestId('pq-status-chip')); expect(chip?.classList.contains('bg-green-100!')).toBe(true); expect(chip?.classList.contains('text-green-700!')).toBe(true); - expect(chip?.textContent?.trim()).toContain('Sent'); + expect(chip?.textContent?.trim()).toContain('Success'); }); it('renders red classes for danger bucket', () => { 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 5cd0b0b4c801..c8e552396345 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 @@ -63,5 +63,5 @@ export class DotPublishingStatusChipComponent { return s ? publishingStatusBucket(s) : null; }); - readonly labelKey = computed(() => `publishing-queue.status.${this.status()}`); + readonly labelKey = computed(() => `publisher_status_${this.status()}`); } diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index af9d300fb703..222d20ff97ca 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -3750,6 +3750,7 @@ publisher_status_BUNDLE_REQUESTED=Pending publisher_status_BUNDLE_SAVED_SUCCESSFULLY=Bundle file saved successfully publisher_status_BUNDLE_SENT_SUCCESSFULLY=Bundle sent publisher_status_BUNDLING=Bundling +publisher_status_FAILED_INTEGRITY_CHECK=Integrity failed publisher_status_FAILED_TO_BUNDLE=Failed to Bundle publisher_status_FAILED_TO_PUBLISH=Failed to Publish publisher_status_FAILED_TO_SEND_TO_ALL_GROUPS=Failed to send to all environments From 5f86b2d27def568f465f9f642d4d86844548423c Mon Sep 17 00:00:00 2001 From: hmoreras <31667212+hmoreras@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:32:04 -0600 Subject: [PATCH 08/43] feat(publishing-queue) #36040: add Bundle Name column to history table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders the human-readable bundle name as the leftmost data column (between the row checkbox and the bundle id), with an em-dash fallback when the name is null. Column order becomes: ☐ · Bundle Name · Bundle Id · Filter · Status · Data Entered · Last Update. Empty-state colspan and skeleton row updated accordingly. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dot-publishing-queue-history.component.html | 15 ++++++++++++++- ...dot-publishing-queue-history.component.spec.ts | 14 ++++++++++++-- .../webapp/WEB-INF/messages/Language.properties | 1 + 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.html b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.html index d078f0f9430f..f061e3b0f815 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.html +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.html @@ -50,6 +50,9 @@ + + {{ 'publishing-queue.column.bundle-name' | dm }} + {{ 'publishing-queue.column.bundle-id' | dm }} @@ -81,6 +84,11 @@ + + + + + @@ -115,6 +123,11 @@ + + + {{ row.bundleName || '—' }} + +
{{ row.bundleId }} @@ -143,7 +156,7 @@ - +
diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.spec.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.spec.ts index 5de280724cf3..17d7127342c3 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.spec.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.spec.ts @@ -97,14 +97,24 @@ describe('DotPublishingQueueHistoryComponent', () => { expect(spectator.query(byTestId('pq-history-table'))).toBeTruthy(); }); - it('renders all five column headers (Filter, Bundle Id, Status, Data Entered, Last Update)', () => { - expect(spectator.query(byTestId('pq-history-col-filter'))).toBeTruthy(); + it('renders all six column headers (Bundle Name, Bundle Id, Filter, Status, Data Entered, Last Update)', () => { + expect(spectator.query(byTestId('pq-history-col-bundle-name'))).toBeTruthy(); expect(spectator.query(byTestId('pq-history-col-bundle-id'))).toBeTruthy(); + expect(spectator.query(byTestId('pq-history-col-filter'))).toBeTruthy(); expect(spectator.query(byTestId('pq-history-col-status'))).toBeTruthy(); expect(spectator.query(byTestId('pq-history-col-created'))).toBeTruthy(); expect(spectator.query(byTestId('pq-history-col-modified'))).toBeTruthy(); }); + it('renders Bundle Name cell with the row name (falls back to "—" when null)', () => { + historyRows.set([row('b1'), { ...row('b2'), bundleName: null }]); + spectator.detectChanges(); + const cells = spectator.queryAll(byTestId('pq-history-bundle-name')); + expect(cells.length).toBe(2); + expect(cells[0].textContent?.trim()).toBe('Bundle b1'); + expect(cells[1].textContent?.trim()).toBe('—'); + }); + it('renders rows with status chips', () => { const tags = spectator.queryAll(byTestId('pq-history-status')); expect(tags.length).toBe(2); diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 222d20ff97ca..4d287e77abd8 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -3783,6 +3783,7 @@ publishing-queue.column.name=Name publishing-queue.column.type=Type publishing-queue.column.filter=Filter publishing-queue.column.bundle-id=Bundle Id +publishing-queue.column.bundle-name=Bundle Name publishing-queue.column.data-entered=Data Entered publishing-queue.column.last-update=Last Update publishing-queue.copy-bundle-id=Copy Bundle Id From 5e36a822f87546472925fc8d6c4215351d097e0d Mon Sep 17 00:00:00 2001 From: hmoreras <31667212+hmoreras@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:32:52 -0600 Subject: [PATCH 09/43] feat(publishing-queue) #36040: Select Bundles to Delete dialog (4 scopes) + toolbar bulk actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the legacy JSP "Select Bundles to Delete" modal end-to-end. The History tab now has the same fire-and-forget delete flow as the JSP, with all four scopes: SELECTED · ALL · SUCCESS · FAILED Endpoint wiring (all async + WebSocket-notified, matching legacy): - SELECTED → DELETE /api/bundle/ids body { identifiers: [...] } - ALL → DELETE /api/v1/publishing/purge (BE safe defaults) - SUCCESS → DELETE /api/v1/publishing/purge?status=SUCCESS,SUCCESS_WITH_WARNINGS - FAILED → DELETE /api/v1/publishing/purge?status= ALL is gated behind a confirm-dialog ("…cannot be undone") to reproduce the legacy `confirm()` step. The FAILED status list deliberately excludes FAILED_INTEGRITY_CHECK / INVALID_TOKEN / LICENSE_REQUIRED to stay 1:1 with `BundleResource#deleteAllFail` — matching legacy semantics is the priority. Relocates the bulk action UI from a row below the tabs to the top toolbar: - "Retry Send" appears only when the history tab has a selection (with an N-selected count next to it). - "Delete Bundles" is visible whenever the history tab has any rows. - The inline `` for bulk-remove moves to the shell (the dialog is the single overlay owner now). Service changes (`dot-publishing-queue.service.ts`): - `deleteBundles(bundleIds)` now hits legacy `/api/bundle/ids` (the endpoint the JSP uses) instead of a non-existent v1 path. - New `purgeBundles(statuses?)` calls `/api/v1/publishing/purge` with optional comma-joined status filter. Store changes (`dot-publishing-queue.store.ts`): - `deleteBundlesBulk` becomes a single async call (no more per-id forkJoin fan-out); clears selection on success. - New `purgeBundles(statuses?, onDone?)` action. - Exports `PURGE_SUCCESS_STATUSES` and `PURGE_FAILED_STATUSES` constants (the exact lists from legacy `/api/bundle/all/{success,fail}`). Tests: 152 passing — 11 suites including the new delete-dialog spec. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dot-publishing-queue.service.spec.ts | 43 +++++++ .../dot-publishing-queue.service.ts | 40 ++++-- ...ot-publishing-queue-toolbar.component.html | 24 ++++ ...publishing-queue-toolbar.component.spec.ts | 114 ++++++++++++++++-- .../dot-publishing-queue-toolbar.component.ts | 26 +++- ...lishing-queue-delete-dialog.component.html | 34 ++++++ ...hing-queue-delete-dialog.component.spec.ts | 85 +++++++++++++ ...ublishing-queue-delete-dialog.component.ts | 43 +++++++ ...ot-publishing-queue-history.component.html | 30 ----- ...publishing-queue-history.component.spec.ts | 43 +------ .../dot-publishing-queue-history.component.ts | 30 ----- .../store/dot-publishing-queue.store.spec.ts | 34 +++++- .../store/dot-publishing-queue.store.ts | 72 ++++++++--- .../dot-publishing-queue-shell.component.html | 6 +- ...t-publishing-queue-shell.component.spec.ts | 97 ++++++++++++++- .../dot-publishing-queue-shell.component.ts | 70 ++++++++++- .../WEB-INF/messages/Language.properties | 1 + 17 files changed, 646 insertions(+), 146 deletions(-) create mode 100644 core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-delete-dialog/dot-publishing-queue-delete-dialog.component.html create mode 100644 core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-delete-dialog/dot-publishing-queue-delete-dialog.component.spec.ts create mode 100644 core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-delete-dialog/dot-publishing-queue-delete-dialog.component.ts diff --git a/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.spec.ts b/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.spec.ts index 5075bd550a7c..44122f0ffd3d 100644 --- a/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.spec.ts +++ b/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.spec.ts @@ -117,6 +117,49 @@ describe('DotPublishingQueueService', () => { }); }); + describe('deleteBundles', () => { + it('DELETEs /api/bundle/ids with { identifiers } body', () => { + service.deleteBundles(['b1', 'b2', 'b3']).subscribe(); + + const req = httpMock.expectOne((r) => r.url === '/api/bundle/ids'); + expect(req.request.method).toBe('DELETE'); + expect(req.request.body).toEqual({ identifiers: ['b1', 'b2', 'b3'] }); + req.flush({ entity: 'Removing bundles in a separated process' }); + }); + }); + + describe('purgeBundles', () => { + it('DELETEs /api/v1/publishing/purge with no status param when statuses is omitted', () => { + service.purgeBundles().subscribe(); + + const req = httpMock.expectOne((r) => r.url === '/api/v1/publishing/purge'); + expect(req.request.method).toBe('DELETE'); + expect(req.request.params.has('status')).toBe(false); + req.flush({ entity: { message: 'Purge started' } }); + }); + + it('DELETEs /api/v1/publishing/purge with comma-joined status param when statuses are provided', () => { + service + .purgeBundles([ + PublishAuditStatus.SUCCESS, + PublishAuditStatus.SUCCESS_WITH_WARNINGS + ]) + .subscribe(); + + const req = httpMock.expectOne((r) => r.url === '/api/v1/publishing/purge'); + expect(req.request.method).toBe('DELETE'); + expect(req.request.params.get('status')).toBe('SUCCESS,SUCCESS_WITH_WARNINGS'); + req.flush({ entity: { message: 'Purge started' } }); + }); + + it('omits the status param when statuses is an empty array', () => { + service.purgeBundles([]).subscribe(); + const req = httpMock.expectOne((r) => r.url === '/api/v1/publishing/purge'); + expect(req.request.params.has('status')).toBe(false); + req.flush({ entity: { message: 'Purge started' } }); + }); + }); + describe('getUnsendBundles', () => { it('hits /api/bundle/getunsendbundles/userid/{userId} with name + start + count', () => { const mockResponse = { diff --git a/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.ts b/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.ts index f7c548257d62..ac947b547cfb 100644 --- a/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.ts +++ b/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.ts @@ -43,10 +43,13 @@ export interface RetryBundlesPayload { * - `GET /api/v1/publishing` — list bundles by status (sort/filter via params) * - `GET /api/v1/publishing/{bundleId}` — full bundle detail with endpoints * - `POST /api/v1/publishing/retry` — retry bundles (bulk) - * - `DELETE /api/v1/publishing/{bundleId}` — delete single bundle - * - `DELETE /api/v1/publishing` — bulk delete by id (added by #36046) + * - `DELETE /api/v1/publishing/{bundleId}` — delete single bundle (synchronous) + * - `DELETE /api/v1/publishing/purge?status=...` — bulk delete by status (async, + * WebSocket-notified; omit `status` to use safe defaults = ALL terminal/queued) * * Legacy endpoints (`com.dotcms.rest.BundleResource`) still used until #36048 lands: + * - `DELETE /api/bundle/ids` — bulk delete by bundle id (async, WebSocket-notified; + * same endpoint the legacy JSP uses for "SELECTED" in the delete-bundles dialog) * - `GET /api/bundle/{bundleId}/assets` — asset list inside a bundle * - `POST /api/bundle/sync` — synchronous .tar.gz upload (licensed) * - `GET /api/bundle/_download/{bundleId}` — bundle download (URL only) @@ -110,15 +113,32 @@ export class DotPublishingQueueService { } /** - * Bulk delete bundles by id (BE endpoint added in #36046). Falls back to per-id loops - * on the consumer side if the endpoint returns 404. + * Bulk delete bundles by id — fire-and-forget. The BE acks immediately with a + * `ResponseEntityView` message; the actual deletion runs on a background + * thread and notifies the user via WebSocket system message when finished. + * + * Uses the legacy `DELETE /api/bundle/ids` (same endpoint as the JSP) until the + * v1 consolidation work (#36048) ships an equivalent under `/api/v1/publishing`. */ - deleteBundles(bundleIds: string[]): Observable<{ message: string; deleted: string[] }> { - return this.http.request<{ message: string; deleted: string[] }>( - 'DELETE', - '/api/v1/publishing', - { body: { bundleIds } } - ); + deleteBundles(bundleIds: string[]): Observable { + return this.http.request('DELETE', '/api/bundle/ids', { + body: { identifiers: bundleIds } + }); + } + + /** + * Bulk-purges bundles by status — fire-and-forget. Mirrors the legacy + * `/api/bundle/all{,/success,/fail}` endpoints behind a single v1 path. Omit + * `statuses` to use the BE's safe defaults (all terminal + queued; in-progress + * statuses are rejected with 400). BE acks immediately; result is delivered via + * WebSocket system message. + */ + purgeBundles(statuses?: readonly PublishAuditStatus[]): Observable { + const params = + statuses && statuses.length > 0 + ? new HttpParams().set('status', statuses.join(',')) + : new HttpParams(); + return this.http.delete('/api/v1/publishing/purge', { params }); } uploadBundle(file: File): Observable<{ bundleName: string; status: string }> { 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 d7c8726bd69f..896e87901887 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 @@ -14,6 +14,30 @@
+ @if (showRetry()) { + + {{ store.historySelectedIds().length }} + {{ 'publishing-queue.selected' | dm }} + + + } + @if (showDelete()) { + + + } { let spectator: Spectator; - let store: InstanceType; + let store: ReturnType; + + const activeTab = signal<'queue' | 'history'>('queue'); + const historySelectedIds = signal([]); + const historyTotal = signal(0); + + function makeStoreStub() { + return { + search: jest.fn().mockReturnValue(''), + setSearch: jest.fn(), + refresh: jest.fn(), + activeTab, + historySelectedIds, + historyTotal, + retryBundles: jest.fn() + }; + } const createComponent = createComponentFactory({ component: DotPublishingQueueToolbarComponent, - componentProviders: [ - mockProvider(DotPublishingQueueStore, { - search: jest.fn().mockReturnValue(''), - setSearch: jest.fn(), - refresh: jest.fn() - }) - ], + componentProviders: [mockProvider(DotPublishingQueueStore, makeStoreStub())], providers: [ { provide: DotMessageService, useValue: new MockDotMessageService({ 'publishing-queue.search.placeholder': 'Search bundles', 'publishing-queue.refresh': 'Refresh', - 'publishing-queue.upload-bundle': 'Upload Bundle' + 'publishing-queue.upload-bundle': 'Upload Bundle', + 'publishing-queue.retry-send': 'Retry Send', + 'publishing-queue.delete-bundles': 'Delete Bundles', + 'publishing-queue.selected': 'selected' }) } ] @@ -34,8 +49,13 @@ describe('DotPublishingQueueToolbarComponent', () => { beforeEach(() => { jest.useFakeTimers(); + activeTab.set('queue'); + historySelectedIds.set([]); + historyTotal.set(0); spectator = createComponent(); - store = spectator.inject(DotPublishingQueueStore, true); + store = spectator.inject(DotPublishingQueueStore, true) as unknown as ReturnType< + typeof makeStoreStub + >; jest.clearAllMocks(); }); @@ -100,4 +120,78 @@ describe('DotPublishingQueueToolbarComponent', () => { expect(store.refresh).toHaveBeenCalled(); }); }); + + describe('Retry Send (selection-gated)', () => { + it('is hidden on the queue tab even with a selection', () => { + activeTab.set('queue'); + historySelectedIds.set(['b1']); + spectator.detectChanges(); + expect(spectator.query(byTestId('pq-history-bulk-retry'))).toBeFalsy(); + expect(spectator.query(byTestId('pq-bulk-count'))).toBeFalsy(); + }); + + it('is hidden on the history tab when nothing is selected', () => { + activeTab.set('history'); + historySelectedIds.set([]); + historyTotal.set(5); + spectator.detectChanges(); + expect(spectator.query(byTestId('pq-history-bulk-retry'))).toBeFalsy(); + }); + + it('shows the retry button + selected-count on the history tab with selection', () => { + activeTab.set('history'); + historySelectedIds.set(['b1', 'b2']); + historyTotal.set(5); + spectator.detectChanges(); + expect(spectator.query(byTestId('pq-history-bulk-retry'))).toBeTruthy(); + expect(spectator.query(byTestId('pq-bulk-count'))?.textContent).toContain('2'); + }); + + it('clicking retry calls retryBundles with the selected ids', () => { + activeTab.set('history'); + historySelectedIds.set(['b1', 'b2']); + historyTotal.set(5); + spectator.detectChanges(); + const btn = spectator.query(byTestId('pq-history-bulk-retry'))?.querySelector('button'); + spectator.click(btn as HTMLButtonElement); + expect(store.retryBundles).toHaveBeenCalledWith({ bundleIds: ['b1', 'b2'] }); + }); + }); + + describe('Delete Bundles (always visible on history when rows exist)', () => { + it('is hidden on the queue tab', () => { + activeTab.set('queue'); + historyTotal.set(5); + spectator.detectChanges(); + expect(spectator.query(byTestId('pq-history-delete-bundles'))).toBeFalsy(); + }); + + it('is hidden on the history tab when the table is empty', () => { + activeTab.set('history'); + historyTotal.set(0); + spectator.detectChanges(); + expect(spectator.query(byTestId('pq-history-delete-bundles'))).toBeFalsy(); + }); + + it('shows on the history tab when there is at least one row (with no selection)', () => { + activeTab.set('history'); + historyTotal.set(2); + historySelectedIds.set([]); + spectator.detectChanges(); + expect(spectator.query(byTestId('pq-history-delete-bundles'))).toBeTruthy(); + }); + + it('emits deleteClick when clicked', () => { + activeTab.set('history'); + historyTotal.set(2); + spectator.detectChanges(); + const emit = jest.fn(); + spectator.component.deleteClick.subscribe(emit); + const btn = spectator + .query(byTestId('pq-history-delete-bundles')) + ?.querySelector('button'); + spectator.click(btn as HTMLButtonElement); + expect(emit).toHaveBeenCalled(); + }); + }); }); 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 2f3827ba592b..0780e7b98e6d 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 @@ -1,6 +1,13 @@ import { Subject } from 'rxjs'; -import { ChangeDetectionStrategy, Component, DestroyRef, inject, output } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + computed, + inject, + output +} from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormsModule } from '@angular/forms'; @@ -34,10 +41,23 @@ import { DotPublishingQueueStore } from '../../dot-publishing-queue-page/store/d export class DotPublishingQueueToolbarComponent { readonly store = inject(DotPublishingQueueStore); readonly uploadClick = output(); + readonly deleteClick = output(); private readonly destroyRef = inject(DestroyRef); private searchSubject = new Subject(); + /** Retry only makes sense for rows the user has explicitly checked. */ + readonly showRetry = computed( + () => this.store.activeTab() === 'history' && this.store.historySelectedIds().length > 0 + ); + + /** The Delete-Bundles button opens a scope picker (SELECTED / ALL / SUCCESS / FAILED), + * three of which work without any row selection, so the button is visible whenever + * the history tab has any data at all. */ + readonly showDelete = computed( + () => this.store.activeTab() === 'history' && this.store.historyTotal() > 0 + ); + constructor() { this.searchSubject .pipe(debounceTime(300), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) @@ -47,4 +67,8 @@ export class DotPublishingQueueToolbarComponent { onSearch(value: string): void { this.searchSubject.next(value); } + + onBulkRetry(): void { + this.store.retryBundles({ bundleIds: this.store.historySelectedIds() }); + } } diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-delete-dialog/dot-publishing-queue-delete-dialog.component.html b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-delete-dialog/dot-publishing-queue-delete-dialog.component.html new file mode 100644 index 000000000000..9457997889aa --- /dev/null +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-delete-dialog/dot-publishing-queue-delete-dialog.component.html @@ -0,0 +1,34 @@ +
+
+ + + + +
+ + +
diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-delete-dialog/dot-publishing-queue-delete-dialog.component.spec.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-delete-dialog/dot-publishing-queue-delete-dialog.component.spec.ts new file mode 100644 index 000000000000..fd5b4d4f94b9 --- /dev/null +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-delete-dialog/dot-publishing-queue-delete-dialog.component.spec.ts @@ -0,0 +1,85 @@ +import { byTestId, createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; + +import { signal } from '@angular/core'; + +import { DynamicDialogRef } from 'primeng/dynamicdialog'; + +import { DotMessageService } from '@dotcms/data-access'; +import { MockDotMessageService } from '@dotcms/utils-testing'; + +import { DotPublishingQueueDeleteDialogComponent } from './dot-publishing-queue-delete-dialog.component'; + +import { DotPublishingQueueStore } from '../../dot-publishing-queue-page/store/dot-publishing-queue.store'; + +describe('DotPublishingQueueDeleteDialogComponent', () => { + let spectator: Spectator; + let dialogRef: jest.Mocked; + + const historySelectedIds = signal([]); + + const createComponent = createComponentFactory({ + component: DotPublishingQueueDeleteDialogComponent, + componentProviders: [mockProvider(DotPublishingQueueStore, { historySelectedIds })], + providers: [ + mockProvider(DynamicDialogRef, { close: jest.fn() }), + { + provide: DotMessageService, + useValue: new MockDotMessageService({ + 'bundle.delete.selected': 'SELECTED', + 'bundle.delete.all': 'ALL', + 'bundle.delete.success': 'SUCCESS', + 'bundle.delete.failed': 'FAILED', + 'bundle.delete.process.info': + 'Bundles will be deleted in the background. Please refresh to update the progress.' + }) + } + ] + }); + + beforeEach(() => { + historySelectedIds.set([]); + spectator = createComponent(); + dialogRef = spectator.inject(DynamicDialogRef) as jest.Mocked; + }); + + function clickButton(testId: string): void { + const btn = spectator.query(byTestId(testId))?.querySelector('button'); + spectator.click(btn as HTMLButtonElement); + } + + it('renders all four scope buttons + the background-process hint', () => { + expect(spectator.query(byTestId('pq-delete-selected'))).toBeTruthy(); + expect(spectator.query(byTestId('pq-delete-all'))).toBeTruthy(); + expect(spectator.query(byTestId('pq-delete-success'))).toBeTruthy(); + expect(spectator.query(byTestId('pq-delete-failed'))).toBeTruthy(); + expect(spectator.query(byTestId('pq-delete-hint'))?.textContent).toContain( + 'will be deleted in the background' + ); + }); + + it('disables SELECTED when there is no selection', () => { + historySelectedIds.set([]); + spectator.detectChanges(); + const btn = spectator.query(byTestId('pq-delete-selected'))?.querySelector('button'); + expect(btn?.hasAttribute('disabled')).toBe(true); + }); + + it('enables SELECTED when there is a selection', () => { + historySelectedIds.set(['b1']); + spectator.detectChanges(); + const btn = spectator.query(byTestId('pq-delete-selected'))?.querySelector('button'); + expect(btn?.hasAttribute('disabled')).toBe(false); + }); + + it.each([ + ['pq-delete-selected', 'selected'], + ['pq-delete-all', 'all'], + ['pq-delete-success', 'success'], + ['pq-delete-failed', 'failed'] + ])('closes with scope "%s" when %s is clicked', (testId, expected) => { + historySelectedIds.set(['b1']); // enable SELECTED for the parameterised test + spectator.detectChanges(); + clickButton(testId); + expect(dialogRef.close).toHaveBeenCalledWith(expected); + }); +}); diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-delete-dialog/dot-publishing-queue-delete-dialog.component.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-delete-dialog/dot-publishing-queue-delete-dialog.component.ts new file mode 100644 index 000000000000..4a7a777c33f7 --- /dev/null +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-delete-dialog/dot-publishing-queue-delete-dialog.component.ts @@ -0,0 +1,43 @@ +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; + +import { ButtonModule } from 'primeng/button'; +import { DynamicDialogRef } from 'primeng/dynamicdialog'; +import { MessageModule } from 'primeng/message'; + +import { DotMessagePipe } from '@dotcms/ui'; + +import { DotPublishingQueueStore } from '../../dot-publishing-queue-page/store/dot-publishing-queue.store'; + +/** Scope chosen by the user — emitted back via DynamicDialogRef.close(). */ +export type DeleteBundlesScope = 'selected' | 'all' | 'success' | 'failed'; + +/** + * "Select Bundles to Delete" dialog — mirrors the legacy JSP modal + * (`view_publish_tool.jsp#deleteBundleActions`) with the same four scopes: + * + * SELECTED · ALL · SUCCESS · FAILED + * + * The dialog only collects intent. It closes with a `DeleteBundlesScope`; the + * shell dispatches the matching store action. + * + * SELECTED is disabled when there's no row selection (legacy hid the button — + * we keep it visible but disabled so the user understands the option exists). + */ +@Component({ + selector: 'dot-publishing-queue-delete-dialog', + standalone: true, + imports: [ButtonModule, MessageModule, DotMessagePipe], + templateUrl: './dot-publishing-queue-delete-dialog.component.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DotPublishingQueueDeleteDialogComponent { + readonly store = inject(DotPublishingQueueStore); + readonly dialogRef = inject(DynamicDialogRef); + + readonly selectedCount = computed(() => this.store.historySelectedIds().length); + readonly hasSelection = computed(() => this.selectedCount() > 0); + + choose(scope: DeleteBundlesScope): void { + this.dialogRef.close(scope); + } +} diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.html b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.html index f061e3b0f815..d08c7895ac89 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.html +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.html @@ -1,31 +1,3 @@ -@if (hasSelection()) { -
- - {{ store.historySelectedIds().length }} - {{ 'publishing-queue.selected' | dm }} - -
- - -
-
-} -
- - diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.spec.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.spec.ts index 17d7127342c3..757b121f9b56 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.spec.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.spec.ts @@ -2,8 +2,6 @@ import { byTestId, createComponentFactory, mockProvider, Spectator } from '@ngne import { signal } from '@angular/core'; -import { ConfirmationService } from 'primeng/api'; - import { DotMessageService } from '@dotcms/data-access'; import { PublishAuditStatus, PublishingJobView } from '@dotcms/dotcms-models'; import { MockDotMessageService } from '@dotcms/utils-testing'; @@ -32,7 +30,6 @@ const row = ( describe('DotPublishingQueueHistoryComponent', () => { let spectator: Spectator; let store: ReturnType; - let confirmationService: jest.Mocked; const historyRows = signal([]); const historyStatus = signal<'init' | 'loading' | 'loaded' | 'error'>('loaded'); @@ -57,19 +54,14 @@ describe('DotPublishingQueueHistoryComponent', () => { cycleHistorySort: jest.fn(), setHistorySelection: jest.fn((ids: string[]) => historySelectedIds.set(ids)), clearHistorySelection: jest.fn(() => historySelectedIds.set([])), - openDetail: jest.fn(), - retryBundles: jest.fn(), - deleteBundlesBulk: jest.fn() + openDetail: jest.fn() }; } const createComponent = createComponentFactory({ component: DotPublishingQueueHistoryComponent, componentProviders: [mockProvider(DotPublishingQueueStore, makeStoreStub())], - providers: [ - ConfirmationService, - { provide: DotMessageService, useValue: new MockDotMessageService({}) } - ] + providers: [{ provide: DotMessageService, useValue: new MockDotMessageService({}) }] }); beforeEach(() => { @@ -83,13 +75,6 @@ describe('DotPublishingQueueHistoryComponent', () => { store = spectator.inject(DotPublishingQueueStore, true) as unknown as ReturnType< typeof makeStoreStub >; - confirmationService = spectator.inject( - ConfirmationService - ) as jest.Mocked; - jest.spyOn(confirmationService, 'confirm').mockImplementation((cfg) => { - cfg.accept?.(); - return confirmationService; - }); jest.clearAllMocks(); }); @@ -133,35 +118,11 @@ describe('DotPublishingQueueHistoryComponent', () => { expect(cells[0].querySelector('[data-testid="pq-history-bundle-id-copy"]')).toBeTruthy(); }); - it('shows the bulk action buttons only when there is a selection', () => { - expect(spectator.query(byTestId('pq-history-bulk-retry'))).toBeFalsy(); - expect(spectator.query(byTestId('pq-history-bulk-remove'))).toBeFalsy(); - - historySelectedIds.set(['b1']); - spectator.detectChanges(); - - expect(spectator.query(byTestId('pq-history-bulk-retry'))).toBeTruthy(); - expect(spectator.query(byTestId('pq-history-bulk-remove'))).toBeTruthy(); - }); - it('row click opens the detail dialog', () => { spectator.component.onRowClick(row('b1')); expect(store.openDetail).toHaveBeenCalledWith('b1'); }); - it('bulk retry calls retryBundles with the selected ids', () => { - historySelectedIds.set(['b1', 'b2']); - spectator.component.onBulkRetry(); - expect(store.retryBundles).toHaveBeenCalledWith({ bundleIds: ['b1', 'b2'] }); - }); - - it('bulk remove opens confirmation, then calls deleteBundlesBulk on accept', () => { - historySelectedIds.set(['b1', 'b2']); - spectator.component.onBulkRemove(); - expect(confirmationService.confirm).toHaveBeenCalled(); - expect(store.deleteBundlesBulk).toHaveBeenCalledWith(['b1', 'b2']); - }); - it('renders a dot-publishing-status-chip per row', () => { const chips = spectator.queryAll('dot-publishing-status-chip'); expect(chips.length).toBe(2); diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.ts index 0ade6db33474..b8bf6287bd73 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.ts @@ -1,9 +1,7 @@ import { DatePipe } from '@angular/common'; import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; -import { ConfirmationService } from 'primeng/api'; import { ButtonModule } from 'primeng/button'; -import { ConfirmDialogModule } from 'primeng/confirmdialog'; import { SkeletonModule } from 'primeng/skeleton'; import { TableLazyLoadEvent, TableModule } from 'primeng/table'; @@ -25,7 +23,6 @@ import { DotPublishingQueueStore } from '../dot-publishing-queue-page/store/dot- imports: [ DatePipe, ButtonModule, - ConfirmDialogModule, SkeletonModule, TableModule, DotCopyButtonComponent, @@ -33,14 +30,12 @@ import { DotPublishingQueueStore } from '../dot-publishing-queue-page/store/dot- DotMessagePipe, DotPublishingStatusChipComponent ], - providers: [ConfirmationService], templateUrl: './dot-publishing-queue-history.component.html', changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'flex flex-col h-full min-h-0 flex-1' } }) export class DotPublishingQueueHistoryComponent { readonly store = inject(DotPublishingQueueStore); - private readonly confirmationService = inject(ConfirmationService); private readonly dotMessageService = inject(DotMessageService); readonly first = computed(() => (this.store.historyPage() - 1) * this.store.rowsPerPage()); @@ -70,8 +65,6 @@ export class DotPublishingQueueHistoryComponent { return this.store.historyRows().filter((row) => selectedIds.has(row.bundleId)); }); - readonly hasSelection = computed(() => this.store.historySelectedIds().length > 0); - onLazyLoad(event: TableLazyLoadEvent): void { const rows = (event.rows as number) ?? this.store.rowsPerPage(); const first = (event.first as number) ?? 0; @@ -100,27 +93,4 @@ export class DotPublishingQueueHistoryComponent { onRowClick(row: PublishingJobView): void { this.store.openDetail(row.bundleId); } - - onBulkRetry(): void { - this.store.retryBundles({ bundleIds: this.store.historySelectedIds() }); - } - - onBulkRemove(): void { - const count = this.store.historySelectedIds().length; - this.confirmationService.confirm({ - header: this.dotMessageService.get('publishing-queue.history.bulk-remove.header'), - message: this.dotMessageService.get( - 'publishing-queue.history.bulk-remove.message', - `${count}` - ), - acceptLabel: this.dotMessageService.get('publishing-queue.remove'), - rejectLabel: this.dotMessageService.get('publishing-queue.cancel'), - acceptButtonStyleClass: 'p-button-danger', - rejectButtonStyleClass: 'p-button-text', - defaultFocus: 'reject', - closable: true, - closeOnEscape: true, - accept: () => this.store.deleteBundlesBulk(this.store.historySelectedIds()) - }); - } } diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/store/dot-publishing-queue.store.spec.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/store/dot-publishing-queue.store.spec.ts index 9770642d92b1..e57a326ac187 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/store/dot-publishing-queue.store.spec.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/store/dot-publishing-queue.store.spec.ts @@ -99,7 +99,8 @@ describe('DotPublishingQueueStore', () => { .mockReturnValue(of([{ assetId: 'a1', success: true, message: 'ok' }])), retryBundles: jest.fn().mockReturnValue(of([])), deleteBundle: jest.fn().mockReturnValue(of({ message: 'ok' })), - deleteBundles: jest.fn().mockReturnValue(of({ message: 'ok', deleted: [] })), + deleteBundles: jest.fn().mockReturnValue(of({ entity: 'ok' })), + purgeBundles: jest.fn().mockReturnValue(of({ entity: { message: 'ok' } })), uploadBundle: jest .fn() .mockReturnValue(of({ bundleName: 'b', status: 'BUNDLE_REQUESTED' })) @@ -332,7 +333,7 @@ describe('DotPublishingQueueStore', () => { }); }); - describe('retryBundles / deleteBundle / deleteBundlesBulk', () => { + describe('retryBundles / deleteBundle / deleteBundlesBulk / purgeBundles', () => { it('retryBundles calls service and refreshes', () => { const onDone = jest.fn(); store.retryBundles({ bundleIds: ['x'] }, onDone); @@ -345,13 +346,36 @@ describe('DotPublishingQueueStore', () => { expect(service.deleteBundle).toHaveBeenCalledWith('x'); }); - it('deleteBundlesBulk loops per-id (until #36046 lands) and clears selection', () => { + it('deleteBundlesBulk hits the bulk service in one call and clears selection', () => { store.setHistorySelection(['a', 'b']); store.deleteBundlesBulk(['a', 'b']); - expect(service.deleteBundle).toHaveBeenCalledWith('a'); - expect(service.deleteBundle).toHaveBeenCalledWith('b'); + expect(service.deleteBundles).toHaveBeenCalledTimes(1); + expect(service.deleteBundles).toHaveBeenCalledWith(['a', 'b']); expect(store.historySelectedIds()).toEqual([]); }); + + it('deleteBundlesBulk is a no-op when given an empty list', () => { + jest.clearAllMocks(); + const onDone = jest.fn(); + store.deleteBundlesBulk([], onDone); + expect(service.deleteBundles).not.toHaveBeenCalled(); + expect(onDone).toHaveBeenCalled(); + }); + + it('purgeBundles forwards the status list and clears selection', () => { + store.setHistorySelection(['a']); + const statuses = [PublishAuditStatus.SUCCESS, PublishAuditStatus.SUCCESS_WITH_WARNINGS]; + store.purgeBundles(statuses); + expect(service.purgeBundles).toHaveBeenCalledWith(statuses); + expect(store.historySelectedIds()).toEqual([]); + }); + + it('purgeBundles calls service without statuses for the "ALL" scope', () => { + const onDone = jest.fn(); + store.purgeBundles(undefined, onDone); + expect(service.purgeBundles).toHaveBeenCalledWith(undefined); + expect(onDone).toHaveBeenCalled(); + }); }); describe('uploadBundle', () => { diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/store/dot-publishing-queue.store.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/store/dot-publishing-queue.store.ts index a9c15bcaca5e..fbdd4e6f1535 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/store/dot-publishing-queue.store.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/store/dot-publishing-queue.store.ts @@ -1,5 +1,5 @@ import { patchState, signalStore, withHooks, withMethods, withState } from '@ngrx/signals'; -import { EMPTY, Observable, forkJoin, of } from 'rxjs'; +import { EMPTY, Observable, of } from 'rxjs'; import { DestroyRef, effect, inject, untracked } from '@angular/core'; @@ -38,6 +38,24 @@ const HISTORY_STATUSES: readonly PublishAuditStatus[] = [ PublishAuditStatus.LICENSE_REQUIRED ]; +/** Statuses targeted by the dialog's "SUCCESS" scope — matches legacy + * `BundleResource#deleteAllSuccess`. */ +export const PURGE_SUCCESS_STATUSES: readonly PublishAuditStatus[] = [ + PublishAuditStatus.SUCCESS, + PublishAuditStatus.SUCCESS_WITH_WARNINGS +]; + +/** Statuses targeted by the dialog's "FAILED" scope — exact 5 statuses from + * legacy `BundleResource#deleteAllFail` (does NOT include the newer + * FAILED_INTEGRITY_CHECK / INVALID_TOKEN / LICENSE_REQUIRED to match the JSP). */ +export const PURGE_FAILED_STATUSES: readonly PublishAuditStatus[] = [ + PublishAuditStatus.FAILED_TO_SEND_TO_ALL_GROUPS, + PublishAuditStatus.FAILED_TO_SEND_TO_SOME_GROUPS, + PublishAuditStatus.FAILED_TO_BUNDLE, + PublishAuditStatus.FAILED_TO_SENT, + PublishAuditStatus.FAILED_TO_PUBLISH +]; + const POLL_INTERVAL_MS = 15000; export type ActiveTab = 'queue' | 'history'; @@ -534,21 +552,47 @@ export const DotPublishingQueueStore = signalStore( }); }, + /** + * Fire-and-forget bulk delete. The BE acks immediately and runs the + * delete on a background thread (WebSocket-notified on completion), so + * `refresh()` here only reflects the immediate state — the user may need + * to refresh again after the system message arrives. + */ deleteBundlesBulk(bundleIds: string[], onDone?: () => void) { - // Fans out per-id until BE adds the bulk DELETE in #36046. - // Uses forkJoin so we refresh once after every id resolves (success or skip). - forkJoin( - bundleIds.map((id) => - service.deleteBundle(id).pipe( - take(1), - catchError((error) => { - httpErrorManager.handle(error); - return EMPTY; - }) - ) + if (bundleIds.length === 0) { + onDone?.(); + return; + } + service + .deleteBundles(bundleIds) + .pipe( + take(1), + catchError((error) => { + httpErrorManager.handle(error); + return EMPTY; + }) + ) + .subscribe(() => { + patchState(store, { historySelectedIds: [] }); + refresh(); + onDone?.(); + }); + }, + + /** + * Fire-and-forget bulk purge by status. Omit `statuses` to use the BE's + * safe defaults (all terminal/queued — equivalent of the JSP "ALL" button). + */ + purgeBundles(statuses?: readonly PublishAuditStatus[], onDone?: () => void) { + service + .purgeBundles(statuses) + .pipe( + take(1), + catchError((error) => { + httpErrorManager.handle(error); + return EMPTY; + }) ) - ) - .pipe(take(1)) .subscribe(() => { patchState(store, { historySelectedIds: [] }); refresh(); diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.html b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.html index e02cac673c71..f5335d7d43d2 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.html +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.html @@ -1,4 +1,6 @@ - + + + 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 7b745132a680..297c80e23680 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 @@ -3,6 +3,7 @@ import { Subject, of } from 'rxjs'; import { CUSTOM_ELEMENTS_SCHEMA, NO_ERRORS_SCHEMA } from '@angular/core'; +import { ConfirmationService } from 'primeng/api'; import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog'; /* eslint-disable @nx/enforce-module-boundaries */ @@ -23,18 +24,22 @@ import { DotPublishingQueueStore } from '../dot-publishing-queue-page/store/dot- describe('DotPublishingQueueShellComponent', () => { let spectator: Spectator; let dialogService: jest.Mocked; + let confirmationService: jest.Mocked; let store: InstanceType; - const onCloseSubject = new Subject(); + let onCloseSubject = new Subject(); const dialogRef = { close: jest.fn(), - onClose: onCloseSubject + get onClose() { + return onCloseSubject; + } } as unknown as DynamicDialogRef; const createComponent = createComponentFactory({ component: DotPublishingQueueShellComponent, componentProviders: [ DotPublishingQueueStore, + ConfirmationService, mockProvider(DialogService, { open: jest.fn().mockReturnValue(dialogRef) }) ], providers: [ @@ -65,8 +70,14 @@ describe('DotPublishingQueueShellComponent', () => { beforeEach(() => { jest.clearAllMocks(); + onCloseSubject = new Subject(); spectator = createComponent(); dialogService = spectator.inject(DialogService, true) as jest.Mocked; + confirmationService = spectator.inject( + ConfirmationService, + true + ) as jest.Mocked; + jest.spyOn(confirmationService, 'confirm'); store = spectator.inject(DotPublishingQueueStore, true); }); @@ -112,4 +123,86 @@ describe('DotPublishingQueueShellComponent', () => { expect(store.activeTab()).toBe('queue'); }); }); + + describe('delete bundles dialog', () => { + function openAndCloseWith(scope: 'selected' | 'all' | 'success' | 'failed' | undefined) { + spectator.component.openDeleteBundles(); + expect(dialogService.open).toHaveBeenCalled(); + onCloseSubject.next(scope); + } + + it('opens the delete dialog when openDeleteBundles is called', () => { + spectator.component.openDeleteBundles(); + expect(dialogService.open).toHaveBeenCalled(); + }); + + it('does nothing when the dialog closes with no scope (X / ESC / overlay)', () => { + jest.spyOn(store, 'deleteBundlesBulk'); + jest.spyOn(store, 'purgeBundles'); + openAndCloseWith(undefined); + expect(store.deleteBundlesBulk).not.toHaveBeenCalled(); + expect(store.purgeBundles).not.toHaveBeenCalled(); + expect(confirmationService.confirm).not.toHaveBeenCalled(); + }); + + it('SELECTED → store.deleteBundlesBulk with current selected ids', () => { + store.setHistorySelection(['b1', 'b2']); + const spy = jest.spyOn(store, 'deleteBundlesBulk').mockReturnValue(undefined); + openAndCloseWith('selected'); + expect(spy).toHaveBeenCalledWith(['b1', 'b2']); + }); + + it('SUCCESS → store.purgeBundles with the SUCCESS status list', () => { + const spy = jest.spyOn(store, 'purgeBundles').mockReturnValue(undefined); + openAndCloseWith('success'); + expect(spy).toHaveBeenCalled(); + const statuses = spy.mock.calls[0][0] as readonly string[]; + expect(statuses).toEqual(expect.arrayContaining(['SUCCESS', 'SUCCESS_WITH_WARNINGS'])); + }); + + it('FAILED → store.purgeBundles with the legacy 5-status FAILED list', () => { + const spy = jest.spyOn(store, 'purgeBundles').mockReturnValue(undefined); + openAndCloseWith('failed'); + expect(spy).toHaveBeenCalled(); + const statuses = spy.mock.calls[0][0] as readonly string[]; + expect(statuses).toEqual( + expect.arrayContaining([ + 'FAILED_TO_SEND_TO_ALL_GROUPS', + 'FAILED_TO_SEND_TO_SOME_GROUPS', + 'FAILED_TO_BUNDLE', + 'FAILED_TO_SENT', + 'FAILED_TO_PUBLISH' + ]) + ); + // Must NOT include the 3 newer statuses (per legacy /api/bundle/all/fail) + expect(statuses).toEqual( + expect.not.arrayContaining([ + 'FAILED_INTEGRITY_CHECK', + 'INVALID_TOKEN', + 'LICENSE_REQUIRED' + ]) + ); + }); + + it('ALL → confirmation dialog; purgeBundles() with no statuses on accept', () => { + const purgeSpy = jest.spyOn(store, 'purgeBundles').mockReturnValue(undefined); + confirmationService.confirm.mockImplementation((cfg) => { + cfg.accept?.(); + return confirmationService; + }); + openAndCloseWith('all'); + expect(confirmationService.confirm).toHaveBeenCalled(); + expect(purgeSpy).toHaveBeenCalledWith(); + }); + + it('ALL → no purge if the user rejects the confirmation', () => { + const purgeSpy = jest.spyOn(store, 'purgeBundles').mockReturnValue(undefined); + confirmationService.confirm.mockImplementation((cfg) => { + cfg.reject?.(); + return confirmationService; + }); + openAndCloseWith('all'); + expect(purgeSpy).not.toHaveBeenCalled(); + }); + }); }); diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.ts index 17eb974d5d5d..18e88d562c35 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.ts @@ -1,5 +1,7 @@ import { ChangeDetectionStrategy, Component, effect, inject, untracked } from '@angular/core'; +import { ConfirmationService } from 'primeng/api'; +import { ConfirmDialogModule } from 'primeng/confirmdialog'; import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog'; import { TabsModule } from 'primeng/tabs'; @@ -11,22 +13,31 @@ import { DotMessagePipe } from '@dotcms/ui'; import { DotPublishingQueueToolbarComponent } from '../components/dot-publishing-queue-toolbar/dot-publishing-queue-toolbar.component'; import { DotPublishingQueueAssetListDialogComponent } from '../dialogs/dot-publishing-queue-asset-list-dialog/dot-publishing-queue-asset-list-dialog.component'; import { DotPublishingQueueBundleDetailsDialogComponent } from '../dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component'; +import { + DeleteBundlesScope, + DotPublishingQueueDeleteDialogComponent +} from '../dialogs/dot-publishing-queue-delete-dialog/dot-publishing-queue-delete-dialog.component'; import { DotPublishingQueueUploadDialogComponent } from '../dialogs/dot-publishing-queue-upload-dialog/dot-publishing-queue-upload-dialog.component'; import { DotPublishingQueueHistoryComponent } from '../dot-publishing-queue-history/dot-publishing-queue-history.component'; import { DotPublishingQueuePageComponent } from '../dot-publishing-queue-page/dot-publishing-queue-page.component'; -import { DotPublishingQueueStore } from '../dot-publishing-queue-page/store/dot-publishing-queue.store'; +import { + DotPublishingQueueStore, + PURGE_FAILED_STATUSES, + PURGE_SUCCESS_STATUSES +} from '../dot-publishing-queue-page/store/dot-publishing-queue.store'; @Component({ selector: 'dot-publishing-queue-shell', standalone: true, imports: [ + ConfirmDialogModule, TabsModule, DotPublishingQueueToolbarComponent, DotPublishingQueuePageComponent, DotPublishingQueueHistoryComponent, DotMessagePipe ], - providers: [DotPublishingQueueStore, DialogService], + providers: [DotPublishingQueueStore, DialogService, ConfirmationService], templateUrl: './dot-publishing-queue-shell.component.html', changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'flex flex-col h-full min-h-0 block' } @@ -35,11 +46,13 @@ export class DotPublishingQueueShellComponent { readonly store = inject(DotPublishingQueueStore); private readonly dialogService = inject(DialogService); + private readonly confirmationService = inject(ConfirmationService); private readonly dotMessageService = inject(DotMessageService); private detailRef: DynamicDialogRef | null = null; private uploadRef: DynamicDialogRef | null = null; private assetListRef: DynamicDialogRef | null = null; + private deleteRef: DynamicDialogRef | null = null; readonly TABS = ['queue', 'history'] as const; @@ -82,6 +95,59 @@ export class DotPublishingQueueShellComponent { }); } + /** Opens the "Select Bundles to Delete" dialog; on close, dispatches the + * chosen scope to the store. The ALL scope is gated by a destructive-confirm + * step to match the legacy JSP's pre-call `confirm("This cannot be undone")`. */ + openDeleteBundles(): void { + if (this.deleteRef) { + return; + } + this.deleteRef = this.dialogService.open(DotPublishingQueueDeleteDialogComponent, { + header: this.dotMessageService.get('bundle.delete.title'), + width: '500px', + closable: true, + closeOnEscape: true, + draggable: false, + position: 'center' + }); + this.deleteRef.onClose + .pipe(take(1)) + .subscribe((scope: DeleteBundlesScope | undefined) => { + this.deleteRef = null; + if (scope) { + this.dispatchDelete(scope); + } + }); + } + + private dispatchDelete(scope: DeleteBundlesScope): void { + switch (scope) { + case 'selected': + this.store.deleteBundlesBulk(this.store.historySelectedIds()); + break; + case 'all': + this.confirmationService.confirm({ + header: this.dotMessageService.get('bundle.delete.title'), + message: this.dotMessageService.get('bundle.delete.all.confirmation'), + acceptLabel: this.dotMessageService.get('publishing-queue.remove'), + rejectLabel: this.dotMessageService.get('publishing-queue.cancel'), + acceptButtonStyleClass: 'p-button-danger', + rejectButtonStyleClass: 'p-button-text', + defaultFocus: 'reject', + closable: true, + closeOnEscape: true, + accept: () => this.store.purgeBundles() + }); + break; + case 'success': + this.store.purgeBundles(PURGE_SUCCESS_STATUSES); + break; + case 'failed': + this.store.purgeBundles(PURGE_FAILED_STATUSES); + break; + } + } + private syncAssetList(bundleId: string | null): void { if (bundleId && !this.assetListRef) { this.assetListRef = this.dialogService.open( diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 4d287e77abd8..0c8356133660 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -3825,6 +3825,7 @@ publishing-queue.cancel=Cancel publishing-queue.remove=Remove publishing-queue.retry=Retry publishing-queue.retry-send=Retry Send +publishing-queue.delete-bundles=Delete Bundles publishing-queue.selected=selected publishing-queue.row.actions=Row actions publishing-queue.kebab.configure-send=Configure & send From 9658986446c6404cf36d9384198c2f4bf9902d9c Mon Sep 17 00:00:00 2001 From: hmoreras <31667212+hmoreras@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:37:17 -0600 Subject: [PATCH 10/43] refactor(publishing-queue) #36040: gate Delete Bundles button on selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show the Delete Bundles button only when the user has at least one row checked in the History tab, matching the visibility model of the bulk Retry Send button (and the "N selected" indicator). Both bulk-action buttons — plus the count and the separator — now appear and disappear together behind a single `hasBulkActions` predicate. The dialog itself still handles the no-selection case defensively (SELECTED disabled) because it doesn't know how it was opened, but in practice that branch is no longer reachable from the toolbar. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...dot-publishing-queue-toolbar.component.html | 4 +--- ...-publishing-queue-toolbar.component.spec.ts | 18 +++++++++--------- .../dot-publishing-queue-toolbar.component.ts | 14 +++++--------- 3 files changed, 15 insertions(+), 21 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 896e87901887..fb3a688049c7 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 @@ -14,7 +14,7 @@
- @if (showRetry()) { + @if (hasBulkActions()) { {{ store.historySelectedIds().length }} {{ 'publishing-queue.selected' | dm }} @@ -26,8 +26,6 @@ size="small" (onClick)="onBulkRetry()" data-testid="pq-history-bulk-retry" /> - } - @if (showDelete()) { { }); }); - describe('Delete Bundles (always visible on history when rows exist)', () => { - it('is hidden on the queue tab', () => { + describe('Delete Bundles (selection-gated)', () => { + it('is hidden on the queue tab even with a selection', () => { activeTab.set('queue'); - historyTotal.set(5); + historySelectedIds.set(['b1']); spectator.detectChanges(); expect(spectator.query(byTestId('pq-history-delete-bundles'))).toBeFalsy(); }); - it('is hidden on the history tab when the table is empty', () => { + it('is hidden on the history tab when nothing is selected', () => { activeTab.set('history'); - historyTotal.set(0); + historyTotal.set(5); + historySelectedIds.set([]); spectator.detectChanges(); expect(spectator.query(byTestId('pq-history-delete-bundles'))).toBeFalsy(); }); - it('shows on the history tab when there is at least one row (with no selection)', () => { + it('shows on the history tab when there is a selection', () => { activeTab.set('history'); - historyTotal.set(2); - historySelectedIds.set([]); + historySelectedIds.set(['b1']); spectator.detectChanges(); expect(spectator.query(byTestId('pq-history-delete-bundles'))).toBeTruthy(); }); it('emits deleteClick when clicked', () => { activeTab.set('history'); - historyTotal.set(2); + historySelectedIds.set(['b1']); spectator.detectChanges(); const emit = jest.fn(); spectator.component.deleteClick.subscribe(emit); 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 0780e7b98e6d..9f2e55bf4b1e 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 @@ -46,18 +46,14 @@ export class DotPublishingQueueToolbarComponent { private readonly destroyRef = inject(DestroyRef); private searchSubject = new Subject(); - /** Retry only makes sense for rows the user has explicitly checked. */ - readonly showRetry = computed( + /** Bulk actions appear only when the user has explicitly checked one or more rows. + * The Delete-Bundles dialog still offers ALL/SUCCESS/FAILED scopes (which don't + * strictly need a selection), but exposing them only after a selection keeps the + * top bar quiet and matches the rest of the bulk-action UI. */ + readonly hasBulkActions = computed( () => this.store.activeTab() === 'history' && this.store.historySelectedIds().length > 0 ); - /** The Delete-Bundles button opens a scope picker (SELECTED / ALL / SUCCESS / FAILED), - * three of which work without any row selection, so the button is visible whenever - * the history tab has any data at all. */ - readonly showDelete = computed( - () => this.store.activeTab() === 'history' && this.store.historyTotal() > 0 - ); - constructor() { this.searchSubject .pipe(debounceTime(300), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) From b11b749d5ce15add3b1f32d7a2e5b2f30bd3c223 Mon Sep 17 00:00:00 2001 From: hmoreras <31667212+hmoreras@users.noreply.github.com> Date: Thu, 18 Jun 2026 17:11:25 -0600 Subject: [PATCH 11/43] =?UTF-8?q?refactor(publishing-queue)=20#36040:=20bu?= =?UTF-8?q?ndle=20details=20=E2=80=94=20single=20endpoints=20table=20+=20e?= =?UTF-8?q?xtract=20assets=20into=20View=20Contents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bundle details dialog used to render one `` per environment group (with its own column headers), which looked like a duplicated table when a bundle had multiple endpoints. Flatten to a single table that carries the environment name as the leftmost column — uniform grid, no subheader rows. Add a `whitespace-nowrap` on the Status column so long labels like "Failed to send to all environments" stay on one line. Endpoint address is now built via `endpointAddress(endpoint)` which returns `null` when the underlying address is empty — the cell shows "—" instead of the malformed `://:` the JSP renders. Protocol and port are optional and omitted from the URL when blank. Extract the assets section into the existing `DotPublishingQueueAssetListDialogComponent`, reused as a read-only "View Contents" surface. The asset-list dialog gains an `allowRemove` flag read from `DynamicDialogConfig.data` (defaults to true so Queue/Ready callers keep their edit UX). The shell decides per `activeTab()`: Queue → true, History → false. The trash column, button, and skeleton cell are hidden when the flag is false; the empty-state colspan adjusts. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...ing-queue-asset-list-dialog.component.html | 42 ++-- ...-queue-asset-list-dialog.component.spec.ts | 47 ++++ ...shing-queue-asset-list-dialog.component.ts | 8 + ...queue-bundle-details-dialog.component.html | 134 +++------- ...ue-bundle-details-dialog.component.spec.ts | 230 ++++++++---------- ...g-queue-bundle-details-dialog.component.ts | 117 ++++----- .../dot-publishing-queue-shell.component.ts | 7 +- .../WEB-INF/messages/Language.properties | 1 + 8 files changed, 267 insertions(+), 319 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.html b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-asset-list-dialog/dot-publishing-queue-asset-list-dialog.component.html index e4e6e5c2c401..3638efe7996d 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.html +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-asset-list-dialog/dot-publishing-queue-asset-list-dialog.component.html @@ -30,7 +30,9 @@ {{ 'publishing-queue.column.name' | dm }} {{ 'publishing-queue.column.type' | dm }} - + @if (allowRemove) { + + } @@ -39,7 +41,9 @@ - + @if (allowRemove) { + + } } @@ -48,26 +52,30 @@ {{ asset.title }} {{ asset.type }} - - - + @if (allowRemove) { + + + + } - + @if (hasNoMatches()) { {{ 'publishing-queue.detail.assets-no-matches' | dm }} 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.spec.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.spec.ts index 10926d353611..d3174fcfba71 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.spec.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.spec.ts @@ -3,6 +3,7 @@ import { byTestId, createComponentFactory, mockProvider, Spectator } from '@ngne import { signal } from '@angular/core'; import { ConfirmationService } from 'primeng/api'; +import { DynamicDialogConfig } from 'primeng/dynamicdialog'; import { DotMessageService } from '@dotcms/data-access'; import { BundleAssetView } from '@dotcms/dotcms-models'; @@ -206,3 +207,49 @@ describe('DotPublishingQueueAssetListDialogComponent', () => { }); }); }); + +describe('DotPublishingQueueAssetListDialogComponent — read-only (allowRemove=false)', () => { + let spectator: Spectator; + + const selectedAssets = signal(ASSETS); + const assetListStatus = signal<'init' | 'loading' | 'loaded' | 'error'>('loaded'); + const selectedBundleId = signal('B-1'); + + const createComponent = createComponentFactory({ + component: DotPublishingQueueAssetListDialogComponent, + componentProviders: [ + mockProvider(DotPublishingQueueStore, { + selectedAssets, + assetListStatus, + selectedBundleId, + removeBundleAsset: jest.fn() + }) + ], + providers: [ + ConfirmationService, + { provide: DynamicDialogConfig, useValue: { data: { allowRemove: false } } }, + { provide: DotMessageService, useValue: new MockDotMessageService({}) } + ] + }); + + beforeEach(() => { + selectedAssets.set(ASSETS); + assetListStatus.set('loaded'); + selectedBundleId.set('B-1'); + spectator = createComponent(); + }); + + it('reads allowRemove=false from DynamicDialogConfig.data', () => { + expect(spectator.component.allowRemove).toBe(false); + }); + + it('renders only Name + Type columns (no trash action column)', () => { + const headers = spectator.queryAll('th'); + expect(headers.length).toBe(2); + }); + + it('does NOT render any trash buttons in rows', () => { + expect(spectator.query(byTestId('pq-asset-remove-btn'))).toBeFalsy(); + expect(spectator.queryAll(byTestId('pq-asset-list-row')).length).toBe(2); + }); +}); 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 789924d2bbe9..ac1ee5370e37 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 @@ -16,6 +16,7 @@ import { FormsModule } from '@angular/forms'; import { ConfirmationService } from 'primeng/api'; import { ButtonModule } from 'primeng/button'; import { ConfirmDialogModule } from 'primeng/confirmdialog'; +import { DynamicDialogConfig } from 'primeng/dynamicdialog'; import { IconFieldModule } from 'primeng/iconfield'; import { InputIconModule } from 'primeng/inputicon'; import { InputTextModule } from 'primeng/inputtext'; @@ -59,6 +60,13 @@ export class DotPublishingQueueAssetListDialogComponent { private readonly confirmationService = inject(ConfirmationService); private readonly dotMessageService = inject(DotMessageService); private readonly destroyRef = inject(DestroyRef); + /** Optional — present only when opened via DialogService. */ + private readonly dialogConfig = inject(DynamicDialogConfig, { optional: true }); + + /** When opened from the History tab the bundle is already in `publish_audit` + * and assets can no longer be removed — the dialog renders as read-only. + * Default true so existing call sites (Queue/Ready) keep their edit UX. */ + readonly allowRemove = (this.dialogConfig?.data?.allowRemove ?? true) as boolean; /** Skeleton rows for the loading state. Length chosen so the placeholder fills * the reserved 384px (h-96) and the dialog stays stable on load + after deletes. */ diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.html b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.html index 57cf310b7884..22db06ff7561 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.html +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.html @@ -62,121 +62,47 @@

{{ 'publishing-queue.detail.no-endpoints' | dm }}

} @else { - @for (env of detail.environments; track env.id) { -
-
- {{ env.name }} -
- - - - {{ 'publishing-queue.detail.endpoint' | dm }} - {{ 'publishing-queue.detail.address' | dm }} - {{ 'publishing-queue.detail.status' | dm }} - {{ 'publishing-queue.detail.message' | dm }} - - - - - {{ endpoint.serverName }} - - {{ endpoint.protocol }}://{{ endpoint.address }}:{{ - endpoint.port - }} - - - @if (endpoint.status) { - - } @else { - - } - - - {{ endpoint.statusMessage || '—' }} - - - - -
- } - } - - -
-
-

- {{ 'publishing-queue.detail.assets' | dm }} - ({{ detail.assetCount }}) -

- @if (showAssetSearch()) { - - - - - } -
- -
+ [value]="endpointRows()" + dataKey="key" + styleClass="rounded-md border border-surface-200 overflow-hidden" + data-testid="pq-detail-endpoint-table"> - {{ 'publishing-queue.column.name' | dm }} - {{ 'publishing-queue.column.type' | dm }} - - - - @for (_ of assetSkeletonRows; track $index) { - - - - - } - - - - {{ asset.title }} - {{ asset.type }} + {{ 'publishing-queue.detail.environment' | dm }} + {{ 'publishing-queue.detail.endpoint' | dm }} + {{ 'publishing-queue.detail.address' | dm }} + + {{ 'publishing-queue.detail.status' | dm }} + + {{ 'publishing-queue.detail.message' | dm }} - - - - @if (hasNoMatches()) { - - {{ 'publishing-queue.detail.assets-no-matches' | dm }} - + + + {{ row.envName }} + {{ row.endpoint.serverName }} + + @if (endpointAddress(row.endpoint); as address) { + {{ address }} } @else { - - {{ 'publishing-queue.asset-list.empty' | dm }} - + } + + @if (row.endpoint.status) { + + } @else { + + } + + + {{ row.endpoint.statusMessage || '—' }} + -
+ }
@if (canDownload()) { diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.spec.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.spec.ts index 13246735b909..f35e23d160ee 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.spec.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.spec.ts @@ -4,7 +4,7 @@ import { signal } from '@angular/core'; import { DotMessageService, DotPublishingQueueService } from '@dotcms/data-access'; import { - BundleAssetView, + EndpointDetailView, PublishAuditStatus, PublishingJobDetailView } from '@dotcms/dotcms-models'; @@ -58,20 +58,11 @@ describe('DotPublishingQueueBundleDetailsDialogComponent', () => { const detail = signal(null); const detailStatus = signal<'init' | 'loading' | 'loaded' | 'error'>('loading'); - const detailAssets = signal([]); - const detailAssetsStatus = signal<'init' | 'loading' | 'loaded' | 'error'>('loaded'); - const detailBundleId = signal(null); const createComponent = createComponentFactory({ component: DotPublishingQueueBundleDetailsDialogComponent, providers: [ - mockProvider(DotPublishingQueueStore, { - detail, - detailStatus, - detailAssets, - detailAssetsStatus, - detailBundleId - }), + mockProvider(DotPublishingQueueStore, { detail, detailStatus }), mockProvider(DotPublishingQueueService, { getBundleDownloadUrl: jest.fn((id: string) => `/api/bundle/_download/${id}`) }), @@ -82,9 +73,6 @@ describe('DotPublishingQueueBundleDetailsDialogComponent', () => { beforeEach(() => { detail.set(null); detailStatus.set('loading'); - detailAssets.set([]); - detailAssetsStatus.set('loaded'); - detailBundleId.set(null); spectator = createComponent(); }); @@ -121,130 +109,128 @@ describe('DotPublishingQueueBundleDetailsDialogComponent', () => { expect(spectator.query(byTestId('pq-detail-endpoints-empty'))).toBeTruthy(); }); - it('shows error state', () => { - detail.set(null); - detailStatus.set('error'); + it('does NOT render the assets section (moved to View Contents dialog)', () => { + detail.set(detailFixture()); + detailStatus.set('loaded'); spectator.detectChanges(); - expect(spectator.query(byTestId('pq-detail-error'))).toBeTruthy(); + expect(spectator.query(byTestId('pq-detail-assets-shell'))).toBeFalsy(); + expect(spectator.query(byTestId('pq-detail-assets-table'))).toBeFalsy(); }); - describe('assets section', () => { - it('reserves space (shell + table header) even while loading so the dialog does not jump', () => { - detail.set(detailFixture()); - detailStatus.set('loaded'); - detailAssetsStatus.set('loading'); - spectator.detectChanges(); - expect(spectator.query(byTestId('pq-detail-assets-shell'))).toBeTruthy(); - expect(spectator.query(byTestId('pq-detail-assets-table'))).toBeTruthy(); - expect(spectator.queryAll(byTestId('pq-detail-asset-skeleton')).length).toBe(5); - expect(spectator.query(byTestId('pq-detail-asset-row'))).toBeFalsy(); - expect(spectator.query(byTestId('pq-detail-assets-empty'))).toBeFalsy(); - }); - - it('renders the asset rows when items are loaded', () => { - detail.set(detailFixture()); - detailStatus.set('loaded'); - detailAssets.set([ - { asset: 'a1', title: 'Page 1', type: 'contentlet' }, - { asset: 'a2', title: 'Template 1', type: 'template' } - ]); - detailAssetsStatus.set('loaded'); - spectator.detectChanges(); - expect(spectator.query(byTestId('pq-detail-assets-shell'))).toBeTruthy(); - expect(spectator.queryAll(byTestId('pq-detail-asset-row')).length).toBe(2); - expect(spectator.query(byTestId('pq-detail-asset-skeleton'))).toBeFalsy(); - }); + it('renders multiple environments inside a single table with the env name as a column', () => { + detail.set( + detailFixture({ + environments: [ + { + id: 'env-A', + name: 'Prod', + endpoints: [ + { + id: 'ep-A1', + serverName: 'srv-A1', + address: '10.0.0.1', + port: '443', + protocol: 'https', + status: PublishAuditStatus.SUCCESS, + statusMessage: 'ok', + stackTrace: null + } + ] + }, + { + id: 'env-B', + name: 'Staging', + endpoints: [ + { + id: 'ep-B1', + serverName: 'srv-B1', + address: '10.0.0.2', + port: '443', + protocol: 'https', + status: PublishAuditStatus.FAILED_TO_PUBLISH, + statusMessage: 'boom', + stackTrace: null + } + ] + } + ] + }) + ); + detailStatus.set('loaded'); + spectator.detectChanges(); - it('shows the empty placeholder inside the shell when loaded with no assets', () => { - detail.set(detailFixture()); - detailStatus.set('loaded'); - detailAssets.set([]); - detailAssetsStatus.set('loaded'); - spectator.detectChanges(); - expect(spectator.query(byTestId('pq-detail-assets-shell'))).toBeTruthy(); - expect(spectator.query(byTestId('pq-detail-assets-empty'))).toBeTruthy(); - expect(spectator.query(byTestId('pq-detail-asset-row'))).toBeFalsy(); - }); + // One table for everything (not one-per-env), no subheader rows + expect(spectator.queryAll(byTestId('pq-detail-endpoint-table')).length).toBe(1); + expect(spectator.queryAll(byTestId('pq-detail-env-row')).length).toBe(0); + + // Env name appears in the row itself, once per endpoint + const rows = spectator.queryAll(byTestId('pq-detail-endpoint-row')); + expect(rows.length).toBe(2); + expect(rows[0].textContent).toContain('Prod'); + expect(rows[0].textContent).toContain('srv-A1'); + expect(rows[1].textContent).toContain('Staging'); + expect(rows[1].textContent).toContain('srv-B1'); }); - describe('assets search (visible only when assetCount > 10)', () => { - const manyAssets = Array.from({ length: 15 }, (_, i) => ({ - id: `a${i}`, - title: i % 2 === 0 ? `Homepage ${i}` : `Template ${i}`, - type: i % 2 === 0 ? 'contentlet' : 'template' - })); + describe('endpointAddress', () => { + function makeEndpoint(over: Partial = {}): EndpointDetailView { + return { + id: 'x', + serverName: 's', + address: '', + port: '', + protocol: '', + status: null, + statusMessage: null, + stackTrace: null, + ...over + }; + } - it('hides the search input when assetCount <= 10', () => { - detail.set(detailFixture({ assetCount: 4 })); + beforeEach(() => { + detail.set(detailFixture()); detailStatus.set('loaded'); - detailAssets.set([{ asset: 'a1', title: 'Asset 1', type: 'contentlet' }]); - detailAssetsStatus.set('loaded'); spectator.detectChanges(); - expect(spectator.query(byTestId('pq-detail-assets-search'))).toBeFalsy(); }); - it('renders the search input when assetCount > 10', () => { - detail.set(detailFixture({ assetCount: 15 })); - detailStatus.set('loaded'); - detailAssets.set(manyAssets); - detailAssetsStatus.set('loaded'); - spectator.detectChanges(); - expect(spectator.query(byTestId('pq-detail-assets-search'))).toBeTruthy(); + it('returns null when the address is empty so the cell can show "—"', () => { + expect(spectator.component.endpointAddress(makeEndpoint())).toBeNull(); + expect( + spectator.component.endpointAddress( + makeEndpoint({ protocol: 'http', port: '8080' }) + ) + ).toBeNull(); }); - it('filters rows by title or type when search is set', () => { - jest.useFakeTimers(); - try { - detail.set(detailFixture({ assetCount: 15 })); - detailStatus.set('loaded'); - detailAssets.set(manyAssets); - detailAssetsStatus.set('loaded'); - spectator.detectChanges(); - expect(spectator.queryAll(byTestId('pq-detail-asset-row')).length).toBe(15); - - spectator.component.onSearch('template'); - jest.advanceTimersByTime(300); - spectator.detectChanges(); - - // Half the assets have type 'template' (every odd index) — 7 of 15 - const rows = spectator.queryAll(byTestId('pq-detail-asset-row')); - expect(rows.length).toBe(7); - } finally { - jest.useRealTimers(); - } + it('builds the full URL when all parts are present', () => { + expect( + spectator.component.endpointAddress( + makeEndpoint({ protocol: 'https', address: '10.0.0.1', port: '443' }) + ) + ).toBe('https://10.0.0.1:443'); }); - it('shows the "no matches" message when search returns zero but bundle has assets', () => { - detail.set(detailFixture({ assetCount: 15 })); - detailStatus.set('loaded'); - detailAssets.set(manyAssets); - detailAssetsStatus.set('loaded'); - spectator.detectChanges(); - - spectator.component.assetSearch.set('something-that-doesnt-exist'); - spectator.detectChanges(); - - expect(spectator.query(byTestId('pq-detail-assets-no-matches'))).toBeTruthy(); - expect(spectator.query(byTestId('pq-detail-assets-empty'))).toBeFalsy(); + it('omits the protocol prefix when blank, and the :port when blank', () => { + expect( + spectator.component.endpointAddress( + makeEndpoint({ address: '10.0.0.1', port: '443' }) + ) + ).toBe('10.0.0.1:443'); + expect( + spectator.component.endpointAddress( + makeEndpoint({ protocol: 'https', address: '10.0.0.1' }) + ) + ).toBe('https://10.0.0.1'); + expect(spectator.component.endpointAddress(makeEndpoint({ address: '10.0.0.1' }))).toBe( + '10.0.0.1' + ); }); + }); - it('resets search when the dialog is reused for a different bundle', () => { - // Open the dialog on bundle A and let the init effect settle. - detailBundleId.set('A'); - detail.set(detailFixture({ bundleId: 'A', assetCount: 15 })); - detailStatus.set('loaded'); - detailAssetsStatus.set('loaded'); - spectator.detectChanges(); - - // Set the search AFTER the effect has run with 'A'. - spectator.component.assetSearch.set('homepage'); - spectator.detectChanges(); - expect(spectator.component.assetSearch()).toBe('homepage'); - - // Switching to a different bundle id triggers the reset effect. - detailBundleId.set('B'); - spectator.detectChanges(); - expect(spectator.component.assetSearch()).toBe(''); - }); + it('shows error state', () => { + detail.set(null); + detailStatus.set('error'); + spectator.detectChanges(); + expect(spectator.query(byTestId('pq-detail-error'))).toBeTruthy(); }); }); diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.ts index 795b992fdfe0..85ab485f40c3 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.ts @@ -1,38 +1,17 @@ -import { Subject } from 'rxjs'; - import { DatePipe } from '@angular/common'; -import { - ChangeDetectionStrategy, - Component, - DestroyRef, - computed, - effect, - inject, - signal, - untracked -} from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { FormsModule } from '@angular/forms'; +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; import { ButtonModule } from 'primeng/button'; -import { IconFieldModule } from 'primeng/iconfield'; -import { InputIconModule } from 'primeng/inputicon'; -import { InputTextModule } from 'primeng/inputtext'; import { SkeletonModule } from 'primeng/skeleton'; import { TableModule } from 'primeng/table'; -import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; - import { DotPublishingQueueService } from '@dotcms/data-access'; -import { PublishAuditStatus } from '@dotcms/dotcms-models'; +import { EndpointDetailView, PublishAuditStatus } from '@dotcms/dotcms-models'; import { DotMessagePipe } from '@dotcms/ui'; import { DotPublishingStatusChipComponent } from '../../components/dot-publishing-status-chip/dot-publishing-status-chip.component'; import { DotPublishingQueueStore } from '../../dot-publishing-queue-page/store/dot-publishing-queue.store'; -/** Show the search input only when the bundle is big enough that scrolling alone is painful. */ -const ASSET_SEARCH_THRESHOLD = 10; - const SUCCESS_STATUSES = new Set([ PublishAuditStatus.SUCCESS, PublishAuditStatus.BUNDLE_SENT_SUCCESSFULLY, @@ -40,16 +19,20 @@ const SUCCESS_STATUSES = new Set([ PublishAuditStatus.SUCCESS_WITH_WARNINGS ]); +/** Flattened row used by the endpoints table — each endpoint carries its + * environment name as a column, so all groups share one uniform grid. */ +export interface EndpointTableRow { + key: string; + envName: string; + endpoint: EndpointDetailView; +} + @Component({ selector: 'dot-publishing-queue-bundle-details-dialog', standalone: true, imports: [ DatePipe, - FormsModule, ButtonModule, - IconFieldModule, - InputIconModule, - InputTextModule, SkeletonModule, TableModule, DotMessagePipe, @@ -62,61 +45,45 @@ export class DotPublishingQueueBundleDetailsDialogComponent { readonly store = inject(DotPublishingQueueStore); private readonly publishingService = inject(DotPublishingQueueService); - private readonly destroyRef = inject(DestroyRef); - - /** Placeholder rows for the assets table's loading state. Length chosen so - * the skeleton fills the reserved 192px (h-48) and the dialog opens at its - * final size — no jump when the assets endpoint resolves. */ - readonly assetSkeletonRows = Array.from({ length: 5 }); - - readonly assetSearch = signal(''); - private readonly searchSubject = new Subject(); - - /** Search input only shows when bundle has > ASSET_SEARCH_THRESHOLD assets. */ - readonly showAssetSearch = computed( - () => (this.store.detail()?.assetCount ?? 0) > ASSET_SEARCH_THRESHOLD - ); - - /** Client-side filter over title + type. Case-insensitive. */ - readonly filteredAssets = computed(() => { - const query = this.assetSearch().trim().toLowerCase(); - const assets = this.store.detailAssets(); - if (!query) { - return assets; - } - return assets.filter( - (a) => a.title.toLowerCase().includes(query) || a.type.toLowerCase().includes(query) - ); - }); - - /** True when search is active but returns nothing — distinct UX from "bundle is empty". */ - readonly hasNoMatches = computed( - () => - this.assetSearch().trim().length > 0 && - this.store.detailAssetsStatus() === 'loaded' && - this.filteredAssets().length === 0 && - this.store.detailAssets().length > 0 - ); readonly canDownload = computed(() => { const status = this.store.detail()?.status; return status ? SUCCESS_STATUSES.has(status) : false; }); - constructor() { - this.searchSubject - .pipe(debounceTime(300), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) - .subscribe((value) => this.assetSearch.set(value)); - - // Reset the input every time the dialog is reused for a different bundle. - effect(() => { - this.store.detailBundleId(); - untracked(() => this.assetSearch.set('')); - }); - } + /** Flattens environments → one row per endpoint, with the env name carried + * as a column. Single table, no subheader rows. */ + readonly endpointRows = computed(() => { + const detail = this.store.detail(); + if (!detail) { + return []; + } + const rows: EndpointTableRow[] = []; + for (const env of detail.environments) { + for (const endpoint of env.endpoints) { + rows.push({ + key: `${env.id}-${endpoint.id}`, + envName: env.name, + endpoint + }); + } + } + return rows; + }); - onSearch(value: string): void { - this.searchSubject.next(value); + /** Builds the endpoint URL, omitting protocol/port when they're blank. + * Returns null when the address itself is empty — the cell renders "—" + * rather than the malformed `://:` the JSP would show. */ + endpointAddress(endpoint: EndpointDetailView): string | null { + const address = (endpoint.address ?? '').trim(); + if (!address) { + return null; + } + const protocol = (endpoint.protocol ?? '').trim(); + const port = (endpoint.port ?? '').trim(); + const prefix = protocol ? `${protocol}://` : ''; + const suffix = port ? `:${port}` : ''; + return `${prefix}${address}${suffix}`; } downloadHref(bundleId: string): string { diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.ts index 18e88d562c35..13973d6e9415 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.ts @@ -150,6 +150,10 @@ export class DotPublishingQueueShellComponent { private syncAssetList(bundleId: string | null): void { if (bundleId && !this.assetListRef) { + // History bundles are already in `publish_audit` — assets are + // read-only there. Only the Queue tab (drafts/in-progress) allows + // removal. + const allowRemove = this.store.activeTab() === 'queue'; this.assetListRef = this.dialogService.open( DotPublishingQueueAssetListDialogComponent, { @@ -158,7 +162,8 @@ export class DotPublishingQueueShellComponent { closable: true, closeOnEscape: true, draggable: false, - position: 'center' + position: 'center', + data: { allowRemove } } ); this.assetListRef.onClose.pipe(take(1)).subscribe(() => { diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 0c8356133660..9d7fdef420b3 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -3847,6 +3847,7 @@ publishing-queue.detail.assets=Assets publishing-queue.detail.endpoints=Endpoints publishing-queue.detail.endpoint=Endpoint publishing-queue.detail.address=Address +publishing-queue.detail.environment=Environment publishing-queue.detail.message=Message publishing-queue.detail.no-endpoints=This bundle has not been sent to any environment yet. publishing-queue.detail.download=Download From 8f983b5f669a7b956657fe3ebc6f7b0b54eff06b Mon Sep 17 00:00:00 2001 From: hmoreras <31667212+hmoreras@users.noreply.github.com> Date: Thu, 18 Jun 2026 17:12:03 -0600 Subject: [PATCH 12/43] =?UTF-8?q?feat(publishing-queue)=20#36040:=20upload?= =?UTF-8?q?=20bundle=20dialog=20=E2=80=94=20canonical=20drag-and-drop=20pa?= =?UTF-8?q?ttern=20+=20inline=20error?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The upload dialog used PrimeNG's `` and delegated errors to the global toast via `DotHttpErrorManagerService.handle()` from inside the store. That violated the canonical pattern documented in `libs/portlets/CLAUDE.md` ("Store MUST NOT interact with UI") and offered a different UX than the other 3 portlet upload dialogs (`dot-tags-import`, `dot-plugins-upload`, `dot-categories-import`). Refactor to match those canonical sites: - `` with a custom drag-and-drop content template (icon + dropzone copy + file-types hint) - Component owns `selectedFile`, `uploading`, and `errorMessage` signals - Calls `service.uploadBundle(file)` directly; on success → `store.refresh()` + close the dialog; on error → set `errorMessage` and stay open so the user can correct + retry - `extractErrorMessage(HttpErrorResponse)` handles the 4 shapes the dotCMS BE returns: array body, `{ errors: [...] }`, `{ message: ... }`, plain string - Inline `` at the top of the dialog (full-bleed with `-mx-6` + `!rounded-none`) — same look as `dot-tags-import` Store cleanup: removed `uploadBundle`, `uploadInFlight`, and `uploadProgress`. The component now owns all upload state. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...lishing-queue-upload-dialog.component.html | 97 +++++++---- ...hing-queue-upload-dialog.component.spec.ts | 158 +++++++++++++----- ...ublishing-queue-upload-dialog.component.ts | 78 ++++++++- .../store/dot-publishing-queue.store.spec.ts | 24 +-- .../store/dot-publishing-queue.store.ts | 27 +-- .../WEB-INF/messages/Language.properties | 5 +- 6 files changed, 257 insertions(+), 132 deletions(-) diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-upload-dialog/dot-publishing-queue-upload-dialog.component.html b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-upload-dialog/dot-publishing-queue-upload-dialog.component.html index 8383d7ab4cd2..768e6fee7d5e 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-upload-dialog/dot-publishing-queue-upload-dialog.component.html +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-upload-dialog/dot-publishing-queue-upload-dialog.component.html @@ -1,43 +1,80 @@ -
- +
+ @if (errorMessage()) { + + {{ errorMessage() }} + + } - +

{{ 'publishing-queue.upload.hint' | dm }}

- @if (selectedFile(); as file) { -
- {{ file.name }} - - {{ (file.size / 1024 / 1024).toFixed(2) }} MB - -
- } +
+ + + +
+
+ +
- @if (store.uploadInFlight()) { - - } +

+ {{ 'publishing-queue.upload.dropzone.prefix' | dm }} + + {{ 'publishing-queue.upload.choose' | dm }} + + {{ 'publishing-queue.upload.dropzone.suffix' | dm }} +

+ +
+ {{ 'publishing-queue.upload.file-types' | dm }} +
+ + @if (selectedFile(); as file) { + + {{ file.name }} + + } +
+
+
+
-
+
diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-upload-dialog/dot-publishing-queue-upload-dialog.component.spec.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-upload-dialog/dot-publishing-queue-upload-dialog.component.spec.ts index 0bb647cc25a9..66f14153c2fd 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-upload-dialog/dot-publishing-queue-upload-dialog.component.spec.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-upload-dialog/dot-publishing-queue-upload-dialog.component.spec.ts @@ -1,10 +1,11 @@ -import { createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; +import { byTestId, createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; +import { of, throwError } from 'rxjs'; -import { signal } from '@angular/core'; +import { HttpErrorResponse } from '@angular/common/http'; import { DynamicDialogRef } from 'primeng/dynamicdialog'; -import { DotMessageService } from '@dotcms/data-access'; +import { DotMessageService, DotPublishingQueueService } from '@dotcms/data-access'; import { MockDotMessageService } from '@dotcms/utils-testing'; import { DotPublishingQueueUploadDialogComponent } from './dot-publishing-queue-upload-dialog.component'; @@ -14,69 +15,136 @@ import { DotPublishingQueueStore } from '../../dot-publishing-queue-page/store/d describe('DotPublishingQueueUploadDialogComponent', () => { let spectator: Spectator; let dialogRef: jest.Mocked; - let store: ReturnType; - - const uploadInFlight = signal(false); - - function storeFactory() { - return { - uploadInFlight, - uploadBundle: jest.fn((_file: File, cb?: () => void) => cb?.()) - }; - } + let service: jest.Mocked; + let store: jest.Mocked<{ refresh: jest.Mock }>; const createComponent = createComponentFactory({ component: DotPublishingQueueUploadDialogComponent, providers: [ - mockProvider(DotPublishingQueueStore, storeFactory()), + mockProvider(DotPublishingQueueStore, { refresh: jest.fn() }), + mockProvider(DotPublishingQueueService, { + uploadBundle: jest + .fn() + .mockReturnValue(of({ bundleName: 'b.tar.gz', status: 'BUNDLE_REQUESTED' })) + }), mockProvider(DynamicDialogRef, { close: jest.fn() }), { provide: DotMessageService, useValue: new MockDotMessageService({}) } ] }); + function bundleFile(name = 'bundle.tar.gz'): File { + return new File(['x'], name, { type: 'application/gzip' }); + } + beforeEach(() => { - uploadInFlight.set(false); spectator = createComponent(); dialogRef = spectator.inject(DynamicDialogRef) as jest.Mocked; - store = spectator.inject(DotPublishingQueueStore) as unknown as ReturnType< - typeof storeFactory - >; + service = spectator.inject( + DotPublishingQueueService + ) as jest.Mocked; + store = spectator.inject(DotPublishingQueueStore) as unknown as jest.Mocked<{ + refresh: jest.Mock; + }>; jest.clearAllMocks(); }); - it('disables submit until a file is selected', () => { - expect(spectator.component.selectedFile()).toBeNull(); - }); - - it('stores the selected file', () => { - const file = new File(['x'], 'bundle.tar.gz', { type: 'application/gzip' }); - spectator.component.onSelect({ files: [file] } as never); - expect(spectator.component.selectedFile()).toBe(file); - }); - - it('clears file on onClear', () => { - spectator.component.onSelect({ - files: [new File(['x'], 'b.tar.gz')] - } as never); - spectator.component.onClear(); - expect(spectator.component.selectedFile()).toBeNull(); + describe('file selection', () => { + it('accepts a .tar.gz file', () => { + const file = bundleFile('my.tar.gz'); + spectator.component.onFileSelect({ files: [file] } as never); + expect(spectator.component.selectedFile()).toBe(file); + }); + + it('accepts a .tgz file', () => { + const file = bundleFile('legacy.tgz'); + spectator.component.onFileSelect({ files: [file] } as never); + expect(spectator.component.selectedFile()).toBe(file); + }); + + it('rejects files with a non-bundle extension', () => { + const file = new File(['x'], 'image.png', { type: 'image/png' }); + spectator.component.onFileSelect({ files: [file] } as never); + expect(spectator.component.selectedFile()).toBeNull(); + }); + + it('clears the file (and any previous error) on clear', () => { + spectator.component.onFileSelect({ files: [bundleFile()] } as never); + spectator.component['errorMessage'].set('previous error'); + spectator.component.onFileClear(); + expect(spectator.component.selectedFile()).toBeNull(); + expect(spectator.component.errorMessage()).toBeNull(); + }); }); - it('submit calls store.uploadBundle + closes the dialog with uploaded:true', () => { - const file = new File(['x'], 'bundle.tar.gz'); - spectator.component.onSelect({ files: [file] } as never); - spectator.component.onSubmit(); - expect(store.uploadBundle).toHaveBeenCalledWith(file, expect.any(Function)); - expect(dialogRef.close).toHaveBeenCalledWith({ uploaded: true }); + describe('submit', () => { + it('is a no-op when nothing is selected', () => { + spectator.component.onSubmit(); + expect(service.uploadBundle).not.toHaveBeenCalled(); + }); + + it('calls service.uploadBundle, refreshes the store, and closes with uploaded:true', () => { + const file = bundleFile(); + spectator.component.onFileSelect({ files: [file] } as never); + spectator.component.onSubmit(); + expect(service.uploadBundle).toHaveBeenCalledWith(file); + expect(store.refresh).toHaveBeenCalled(); + expect(dialogRef.close).toHaveBeenCalledWith({ uploaded: true }); + expect(spectator.component.uploading()).toBe(false); + }); }); - it('submit is a no-op when no file is selected', () => { - spectator.component.onSubmit(); - expect(store.uploadBundle).not.toHaveBeenCalled(); + describe('error handling (inline, not toast)', () => { + function makeError(body: unknown, status = 400): HttpErrorResponse { + return new HttpErrorResponse({ error: body, status, statusText: 'Bad Request' }); + } + + function submitWithError(error: HttpErrorResponse): void { + (service.uploadBundle as jest.Mock).mockReturnValueOnce(throwError(() => error)); + spectator.component.onFileSelect({ files: [bundleFile()] } as never); + spectator.component.onSubmit(); + } + + it('surfaces `body.message` inside the dialog (does NOT close)', () => { + submitWithError(makeError({ message: 'License required to upload' })); + expect(spectator.component.errorMessage()).toBe('License required to upload'); + expect(dialogRef.close).not.toHaveBeenCalled(); + expect(store.refresh).not.toHaveBeenCalled(); + expect(spectator.component.uploading()).toBe(false); + spectator.detectChanges(); + expect(spectator.query(byTestId('pq-upload-error'))?.textContent).toContain( + 'License required to upload' + ); + }); + + it('surfaces the first entry of `body.errors[]`', () => { + submitWithError(makeError({ errors: [{ message: 'Invalid bundle archive' }] })); + expect(spectator.component.errorMessage()).toBe('Invalid bundle archive'); + }); + + it('surfaces the first entry when the body itself is an array', () => { + submitWithError(makeError([{ error: 'Unauthorized' }], 401)); + expect(spectator.component.errorMessage()).toBe('Unauthorized'); + }); + + it('falls back to a plain-string body', () => { + submitWithError(makeError('Upload failed: disk full', 500)); + expect(spectator.component.errorMessage()).toBe('Upload failed: disk full'); + }); + + it('clears the previous error before retrying', () => { + submitWithError(makeError({ message: 'first error' })); + expect(spectator.component.errorMessage()).toBe('first error'); + (service.uploadBundle as jest.Mock).mockReturnValueOnce( + of({ bundleName: 'b.tar.gz', status: 'BUNDLE_REQUESTED' }) + ); + spectator.component.onSubmit(); + expect(spectator.component.errorMessage()).toBeNull(); + expect(dialogRef.close).toHaveBeenCalledWith({ uploaded: true }); + }); }); - it('cancel closes the dialog', () => { + it('cancel closes the dialog without a result', () => { spectator.component.onCancel(); - expect(dialogRef.close).toHaveBeenCalled(); + expect(dialogRef.close).toHaveBeenCalledWith(); }); }); diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-upload-dialog/dot-publishing-queue-upload-dialog.component.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-upload-dialog/dot-publishing-queue-upload-dialog.component.ts index ba7044e9d118..7a7428169c86 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-upload-dialog/dot-publishing-queue-upload-dialog.component.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-upload-dialog/dot-publishing-queue-upload-dialog.component.ts @@ -1,35 +1,57 @@ +import { EMPTY } from 'rxjs'; + +import { HttpErrorResponse } from '@angular/common/http'; import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; import { ButtonModule } from 'primeng/button'; import { DynamicDialogRef } from 'primeng/dynamicdialog'; import { FileSelectEvent, FileUploadModule } from 'primeng/fileupload'; import { MessageModule } from 'primeng/message'; -import { ProgressBarModule } from 'primeng/progressbar'; +import { catchError, take } from 'rxjs/operators'; + +import { DotPublishingQueueService } from '@dotcms/data-access'; import { DotMessagePipe } from '@dotcms/ui'; import { DotPublishingQueueStore } from '../../dot-publishing-queue-page/store/dot-publishing-queue.store'; +/** `.tar.gz` (most common) and `.tgz` (legacy alias). */ +const BUNDLE_FILE_PATTERN = /\.(tar\.gz|tgz)$/i; + +/** + * Bundle upload dialog. Follows the canonical `dot-tags-import` pattern: + * - drag-and-drop advanced file upload (NOT `mode="basic"`) + * - upload state + error state owned by the component (NOT the store) + * - errors surface inline via `` instead of a global toast — so + * the user can correct the file and retry without closing the modal + * + * Calls the service directly and triggers `store.refresh()` on success. + */ @Component({ selector: 'dot-publishing-queue-upload-dialog', standalone: true, - imports: [ButtonModule, FileUploadModule, MessageModule, ProgressBarModule, DotMessagePipe], + imports: [ButtonModule, FileUploadModule, MessageModule, DotMessagePipe], templateUrl: './dot-publishing-queue-upload-dialog.component.html', changeDetection: ChangeDetectionStrategy.OnPush }) export class DotPublishingQueueUploadDialogComponent { - readonly store = inject(DotPublishingQueueStore); readonly dialogRef = inject(DynamicDialogRef); + private readonly service = inject(DotPublishingQueueService); + private readonly store = inject(DotPublishingQueueStore); readonly selectedFile = signal(null); + readonly uploading = signal(false); + readonly errorMessage = signal(null); - onSelect(event: FileSelectEvent): void { + onFileSelect(event: FileSelectEvent): void { const file = event.files?.[0] ?? null; - this.selectedFile.set(file); + this.selectedFile.set(this.isBundleFile(file) ? file : null); + this.errorMessage.set(null); } - onClear(): void { + onFileClear(): void { this.selectedFile.set(null); + this.errorMessage.set(null); } onSubmit(): void { @@ -37,10 +59,52 @@ export class DotPublishingQueueUploadDialogComponent { if (!file) { return; } - this.store.uploadBundle(file, () => this.dialogRef.close({ uploaded: true })); + + this.uploading.set(true); + this.errorMessage.set(null); + + this.service + .uploadBundle(file) + .pipe( + take(1), + catchError((error: HttpErrorResponse) => { + this.errorMessage.set(this.extractErrorMessage(error)); + this.uploading.set(false); + return EMPTY; + }) + ) + .subscribe(() => { + this.uploading.set(false); + this.store.refresh(); + this.dialogRef.close({ uploaded: true }); + }); } onCancel(): void { this.dialogRef.close(); } + + /** Pulls the most useful message out of a dotCMS error body. Handles the + * three shapes the BE returns: array, `{ errors: [...] }`, `{ message: ... }`, + * or a plain string. Falls back to the HTTP error message. */ + private extractErrorMessage(error: HttpErrorResponse): string { + const body = error.error; + + if (Array.isArray(body) && body.length > 0) { + return body[0]?.message || body[0]?.error || error.message; + } + + if (body?.errors?.length > 0) { + return body.errors[0]?.message || body.errors[0]?.error || error.message; + } + + return body?.message || (typeof body === 'string' ? body : null) || error.message; + } + + private isBundleFile(file: File | null): file is File { + if (!file) { + return false; + } + return BUNDLE_FILE_PATTERN.test(file.name); + } } diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/store/dot-publishing-queue.store.spec.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/store/dot-publishing-queue.store.spec.ts index e57a326ac187..341ee573955a 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/store/dot-publishing-queue.store.spec.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/store/dot-publishing-queue.store.spec.ts @@ -100,10 +100,7 @@ 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' } })), - uploadBundle: jest - .fn() - .mockReturnValue(of({ bundleName: 'b', status: 'BUNDLE_REQUESTED' })) + purgeBundles: jest.fn().mockReturnValue(of({ entity: { message: 'ok' } })) }), mockProvider(DotHttpErrorManagerService), mockProvider(DotCurrentUserService, { @@ -378,17 +375,6 @@ describe('DotPublishingQueueStore', () => { }); }); - describe('uploadBundle', () => { - it('toggles uploadInFlight and refreshes on success', () => { - const onDone = jest.fn(); - const file = new File(['x'], 'b.tar.gz'); - store.uploadBundle(file, onDone); - expect(service.uploadBundle).toHaveBeenCalledWith(file); - expect(store.uploadInFlight()).toBe(false); - expect(onDone).toHaveBeenCalled(); - }); - }); - describe('polling', () => { it('startPolling / stopPolling do not throw', () => { store.stopPolling(); @@ -423,13 +409,5 @@ describe('DotPublishingQueueStore', () => { expect(httpErrorManager.handle).toHaveBeenCalledWith(error); expect(store.detailStatus()).toBe('error'); }); - - it('uploadBundle error → handle + uploadInFlight reset', () => { - const error = new Error('boom'); - (service.uploadBundle as jest.Mock).mockReturnValueOnce(throwError(() => error)); - store.uploadBundle(new File(['x'], 'b.tar.gz')); - expect(httpErrorManager.handle).toHaveBeenCalledWith(error); - expect(store.uploadInFlight()).toBe(false); - }); }); }); diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/store/dot-publishing-queue.store.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/store/dot-publishing-queue.store.ts index fbdd4e6f1535..cad3c02c183e 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/store/dot-publishing-queue.store.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/store/dot-publishing-queue.store.ts @@ -98,9 +98,6 @@ interface DotPublishingQueueState { * Asset List modal's `selectedAssets` so the two modals don't fight over state). */ detailAssets: BundleAssetView[]; detailAssetsStatus: LoadStatus; - - uploadInFlight: boolean; - uploadProgress: number; } const initialState: DotPublishingQueueState = { @@ -137,10 +134,7 @@ const initialState: DotPublishingQueueState = { detail: null, detailStatus: 'init', detailAssets: [], - detailAssetsStatus: 'init', - - uploadInFlight: false, - uploadProgress: 0 + detailAssetsStatus: 'init' }; export const DotPublishingQueueStore = signalStore( @@ -598,25 +592,6 @@ export const DotPublishingQueueStore = signalStore( refresh(); onDone?.(); }); - }, - - uploadBundle(file: File, onDone?: () => void) { - patchState(store, { uploadInFlight: true, uploadProgress: 0 }); - service - .uploadBundle(file) - .pipe( - take(1), - catchError((error) => { - httpErrorManager.handle(error); - patchState(store, { uploadInFlight: false, uploadProgress: 0 }); - return EMPTY; - }) - ) - .subscribe(() => { - patchState(store, { uploadInFlight: false, uploadProgress: 100 }); - refresh(); - onDone?.(); - }); } }; }), diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 9d7fdef420b3..4eb4bba7cf4e 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -3856,8 +3856,11 @@ publishing-queue.detail.search-assets=Search assets publishing-queue.detail.assets-no-matches=No assets match your search. publishing-queue.upload.title=Upload Bundle publishing-queue.upload.hint=Upload a previously generated bundle as a .tar.gz file. -publishing-queue.upload.choose=Choose file +publishing-queue.upload.choose=choose a file publishing-queue.upload.submit=Upload +publishing-queue.upload.dropzone.prefix=Drag and drop a bundle here, or +publishing-queue.upload.dropzone.suffix=to upload. +publishing-queue.upload.file-types=.tar.gz · .tgz PUBLISHING-NOT-LICENSED=Push Publishing is a dotCMS Enterprise Professional and Enterprise Prime only feature. It allows you to:
  • Create, delete, and publish content, Content Types, Pages, and Templates from one dotCMS environment and push them to another
  • Schedule publishing/removal of content through Workflows
  • Batch migrate content to different environments
  • Publish to multiple remote environments simultaneously
  • Publish static content to an AWS S3 content store (with a Platform License)
push-assets-could-not-be-deleted=Pushed assets could not be deleted. push_publish_integrity_cms_roles_conflicts=Roles/User Roles Conflicts From 549b63b0089fc44db0a84ffd1bf4b32555949a1e Mon Sep 17 00:00:00 2001 From: hmoreras <31667212+hmoreras@users.noreply.github.com> Date: Thu, 18 Jun 2026 17:12:46 -0600 Subject: [PATCH 13/43] feat(publishing-queue) #36040: history row kebab + layout polish + delete confirms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-row kebab in the History tab — same pattern as the Queue list's `readyKebabFor`. Three items: View details · View Contents · separator · Delete. Items intentionally text-only (no icons) per design feedback. - View details → `store.openDetail(bundleId)` (current Bundle Details dialog) - View Contents → `store.openAssetList(bundleId)` (the same `AssetListDialog` the Queue tab uses, but opened read-only via the `allowRemove=false` flag introduced in the previous commit) - Delete → per-row confirm + `store.deleteBundle(bundleId)` The kebab button uses `` (auto-rounded `p-button-icon-only p-button-rounded` look) and is wrapped in a hover-only `opacity-0 group-hover:opacity-100 focus-within:opacity-100` div so it stays out of sight until the user mouses over the row. The row click handler still opens the Details dialog so the dialog and the kebab's "View details" both behave identically. Critical fix: `kebabFor(row)` returns a memoized `MenuItem[]` reference (map keyed by `bundleId`) — `` thrashes when it receives a brand-new array on every CD cycle, causing the well-known "first click only closes the menu" bug. Mirrors the fix already in `dot-publishing-queue-list`. Layout polish: - Explicit `` widths per column + `table-layout: fixed`. - Switched the table to `width: auto` (via `$ptConfig`) so leftover container space stops being distributed across the fixed columns — that was leaving big gaps after Bundle Id / Status while squeezing Filter to ellipsis. - `whitespace-nowrap` on Status (chip doesn't wrap "Failed to send to all environments" anymore) and on the date columns. Bundle Id cell: - Removed `font-mono`, capped to 32 chars in TS (`truncateBundleId`) with the full id exposed via `title=`. Standard 26-char ULIDs are unchanged; longer ids (custom imports) get "…" suffix. - Replaced `` with the inline pattern from `dot-es-search-copy-identifier`: `` + `DotClipboardUtil` + `DotGlobalMessageService`. The button sits next to the text (via `inline-flex`) and only appears on row hover. Click is `stopPropagation`'d so it doesn't fall through to the row's `openDetail` handler. Delete confirms (both per-row in history and the ALL scope in the shell): - New i18n keys `publishing-queue.delete.confirm.header=Delete` + `publishing-queue.delete.confirm.message=Are you sure you want to delete "{0}"? This action cannot be undone.` - Header is "Delete"; accept label is "Delete" (reusing the kebab key). - Styling: `acceptButtonStyleClass: 'p-button-primary'` (NOT danger/red), `rejectButtonStyleClass: 'p-button-text'` (tertiary). Default focus stays on reject as a safety measure. - Toolbar trigger relabeled "Delete Bundles" → "Delete" via the i18n value of `publishing-queue.delete-bundles`. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...ot-publishing-queue-history.component.html | 97 +++++++++++--- ...publishing-queue-history.component.spec.ts | 123 +++++++++++++++++- .../dot-publishing-queue-history.component.ts | 118 +++++++++++++++-- ...t-publishing-queue-shell.component.spec.ts | 2 + .../dot-publishing-queue-shell.component.ts | 8 +- .../WEB-INF/messages/Language.properties | 9 +- 6 files changed, 320 insertions(+), 37 deletions(-) diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.html b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.html index d08c7895ac89..4311d1224105 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.html +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.html @@ -22,27 +22,40 @@ - + {{ 'publishing-queue.column.bundle-name' | dm }} - + {{ 'publishing-queue.column.bundle-id' | dm }} - + {{ 'publishing-queue.column.filter' | dm }} - + {{ 'publishing-queue.column.status' | dm }} - + {{ 'publishing-queue.column.data-entered' | dm }} - + {{ 'publishing-queue.column.last-update' | dm }} + @@ -86,6 +99,11 @@ + + + + + } @else { - +
{{ row.bundleName || '—' }} - +
-
- {{ row.bundleId }} - +
+ + {{ truncateBundleId(row.bundleId) }} + +
- {{ row.filterName || row.filterKey || '—' }} +
+ {{ row.filterName || row.filterKey || '—' }} +
- + - + {{ (row.createDate | date: 'medium') || '—' }} - + {{ (row.statusUpdated || row.createDate | date: 'medium') || '—' }} + + +
+ +
+ } - +
@@ -141,3 +198,5 @@
+ + diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.spec.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.spec.ts index 757b121f9b56..10f3dbeb8f16 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.spec.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.spec.ts @@ -2,8 +2,11 @@ import { byTestId, createComponentFactory, mockProvider, Spectator } from '@ngne import { signal } from '@angular/core'; -import { DotMessageService } from '@dotcms/data-access'; +import { ConfirmationService } from 'primeng/api'; + +import { DotGlobalMessageService, DotMessageService } from '@dotcms/data-access'; import { PublishAuditStatus, PublishingJobView } from '@dotcms/dotcms-models'; +import { DotClipboardUtil } from '@dotcms/ui'; import { MockDotMessageService } from '@dotcms/utils-testing'; import { DotPublishingQueueHistoryComponent } from './dot-publishing-queue-history.component'; @@ -54,14 +57,30 @@ describe('DotPublishingQueueHistoryComponent', () => { cycleHistorySort: jest.fn(), setHistorySelection: jest.fn((ids: string[]) => historySelectedIds.set(ids)), clearHistorySelection: jest.fn(() => historySelectedIds.set([])), - openDetail: jest.fn() + openDetail: jest.fn(), + openAssetList: jest.fn(), + deleteBundle: jest.fn() }; } + let confirmationService: jest.Mocked; + + let clipboard: jest.Mocked; + let globalMessage: jest.Mocked; + const createComponent = createComponentFactory({ component: DotPublishingQueueHistoryComponent, - componentProviders: [mockProvider(DotPublishingQueueStore, makeStoreStub())], - providers: [{ provide: DotMessageService, useValue: new MockDotMessageService({}) }] + componentProviders: [ + mockProvider(DotPublishingQueueStore, makeStoreStub()), + ConfirmationService, + mockProvider(DotClipboardUtil, { + copy: jest.fn().mockResolvedValue(true) + }) + ], + providers: [ + { provide: DotMessageService, useValue: new MockDotMessageService({}) }, + mockProvider(DotGlobalMessageService, { error: jest.fn() }) + ] }); beforeEach(() => { @@ -75,6 +94,18 @@ describe('DotPublishingQueueHistoryComponent', () => { store = spectator.inject(DotPublishingQueueStore, true) as unknown as ReturnType< typeof makeStoreStub >; + confirmationService = spectator.inject( + ConfirmationService, + true + ) as jest.Mocked; + clipboard = spectator.inject(DotClipboardUtil, true) as jest.Mocked; + globalMessage = spectator.inject( + DotGlobalMessageService + ) as jest.Mocked; + jest.spyOn(confirmationService, 'confirm').mockImplementation((cfg) => { + cfg.accept?.(); + return confirmationService; + }); jest.clearAllMocks(); }); @@ -127,4 +158,88 @@ describe('DotPublishingQueueHistoryComponent', () => { const chips = spectator.queryAll('dot-publishing-status-chip'); expect(chips.length).toBe(2); }); + + describe('truncateBundleId', () => { + it('returns the id unchanged when shorter than or equal to 32 chars', () => { + // standard 26-char ULID + expect(spectator.component.truncateBundleId('01KVBQPPFQCVG6C9VP4D0V47M0')).toBe( + '01KVBQPPFQCVG6C9VP4D0V47M0' + ); + // exactly 32 chars + expect(spectator.component.truncateBundleId('a'.repeat(32))).toBe('a'.repeat(32)); + }); + + it('truncates to the first 32 chars + "…" when longer', () => { + const long = 'Bulk-product-bundle-01KV6E1E62SNRW4EWWKFW29S9B'; // 47 chars + const truncated = spectator.component.truncateBundleId(long); + expect(truncated).toBe(`${long.slice(0, 32)}…`); + expect(truncated.length).toBe(33); // 32 chars + 1 ellipsis + }); + + it('handles empty / nullish input safely', () => { + expect(spectator.component.truncateBundleId('')).toBe(''); + }); + }); + + describe('copyToClipboard', () => { + it('delegates to DotClipboardUtil.copy', async () => { + (clipboard.copy as jest.Mock).mockResolvedValue(true); + await spectator.component.copyToClipboard('bundle-xyz'); + expect(clipboard.copy).toHaveBeenCalledWith('bundle-xyz'); + expect(globalMessage.error).not.toHaveBeenCalled(); + }); + + it('surfaces a global error toast when copy fails', async () => { + (clipboard.copy as jest.Mock).mockResolvedValue(false); + await spectator.component.copyToClipboard('bundle-xyz'); + expect(globalMessage.error).toHaveBeenCalled(); + }); + }); + + describe('row kebab menu', () => { + it('renders a kebab button per row', () => { + expect(spectator.queryAll(byTestId('pq-history-kebab-btn')).length).toBe(2); + }); + + // Regression: `` thrashes when it receives a new + // array reference on every CD — the first click only closes the menu + // and the user has to click twice. `kebabFor` must memoize. + it('kebabFor returns the SAME array reference across change-detection cycles', () => { + const r = historyRows()[0]; + const a = spectator.component.kebabFor(r); + spectator.detectChanges(); + const b = spectator.component.kebabFor(r); + expect(b).toBe(a); + }); + + it('builds 4 items: View details · View Contents · separator · Delete (no icons)', () => { + const items = spectator.component.historyKebabFor(row('b1')); + expect(items.length).toBe(4); + // No icon on any item — design preference to keep the menu text-only + expect(items[0].icon).toBeUndefined(); + expect(items[1].icon).toBeUndefined(); + expect(items[2].separator).toBe(true); + expect(items[3].icon).toBeUndefined(); + expect(items[3].styleClass).toBe('p-menuitem-danger'); + }); + + it('View details → store.openDetail', () => { + const items = spectator.component.historyKebabFor(row('b1')); + items[0].command?.({} as never); + expect(store.openDetail).toHaveBeenCalledWith('b1'); + }); + + it('View Contents → store.openAssetList', () => { + const items = spectator.component.historyKebabFor(row('b1')); + items[1].command?.({} as never); + expect(store.openAssetList).toHaveBeenCalledWith('b1'); + }); + + it('Delete → confirmation, then store.deleteBundle on accept', () => { + const items = spectator.component.historyKebabFor(row('b1')); + items[3].command?.({} as never); + expect(confirmationService.confirm).toHaveBeenCalled(); + expect(store.deleteBundle).toHaveBeenCalledWith('b1'); + }); + }); }); diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.ts index b8bf6287bd73..a2955c02024a 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.ts @@ -1,14 +1,22 @@ import { DatePipe } from '@angular/common'; import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { ConfirmationService, MenuItem } from 'primeng/api'; import { ButtonModule } from 'primeng/button'; +import { ConfirmDialogModule } from 'primeng/confirmdialog'; +import { MenuModule } from 'primeng/menu'; import { SkeletonModule } from 'primeng/skeleton'; import { TableLazyLoadEvent, TableModule } from 'primeng/table'; +import { TooltipModule } from 'primeng/tooltip'; -import { DotMessageService, PublishingSortField } from '@dotcms/data-access'; +import { + DotGlobalMessageService, + DotMessageService, + PublishingSortField +} from '@dotcms/data-access'; import { PublishingJobView } from '@dotcms/dotcms-models'; import { - DotCopyButtonComponent, + DotClipboardUtil, DotEmptyContainerComponent, DotMessagePipe, PrincipalConfiguration @@ -17,19 +25,27 @@ import { import { DotPublishingStatusChipComponent } from '../components/dot-publishing-status-chip/dot-publishing-status-chip.component'; import { DotPublishingQueueStore } from '../dot-publishing-queue-page/store/dot-publishing-queue.store'; +/** Standard dotCMS bundle ids are 26-char ULIDs. Some are longer (custom + * import/sync paths). Cap the visible length so the column doesn't stretch; + * the full id stays accessible via the `title` tooltip + the copy button. */ +const BUNDLE_ID_DISPLAY_MAX = 32; + @Component({ selector: 'dot-publishing-queue-history', standalone: true, imports: [ DatePipe, ButtonModule, + ConfirmDialogModule, + MenuModule, SkeletonModule, TableModule, - DotCopyButtonComponent, + TooltipModule, DotEmptyContainerComponent, DotMessagePipe, DotPublishingStatusChipComponent ], + providers: [ConfirmationService, DotClipboardUtil], templateUrl: './dot-publishing-queue-history.component.html', changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'flex flex-col h-full min-h-0 flex-1' } @@ -37,19 +53,28 @@ import { DotPublishingQueueStore } from '../dot-publishing-queue-page/store/dot- export class DotPublishingQueueHistoryComponent { readonly store = inject(DotPublishingQueueStore); private readonly dotMessageService = inject(DotMessageService); + private readonly confirmationService = inject(ConfirmationService); + private readonly clipboard = inject(DotClipboardUtil); + private readonly globalMessage = inject(DotGlobalMessageService); readonly first = computed(() => (this.store.historyPage() - 1) * this.store.rowsPerPage()); - /** Pass-through config so the table fills 100% height when empty/loading, - * matching the dot-tags pattern (no rounded card, table flows edge-to-edge). */ + /** Pass-through config: + * - `table-layout: fixed` so each `` is honored exactly. + * - When there are rows: `width: auto` so the table sits at the natural sum + * of column widths. Without this, PrimeNG's default `width: 100%` makes + * the browser distribute leftover container space across the fixed + * columns — that's what was leaving empty space on the right of Bundle Id + * and Status while squeezing Filter. + * - When empty/loading: fill the container so the empty placeholder/skeleton + * takes the full area. */ readonly $ptConfig = computed(() => ({ table: { style: { 'table-layout': 'fixed' as const, - ...(this.store.historyRows().length === 0 && { - height: '100%', - width: '100%' - }) + ...(this.store.historyRows().length === 0 + ? { height: '100%', width: '100%' } + : { width: 'auto' }) } } })); @@ -65,6 +90,43 @@ export class DotPublishingQueueHistoryComponent { return this.store.historyRows().filter((row) => selectedIds.has(row.bundleId)); }); + /** Per-row builder. Kept as a pure arrow so the spec can call it directly + * to verify the items' shape. The template never calls this — it calls + * `kebabFor(row)` which returns a memoized reference (see below). */ + readonly historyKebabFor = (row: PublishingJobView): MenuItem[] => [ + { + label: this.dotMessageService.get('publishing-queue.history.kebab.view-details'), + command: () => this.store.openDetail(row.bundleId) + }, + { + label: this.dotMessageService.get('publishing-queue.history.kebab.view-contents'), + command: () => this.store.openAssetList(row.bundleId) + }, + { separator: true }, + { + label: this.dotMessageService.get('publishing-queue.history.kebab.delete'), + styleClass: 'p-menuitem-danger', + command: () => this.confirmRemove(row) + } + ]; + + /** Memoizes the kebab items per row so `` keeps the same + * array reference across CD cycles. Without this, PrimeNG re-processes the + * items on every CD and the menu thrashes — the first click only closes the + * menu instead of firing the item's `command`, forcing the user to click + * twice. Mirrors the fix already applied in `dot-publishing-queue-list`. */ + private readonly kebabMenus = computed(() => { + const map = new Map(); + for (const row of this.store.historyRows()) { + map.set(row.bundleId, this.historyKebabFor(row)); + } + return map; + }); + + kebabFor(row: PublishingJobView): MenuItem[] { + return this.kebabMenus().get(row.bundleId) ?? []; + } + onLazyLoad(event: TableLazyLoadEvent): void { const rows = (event.rows as number) ?? this.store.rowsPerPage(); const first = (event.first as number) ?? 0; @@ -93,4 +155,42 @@ export class DotPublishingQueueHistoryComponent { onRowClick(row: PublishingJobView): void { this.store.openDetail(row.bundleId); } + + /** Inline copy-to-clipboard for the Bundle Id column — same approach as + * `dot-es-search-page` (an `` + `DotClipboardUtil` + global error + * toast). Avoids the heavier `` wrapper which doesn't fit + * the row hover-only + compact icon-only style we want here. */ + async copyToClipboard(value: string): Promise { + const ok = await this.clipboard.copy(value); + if (!ok) { + this.globalMessage.error(); + } + } + + truncateBundleId(bundleId: string): string { + if (!bundleId || bundleId.length <= BUNDLE_ID_DISPLAY_MAX) { + return bundleId; + } + return `${bundleId.slice(0, BUNDLE_ID_DISPLAY_MAX)}…`; + } + + private confirmRemove(row: PublishingJobView): void { + this.confirmationService.confirm({ + header: this.dotMessageService.get('publishing-queue.delete.confirm.header'), + message: this.dotMessageService.get( + 'publishing-queue.delete.confirm.message', + row.bundleName || row.bundleId + ), + acceptLabel: this.dotMessageService.get('publishing-queue.history.kebab.delete'), + rejectLabel: this.dotMessageService.get('publishing-queue.cancel'), + // Delete = primary, Cancel = tertiary (text). Destructive intent + // is communicated by the message text, not by color. + acceptButtonStyleClass: 'p-button-primary', + rejectButtonStyleClass: 'p-button-text', + defaultFocus: 'reject', + closable: true, + closeOnEscape: true, + accept: () => this.store.deleteBundle(row.bundleId) + }); + } } 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 297c80e23680..1fb640ed799b 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 @@ -10,6 +10,7 @@ import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog'; import { DotCurrentUserService, + DotGlobalMessageService, DotHttpErrorManagerService, DotMessageService, DotPublishingQueueService @@ -63,6 +64,7 @@ describe('DotPublishingQueueShellComponent', () => { }), mockProvider(DotHttpErrorManagerService), mockProvider(DotDownloadBundleDialogService, { open: jest.fn() }), + mockProvider(DotGlobalMessageService, { error: jest.fn() }), { provide: DotMessageService, useValue: new MockDotMessageService({}) } ], schemas: [CUSTOM_ELEMENTS_SCHEMA, NO_ERRORS_SCHEMA] diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.ts index 13973d6e9415..ba9a01879ca6 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.ts @@ -127,11 +127,13 @@ export class DotPublishingQueueShellComponent { break; case 'all': this.confirmationService.confirm({ - header: this.dotMessageService.get('bundle.delete.title'), + header: this.dotMessageService.get('publishing-queue.delete.confirm.header'), message: this.dotMessageService.get('bundle.delete.all.confirmation'), - acceptLabel: this.dotMessageService.get('publishing-queue.remove'), + acceptLabel: this.dotMessageService.get('publishing-queue.history.kebab.delete'), rejectLabel: this.dotMessageService.get('publishing-queue.cancel'), - acceptButtonStyleClass: 'p-button-danger', + // Delete = primary, Cancel = tertiary (text) — consistent + // with the per-row delete confirm in history. + acceptButtonStyleClass: 'p-button-primary', rejectButtonStyleClass: 'p-button-text', defaultFocus: 'reject', closable: true, diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 4eb4bba7cf4e..b14835293c24 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -3825,14 +3825,19 @@ publishing-queue.cancel=Cancel publishing-queue.remove=Remove publishing-queue.retry=Retry publishing-queue.retry-send=Retry Send -publishing-queue.delete-bundles=Delete Bundles +publishing-queue.delete-bundles=Delete publishing-queue.selected=selected publishing-queue.row.actions=Row actions publishing-queue.kebab.configure-send=Configure & send publishing-queue.kebab.generate-download=Generate / download publishing-queue.kebab.remove=Remove from queue +publishing-queue.history.kebab.view-details=View details +publishing-queue.history.kebab.view-contents=View Contents +publishing-queue.history.kebab.delete=Delete publishing-queue.confirm-remove.header=Remove bundle? publishing-queue.confirm-remove.message=Are you sure you want to remove "{0}"? This action cannot be undone. +publishing-queue.delete.confirm.header=Delete +publishing-queue.delete.confirm.message=Are you sure you want to delete "{0}"? This action cannot be undone. publishing-queue.history.bulk-remove.header=Remove {0} bundles? publishing-queue.history.bulk-remove.message=This will permanently remove {0} bundles from the history. This action cannot be undone. publishing-queue.detail.title=Bundle details @@ -3845,9 +3850,9 @@ publishing-queue.detail.publish-end=Publish end publishing-queue.detail.filter=Filter publishing-queue.detail.assets=Assets publishing-queue.detail.endpoints=Endpoints +publishing-queue.detail.environment=Environment publishing-queue.detail.endpoint=Endpoint publishing-queue.detail.address=Address -publishing-queue.detail.environment=Environment publishing-queue.detail.message=Message publishing-queue.detail.no-endpoints=This bundle has not been sent to any environment yet. publishing-queue.detail.download=Download From a2efc00bf7388d820b19d3756712074592d8ecc8 Mon Sep 17 00:00:00 2001 From: hmoreras <31667212+hmoreras@users.noreply.github.com> Date: Thu, 18 Jun 2026 17:14:22 -0600 Subject: [PATCH 14/43] =?UTF-8?q?style(publishing-queue)=20#36040:=20histo?= =?UTF-8?q?ry=20table=20=E2=80=94=20drop=20per-cell=20font=20size/weight?= =?UTF-8?q?=20overrides?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match the site-standard table font (per dot-tags-list) by letting the default `p-datatable` typography apply uniformly across cells: - Bundle Name: drop `text-sm font-medium text-color` from the cell wrapper (kept the `truncate`). - Bundle Id: drop `text-xs` from the id span — uses the row default like every other column. - Date columns: drop `text-xs` from the wrapper class (kept `text-color-secondary whitespace-nowrap`). Status chip keeps its own `text-xs` internally (chip convention, owned by the chip component, not the table cell). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dot-publishing-queue-history.component.html | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.html b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.html index 4311d1224105..2df28c73606e 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.html +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.html @@ -114,7 +114,7 @@ -
+
{{ row.bundleName || '—' }}
@@ -124,7 +124,7 @@ copy button right next to the text instead of floating at the far edge of the column. -->
- + {{ truncateBundleId(row.bundleId) }} {{ (row.createDate | date: 'medium') || '—' }} {{ (row.statusUpdated || row.createDate | date: 'medium') || '—' }} From 874232bd3032069852ff2b7be23443b2dad4f387 Mon Sep 17 00:00:00 2001 From: hmoreras <31667212+hmoreras@users.noreply.github.com> Date: Thu, 18 Jun 2026 17:58:03 -0600 Subject: [PATCH 15/43] style(publishing-queue) #36040: smaller font for Bundle Id + dates; mt-1 on Contents search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit History table: - Bundle Id span gets `text-xs` so the ULID reads as a secondary identifier (the human-readable Bundle Name in the previous column carries the visual weight). Matches the dates' size for visual consistency. - Date columns (`pq-history-created`, `pq-history-modified`) keep `text-xs` — the previous "drop per-cell overrides" commit was too aggressive on these. Asset list dialog (View Contents): - `mt-1` on the search bar reserves room for the input's focus ring; without it the ring clipped against the dialog header when the input got keyboard focus. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dot-publishing-queue-asset-list-dialog.component.html | 4 +++- .../dot-publishing-queue-history.component.html | 6 +++--- 2 files changed, 6 insertions(+), 4 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.html b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-asset-list-dialog/dot-publishing-queue-asset-list-dialog.component.html index 3638efe7996d..a21c396cdd1d 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.html +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-asset-list-dialog/dot-publishing-queue-asset-list-dialog.component.html @@ -1,6 +1,8 @@
@if (showAssetSearch()) { -
+ +
- + {{ truncateBundleId(row.bundleId) }} {{ (row.createDate | date: 'medium') || '—' }} {{ (row.statusUpdated || row.createDate | date: 'medium') || '—' }} From d796fdf3d31ab47b53f91218b1ca914d2d951a4b Mon Sep 17 00:00:00 2001 From: hmoreras <31667212+hmoreras@users.noreply.github.com> Date: Fri, 19 Jun 2026 11:34:14 -0600 Subject: [PATCH 16/43] refactor(publishing-queue) #36040: single unified table + status chip filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapse the two-tab UI (Queue/History) into one table that holds both history and active (in-progress + scheduled) bundles. Status filter chip in the toolbar lets the user narrow by any subset of statuses; per-row kebab adapts to the row's status (Retry on failures, Configure & send on scheduled/active, View details / View Contents / Generate-download / Delete everywhere). Bundle details dialog: meta block switches from a two-column dl/dt/dd grid to a single-column key/value p-table with shaded labels, matching the design spec. Drops the legacy getunsendbundles (user-owned drafts) flow — those don't live in publish_audit and aren't part of the unified view. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...lishing-queue-status-filter.component.html | 27 ++ ...ublishing-queue-status-filter.component.ts | 89 ++++++ ...ot-publishing-queue-toolbar.component.html | 8 +- ...publishing-queue-toolbar.component.spec.ts | 96 +++--- .../dot-publishing-queue-toolbar.component.ts | 17 +- ...-queue-asset-list-dialog.component.spec.ts | 2 +- ...shing-queue-asset-list-dialog.component.ts | 2 +- ...queue-bundle-details-dialog.component.html | 101 ++++--- ...ue-bundle-details-dialog.component.spec.ts | 2 +- ...g-queue-bundle-details-dialog.component.ts | 54 +++- ...hing-queue-delete-dialog.component.spec.ts | 14 +- ...ublishing-queue-delete-dialog.component.ts | 4 +- ...hing-queue-upload-dialog.component.spec.ts | 2 +- ...ublishing-queue-upload-dialog.component.ts | 2 +- ...publishing-queue-history.component.spec.ts | 245 --------------- .../dot-publishing-queue-list.component.html | 96 ------ ...ot-publishing-queue-list.component.spec.ts | 172 ----------- .../dot-publishing-queue-list.component.ts | 105 ------- .../dot-publishing-queue-page.component.html | 30 -- ...ot-publishing-queue-page.component.spec.ts | 150 --------- .../dot-publishing-queue-page.component.ts | 125 -------- .../dot-publishing-queue-shell.component.html | 29 +- ...t-publishing-queue-shell.component.spec.ts | 31 +- .../dot-publishing-queue-shell.component.ts | 65 ++-- ...dot-publishing-queue-table.component.html} | 50 +-- ...t-publishing-queue-table.component.spec.ts | 247 +++++++++++++++ .../dot-publishing-queue-table.component.ts} | 146 ++++++--- .../store/dot-publishing-queue.store.spec.ts | 196 ++++-------- .../store/dot-publishing-queue.store.ts | 286 +++++------------- .../WEB-INF/messages/Language.properties | 5 + 30 files changed, 845 insertions(+), 1553 deletions(-) create mode 100644 core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-status-filter/dot-publishing-queue-status-filter.component.html create mode 100644 core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-status-filter/dot-publishing-queue-status-filter.component.ts delete mode 100644 core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.spec.ts delete mode 100644 core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-list/dot-publishing-queue-list.component.html delete mode 100644 core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-list/dot-publishing-queue-list.component.spec.ts delete mode 100644 core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-list/dot-publishing-queue-list.component.ts delete mode 100644 core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/dot-publishing-queue-page.component.html delete mode 100644 core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/dot-publishing-queue-page.component.spec.ts delete mode 100644 core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/dot-publishing-queue-page.component.ts rename core-web/libs/portlets/dot-publishing-queue/src/lib/{dot-publishing-queue-history/dot-publishing-queue-history.component.html => dot-publishing-queue-table/dot-publishing-queue-table.component.html} (85%) create mode 100644 core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-table/dot-publishing-queue-table.component.spec.ts rename core-web/libs/portlets/dot-publishing-queue/src/lib/{dot-publishing-queue-history/dot-publishing-queue-history.component.ts => dot-publishing-queue-table/dot-publishing-queue-table.component.ts} (57%) rename core-web/libs/portlets/dot-publishing-queue/src/lib/{dot-publishing-queue-page => }/store/dot-publishing-queue.store.spec.ts (61%) rename core-web/libs/portlets/dot-publishing-queue/src/lib/{dot-publishing-queue-page => }/store/dot-publishing-queue.store.ts (61%) diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-status-filter/dot-publishing-queue-status-filter.component.html b/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-status-filter/dot-publishing-queue-status-filter.component.html new file mode 100644 index 000000000000..6d81232541ba --- /dev/null +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-status-filter/dot-publishing-queue-status-filter.component.html @@ -0,0 +1,27 @@ + + + + + + + + + diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-status-filter/dot-publishing-queue-status-filter.component.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-status-filter/dot-publishing-queue-status-filter.component.ts new file mode 100644 index 000000000000..78a99e6655cc --- /dev/null +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-status-filter/dot-publishing-queue-status-filter.component.ts @@ -0,0 +1,89 @@ +import { ChangeDetectionStrategy, Component, computed, inject, linkedSignal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; + +import { ListboxModule } from 'primeng/listbox'; +import { PopoverModule } from 'primeng/popover'; + +import { DotMessageService } from '@dotcms/data-access'; +import { PublishAuditStatus } from '@dotcms/dotcms-models'; +import { + CHIP_FILTER_LISTBOX_PT, + CHIP_FILTER_POPOVER_PT, + DotChipFilterComponent, + DotFilterListItemComponent +} from '@dotcms/portlets/content-drive/ui'; +import { DotMessagePipe } from '@dotcms/ui'; + +import { DotPublishingQueueStore } from '../../store/dot-publishing-queue.store'; + +interface StatusOption { + value: PublishAuditStatus; + label: string; +} + +/** Ordered: active/scheduled first, success, warnings, failures. Keeps the + * checkbox list in a sensible grouping when the popover opens. */ +const STATUS_VALUES: readonly PublishAuditStatus[] = [ + PublishAuditStatus.BUNDLE_REQUESTED, + PublishAuditStatus.WAITING_FOR_PUBLISHING, + PublishAuditStatus.BUNDLING, + PublishAuditStatus.SENDING_TO_ENDPOINTS, + PublishAuditStatus.PUBLISHING_BUNDLE, + PublishAuditStatus.RECEIVED_BUNDLE, + PublishAuditStatus.SUCCESS, + PublishAuditStatus.SUCCESS_WITH_WARNINGS, + PublishAuditStatus.BUNDLE_SENT_SUCCESSFULLY, + PublishAuditStatus.BUNDLE_SAVED_SUCCESSFULLY, + PublishAuditStatus.FAILED_TO_SEND_TO_ALL_GROUPS, + PublishAuditStatus.FAILED_TO_SEND_TO_SOME_GROUPS, + PublishAuditStatus.FAILED_TO_BUNDLE, + PublishAuditStatus.FAILED_TO_SENT, + PublishAuditStatus.FAILED_TO_PUBLISH, + PublishAuditStatus.FAILED_INTEGRITY_CHECK, + PublishAuditStatus.INVALID_TOKEN, + PublishAuditStatus.LICENSE_REQUIRED +]; + +@Component({ + selector: 'dot-publishing-queue-status-filter', + standalone: true, + imports: [ + FormsModule, + ListboxModule, + PopoverModule, + DotChipFilterComponent, + DotFilterListItemComponent, + DotMessagePipe + ], + templateUrl: './dot-publishing-queue-status-filter.component.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DotPublishingQueueStatusFilterComponent { + private readonly store = inject(DotPublishingQueueStore); + private readonly dotMessageService = inject(DotMessageService); + + protected readonly popoverPt = CHIP_FILTER_POPOVER_PT; + protected readonly listboxPt = CHIP_FILTER_LISTBOX_PT; + protected readonly LISTBOX_SCROLL_HEIGHT = '320px'; + + protected readonly $options: StatusOption[] = STATUS_VALUES.map((value) => ({ + value, + label: this.dotMessageService.get(`publishing-queue.status.${value}`) + })); + + protected readonly $selected = linkedSignal(() => this.store.statusFilter()); + + protected readonly $selectedLabels = computed(() => { + const lookup = new Map(this.$options.map((o) => [o.value, o.label])); + return this.$selected().map((s) => lookup.get(s) ?? s); + }); + + protected onChange(): void { + this.store.setStatusFilter(this.$selected()); + } + + protected onRemoveAll(): void { + this.$selected.set([]); + this.onChange(); + } +} 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 fb3a688049c7..a6cef311135d 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 @@ -13,10 +13,12 @@ class="w-80" /> + +
@if (hasBulkActions()) { - {{ store.historySelectedIds().length }} + {{ store.bundlesSelectedIds().length }} {{ 'publishing-queue.selected' | dm }} + data-testid="pq-bulk-retry" /> + data-testid="pq-bulk-delete" /> } { let spectator: Spectator; let store: ReturnType; - const activeTab = signal<'queue' | 'history'>('queue'); - const historySelectedIds = signal([]); - const historyTotal = signal(0); + const bundlesSelectedIds = signal([]); + const bundlesTotal = signal(0); function makeStoreStub() { return { search: jest.fn().mockReturnValue(''), setSearch: jest.fn(), refresh: jest.fn(), - activeTab, - historySelectedIds, - historyTotal, + bundlesSelectedIds, + bundlesTotal, retryBundles: jest.fn() }; } const createComponent = createComponentFactory({ component: DotPublishingQueueToolbarComponent, + overrideComponents: [ + [ + DotPublishingQueueStatusFilterComponent, + { + set: { + template: '
', + imports: [] + } + } + ] + ], componentProviders: [mockProvider(DotPublishingQueueStore, makeStoreStub())], providers: [ { @@ -44,14 +54,14 @@ describe('DotPublishingQueueToolbarComponent', () => { 'publishing-queue.selected': 'selected' }) } - ] + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA] }); beforeEach(() => { jest.useFakeTimers(); - activeTab.set('queue'); - historySelectedIds.set([]); - historyTotal.set(0); + bundlesSelectedIds.set([]); + bundlesTotal.set(0); spectator = createComponent(); store = spectator.inject(DotPublishingQueueStore, true) as unknown as ReturnType< typeof makeStoreStub @@ -64,11 +74,11 @@ describe('DotPublishingQueueToolbarComponent', () => { }); describe('layout', () => { - it('renders search, refresh, upload', () => { + it('renders search, status filter, refresh, upload', () => { expect(spectator.query(byTestId('pq-search-input'))).toBeTruthy(); + expect(spectator.query(byTestId('pq-status-filter-stub'))).toBeTruthy(); expect(spectator.query(byTestId('pq-refresh-btn'))).toBeTruthy(); expect(spectator.query(byTestId('pq-upload-btn'))).toBeTruthy(); - expect(spectator.query(byTestId('pq-site-selector'))).toBeFalsy(); }); it('upload button click emits uploadClick', () => { @@ -122,74 +132,48 @@ describe('DotPublishingQueueToolbarComponent', () => { }); describe('Retry Send (selection-gated)', () => { - it('is hidden on the queue tab even with a selection', () => { - activeTab.set('queue'); - historySelectedIds.set(['b1']); + it('is hidden when nothing is selected', () => { + bundlesSelectedIds.set([]); spectator.detectChanges(); - expect(spectator.query(byTestId('pq-history-bulk-retry'))).toBeFalsy(); + expect(spectator.query(byTestId('pq-bulk-retry'))).toBeFalsy(); expect(spectator.query(byTestId('pq-bulk-count'))).toBeFalsy(); }); - it('is hidden on the history tab when nothing is selected', () => { - activeTab.set('history'); - historySelectedIds.set([]); - historyTotal.set(5); + it('shows the retry button + selected-count when there is a selection', () => { + bundlesSelectedIds.set(['b1', 'b2']); spectator.detectChanges(); - expect(spectator.query(byTestId('pq-history-bulk-retry'))).toBeFalsy(); - }); - - it('shows the retry button + selected-count on the history tab with selection', () => { - activeTab.set('history'); - historySelectedIds.set(['b1', 'b2']); - historyTotal.set(5); - spectator.detectChanges(); - expect(spectator.query(byTestId('pq-history-bulk-retry'))).toBeTruthy(); + expect(spectator.query(byTestId('pq-bulk-retry'))).toBeTruthy(); expect(spectator.query(byTestId('pq-bulk-count'))?.textContent).toContain('2'); }); it('clicking retry calls retryBundles with the selected ids', () => { - activeTab.set('history'); - historySelectedIds.set(['b1', 'b2']); - historyTotal.set(5); + bundlesSelectedIds.set(['b1', 'b2']); spectator.detectChanges(); - const btn = spectator.query(byTestId('pq-history-bulk-retry'))?.querySelector('button'); + const btn = spectator.query(byTestId('pq-bulk-retry'))?.querySelector('button'); spectator.click(btn as HTMLButtonElement); expect(store.retryBundles).toHaveBeenCalledWith({ bundleIds: ['b1', 'b2'] }); }); }); describe('Delete Bundles (selection-gated)', () => { - it('is hidden on the queue tab even with a selection', () => { - activeTab.set('queue'); - historySelectedIds.set(['b1']); - spectator.detectChanges(); - expect(spectator.query(byTestId('pq-history-delete-bundles'))).toBeFalsy(); - }); - - it('is hidden on the history tab when nothing is selected', () => { - activeTab.set('history'); - historyTotal.set(5); - historySelectedIds.set([]); + it('is hidden when nothing is selected', () => { + bundlesSelectedIds.set([]); spectator.detectChanges(); - expect(spectator.query(byTestId('pq-history-delete-bundles'))).toBeFalsy(); + expect(spectator.query(byTestId('pq-bulk-delete'))).toBeFalsy(); }); - it('shows on the history tab when there is a selection', () => { - activeTab.set('history'); - historySelectedIds.set(['b1']); + it('shows when there is a selection', () => { + bundlesSelectedIds.set(['b1']); spectator.detectChanges(); - expect(spectator.query(byTestId('pq-history-delete-bundles'))).toBeTruthy(); + expect(spectator.query(byTestId('pq-bulk-delete'))).toBeTruthy(); }); it('emits deleteClick when clicked', () => { - activeTab.set('history'); - historySelectedIds.set(['b1']); + bundlesSelectedIds.set(['b1']); spectator.detectChanges(); const emit = jest.fn(); spectator.component.deleteClick.subscribe(emit); - const btn = spectator - .query(byTestId('pq-history-delete-bundles')) - ?.querySelector('button'); + const btn = spectator.query(byTestId('pq-bulk-delete'))?.querySelector('button'); spectator.click(btn as HTMLButtonElement); expect(emit).toHaveBeenCalled(); }); 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 9f2e55bf4b1e..315347d1ba36 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 @@ -21,7 +21,8 @@ import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; import { DotMessagePipe } from '@dotcms/ui'; -import { DotPublishingQueueStore } from '../../dot-publishing-queue-page/store/dot-publishing-queue.store'; +import { DotPublishingQueueStore } from '../../store/dot-publishing-queue.store'; +import { DotPublishingQueueStatusFilterComponent } from '../dot-publishing-queue-status-filter/dot-publishing-queue-status-filter.component'; @Component({ selector: 'dot-publishing-queue-toolbar', @@ -33,7 +34,8 @@ import { DotPublishingQueueStore } from '../../dot-publishing-queue-page/store/d InputIconModule, InputTextModule, ToolbarModule, - DotMessagePipe + DotMessagePipe, + DotPublishingQueueStatusFilterComponent ], templateUrl: './dot-publishing-queue-toolbar.component.html', changeDetection: ChangeDetectionStrategy.OnPush @@ -46,13 +48,8 @@ export class DotPublishingQueueToolbarComponent { private readonly destroyRef = inject(DestroyRef); private searchSubject = new Subject(); - /** Bulk actions appear only when the user has explicitly checked one or more rows. - * The Delete-Bundles dialog still offers ALL/SUCCESS/FAILED scopes (which don't - * strictly need a selection), but exposing them only after a selection keeps the - * top bar quiet and matches the rest of the bulk-action UI. */ - readonly hasBulkActions = computed( - () => this.store.activeTab() === 'history' && this.store.historySelectedIds().length > 0 - ); + /** Bulk actions appear only when the user has explicitly checked one or more rows. */ + readonly hasBulkActions = computed(() => this.store.bundlesSelectedIds().length > 0); constructor() { this.searchSubject @@ -65,6 +62,6 @@ export class DotPublishingQueueToolbarComponent { } onBulkRetry(): void { - this.store.retryBundles({ bundleIds: this.store.historySelectedIds() }); + this.store.retryBundles({ bundleIds: this.store.bundlesSelectedIds() }); } } 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.spec.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.spec.ts index d3174fcfba71..c492e3bd3a45 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.spec.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.spec.ts @@ -11,7 +11,7 @@ import { MockDotMessageService } from '@dotcms/utils-testing'; import { DotPublishingQueueAssetListDialogComponent } from './dot-publishing-queue-asset-list-dialog.component'; -import { DotPublishingQueueStore } from '../../dot-publishing-queue-page/store/dot-publishing-queue.store'; +import { DotPublishingQueueStore } from '../../store/dot-publishing-queue.store'; const ASSETS: BundleAssetView[] = [ { asset: 'a1', title: 'Asset 1', type: 'contentlet' }, 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 ac1ee5370e37..855312f1746b 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 @@ -30,7 +30,7 @@ import { DotMessageService } from '@dotcms/data-access'; import { BundleAssetView } from '@dotcms/dotcms-models'; import { DotMessagePipe } from '@dotcms/ui'; -import { DotPublishingQueueStore } from '../../dot-publishing-queue-page/store/dot-publishing-queue.store'; +import { DotPublishingQueueStore } from '../../store/dot-publishing-queue.store'; /** Show the search input only when the bundle is big enough that scrolling alone is painful. */ const ASSET_SEARCH_THRESHOLD = 10; diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.html b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.html index 22db06ff7561..7146951b43fe 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.html +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.html @@ -4,54 +4,59 @@ } @else if (store.detail(); as detail) { -
-
-
{{ 'publishing-queue.detail.title' | dm }}
-
{{ detail.bundleName || '—' }}
-
-
-
{{ 'publishing-queue.detail.status' | dm }}
-
- -
-
-
-
{{ 'publishing-queue.detail.bundle-id' | dm }}
-
{{ detail.bundleId }}
-
-
-
- {{ 'publishing-queue.detail.bundle-start' | dm }} -
-
{{ (detail.timestamps.bundleStart | date: 'medium') || '—' }}
-
-
-
- {{ 'publishing-queue.detail.bundle-end' | dm }} -
-
{{ (detail.timestamps.bundleEnd | date: 'medium') || '—' }}
-
-
-
- {{ 'publishing-queue.detail.publish-start' | dm }} -
-
{{ (detail.timestamps.publishStart | date: 'medium') || '—' }}
-
-
-
- {{ 'publishing-queue.detail.publish-end' | dm }} -
-
{{ (detail.timestamps.publishEnd | date: 'medium') || '—' }}
-
-
-
{{ 'publishing-queue.detail.filter' | dm }}
-
{{ detail.filterName || detail.filterKey || '—' }}
-
-
-
{{ 'publishing-queue.detail.assets' | dm }}
-
{{ detail.assetCount }}
-
-
+ + + + + {{ row.label }} + + + @switch (row.key) { + @case ('title') { + {{ detail.bundleName || '—' }} + @if (detail.assetCount > 0) { + + · {{ detail.assetCount }} + {{ 'publishing-queue.detail.assets-suffix' | dm }} + + } + } + @case ('status') { + + } + @case ('bundleId') { + + {{ detail.bundleId }} + + } + @case ('bundleStart') { + {{ (detail.timestamps.bundleStart | date: 'medium') || '—' }} + } + @case ('bundleEnd') { + {{ (detail.timestamps.bundleEnd | date: 'medium') || '—' }} + } + @case ('publishStart') { + {{ (detail.timestamps.publishStart | date: 'medium') || '—' }} + } + @case ('publishEnd') { + {{ (detail.timestamps.publishEnd | date: 'medium') || '—' }} + } + @case ('filter') { + {{ detail.filterName || detail.filterKey || '—' }} + } + @case ('assets') { + {{ detail.assetCount }} + } + } + + + +

diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.spec.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.spec.ts index f35e23d160ee..8417fd434b08 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.spec.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.spec.ts @@ -12,7 +12,7 @@ import { MockDotMessageService } from '@dotcms/utils-testing'; import { DotPublishingQueueBundleDetailsDialogComponent } from './dot-publishing-queue-bundle-details-dialog.component'; -import { DotPublishingQueueStore } from '../../dot-publishing-queue-page/store/dot-publishing-queue.store'; +import { DotPublishingQueueStore } from '../../store/dot-publishing-queue.store'; const detailFixture = ( overrides: Partial = {} diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.ts index 85ab485f40c3..6f31b4d60062 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.ts @@ -5,12 +5,12 @@ import { ButtonModule } from 'primeng/button'; import { SkeletonModule } from 'primeng/skeleton'; import { TableModule } from 'primeng/table'; -import { DotPublishingQueueService } from '@dotcms/data-access'; +import { DotMessageService, DotPublishingQueueService } from '@dotcms/data-access'; import { EndpointDetailView, PublishAuditStatus } from '@dotcms/dotcms-models'; import { DotMessagePipe } from '@dotcms/ui'; import { DotPublishingStatusChipComponent } from '../../components/dot-publishing-status-chip/dot-publishing-status-chip.component'; -import { DotPublishingQueueStore } from '../../dot-publishing-queue-page/store/dot-publishing-queue.store'; +import { DotPublishingQueueStore } from '../../store/dot-publishing-queue.store'; const SUCCESS_STATUSES = new Set([ PublishAuditStatus.SUCCESS, @@ -27,6 +27,25 @@ export interface EndpointTableRow { endpoint: EndpointDetailView; } +/** Discriminator for which body cell renders the row's value. Plain rows just + * show `value`; the special cases need bespoke markup (a chip, a monospace id, + * the "name · N assets" title). */ +type MetaKey = + | 'title' + | 'status' + | 'bundleId' + | 'bundleStart' + | 'bundleEnd' + | 'publishStart' + | 'publishEnd' + | 'filter' + | 'assets'; + +export interface MetaRow { + key: MetaKey; + label: string; +} + @Component({ selector: 'dot-publishing-queue-bundle-details-dialog', standalone: true, @@ -45,6 +64,37 @@ export class DotPublishingQueueBundleDetailsDialogComponent { readonly store = inject(DotPublishingQueueStore); private readonly publishingService = inject(DotPublishingQueueService); + private readonly dotMessageService = inject(DotMessageService); + + /** Static list of rows shown in the meta key/value table — the order here + * is the order rendered in the dialog. The body template switches on `key` + * to pick the right value cell. */ + readonly metaRows: readonly MetaRow[] = [ + { key: 'title', label: this.dotMessageService.get('publishing-queue.detail.title') }, + { key: 'status', label: this.dotMessageService.get('publishing-queue.detail.status') }, + { key: 'bundleId', label: this.dotMessageService.get('publishing-queue.detail.bundle-id') }, + { + key: 'bundleStart', + label: this.dotMessageService.get('publishing-queue.detail.bundle-start') + }, + { + key: 'bundleEnd', + label: this.dotMessageService.get('publishing-queue.detail.bundle-end') + }, + { + key: 'publishStart', + label: this.dotMessageService.get('publishing-queue.detail.publish-start') + }, + { + key: 'publishEnd', + label: this.dotMessageService.get('publishing-queue.detail.publish-end') + }, + { key: 'filter', label: this.dotMessageService.get('publishing-queue.detail.filter') }, + { + key: 'assets', + label: this.dotMessageService.get('publishing-queue.detail.total-assets') + } + ]; readonly canDownload = computed(() => { const status = this.store.detail()?.status; diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-delete-dialog/dot-publishing-queue-delete-dialog.component.spec.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-delete-dialog/dot-publishing-queue-delete-dialog.component.spec.ts index fd5b4d4f94b9..d595d124457a 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-delete-dialog/dot-publishing-queue-delete-dialog.component.spec.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-delete-dialog/dot-publishing-queue-delete-dialog.component.spec.ts @@ -9,17 +9,17 @@ import { MockDotMessageService } from '@dotcms/utils-testing'; import { DotPublishingQueueDeleteDialogComponent } from './dot-publishing-queue-delete-dialog.component'; -import { DotPublishingQueueStore } from '../../dot-publishing-queue-page/store/dot-publishing-queue.store'; +import { DotPublishingQueueStore } from '../../store/dot-publishing-queue.store'; describe('DotPublishingQueueDeleteDialogComponent', () => { let spectator: Spectator; let dialogRef: jest.Mocked; - const historySelectedIds = signal([]); + const bundlesSelectedIds = signal([]); const createComponent = createComponentFactory({ component: DotPublishingQueueDeleteDialogComponent, - componentProviders: [mockProvider(DotPublishingQueueStore, { historySelectedIds })], + componentProviders: [mockProvider(DotPublishingQueueStore, { bundlesSelectedIds })], providers: [ mockProvider(DynamicDialogRef, { close: jest.fn() }), { @@ -37,7 +37,7 @@ describe('DotPublishingQueueDeleteDialogComponent', () => { }); beforeEach(() => { - historySelectedIds.set([]); + bundlesSelectedIds.set([]); spectator = createComponent(); dialogRef = spectator.inject(DynamicDialogRef) as jest.Mocked; }); @@ -58,14 +58,14 @@ describe('DotPublishingQueueDeleteDialogComponent', () => { }); it('disables SELECTED when there is no selection', () => { - historySelectedIds.set([]); + bundlesSelectedIds.set([]); spectator.detectChanges(); const btn = spectator.query(byTestId('pq-delete-selected'))?.querySelector('button'); expect(btn?.hasAttribute('disabled')).toBe(true); }); it('enables SELECTED when there is a selection', () => { - historySelectedIds.set(['b1']); + bundlesSelectedIds.set(['b1']); spectator.detectChanges(); const btn = spectator.query(byTestId('pq-delete-selected'))?.querySelector('button'); expect(btn?.hasAttribute('disabled')).toBe(false); @@ -77,7 +77,7 @@ describe('DotPublishingQueueDeleteDialogComponent', () => { ['pq-delete-success', 'success'], ['pq-delete-failed', 'failed'] ])('closes with scope "%s" when %s is clicked', (testId, expected) => { - historySelectedIds.set(['b1']); // enable SELECTED for the parameterised test + bundlesSelectedIds.set(['b1']); // enable SELECTED for the parameterised test spectator.detectChanges(); clickButton(testId); expect(dialogRef.close).toHaveBeenCalledWith(expected); diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-delete-dialog/dot-publishing-queue-delete-dialog.component.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-delete-dialog/dot-publishing-queue-delete-dialog.component.ts index 4a7a777c33f7..227940c8572e 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-delete-dialog/dot-publishing-queue-delete-dialog.component.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-delete-dialog/dot-publishing-queue-delete-dialog.component.ts @@ -6,7 +6,7 @@ import { MessageModule } from 'primeng/message'; import { DotMessagePipe } from '@dotcms/ui'; -import { DotPublishingQueueStore } from '../../dot-publishing-queue-page/store/dot-publishing-queue.store'; +import { DotPublishingQueueStore } from '../../store/dot-publishing-queue.store'; /** Scope chosen by the user — emitted back via DynamicDialogRef.close(). */ export type DeleteBundlesScope = 'selected' | 'all' | 'success' | 'failed'; @@ -34,7 +34,7 @@ export class DotPublishingQueueDeleteDialogComponent { readonly store = inject(DotPublishingQueueStore); readonly dialogRef = inject(DynamicDialogRef); - readonly selectedCount = computed(() => this.store.historySelectedIds().length); + readonly selectedCount = computed(() => this.store.bundlesSelectedIds().length); readonly hasSelection = computed(() => this.selectedCount() > 0); choose(scope: DeleteBundlesScope): void { diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-upload-dialog/dot-publishing-queue-upload-dialog.component.spec.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-upload-dialog/dot-publishing-queue-upload-dialog.component.spec.ts index 66f14153c2fd..f48786bfe711 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-upload-dialog/dot-publishing-queue-upload-dialog.component.spec.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-upload-dialog/dot-publishing-queue-upload-dialog.component.spec.ts @@ -10,7 +10,7 @@ import { MockDotMessageService } from '@dotcms/utils-testing'; import { DotPublishingQueueUploadDialogComponent } from './dot-publishing-queue-upload-dialog.component'; -import { DotPublishingQueueStore } from '../../dot-publishing-queue-page/store/dot-publishing-queue.store'; +import { DotPublishingQueueStore } from '../../store/dot-publishing-queue.store'; describe('DotPublishingQueueUploadDialogComponent', () => { let spectator: Spectator; diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-upload-dialog/dot-publishing-queue-upload-dialog.component.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-upload-dialog/dot-publishing-queue-upload-dialog.component.ts index 7a7428169c86..22e54db68e60 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-upload-dialog/dot-publishing-queue-upload-dialog.component.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-upload-dialog/dot-publishing-queue-upload-dialog.component.ts @@ -13,7 +13,7 @@ import { catchError, take } from 'rxjs/operators'; import { DotPublishingQueueService } from '@dotcms/data-access'; import { DotMessagePipe } from '@dotcms/ui'; -import { DotPublishingQueueStore } from '../../dot-publishing-queue-page/store/dot-publishing-queue.store'; +import { DotPublishingQueueStore } from '../../store/dot-publishing-queue.store'; /** `.tar.gz` (most common) and `.tgz` (legacy alias). */ const BUNDLE_FILE_PATTERN = /\.(tar\.gz|tgz)$/i; diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.spec.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.spec.ts deleted file mode 100644 index 10f3dbeb8f16..000000000000 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.spec.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { byTestId, createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; - -import { signal } from '@angular/core'; - -import { ConfirmationService } from 'primeng/api'; - -import { DotGlobalMessageService, DotMessageService } from '@dotcms/data-access'; -import { PublishAuditStatus, PublishingJobView } from '@dotcms/dotcms-models'; -import { DotClipboardUtil } from '@dotcms/ui'; -import { MockDotMessageService } from '@dotcms/utils-testing'; - -import { DotPublishingQueueHistoryComponent } from './dot-publishing-queue-history.component'; - -import { DotPublishingQueueStore } from '../dot-publishing-queue-page/store/dot-publishing-queue.store'; - -const row = ( - bundleId: string, - status: PublishAuditStatus = PublishAuditStatus.SUCCESS -): PublishingJobView => ({ - bundleId, - bundleName: `Bundle ${bundleId}`, - status, - filterName: null, - filterKey: null, - assetCount: 1, - assetPreview: [], - environmentCount: 1, - createDate: '2026-06-08T10:00:00Z', - statusUpdated: null, - numTries: 1 -}); - -describe('DotPublishingQueueHistoryComponent', () => { - let spectator: Spectator; - let store: ReturnType; - - const historyRows = signal([]); - const historyStatus = signal<'init' | 'loading' | 'loaded' | 'error'>('loaded'); - const historyTotal = signal(0); - const historyPage = signal(1); - const historySort = signal(null); - const historySortDirection = signal<'asc' | 'desc'>('desc'); - const historySelectedIds = signal([]); - const rowsPerPage = signal(10); - - function makeStoreStub() { - return { - historyRows, - historyStatus, - historyTotal, - historyPage, - historySort, - historySortDirection, - historySelectedIds, - rowsPerPage, - setHistoryPage: jest.fn((p: number) => historyPage.set(p)), - cycleHistorySort: jest.fn(), - setHistorySelection: jest.fn((ids: string[]) => historySelectedIds.set(ids)), - clearHistorySelection: jest.fn(() => historySelectedIds.set([])), - openDetail: jest.fn(), - openAssetList: jest.fn(), - deleteBundle: jest.fn() - }; - } - - let confirmationService: jest.Mocked; - - let clipboard: jest.Mocked; - let globalMessage: jest.Mocked; - - const createComponent = createComponentFactory({ - component: DotPublishingQueueHistoryComponent, - componentProviders: [ - mockProvider(DotPublishingQueueStore, makeStoreStub()), - ConfirmationService, - mockProvider(DotClipboardUtil, { - copy: jest.fn().mockResolvedValue(true) - }) - ], - providers: [ - { provide: DotMessageService, useValue: new MockDotMessageService({}) }, - mockProvider(DotGlobalMessageService, { error: jest.fn() }) - ] - }); - - beforeEach(() => { - historyRows.set([row('b1'), row('b2', PublishAuditStatus.FAILED_TO_PUBLISH)]); - historyStatus.set('loaded'); - historyTotal.set(2); - historyPage.set(1); - historySelectedIds.set([]); - rowsPerPage.set(10); - spectator = createComponent(); - store = spectator.inject(DotPublishingQueueStore, true) as unknown as ReturnType< - typeof makeStoreStub - >; - confirmationService = spectator.inject( - ConfirmationService, - true - ) as jest.Mocked; - clipboard = spectator.inject(DotClipboardUtil, true) as jest.Mocked; - globalMessage = spectator.inject( - DotGlobalMessageService - ) as jest.Mocked; - jest.spyOn(confirmationService, 'confirm').mockImplementation((cfg) => { - cfg.accept?.(); - return confirmationService; - }); - jest.clearAllMocks(); - }); - - it('renders the table', () => { - expect(spectator.query(byTestId('pq-history-table'))).toBeTruthy(); - }); - - it('renders all six column headers (Bundle Name, Bundle Id, Filter, Status, Data Entered, Last Update)', () => { - expect(spectator.query(byTestId('pq-history-col-bundle-name'))).toBeTruthy(); - expect(spectator.query(byTestId('pq-history-col-bundle-id'))).toBeTruthy(); - expect(spectator.query(byTestId('pq-history-col-filter'))).toBeTruthy(); - expect(spectator.query(byTestId('pq-history-col-status'))).toBeTruthy(); - expect(spectator.query(byTestId('pq-history-col-created'))).toBeTruthy(); - expect(spectator.query(byTestId('pq-history-col-modified'))).toBeTruthy(); - }); - - it('renders Bundle Name cell with the row name (falls back to "—" when null)', () => { - historyRows.set([row('b1'), { ...row('b2'), bundleName: null }]); - spectator.detectChanges(); - const cells = spectator.queryAll(byTestId('pq-history-bundle-name')); - expect(cells.length).toBe(2); - expect(cells[0].textContent?.trim()).toBe('Bundle b1'); - expect(cells[1].textContent?.trim()).toBe('—'); - }); - - it('renders rows with status chips', () => { - const tags = spectator.queryAll(byTestId('pq-history-status')); - expect(tags.length).toBe(2); - }); - - it('renders Filter cell falling back to "—" when filterName + filterKey are null', () => { - const cells = spectator.queryAll(byTestId('pq-history-filter')); - expect(cells.length).toBe(2); - expect(cells[0].textContent?.trim()).toBe('—'); - }); - - it('renders Bundle Id cell with the full id (no truncation) + copy button', () => { - const cells = spectator.queryAll(byTestId('pq-history-bundle-id')); - expect(cells.length).toBe(2); - expect(cells[0].textContent).toContain('b1'); - expect(cells[0].querySelector('[data-testid="pq-history-bundle-id-copy"]')).toBeTruthy(); - }); - - it('row click opens the detail dialog', () => { - spectator.component.onRowClick(row('b1')); - expect(store.openDetail).toHaveBeenCalledWith('b1'); - }); - - it('renders a dot-publishing-status-chip per row', () => { - const chips = spectator.queryAll('dot-publishing-status-chip'); - expect(chips.length).toBe(2); - }); - - describe('truncateBundleId', () => { - it('returns the id unchanged when shorter than or equal to 32 chars', () => { - // standard 26-char ULID - expect(spectator.component.truncateBundleId('01KVBQPPFQCVG6C9VP4D0V47M0')).toBe( - '01KVBQPPFQCVG6C9VP4D0V47M0' - ); - // exactly 32 chars - expect(spectator.component.truncateBundleId('a'.repeat(32))).toBe('a'.repeat(32)); - }); - - it('truncates to the first 32 chars + "…" when longer', () => { - const long = 'Bulk-product-bundle-01KV6E1E62SNRW4EWWKFW29S9B'; // 47 chars - const truncated = spectator.component.truncateBundleId(long); - expect(truncated).toBe(`${long.slice(0, 32)}…`); - expect(truncated.length).toBe(33); // 32 chars + 1 ellipsis - }); - - it('handles empty / nullish input safely', () => { - expect(spectator.component.truncateBundleId('')).toBe(''); - }); - }); - - describe('copyToClipboard', () => { - it('delegates to DotClipboardUtil.copy', async () => { - (clipboard.copy as jest.Mock).mockResolvedValue(true); - await spectator.component.copyToClipboard('bundle-xyz'); - expect(clipboard.copy).toHaveBeenCalledWith('bundle-xyz'); - expect(globalMessage.error).not.toHaveBeenCalled(); - }); - - it('surfaces a global error toast when copy fails', async () => { - (clipboard.copy as jest.Mock).mockResolvedValue(false); - await spectator.component.copyToClipboard('bundle-xyz'); - expect(globalMessage.error).toHaveBeenCalled(); - }); - }); - - describe('row kebab menu', () => { - it('renders a kebab button per row', () => { - expect(spectator.queryAll(byTestId('pq-history-kebab-btn')).length).toBe(2); - }); - - // Regression: `` thrashes when it receives a new - // array reference on every CD — the first click only closes the menu - // and the user has to click twice. `kebabFor` must memoize. - it('kebabFor returns the SAME array reference across change-detection cycles', () => { - const r = historyRows()[0]; - const a = spectator.component.kebabFor(r); - spectator.detectChanges(); - const b = spectator.component.kebabFor(r); - expect(b).toBe(a); - }); - - it('builds 4 items: View details · View Contents · separator · Delete (no icons)', () => { - const items = spectator.component.historyKebabFor(row('b1')); - expect(items.length).toBe(4); - // No icon on any item — design preference to keep the menu text-only - expect(items[0].icon).toBeUndefined(); - expect(items[1].icon).toBeUndefined(); - expect(items[2].separator).toBe(true); - expect(items[3].icon).toBeUndefined(); - expect(items[3].styleClass).toBe('p-menuitem-danger'); - }); - - it('View details → store.openDetail', () => { - const items = spectator.component.historyKebabFor(row('b1')); - items[0].command?.({} as never); - expect(store.openDetail).toHaveBeenCalledWith('b1'); - }); - - it('View Contents → store.openAssetList', () => { - const items = spectator.component.historyKebabFor(row('b1')); - items[1].command?.({} as never); - expect(store.openAssetList).toHaveBeenCalledWith('b1'); - }); - - it('Delete → confirmation, then store.deleteBundle on accept', () => { - const items = spectator.component.historyKebabFor(row('b1')); - items[3].command?.({} as never); - expect(confirmationService.confirm).toHaveBeenCalled(); - expect(store.deleteBundle).toHaveBeenCalledWith('b1'); - }); - }); -}); diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-list/dot-publishing-queue-list.component.html b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-list/dot-publishing-queue-list.component.html deleted file mode 100644 index d7d7b9bfc649..000000000000 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-list/dot-publishing-queue-list.component.html +++ /dev/null @@ -1,96 +0,0 @@ -
-
-

- {{ headerKey() | dm }} - ({{ total() }}) -

-
- -
- @if (status() === 'loading' && rows().length === 0) { - @for (_ of skeletonRows; track $index) { -
- - -
- } - } @else if (rows().length === 0) { -
- -
- } @else { - @for (job of rows(); track job.bundleId) { -
-
- - {{ job.bundleName || job.bundleId }} - - @if (job.assetCount > 0 || job.environmentCount > 0) { - - {{ job.assetCount }} assets · {{ job.environmentCount }} env - - } -
- @if (job.status) { - - } - @if (mode() === 'ready') { - - - - } @else if (isRetryable(job.status)) { - - } -
- } - } -
- - @if (total() > rowsPerPage()) { - - } -
diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-list/dot-publishing-queue-list.component.spec.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-list/dot-publishing-queue-list.component.spec.ts deleted file mode 100644 index c41de4e47183..000000000000 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-list/dot-publishing-queue-list.component.spec.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { byTestId, createComponentFactory, Spectator } from '@ngneat/spectator/jest'; - -import { DotMessageService } from '@dotcms/data-access'; -import { PublishAuditStatus, PublishingJobView } from '@dotcms/dotcms-models'; -import { MockDotMessageService } from '@dotcms/utils-testing'; - -import { DotPublishingQueueListComponent } from './dot-publishing-queue-list.component'; - -const job = (overrides: Partial = {}): PublishingJobView => ({ - bundleId: 'bundle-1', - bundleName: 'Bundle One', - status: PublishAuditStatus.WAITING_FOR_PUBLISHING, - filterName: null, - filterKey: null, - assetCount: 3, - assetPreview: [], - environmentCount: 2, - createDate: '2026-06-08T10:00:00Z', - statusUpdated: null, - numTries: 0, - ...overrides -}); - -describe('DotPublishingQueueListComponent', () => { - let spectator: Spectator; - - const createComponent = createComponentFactory({ - component: DotPublishingQueueListComponent, - providers: [{ provide: DotMessageService, useValue: new MockDotMessageService({}) }], - detectChanges: false - }); - - const defaultInputs = { - mode: 'ready' as const, - rows: [job()], - status: 'loaded' as const, - total: 1, - page: 1, - rowsPerPage: 10, - headerKey: 'publishing-queue.ready.title', - emptyConfig: { - icon: 'pi-folder-open', - title: "Your bundle's empty", - subtitle: 'Add content here' - } - }; - - beforeEach(() => { - spectator = createComponent({ props: defaultInputs }); - spectator.detectChanges(); - }); - - it('renders the header with the count', () => { - const header = spectator.query(byTestId('pq-list-header')); - expect(header?.textContent).toContain('(1)'); - }); - - it('renders a row per job', () => { - const rows = spectator.queryAll(byTestId('pq-list-row')); - expect(rows.length).toBe(1); - }); - - it('renders the Send button in ready mode (disabled)', () => { - const sendBtn = spectator.query(byTestId('pq-row-send-btn'))?.querySelector('button'); - expect(sendBtn).toBeTruthy(); - expect(sendBtn?.disabled).toBe(false); - expect(spectator.query(byTestId('pq-row-kebab-btn'))).toBeTruthy(); - }); - - it('emits sendClick when Send is clicked', () => { - let emitted: PublishingJobView | undefined; - spectator.output('sendClick').subscribe((j) => (emitted = j as PublishingJobView)); - const sendBtn = spectator.query(byTestId('pq-row-send-btn'))?.querySelector('button'); - spectator.click(sendBtn as HTMLButtonElement); - expect(emitted?.bundleId).toBe('bundle-1'); - }); - - it('hides Send + kebab in progress mode', () => { - spectator.setInput('mode', 'progress'); - spectator.setInput('rows', [job({ status: PublishAuditStatus.BUNDLING })]); - spectator.detectChanges(); - expect(spectator.query(byTestId('pq-row-send-btn'))).toBeFalsy(); - expect(spectator.query(byTestId('pq-row-kebab-btn'))).toBeFalsy(); - }); - - it('shows Retry button on failed progress rows + emits retryClick', () => { - spectator.setInput('mode', 'progress'); - spectator.setInput('rows', [job({ status: PublishAuditStatus.FAILED_TO_PUBLISH })]); - spectator.detectChanges(); - const retry = spectator.query(byTestId('pq-row-retry-btn')); - expect(retry).toBeTruthy(); - - let emitted: PublishingJobView | undefined; - spectator.output('retryClick').subscribe((j) => (emitted = j as PublishingJobView)); - spectator.click(retry?.querySelector('button') as HTMLButtonElement); - expect(emitted?.bundleId).toBe('bundle-1'); - }); - - it('shows skeletons while loading and no rows yet', () => { - spectator.setInput('rows', []); - spectator.setInput('status', 'loading'); - spectator.detectChanges(); - expect(spectator.queryAll(byTestId('pq-list-skeleton')).length).toBeGreaterThan(0); - }); - - it('shows empty state when not loading and zero rows', () => { - spectator.setInput('rows', []); - spectator.setInput('status', 'loaded'); - spectator.setInput('total', 0); - spectator.detectChanges(); - expect(spectator.query(byTestId('pq-list-empty'))).toBeTruthy(); - }); - - it('emits rowClick on row click', () => { - let emitted: PublishingJobView | undefined; - spectator.output('rowClick').subscribe((j) => (emitted = j as PublishingJobView)); - - const row = spectator.query(byTestId('pq-list-row')); - spectator.click(row as HTMLElement); - - expect(emitted?.bundleId).toBe('bundle-1'); - }); - - it('emits rowClick on Enter keydown', () => { - let emitted: PublishingJobView | undefined; - spectator.output('rowClick').subscribe((j) => (emitted = j as PublishingJobView)); - - const row = spectator.query(byTestId('pq-list-row')); - spectator.dispatchKeyboardEvent(row as HTMLElement, 'keydown', 'Enter'); - - expect(emitted?.bundleId).toBe('bundle-1'); - }); - - describe('status chip', () => { - it('renders the chip when row.status is set', () => { - spectator.setInput('mode', 'progress'); - spectator.setInput('rows', [job({ status: PublishAuditStatus.BUNDLING })]); - spectator.detectChanges(); - expect(spectator.query(byTestId('pq-row-status'))).toBeTruthy(); - }); - - it('skips the chip when row.status is null (drafts)', () => { - spectator.setInput('rows', [job({ status: null })]); - spectator.detectChanges(); - expect(spectator.query(byTestId('pq-row-status'))).toBeFalsy(); - }); - }); - - describe('pagination', () => { - it('shows paginator only when total exceeds page size', () => { - spectator.setInput('total', 5); - spectator.setInput('rowsPerPage', 10); - spectator.detectChanges(); - expect(spectator.query(byTestId('pq-list-paginator'))).toBeFalsy(); - - spectator.setInput('total', 30); - spectator.detectChanges(); - expect(spectator.query(byTestId('pq-list-paginator'))).toBeTruthy(); - }); - - it('emits pageChange with 1-based page when paginator fires', () => { - spectator.setInput('total', 100); - spectator.detectChanges(); - let emitted = 0; - spectator.output('pageChange').subscribe((p) => (emitted = p as number)); - - spectator.component.onPaginate({ first: 20, rows: 10, page: 2, pageCount: 10 }); - - expect(emitted).toBe(3); - }); - }); -}); diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-list/dot-publishing-queue-list.component.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-list/dot-publishing-queue-list.component.ts deleted file mode 100644 index 05b57f46f6ba..000000000000 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-list/dot-publishing-queue-list.component.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; - -import { MenuItem } from 'primeng/api'; -import { ButtonModule } from 'primeng/button'; -import { MenuModule } from 'primeng/menu'; -import { PaginatorModule, PaginatorState } from 'primeng/paginator'; -import { SkeletonModule } from 'primeng/skeleton'; - -import { PublishAuditStatus, PublishingJobView } from '@dotcms/dotcms-models'; -import { DotEmptyContainerComponent, DotMessagePipe, PrincipalConfiguration } from '@dotcms/ui'; - -import { DotPublishingStatusChipComponent } from '../components/dot-publishing-status-chip/dot-publishing-status-chip.component'; - -type LoadStatus = 'init' | 'loading' | 'loaded' | 'error'; -type Mode = 'ready' | 'progress'; - -const FAILURE_STATUSES = new Set([ - PublishAuditStatus.FAILED_TO_SEND_TO_ALL_GROUPS, - PublishAuditStatus.FAILED_TO_SEND_TO_SOME_GROUPS, - PublishAuditStatus.FAILED_TO_BUNDLE, - PublishAuditStatus.FAILED_TO_SENT, - PublishAuditStatus.FAILED_TO_PUBLISH, - PublishAuditStatus.FAILED_INTEGRITY_CHECK, - PublishAuditStatus.INVALID_TOKEN, - PublishAuditStatus.LICENSE_REQUIRED -]); - -@Component({ - selector: 'dot-publishing-queue-list', - standalone: true, - imports: [ - ButtonModule, - MenuModule, - PaginatorModule, - SkeletonModule, - DotEmptyContainerComponent, - DotMessagePipe, - DotPublishingStatusChipComponent - ], - templateUrl: './dot-publishing-queue-list.component.html', - changeDetection: ChangeDetectionStrategy.OnPush, - host: { class: 'flex flex-col min-h-0 h-full' } -}) -export class DotPublishingQueueListComponent { - readonly mode = input.required(); - readonly rows = input.required(); - readonly status = input.required(); - readonly total = input.required(); - readonly page = input.required(); - readonly rowsPerPage = input.required(); - readonly headerKey = input.required(); - readonly emptyConfig = input.required(); - /** Builder for the per-row kebab menu items. Only used in ready mode. */ - readonly kebabBuilder = input<(job: PublishingJobView) => MenuItem[] | null>(() => null); - - readonly rowClick = output(); - readonly sendClick = output(); - readonly retryClick = output(); - readonly pageChange = output(); - - readonly first = computed(() => (this.page() - 1) * this.rowsPerPage()); - - readonly skeletonRows = Array.from({ length: 5 }); - - /** - * Builds the kebab menu items once per row (keyed by bundleId) and returns - * the same array reference across change detection cycles. PrimeNG `p-menu` - * thrashes when `[model]` receives a brand-new array on every CD — the menu - * re-processes the items and the first click on an item only closes the menu - * instead of firing its `command`, forcing the user to click twice. - */ - private readonly kebabMenus = computed(() => { - const builder = this.kebabBuilder(); - const map = new Map(); - for (const job of this.rows()) { - const items = builder(job); - if (items) { - map.set(job.bundleId, items); - } - } - return map; - }); - - isRetryable(status: PublishAuditStatus | null): boolean { - return status !== null && FAILURE_STATUSES.has(status); - } - - kebabFor(job: PublishingJobView): MenuItem[] { - return this.kebabMenus().get(job.bundleId) ?? []; - } - - onRowKeyDown(event: KeyboardEvent, job: PublishingJobView): void { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - this.rowClick.emit(job); - } - } - - onPaginate(event: PaginatorState): void { - const newRows = event.rows ?? this.rowsPerPage(); - const newFirst = event.first ?? 0; - const newPage = Math.floor(newFirst / newRows) + 1; - this.pageChange.emit(newPage); - } -} diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/dot-publishing-queue-page.component.html b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/dot-publishing-queue-page.component.html deleted file mode 100644 index 2d67664201ee..000000000000 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/dot-publishing-queue-page.component.html +++ /dev/null @@ -1,30 +0,0 @@ -
- - -
- diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/dot-publishing-queue-page.component.spec.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/dot-publishing-queue-page.component.spec.ts deleted file mode 100644 index 3ad88ab1aead..000000000000 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/dot-publishing-queue-page.component.spec.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { byTestId, createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; - -import { signal } from '@angular/core'; - -import { ConfirmationService } from 'primeng/api'; - -/* eslint-disable @nx/enforce-module-boundaries */ - -import { DotMessageService } from '@dotcms/data-access'; -import { DotPushPublishDialogService } from '@dotcms/dotcms-js'; -import { PublishAuditStatus, PublishingJobView } from '@dotcms/dotcms-models'; -import { MockDotMessageService } from '@dotcms/utils-testing'; -import { DotDownloadBundleDialogService } from '@services/dot-download-bundle-dialog/dot-download-bundle-dialog.service'; - -import { DotPublishingQueuePageComponent } from './dot-publishing-queue-page.component'; -import { DotPublishingQueueStore } from './store/dot-publishing-queue.store'; - -const buildJob = (overrides: Partial = {}): PublishingJobView => ({ - bundleId: 'b1', - bundleName: 'Bundle 1', - status: PublishAuditStatus.WAITING_FOR_PUBLISHING, - filterName: null, - filterKey: null, - assetCount: 3, - assetPreview: [], - environmentCount: 1, - createDate: '2026-06-08T10:00:00Z', - statusUpdated: null, - numTries: 0, - ...overrides -}); - -describe('DotPublishingQueuePageComponent', () => { - let spectator: Spectator; - let store: InstanceType; - let pushPublishDialog: jest.Mocked; - let downloadBundleDialog: jest.Mocked; - - const readyRows = signal([buildJob()]); - const progressRows = signal([ - buildJob({ bundleId: 'p1', status: PublishAuditStatus.FAILED_TO_PUBLISH }) - ]); - - const createComponent = createComponentFactory({ - component: DotPublishingQueuePageComponent, - componentProviders: [ - mockProvider(DotPublishingQueueStore, { - readyRows, - progressRows, - readyStatus: jest.fn().mockReturnValue('loaded'), - progressStatus: jest.fn().mockReturnValue('loaded'), - readyTotal: jest.fn().mockReturnValue(1), - progressTotal: jest.fn().mockReturnValue(1), - readyPage: jest.fn().mockReturnValue(1), - progressPage: jest.fn().mockReturnValue(1), - rowsPerPage: jest.fn().mockReturnValue(10), - openAssetList: jest.fn(), - openDetail: jest.fn(), - retryBundles: jest.fn(), - deleteBundle: jest.fn(), - setReadyPage: jest.fn(), - setProgressPage: jest.fn() - }), - ConfirmationService - ], - providers: [ - mockProvider(DotPushPublishDialogService, { open: jest.fn() }), - mockProvider(DotDownloadBundleDialogService, { open: jest.fn() }), - { provide: DotMessageService, useValue: new MockDotMessageService({}) } - ] - }); - - beforeEach(() => { - readyRows.set([buildJob()]); - progressRows.set([ - buildJob({ bundleId: 'p1', status: PublishAuditStatus.FAILED_TO_PUBLISH }) - ]); - spectator = createComponent(); - store = spectator.inject(DotPublishingQueueStore, true); - pushPublishDialog = spectator.inject( - DotPushPublishDialogService - ) as jest.Mocked; - downloadBundleDialog = spectator.inject( - DotDownloadBundleDialogService - ) as jest.Mocked; - jest.clearAllMocks(); - }); - - it('renders both ready and progress list slots', () => { - expect(spectator.query(byTestId('pq-ready-list'))).toBeTruthy(); - expect(spectator.query(byTestId('pq-progress-list'))).toBeTruthy(); - }); - - it('ready row click opens the asset list', () => { - spectator.component.onRowClick(buildJob({ bundleId: 'B-X' }), 'ready'); - expect(store.openAssetList).toHaveBeenCalledWith('B-X'); - }); - - it('progress row click opens the detail dialog', () => { - spectator.component.onRowClick(buildJob({ bundleId: 'B-Y' }), 'progress'); - expect(store.openDetail).toHaveBeenCalledWith('B-Y'); - }); - - it('Send opens the project-wide push publish dialog with isBundle=true', () => { - const job = buildJob({ bundleId: 'B-Z', bundleName: 'Bundle Z' }); - spectator.component.onSend(job); - expect(pushPublishDialog.open).toHaveBeenCalledWith({ - assetIdentifier: 'B-Z', - title: 'Bundle Z', - isBundle: true - }); - }); - - it('Retry calls retryBundles with the single bundle id', () => { - const job = buildJob({ bundleId: 'B-R', status: PublishAuditStatus.FAILED_TO_PUBLISH }); - spectator.component.onRetry(job); - expect(store.retryBundles).toHaveBeenCalledWith({ bundleIds: ['B-R'] }); - }); - - it('builds 4 kebab items for a READY row (configure, generate, sep, remove)', () => { - const items = spectator.component.readyKebabFor(buildJob()); - expect(items.length).toBe(4); - expect(items[2].separator).toBe(true); - expect(items[3].styleClass).toContain('danger'); - }); - - it('kebab "Configure & send" item opens the project-wide push publish dialog', () => { - const job = buildJob({ bundleId: 'B-K', bundleName: 'Bundle K' }); - const items = spectator.component.readyKebabFor(job); - items[0].command?.({} as never); - expect(pushPublishDialog.open).toHaveBeenCalledWith({ - assetIdentifier: 'B-K', - title: 'Bundle K', - isBundle: true - }); - }); - - it('kebab "Generate / download" item opens the project-wide download bundle dialog', () => { - const job = buildJob({ bundleId: 'B-D' }); - const items = spectator.component.readyKebabFor(job); - items[1].command?.({} as never); - expect(downloadBundleDialog.open).toHaveBeenCalledWith('B-D'); - }); - - it('readyKebabFor returns a stable reference across calls (no .bind in template)', () => { - // Class arrow property → same reference forever. Critical for the list - // component's `kebabMenus` memoization to work. - expect(spectator.component.readyKebabFor).toBe(spectator.component.readyKebabFor); - }); -}); diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/dot-publishing-queue-page.component.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/dot-publishing-queue-page.component.ts deleted file mode 100644 index db6d1248779e..000000000000 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-page/dot-publishing-queue-page.component.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; - -import { ConfirmationService, MenuItem } from 'primeng/api'; -import { ConfirmDialogModule } from 'primeng/confirmdialog'; -import { MenuModule } from 'primeng/menu'; - -/* eslint-disable @nx/enforce-module-boundaries */ - -import { DotMessageService } from '@dotcms/data-access'; -import { DotPushPublishDialogService } from '@dotcms/dotcms-js'; -import { PublishingJobView } from '@dotcms/dotcms-models'; -import { PrincipalConfiguration } from '@dotcms/ui'; -import { DotDownloadBundleDialogService } from '@services/dot-download-bundle-dialog/dot-download-bundle-dialog.service'; - -import { DotPublishingQueueStore } from './store/dot-publishing-queue.store'; - -import { DotPublishingQueueListComponent } from '../dot-publishing-queue-list/dot-publishing-queue-list.component'; - -// `DotDownloadBundleDialogService` lives in apps/dotcms-ui (not yet promoted to a -// shared lib). Same pattern as `app.routes.ts` uses for `@portlets/*` imports. -// TODO: promote the service+component to `@dotcms/dotcms-js` (alongside -// DotPushPublishDialogService) — tracked as a follow-up to the v1 consolidation -// work (#36048). - -@Component({ - selector: 'dot-publishing-queue-page', - standalone: true, - imports: [ConfirmDialogModule, MenuModule, DotPublishingQueueListComponent], - providers: [ConfirmationService], - templateUrl: './dot-publishing-queue-page.component.html', - changeDetection: ChangeDetectionStrategy.OnPush, - host: { class: 'flex min-h-0 flex-1' } -}) -export class DotPublishingQueuePageComponent { - readonly store = inject(DotPublishingQueueStore); - - private readonly confirmationService = inject(ConfirmationService); - private readonly dotMessageService = inject(DotMessageService); - private readonly dotPushPublishDialogService = inject(DotPushPublishDialogService); - private readonly dotDownloadBundleDialogService = inject(DotDownloadBundleDialogService); - - readonly readyEmpty: PrincipalConfiguration = { - icon: 'pi-folder-open', - title: this.dotMessageService.get('publishing-queue.empty.ready.title'), - subtitle: this.dotMessageService.get('publishing-queue.empty.ready.subtitle') - }; - - readonly progressEmpty: PrincipalConfiguration = { - icon: 'pi-hourglass', - title: this.dotMessageService.get('publishing-queue.empty.in-progress.title'), - subtitle: this.dotMessageService.get('publishing-queue.empty.in-progress.subtitle') - }; - - /** - * Arrow property (not a method) so the function reference stays stable - * across change detection cycles. Passing `readyKebabFor.bind(this)` in the - * template would create a fresh function on every CD, which defeats the - * list component's `kebabMenus` memoization and triggers `` thrash - * (the first click on an item only closes the menu instead of firing). - */ - readonly readyKebabFor = (job: PublishingJobView): MenuItem[] => [ - { - label: this.dotMessageService.get('publishing-queue.kebab.configure-send'), - command: () => this.openPushPublish(job) - }, - { - label: this.dotMessageService.get('publishing-queue.kebab.generate-download'), - command: () => this.dotDownloadBundleDialogService.open(job.bundleId) - }, - { separator: true }, - { - label: this.dotMessageService.get('publishing-queue.kebab.remove'), - styleClass: 'p-menuitem-danger', - command: () => this.confirmRemove(job) - } - ]; - - onRowClick(row: PublishingJobView, mode: 'ready' | 'progress'): void { - if (mode === 'ready') { - this.store.openAssetList(row.bundleId); - } else { - this.store.openDetail(row.bundleId); - } - } - - onSend(job: PublishingJobView): void { - this.openPushPublish(job); - } - - onRetry(job: PublishingJobView): void { - this.store.retryBundles({ bundleIds: [job.bundleId] }); - } - - /** - * Opens the project-wide push publish dialog (the same one used by templates, - * containers, content types, pages, content). Triggered via the global singleton - * service — the dialog itself is mounted once in `main-legacy.component.html`. - * `isBundle: true` routes the submit to the bundle endpoint instead of asset. - */ - private openPushPublish(job: PublishingJobView): void { - this.dotPushPublishDialogService.open({ - assetIdentifier: job.bundleId, - title: job.bundleName || job.bundleId, - isBundle: true - }); - } - - private confirmRemove(job: PublishingJobView): void { - this.confirmationService.confirm({ - header: this.dotMessageService.get('publishing-queue.confirm-remove.header'), - message: this.dotMessageService.get( - 'publishing-queue.confirm-remove.message', - job.bundleName || job.bundleId - ), - acceptLabel: this.dotMessageService.get('publishing-queue.remove'), - rejectLabel: this.dotMessageService.get('publishing-queue.cancel'), - acceptButtonStyleClass: 'p-button-danger', - rejectButtonStyleClass: 'p-button-text', - defaultFocus: 'reject', - closable: true, - closeOnEscape: true, - accept: () => this.store.deleteBundle(job.bundleId) - }); - } -} diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.html b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.html index f5335d7d43d2..1ebb94d1a5fd 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.html +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.html @@ -1,30 +1,5 @@ - + - - - - {{ 'publishing-queue.tab.history' | dm }} - - - {{ 'publishing-queue.tab.queue' | dm }} - - - - - - - - - - - - + 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 1fb640ed799b..8d99e5b1a608 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,18 +9,18 @@ import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog'; /* eslint-disable @nx/enforce-module-boundaries */ import { - DotCurrentUserService, DotGlobalMessageService, DotHttpErrorManagerService, DotMessageService, DotPublishingQueueService } from '@dotcms/data-access'; +import { DotPushPublishDialogService } from '@dotcms/dotcms-js'; import { MockDotMessageService } from '@dotcms/utils-testing'; import { DotDownloadBundleDialogService } from '@services/dot-download-bundle-dialog/dot-download-bundle-dialog.service'; import { DotPublishingQueueShellComponent } from './dot-publishing-queue-shell.component'; -import { DotPublishingQueueStore } from '../dot-publishing-queue-page/store/dot-publishing-queue.store'; +import { DotPublishingQueueStore } from '../store/dot-publishing-queue.store'; describe('DotPublishingQueueShellComponent', () => { let spectator: Spectator; @@ -51,20 +51,13 @@ describe('DotPublishingQueueShellComponent', () => { pagination: { currentPage: 1, perPage: 10, totalEntries: 0 } }) ), - getUnsendBundles: jest - .fn() - .mockReturnValue( - of({ identifier: 'id', label: 'name', items: [], numRows: 0 }) - ), getBundleAssets: jest.fn().mockReturnValue(of([])), getPublishingJobDetails: jest.fn().mockReturnValue(of({})) }), - mockProvider(DotCurrentUserService, { - getCurrentUser: jest.fn().mockReturnValue(of({ userId: 'dotcms.org.1' })) - }), mockProvider(DotHttpErrorManagerService), - mockProvider(DotDownloadBundleDialogService, { open: jest.fn() }), mockProvider(DotGlobalMessageService, { error: jest.fn() }), + mockProvider(DotPushPublishDialogService, { open: jest.fn() }), + mockProvider(DotDownloadBundleDialogService, { open: jest.fn() }), { provide: DotMessageService, useValue: new MockDotMessageService({}) } ], schemas: [CUSTOM_ELEMENTS_SCHEMA, NO_ERRORS_SCHEMA] @@ -87,6 +80,11 @@ describe('DotPublishingQueueShellComponent', () => { expect(spectator.query('dot-publishing-queue-toolbar')).toBeTruthy(); }); + it('renders the single bundles table (no tabs)', () => { + expect(spectator.query('dot-publishing-queue-table')).toBeTruthy(); + expect(spectator.query('p-tabs')).toBeFalsy(); + }); + describe('asset list dialog sync', () => { it('opens dialog when selectedBundleId becomes set', () => { store.openAssetList('B-1'); @@ -117,15 +115,6 @@ describe('DotPublishingQueueShellComponent', () => { }); }); - describe('tab change', () => { - it('forwards value to setActiveTab', () => { - spectator.component.onTabChange('history'); - expect(store.activeTab()).toBe('history'); - spectator.component.onTabChange('queue'); - expect(store.activeTab()).toBe('queue'); - }); - }); - describe('delete bundles dialog', () => { function openAndCloseWith(scope: 'selected' | 'all' | 'success' | 'failed' | undefined) { spectator.component.openDeleteBundles(); @@ -148,7 +137,7 @@ describe('DotPublishingQueueShellComponent', () => { }); it('SELECTED → store.deleteBundlesBulk with current selected ids', () => { - store.setHistorySelection(['b1', 'b2']); + store.setBundlesSelection(['b1', 'b2']); const spy = jest.spyOn(store, 'deleteBundlesBulk').mockReturnValue(undefined); openAndCloseWith('selected'); expect(spy).toHaveBeenCalledWith(['b1', 'b2']); diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.ts index ba9a01879ca6..726d41e67dfd 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.ts @@ -3,12 +3,11 @@ import { ChangeDetectionStrategy, Component, effect, inject, untracked } from '@ import { ConfirmationService } from 'primeng/api'; import { ConfirmDialogModule } from 'primeng/confirmdialog'; import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog'; -import { TabsModule } from 'primeng/tabs'; import { take } from 'rxjs/operators'; import { DotMessageService } from '@dotcms/data-access'; -import { DotMessagePipe } from '@dotcms/ui'; +import { PublishAuditStatus } from '@dotcms/dotcms-models'; import { DotPublishingQueueToolbarComponent } from '../components/dot-publishing-queue-toolbar/dot-publishing-queue-toolbar.component'; import { DotPublishingQueueAssetListDialogComponent } from '../dialogs/dot-publishing-queue-asset-list-dialog/dot-publishing-queue-asset-list-dialog.component'; @@ -18,24 +17,29 @@ import { DotPublishingQueueDeleteDialogComponent } from '../dialogs/dot-publishing-queue-delete-dialog/dot-publishing-queue-delete-dialog.component'; import { DotPublishingQueueUploadDialogComponent } from '../dialogs/dot-publishing-queue-upload-dialog/dot-publishing-queue-upload-dialog.component'; -import { DotPublishingQueueHistoryComponent } from '../dot-publishing-queue-history/dot-publishing-queue-history.component'; -import { DotPublishingQueuePageComponent } from '../dot-publishing-queue-page/dot-publishing-queue-page.component'; +import { DotPublishingQueueTableComponent } from '../dot-publishing-queue-table/dot-publishing-queue-table.component'; import { DotPublishingQueueStore, PURGE_FAILED_STATUSES, PURGE_SUCCESS_STATUSES -} from '../dot-publishing-queue-page/store/dot-publishing-queue.store'; +} from '../store/dot-publishing-queue.store'; + +/** Statuses for which the bundle hasn't yet been packed — assets can still be + * edited from the asset list dialog. Anything else is read-only (already in + * `publish_audit`). */ +const EDITABLE_ASSET_STATUSES = new Set([ + null, + PublishAuditStatus.BUNDLE_REQUESTED, + PublishAuditStatus.WAITING_FOR_PUBLISHING +]); @Component({ selector: 'dot-publishing-queue-shell', standalone: true, imports: [ ConfirmDialogModule, - TabsModule, DotPublishingQueueToolbarComponent, - DotPublishingQueuePageComponent, - DotPublishingQueueHistoryComponent, - DotMessagePipe + DotPublishingQueueTableComponent ], providers: [DotPublishingQueueStore, DialogService, ConfirmationService], templateUrl: './dot-publishing-queue-shell.component.html', @@ -54,13 +58,6 @@ export class DotPublishingQueueShellComponent { private assetListRef: DynamicDialogRef | null = null; private deleteRef: DynamicDialogRef | null = null; - readonly TABS = ['queue', 'history'] as const; - - /** Zero-out PrimeNG's default tabpanel padding so portlet content goes flush - * edge-to-edge, matching the dot-tags / dot-query-tool layout. */ - readonly tabPanelsPt = { root: { class: 'flex-1 min-h-0 p-0!' } }; - readonly tabPanelPt = { root: { class: 'h-full p-0! flex flex-col min-h-0' } }; - constructor() { effect(() => { const bundleId = this.store.selectedBundleId(); @@ -73,10 +70,6 @@ export class DotPublishingQueueShellComponent { }); } - onTabChange(value: string | number): void { - this.store.setActiveTab(value === 'history' ? 'history' : 'queue'); - } - openUpload(): void { if (this.uploadRef) { return; @@ -110,29 +103,27 @@ export class DotPublishingQueueShellComponent { draggable: false, position: 'center' }); - this.deleteRef.onClose - .pipe(take(1)) - .subscribe((scope: DeleteBundlesScope | undefined) => { - this.deleteRef = null; - if (scope) { - this.dispatchDelete(scope); - } - }); + this.deleteRef.onClose.pipe(take(1)).subscribe((scope: DeleteBundlesScope | undefined) => { + this.deleteRef = null; + if (scope) { + this.dispatchDelete(scope); + } + }); } private dispatchDelete(scope: DeleteBundlesScope): void { switch (scope) { case 'selected': - this.store.deleteBundlesBulk(this.store.historySelectedIds()); + this.store.deleteBundlesBulk(this.store.bundlesSelectedIds()); break; case 'all': this.confirmationService.confirm({ header: this.dotMessageService.get('publishing-queue.delete.confirm.header'), message: this.dotMessageService.get('bundle.delete.all.confirmation'), - acceptLabel: this.dotMessageService.get('publishing-queue.history.kebab.delete'), + acceptLabel: this.dotMessageService.get( + 'publishing-queue.history.kebab.delete' + ), rejectLabel: this.dotMessageService.get('publishing-queue.cancel'), - // Delete = primary, Cancel = tertiary (text) — consistent - // with the per-row delete confirm in history. acceptButtonStyleClass: 'p-button-primary', rejectButtonStyleClass: 'p-button-text', defaultFocus: 'reject', @@ -152,10 +143,12 @@ export class DotPublishingQueueShellComponent { private syncAssetList(bundleId: string | null): void { if (bundleId && !this.assetListRef) { - // History bundles are already in `publish_audit` — assets are - // read-only there. Only the Queue tab (drafts/in-progress) allows - // removal. - const allowRemove = this.store.activeTab() === 'queue'; + // Editable only when the bundle hasn't started packing yet + // (BUNDLE_REQUESTED / WAITING_FOR_PUBLISHING / drafts with null status). + // Once the bundle is in motion or terminal, the asset list is read-only. + const row = this.store.bundlesRows().find((r) => r.bundleId === bundleId); + const allowRemove = EDITABLE_ASSET_STATUSES.has(row?.status ?? null); + this.assetListRef = this.dialogService.open( DotPublishingQueueAssetListDialogComponent, { diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.html b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-table/dot-publishing-queue-table.component.html similarity index 85% rename from core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.html rename to core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-table/dot-publishing-queue-table.component.html index e79309d2ee16..54e590bea9d3 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-history/dot-publishing-queue-history.component.html +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-table/dot-publishing-queue-table.component.html @@ -1,12 +1,12 @@ -
+
+ data-testid="pq-bundles-table"> - + {{ 'publishing-queue.column.bundle-name' | dm }} - + {{ 'publishing-queue.column.bundle-id' | dm }} - + {{ 'publishing-queue.column.filter' | dm }} + data-testid="pq-bundles-col-status"> {{ 'publishing-queue.column.status' | dm }} @@ -43,7 +43,7 @@ pSortableColumn="created" style="width: 11rem" class="whitespace-nowrap" - data-testid="pq-history-col-created"> + data-testid="pq-bundles-col-created"> {{ 'publishing-queue.column.data-entered' | dm }} @@ -51,18 +51,18 @@ pSortableColumn="modified" style="width: 11rem" class="whitespace-nowrap" - data-testid="pq-history-col-modified"> + data-testid="pq-bundles-col-modified"> {{ 'publishing-queue.column.last-update' | dm }} - + - @if (store.historyStatus() === 'loading') { + @if (store.bundlesStatus() === 'loading') { @@ -109,16 +109,16 @@ + data-testid="pq-bundles-row"> - +
{{ row.bundleName || '—' }}
- + +
+
+ + + + +
+ +
+ + + + + + + + {{ 'publishing-queue.select-bundle.bundles' | dm }} + + + + + @if (bundlesStatus() === 'loading') { + + + + + + + + } @else { + + + + + +
+
+
+ {{ bundle.name }} +
+
+ + {{ bundle.id }} + + +
+
+ +
+ + + } +
+ + + + + {{ 'publishing-queue.select-bundle.empty' | dm }} + + + + +
+
+ +
+ + {{ 'publishing-queue.select-bundle.page' | dm }} {{ bundlesPage() }} + + + +
+
+ + +
+
+ + + + + {{ 'publishing-queue.select-bundle.col.name' | dm }} + + + {{ 'publishing-queue.select-bundle.col.type' | dm }} + + + + + + @if (assetsStatus() === 'loading') { + + + + + + } @else { + + +
+ + @if (editUrlFor(asset); as editUrl) { + + {{ asset.title || asset.asset }} + + } @else { + + {{ asset.title || asset.asset }} + + } +
+ + + + + + + + + } +
+ + + + + @if (activeBundleId()) { + {{ 'publishing-queue.select-bundle.asset-empty' | dm }} + } @else { + {{ 'publishing-queue.select-bundle.no-active' | dm }} + } + + + + +
+
+ +
+ + {{ 'publishing-queue.select-bundle.page' | dm }} {{ assetsPage() }} + + + +
+
+
+ + +
+ + + +
+ + +
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.spec.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.spec.ts new file mode 100644 index 000000000000..2062b402adc7 --- /dev/null +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-select-bundle-dialog/dot-publishing-queue-select-bundle-dialog.component.spec.ts @@ -0,0 +1,268 @@ +import { byTestId, createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; +import { of, throwError } from 'rxjs'; + +import { CUSTOM_ELEMENTS_SCHEMA, NO_ERRORS_SCHEMA } from '@angular/core'; + +import { ConfirmationService } from 'primeng/api'; + +/* eslint-disable @nx/enforce-module-boundaries */ + +import { + DotContentTypeService, + DotCurrentUserService, + DotHttpErrorManagerService, + DotMessageService, + DotPublishingQueueService +} from '@dotcms/data-access'; +import { DotPushPublishDialogService } from '@dotcms/dotcms-js'; +import { MockDotMessageService } from '@dotcms/utils-testing'; +import { DotDownloadBundleDialogService } from '@services/dot-download-bundle-dialog/dot-download-bundle-dialog.service'; + +import { DotPublishingQueueSelectBundleDialogComponent } from './dot-publishing-queue-select-bundle-dialog.component'; + +const UNSENT_RESPONSE = { + identifier: 'id', + label: 'name', + items: [ + { id: 'bundle-1', name: 'Spring campaign refresh' }, + { id: 'bundle-2', name: 'Blog content sync' } + ], + numRows: 2 +}; + +const MOCK_ASSETS = [ + { asset: 'a1', title: 'Spring Sale Landing', type: 'contentlet' }, + { asset: 'a2', title: 'hero-spring.jpg', type: 'contentlet' } +]; + +describe('DotPublishingQueueSelectBundleDialogComponent', () => { + let spectator: Spectator; + let service: jest.Mocked; + let confirmationService: jest.Mocked; + let pushPublishService: jest.Mocked; + let downloadService: jest.Mocked; + + const createComponent = createComponentFactory({ + component: DotPublishingQueueSelectBundleDialogComponent, + providers: [ + mockProvider(DotPublishingQueueService, { + getUnsendBundles: jest.fn().mockReturnValue(of(UNSENT_RESPONSE)), + getBundleAssets: jest.fn().mockReturnValue(of(MOCK_ASSETS)), + removeAssetsFromBundle: jest + .fn() + .mockReturnValue(of([{ assetId: 'a1', success: true, message: 'ok' }])), + deleteBundles: jest.fn().mockReturnValue(of({ entity: 'ok' })) + }), + mockProvider(DotCurrentUserService, { + getCurrentUser: jest + .fn() + .mockReturnValue(of({ userId: 'dotcms.org.1', email: 'admin@dotcms.com' })) + }), + mockProvider(DotHttpErrorManagerService), + mockProvider(DotPushPublishDialogService, { open: jest.fn() }), + mockProvider(DotDownloadBundleDialogService, { open: jest.fn() }), + mockProvider(DotContentTypeService, { + getContentType: jest.fn().mockReturnValue(of({})) + }), + { provide: DotMessageService, useValue: new MockDotMessageService({}) } + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA, NO_ERRORS_SCHEMA] + }); + + beforeEach(() => { + spectator = createComponent(); + service = spectator.inject( + DotPublishingQueueService + ) as jest.Mocked; + pushPublishService = spectator.inject( + DotPushPublishDialogService + ) as jest.Mocked; + downloadService = spectator.inject( + DotDownloadBundleDialogService + ) as jest.Mocked; + confirmationService = spectator.inject( + ConfirmationService, + true + ) as jest.Mocked; + jest.spyOn(confirmationService, 'confirm').mockImplementation((cfg) => { + cfg.accept?.(); + return confirmationService; + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('init', () => { + it('fetches drafts via getUnsendBundles and renders both bundle rows', () => { + spectator.detectChanges(); + expect(service.getUnsendBundles).toHaveBeenCalledWith( + 'dotcms.org.1', + '*', + 0, + expect.any(Number) + ); + expect(spectator.component.bundles().length).toBe(2); + }); + + it('auto-selects the first bundle and loads its assets', () => { + spectator.detectChanges(); + expect(spectator.component.activeBundleId()).toBe('bundle-1'); + expect(service.getBundleAssets).toHaveBeenCalledWith('bundle-1'); + expect(spectator.component.assets().length).toBe(2); + }); + }); + + describe('select bundle', () => { + it('clicking a different bundle loads its assets', () => { + spectator.detectChanges(); + (service.getBundleAssets as jest.Mock).mockClear(); + + spectator.component.onSelectBundle({ id: 'bundle-2', name: 'Blog content sync' }); + expect(spectator.component.activeBundleId()).toBe('bundle-2'); + expect(service.getBundleAssets).toHaveBeenCalledWith('bundle-2'); + }); + + it('clicking the already-active bundle is a no-op (no extra fetch)', () => { + spectator.detectChanges(); + (service.getBundleAssets as jest.Mock).mockClear(); + + spectator.component.onSelectBundle({ id: 'bundle-1', name: 'Spring campaign refresh' }); + expect(service.getBundleAssets).not.toHaveBeenCalled(); + }); + }); + + describe('type icon', () => { + it('maps known asset types to icons', () => { + expect(spectator.component.typeIcon('contentlet')).toBe('pi pi-file'); + expect(spectator.component.typeIcon('template')).toBe('pi pi-window-maximize'); + }); + + it('falls back to a generic icon for unknown types', () => { + expect(spectator.component.typeIcon('weird-type')).toBe('pi pi-file'); + }); + }); + + describe('remove asset', () => { + it('confirms then calls removeAssetsFromBundle and refetches', () => { + spectator.detectChanges(); + (service.getBundleAssets as jest.Mock).mockClear(); + + spectator.component.onRemoveAsset({ + asset: 'a1', + title: 'Spring Sale Landing', + type: 'contentlet' + }); + + expect(confirmationService.confirm).toHaveBeenCalled(); + expect(service.removeAssetsFromBundle).toHaveBeenCalledWith('bundle-1', ['a1']); + expect(service.getBundleAssets).toHaveBeenCalledWith('bundle-1'); + }); + + it('is a no-op when no active bundle', () => { + spectator.detectChanges(); + spectator.component.activeBundleId.set(null); + (service.removeAssetsFromBundle as jest.Mock).mockClear(); + spectator.component.onRemoveAsset({ + asset: 'a1', + title: 'x', + type: 'contentlet' + }); + expect(service.removeAssetsFromBundle).not.toHaveBeenCalled(); + }); + + it('on service error: hands off to httpErrorManager', () => { + spectator.detectChanges(); + const error = new Error('boom'); + (service.removeAssetsFromBundle as jest.Mock).mockReturnValueOnce( + throwError(() => error) + ); + const handler = spectator.inject( + DotHttpErrorManagerService + ) as jest.Mocked; + spectator.component.onRemoveAsset({ + asset: 'a1', + title: 'x', + type: 'contentlet' + }); + expect(handler.handle).toHaveBeenCalledWith(error); + }); + }); + + describe('remove bundles (bulk)', () => { + it('confirms then calls deleteBundles with the checked ids; auto-selects next bundle if active was deleted', () => { + spectator.detectChanges(); + spectator.component.onCheckedChange([ + { id: 'bundle-1', name: 'Spring campaign refresh' } + ]); + // After delete, the next list call returns only the remaining bundle. + (service.getUnsendBundles as jest.Mock).mockReturnValueOnce( + of({ + identifier: 'id', + label: 'name', + items: [{ id: 'bundle-2', name: 'Blog content sync' }], + numRows: 1 + }) + ); + + spectator.component.onRemoveBundles(); + + expect(confirmationService.confirm).toHaveBeenCalled(); + expect(service.deleteBundles).toHaveBeenCalledWith(['bundle-1']); + // Active flips off the deleted bundle and re-selects the next remaining one. + expect(spectator.component.activeBundleId()).toBe('bundle-2'); + }); + + it('is a no-op when nothing is checked', () => { + spectator.detectChanges(); + (service.deleteBundles as jest.Mock).mockClear(); + spectator.component.onRemoveBundles(); + expect(service.deleteBundles).not.toHaveBeenCalled(); + }); + }); + + describe('configure / download', () => { + it('Configure → opens push publish dialog for the active bundle', () => { + spectator.detectChanges(); + spectator.component.onConfigureActive(); + expect(pushPublishService.open).toHaveBeenCalledWith( + expect.objectContaining({ + assetIdentifier: 'bundle-1', + isBundle: true + }) + ); + }); + + it('Download → opens download dialog for the active bundle', () => { + spectator.detectChanges(); + spectator.component.onDownloadActive(); + expect(downloadService.open).toHaveBeenCalledWith('bundle-1'); + }); + + it('Configure / Download are no-ops when no active bundle', () => { + spectator.detectChanges(); + spectator.component.activeBundleId.set(null); + (pushPublishService.open as jest.Mock).mockClear(); + (downloadService.open as jest.Mock).mockClear(); + spectator.component.onConfigureActive(); + spectator.component.onDownloadActive(); + expect(pushPublishService.open).not.toHaveBeenCalled(); + expect(downloadService.open).not.toHaveBeenCalled(); + }); + }); + + describe('layout', () => { + it('renders the two panes + action bar', () => { + spectator.detectChanges(); + expect(spectator.query(byTestId('pq-select-bundle-left'))).toBeTruthy(); + expect(spectator.query(byTestId('pq-select-bundle-right'))).toBeTruthy(); + expect(spectator.query(byTestId('pq-select-bundle-actions'))).toBeTruthy(); + }); + + it('renders bundle rows', () => { + spectator.detectChanges(); + expect(spectator.queryAll(byTestId('pq-select-bundle-row')).length).toBe(2); + }); + }); +}); 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 new file mode 100644 index 000000000000..ada213e3a340 --- /dev/null +++ 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 @@ -0,0 +1,434 @@ +import { EMPTY, Subject } from 'rxjs'; + +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + OnInit, + computed, + inject, + signal +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormsModule } from '@angular/forms'; + +import { ConfirmationService } from 'primeng/api'; +import { ButtonModule } from 'primeng/button'; +import { ConfirmDialogModule } from 'primeng/confirmdialog'; +import { IconFieldModule } from 'primeng/iconfield'; +import { InputIconModule } from 'primeng/inputicon'; +import { InputTextModule } from 'primeng/inputtext'; +import { SkeletonModule } from 'primeng/skeleton'; +import { TableModule } from 'primeng/table'; +import { TagModule } from 'primeng/tag'; +import { TooltipModule } from 'primeng/tooltip'; + +import { catchError, debounceTime, distinctUntilChanged, finalize, take } from 'rxjs/operators'; + +/* eslint-disable @nx/enforce-module-boundaries */ +// `DotDownloadBundleDialogService` lives in apps/dotcms-ui (not yet promoted to +// a shared lib). Same pattern as `dot-publishing-queue-table`. Tracked +// alongside the v1 consolidation work (#36048). + +import { + DotContentletEditUrlService, + DotCurrentUserService, + DotHttpErrorManagerService, + DotMessageService, + DotPublishingQueueService +} from '@dotcms/data-access'; +import { DotPushPublishDialogService } from '@dotcms/dotcms-js'; +import { BundleAssetView, DotCMSContentlet } from '@dotcms/dotcms-models'; +import { DotCopyButtonComponent, DotMessagePipe } from '@dotcms/ui'; +import { DotDownloadBundleDialogService } from '@services/dot-download-bundle-dialog/dot-download-bundle-dialog.service'; + +type LoadStatus = 'init' | 'loading' | 'loaded' | 'error'; + +interface BundleRow { + id: string; + name: string; +} + +/** Map asset `type` string (lowercase, comes from `PusheableAsset.getType()`) + * to a PrimeIcon. Unknown types fall back to a generic file icon. */ +const TYPE_ICONS: Record = { + contentlet: 'pi pi-file', + contenttype: 'pi pi-box', + template: 'pi pi-window-maximize', + containers: 'pi pi-th-large', + folder: 'pi pi-folder', + host: 'pi pi-globe', + category: 'pi pi-tag', + links: 'pi pi-link', + workflow: 'pi pi-cog', + language: 'pi pi-language', + rule: 'pi pi-shield', + user: 'pi pi-user', + osgi: 'pi pi-box', + relationship: 'pi pi-share-alt', + experiment: 'pi pi-chart-bar', + variant: 'pi pi-clone' +}; + +const BUNDLES_PER_PAGE = 10; +const ASSETS_PER_PAGE = 10; + +/** + * Two-pane dialog: drafts on the left, the active draft's assets on the right. + * Used by the "Add Bundle → Select Bundle" entry point in the toolbar. + * + * Self-contained state — does NOT mutate the unified bundles table store. + * The "Configure" and "Download" actions delegate to the project-wide dialogs + * (same path as the table row kebab in `dot-publishing-queue-table`). + */ +@Component({ + selector: 'dot-publishing-queue-select-bundle-dialog', + standalone: true, + imports: [ + FormsModule, + ButtonModule, + ConfirmDialogModule, + IconFieldModule, + InputIconModule, + InputTextModule, + SkeletonModule, + TableModule, + TagModule, + TooltipModule, + DotCopyButtonComponent, + DotMessagePipe + ], + providers: [ConfirmationService], + templateUrl: './dot-publishing-queue-select-bundle-dialog.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'flex h-full min-h-0 flex-col' } +}) +export class DotPublishingQueueSelectBundleDialogComponent implements OnInit { + private readonly publishingService = inject(DotPublishingQueueService); + private readonly currentUserService = inject(DotCurrentUserService); + private readonly httpErrorManager = inject(DotHttpErrorManagerService); + private readonly confirmationService = inject(ConfirmationService); + private readonly dotMessageService = inject(DotMessageService); + private readonly destroyRef = inject(DestroyRef); + private readonly pushPublishService = inject(DotPushPublishDialogService); + private readonly downloadService = inject(DotDownloadBundleDialogService); + private readonly editUrlService = inject(DotContentletEditUrlService); + + private userId: string | null = null; + + readonly bundles = signal([]); + readonly bundlesTotal = signal(0); + readonly bundlesStatus = signal('init'); + readonly bundlesPage = signal(1); + readonly bundleSearch = signal(''); + /** Multi-select for bulk operations (Remove). Independent of `activeBundleId`. */ + readonly checkedBundleIds = signal([]); + /** Single "active" bundle whose assets are shown on the right pane. */ + readonly activeBundleId = signal(null); + + readonly assets = signal([]); + readonly assetsStatus = signal('init'); + readonly assetsPage = signal(1); + /** Per-asset edit URLs resolved by `DotContentletEditUrlService` after each + * asset load. Only contentlet rows get an entry — non-contentlet types are + * rendered as plain text. Resolution is async (one metadata fetch per + * content type, cached app-wide by the service). */ + readonly assetEditUrls = signal>(new Map()); + + readonly bundlesPerPage = BUNDLES_PER_PAGE; + readonly assetsPerPage = ASSETS_PER_PAGE; + + readonly bundlesSkeleton = Array.from({ length: 6 }); + readonly assetsSkeleton = Array.from({ length: 6 }); + + /** `table-layout: fixed` + `width: 100%` so column widths are driven by the + * ``/header widths instead of by cell content. Without this, a long + * bundle name or asset title would push the table past the pane width and + * trigger horizontal scroll. */ + readonly tableStyleFixed = { 'table-layout': 'fixed' as const, width: '100%' }; + + readonly activeBundle = computed(() => { + const id = this.activeBundleId(); + return id ? (this.bundles().find((b) => b.id === id) ?? null) : null; + }); + + readonly hasChecked = computed(() => this.checkedBundleIds().length > 0); + readonly hasActive = computed(() => this.activeBundleId() !== null); + + /** Bundle rows currently selected via checkbox. p-table's `[selection]` + * binding wants the row objects (not just ids), so we re-derive them from + * the visible bundle list each CD. */ + readonly checkedBundles = computed(() => { + const ids = new Set(this.checkedBundleIds()); + return this.bundles().filter((b) => ids.has(b.id)); + }); + + readonly pagedAssets = computed(() => { + const all = this.assets(); + const page = this.assetsPage(); + const start = (page - 1) * ASSETS_PER_PAGE; + return all.slice(start, start + ASSETS_PER_PAGE); + }); + + readonly assetsTotal = computed(() => this.assets().length); + + private readonly bundleSearchSubject = new Subject(); + + ngOnInit(): void { + this.bundleSearchSubject + .pipe(debounceTime(300), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) + .subscribe((value) => { + this.bundleSearch.set(value); + this.bundlesPage.set(1); + this.loadBundles(); + }); + + this.currentUserService + .getCurrentUser() + .pipe(take(1)) + .subscribe((user) => { + this.userId = user.userId; + this.loadBundles(); + }); + } + + onBundleSearch(value: string): void { + this.bundleSearchSubject.next(value); + } + + onBundlesPagePrev(): void { + if (this.bundlesPage() > 1) { + this.bundlesPage.update((p) => p - 1); + this.loadBundles(); + } + } + + onBundlesPageNext(): void { + const maxPage = Math.max(1, Math.ceil(this.bundlesTotal() / BUNDLES_PER_PAGE)); + if (this.bundlesPage() < maxPage) { + this.bundlesPage.update((p) => p + 1); + this.loadBundles(); + } + } + + onAssetsPagePrev(): void { + if (this.assetsPage() > 1) { + this.assetsPage.update((p) => p - 1); + } + } + + onAssetsPageNext(): void { + const maxPage = Math.max(1, Math.ceil(this.assetsTotal() / ASSETS_PER_PAGE)); + if (this.assetsPage() < maxPage) { + this.assetsPage.update((p) => p + 1); + } + } + + onSelectBundle(bundle: BundleRow): void { + if (this.activeBundleId() === bundle.id) { + return; + } + this.activeBundleId.set(bundle.id); + this.assetsPage.set(1); + this.loadAssets(bundle.id); + } + + onCheckedChange(ids: BundleRow[]): void { + this.checkedBundleIds.set(ids.map((b) => b.id)); + } + + typeIcon(type: string): string { + return TYPE_ICONS[(type ?? '').toLowerCase()] ?? 'pi pi-file'; + } + + onRemoveAsset(asset: BundleAssetView): void { + const bundleId = this.activeBundleId(); + if (!bundleId) { + return; + } + this.confirmationService.confirm({ + header: this.dotMessageService.get('publishing-queue.asset-list.remove-confirm.header'), + message: this.dotMessageService.get( + 'publishing-queue.asset-list.remove-confirm.message', + asset.title || asset.asset + ), + acceptLabel: this.dotMessageService.get('publishing-queue.remove'), + rejectLabel: this.dotMessageService.get('publishing-queue.cancel'), + acceptButtonStyleClass: 'p-button-danger', + rejectButtonStyleClass: 'p-button-text', + defaultFocus: 'reject', + closable: true, + closeOnEscape: true, + accept: () => { + this.publishingService + .removeAssetsFromBundle(bundleId, [asset.asset]) + .pipe( + take(1), + catchError((error) => { + this.httpErrorManager.handle(error); + return EMPTY; + }) + ) + .subscribe(() => this.loadAssets(bundleId)); + } + }); + } + + onRemoveBundles(): void { + const ids = this.checkedBundleIds(); + if (ids.length === 0) { + return; + } + this.confirmationService.confirm({ + header: this.dotMessageService.get('publishing-queue.delete.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'), + rejectLabel: this.dotMessageService.get('publishing-queue.cancel'), + acceptButtonStyleClass: 'p-button-primary', + rejectButtonStyleClass: 'p-button-text', + defaultFocus: 'reject', + closable: true, + closeOnEscape: true, + accept: () => { + this.publishingService + .deleteBundles(ids) + .pipe( + take(1), + catchError((error) => { + this.httpErrorManager.handle(error); + return EMPTY; + }) + ) + .subscribe(() => { + this.checkedBundleIds.set([]); + // If the active bundle was deleted, clear the right pane. + if (this.activeBundleId() && ids.includes(this.activeBundleId() ?? '')) { + this.activeBundleId.set(null); + this.assets.set([]); + } + this.loadBundles(); + }); + } + }); + } + + onDownloadActive(): void { + const id = this.activeBundleId(); + if (id) { + this.downloadService.open(id); + } + } + + onConfigureActive(): void { + const bundle = this.activeBundle(); + if (!bundle) { + return; + } + this.pushPublishService.open({ + assetIdentifier: bundle.id, + title: bundle.name || bundle.id, + isBundle: true + }); + } + + private loadBundles(): void { + if (!this.userId) { + return; + } + this.bundlesStatus.set('loading'); + const start = (this.bundlesPage() - 1) * BUNDLES_PER_PAGE; + const search = this.bundleSearch().trim(); + const filter = search ? `*${search}*` : '*'; + + this.publishingService + .getUnsendBundles(this.userId, filter, start, BUNDLES_PER_PAGE) + .pipe( + take(1), + catchError((error) => { + this.httpErrorManager.handle(error); + this.bundlesStatus.set('error'); + return EMPTY; + }) + ) + .subscribe((response) => { + this.bundles.set(response.items.map((item) => ({ id: item.id, name: item.name }))); + this.bundlesTotal.set(response.numRows ?? response.items.length); + this.bundlesStatus.set('loaded'); + // Auto-select the first bundle on initial load so the right pane + // isn't empty by default (matches the design's "first row active"). + if (!this.activeBundleId() && response.items.length > 0) { + this.onSelectBundle({ + id: response.items[0].id, + name: response.items[0].name + }); + } + }); + } + + private loadAssets(bundleId: string): void { + this.assetsStatus.set('loading'); + this.assetEditUrls.set(new Map()); + this.publishingService + .getBundleAssets(bundleId) + .pipe( + take(1), + catchError((error) => { + this.httpErrorManager.handle(error); + this.assetsStatus.set('error'); + return EMPTY; + }), + finalize(() => { + if (this.assetsStatus() === 'loading') { + this.assetsStatus.set('loaded'); + } + }) + ) + .subscribe((assets) => { + this.assets.set(assets); + this.assetsStatus.set('loaded'); + this.resolveAssetEditUrls(assets); + }); + } + + /** Resolves the edit URL for each contentlet asset in parallel. Non-contentlet + * types (templates, languages, containers, etc.) are skipped — the row renders + * as plain text. + * + * The service caches by content type, so 50 contentlets of the same type + * trigger one metadata fetch. `baseType` is not available from + * `/api/bundle/{id}/assets`, so HTML pages won't get the dedicated page editor + * route — they'll get the contentlet editor URL instead (acceptable; the + * user can navigate to the visual editor from there). + */ + private resolveAssetEditUrls(assets: BundleAssetView[]): void { + const urls = new Map(); + + for (const asset of assets) { + if (asset.type !== 'contentlet' || !asset.inode) { + continue; + } + const partial = { + inode: asset.inode, + contentType: asset.content_type_name ?? '' + } as DotCMSContentlet; + + this.editUrlService + .resolveEditUrl(partial) + .pipe(take(1)) + .subscribe((url) => { + urls.set(asset.asset, url); + // Re-emit to trigger CD — Map mutation alone won't notify signal consumers + this.assetEditUrls.set(new Map(urls)); + }); + } + } + + /** Template helper. Returns the edit URL for an asset if it's a linkable type + * and the URL has been resolved, otherwise `null` (render as plain text). */ + editUrlFor(asset: BundleAssetView): string | null { + return this.assetEditUrls().get(asset.asset) ?? null; + } +} diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.html b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.html index 1ebb94d1a5fd..3df91617ad33 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.html +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.html @@ -1,4 +1,7 @@ - + diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.ts index 726d41e67dfd..d4aa509829b2 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.ts @@ -16,6 +16,7 @@ import { DeleteBundlesScope, DotPublishingQueueDeleteDialogComponent } from '../dialogs/dot-publishing-queue-delete-dialog/dot-publishing-queue-delete-dialog.component'; +import { DotPublishingQueueSelectBundleDialogComponent } from '../dialogs/dot-publishing-queue-select-bundle-dialog/dot-publishing-queue-select-bundle-dialog.component'; import { DotPublishingQueueUploadDialogComponent } from '../dialogs/dot-publishing-queue-upload-dialog/dot-publishing-queue-upload-dialog.component'; import { DotPublishingQueueTableComponent } from '../dot-publishing-queue-table/dot-publishing-queue-table.component'; import { @@ -57,6 +58,7 @@ export class DotPublishingQueueShellComponent { private uploadRef: DynamicDialogRef | null = null; private assetListRef: DynamicDialogRef | null = null; private deleteRef: DynamicDialogRef | null = null; + private selectBundleRef: DynamicDialogRef | null = null; constructor() { effect(() => { @@ -70,6 +72,30 @@ export class DotPublishingQueueShellComponent { }); } + openSelectBundle(): void { + if (this.selectBundleRef) { + return; + } + this.selectBundleRef = this.dialogService.open( + DotPublishingQueueSelectBundleDialogComponent, + { + header: this.dotMessageService.get('publishing-queue.select-bundle.title'), + width: 'min(95vw, 1100px)', + contentStyle: { height: '70vh', padding: '0' }, + closable: true, + closeOnEscape: true, + draggable: false, + position: 'center' + } + ); + this.selectBundleRef.onClose.pipe(take(1)).subscribe(() => { + this.selectBundleRef = null; + // Selecting/removing bundles inside the dialog may have changed the + // active set — refresh the unified table so the user sees the latest. + this.store.refresh(); + }); + } + openUpload(): void { if (this.uploadRef) { return; diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 15e7e0858e75..0b0fdbae00c6 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -3779,6 +3779,25 @@ publishing-queue.in-progress.title=In Progress publishing-queue.search.placeholder=Search bundles, content, or environments publishing-queue.refresh=Refresh publishing-queue.upload-bundle=Upload Bundle +publishing-queue.add-bundle=Add Bundle +publishing-queue.add-bundle.select=Select Bundle +publishing-queue.add-bundle.upload=Upload +publishing-queue.select-bundle.title=Select Bundle +publishing-queue.select-bundle.bundles=Bundles +publishing-queue.select-bundle.search.placeholder=Search bundles +publishing-queue.select-bundle.empty=No bundles found. +publishing-queue.select-bundle.no-active=Select a bundle to view its contents. +publishing-queue.select-bundle.asset-empty=This bundle has no contents. +publishing-queue.select-bundle.page=Page +publishing-queue.select-bundle.prev-page=Previous page +publishing-queue.select-bundle.next-page=Next page +publishing-queue.select-bundle.col.name=Name +publishing-queue.select-bundle.col.type=Type +publishing-queue.select-bundle.delete-tooltip=Delete from bundle +publishing-queue.select-bundle.remove=Remove +publishing-queue.select-bundle.download=Download +publishing-queue.select-bundle.configure=Configure +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 From eb7bcb9210181af8efe3545e7b5eb2b9bb52fdfc Mon Sep 17 00:00:00 2001 From: hmoreras <31667212+hmoreras@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:21:36 -0600 Subject: [PATCH 19/43] style(publishing-queue) #36040: shorter status chip labels + tighter status column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per design feedback the legacy `publisher_status_*` labels were too long for the new chip — e.g. "Failed to send to some environments" (35 chars) wrapped the chip onto two lines and forced the status column to 16rem. Switches the chip to portlet-scoped keys `publishing-queue.status.*` so we can shorten labels without affecting the legacy JSPs that still read `publisher_status_*`. Each enum value gets a short label: All success → Sent / Saved In-flight → Bundling / Sending / Publishing / Received Pending → Pending / Waiting Failures → Build error / Send error / Publish error Failed (all) / Failed (some) Integrity / Auth error / No license Warnings → Sent (warn) Bundles table: status column width 16rem → 9rem (fits the new longest label `Publish error` with margin). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dot-publishing-status-chip.component.spec.ts | 10 +++++----- .../dot-publishing-status-chip.component.ts | 5 ++++- .../dot-publishing-queue-table.component.html | 2 +- .../webapp/WEB-INF/messages/Language.properties | 14 +++++++------- 4 files changed, 17 insertions(+), 14 deletions(-) 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 684ca752349a..c516c28a4ddc 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 @@ -53,10 +53,10 @@ describe('DotPublishingStatusChipComponent', () => { { provide: DotMessageService, useValue: new MockDotMessageService({ - 'publisher_status_SUCCESS': 'Success', - 'publisher_status_FAILED_TO_PUBLISH': 'Failed to Publish', - 'publisher_status_BUNDLING': 'Bundling', - 'publisher_status_WAITING_FOR_PUBLISHING': 'Waiting for Publishing' + 'publishing-queue.status.SUCCESS': 'Sent', + 'publishing-queue.status.FAILED_TO_PUBLISH': 'Publish error', + 'publishing-queue.status.BUNDLING': 'Bundling', + 'publishing-queue.status.WAITING_FOR_PUBLISHING': 'Waiting' }) } ], @@ -75,7 +75,7 @@ describe('DotPublishingStatusChipComponent', () => { const chip = spectator.query(byTestId('pq-status-chip')); expect(chip?.classList.contains('bg-green-100!')).toBe(true); expect(chip?.classList.contains('text-green-700!')).toBe(true); - expect(chip?.textContent?.trim()).toContain('Success'); + expect(chip?.textContent?.trim()).toContain('Sent'); }); it('renders red classes for danger bucket', () => { 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 c8e552396345..140c41f03b41 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 @@ -63,5 +63,8 @@ export class DotPublishingStatusChipComponent { return s ? publishingStatusBucket(s) : null; }); - readonly labelKey = computed(() => `publisher_status_${this.status()}`); + /** Uses portlet-scoped keys (`publishing-queue.status.*`) so the chip labels + * stay short for the new design without affecting the legacy JSPs that + * still read `publisher_status_*`. */ + readonly labelKey = computed(() => `publishing-queue.status.${this.status()}`); } 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 54e590bea9d3..5b619f37a47b 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 @@ -33,7 +33,7 @@ {{ 'publishing-queue.column.status' | dm }} diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 0b0fdbae00c6..a4f3f4862501 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -3818,14 +3818,14 @@ 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.send=Send -publishing-queue.status.BUNDLE_REQUESTED=Requested +publishing-queue.status.BUNDLE_REQUESTED=Pending publishing-queue.status.BUNDLING=Bundling publishing-queue.status.SENDING_TO_ENDPOINTS=Sending publishing-queue.status.FAILED_TO_SEND_TO_ALL_GROUPS=Failed (all) publishing-queue.status.FAILED_TO_SEND_TO_SOME_GROUPS=Failed (some) -publishing-queue.status.FAILED_TO_BUNDLE=Bundle failed -publishing-queue.status.FAILED_TO_SENT=Send failed -publishing-queue.status.FAILED_TO_PUBLISH=Publish failed +publishing-queue.status.FAILED_TO_BUNDLE=Build error +publishing-queue.status.FAILED_TO_SENT=Send error +publishing-queue.status.FAILED_TO_PUBLISH=Publish error publishing-queue.status.SUCCESS=Sent publishing-queue.status.BUNDLE_SENT_SUCCESSFULLY=Sent publishing-queue.status.RECEIVED_BUNDLE=Received @@ -3833,9 +3833,9 @@ publishing-queue.status.PUBLISHING_BUNDLE=Publishing publishing-queue.status.WAITING_FOR_PUBLISHING=Waiting publishing-queue.status.BUNDLE_SAVED_SUCCESSFULLY=Saved publishing-queue.status.INVALID_TOKEN=Auth error -publishing-queue.status.LICENSE_REQUIRED=License required -publishing-queue.status.SUCCESS_WITH_WARNINGS=Sent (warnings) -publishing-queue.status.FAILED_INTEGRITY_CHECK=Integrity failed +publishing-queue.status.LICENSE_REQUIRED=No license +publishing-queue.status.SUCCESS_WITH_WARNINGS=Sent (warn) +publishing-queue.status.FAILED_INTEGRITY_CHECK=Integrity publishing-queue.tab.queue=Queue publishing-queue.tab.history=History publishing-queue.column.status=Status From 502ed8290b370b0535adc2099ac7bf4195608624 Mon Sep 17 00:00:00 2001 From: hmoreras <31667212+hmoreras@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:21:53 -0600 Subject: [PATCH 20/43] =?UTF-8?q?fix(publishing-queue)=20#36040:=20Select?= =?UTF-8?q?=20Bundle=20asset=20table=20=E2=80=94=20name=20+=20type=20trunc?= =?UTF-8?q?ation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues in the asset table: 1. The Type chip text was wrapping onto two lines for long content type names (e.g. "Language Variable"). PrimeNG `` defaults to `white-space: normal`, so a 17-char value inside a 10rem cell broke across lines. 2. The Name column wasn't truncating long titles (e.g. `com.dotcms.repackage.javax.portlet.title.c_Blogs`) despite the `truncate` class. Cause: the `
` / `` are flex items, and flex items have `min-width: auto` so they expand to fit content. Fixes: - Name link/span: add `min-w-0 flex-1` so the flex item can shrink and `truncate` kicks in. Full text still available via the existing `[title]` tooltip. - Type chip: `styleClass` with `max-w-full whitespace-nowrap overflow-hidden text-ellipsis` + nested `.p-tag-label` truncation. Added `[pTooltip]` with the full content type for hover-to-see-full. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...ot-publishing-queue-select-bundle-dialog.component.html | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 b5cf87219d91..d6199bc1ac6c 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 @@ -185,14 +185,14 @@ [href]="editUrl" target="_blank" rel="noopener" - class="truncate text-primary-600 hover:text-primary-700 hover:underline" + class="min-w-0 flex-1 truncate text-primary-600 hover:text-primary-700 hover:underline" [title]="asset.title || asset.asset" data-testid="pq-select-bundle-asset-name"> {{ asset.title || asset.asset }} } @else { {{ asset.title || asset.asset }} @@ -205,6 +205,9 @@ [value]="asset.content_type_name || asset.type" severity="secondary" [rounded]="false" + styleClass="max-w-full whitespace-nowrap overflow-hidden text-ellipsis [&_.p-tag-label]:truncate [&_.p-tag-label]:block" + [pTooltip]="asset.content_type_name || asset.type" + tooltipPosition="left" data-testid="pq-select-bundle-asset-type" /> From 2c4774faf1ec9d7624c51e7ac23b56cec0c68336 Mon Sep 17 00:00:00 2001 From: hmoreras <31667212+hmoreras@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:22:48 -0600 Subject: [PATCH 21/43] feat(publishing-queue): items column, custom dialog header, row context menu - Add Items column to the bundles table (asset count as a gray p-tag), move Status to last column before the row kebab. - Use the bundle name as the asset list dialog title via a dedicated header component injected through DynamicDialogConfig.templates.header, with truncation past 30 chars and a "{N} item(s)" pill next to it. - Wrap the asset type column in a gray p-tag for visual consistency. - Right-clicking a row opens the same actions menu as the kebab via a shared p-contextMenu; matchMedia polyfill added to the test setup. - Tighten the toolbar search placeholder to "Search bundles". Co-Authored-By: Claude Opus 4.7 (1M context) --- ...ueue-asset-list-dialog-header.component.ts | 62 +++++++++++++++++++ ...ing-queue-asset-list-dialog.component.html | 4 +- ...shing-queue-asset-list-dialog.component.ts | 2 + .../dot-publishing-queue-shell.component.ts | 8 ++- .../dot-publishing-queue-table.component.html | 45 ++++++++++---- ...t-publishing-queue-table.component.spec.ts | 5 +- .../dot-publishing-queue-table.component.ts | 29 ++++++++- .../dot-publishing-queue/src/test-setup.ts | 14 +++++ .../WEB-INF/messages/Language.properties | 5 +- 9 files changed, 156 insertions(+), 18 deletions(-) create mode 100644 core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-asset-list-dialog/dot-publishing-queue-asset-list-dialog-header.component.ts 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-header.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-header.component.ts new file mode 100644 index 000000000000..1ebb038ed9ac --- /dev/null +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-asset-list-dialog/dot-publishing-queue-asset-list-dialog-header.component.ts @@ -0,0 +1,62 @@ +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; + +import { DynamicDialogConfig } from 'primeng/dynamicdialog'; +import { TagModule } from 'primeng/tag'; + +import { DotMessageService } from '@dotcms/data-access'; + +import { DotPublishingQueueStore } from '../../store/dot-publishing-queue.store'; + +/** Custom header rendered inside PrimeNG's native `.p-dialog-header` via + * `DynamicDialogConfig.templates.header`. PrimeNG provides the padding, border, + * and close button; we only contribute the title + items-count pill. */ +@Component({ + selector: 'dot-publishing-queue-asset-list-dialog-header', + standalone: true, + imports: [TagModule], + template: ` +
+

+ {{ displayTitle }} +

+ +
+ `, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DotPublishingQueueAssetListDialogHeaderComponent { + private readonly store = inject(DotPublishingQueueStore); + private readonly dialogConfig = inject(DynamicDialogConfig); + private readonly dotMessageService = inject(DotMessageService); + + readonly bundleName = (this.dialogConfig.data?.bundleName ?? null) as string | null; + + /** Truncate long bundle names so the header stays in one tidy line. The full + * name is exposed via the `title` attribute for hover discovery. */ + readonly displayTitle = ((): string => { + const name = this.bundleName; + if (!name) { + return this.dotMessageService.get('publishing-queue.asset-list.title'); + } + return name.length > 30 ? `${name.slice(0, 30)}…` : name; + })(); + + /** "N items" / "1 item" — singular vs plural so the pill reads naturally. */ + readonly itemsCountLabel = computed(() => { + const count = this.store.selectedAssets().length; + const key = + count === 1 + ? 'publishing-queue.asset-list.items-count.singular' + : 'publishing-queue.asset-list.items-count.plural'; + + return this.dotMessageService.get(key, String(count)); + }); +} 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.html b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-asset-list-dialog/dot-publishing-queue-asset-list-dialog.component.html index a21c396cdd1d..f842ff7b50a9 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.html +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-asset-list-dialog/dot-publishing-queue-asset-list-dialog.component.html @@ -53,7 +53,9 @@ {{ asset.title }} - {{ asset.type }} + + + @if (allowRemove) { r.bundleId === bundleId); const allowRemove = EDITABLE_ASSET_STATUSES.has(row?.status ?? null); + const bundleName = row?.bundleName ?? null; this.assetListRef = this.dialogService.open( DotPublishingQueueAssetListDialogComponent, { - header: this.dotMessageService.get('publishing-queue.asset-list.title'), + templates: { + header: DotPublishingQueueAssetListDialogHeaderComponent + }, width: '700px', closable: true, closeOnEscape: true, draggable: false, position: 'center', - data: { allowRemove } + data: { allowRemove, bundleName } } ); this.assetListRef.onClose.pipe(take(1)).subscribe(() => { 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 5b619f37a47b..d5a76bd8a3cd 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 @@ -32,12 +32,10 @@ {{ 'publishing-queue.column.filter' | dm }} - {{ 'publishing-queue.column.status' | dm }} - + style="width: 6rem" + class="whitespace-nowrap text-right" + data-testid="pq-bundles-col-items"> + {{ 'publishing-queue.column.items' | dm }} + + {{ 'publishing-queue.column.status' | dm }} + + @@ -85,8 +91,8 @@
- - + + @@ -99,6 +105,11 @@ + + + + + @@ -109,6 +120,7 @@ @@ -144,8 +156,11 @@ {{ row.filterName || row.filterKey || '—' }}

- - + + {{ (row.statusUpdated || row.createDate | date: 'medium') || '—' }} + + + - +
@@ -199,4 +217,9 @@
+ diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-table/dot-publishing-queue-table.component.spec.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-table/dot-publishing-queue-table.component.spec.ts index 041ecb17f58b..bb84ada04b0e 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-table/dot-publishing-queue-table.component.spec.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-table/dot-publishing-queue-table.component.spec.ts @@ -127,13 +127,14 @@ describe('DotPublishingQueueTableComponent', () => { expect(spectator.query(byTestId('pq-bundles-table'))).toBeTruthy(); }); - it('renders all six column headers', () => { + it('renders all seven column headers', () => { expect(spectator.query(byTestId('pq-bundles-col-bundle-name'))).toBeTruthy(); expect(spectator.query(byTestId('pq-bundles-col-bundle-id'))).toBeTruthy(); expect(spectator.query(byTestId('pq-bundles-col-filter'))).toBeTruthy(); - expect(spectator.query(byTestId('pq-bundles-col-status'))).toBeTruthy(); + expect(spectator.query(byTestId('pq-bundles-col-items'))).toBeTruthy(); expect(spectator.query(byTestId('pq-bundles-col-created'))).toBeTruthy(); expect(spectator.query(byTestId('pq-bundles-col-modified'))).toBeTruthy(); + expect(spectator.query(byTestId('pq-bundles-col-status'))).toBeTruthy(); }); it('row click opens the detail dialog', () => { 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 38b2ef4358de..dc97372b88e1 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 @@ -1,12 +1,21 @@ import { DatePipe } from '@angular/common'; -import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + signal, + viewChild +} from '@angular/core'; import { ConfirmationService, MenuItem } from 'primeng/api'; import { ButtonModule } from 'primeng/button'; import { ConfirmDialogModule } from 'primeng/confirmdialog'; +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 */ @@ -63,9 +72,11 @@ const ACTIVE_STATUSES = new Set([ DatePipe, ButtonModule, ConfirmDialogModule, + ContextMenuModule, MenuModule, SkeletonModule, TableModule, + TagModule, TooltipModule, DotEmptyContainerComponent, DotMessagePipe, @@ -179,6 +190,22 @@ export class DotPublishingQueueTableComponent { return this.kebabMenus().get(row.bundleId) ?? []; } + /** Right-click context menu reuses the same kebab items, scoped to whichever + * row was right-clicked. One shared `` instead of one per row + * keeps the DOM small even with hundreds of rows. */ + readonly contextMenu = viewChild('rowContextMenu'); + readonly contextMenuRow = signal(null); + readonly contextMenuItems = computed(() => { + const row = this.contextMenuRow(); + return row ? this.kebabFor(row) : []; + }); + + onRowContextMenu(event: MouseEvent, row: PublishingJobView): void { + event.preventDefault(); + this.contextMenuRow.set(row); + this.contextMenu()?.show(event); + } + onLazyLoad(event: TableLazyLoadEvent): void { const rows = (event.rows as number) ?? this.store.rowsPerPage(); const first = (event.first as number) ?? 0; diff --git a/core-web/libs/portlets/dot-publishing-queue/src/test-setup.ts b/core-web/libs/portlets/dot-publishing-queue/src/test-setup.ts index 29b4a8b073cf..791354086e1c 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/test-setup.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/test-setup.ts @@ -16,3 +16,17 @@ Object.defineProperty(window, 'ResizeObserver', { configurable: true, value: MockResizeObserver }); + +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn() + })) +}); diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index d545a1899073..f8a1b91b3b42 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -3776,7 +3776,7 @@ publisher_upload=Upload Bundle publisher=Publisher publishing-queue.ready.title=Ready to Send publishing-queue.in-progress.title=In Progress -publishing-queue.search.placeholder=Search bundles, content, or environments +publishing-queue.search.placeholder=Search bundles publishing-queue.refresh=Refresh publishing-queue.upload-bundle=Upload Bundle publishing-queue.add-bundle=Add Bundle @@ -3801,6 +3801,7 @@ publishing-queue.select-bundle.remove.confirm.message=Are you sure you want to r publishing-queue.column.name=Name publishing-queue.column.type=Type publishing-queue.column.filter=Filter +publishing-queue.column.items=Items publishing-queue.column.bundle-id=Bundle Id publishing-queue.column.bundle-name=Bundle Name publishing-queue.column.data-entered=Data Entered @@ -3813,6 +3814,8 @@ publishing-queue.empty.in-progress.subtitle=Bundles being packed or shipped will publishing-queue.empty.history.title=No bundles sent yet publishing-queue.empty.history.subtitle=Once a bundle ships, you'll see its status here. publishing-queue.asset-list.title=Bundle Assets +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? From 556366a3526102e9ce532d7a82c9d3636552b074 Mon Sep 17 00:00:00 2001 From: hmoreras <31667212+hmoreras@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:46:47 -0600 Subject: [PATCH 22/43] feat(publishing-queue): gate bundle + manifest download buttons on HEAD probes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bundle-detail dialog now mirrors the legacy JSP's file-on-disk gating for the Download Bundle and Download Manifest buttons. Because GET /api/v1/publishing/{bundleId} doesn't currently expose hasBundle / hasManifest, the store fires two HEAD probes on openDetail and only shows each button once its probe confirms a 200 — replacing the previous status heuristic (SUCCESS_STATUSES) that didn't account for purged .tar.gz files or older bundles without a manifest entry. The probe rationale is documented on probeBundleDownload / probeBundleManifest in the data-access service, on the new store state fields, and on the canDownloadBundle / canDownloadManifest computeds in the dialog — including the path to retire the probes once the BE adds the flags to the detail response. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dot-publishing-queue.service.spec.ts | 59 ++++++++++++++++ .../dot-publishing-queue.service.ts | 59 +++++++++++++++- ...queue-bundle-details-dialog.component.html | 32 ++++++--- ...ue-bundle-details-dialog.component.spec.ts | 69 +++++++++++++++---- ...g-queue-bundle-details-dialog.component.ts | 24 +++---- ...t-publishing-queue-shell.component.spec.ts | 4 +- .../store/dot-publishing-queue.store.spec.ts | 2 + .../lib/store/dot-publishing-queue.store.ts | 61 +++++++++++++++- .../WEB-INF/messages/Language.properties | 1 + 9 files changed, 270 insertions(+), 41 deletions(-) diff --git a/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.spec.ts b/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.spec.ts index aca33fd187ba..a77e3e539494 100644 --- a/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.spec.ts +++ b/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.spec.ts @@ -208,4 +208,63 @@ describe('DotPublishingQueueService', () => { req.flush({ identifier: 'id', label: 'name', items: [], numRows: 0 }); }); }); + + describe('download URL builders', () => { + it('getBundleDownloadUrl returns the /api/bundle/_download path', () => { + expect(service.getBundleDownloadUrl('b-1')).toBe('/api/bundle/_download/b-1'); + }); + + it('getBundleManifestUrl returns the /api/bundle/{id}/manifest path', () => { + expect(service.getBundleManifestUrl('b-1')).toBe('/api/bundle/b-1/manifest'); + }); + }); + + // These probes mirror the legacy JSP's file-existence checks because the + // current detail response does not expose `hasBundle` / `hasManifest`. See + // the service-level docs on `probeBundleDownload` for the full why. + describe('probeBundleDownload', () => { + it('issues a HEAD against /api/bundle/_download/{id} and maps 200 → true', () => { + let result: boolean | undefined; + service.probeBundleDownload('b-1').subscribe((value) => (result = value)); + + const req = httpMock.expectOne('/api/bundle/_download/b-1'); + expect(req.request.method).toBe('HEAD'); + req.flush(null, { status: 200, statusText: 'OK' }); + + expect(result).toBe(true); + }); + + it('maps non-2xx (file purged) to false via catchError', () => { + let result: boolean | undefined; + service.probeBundleDownload('b-1').subscribe((value) => (result = value)); + + const req = httpMock.expectOne('/api/bundle/_download/b-1'); + req.flush(null, { status: 404, statusText: 'Not Found' }); + + expect(result).toBe(false); + }); + }); + + describe('probeBundleManifest', () => { + it('issues a HEAD against /api/bundle/{id}/manifest and maps 200 → true', () => { + let result: boolean | undefined; + service.probeBundleManifest('b-1').subscribe((value) => (result = value)); + + const req = httpMock.expectOne('/api/bundle/b-1/manifest'); + expect(req.request.method).toBe('HEAD'); + req.flush(null, { status: 200, statusText: 'OK' }); + + expect(result).toBe(true); + }); + + it('maps non-2xx (no manifest / archive missing) to false', () => { + let result: boolean | undefined; + service.probeBundleManifest('b-1').subscribe((value) => (result = value)); + + const req = httpMock.expectOne('/api/bundle/b-1/manifest'); + req.flush(null, { status: 404, statusText: 'Not Found' }); + + expect(result).toBe(false); + }); + }); }); diff --git a/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.ts b/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.ts index 77e00b2eb9e1..b0d4fe05b1f7 100644 --- a/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.ts +++ b/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.ts @@ -1,9 +1,9 @@ -import { Observable } from 'rxjs'; +import { Observable, of } from 'rxjs'; import { HttpClient, HttpParams } from '@angular/common/http'; import { Injectable, inject } from '@angular/core'; -import { map } from 'rxjs/operators'; +import { catchError, map } from 'rxjs/operators'; import { BundleAssetView, @@ -160,6 +160,61 @@ export class DotPublishingQueueService { return `/api/bundle/_download/${bundleId}`; } + /** Builds the absolute download URL for a bundle's manifest CSV. */ + getBundleManifestUrl(bundleId: string): string { + return `/api/bundle/${bundleId}/manifest`; + } + + /** + * Returns `true` when the bundle's `.tar.gz` is downloadable right now. + * + * Why this probe exists: + * The legacy JSP gates the "Download Bundle" button on two server-side + * conditions: + * 1. the `.tar.gz` file existing on disk (it may have been purged via + * `DELETE /api/bundle/olderthan/{N}` — there is no scheduled cleanup + * built into the core, so this happens only on manual admin action); + * 2. the targeted environments not being static-publish / S3 (those + * protocols don't produce a downloadable archive). + * + * Neither piece of state is exposed on the current detail response + * (`GET /api/v1/publishing/{bundleId}`). Until the BE adds a `hasBundle` + * (or `endpointProtocols`) flag we can read directly, the FE has to ask + * the actual download endpoint. HEAD is the lightweight option — JAX-RS + * auto-handles HEAD by invoking the `@GET` handler and discarding the + * body, so we get the file-existence answer without paying for the + * payload. A 404 (file purged) becomes `false` via `catchError`. + * + * @see `RemotePublishAjaxAction.downloadBundle` and `BundleResource._download` + * for the server-side behavior. + */ + probeBundleDownload(bundleId: string): Observable { + return this.http.head(this.getBundleDownloadUrl(bundleId), { observe: 'response' }).pipe( + map((response) => response.status === 200), + catchError(() => of(false)) + ); + } + + /** + * Returns `true` when the bundle's manifest CSV is downloadable right now. + * + * Same rationale as {@link probeBundleDownload}: the legacy JSP calls + * `ManifestUtil.manifestExists(bundleId)` server-side, which (a) checks + * that the `.tar.gz` is on disk and (b) scans the archive for a manifest + * entry — bundles built before the manifest feature was introduced have + * no entry. Neither check is exposed on `GET /api/v1/publishing/{bundleId}`, + * so the FE probes the actual download endpoint with HEAD. + * + * @see `BundleResource.downloadManifest` and `ManifestUtil.manifestExists` + * for the server-side behavior. + */ + probeBundleManifest(bundleId: string): Observable { + return this.http.head(this.getBundleManifestUrl(bundleId), { observe: 'response' }).pipe( + map((response) => response.status === 200), + catchError(() => of(false)) + ); + } + /** * Lists unsent (draft) bundles owned by the given user. * diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.html b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.html index 7146951b43fe..e943a4232051 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.html +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.html @@ -110,16 +110,28 @@

} - @if (canDownload()) { -
- - - {{ 'publishing-queue.detail.download' | dm }} - + @if (canDownloadBundle() || canDownloadManifest()) { + } } @else if (store.detailStatus() === 'error') { diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.spec.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.spec.ts index 8417fd434b08..96d6b74f498f 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.spec.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.spec.ts @@ -58,13 +58,21 @@ describe('DotPublishingQueueBundleDetailsDialogComponent', () => { const detail = signal(null); const detailStatus = signal<'init' | 'loading' | 'loaded' | 'error'>('loading'); + const canDownloadBundle = signal(null); + const canDownloadManifest = signal(null); const createComponent = createComponentFactory({ component: DotPublishingQueueBundleDetailsDialogComponent, providers: [ - mockProvider(DotPublishingQueueStore, { detail, detailStatus }), + mockProvider(DotPublishingQueueStore, { + detail, + detailStatus, + canDownloadBundle, + canDownloadManifest + }), mockProvider(DotPublishingQueueService, { - getBundleDownloadUrl: jest.fn((id: string) => `/api/bundle/_download/${id}`) + getBundleDownloadUrl: jest.fn((id: string) => `/api/bundle/_download/${id}`), + getBundleManifestUrl: jest.fn((id: string) => `/api/bundle/${id}/manifest`) }), { provide: DotMessageService, useValue: new MockDotMessageService({}) } ] @@ -73,6 +81,8 @@ describe('DotPublishingQueueBundleDetailsDialogComponent', () => { beforeEach(() => { detail.set(null); detailStatus.set('loading'); + canDownloadBundle.set(null); + canDownloadManifest.set(null); spectator = createComponent(); }); @@ -88,18 +98,51 @@ describe('DotPublishingQueueBundleDetailsDialogComponent', () => { expect(spectator.queryAll(byTestId('pq-detail-endpoint-row')).length).toBe(1); }); - it('shows download button only for completed bundles', () => { - detail.set(detailFixture({ status: PublishAuditStatus.SUCCESS })); - detailStatus.set('loaded'); - spectator.detectChanges(); - expect(spectator.query(byTestId('pq-detail-download-btn'))).toBeTruthy(); - }); + describe('download buttons (probe-driven)', () => { + beforeEach(() => { + detail.set(detailFixture()); + detailStatus.set('loaded'); + }); - it('hides download button for failed bundles', () => { - detail.set(detailFixture({ status: PublishAuditStatus.FAILED_TO_PUBLISH })); - detailStatus.set('loaded'); - spectator.detectChanges(); - expect(spectator.query(byTestId('pq-detail-download-btn'))).toBeFalsy(); + it('hides both buttons while probes are in flight (null)', () => { + canDownloadBundle.set(null); + canDownloadManifest.set(null); + spectator.detectChanges(); + expect(spectator.query(byTestId('pq-detail-download-btn'))).toBeFalsy(); + expect(spectator.query(byTestId('pq-detail-download-manifest-btn'))).toBeFalsy(); + }); + + it('shows the bundle button only when probeBundleDownload returned true', () => { + canDownloadBundle.set(true); + canDownloadManifest.set(false); + spectator.detectChanges(); + expect(spectator.query(byTestId('pq-detail-download-btn'))).toBeTruthy(); + expect(spectator.query(byTestId('pq-detail-download-manifest-btn'))).toBeFalsy(); + }); + + it('shows the manifest button only when probeBundleManifest returned true', () => { + canDownloadBundle.set(false); + canDownloadManifest.set(true); + spectator.detectChanges(); + expect(spectator.query(byTestId('pq-detail-download-btn'))).toBeFalsy(); + expect(spectator.query(byTestId('pq-detail-download-manifest-btn'))).toBeTruthy(); + }); + + it('shows both when both probes returned true', () => { + canDownloadBundle.set(true); + canDownloadManifest.set(true); + spectator.detectChanges(); + expect(spectator.query(byTestId('pq-detail-download-btn'))).toBeTruthy(); + expect(spectator.query(byTestId('pq-detail-download-manifest-btn'))).toBeTruthy(); + }); + + it('hides both when both probes returned false (artifacts purged)', () => { + canDownloadBundle.set(false); + canDownloadManifest.set(false); + spectator.detectChanges(); + expect(spectator.query(byTestId('pq-detail-download-btn'))).toBeFalsy(); + expect(spectator.query(byTestId('pq-detail-download-manifest-btn'))).toBeFalsy(); + }); }); it('shows empty-endpoints message when environments is empty', () => { diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.ts index 6f31b4d60062..ead0f19ca29f 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.ts @@ -6,19 +6,12 @@ import { SkeletonModule } from 'primeng/skeleton'; import { TableModule } from 'primeng/table'; import { DotMessageService, DotPublishingQueueService } from '@dotcms/data-access'; -import { EndpointDetailView, PublishAuditStatus } from '@dotcms/dotcms-models'; +import { EndpointDetailView } from '@dotcms/dotcms-models'; import { DotMessagePipe } from '@dotcms/ui'; import { DotPublishingStatusChipComponent } from '../../components/dot-publishing-status-chip/dot-publishing-status-chip.component'; import { DotPublishingQueueStore } from '../../store/dot-publishing-queue.store'; -const SUCCESS_STATUSES = new Set([ - PublishAuditStatus.SUCCESS, - PublishAuditStatus.BUNDLE_SENT_SUCCESSFULLY, - PublishAuditStatus.BUNDLE_SAVED_SUCCESSFULLY, - PublishAuditStatus.SUCCESS_WITH_WARNINGS -]); - /** Flattened row used by the endpoints table — each endpoint carries its * environment name as a column, so all groups share one uniform grid. */ export interface EndpointTableRow { @@ -96,10 +89,13 @@ export class DotPublishingQueueBundleDetailsDialogComponent { } ]; - readonly canDownload = computed(() => { - const status = this.store.detail()?.status; - return status ? SUCCESS_STATUSES.has(status) : false; - }); + /** Both download buttons are driven by HEAD probes the store fires on + * `openDetail` — `true` only after the probe confirms the artifact is + * actually downloadable right now. `null` (in flight) stays hidden so the + * UI doesn't flicker on the way in. See + * `DotPublishingQueueService.probeBundleDownload` for the full why. */ + readonly canDownloadBundle = computed(() => this.store.canDownloadBundle() === true); + readonly canDownloadManifest = computed(() => this.store.canDownloadManifest() === true); /** Flattens environments → one row per endpoint, with the env name carried * as a column. Single table, no subheader rows. */ @@ -139,4 +135,8 @@ export class DotPublishingQueueBundleDetailsDialogComponent { downloadHref(bundleId: string): string { return this.publishingService.getBundleDownloadUrl(bundleId); } + + manifestHref(bundleId: string): string { + return this.publishingService.getBundleManifestUrl(bundleId); + } } 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 8d99e5b1a608..78ed89244a1c 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 @@ -52,7 +52,9 @@ describe('DotPublishingQueueShellComponent', () => { }) ), getBundleAssets: jest.fn().mockReturnValue(of([])), - getPublishingJobDetails: jest.fn().mockReturnValue(of({})) + getPublishingJobDetails: jest.fn().mockReturnValue(of({})), + probeBundleDownload: jest.fn().mockReturnValue(of(true)), + probeBundleManifest: jest.fn().mockReturnValue(of(true)) }), mockProvider(DotHttpErrorManagerService), mockProvider(DotGlobalMessageService, { error: jest.fn() }), 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 929c07d718be..684f8ec82704 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 @@ -72,6 +72,8 @@ describe('DotPublishingQueueStore', () => { listPublishingJobs: jest.fn().mockReturnValue(of(BUNDLES_RESPONSE)), getBundleAssets: jest.fn().mockReturnValue(of(MOCK_ASSETS)), getPublishingJobDetails: jest.fn().mockReturnValue(of(MOCK_DETAIL)), + probeBundleDownload: jest.fn().mockReturnValue(of(true)), + probeBundleManifest: jest.fn().mockReturnValue(of(true)), removeAssetsFromBundle: jest .fn() .mockReturnValue(of([{ assetId: 'a1', success: true, message: 'ok' }])), 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 79f564f93f22..842b1f756510 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 @@ -66,6 +66,19 @@ interface DotPublishingQueueState { * Asset List modal's `selectedAssets` so the two modals don't fight over state). */ detailAssets: BundleAssetView[]; detailAssetsStatus: LoadStatus; + + /** File-on-disk probes for the detail dialog's two download buttons. + * + * `null` = unknown (probe in flight or not yet started — buttons stay hidden). + * `true`/`false` = HEAD probe completed; the button shows when `true`. + * + * We need these as state instead of computing from `detail` because the + * existence of the `.tar.gz` (and a manifest entry inside it) is not + * exposed on the detail response — see + * `DotPublishingQueueService.probeBundleDownload` / + * `probeBundleManifest` for the full rationale. */ + canDownloadBundle: boolean | null; + canDownloadManifest: boolean | null; } const initialState: DotPublishingQueueState = { @@ -89,7 +102,9 @@ const initialState: DotPublishingQueueState = { detail: null, detailStatus: 'init', detailAssets: [], - detailAssetsStatus: 'init' + detailAssetsStatus: 'init', + canDownloadBundle: null, + canDownloadManifest: null }; export const DotPublishingQueueStore = signalStore( @@ -185,6 +200,41 @@ export const DotPublishingQueueStore = signalStore( }); } + /** + * Fires two parallel HEAD probes so the detail dialog knows whether to + * render each of its download buttons. We have to do this client-side + * because `GET /api/v1/publishing/{bundleId}` does not expose either: + * - whether the `.tar.gz` is still on disk (it may have been purged + * via `DELETE /api/bundle/olderthan/{N}`), or + * - whether the archive contains a manifest entry (older bundles + * pre-dating the manifest feature don't). + * + * Two extra requests per dialog open is the price of mirroring the + * legacy JSP's file-existence gates without a backend change. When the + * detail response grows `hasBundle` / `hasManifest` flags, delete this + * function and read directly from `detail`. + */ + function probeDownloads() { + const bundleId = store.detailBundleId(); + if (!bundleId) { + return; + } + + service + .probeBundleDownload(bundleId) + .pipe(take(1)) + .subscribe((canDownload) => { + patchState(store, { canDownloadBundle: canDownload }); + }); + + service + .probeBundleManifest(bundleId) + .pipe(take(1)) + .subscribe((canDownload) => { + patchState(store, { canDownloadManifest: canDownload }); + }); + } + /** * Lazy-loads the assets that travelled in a bundle. Backed by the legacy * `GET /api/bundle/{bundleId}/assets` (the only endpoint that returns the @@ -347,10 +397,13 @@ export const DotPublishingQueueStore = signalStore( detail: null, detailStatus: 'init', detailAssets: [], - detailAssetsStatus: 'init' + detailAssetsStatus: 'init', + canDownloadBundle: null, + canDownloadManifest: null }); loadDetail(); loadDetailAssets(); + probeDownloads(); }, loadDetailAssets, @@ -361,7 +414,9 @@ export const DotPublishingQueueStore = signalStore( detail: null, detailStatus: 'init', detailAssets: [], - detailAssetsStatus: 'init' + detailAssetsStatus: 'init', + canDownloadBundle: null, + canDownloadManifest: null }); }, diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index f8a1b91b3b42..ce46234be539 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -3880,6 +3880,7 @@ publishing-queue.detail.address=Address publishing-queue.detail.message=Message publishing-queue.detail.no-endpoints=This bundle has not been sent to any environment yet. publishing-queue.detail.download=Download +publishing-queue.detail.download-manifest=Download Manifest publishing-queue.detail.load-error=Could not load bundle details. publishing-queue.detail.search-assets=Search assets publishing-queue.detail.assets-no-matches=No assets match your search. From c271d153b019aa312390d87b666ed5d2c25ea91d Mon Sep 17 00:00:00 2001 From: hmoreras <31667212+hmoreras@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:08:18 -0600 Subject: [PATCH 23/43] feat(publishing-queue): red bundle ids on failed rows, dotRelativeDate for last update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bundle id text turns text-red-700 + medium weight when the row is in any failure bucket (mirrors the danger color the status chip already uses), giving an at-a-glance failure signal even when the Status column scrolls off on narrow viewports. - Data Entered column switches to MM/dd/yyyy hh:mma absolute format. - Last Update column adopts the project-standard DotRelativeDatePipe from @dotcms/ui ("now", "N minutes ago", "N hours ago", "N days ago" up to 7 days; absolute MM/dd/yyyy hh:mma after that) — same pipe used by dot-folder-list-view, edit-content sidebars, and content-compare. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dot-publishing-queue-table.component.html | 17 ++++++--- ...t-publishing-queue-table.component.spec.ts | 36 +++++++++++++++++-- .../dot-publishing-queue-table.component.ts | 12 ++++++- 3 files changed, 57 insertions(+), 8 deletions(-) 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 d5a76bd8a3cd..e6b4ccf6cca2 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 @@ -33,7 +33,7 @@ {{ 'publishing-queue.column.items' | dm }} @@ -136,7 +136,11 @@ copy button right next to the text instead of floating at the far edge of the column. -->
- + {{ truncateBundleId(row.bundleId) }} - + - {{ (row.createDate | date: 'medium') || '—' }} + {{ (row.createDate | date: 'MM/dd/yyyy hh:mma') || '—' }} - {{ (row.statusUpdated || row.createDate | date: 'medium') || '—' }} + {{ + (row.statusUpdated || row.createDate + | dotRelativeDate: 'MM/dd/yyyy hh:mma') || '—' + }} diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-table/dot-publishing-queue-table.component.spec.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-table/dot-publishing-queue-table.component.spec.ts index bb84ada04b0e..16fd8de8bda5 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-table/dot-publishing-queue-table.component.spec.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-table/dot-publishing-queue-table.component.spec.ts @@ -6,7 +6,11 @@ import { ConfirmationService } from 'primeng/api'; /* eslint-disable @nx/enforce-module-boundaries */ -import { DotGlobalMessageService, DotMessageService } from '@dotcms/data-access'; +import { + DotFormatDateService, + DotGlobalMessageService, + DotMessageService +} from '@dotcms/data-access'; import { DotPushPublishDialogService } from '@dotcms/dotcms-js'; import { PublishAuditStatus, PublishingJobView } from '@dotcms/dotcms-models'; import { DotClipboardUtil } from '@dotcms/ui'; @@ -87,7 +91,8 @@ describe('DotPublishingQueueTableComponent', () => { { provide: DotMessageService, useValue: new MockDotMessageService({}) }, mockProvider(DotGlobalMessageService, { error: jest.fn() }), mockProvider(DotPushPublishDialogService, { open: jest.fn() }), - mockProvider(DotDownloadBundleDialogService, { open: jest.fn() }) + mockProvider(DotDownloadBundleDialogService, { open: jest.fn() }), + mockProvider(DotFormatDateService) ] }); @@ -147,6 +152,33 @@ describe('DotPublishingQueueTableComponent', () => { expect(chips.length).toBe(2); }); + describe('failed-row bundle id styling', () => { + it('isFailedRow returns true for any FAILURE_STATUSES entry', () => { + expect( + spectator.component.isFailedRow(row('x', PublishAuditStatus.FAILED_TO_PUBLISH)) + ).toBe(true); + expect( + spectator.component.isFailedRow(row('x', PublishAuditStatus.LICENSE_REQUIRED)) + ).toBe(true); + }); + + it('isFailedRow returns false for success/in-progress', () => { + expect(spectator.component.isFailedRow(row('x', PublishAuditStatus.SUCCESS))).toBe( + false + ); + expect( + spectator.component.isFailedRow(row('x', PublishAuditStatus.SENDING_TO_ENDPOINTS)) + ).toBe(false); + }); + + it('paints the bundle id in text-red-700 for failed rows', () => { + // Row at index 1 in the fixture set is FAILED_TO_PUBLISH; index 0 is SUCCESS. + const ids = spectator.queryAll(byTestId('pq-bundles-bundle-id')); + expect(ids[0].querySelector('span')?.classList.contains('text-red-700')).toBe(false); + expect(ids[1].querySelector('span')?.classList.contains('text-red-700')).toBe(true); + }); + }); + describe('copyToClipboard', () => { it('delegates to DotClipboardUtil.copy', async () => { (clipboard.copy as jest.Mock).mockResolvedValue(true); 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 dc97372b88e1..61dc752372d4 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 @@ -33,6 +33,7 @@ import { DotClipboardUtil, DotEmptyContainerComponent, DotMessagePipe, + DotRelativeDatePipe, PrincipalConfiguration } from '@dotcms/ui'; import { DotDownloadBundleDialogService } from '@services/dot-download-bundle-dialog/dot-download-bundle-dialog.service'; @@ -80,7 +81,8 @@ const ACTIVE_STATUSES = new Set([ TooltipModule, DotEmptyContainerComponent, DotMessagePipe, - DotPublishingStatusChipComponent + DotPublishingStatusChipComponent, + DotRelativeDatePipe ], providers: [ConfirmationService, DotClipboardUtil], templateUrl: './dot-publishing-queue-table.component.html', @@ -206,6 +208,14 @@ export class DotPublishingQueueTableComponent { this.contextMenu()?.show(event); } + /** Rows in any failure bucket render their bundle id in danger-red — same + * `text-red-700` the status chip uses for the danger bucket. Gives an + * at-a-glance signal even when the Status column is off-screen on narrow + * viewports. */ + isFailedRow(row: PublishingJobView): boolean { + return row.status ? FAILURE_STATUSES.has(row.status) : false; + } + onLazyLoad(event: TableLazyLoadEvent): void { const rows = (event.rows as number) ?? this.store.rowsPerPage(); const first = (event.first as number) ?? 0; From fd17f4d76f609dc6ab02f34966b54f38c0d2b665 Mon Sep 17 00:00:00 2001 From: hmoreras <31667212+hmoreras@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:36:04 -0600 Subject: [PATCH 24/43] chore(core-web): regenerate pnpm-lock.yaml for jest-util CI was failing on `pnpm install --frozen-lockfile` because `jest-util@^30.0.2` was added to core-web/package.json without a matching importer entry in pnpm-lock.yaml. Co-Authored-By: Claude Opus 4.7 (1M context) --- core-web/pnpm-lock.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/core-web/pnpm-lock.yaml b/core-web/pnpm-lock.yaml index 6c31ab6bf331..570ed0f4f6b2 100644 --- a/core-web/pnpm-lock.yaml +++ b/core-web/pnpm-lock.yaml @@ -707,6 +707,9 @@ importers: jest-preset-angular: specifier: 16.0.0 version: 16.0.0(db4775bb8e1d7033ec04c76ee442fbb7) + jest-util: + specifier: ^30.0.2 + version: 30.3.0 jiti: specifier: 2.4.2 version: 2.4.2 From d1ee1346087c0e9f57d26925c250d1cde28b32c7 Mon Sep 17 00:00:00 2001 From: hmoreras <31667212+hmoreras@users.noreply.github.com> Date: Thu, 25 Jun 2026 17:03:59 -0600 Subject: [PATCH 25/43] feat(publishing-queue): surface the synthetic SCHEDULED status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The BE introduced PublishAuditStatus.SCHEDULED (#36267) — a synthetic status for bundles with a future publish_date that haven't yet been picked up by PublisherQueueJob. The FE was missing it everywhere it mattered: - Added SCHEDULED to the PublishAuditStatus TS enum (mirror of the Java source of truth) with a doc explaining its synthetic nature. - Mapped SCHEDULED → 'info' bucket in the status chip, alongside BUNDLE_REQUESTED / WAITING_FOR_PUBLISHING (all "queued, not yet started" semantics). - Added publishing-queue.status.SCHEDULED=Scheduled so the chip renders a label instead of the raw key. - Updated the chip's exhaustive-coverage test so it stays exhaustive. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dotcms-models/src/lib/publishing-status.model.ts | 9 ++++++++- .../dot-publishing-status-chip.component.spec.ts | 1 + .../dot-publishing-status-chip.component.ts | 2 ++ .../src/main/webapp/WEB-INF/messages/Language.properties | 1 + 4 files changed, 12 insertions(+), 1 deletion(-) diff --git a/core-web/libs/dotcms-models/src/lib/publishing-status.model.ts b/core-web/libs/dotcms-models/src/lib/publishing-status.model.ts index 710613061ad3..0351c058ef1b 100644 --- a/core-web/libs/dotcms-models/src/lib/publishing-status.model.ts +++ b/core-web/libs/dotcms-models/src/lib/publishing-status.model.ts @@ -23,7 +23,14 @@ export enum PublishAuditStatus { INVALID_TOKEN = 'INVALID_TOKEN', LICENSE_REQUIRED = 'LICENSE_REQUIRED', SUCCESS_WITH_WARNINGS = 'SUCCESS_WITH_WARNINGS', - FAILED_INTEGRITY_CHECK = 'FAILED_INTEGRITY_CHECK' + FAILED_INTEGRITY_CHECK = 'FAILED_INTEGRITY_CHECK', + /** + * Synthetic status for bundles pushed with a future publish date but not yet + * picked up by `PublisherQueueJob` — synthesized at read time by the v1 + * publishing API, never persisted. Mirrors the BE sentinel introduced in + * `PublishAuditStatus.Status.SCHEDULED` (#36267). + */ + SCHEDULED = 'SCHEDULED' } /** Bundles authored but not yet sent — populate the Queue tab's READY TO SEND column. */ 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 c516c28a4ddc..fc4f923079f5 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 @@ -25,6 +25,7 @@ describe('publishingStatusBucket (pure fn)', () => { [PublishAuditStatus.LICENSE_REQUIRED, 'danger'], [PublishAuditStatus.WAITING_FOR_PUBLISHING, 'info'], [PublishAuditStatus.BUNDLE_REQUESTED, 'info'], + [PublishAuditStatus.SCHEDULED, 'info'], [PublishAuditStatus.BUNDLING, 'warning'], [PublishAuditStatus.SENDING_TO_ENDPOINTS, 'warning'], [PublishAuditStatus.PUBLISHING_BUNDLE, 'warning'], 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 140c41f03b41..6c4923880da8 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 @@ -29,6 +29,8 @@ const BUCKETS: Record = { // info: in the queue, waiting to start [PublishAuditStatus.WAITING_FOR_PUBLISHING]: 'info', [PublishAuditStatus.BUNDLE_REQUESTED]: 'info', + // 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', diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 2ccef08e9a89..dbca29ee0dfa 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -3839,6 +3839,7 @@ publishing-queue.status.INVALID_TOKEN=Auth error publishing-queue.status.LICENSE_REQUIRED=No license publishing-queue.status.SUCCESS_WITH_WARNINGS=Sent (warn) publishing-queue.status.FAILED_INTEGRITY_CHECK=Integrity +publishing-queue.status.SCHEDULED=Scheduled publishing-queue.tab.queue=Queue publishing-queue.tab.history=History publishing-queue.column.status=Status From d8ff4dbf23a912a8c934f644173c18b149c413c2 Mon Sep 17 00:00:00 2001 From: hmoreras <31667212+hmoreras@users.noreply.github.com> Date: Thu, 25 Jun 2026 17:59:31 -0600 Subject: [PATCH 26/43] feat(publishing-queue): inline Configure step in Select Bundle, push via v1 REST MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the multi-bundle "Configure (N)" → "Configure & Send" flow inside the existing Select Bundle dialog instead of stacking a second modal: - Adds a step signal ('select' | 'configure') to the dialog and a switch in the template. Step 2 embeds the existing DotPushPublishFormComponent (the same form the legacy global push-publish dialog uses) so customers see the exact field set they know: action / publishDate / expireDate / timezone / environment / push filter. Configured once, applied to every selected bundle. - All footer buttons (Remove, Download, Configure) now gate on the checkbox selection. Download stays single-target — disabled with a tooltip when N != 1. - Send hits the modern REST endpoint POST /api/v1/publishing/push/{bundleId} (PublishingResource.pushBundle, JSON + proper status codes) instead of the legacy /DotAjaxDirector/.../cmd/pushBundle AJAX action. Adds DotPublishingQueueService.pushBundle and PushBundleForm/PushBundleResultView types. Submit fans out one call per checked bundle with the same payload; full success closes the dialog (shell refreshes the unified table on onClose), partial failure surfaces via DotGlobalMessageService and the dialog stays in step 2. - toPushBundleForm() helper translates the form's DotPushPublishData into the v1 shape: renames operation/environments and combines the form's Date + selected timezoneId into ISO 8601 with offset (computed via Intl.DateTimeFormat so DST is handled correctly). - DotPushPublishFiltersService is provided at the component level (mirrors the legacy DotPushPublishDialogComponent) so the embedded form's ngOnInit lookup resolves. - DotPushPublishFormComponent lives in apps/dotcms-ui; imported via the same @nx/enforce-module-boundaries disable already used here for DotDownloadBundleDialogService. Track extraction to a shared lib alongside the v1 consolidation work (#36048). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dot-publishing-queue.service.ts | 21 + .../src/lib/publishing-job.model.ts | 33 + ...-queue-select-bundle-dialog.component.html | 663 ++++++++++-------- ...eue-select-bundle-dialog.component.spec.ts | 247 ++++++- ...ng-queue-select-bundle-dialog.component.ts | 247 ++++++- .../WEB-INF/messages/Language.properties | 7 + 6 files changed, 883 insertions(+), 335 deletions(-) diff --git a/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.ts b/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.ts index b0d4fe05b1f7..ffd40e60b111 100644 --- a/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.ts +++ b/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.ts @@ -11,6 +11,8 @@ import { PublishAuditStatus, PublishingJobDetailView, PublishingJobsResponse, + PushBundleForm, + PushBundleResultView, RemoveAssetResultView, RetryBundleResultView, UnsentBundlesResponse @@ -116,6 +118,25 @@ export class DotPublishingQueueService { .pipe(map((response) => response.entity)); } + /** + * Queues a bundle for publishing via the modern v1 REST endpoint. + * Replaces the legacy `/DotAjaxDirector/.../cmd/pushBundle` AJAX action — same + * effect, proper JSON in/out + proper HTTP status codes. + * + * BE endpoint: `POST /api/v1/publishing/push/{bundleId}` (see + * `com.dotcms.rest.api.v1.publishing.PublishingResource.pushBundle`). + * Single-bundle per call; callers needing to push N bundles must fan out + * (e.g. via `forkJoin`). + */ + pushBundle(bundleId: string, form: PushBundleForm): Observable { + return this.http + .post>( + `/api/v1/publishing/push/${bundleId}`, + form + ) + .pipe(map((response) => response.entity)); + } + deleteBundle(bundleId: string): Observable<{ message: string }> { return this.http.delete<{ message: string }>(`/api/v1/publishing/${bundleId}`); } diff --git a/core-web/libs/dotcms-models/src/lib/publishing-job.model.ts b/core-web/libs/dotcms-models/src/lib/publishing-job.model.ts index 2801ee9166e5..f0db4b7352e8 100644 --- a/core-web/libs/dotcms-models/src/lib/publishing-job.model.ts +++ b/core-web/libs/dotcms-models/src/lib/publishing-job.model.ts @@ -56,3 +56,36 @@ export interface PublishingJobsResponse { entity: PublishingJobView[]; pagination: PublishingJobsPagination; } + +/** Operation values accepted by the push-bundle endpoint. Same vocabulary as + * the legacy push-publish form's `pushActionSelected`. */ +export type PushBundleOperation = 'publish' | 'expire' | 'publishexpire'; + +/** + * Request body for `POST /api/v1/publishing/push/{bundleId}`. + * Mirrors `com.dotcms.rest.api.v1.publishing.PushBundleForm`. + * + * Dates must be ISO 8601 with timezone offset (e.g. `2025-03-15T14:30:00-05:00`). + * `publishDate` is required for `publish` / `publishexpire`; `expireDate` is + * required for `expire` / `publishexpire` — the BE enforces this. + */ +export interface PushBundleForm { + operation: PushBundleOperation; + publishDate?: string; + expireDate?: string; + environments: string[]; + filterKey: string; +} + +/** + * Response payload from `POST /api/v1/publishing/push/{bundleId}`. + * Mirrors `com.dotcms.rest.api.v1.publishing.AbstractPushBundleResultView`. + */ +export interface PushBundleResultView { + bundleId: string; + operation: PushBundleOperation; + publishDate: string | null; + expireDate: string | null; + environments: string[]; + filterKey: string; +} 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 d6199bc1ac6c..08a3bb43ba3b 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 @@ -1,309 +1,404 @@
-
- -
-
- - - - -
+ @switch (step()) { + @case ('select') { +
+ +
+
+ + + + +
-
- - - - - - - - {{ 'publishing-queue.select-bundle.bundles' | dm }} - - - - - @if (bundlesStatus() === 'loading') { - - - - - - - - } @else { - - - - - -
-
-
- {{ bundle.name }} -
-
- - {{ bundle.id }} - - + + + + + + + + {{ 'publishing-queue.select-bundle.bundles' | dm }} + + + + + @if (bundlesStatus() === 'loading') { + + + + + + + + } @else { + + + + + +
+
+
+ {{ bundle.name }} +
+
+ + {{ bundle.id }} + + +
+
+ + aria-hidden="true">
-
- -
- - - } - - - - - - {{ 'publishing-queue.select-bundle.empty' | dm }} - - - - - + + + } + + + + + + {{ 'publishing-queue.select-bundle.empty' | dm }} + + + + + +
+ +
+ + {{ 'publishing-queue.select-bundle.page' | dm }} {{ bundlesPage() }} + + + +
+
+ + +
+
+ + + + + {{ 'publishing-queue.select-bundle.col.name' | dm }} + + + {{ 'publishing-queue.select-bundle.col.type' | dm }} + + + + + + @if (assetsStatus() === 'loading') { + + + + + + } @else { + + +
+ + @if (editUrlFor(asset); as editUrl) { + + {{ asset.title || asset.asset }} + + } @else { + + {{ asset.title || asset.asset }} + + } +
+ + + + + + + + + } +
+ + + + + @if (activeBundleId()) { + {{ + 'publishing-queue.select-bundle.asset-empty' + | dm + }} + } @else { + {{ + 'publishing-queue.select-bundle.no-active' | dm + }} + } + + + + +
+
+ +
+ + {{ 'publishing-queue.select-bundle.page' | dm }} {{ assetsPage() }} + + + +
+
+
- - {{ 'publishing-queue.select-bundle.page' | dm }} {{ bundlesPage() }} - + class="flex shrink-0 items-center justify-end gap-3 border-t border-surface-200 px-4 py-3" + data-testid="pq-select-bundle-actions"> + severity="danger" + [disabled]="!hasChecked()" + (onClick)="onRemoveBundles()" + data-testid="pq-select-bundle-remove-btn" /> + +
+ } + @case ('configure') { +
+ + [attr.aria-label]="'publishing-queue.select-bundle.back-to-list' | dm" + (onClick)="onBackToList()" + data-testid="pq-select-bundle-back-btn" /> +

+ {{ 'publishing-queue.select-bundle.configure-and-send' | dm }} +

+
-
- -
-
- - - - - {{ 'publishing-queue.select-bundle.col.name' | dm }} - - - {{ 'publishing-queue.select-bundle.col.type' | dm }} - - - - - - @if (assetsStatus() === 'loading') { - - - - - - } @else { - - -
- - @if (editUrlFor(asset); as editUrl) { - - {{ asset.title || asset.asset }} - - } @else { - - {{ asset.title || asset.asset }} - - } -
- - - - - - - - - } -
- - - - - @if (activeBundleId()) { - {{ 'publishing-queue.select-bundle.asset-empty' | dm }} - } @else { - {{ 'publishing-queue.select-bundle.no-active' | dm }} - } - - - - -
+
+
- - {{ 'publishing-queue.select-bundle.page' | dm }} {{ assetsPage() }} - + class="flex shrink-0 items-center justify-end gap-3 border-t border-surface-200 px-4 py-3" + data-testid="pq-select-bundle-configure-footer"> + [disabled]="isSending()" + (onClick)="onBackToList()" + data-testid="pq-select-bundle-back-to-list-btn" /> + [label]="'publishing-queue.select-bundle.send' | dm" + [loading]="isSending()" + [disabled]="!canSend()" + (onClick)="onSend()" + data-testid="pq-select-bundle-send-btn" />
-
-
- - -
- - - -
+ } + }
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.spec.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.spec.ts index 2062b402adc7..5672f19c6a9a 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.spec.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.spec.ts @@ -1,22 +1,26 @@ import { byTestId, createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; -import { of, throwError } from 'rxjs'; +import { Subject, of, throwError } from 'rxjs'; import { CUSTOM_ELEMENTS_SCHEMA, NO_ERRORS_SCHEMA } from '@angular/core'; import { ConfirmationService } from 'primeng/api'; +import { DynamicDialogRef } from 'primeng/dynamicdialog'; /* eslint-disable @nx/enforce-module-boundaries */ import { DotContentTypeService, DotCurrentUserService, + DotGlobalMessageService, DotHttpErrorManagerService, DotMessageService, - DotPublishingQueueService + DotPublishingQueueService, + DotPushPublishFiltersService } from '@dotcms/data-access'; -import { DotPushPublishDialogService } from '@dotcms/dotcms-js'; +import { DotcmsConfigService } from '@dotcms/dotcms-js'; import { MockDotMessageService } from '@dotcms/utils-testing'; import { DotDownloadBundleDialogService } from '@services/dot-download-bundle-dialog/dot-download-bundle-dialog.service'; +import { DotParseHtmlService } from '@services/dot-parse-html/dot-parse-html.service'; import { DotPublishingQueueSelectBundleDialogComponent } from './dot-publishing-queue-select-bundle-dialog.component'; @@ -39,8 +43,9 @@ describe('DotPublishingQueueSelectBundleDialogComponent', () => { let spectator: Spectator; let service: jest.Mocked; let confirmationService: jest.Mocked; - let pushPublishService: jest.Mocked; let downloadService: jest.Mocked; + let dialogRef: jest.Mocked; + let globalMessage: jest.Mocked; const createComponent = createComponentFactory({ component: DotPublishingQueueSelectBundleDialogComponent, @@ -51,7 +56,17 @@ describe('DotPublishingQueueSelectBundleDialogComponent', () => { removeAssetsFromBundle: jest .fn() .mockReturnValue(of([{ assetId: 'a1', success: true, message: 'ok' }])), - deleteBundles: jest.fn().mockReturnValue(of({ entity: 'ok' })) + deleteBundles: jest.fn().mockReturnValue(of({ entity: 'ok' })), + pushBundle: jest.fn().mockReturnValue( + of({ + bundleId: 'bundle-1', + operation: 'publish', + publishDate: null, + expireDate: null, + environments: ['env-1'], + filterKey: 'default.yml' + }) + ) }), mockProvider(DotCurrentUserService, { getCurrentUser: jest @@ -59,11 +74,25 @@ describe('DotPublishingQueueSelectBundleDialogComponent', () => { .mockReturnValue(of({ userId: 'dotcms.org.1', email: 'admin@dotcms.com' })) }), mockProvider(DotHttpErrorManagerService), - mockProvider(DotPushPublishDialogService, { open: jest.fn() }), mockProvider(DotDownloadBundleDialogService, { open: jest.fn() }), mockProvider(DotContentTypeService, { getContentType: jest.fn().mockReturnValue(of({})) }), + mockProvider(DotGlobalMessageService, { error: jest.fn() }), + mockProvider(DynamicDialogRef, { close: jest.fn() }), + // The dialog provides DotPushPublishFiltersService at the component level + // (mirrors the legacy DotPushPublishDialogComponent). The embedded + // calls .get() on it during ngOnInit. + mockProvider(DotPushPublishFiltersService, { + get: jest.fn().mockReturnValue(of([])) + }), + // The form also calls DotcmsConfigService.getTimeZones() during ngOnInit. + mockProvider(DotcmsConfigService, { + getTimeZones: jest.fn().mockReturnValue(of([])) + }), + // The form injects DotParseHtmlService (only used when `customCode` is in + // data; we don't pass that, but the DI lookup happens unconditionally). + mockProvider(DotParseHtmlService, { parse: jest.fn() }), { provide: DotMessageService, useValue: new MockDotMessageService({}) } ], schemas: [CUSTOM_ELEMENTS_SCHEMA, NO_ERRORS_SCHEMA] @@ -74,12 +103,13 @@ describe('DotPublishingQueueSelectBundleDialogComponent', () => { service = spectator.inject( DotPublishingQueueService ) as jest.Mocked; - pushPublishService = spectator.inject( - DotPushPublishDialogService - ) as jest.Mocked; downloadService = spectator.inject( DotDownloadBundleDialogService ) as jest.Mocked; + dialogRef = spectator.inject(DynamicDialogRef) as jest.Mocked; + globalMessage = spectator.inject( + DotGlobalMessageService + ) as jest.Mocked; confirmationService = spectator.inject( ConfirmationService, true @@ -112,6 +142,11 @@ describe('DotPublishingQueueSelectBundleDialogComponent', () => { expect(service.getBundleAssets).toHaveBeenCalledWith('bundle-1'); expect(spectator.component.assets().length).toBe(2); }); + + it('starts in the select step', () => { + spectator.detectChanges(); + expect(spectator.component.step()).toBe('select'); + }); }); describe('select bundle', () => { @@ -222,38 +257,178 @@ describe('DotPublishingQueueSelectBundleDialogComponent', () => { }); }); - describe('configure / download', () => { - it('Configure → opens push publish dialog for the active bundle', () => { + describe('download (gated to a single checked bundle)', () => { + beforeEach(() => spectator.detectChanges()); + + it('opens the download dialog for the only checked bundle', () => { + spectator.component.onCheckedChange([{ id: 'bundle-2', name: 'Blog content sync' }]); + spectator.component.onDownloadChecked(); + expect(downloadService.open).toHaveBeenCalledWith('bundle-2'); + }); + + it('is a no-op when no bundles are checked', () => { + spectator.component.checkedBundleIds.set([]); + spectator.component.onDownloadChecked(); + expect(downloadService.open).not.toHaveBeenCalled(); + }); + + it('is a no-op when more than one bundle is checked', () => { + spectator.component.onCheckedChange([ + { id: 'bundle-1', name: 'a' }, + { id: 'bundle-2', name: 'b' } + ]); + spectator.component.onDownloadChecked(); + expect(downloadService.open).not.toHaveBeenCalled(); + }); + }); + + describe('configure step transition', () => { + beforeEach(() => spectator.detectChanges()); + + it('Configure → transitions step from "select" to "configure" when bundles are checked', () => { + spectator.component.onCheckedChange([ + { id: 'bundle-1', name: 'Spring campaign refresh' } + ]); + spectator.component.onOpenConfigureStep(); + expect(spectator.component.step()).toBe('configure'); + }); + + it('Configure → is a no-op when nothing is checked (step stays "select")', () => { + spectator.component.checkedBundleIds.set([]); + spectator.component.onOpenConfigureStep(); + expect(spectator.component.step()).toBe('select'); + }); + + it('Back to list → reverts step to "select"', () => { + spectator.component.onCheckedChange([ + { id: 'bundle-1', name: 'Spring campaign refresh' } + ]); + spectator.component.onOpenConfigureStep(); + expect(spectator.component.step()).toBe('configure'); + spectator.component.onBackToList(); + expect(spectator.component.step()).toBe('select'); + }); + + it('configureFormData carries the first checked bundle id and a count title for N>1', () => { + spectator.component.onCheckedChange([ + { id: 'bundle-1', name: 'Spring campaign refresh' }, + { id: 'bundle-2', name: 'Blog content sync' } + ]); + const data = spectator.component.configureFormData(); + expect(data.assetIdentifier).toBe('bundle-1'); + expect(data.title).toBe('2 bundles'); + expect(data.isBundle).toBe(true); + }); + + it('configureFormData uses the bundle name as the title for a single checked bundle', () => { + spectator.component.onCheckedChange([{ id: 'bundle-2', name: 'Blog content sync' }]); + const data = spectator.component.configureFormData(); + expect(data.assetIdentifier).toBe('bundle-2'); + expect(data.title).toBe('Blog content sync'); + }); + }); + + describe('send (fan-out push)', () => { + const validForm = { + pushActionSelected: 'publish', + publishDate: new Date('2026-07-01T10:00:00Z').toString(), + expireDate: new Date('2026-08-01T10:00:00Z').toString(), + environment: ['env-1'], + filterKey: 'default.yml', + timezoneId: 'UTC' + }; + + function primeSendableState(ids: string[]) { + spectator.detectChanges(); + spectator.component.onCheckedChange(ids.map((id) => ({ id, name: id }))); + spectator.component.onOpenConfigureStep(); + spectator.component.onConfigureFormValue(validForm as never); + spectator.component.onConfigureFormValid(true); + } + + it('Send is disabled until the embedded form reports valid', () => { spectator.detectChanges(); - spectator.component.onConfigureActive(); - expect(pushPublishService.open).toHaveBeenCalledWith( - expect.objectContaining({ - assetIdentifier: 'bundle-1', - isBundle: true + spectator.component.onCheckedChange([{ id: 'bundle-1', name: 'a' }]); + spectator.component.onOpenConfigureStep(); + // form has not emitted (value=null, valid=false) → canSend = false + expect(spectator.component.canSend()).toBe(false); + }); + + it('fans out one pushBundle call per checked bundle and closes the dialog on success', () => { + primeSendableState(['bundle-1', 'bundle-2']); + + spectator.component.onSend(); + + expect(service.pushBundle).toHaveBeenCalledTimes(2); + expect(service.pushBundle).toHaveBeenCalledWith( + 'bundle-1', + expect.objectContaining({ operation: 'publish' }) + ); + expect(service.pushBundle).toHaveBeenCalledWith( + 'bundle-2', + expect.objectContaining({ operation: 'publish' }) + ); + expect(dialogRef.close).toHaveBeenCalled(); + }); + + it('surfaces a partial-failure toast and keeps the dialog open if any push fails', () => { + primeSendableState(['bundle-1', 'bundle-2']); + (service.pushBundle as jest.Mock).mockImplementationOnce(() => + of({ + bundleId: 'bundle-1', + operation: 'publish', + publishDate: null, + expireDate: null, + environments: ['env-1'], + filterKey: 'default.yml' }) ); + (service.pushBundle as jest.Mock).mockImplementationOnce(() => + throwError(() => new Error('boom')) + ); + + spectator.component.onSend(); + + expect(globalMessage.error).toHaveBeenCalled(); + expect(dialogRef.close).not.toHaveBeenCalled(); }); - it('Download → opens download dialog for the active bundle', () => { + it('is a no-op when nothing is checked OR the form is invalid', () => { spectator.detectChanges(); - spectator.component.onDownloadActive(); - expect(downloadService.open).toHaveBeenCalledWith('bundle-1'); + spectator.component.checkedBundleIds.set([]); + spectator.component.onConfigureFormValid(true); + spectator.component.onSend(); + expect(service.pushBundle).not.toHaveBeenCalled(); + + spectator.component.onCheckedChange([{ id: 'bundle-1', name: 'a' }]); + spectator.component.onConfigureFormValid(false); + spectator.component.onSend(); + expect(service.pushBundle).not.toHaveBeenCalled(); }); - it('Configure / Download are no-ops when no active bundle', () => { - spectator.detectChanges(); - spectator.component.activeBundleId.set(null); - (pushPublishService.open as jest.Mock).mockClear(); - (downloadService.open as jest.Mock).mockClear(); - spectator.component.onConfigureActive(); - spectator.component.onDownloadActive(); - expect(pushPublishService.open).not.toHaveBeenCalled(); - expect(downloadService.open).not.toHaveBeenCalled(); + it('toggles isSending around the network calls', () => { + const subject = new Subject(); + (service.pushBundle as jest.Mock).mockReturnValueOnce(subject.asObservable()); + primeSendableState(['bundle-1']); + + expect(spectator.component.isSending()).toBe(false); + spectator.component.onSend(); + expect(spectator.component.isSending()).toBe(true); + subject.next({ + bundleId: 'bundle-1', + operation: 'publish', + publishDate: null, + expireDate: null, + environments: ['env-1'], + filterKey: 'default.yml' + }); + subject.complete(); + expect(spectator.component.isSending()).toBe(false); }); }); describe('layout', () => { - it('renders the two panes + action bar', () => { + it('renders the two panes + action bar in the select step', () => { spectator.detectChanges(); expect(spectator.query(byTestId('pq-select-bundle-left'))).toBeTruthy(); expect(spectator.query(byTestId('pq-select-bundle-right'))).toBeTruthy(); @@ -264,5 +439,19 @@ describe('DotPublishingQueueSelectBundleDialogComponent', () => { spectator.detectChanges(); expect(spectator.queryAll(byTestId('pq-select-bundle-row')).length).toBe(2); }); + + it('renders the configure header, body, and footer in the configure step', () => { + spectator.detectChanges(); + spectator.component.onCheckedChange([ + { id: 'bundle-1', name: 'Spring campaign refresh' } + ]); + spectator.component.onOpenConfigureStep(); + spectator.detectChanges(); + expect(spectator.query(byTestId('pq-select-bundle-configure-header'))).toBeTruthy(); + expect(spectator.query(byTestId('pq-select-bundle-configure-body'))).toBeTruthy(); + expect(spectator.query(byTestId('pq-select-bundle-configure-footer'))).toBeTruthy(); + // Select-step content is no longer in the DOM. + expect(spectator.query(byTestId('pq-select-bundle-actions'))).toBeFalsy(); + }); }); }); 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 ada213e3a340..b56a8a2446fd 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 @@ -1,4 +1,4 @@ -import { EMPTY, Subject } from 'rxjs'; +import { EMPTY, Subject, forkJoin, of } from 'rxjs'; import { ChangeDetectionStrategy, @@ -15,6 +15,7 @@ import { FormsModule } from '@angular/forms'; import { ConfirmationService } from 'primeng/api'; import { ButtonModule } from 'primeng/button'; import { ConfirmDialogModule } from 'primeng/confirmdialog'; +import { DynamicDialogRef } from 'primeng/dynamicdialog'; import { IconFieldModule } from 'primeng/iconfield'; import { InputIconModule } from 'primeng/inputicon'; import { InputTextModule } from 'primeng/inputtext'; @@ -26,19 +27,29 @@ import { TooltipModule } from 'primeng/tooltip'; import { catchError, debounceTime, distinctUntilChanged, finalize, take } from 'rxjs/operators'; /* eslint-disable @nx/enforce-module-boundaries */ -// `DotDownloadBundleDialogService` lives in apps/dotcms-ui (not yet promoted to -// a shared lib). Same pattern as `dot-publishing-queue-table`. Tracked -// alongside the v1 consolidation work (#36048). +// Both `DotDownloadBundleDialogService` and `DotPushPublishFormComponent` live +// in apps/dotcms-ui (not yet promoted to shared libs). Same pattern as +// `dot-publishing-queue-table`. Tracked alongside the v1 consolidation work +// (#36048). +import { DotPushPublishFormComponent } from '@components/_common/forms/dot-push-publish-form/dot-push-publish-form.component'; import { DotContentletEditUrlService, DotCurrentUserService, + DotGlobalMessageService, DotHttpErrorManagerService, DotMessageService, - DotPublishingQueueService + DotPublishingQueueService, + DotPushPublishFiltersService } from '@dotcms/data-access'; -import { DotPushPublishDialogService } from '@dotcms/dotcms-js'; -import { BundleAssetView, DotCMSContentlet } from '@dotcms/dotcms-models'; +import { + BundleAssetView, + DotCMSContentlet, + DotPushPublishData, + DotPushPublishDialogData, + PushBundleForm, + PushBundleOperation +} from '@dotcms/dotcms-models'; import { DotCopyButtonComponent, DotMessagePipe } from '@dotcms/ui'; import { DotDownloadBundleDialogService } from '@services/dot-download-bundle-dialog/dot-download-bundle-dialog.service'; @@ -96,9 +107,13 @@ const ASSETS_PER_PAGE = 10; TagModule, TooltipModule, DotCopyButtonComponent, - DotMessagePipe + DotMessagePipe, + DotPushPublishFormComponent ], - providers: [ConfirmationService], + // `DotPushPublishFiltersService` is provided here (same as the legacy global + // `DotPushPublishDialogComponent` does) so the embedded + // `` can inject it without leaking outside this dialog. + providers: [ConfirmationService, DotPushPublishFiltersService], templateUrl: './dot-publishing-queue-select-bundle-dialog.component.html', changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'flex h-full min-h-0 flex-col' } @@ -110,9 +125,10 @@ export class DotPublishingQueueSelectBundleDialogComponent implements OnInit { private readonly confirmationService = inject(ConfirmationService); private readonly dotMessageService = inject(DotMessageService); private readonly destroyRef = inject(DestroyRef); - private readonly pushPublishService = inject(DotPushPublishDialogService); private readonly downloadService = inject(DotDownloadBundleDialogService); private readonly editUrlService = inject(DotContentletEditUrlService); + private readonly globalMessage = inject(DotGlobalMessageService); + private readonly dialogRef = inject(DynamicDialogRef, { optional: true }); private userId: string | null = null; @@ -155,6 +171,37 @@ export class DotPublishingQueueSelectBundleDialogComponent implements OnInit { readonly hasChecked = computed(() => this.checkedBundleIds().length > 0); readonly hasActive = computed(() => this.activeBundleId() !== null); + /** Two-step wizard inside this single modal: step 1 picks bundles, step 2 + * embeds the push-publish form and submits to /api/v1/publishing/push. */ + readonly step = signal<'select' | 'configure'>('select'); + readonly isSending = signal(false); + + /** Latest form value emitted by the embedded ``. The + * form re-emits on every keystroke; we just hold the most recent. */ + readonly configureFormValue = signal(null); + readonly configureFormValid = signal(false); + + /** Send is enabled only when the form is valid AND we're not already + * pushing. Disabled-while-sending prevents double-submit. */ + readonly canSend = computed(() => this.configureFormValid() && !this.isSending()); + + /** Data fed to the embedded ``. `assetIdentifier` + * keys the env selector's "remember last push" — for multi-bundle we use + * the first checked id (same form values get applied to all, so they share + * the same "last push" memory anyway). */ + readonly configureFormData = computed(() => { + const ids = this.checkedBundleIds(); + const first = ids[0] ?? ''; + const firstName = this.bundles().find((b) => b.id === first)?.name ?? first; + const title = ids.length <= 1 ? firstName : `${ids.length} bundles`; + + return { + assetIdentifier: first, + title, + isBundle: true + }; + }); + /** Bundle rows currently selected via checkbox. p-table's `[selection]` * binding wants the row objects (not just ids), so we re-derive them from * the visible bundle list each CD. */ @@ -315,23 +362,78 @@ export class DotPublishingQueueSelectBundleDialogComponent implements OnInit { }); } - onDownloadActive(): void { - const id = this.activeBundleId(); - if (id) { - this.downloadService.open(id); + /** Download is single-target by design — `DotDownloadBundleDialogService.open` + * accepts one bundle id. The button is gated in the template to be enabled + * only when exactly one bundle is checked. */ + onDownloadChecked(): void { + const ids = this.checkedBundleIds(); + if (ids.length !== 1) { + return; } + this.downloadService.open(ids[0]); } - onConfigureActive(): void { - const bundle = this.activeBundle(); - if (!bundle) { + onOpenConfigureStep(): void { + if (!this.hasChecked()) { return; } - this.pushPublishService.open({ - assetIdentifier: bundle.id, - title: bundle.name || bundle.id, - isBundle: true - }); + this.step.set('configure'); + } + + onBackToList(): void { + this.step.set('select'); + } + + onConfigureFormValue(value: DotPushPublishData): void { + this.configureFormValue.set(value); + } + + onConfigureFormValid(valid: boolean): void { + this.configureFormValid.set(valid); + } + + /** + * Fans out one `POST /api/v1/publishing/push/{bundleId}` per checked bundle + * with the same form payload. The user filled the form once; the parallel + * calls are an implementation detail invisible to them. + */ + onSend(): void { + const ids = this.checkedBundleIds(); + const value = this.configureFormValue(); + if (!value || ids.length === 0 || !this.configureFormValid()) { + return; + } + + const form = toPushBundleForm(value); + + this.isSending.set(true); + forkJoin( + ids.map((id) => + this.publishingService + .pushBundle(id, form) + .pipe(catchError(() => of(null))) + ) + ) + .pipe( + take(1), + finalize(() => this.isSending.set(false)) + ) + .subscribe((results) => { + const failed = results.filter((r) => r === null).length; + + if (failed === 0) { + this.dialogRef?.close(); + return; + } + + this.globalMessage.error( + this.dotMessageService.get( + 'publishing-queue.select-bundle.send-partial-fail', + String(failed), + String(ids.length) + ) + ); + }); } private loadBundles(): void { @@ -432,3 +534,104 @@ export class DotPublishingQueueSelectBundleDialogComponent implements OnInit { return this.assetEditUrls().get(asset.asset) ?? null; } } + +/** + * Translates the embedded form's `DotPushPublishData` (the same shape the + * legacy AJAX dialog produces) into the v1 REST endpoint's `PushBundleForm`. + * + * Field map: + * pushActionSelected → operation (same vocabulary, just renamed) + * environment[] → environments[] (renamed) + * filterKey → filterKey + * publishDate + timezoneId → publishDate (ISO 8601 with TZ offset) + * expireDate + timezoneId → expireDate (same) + * + * Date conversion uses the user-selected `timezoneId` so the BE receives the + * intended wall-clock time + offset (mirrors `PublishingJobsHelper#parseISO8601Date` + * server-side). + */ +function toPushBundleForm(value: DotPushPublishData): PushBundleForm { + const operation = value.pushActionSelected as PushBundleOperation; + const form: PushBundleForm = { + operation, + environments: value.environment, + filterKey: value.filterKey ?? '' + }; + + if (operation === 'publish' || operation === 'publishexpire') { + if (value.publishDate) { + form.publishDate = toIso8601WithOffset( + new Date(value.publishDate), + value.timezoneId + ); + } + } + + if (operation === 'expire' || operation === 'publishexpire') { + if (value.expireDate) { + form.expireDate = toIso8601WithOffset( + new Date(value.expireDate), + value.timezoneId + ); + } + } + + return form; +} + +/** + * Formats `date` as ISO 8601 with the timezone offset of `timezoneId`. + * + * The wall-clock parts (yyyy-MM-ddTHH:mm:ss) come from the browser's local time + * — this matches what the user picked in the date picker, and what the legacy + * AJAX path sends. The offset suffix is computed for the user-selected timezone + * at that instant (handles DST transitions correctly via `Intl.DateTimeFormat`). + */ +function toIso8601WithOffset(date: Date, timezoneId: string): string { + const pad = (n: number) => n.toString().padStart(2, '0'); + const yyyy = date.getFullYear(); + const mm = pad(date.getMonth() + 1); + const dd = pad(date.getDate()); + const hh = pad(date.getHours()); + const mi = pad(date.getMinutes()); + const ss = pad(date.getSeconds()); + + const offsetMinutes = getTimezoneOffsetMinutes(date, timezoneId); + const sign = offsetMinutes >= 0 ? '+' : '-'; + const abs = Math.abs(offsetMinutes); + const offHH = pad(Math.floor(abs / 60)); + const offMM = pad(abs % 60); + + return `${yyyy}-${mm}-${dd}T${hh}:${mi}:${ss}${sign}${offHH}:${offMM}`; +} + +/** Returns minutes east of UTC for `timezoneId` at the instant `date` — e.g. + * `-300` for America/New_York during EST, `+60` for Europe/Madrid in winter. + * Diffs the wall-clock parts emitted by `Intl.DateTimeFormat` in UTC vs the + * target zone; correct across DST. */ +function getTimezoneOffsetMinutes(date: Date, timezoneId: string): number { + const fields: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false + }; + const utcParts = partsToEpoch( + new Intl.DateTimeFormat('en-US', { ...fields, timeZone: 'UTC' }).formatToParts(date) + ); + const tzParts = partsToEpoch( + new Intl.DateTimeFormat('en-US', { ...fields, timeZone: timezoneId }).formatToParts(date) + ); + + return Math.round((tzParts - utcParts) / 60000); +} + +function partsToEpoch(parts: Intl.DateTimeFormatPart[]): number { + const get = (type: string) => Number(parts.find((p) => p.type === type)?.value ?? 0); + let hour = get('hour'); + if (hour === 24) hour = 0; + return Date.UTC(get('year'), get('month') - 1, get('day'), hour, get('minute'), get('second')); +} diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index dbca29ee0dfa..51ce7fdd1e7c 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -3797,6 +3797,13 @@ publishing-queue.select-bundle.delete-tooltip=Delete from bundle publishing-queue.select-bundle.remove=Remove publishing-queue.select-bundle.download=Download publishing-queue.select-bundle.configure=Configure +publishing-queue.select-bundle.download.single-only=Select a single bundle to download. +publishing-queue.select-bundle.configure-and-send=Configure & Send +publishing-queue.select-bundle.bundle-count.singular={0} bundle +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.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 From c05873f78359a9bb34e4da6cc85232583b4eb7fc Mon Sep 17 00:00:00 2001 From: hmoreras <31667212+hmoreras@users.noreply.github.com> Date: Thu, 25 Jun 2026 17:59:59 -0600 Subject: [PATCH 27/43] fix(publishing-queue): persist rows-per-page and match content-drive paginator The rows-per-page dropdown was a no-op: onLazyLoad never persisted event.rows to the store, so the next fetch still went out with the stale size. The store's reactive effect also did not track rowsPerPage, so even a direct patch would not have refetched. - Add setRowsPerPage(rows) to the store. It snaps bundlesPage back to 1 on a size change so the user does not land on an out-of-range page. - Track store.rowsPerPage() in the withHooks effect so a size change triggers loadBundles automatically (same way bundlesPage already does). - onLazyLoad now routes a size change through setRowsPerPage when event.rows differs from the store, and through setBundlesPage on a page-only change. Without that split, PrimeNG's combined "page + rows" event was discarded entirely. Paginator chrome now matches dot-folder-list-view (content-drive): showFirstLastIcon=false, showPageLinks=false, showCurrentPageReport=true with a "Page {currentPage}" template. rowsPerPageOptions becomes [20, 40, 60] (was [10, 25, 50]) and the store default rowsPerPage bumps from 10 to 20 to keep the dropdown's selected value valid. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dot-publishing-queue-table.component.html | 6 +++++- ...dot-publishing-queue-table.component.spec.ts | 17 +++++++++++++++++ .../dot-publishing-queue-table.component.ts | 12 +++++++++++- .../src/lib/store/dot-publishing-queue.store.ts | 14 +++++++++++++- 4 files changed, 46 insertions(+), 3 deletions(-) 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 e6b4ccf6cca2..b06a8e391630 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 @@ -5,10 +5,14 @@ [lazy]="true" (onLazyLoad)="onLazyLoad($event)" [paginator]="true" + [showFirstLastIcon]="false" + [showPageLinks]="false" + [showCurrentPageReport]="true" + [currentPageReportTemplate]="('Page' | dm) + ' {currentPage}'" [rows]="store.rowsPerPage()" [totalRecords]="store.bundlesTotal()" [first]="first()" - [rowsPerPageOptions]="[10, 25, 50]" + [rowsPerPageOptions]="rowsPerPageOptions" [rowHover]="true" [scrollable]="true" scrollHeight="flex" diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-table/dot-publishing-queue-table.component.spec.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-table/dot-publishing-queue-table.component.spec.ts index 16fd8de8bda5..31ae15d04e58 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-table/dot-publishing-queue-table.component.spec.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-table/dot-publishing-queue-table.component.spec.ts @@ -62,6 +62,7 @@ describe('DotPublishingQueueTableComponent', () => { bundlesSelectedIds, rowsPerPage, setBundlesPage: jest.fn((p: number) => bundlesPage.set(p)), + setRowsPerPage: jest.fn((r: number) => rowsPerPage.set(r)), cycleBundlesSort: jest.fn(), setBundlesSelection: jest.fn((ids: string[]) => bundlesSelectedIds.set(ids)), clearBundlesSelection: jest.fn(() => bundlesSelectedIds.set([])), @@ -147,6 +148,22 @@ describe('DotPublishingQueueTableComponent', () => { expect(store.openDetail).toHaveBeenCalledWith('b1'); }); + describe('pagination', () => { + it('persists a rows-per-page change so the next fetch picks it up', () => { + // PrimeNG fires onLazyLoad with the new `rows` when the page-size + // dropdown changes. The handler must route that into the store, or + // the next fetch still goes out with the stale size. + spectator.component.onLazyLoad({ first: 0, rows: 40 }); + expect(store.setRowsPerPage).toHaveBeenCalledWith(40); + }); + + it('routes a page-only change through setBundlesPage (not setRowsPerPage)', () => { + spectator.component.onLazyLoad({ first: 10, rows: 10 }); // page 2 with same size + expect(store.setBundlesPage).toHaveBeenCalledWith(2); + expect(store.setRowsPerPage).not.toHaveBeenCalled(); + }); + }); + it('renders a dot-publishing-status-chip per row', () => { const chips = spectator.queryAll('dot-publishing-status-chip'); expect(chips.length).toBe(2); 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 61dc752372d4..e8149faf90ca 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 @@ -100,6 +100,10 @@ export class DotPublishingQueueTableComponent { readonly first = computed(() => (this.store.bundlesPage() - 1) * this.store.rowsPerPage()); + /** Page-size dropdown options. Mirrors `dot-folder-list-view` (content-drive) + * for visual consistency across the admin UI. The store seeds at 20 too. */ + readonly rowsPerPageOptions = [20, 40, 60]; + /** Pass-through config: * - `table-layout: fixed` so each `` is honored exactly. * - When there are rows: `width: auto` so the table sits at the natural sum @@ -220,7 +224,13 @@ export class DotPublishingQueueTableComponent { const rows = (event.rows as number) ?? this.store.rowsPerPage(); const first = (event.first as number) ?? 0; const page = Math.floor(first / rows) + 1; - if (page !== this.store.bundlesPage()) { + + // Persist a rows-per-page change BEFORE the page change so the next + // fetch goes out with the new size. The store's effect debounces both + // patches into a single `loadBundles` call. + if (rows !== this.store.rowsPerPage()) { + this.store.setRowsPerPage(rows); + } else if (page !== this.store.bundlesPage()) { this.store.setBundlesPage(page); } 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 842b1f756510..0e372adfb52a 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 @@ -90,7 +90,7 @@ const initialState: DotPublishingQueueState = { bundlesSortDirection: 'desc', bundlesSelectedIds: [], - rowsPerPage: 10, + rowsPerPage: 20, search: '', statusFilter: [], @@ -318,6 +318,17 @@ export const DotPublishingQueueStore = signalStore( patchState(store, { bundlesPage: page }); }, + /** Updates the rows-per-page size and snaps back to page 1 so the + * user doesn't land on an out-of-range page when shrinking the + * window. The `withHooks` effect tracks `rowsPerPage` and refetches + * automatically. */ + setRowsPerPage(rows: number) { + if (rows === store.rowsPerPage()) { + return; + } + patchState(store, { rowsPerPage: rows, bundlesPage: 1 }); + }, + cycleBundlesSort(field: PublishingSortField) { const current = store.bundlesSort(); const dir = store.bundlesSortDirection(); @@ -510,6 +521,7 @@ export const DotPublishingQueueStore = signalStore( store.bundlesPage(); store.bundlesSort(); store.bundlesSortDirection(); + store.rowsPerPage(); untracked(() => store.loadBundles()); }); From ad5ddccf92b353d993a984ef3cf25aadf42d0dfd Mon Sep 17 00:00:00 2001 From: hmoreras <31667212+hmoreras@users.noreply.github.com> Date: Thu, 25 Jun 2026 18:21:43 -0600 Subject: [PATCH 28/43] =?UTF-8?q?fix(publishing-queue):=20silent=20polling?= =?UTF-8?q?=20=E2=80=94=20stop=20the=20every-15s=20skeleton=20flash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 15-second auto-refresh routed through the same loadBundles() path as user-initiated reloads, which sets bundlesStatus to 'loading'. That flipped the template into the skeleton branch on every tick and produced a visible flicker between the loading state and the actual rows. Background polls now run in a "silent" mode: keep the existing rows on screen, only patch in the new data when the response arrives. User actions (search, filter, page, sort, post-action refresh) still take the loud path so the user gets skeleton feedback that the system is responding to their click. Silent polls also swallow transient errors instead of flipping the table into the red error state — the next tick retries. Loud loads still surface errors via DotHttpErrorManagerService as before. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../lib/store/dot-publishing-queue.store.ts | 33 ++++++++++++++++--- 1 file changed, 28 insertions(+), 5 deletions(-) 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 0e372adfb52a..241e5a85690e 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 @@ -116,8 +116,23 @@ export const DotPublishingQueueStore = signalStore( let pollHandle: ReturnType | null = null; - function loadBundles() { - patchState(store, { bundlesStatus: 'loading' }); + /** + * Fetches the bundles list. + * + * `silent` controls the visible loading state: + * - **false** (default) — user-initiated reload: flips `bundlesStatus` + * to `'loading'` so the table renders skeleton rows. Used for the + * first fetch, search/filter/sort/page changes, and post-action + * refresh — anywhere the user is waiting and needs feedback. + * - **true** — background poll: leaves the existing rows on screen + * and only patches in the new data when the response arrives. + * Prevents the every-15s skeleton flash the polling otherwise + * causes. + */ + function loadBundles(silent = false) { + if (!silent) { + patchState(store, { bundlesStatus: 'loading' }); + } const filter = store.statusFilter(); @@ -136,8 +151,14 @@ export const DotPublishingQueueStore = signalStore( .pipe( take(1), catchError((error) => { - httpErrorManager.handle(error); - patchState(store, { bundlesStatus: 'error' }); + // Silent polls swallow transient errors: keep showing the + // existing rows, let the next tick retry. Surfacing an + // error toast / red-state on a background blink would be + // worse UX than nothing. + if (!silent) { + httpErrorManager.handle(error); + patchState(store, { bundlesStatus: 'error' }); + } return EMPTY; }) @@ -277,7 +298,9 @@ export const DotPublishingQueueStore = signalStore( if (document.hidden) { return; } - loadBundles(); + // Silent: keep existing rows on screen, only swap when the + // response arrives. No skeleton flash every 15 s. + loadBundles(true); }, POLL_INTERVAL_MS); } From 464b91154e34beec749d94ed09bb502c7dcbfe54 Mon Sep 17 00:00:00 2001 From: hmoreras <31667212+hmoreras@users.noreply.github.com> Date: Fri, 26 Jun 2026 09:07:12 -0600 Subject: [PATCH 29/43] =?UTF-8?q?fix(publishing-queue):=20status=20filter?= =?UTF-8?q?=20=E2=80=94=20include=20SCHEDULED,=20dedupe=20by=20label?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The toolbar status filter hardcoded its own list of 18 statuses instead of deriving from the PublishAuditStatus enum. Two visible bugs: - SCHEDULED was missing. When the synthetic status was added to the enum + chip in d1ee134608, the filter array was not updated, so users could not filter for future-dated bundles. - Multiple enum codes share the same translated label (SUCCESS and BUNDLE_SENT_SUCCESSFULLY both render as "Sent"; BUNDLE_SAVED_SUCCESSFULLY as "Saved"; etc.). The dropdown showed them as separate rows, which read as duplicates. Rewires the filter to: - Use STATUS_ORDER as the explicit display order, asserted by a new "covers every value of PublishAuditStatus" test so future enum additions force a placement decision instead of silently disappearing. - Group options by translated label and store the underlying codes on each option. Picking "Sent" sends the union { SUCCESS, BUNDLE_SENT_SUCCESSFULLY } to the BE; the option only renders as selected when ALL of its grouped codes are present in the store filter. - Add explicit regression tests: SCHEDULED is present, "Sent" appears once and groups its two codes, partial-state grouped options are not shown as selected. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...hing-queue-status-filter.component.spec.ts | 198 ++++++++++++++++++ ...ublishing-queue-status-filter.component.ts | 92 ++++++-- 2 files changed, 271 insertions(+), 19 deletions(-) create mode 100644 core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-status-filter/dot-publishing-queue-status-filter.component.spec.ts diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-status-filter/dot-publishing-queue-status-filter.component.spec.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-status-filter/dot-publishing-queue-status-filter.component.spec.ts new file mode 100644 index 000000000000..33712ecbb051 --- /dev/null +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-status-filter/dot-publishing-queue-status-filter.component.spec.ts @@ -0,0 +1,198 @@ +import { createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; + +import { CUSTOM_ELEMENTS_SCHEMA, signal } from '@angular/core'; + +import { DotMessageService } from '@dotcms/data-access'; +import { PublishAuditStatus } from '@dotcms/dotcms-models'; +import { MockDotMessageService } from '@dotcms/utils-testing'; + +import { DotPublishingQueueStatusFilterComponent } from './dot-publishing-queue-status-filter.component'; + +import { DotPublishingQueueStore } from '../../store/dot-publishing-queue.store'; + +// Mirrors the actual labels in Language.properties — the filter dedupes by +// translated label, so the test must give it the same overlaps. +const STATUS_LABELS: Record = { + [PublishAuditStatus.SCHEDULED]: 'Scheduled', + [PublishAuditStatus.BUNDLE_REQUESTED]: 'Pending', + [PublishAuditStatus.WAITING_FOR_PUBLISHING]: 'Waiting', + [PublishAuditStatus.BUNDLING]: 'Bundling', + [PublishAuditStatus.SENDING_TO_ENDPOINTS]: 'Sending', + [PublishAuditStatus.PUBLISHING_BUNDLE]: 'Publishing', + [PublishAuditStatus.RECEIVED_BUNDLE]: 'Received', + [PublishAuditStatus.SUCCESS]: 'Sent', + [PublishAuditStatus.BUNDLE_SENT_SUCCESSFULLY]: 'Sent', + [PublishAuditStatus.BUNDLE_SAVED_SUCCESSFULLY]: 'Saved', + [PublishAuditStatus.SUCCESS_WITH_WARNINGS]: 'Sent (warn)', + [PublishAuditStatus.FAILED_TO_SEND_TO_ALL_GROUPS]: 'Failed (all)', + [PublishAuditStatus.FAILED_TO_SEND_TO_SOME_GROUPS]: 'Failed (some)', + [PublishAuditStatus.FAILED_TO_BUNDLE]: 'Build error', + [PublishAuditStatus.FAILED_TO_SENT]: 'Send error', + [PublishAuditStatus.FAILED_TO_PUBLISH]: 'Publish error', + [PublishAuditStatus.FAILED_INTEGRITY_CHECK]: 'Integrity', + [PublishAuditStatus.INVALID_TOKEN]: 'Auth error', + [PublishAuditStatus.LICENSE_REQUIRED]: 'No license' +}; + +describe('DotPublishingQueueStatusFilterComponent', () => { + let spectator: Spectator; + + const statusFilter = signal([]); + + const createComponent = createComponentFactory({ + component: DotPublishingQueueStatusFilterComponent, + componentProviders: [ + mockProvider(DotPublishingQueueStore, { + statusFilter, + setStatusFilter: jest.fn((codes: PublishAuditStatus[]) => statusFilter.set(codes)) + }) + ], + providers: [ + { + provide: DotMessageService, + useValue: new MockDotMessageService({ + 'publishing-queue.filter.status': 'Status', + search: 'Search', + ...Object.fromEntries( + Object.entries(STATUS_LABELS).map(([code, label]) => [ + `publishing-queue.status.${code}`, + label + ]) + ) + }) + } + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA] + }); + + beforeEach(() => { + statusFilter.set([]); + spectator = createComponent(); + }); + + describe('source-of-truth invariant', () => { + it('STATUS_ORDER covers every value of PublishAuditStatus (catches future enum drift)', () => { + const ordered = new Set( + DotPublishingQueueStatusFilterComponent.STATUS_ORDER + ); + for (const value of Object.values(PublishAuditStatus)) { + expect(ordered.has(value)).toBe(true); + } + }); + + it('exposes SCHEDULED as a filterable status', () => { + // The bug this fix closed: SCHEDULED is in the enum but used to be + // missing from the filter, so users couldn't filter for it. + expect(DotPublishingQueueStatusFilterComponent.STATUS_ORDER).toContain( + PublishAuditStatus.SCHEDULED + ); + }); + }); + + describe('option list (dedupe by label)', () => { + it('renders one option per unique translated label', () => { + const options = ( + spectator.component as unknown as { $options: { label: string }[] } + ).$options; + const labels = options.map((o) => o.label); + // SUCCESS + BUNDLE_SENT_SUCCESSFULLY both render as "Sent" → one option + expect(labels.filter((l) => l === 'Sent').length).toBe(1); + // No duplicates across the full list + expect(new Set(labels).size).toBe(labels.length); + }); + + it('the "Sent" option groups SUCCESS and BUNDLE_SENT_SUCCESSFULLY', () => { + const options = ( + spectator.component as unknown as { + $options: { label: string; codes: PublishAuditStatus[] }[]; + } + ).$options; + const sent = options.find((o) => o.label === 'Sent'); + expect(sent).toBeDefined(); + expect([...(sent?.codes ?? [])].sort()).toEqual( + [ + PublishAuditStatus.SUCCESS, + PublishAuditStatus.BUNDLE_SENT_SUCCESSFULLY + ].sort() + ); + }); + + it('Scheduled appears first in the rendered order', () => { + const options = ( + spectator.component as unknown as { $options: { label: string }[] } + ).$options; + expect(options[0].label).toBe('Scheduled'); + }); + }); + + describe('selection wiring', () => { + function getOptions() { + return ( + spectator.component as unknown as { + $options: { value: string; label: string; codes: PublishAuditStatus[] }[]; + } + ).$options; + } + + function getSelected(): string[] { + return (spectator.component as unknown as { $selected: () => string[] }).$selected(); + } + + function setSelected(labels: string[]): void { + ( + spectator.component as unknown as { $selected: { set: (v: string[]) => void } } + ).$selected.set(labels); + } + + function callOnChange(): void { + ( + spectator.component as unknown as { onChange: () => void } + ).onChange(); + } + + it('picking "Sent" flattens to SUCCESS + BUNDLE_SENT_SUCCESSFULLY in the store', () => { + setSelected(['Sent']); + callOnChange(); + const stored = [...statusFilter()].sort(); + expect(stored).toEqual( + [ + PublishAuditStatus.SUCCESS, + PublishAuditStatus.BUNDLE_SENT_SUCCESSFULLY + ].sort() + ); + }); + + it('shows "Sent" selected only when both grouped codes are present in the store filter', () => { + // Only one of the two grouped codes → option is NOT considered selected. + statusFilter.set([PublishAuditStatus.SUCCESS]); + spectator.detectChanges(); + expect(getSelected()).not.toContain('Sent'); + + // Both codes → option IS considered selected. + statusFilter.set([ + PublishAuditStatus.SUCCESS, + PublishAuditStatus.BUNDLE_SENT_SUCCESSFULLY + ]); + spectator.detectChanges(); + expect(getSelected()).toContain('Sent'); + }); + + it('clearing all empties the store filter', () => { + statusFilter.set([ + PublishAuditStatus.SUCCESS, + PublishAuditStatus.BUNDLE_SENT_SUCCESSFULLY + ]); + spectator.detectChanges(); + ( + spectator.component as unknown as { onRemoveAll: () => void } + ).onRemoveAll(); + expect(statusFilter()).toEqual([]); + }); + + it('exposes a Scheduled option that maps to the SCHEDULED code', () => { + const scheduled = getOptions().find((o) => o.label === 'Scheduled'); + expect(scheduled).toBeDefined(); + expect(scheduled?.codes).toEqual([PublishAuditStatus.SCHEDULED]); + }); + }); +}); diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-status-filter/dot-publishing-queue-status-filter.component.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-status-filter/dot-publishing-queue-status-filter.component.ts index 78a99e6655cc..12e727145134 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-status-filter/dot-publishing-queue-status-filter.component.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-status-filter/dot-publishing-queue-status-filter.component.ts @@ -16,14 +16,24 @@ import { DotMessagePipe } from '@dotcms/ui'; import { DotPublishingQueueStore } from '../../store/dot-publishing-queue.store'; -interface StatusOption { - value: PublishAuditStatus; - label: string; -} - -/** Ordered: active/scheduled first, success, warnings, failures. Keeps the - * checkbox list in a sensible grouping when the popover opens. */ -const STATUS_VALUES: readonly PublishAuditStatus[] = [ +/** + * Display order for the status filter options. + * + * - Drives the visual ordering inside the listbox (lifecycle-first: + * scheduled → queued → in-flight → terminal success → failures). + * - Acts as the single source of truth for which enum values appear in the + * filter. The unit test `covers every value of PublishAuditStatus` asserts + * that this array contains every member of the enum, so any future enum + * addition forces an explicit placement decision here instead of silently + * disappearing from the dropdown — which is exactly how `SCHEDULED` was + * missing before. + * + * Several enum values share a translated label (e.g. SUCCESS and + * BUNDLE_SENT_SUCCESSFULLY both render as "Sent"). They are grouped into a + * single dropdown option at render time — see `$options` below. + */ +const STATUS_ORDER: readonly PublishAuditStatus[] = [ + PublishAuditStatus.SCHEDULED, PublishAuditStatus.BUNDLE_REQUESTED, PublishAuditStatus.WAITING_FOR_PUBLISHING, PublishAuditStatus.BUNDLING, @@ -31,9 +41,9 @@ const STATUS_VALUES: readonly PublishAuditStatus[] = [ PublishAuditStatus.PUBLISHING_BUNDLE, PublishAuditStatus.RECEIVED_BUNDLE, PublishAuditStatus.SUCCESS, - PublishAuditStatus.SUCCESS_WITH_WARNINGS, PublishAuditStatus.BUNDLE_SENT_SUCCESSFULLY, PublishAuditStatus.BUNDLE_SAVED_SUCCESSFULLY, + PublishAuditStatus.SUCCESS_WITH_WARNINGS, PublishAuditStatus.FAILED_TO_SEND_TO_ALL_GROUPS, PublishAuditStatus.FAILED_TO_SEND_TO_SOME_GROUPS, PublishAuditStatus.FAILED_TO_BUNDLE, @@ -44,6 +54,17 @@ const STATUS_VALUES: readonly PublishAuditStatus[] = [ PublishAuditStatus.LICENSE_REQUIRED ]; +interface StatusOption { + /** The translated label — also doubles as the listbox `value` so picking + * "Sent" emits the string "Sent", which we expand back to its codes on + * change. */ + value: string; + label: string; + /** Every enum value that maps to this label. Picking the option in the UI + * sets the store filter to the union of these codes. */ + codes: readonly PublishAuditStatus[]; +} + @Component({ selector: 'dot-publishing-queue-status-filter', standalone: true, @@ -66,24 +87,57 @@ export class DotPublishingQueueStatusFilterComponent { protected readonly listboxPt = CHIP_FILTER_LISTBOX_PT; protected readonly LISTBOX_SCROLL_HEIGHT = '320px'; - protected readonly $options: StatusOption[] = STATUS_VALUES.map((value) => ({ - value, - label: this.dotMessageService.get(`publishing-queue.status.${value}`) - })); + /** Listbox options, deduplicated by translated label. Order follows + * `STATUS_ORDER` (first occurrence wins). */ + protected readonly $options: StatusOption[] = this.buildOptions(); - protected readonly $selected = linkedSignal(() => this.store.statusFilter()); - - protected readonly $selectedLabels = computed(() => { - const lookup = new Map(this.$options.map((o) => [o.value, o.label])); - return this.$selected().map((s) => lookup.get(s) ?? s); + /** Selected labels, reactively derived from the store. An option is + * considered selected when **all** of its codes are present in the store + * filter — picking "Sent" only counts when SUCCESS *and* + * BUNDLE_SENT_SUCCESSFULLY are both in the filter. */ + protected readonly $selected = linkedSignal(() => { + const filter = new Set(this.store.statusFilter()); + return this.$options + .filter((opt) => opt.codes.every((c) => filter.has(c))) + .map((opt) => opt.value); }); + protected readonly $selectedLabels = computed(() => this.$selected()); + + /** Exposed for the unit test that pins the source-of-truth invariant. */ + static readonly STATUS_ORDER = STATUS_ORDER; + protected onChange(): void { - this.store.setStatusFilter(this.$selected()); + const selectedLabels = new Set(this.$selected()); + const codes = this.$options + .filter((opt) => selectedLabels.has(opt.value)) + .flatMap((opt) => [...opt.codes]); + this.store.setStatusFilter(codes); } protected onRemoveAll(): void { this.$selected.set([]); this.onChange(); } + + private buildOptions(): StatusOption[] { + // Group consecutive-or-not enum values by their translated label, in + // STATUS_ORDER. First occurrence determines display position; later + // occurrences just append to the same option's `codes`. + const byLabel = new Map(); + for (const value of STATUS_ORDER) { + const label = this.dotMessageService.get(`publishing-queue.status.${value}`); + const existing = byLabel.get(label); + if (existing) { + existing.codes.push(value); + } else { + byLabel.set(label, { label, codes: [value] }); + } + } + return Array.from(byLabel.values()).map((g) => ({ + value: g.label, + label: g.label, + codes: g.codes + })); + } } From 7bdb68c6dc1b09e6f7b5635ffb41ed4d3fee2ed4 Mon Sep 17 00:00:00 2001 From: hmoreras <31667212+hmoreras@users.noreply.github.com> Date: Fri, 26 Jun 2026 09:50:45 -0600 Subject: [PATCH 30/43] feat(publishing-queue): inline Download menu in Select Bundle dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the Select Bundle dialog's Download button — which used to pop the global DotDownloadBundleDialogComponent on top of the current modal — with an inline tiered menu that opens upward from the button: Download ▾ ├── To Publish › │ ├── Content and Relationships │ ├── Content, Assets and Pages │ ├── Everything and Dependencies │ ├── Force Push Everything │ └── Only Selected Items └── To Unpublish Picking a leaf fires the same backend the legacy modal fires — exact same payload, same .tar.gz — without the second modal layer. - DotPublishingQueueService.generateBundle(id, op, filterKey) POSTs to /api/bundle/_generate with the legacy {'0' | '1'} operation vocabulary the endpoint expects, observes the response as a blob, and parses the filename from content-disposition. Uses HttpClient (not raw fetch) so the project's auth + error interceptors apply. - Select Bundle dialog drops DotDownloadBundleDialogService and gains a downloadFilters signal (loaded once in ngOnInit via DotPushPublish- FiltersService), an isDownloading signal, and a downloadMenuItems computed that builds the 2-level MenuItem array. To Unpublish is a leaf (legacy modal disables the filter dropdown for unpublish anyway). - PrimeNG v21 has no placement prop on TieredMenu/SplitButton/Menu/ Popover, and its DomHandler.absolutePosition measures the viewport, not the dialog container, so auto-flip never triggers for a footer- positioned trigger. onShow event hook reads the trigger rect via getBoundingClientRect and resets the overlay's top so the menu opens above the button. Smallest possible override that still uses PrimeNG's public event API. - DotPushPublishFiltersService removed from the component's providers array — it's providedIn:'root' and stateless, the per-instance copy was a misread of the legacy DotPushPublishDialogComponent pattern. DotDownloadBundleDialogService + DotDownloadBundleDialogComponent are untouched — the global modal remains the entry point for every other caller in the admin UI. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dot-publishing-queue.service.spec.ts | 73 ++++++++ .../dot-publishing-queue.service.ts | 64 ++++++- ...-queue-select-bundle-dialog.component.html | 18 +- ...eue-select-bundle-dialog.component.spec.ts | 149 ++++++++++++++--- ...ng-queue-select-bundle-dialog.component.ts | 158 ++++++++++++++++-- .../WEB-INF/messages/Language.properties | 3 + 6 files changed, 417 insertions(+), 48 deletions(-) diff --git a/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.spec.ts b/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.spec.ts index a77e3e539494..b88874ee1f1f 100644 --- a/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.spec.ts +++ b/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.spec.ts @@ -267,4 +267,77 @@ describe('DotPublishingQueueService', () => { expect(result).toBe(false); }); }); + + // Mirrors the legacy DotDownloadBundleDialogComponent payload byte-for-byte + // so users get the same .tar.gz from the inline menu and the global modal. + describe('generateBundle', () => { + it('POSTs { bundleId, operation, filterKey } to /api/bundle/_generate', () => { + service.generateBundle('b-1', '0', 'ForcePush.yml').subscribe(); + + const req = httpMock.expectOne('/api/bundle/_generate'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ + bundleId: 'b-1', + operation: '0', + filterKey: 'ForcePush.yml' + }); + req.flush(new Blob(['x']), { + status: 200, + statusText: 'OK', + headers: { 'content-disposition': 'attachment; filename=b-1.tar.gz' } + }); + }); + + it('emits { blob, filename } parsed from content-disposition', (done) => { + service.generateBundle('b-1', '0', 'ForcePush.yml').subscribe(({ blob, filename }) => { + expect(blob).toBeInstanceOf(Blob); + expect(filename).toBe('b-1.tar.gz'); + done(); + }); + + const req = httpMock.expectOne('/api/bundle/_generate'); + req.flush(new Blob(['x']), { + status: 200, + statusText: 'OK', + headers: { 'content-disposition': 'attachment; filename=b-1.tar.gz' } + }); + }); + + it('strips surrounding quotes from the filename', (done) => { + service.generateBundle('b-1', '0', 'ForcePush.yml').subscribe(({ filename }) => { + expect(filename).toBe('b-1.tar.gz'); + done(); + }); + + const req = httpMock.expectOne('/api/bundle/_generate'); + req.flush(new Blob(['x']), { + status: 200, + statusText: 'OK', + headers: { 'content-disposition': 'attachment; filename="b-1.tar.gz"' } + }); + }); + + it('returns an empty filename when content-disposition is missing (still emits the blob)', (done) => { + service.generateBundle('b-1', '1', '').subscribe(({ blob, filename }) => { + expect(blob).toBeInstanceOf(Blob); + expect(filename).toBe(''); + done(); + }); + + const req = httpMock.expectOne('/api/bundle/_generate'); + req.flush(new Blob(['x']), { status: 200, statusText: 'OK' }); + }); + + it('passes empty filterKey for unpublish operation', () => { + service.generateBundle('b-1', '1', '').subscribe(); + + const req = httpMock.expectOne('/api/bundle/_generate'); + expect(req.request.body).toEqual({ + bundleId: 'b-1', + operation: '1', + filterKey: '' + }); + req.flush(new Blob(['x']), { status: 200, statusText: 'OK' }); + }); + }); }); diff --git a/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.ts b/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.ts index ffd40e60b111..1b0634d6e4f9 100644 --- a/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.ts +++ b/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.ts @@ -130,10 +130,7 @@ export class DotPublishingQueueService { */ pushBundle(bundleId: string, form: PushBundleForm): Observable { return this.http - .post>( - `/api/v1/publishing/push/${bundleId}`, - form - ) + .post>(`/api/v1/publishing/push/${bundleId}`, form) .pipe(map((response) => response.entity)); } @@ -181,6 +178,48 @@ export class DotPublishingQueueService { return `/api/bundle/_download/${bundleId}`; } + /** + * Mirrors the legacy `DotDownloadBundleDialogComponent` submit: POSTs to + * `/api/bundle/_generate` and returns the resulting `.tar.gz` blob plus the + * filename parsed from the `content-disposition` header. + * + * Used by the inline Download menu in the Select Bundle dialog — picking a + * filter from the menu must produce the exact same file a customer gets + * from the legacy modal, so the wire payload here is byte-identical: + * `{ bundleId, operation: '0' | '1', filterKey }` + * where `'0'` = publish, `'1'` = unpublish (BE vocabulary, not the v1 REST + * `'publish'` / `'expire'` strings — the `_generate` endpoint predates + * v1). + * + * Uses `HttpClient.post` with `{ observe: 'response', responseType: 'blob' }` + * so the project's auth + error interceptors apply and the headers are + * available for the filename parse — the legacy dialog used raw `fetch` + * only because of how it bootstrapped pre-v1. + */ + generateBundle( + bundleId: string, + operation: '0' | '1', + filterKey: string + ): Observable<{ blob: Blob; filename: string }> { + return this.http + .post( + '/api/bundle/_generate', + { bundleId, operation, filterKey }, + { + observe: 'response', + responseType: 'blob' + } + ) + .pipe( + map((response) => ({ + blob: response.body as Blob, + filename: parseFilenameFromContentDisposition( + response.headers.get('content-disposition') + ) + })) + ); + } + /** Builds the absolute download URL for a bundle's manifest CSV. */ getBundleManifestUrl(bundleId: string): string { return `/api/bundle/${bundleId}/manifest`; @@ -292,3 +331,20 @@ export class DotPublishingQueueService { .pipe(map((response) => response.entity)); } } + +/** Pulls `filename` out of a `content-disposition` header. Mirrors the parse + * the legacy download dialog does (`contentDisposition.slice(idx + 'filename='.length)`) + * so generated filenames match exactly. Returns an empty string when the + * header is missing or malformed — `getDownloadLink` will still produce a + * usable anchor in that case. */ +function parseFilenameFromContentDisposition(header: string | null): string { + if (!header) { + return ''; + } + const key = 'filename='; + const idx = header.indexOf(key); + if (idx < 0) { + return ''; + } + return header.slice(idx + key.length).replace(/^"|"$/g, ''); +} 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 08a3bb43ba3b..53ef72509b83 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 @@ -314,19 +314,31 @@ (onClick)="onRemoveBundles()" data-testid="pq-select-bundle-remove-btn" /> + { + mockAnchorClick.mockClear(); spectator = createComponent(); service = spectator.inject( DotPublishingQueueService ) as jest.Mocked; - downloadService = spectator.inject( - DotDownloadBundleDialogService - ) as jest.Mocked; dialogRef = spectator.inject(DynamicDialogRef) as jest.Mocked; globalMessage = spectator.inject( DotGlobalMessageService ) as jest.Mocked; + httpErrorManager = spectator.inject( + DotHttpErrorManagerService + ) as jest.Mocked; confirmationService = spectator.inject( ConfirmationService, true @@ -257,19 +295,67 @@ describe('DotPublishingQueueSelectBundleDialogComponent', () => { }); }); - describe('download (gated to a single checked bundle)', () => { - beforeEach(() => spectator.detectChanges()); - - it('opens the download dialog for the only checked bundle', () => { + describe('inline download menu', () => { + beforeEach(() => { + spectator.detectChanges(); spectator.component.onCheckedChange([{ id: 'bundle-2', name: 'Blog content sync' }]); - spectator.component.onDownloadChecked(); - expect(downloadService.open).toHaveBeenCalledWith('bundle-2'); + }); + + it('builds a 2-level menu: To Publish (with filters) + To Unpublish (leaf)', () => { + const items = spectator.component.downloadMenuItems(); + expect(items.length).toBe(2); + // To Publish parent has the 3 fixture filters as children. + expect(items[0].items?.length).toBe(3); + expect(items[0].items?.map((i) => i.label)).toEqual([ + 'Force Push Everything', + 'Only Selected Items', + 'Content, Assets and Pages' + ]); + // To Unpublish is a leaf (no children, direct command). + expect(items[1].items).toBeUndefined(); + expect(items[1].command).toBeDefined(); + }); + + it('picking a filter leaf calls generateBundle(bundleId, "0", filterKey)', () => { + const items = spectator.component.downloadMenuItems(); + const forcePush = items[0].items?.find((i) => i.label === 'Force Push Everything'); + forcePush?.command?.({} as never); + expect(service.generateBundle).toHaveBeenCalledWith('bundle-2', '0', 'ForcePush.yml'); + expect(mockAnchorClick).toHaveBeenCalled(); + }); + + it('picking To Unpublish calls generateBundle(bundleId, "1", "")', () => { + const items = spectator.component.downloadMenuItems(); + items[1].command?.({} as never); + expect(service.generateBundle).toHaveBeenCalledWith('bundle-2', '1', ''); + expect(mockAnchorClick).toHaveBeenCalled(); + }); + + it('toggles isDownloading around the network call', () => { + const subject = new Subject<{ blob: Blob; filename: string }>(); + (service.generateBundle as jest.Mock).mockReturnValueOnce(subject.asObservable()); + + expect(spectator.component.isDownloading()).toBe(false); + spectator.component.onDownloadOption('1', ''); + expect(spectator.component.isDownloading()).toBe(true); + subject.next({ blob: new Blob(['x']), filename: 'bundle-2.tar.gz' }); + subject.complete(); + expect(spectator.component.isDownloading()).toBe(false); + }); + + it('hands errors off to DotHttpErrorManagerService and releases isDownloading', () => { + const error = new Error('boom'); + (service.generateBundle as jest.Mock).mockReturnValueOnce(throwError(() => error)); + spectator.component.onDownloadOption('0', 'ForcePush.yml'); + expect(httpErrorManager.handle).toHaveBeenCalledWith(error); + expect(spectator.component.isDownloading()).toBe(false); + expect(mockAnchorClick).not.toHaveBeenCalled(); }); it('is a no-op when no bundles are checked', () => { spectator.component.checkedBundleIds.set([]); - spectator.component.onDownloadChecked(); - expect(downloadService.open).not.toHaveBeenCalled(); + spectator.component.onDownloadOption('1', ''); + expect(service.generateBundle).not.toHaveBeenCalled(); }); it('is a no-op when more than one bundle is checked', () => { @@ -277,8 +363,25 @@ describe('DotPublishingQueueSelectBundleDialogComponent', () => { { id: 'bundle-1', name: 'a' }, { id: 'bundle-2', name: 'b' } ]); - spectator.component.onDownloadChecked(); - expect(downloadService.open).not.toHaveBeenCalled(); + spectator.component.onDownloadOption('1', ''); + expect(service.generateBundle).not.toHaveBeenCalled(); + }); + + it('does not re-fire while a download is already in flight', () => { + spectator.component.isDownloading.set(true); + spectator.component.onDownloadOption('1', ''); + expect(service.generateBundle).not.toHaveBeenCalled(); + }); + + it('disables the To Publish item when no filters loaded yet', () => { + // Fresh component instance with the filters service returning nothing + // would yield an empty filter list. Here we simulate that state + // directly on the signal to keep the rest of the suite stable. + spectator.component.downloadFilters.set([]); + const items = spectator.component.downloadMenuItems(); + expect(items[0].disabled).toBe(true); + // To Unpublish stays usable. + expect(items[1].command).toBeDefined(); }); }); 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 b56a8a2446fd..f05fbdf5b970 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 @@ -7,12 +7,13 @@ import { OnInit, computed, inject, - signal + signal, + viewChild } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormsModule } from '@angular/forms'; -import { ConfirmationService } from 'primeng/api'; +import { ConfirmationService, MenuItem } from 'primeng/api'; import { ButtonModule } from 'primeng/button'; import { ConfirmDialogModule } from 'primeng/confirmdialog'; import { DynamicDialogRef } from 'primeng/dynamicdialog'; @@ -22,15 +23,15 @@ import { InputTextModule } from 'primeng/inputtext'; import { SkeletonModule } from 'primeng/skeleton'; import { TableModule } from 'primeng/table'; import { TagModule } from 'primeng/tag'; +import { TieredMenu, TieredMenuModule } from 'primeng/tieredmenu'; import { TooltipModule } from 'primeng/tooltip'; import { catchError, debounceTime, distinctUntilChanged, finalize, take } from 'rxjs/operators'; /* eslint-disable @nx/enforce-module-boundaries */ -// Both `DotDownloadBundleDialogService` and `DotPushPublishFormComponent` live -// in apps/dotcms-ui (not yet promoted to shared libs). Same pattern as -// `dot-publishing-queue-table`. Tracked alongside the v1 consolidation work -// (#36048). +// `DotPushPublishFormComponent` lives in apps/dotcms-ui (not yet promoted to +// shared libs). Same pattern as `dot-publishing-queue-table`. Tracked alongside +// the v1 consolidation work (#36048). import { DotPushPublishFormComponent } from '@components/_common/forms/dot-push-publish-form/dot-push-publish-form.component'; import { @@ -40,6 +41,7 @@ import { DotHttpErrorManagerService, DotMessageService, DotPublishingQueueService, + DotPushPublishFilter, DotPushPublishFiltersService } from '@dotcms/data-access'; import { @@ -51,7 +53,7 @@ import { PushBundleOperation } from '@dotcms/dotcms-models'; import { DotCopyButtonComponent, DotMessagePipe } from '@dotcms/ui'; -import { DotDownloadBundleDialogService } from '@services/dot-download-bundle-dialog/dot-download-bundle-dialog.service'; +import { getDownloadLink } from '@dotcms/utils'; type LoadStatus = 'init' | 'loading' | 'loaded' | 'error'; @@ -105,15 +107,18 @@ const ASSETS_PER_PAGE = 10; SkeletonModule, TableModule, TagModule, + TieredMenuModule, TooltipModule, DotCopyButtonComponent, DotMessagePipe, DotPushPublishFormComponent ], - // `DotPushPublishFiltersService` is provided here (same as the legacy global - // `DotPushPublishDialogComponent` does) so the embedded - // `` can inject it without leaking outside this dialog. - providers: [ConfirmationService, DotPushPublishFiltersService], + // `DotPushPublishFiltersService` is `providedIn: 'root'` (libs/data-access) + // — both the embedded `` and the inline Download + // menu inject the root-scoped singleton. No need to component-provide it + // (the legacy DotPushPublishDialogComponent did, but that's a stateless + // HttpClient wrapper so the per-instance copy was redundant). + providers: [ConfirmationService], templateUrl: './dot-publishing-queue-select-bundle-dialog.component.html', changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'flex h-full min-h-0 flex-col' } @@ -125,7 +130,7 @@ export class DotPublishingQueueSelectBundleDialogComponent implements OnInit { private readonly confirmationService = inject(ConfirmationService); private readonly dotMessageService = inject(DotMessageService); private readonly destroyRef = inject(DestroyRef); - private readonly downloadService = inject(DotDownloadBundleDialogService); + private readonly filtersService = inject(DotPushPublishFiltersService); private readonly editUrlService = inject(DotContentletEditUrlService); private readonly globalMessage = inject(DotGlobalMessageService); private readonly dialogRef = inject(DynamicDialogRef, { optional: true }); @@ -185,6 +190,52 @@ export class DotPublishingQueueSelectBundleDialogComponent implements OnInit { * pushing. Disabled-while-sending prevents double-submit. */ readonly canSend = computed(() => this.configureFormValid() && !this.isSending()); + /** Push publish filters powering the inline Download menu. Loaded once on + * `ngOnInit` from the same source the legacy global dialog uses + * (`/api/v1/pushpublish/filters/`). Empty until the fetch returns — the + * menu just shows "To Unpublish" in the meantime. */ + readonly downloadFilters = signal([]); + + /** True while a `_generate` POST is in flight — disables the Download + * button and swaps its label so the user can't double-click. */ + readonly isDownloading = signal(false); + + /** Refs used to flip the Download tiered menu so it opens upward — the + * button sits in the dialog footer, so the default downward popup would + * either clip against the dialog body or fall off the bottom of the + * viewport. PrimeNG's auto-positioning measures the viewport, not the + * dialog container, so we reposition explicitly in `onDownloadMenuShow`. */ + private readonly downloadMenuRef = viewChild('downloadMenu'); + private downloadTrigger: HTMLElement | null = null; + + /** Two-level menu model for the Download chevron. "To Publish" reveals + * the filter list as a submenu; "To Unpublish" is a leaf that fires the + * download with an empty filterKey (the legacy dialog disables the filter + * dropdown entirely for unpublish, so there's no meaningful sub-choice). */ + readonly downloadMenuItems = computed(() => { + const filters = this.downloadFilters(); + const filterItems: MenuItem[] = filters.map((filter) => ({ + label: filter.title, + command: () => this.onDownloadOption('0', filter.key) + })); + + return [ + { + label: this.dotMessageService.get( + 'publishing-queue.select-bundle.download.to-publish' + ), + items: filterItems, + disabled: filterItems.length === 0 + }, + { + label: this.dotMessageService.get( + 'publishing-queue.select-bundle.download.to-unpublish' + ), + command: () => this.onDownloadOption('1', '') + } + ]; + }); + /** Data fed to the embedded ``. `assetIdentifier` * keys the env selector's "remember last push" — for multi-bundle we use * the first checked id (same form values get applied to all, so they share @@ -237,6 +288,16 @@ export class DotPublishingQueueSelectBundleDialogComponent implements OnInit { this.userId = user.userId; this.loadBundles(); }); + + // Eager-load filters for the inline Download menu. We tolerate errors + // silently — the menu just falls back to a single "To Unpublish" leaf. + this.filtersService + .get() + .pipe( + take(1), + catchError(() => of([] as DotPushPublishFilter[])) + ) + .subscribe((filters) => this.downloadFilters.set(filters)); } onBundleSearch(value: string): void { @@ -362,15 +423,76 @@ export class DotPublishingQueueSelectBundleDialogComponent implements OnInit { }); } - /** Download is single-target by design — `DotDownloadBundleDialogService.open` - * accepts one bundle id. The button is gated in the template to be enabled - * only when exactly one bundle is checked. */ - onDownloadChecked(): void { + /** + * Captures the trigger button reference for the menu flip-up logic, then + * delegates to the menu's own toggle. + */ + onDownloadButtonClick(event: MouseEvent): void { + this.downloadTrigger = (event.currentTarget as HTMLElement) ?? null; + this.downloadMenuRef()?.toggle(event); + } + + /** + * Repositions the tiered-menu popup so it opens *above* the Download + * button instead of below it. + * + * Why: PrimeNG's `DomHandler.absolutePosition` measures the viewport, not + * the surrounding dialog. The Download button lives in the dialog footer, + * so the default downward popup either clips against the dialog body or + * spills out the viewport's bottom. We always want it above. + * + * Called from the `` event after PrimeNG has placed + * the overlay. We read the trigger rect captured by + * `onDownloadButtonClick` and re-set `top` so the popup's bottom edge sits + * just above the trigger. + */ + onDownloadMenuShow(): void { + const trigger = this.downloadTrigger; + const overlay = this.downloadMenuRef()?.containerViewChild?.nativeElement as + | HTMLElement + | undefined; + if (!trigger || !overlay) { + return; + } + const triggerRect = trigger.getBoundingClientRect(); + const gap = 4; + const overlayHeight = overlay.offsetHeight; + overlay.style.top = `${triggerRect.top + window.scrollY - overlayHeight - gap}px`; + } + + /** + * Fires the inline Download menu's leaf click. Replicates the legacy + * `DotDownloadBundleDialogComponent` submit exactly — same endpoint, same + * payload, same blob-anchor click — but with no modal. + * + * `operation`: '0' = publish, '1' = unpublish (BE vocabulary the + * `/api/bundle/_generate` endpoint expects). + * `filterKey`: '' for unpublish, the chosen filter's key otherwise. + * + * Gated to a single checked bundle in the template — the endpoint accepts + * one bundleId per call. + */ + onDownloadOption(operation: '0' | '1', filterKey: string): void { const ids = this.checkedBundleIds(); - if (ids.length !== 1) { + if (ids.length !== 1 || this.isDownloading()) { return; } - this.downloadService.open(ids[0]); + const bundleId = ids[0]; + + this.isDownloading.set(true); + this.publishingService + .generateBundle(bundleId, operation, filterKey) + .pipe( + take(1), + catchError((error) => { + this.httpErrorManager.handle(error); + return EMPTY; + }), + finalize(() => this.isDownloading.set(false)) + ) + .subscribe(({ blob, filename }) => { + getDownloadLink(blob, filename).click(); + }); } onOpenConfigureStep(): void { diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 51ce7fdd1e7c..6767071c6b7f 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -3798,6 +3798,9 @@ publishing-queue.select-bundle.remove=Remove publishing-queue.select-bundle.download=Download publishing-queue.select-bundle.configure=Configure publishing-queue.select-bundle.download.single-only=Select a single bundle to download. +publishing-queue.select-bundle.download.to-publish=To Publish +publishing-queue.select-bundle.download.to-unpublish=To Unpublish +publishing-queue.select-bundle.download.in-progress=Downloading… publishing-queue.select-bundle.configure-and-send=Configure & Send publishing-queue.select-bundle.bundle-count.singular={0} bundle publishing-queue.select-bundle.bundle-count.plural={0} bundles From 6e9a58bf0e338ae636c192cd3d1a06a03e1add5d Mon Sep 17 00:00:00 2001 From: hmoreras <31667212+hmoreras@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:18:59 -0600 Subject: [PATCH 31/43] feat(publishing-queue): make Select Bundle asset rows clickable, drop the link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The asset list inside the Select Bundle dialog rendered each name as an underlined primary-coloured anchor when an edit URL was resolved. That gave the row two click targets (anchor + delete) with no whole-row hover affordance. - Asset name renders as a plain , no anchor, no underline. - The row itself is clickable when DotContentletEditUrlService resolves an edit URL for the asset; clicking opens that URL in a new tab (window.open with target=_blank, noopener) — same context the prior anchor used, so the dialog stays open. - cursor-pointer only when the row has a resolved URL — rows without one (templates, languages, etc.) don't pretend to be clickable. - [rowHover]="true" on the asset p-table for PrimeNG-native row hover bg across the whole table (consistent visual feedback regardless of click affordance). - Trash cell gets pr-3 (12px) right padding so the icon doesn't touch the dialog edge, and (click)="$event.stopPropagation()" so deleting doesn't also navigate. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...-queue-select-bundle-dialog.component.html | 34 ++++++-------- ...eue-select-bundle-dialog.component.spec.ts | 45 +++++++++++++++++++ ...ng-queue-select-bundle-dialog.component.ts | 11 +++++ 3 files changed, 70 insertions(+), 20 deletions(-) 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 53ef72509b83..c551e0dc51b0 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 @@ -165,6 +165,7 @@ [value]="pagedAssets()" dataKey="asset" [scrollable]="false" + [rowHover]="true" [tableStyle]="tableStyleFixed" data-testid="pq-select-bundle-asset-table"> @@ -189,31 +190,22 @@ } @else { - +
- @if (editUrlFor(asset); as editUrl) { - - {{ asset.title || asset.asset }} - - } @else { - - {{ asset.title || asset.asset }} - - } + + {{ asset.title || asset.asset }} +
@@ -226,7 +218,9 @@ tooltipPosition="left" data-testid="pq-select-bundle-asset-type" /> - + { }); }); + describe('asset row click → opens content editor', () => { + let openSpy: jest.SpyInstance; + + beforeEach(() => { + openSpy = jest.spyOn(window, 'open').mockImplementation(() => null); + spectator.detectChanges(); + }); + + afterEach(() => openSpy.mockRestore()); + + it('opens the resolved edit URL in a new tab when the row has one', () => { + // Prime the resolved URL map so editUrlFor() returns a non-null URL. + spectator.component.assetEditUrls.set( + new Map([['a1', '/edit/contentlet/a1']]) + ); + spectator.component.onSelectAssetRow({ + asset: 'a1', + title: 'Spring Sale Landing', + type: 'contentlet' + }); + expect(openSpy).toHaveBeenCalledWith('/edit/contentlet/a1', '_blank', 'noopener'); + }); + + it('is a no-op when the asset has no resolved edit URL', () => { + spectator.component.assetEditUrls.set(new Map()); + spectator.component.onSelectAssetRow({ + asset: 'a1', + title: 'x', + type: 'template' + }); + expect(openSpy).not.toHaveBeenCalled(); + }); + + it('renders the asset name as plain text, not as an anchor', () => { + spectator.component.assetEditUrls.set( + new Map([['a1', '/edit/contentlet/a1']]) + ); + spectator.detectChanges(); + const name = spectator.query(byTestId('pq-select-bundle-asset-name')); + // The element tag must not be — the row, not the name, is the + // clickable surface now. + expect(name?.tagName.toLowerCase()).not.toBe('a'); + }); + }); + describe('remove asset', () => { it('confirms then calls removeAssetsFromBundle and refetches', () => { spectator.detectChanges(); 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 f05fbdf5b970..24fb787628b1 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 @@ -655,6 +655,17 @@ export class DotPublishingQueueSelectBundleDialogComponent implements OnInit { editUrlFor(asset: BundleAssetView): string | null { return this.assetEditUrls().get(asset.asset) ?? null; } + + /** Row click handler for the asset table. Opens the asset's editor in a + * new tab when an edit URL is resolved (matches the prior anchor's + * `target="_blank"` so the dialog stays in context). No-op for assets + * that don't have a resolved URL (e.g. non-contentlet types). */ + onSelectAssetRow(asset: BundleAssetView): void { + const url = this.editUrlFor(asset); + if (url) { + window.open(url, '_blank', 'noopener'); + } + } } /** From bb080e8afe8f7e52dbe7529b4b0d31e57150d8d5 Mon Sep 17 00:00:00 2001 From: hmoreras <31667212+hmoreras@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:19:24 -0600 Subject: [PATCH 32/43] =?UTF-8?q?fix(publishing-queue):=20drop=20sort=20UI?= =?UTF-8?q?=20from=20the=20bundles=20table=20=E2=80=94=20BE=20doesn't=20su?= =?UTF-8?q?pport=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Data Entered / Last Update / Status column headers carried pSortableColumn + directives that emitted lazy-load events with a sortField. The v1 endpoint backing the table (GET /api/v1/publishing) does not accept a sort query param — PublishAuditAPIImpl returns rows in a hardcoded "ORDER BY status_updated DESC" (see SELECT_ALL_ORDER_BY_STATUSUPDATED_DESC at PublishAuditAPIImpl.java:49). So every click on those headers fired a useless network round-trip and the visible order never changed. - Remove pSortableColumn + from the three previously- sortable columns. Headers render as plain text. - Remove the now-unreachable event.sortField branch from onLazyLoad. - Drop the unused PublishingSortField import. Code comments in the template and the handler point at the BE constraint so future devs know exactly where to re-enable when the backend gains the sort param. The store's bundlesSort / bundlesSortDirection state and cycleBundlesSort action are kept intentionally — they'll wire back up cleanly once the BE supports sort. Removing them now would balloon into the service signature, the spec, and several call sites with no immediate benefit. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dot-publishing-queue-table.component.html | 12 +++++----- .../dot-publishing-queue-table.component.ts | 24 ++++++------------- 2 files changed, 13 insertions(+), 23 deletions(-) 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 b06a8e391630..b00aeace0269 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 @@ -41,29 +41,29 @@ data-testid="pq-bundles-col-items"> {{ 'publishing-queue.column.items' | dm }} + {{ 'publishing-queue.column.data-entered' | dm }} - {{ 'publishing-queue.column.last-update' | dm }} - {{ 'publishing-queue.column.status' | dm }} - 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 e8149faf90ca..2b7298631048 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 @@ -22,11 +22,7 @@ import { TooltipModule } from 'primeng/tooltip'; // `DotDownloadBundleDialogService` lives in apps/dotcms-ui (not yet promoted to a // shared lib). Tracked alongside the v1 consolidation work (#36048). -import { - DotGlobalMessageService, - DotMessageService, - PublishingSortField -} from '@dotcms/data-access'; +import { DotGlobalMessageService, DotMessageService } from '@dotcms/data-access'; import { DotPushPublishDialogService } from '@dotcms/dotcms-js'; import { PublishAuditStatus, PublishingJobView } from '@dotcms/dotcms-models'; import { @@ -228,23 +224,17 @@ export class DotPublishingQueueTableComponent { // Persist a rows-per-page change BEFORE the page change so the next // fetch goes out with the new size. The store's effect debounces both // patches into a single `loadBundles` call. + // + // Sort handling is intentionally omitted: the `/api/v1/publishing` + // endpoint does not accept a `sort` query param (the BE always returns + // rows by `status_updated DESC`), so there are no `pSortableColumn` + // directives in the template — PrimeNG never emits a sortField change. + // Re-add the sort branch here when the BE gains the param. if (rows !== this.store.rowsPerPage()) { this.store.setRowsPerPage(rows); } else if (page !== this.store.bundlesPage()) { this.store.setBundlesPage(page); } - - if (event.sortField) { - const field = ( - Array.isArray(event.sortField) ? event.sortField[0] : event.sortField - ) as PublishingSortField; - if ( - field !== this.store.bundlesSort() || - (event.sortOrder === 1 ? 'asc' : 'desc') !== this.store.bundlesSortDirection() - ) { - this.store.cycleBundlesSort(field); - } - } } onSelectionChange(rows: PublishingJobView[]): void { From 52261e65f6a8f19106c27b00ec6547cbd400e4c1 Mon Sep 17 00:00:00 2001 From: hmoreras <31667212+hmoreras@users.noreply.github.com> Date: Fri, 26 Jun 2026 11:24:08 -0600 Subject: [PATCH 33/43] feat(publishing-queue): inline warning instead of disabled buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Select Bundle dialog's Remove / Download / Configure buttons used to render as disabled while the user had nothing checked. The buttons were the most obvious target for new users, so "disabled with no explanation" was an information gap. - The three buttons stay enabled at all times (Download still disables itself while a download is in flight, to prevent double-fire). Click handlers pre-validate the selection: empty → warn, multi for Download → warn, otherwise proceed. - New validationWarningKey signal holds the i18n key of the current warning; the footer renders an inline red message with a triangle icon on the left when the key is set. - onCheckedChange clears the warning automatically so the user gets immediate feedback when they take a corrective action. - Two warning strings: "Select at least one bundle first." (Remove, Configure, Download with N=0) and the existing "Select a single bundle to download." (Download with N>1, repurposed from the old tooltip). - Download's old `[pTooltip]` is gone — the inline warning replaces it and is more discoverable. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...-queue-select-bundle-dialog.component.html | 101 ++++++++++-------- ...eue-select-bundle-dialog.component.spec.ts | 69 +++++++++++- ...ng-queue-select-bundle-dialog.component.ts | 30 +++++- .../WEB-INF/messages/Language.properties | 1 + 4 files changed, 152 insertions(+), 49 deletions(-) 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 c551e0dc51b0..11cc00001971 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 @@ -297,54 +297,63 @@
+
- - - - +
+ @if (validationWarningKey(); as warningKey) { + + + {{ warningKey | dm }} + + } +
+
+ + + + +
} @case ('configure') { 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.spec.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.spec.ts index 9e7032a1269a..c4032dea5214 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.spec.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.spec.ts @@ -332,11 +332,14 @@ describe('DotPublishingQueueSelectBundleDialogComponent', () => { expect(spectator.component.activeBundleId()).toBe('bundle-2'); }); - it('is a no-op when nothing is checked', () => { + it('does not delete and surfaces the "select one" warning when nothing is checked', () => { spectator.detectChanges(); (service.deleteBundles as jest.Mock).mockClear(); spectator.component.onRemoveBundles(); expect(service.deleteBundles).not.toHaveBeenCalled(); + expect(spectator.component.validationWarningKey()).toBe( + 'publishing-queue.select-bundle.warning.select-one' + ); }); }); @@ -441,10 +444,72 @@ describe('DotPublishingQueueSelectBundleDialogComponent', () => { expect(spectator.component.step()).toBe('configure'); }); - it('Configure → is a no-op when nothing is checked (step stays "select")', () => { + it('Configure → does not transition and sets the "select one" warning when nothing is checked', () => { spectator.component.checkedBundleIds.set([]); spectator.component.onOpenConfigureStep(); expect(spectator.component.step()).toBe('select'); + expect(spectator.component.validationWarningKey()).toBe( + 'publishing-queue.select-bundle.warning.select-one' + ); + }); + + it('clicking Download with nothing checked sets the "select one" warning and does not open the menu', () => { + spectator.component.checkedBundleIds.set([]); + const toggleSpy = jest.fn(); + // Stub the menu's toggle so we can assert it was NOT called. + ( + spectator.component as unknown as { + downloadMenuRef: () => { toggle: jest.Mock }; + } + ).downloadMenuRef = () => ({ toggle: toggleSpy }); + + spectator.component.onDownloadButtonClick({ + currentTarget: document.createElement('button') + } as unknown as MouseEvent); + + expect(toggleSpy).not.toHaveBeenCalled(); + expect(spectator.component.validationWarningKey()).toBe( + 'publishing-queue.select-bundle.warning.select-one' + ); + }); + + it('clicking Download with multiple checked sets the "single only" warning and does not open the menu', () => { + spectator.component.onCheckedChange([ + { id: 'bundle-1', name: 'a' }, + { id: 'bundle-2', name: 'b' } + ]); + const toggleSpy = jest.fn(); + ( + spectator.component as unknown as { + downloadMenuRef: () => { toggle: jest.Mock }; + } + ).downloadMenuRef = () => ({ toggle: toggleSpy }); + + spectator.component.onDownloadButtonClick({ + currentTarget: document.createElement('button') + } as unknown as MouseEvent); + + expect(toggleSpy).not.toHaveBeenCalled(); + expect(spectator.component.validationWarningKey()).toBe( + 'publishing-queue.select-bundle.download.single-only' + ); + }); + + it('changing the selection clears any active warning', () => { + spectator.component.validationWarningKey.set( + 'publishing-queue.select-bundle.warning.select-one' + ); + spectator.component.onCheckedChange([{ id: 'bundle-1', name: 'a' }]); + expect(spectator.component.validationWarningKey()).toBeNull(); + }); + + it('renders the warning element in the footer when validationWarningKey is set', () => { + spectator.detectChanges(); + spectator.component.validationWarningKey.set( + 'publishing-queue.select-bundle.warning.select-one' + ); + spectator.detectChanges(); + expect(spectator.query(byTestId('pq-select-bundle-warning'))).toBeTruthy(); }); it('Back to list → reverts step to "select"', () => { 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 24fb787628b1..1eef8b191611 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 @@ -200,6 +200,13 @@ export class DotPublishingQueueSelectBundleDialogComponent implements OnInit { * button and swaps its label so the user can't double-click. */ readonly isDownloading = signal(false); + /** Inline warning shown in the footer's left side when the user clicks an + * action button (Remove / Download / Configure) without a valid selection. + * Cleared automatically when the selection changes — `onCheckedChange` is + * the only place a "valid" state can come into being from this dialog. + * Stored as a translated i18n key, resolved at render time. */ + readonly validationWarningKey = signal(null); + /** Refs used to flip the Download tiered menu so it opens upward — the * button sits in the dialog footer, so the default downward popup would * either clip against the dialog body or fall off the bottom of the @@ -343,6 +350,9 @@ export class DotPublishingQueueSelectBundleDialogComponent implements OnInit { onCheckedChange(ids: BundleRow[]): void { this.checkedBundleIds.set(ids.map((b) => b.id)); + // Any selection change is a direct response to a footer warning — clear + // it so the user gets immediate feedback that their click registered. + this.validationWarningKey.set(null); } typeIcon(type: string): string { @@ -385,8 +395,10 @@ export class DotPublishingQueueSelectBundleDialogComponent implements OnInit { onRemoveBundles(): void { const ids = this.checkedBundleIds(); if (ids.length === 0) { + this.validationWarningKey.set('publishing-queue.select-bundle.warning.select-one'); return; } + this.validationWarningKey.set(null); this.confirmationService.confirm({ header: this.dotMessageService.get('publishing-queue.delete.confirm.header'), message: this.dotMessageService.get( @@ -425,9 +437,23 @@ export class DotPublishingQueueSelectBundleDialogComponent implements OnInit { /** * Captures the trigger button reference for the menu flip-up logic, then - * delegates to the menu's own toggle. + * delegates to the menu's own toggle. Before opening the menu we + * pre-validate selection: empty → "select at least one", multi → "single + * only" (the BE `_generate` endpoint accepts one bundleId per call). */ onDownloadButtonClick(event: MouseEvent): void { + const count = this.checkedBundleIds().length; + if (count === 0) { + this.validationWarningKey.set('publishing-queue.select-bundle.warning.select-one'); + return; + } + if (count > 1) { + this.validationWarningKey.set( + 'publishing-queue.select-bundle.download.single-only' + ); + return; + } + this.validationWarningKey.set(null); this.downloadTrigger = (event.currentTarget as HTMLElement) ?? null; this.downloadMenuRef()?.toggle(event); } @@ -497,8 +523,10 @@ export class DotPublishingQueueSelectBundleDialogComponent implements OnInit { onOpenConfigureStep(): void { if (!this.hasChecked()) { + this.validationWarningKey.set('publishing-queue.select-bundle.warning.select-one'); return; } + this.validationWarningKey.set(null); this.step.set('configure'); } diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 6767071c6b7f..d8a6d916e6b3 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -3801,6 +3801,7 @@ publishing-queue.select-bundle.download.single-only=Select a single bundle to do publishing-queue.select-bundle.download.to-publish=To Publish publishing-queue.select-bundle.download.to-unpublish=To Unpublish publishing-queue.select-bundle.download.in-progress=Downloading… +publishing-queue.select-bundle.warning.select-one=Select at least one bundle first. publishing-queue.select-bundle.configure-and-send=Configure & Send publishing-queue.select-bundle.bundle-count.singular={0} bundle publishing-queue.select-bundle.bundle-count.plural={0} bundles From 8b18c847f6fbf0aa61ffc60d8c616feced094ac4 Mon Sep 17 00:00:00 2001 From: hmoreras <31667212+hmoreras@users.noreply.github.com> Date: Tue, 30 Jun 2026 12:12:50 -0600 Subject: [PATCH 34/43] feat(publishing-queue): surface scheduledPublishDate in bundle details Add a "Scheduled for" row to the bundle details dialog's metadata table that renders only when the bundle is in SCHEDULED status. The BE already returns `scheduledPublishDate` on `GET /api/v1/publishing/{bundleId}` and leaves it null for every other status, so wiring the FE model + a conditional row is all that's needed. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/lib/publishing-job-detail.model.ts | 2 + ...queue-bundle-details-dialog.component.html | 5 +- ...ue-bundle-details-dialog.component.spec.ts | 33 ++++++++ ...g-queue-bundle-details-dialog.component.ts | 77 +++++++++++-------- .../store/dot-publishing-queue.store.spec.ts | 3 +- .../WEB-INF/messages/Language.properties | 1 + 6 files changed, 89 insertions(+), 32 deletions(-) diff --git a/core-web/libs/dotcms-models/src/lib/publishing-job-detail.model.ts b/core-web/libs/dotcms-models/src/lib/publishing-job-detail.model.ts index 5949da01b070..507515214217 100644 --- a/core-web/libs/dotcms-models/src/lib/publishing-job-detail.model.ts +++ b/core-web/libs/dotcms-models/src/lib/publishing-job-detail.model.ts @@ -52,6 +52,8 @@ export interface PublishingJobDetailView { environments: EnvironmentDetailView[]; timestamps: TimestampsView; numTries: number; + /** Future `publishDate` the bundle was pushed with — set only when status is SCHEDULED. */ + scheduledPublishDate: string | null; } /** Per-asset result of `DELETE /v1/bundles/{bundleId}/assets`. */ diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.html b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.html index e943a4232051..c56d43891b25 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.html +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.html @@ -5,7 +5,7 @@ } @else if (store.detail(); as detail) { @@ -29,6 +29,9 @@ @case ('status') { } + @case ('scheduledFor') { + {{ (detail.scheduledPublishDate | date: 'medium') || '—' }} + } @case ('bundleId') { {{ detail.bundleId }} diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.spec.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.spec.ts index 96d6b74f498f..7ca4763ea84c 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.spec.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.spec.ts @@ -50,6 +50,7 @@ const detailFixture = ( statusUpdated: '2026-06-08T10:02:00Z' }, numTries: 1, + scheduledPublishDate: null, ...overrides }); @@ -270,6 +271,38 @@ describe('DotPublishingQueueBundleDetailsDialogComponent', () => { }); }); + describe('scheduledFor meta row', () => { + it('renders the row only when the bundle is in SCHEDULED status', () => { + detail.set( + detailFixture({ + status: PublishAuditStatus.SCHEDULED, + scheduledPublishDate: '2026-09-15T14:30:00Z' + }) + ); + detailStatus.set('loaded'); + spectator.detectChanges(); + + const row = spectator.query('[data-key="scheduledFor"]'); + expect(row).toBeTruthy(); + // DatePipe 'medium' format includes year — assert the year survived + // rather than the exact locale-formatted output, which depends on TZ. + expect(row?.textContent).toContain('2026'); + }); + + it('omits the row for non-SCHEDULED statuses even when scheduledPublishDate is set', () => { + detail.set( + detailFixture({ + status: PublishAuditStatus.SUCCESS, + scheduledPublishDate: '2026-09-15T14:30:00Z' + }) + ); + detailStatus.set('loaded'); + spectator.detectChanges(); + + expect(spectator.query('[data-key="scheduledFor"]')).toBeFalsy(); + }); + }); + it('shows error state', () => { detail.set(null); detailStatus.set('error'); diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.ts index ead0f19ca29f..711380804f7b 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.ts @@ -6,7 +6,7 @@ import { SkeletonModule } from 'primeng/skeleton'; import { TableModule } from 'primeng/table'; import { DotMessageService, DotPublishingQueueService } from '@dotcms/data-access'; -import { EndpointDetailView } from '@dotcms/dotcms-models'; +import { EndpointDetailView, PublishAuditStatus } from '@dotcms/dotcms-models'; import { DotMessagePipe } from '@dotcms/ui'; import { DotPublishingStatusChipComponent } from '../../components/dot-publishing-status-chip/dot-publishing-status-chip.component'; @@ -26,6 +26,7 @@ export interface EndpointTableRow { type MetaKey = | 'title' | 'status' + | 'scheduledFor' | 'bundleId' | 'bundleStart' | 'bundleEnd' @@ -59,35 +60,51 @@ export class DotPublishingQueueBundleDetailsDialogComponent { private readonly publishingService = inject(DotPublishingQueueService); private readonly dotMessageService = inject(DotMessageService); - /** Static list of rows shown in the meta key/value table — the order here - * is the order rendered in the dialog. The body template switches on `key` - * to pick the right value cell. */ - readonly metaRows: readonly MetaRow[] = [ - { key: 'title', label: this.dotMessageService.get('publishing-queue.detail.title') }, - { key: 'status', label: this.dotMessageService.get('publishing-queue.detail.status') }, - { key: 'bundleId', label: this.dotMessageService.get('publishing-queue.detail.bundle-id') }, - { - key: 'bundleStart', - label: this.dotMessageService.get('publishing-queue.detail.bundle-start') - }, - { - key: 'bundleEnd', - label: this.dotMessageService.get('publishing-queue.detail.bundle-end') - }, - { - key: 'publishStart', - label: this.dotMessageService.get('publishing-queue.detail.publish-start') - }, - { - key: 'publishEnd', - label: this.dotMessageService.get('publishing-queue.detail.publish-end') - }, - { key: 'filter', label: this.dotMessageService.get('publishing-queue.detail.filter') }, - { - key: 'assets', - label: this.dotMessageService.get('publishing-queue.detail.total-assets') - } - ]; + /** Rows shown in the meta key/value table — the order here is the order + * rendered. The body template switches on `key` to pick the right value + * cell. Computed because the `scheduledFor` row is only included when the + * bundle is in SCHEDULED status (the BE leaves `scheduledPublishDate` null + * for every other status, so the row would be a permanent "—" otherwise). */ + readonly metaRows = computed(() => { + const isScheduled = this.store.detail()?.status === PublishAuditStatus.SCHEDULED; + return [ + { key: 'title', label: this.dotMessageService.get('publishing-queue.detail.title') }, + { key: 'status', label: this.dotMessageService.get('publishing-queue.detail.status') }, + ...(isScheduled + ? [ + { + key: 'scheduledFor' as const, + label: this.dotMessageService.get('publishing-queue.detail.scheduled-for') + } + ] + : []), + { + key: 'bundleId', + label: this.dotMessageService.get('publishing-queue.detail.bundle-id') + }, + { + key: 'bundleStart', + label: this.dotMessageService.get('publishing-queue.detail.bundle-start') + }, + { + key: 'bundleEnd', + label: this.dotMessageService.get('publishing-queue.detail.bundle-end') + }, + { + key: 'publishStart', + label: this.dotMessageService.get('publishing-queue.detail.publish-start') + }, + { + key: 'publishEnd', + label: this.dotMessageService.get('publishing-queue.detail.publish-end') + }, + { key: 'filter', label: this.dotMessageService.get('publishing-queue.detail.filter') }, + { + key: 'assets', + label: this.dotMessageService.get('publishing-queue.detail.total-assets') + } + ]; + }); /** Both download buttons are driven by HEAD probes the store fires on * `openDetail` — `true` only after the probe confirms the artifact is 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 684f8ec82704..5bd2d66531da 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 @@ -56,7 +56,8 @@ const MOCK_DETAIL: PublishingJobDetailView = { createDate: '2026-06-08T10:00:00Z', statusUpdated: null }, - numTries: 1 + numTries: 1, + scheduledPublishDate: null }; describe('DotPublishingQueueStore', () => { diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index d8a6d916e6b3..26a02d0a976f 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -3876,6 +3876,7 @@ publishing-queue.history.bulk-remove.header=Remove {0} bundles? publishing-queue.history.bulk-remove.message=This will permanently remove {0} bundles from the history. This action cannot be undone. publishing-queue.detail.title=Bundle details publishing-queue.detail.status=Status +publishing-queue.detail.scheduled-for=Scheduled for publishing-queue.detail.bundle-id=Bundle Id publishing-queue.detail.bundle-start=Bundle start publishing-queue.detail.bundle-end=Bundle end From 79b99bb1248563955e2f41f31cc7c54c20720fe7 Mon Sep 17 00:00:00 2001 From: hmoreras <31667212+hmoreras@users.noreply.github.com> Date: Tue, 30 Jun 2026 12:13:16 -0600 Subject: [PATCH 35/43] =?UTF-8?q?feat(publishing-queue):=20swap=20Add=20Bu?= =?UTF-8?q?ndle=20=E2=86=94=20Retry=20Send=20on=20selection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the user checks one or more rows, Add Bundle is replaced by Retry Send as the toolbar's primary action (no icon). Refresh stays in place and Remove moves in as a tertiary danger-text button on the left of Refresh. The "N selected" label is dropped — the checked rows + active primary read the selection just fine. Renames the bulk button label from "Delete" to "Remove" to match the kebab-menu vocabulary used elsewhere in the portlet. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...ot-publishing-queue-toolbar.component.html | 53 ++++++++----------- ...publishing-queue-toolbar.component.spec.ts | 18 ++++--- .../WEB-INF/messages/Language.properties | 3 +- 3 files changed, 36 insertions(+), 38 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 631848f84835..c9a8236a419c 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 @@ -17,26 +17,12 @@
@if (hasBulkActions()) { - - {{ store.bundlesSelectedIds().length }} - {{ 'publishing-queue.selected' | dm }} - - - } - - + @if (hasBulkActions()) { + + } @else { + + + }
diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-toolbar/dot-publishing-queue-toolbar.component.spec.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-toolbar/dot-publishing-queue-toolbar.component.spec.ts index f7003eae37de..fcf8f861ee2b 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-toolbar/dot-publishing-queue-toolbar.component.spec.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-toolbar/dot-publishing-queue-toolbar.component.spec.ts @@ -50,7 +50,7 @@ describe('DotPublishingQueueToolbarComponent', () => { 'publishing-queue.refresh': 'Refresh', 'publishing-queue.upload-bundle': 'Upload Bundle', 'publishing-queue.retry-send': 'Retry Send', - 'publishing-queue.delete-bundles': 'Delete Bundles', + 'publishing-queue.delete-bundles': 'Remove', 'publishing-queue.selected': 'selected' }) } @@ -145,19 +145,25 @@ describe('DotPublishingQueueToolbarComponent', () => { }); }); - describe('Retry Send (selection-gated)', () => { - it('is hidden when nothing is selected', () => { + describe('Retry Send (selection-gated, swaps with Add Bundle)', () => { + it('is hidden when nothing is selected — Add Bundle is the primary', () => { bundlesSelectedIds.set([]); spectator.detectChanges(); expect(spectator.query(byTestId('pq-bulk-retry'))).toBeFalsy(); - expect(spectator.query(byTestId('pq-bulk-count'))).toBeFalsy(); + expect(spectator.query(byTestId('pq-add-bundle-btn'))).toBeTruthy(); }); - it('shows the retry button + selected-count when there is a selection', () => { + it('replaces Add Bundle as the primary when there is a selection', () => { bundlesSelectedIds.set(['b1', 'b2']); spectator.detectChanges(); expect(spectator.query(byTestId('pq-bulk-retry'))).toBeTruthy(); - expect(spectator.query(byTestId('pq-bulk-count'))?.textContent).toContain('2'); + expect(spectator.query(byTestId('pq-add-bundle-btn'))).toBeFalsy(); + }); + + it('does not show a separate selected-count label', () => { + bundlesSelectedIds.set(['b1', 'b2']); + spectator.detectChanges(); + expect(spectator.query(byTestId('pq-bulk-count'))).toBeFalsy(); }); it('clicking retry calls retryBundles with the selected ids', () => { diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 26a02d0a976f..1b5853e6fd5f 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -3859,8 +3859,7 @@ publishing-queue.cancel=Cancel publishing-queue.remove=Remove publishing-queue.retry=Retry publishing-queue.retry-send=Retry Send -publishing-queue.delete-bundles=Delete -publishing-queue.selected=selected +publishing-queue.delete-bundles=Remove publishing-queue.row.actions=Row actions publishing-queue.kebab.configure-send=Configure & send publishing-queue.kebab.generate-download=Generate / download From b4a6b70131cda2120ddace271737b04713ccc707 Mon Sep 17 00:00:00 2001 From: hmoreras <31667212+hmoreras@users.noreply.github.com> Date: Wed, 1 Jul 2026 09:50:11 -0600 Subject: [PATCH 36/43] feat(publishing-queue): replace 4-scope Remove dialog with a simple confirm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old "Select Bundles to Delete" dialog exposed SELECTED / ALL / SUCCESS / FAILED scopes as buttons. That's redundant now that the toolbar's Remove button is selection-gated and the status filter + search let the user shape the working set before selecting. Drop the dialog and replace it with a ConfirmDialog on the shell (same pattern as dot-tags): "Are you sure you want to remove N bundle(s)? Bundles are removed in the background — this action cannot be undone." Co-Authored-By: Claude Opus 4.7 (1M context) --- ...lishing-queue-delete-dialog.component.html | 34 -------- ...hing-queue-delete-dialog.component.spec.ts | 85 ------------------- ...ublishing-queue-delete-dialog.component.ts | 43 ---------- .../dot-publishing-queue-shell.component.html | 2 +- ...t-publishing-queue-shell.component.spec.ts | 79 ++++------------- .../dot-publishing-queue-shell.component.ts | 76 +++++------------ .../WEB-INF/messages/Language.properties | 2 + 7 files changed, 40 insertions(+), 281 deletions(-) delete mode 100644 core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-delete-dialog/dot-publishing-queue-delete-dialog.component.html delete mode 100644 core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-delete-dialog/dot-publishing-queue-delete-dialog.component.spec.ts delete mode 100644 core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-delete-dialog/dot-publishing-queue-delete-dialog.component.ts diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-delete-dialog/dot-publishing-queue-delete-dialog.component.html b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-delete-dialog/dot-publishing-queue-delete-dialog.component.html deleted file mode 100644 index 9457997889aa..000000000000 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-delete-dialog/dot-publishing-queue-delete-dialog.component.html +++ /dev/null @@ -1,34 +0,0 @@ -
-
- - - - -
- - -
diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-delete-dialog/dot-publishing-queue-delete-dialog.component.spec.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-delete-dialog/dot-publishing-queue-delete-dialog.component.spec.ts deleted file mode 100644 index d595d124457a..000000000000 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-delete-dialog/dot-publishing-queue-delete-dialog.component.spec.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { byTestId, createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; - -import { signal } from '@angular/core'; - -import { DynamicDialogRef } from 'primeng/dynamicdialog'; - -import { DotMessageService } from '@dotcms/data-access'; -import { MockDotMessageService } from '@dotcms/utils-testing'; - -import { DotPublishingQueueDeleteDialogComponent } from './dot-publishing-queue-delete-dialog.component'; - -import { DotPublishingQueueStore } from '../../store/dot-publishing-queue.store'; - -describe('DotPublishingQueueDeleteDialogComponent', () => { - let spectator: Spectator; - let dialogRef: jest.Mocked; - - const bundlesSelectedIds = signal([]); - - const createComponent = createComponentFactory({ - component: DotPublishingQueueDeleteDialogComponent, - componentProviders: [mockProvider(DotPublishingQueueStore, { bundlesSelectedIds })], - providers: [ - mockProvider(DynamicDialogRef, { close: jest.fn() }), - { - provide: DotMessageService, - useValue: new MockDotMessageService({ - 'bundle.delete.selected': 'SELECTED', - 'bundle.delete.all': 'ALL', - 'bundle.delete.success': 'SUCCESS', - 'bundle.delete.failed': 'FAILED', - 'bundle.delete.process.info': - 'Bundles will be deleted in the background. Please refresh to update the progress.' - }) - } - ] - }); - - beforeEach(() => { - bundlesSelectedIds.set([]); - spectator = createComponent(); - dialogRef = spectator.inject(DynamicDialogRef) as jest.Mocked; - }); - - function clickButton(testId: string): void { - const btn = spectator.query(byTestId(testId))?.querySelector('button'); - spectator.click(btn as HTMLButtonElement); - } - - it('renders all four scope buttons + the background-process hint', () => { - expect(spectator.query(byTestId('pq-delete-selected'))).toBeTruthy(); - expect(spectator.query(byTestId('pq-delete-all'))).toBeTruthy(); - expect(spectator.query(byTestId('pq-delete-success'))).toBeTruthy(); - expect(spectator.query(byTestId('pq-delete-failed'))).toBeTruthy(); - expect(spectator.query(byTestId('pq-delete-hint'))?.textContent).toContain( - 'will be deleted in the background' - ); - }); - - it('disables SELECTED when there is no selection', () => { - bundlesSelectedIds.set([]); - spectator.detectChanges(); - const btn = spectator.query(byTestId('pq-delete-selected'))?.querySelector('button'); - expect(btn?.hasAttribute('disabled')).toBe(true); - }); - - it('enables SELECTED when there is a selection', () => { - bundlesSelectedIds.set(['b1']); - spectator.detectChanges(); - const btn = spectator.query(byTestId('pq-delete-selected'))?.querySelector('button'); - expect(btn?.hasAttribute('disabled')).toBe(false); - }); - - it.each([ - ['pq-delete-selected', 'selected'], - ['pq-delete-all', 'all'], - ['pq-delete-success', 'success'], - ['pq-delete-failed', 'failed'] - ])('closes with scope "%s" when %s is clicked', (testId, expected) => { - bundlesSelectedIds.set(['b1']); // enable SELECTED for the parameterised test - spectator.detectChanges(); - clickButton(testId); - expect(dialogRef.close).toHaveBeenCalledWith(expected); - }); -}); diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-delete-dialog/dot-publishing-queue-delete-dialog.component.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-delete-dialog/dot-publishing-queue-delete-dialog.component.ts deleted file mode 100644 index 227940c8572e..000000000000 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-delete-dialog/dot-publishing-queue-delete-dialog.component.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; - -import { ButtonModule } from 'primeng/button'; -import { DynamicDialogRef } from 'primeng/dynamicdialog'; -import { MessageModule } from 'primeng/message'; - -import { DotMessagePipe } from '@dotcms/ui'; - -import { DotPublishingQueueStore } from '../../store/dot-publishing-queue.store'; - -/** Scope chosen by the user — emitted back via DynamicDialogRef.close(). */ -export type DeleteBundlesScope = 'selected' | 'all' | 'success' | 'failed'; - -/** - * "Select Bundles to Delete" dialog — mirrors the legacy JSP modal - * (`view_publish_tool.jsp#deleteBundleActions`) with the same four scopes: - * - * SELECTED · ALL · SUCCESS · FAILED - * - * The dialog only collects intent. It closes with a `DeleteBundlesScope`; the - * shell dispatches the matching store action. - * - * SELECTED is disabled when there's no row selection (legacy hid the button — - * we keep it visible but disabled so the user understands the option exists). - */ -@Component({ - selector: 'dot-publishing-queue-delete-dialog', - standalone: true, - imports: [ButtonModule, MessageModule, DotMessagePipe], - templateUrl: './dot-publishing-queue-delete-dialog.component.html', - changeDetection: ChangeDetectionStrategy.OnPush -}) -export class DotPublishingQueueDeleteDialogComponent { - readonly store = inject(DotPublishingQueueStore); - readonly dialogRef = inject(DynamicDialogRef); - - readonly selectedCount = computed(() => this.store.bundlesSelectedIds().length); - readonly hasSelection = computed(() => this.selectedCount() > 0); - - choose(scope: DeleteBundlesScope): void { - this.dialogRef.close(scope); - } -} diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.html b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.html index 3df91617ad33..a4f00c3a81b6 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.html +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.html @@ -1,7 +1,7 @@ + (deleteClick)="confirmDeleteBundles()" /> 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 78ed89244a1c..c68fdfd2dc8b 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 @@ -117,85 +117,40 @@ describe('DotPublishingQueueShellComponent', () => { }); }); - describe('delete bundles dialog', () => { - function openAndCloseWith(scope: 'selected' | 'all' | 'success' | 'failed' | undefined) { - spectator.component.openDeleteBundles(); - expect(dialogService.open).toHaveBeenCalled(); - onCloseSubject.next(scope); - } - - it('opens the delete dialog when openDeleteBundles is called', () => { - spectator.component.openDeleteBundles(); - expect(dialogService.open).toHaveBeenCalled(); - }); - - it('does nothing when the dialog closes with no scope (X / ESC / overlay)', () => { + describe('confirmDeleteBundles', () => { + it('does nothing when there is no selection (defensive guard)', () => { jest.spyOn(store, 'deleteBundlesBulk'); - jest.spyOn(store, 'purgeBundles'); - openAndCloseWith(undefined); - expect(store.deleteBundlesBulk).not.toHaveBeenCalled(); - expect(store.purgeBundles).not.toHaveBeenCalled(); + spectator.component.confirmDeleteBundles(); expect(confirmationService.confirm).not.toHaveBeenCalled(); + expect(store.deleteBundlesBulk).not.toHaveBeenCalled(); }); - it('SELECTED → store.deleteBundlesBulk with current selected ids', () => { + it('opens a ConfirmDialog when there is a selection', () => { store.setBundlesSelection(['b1', 'b2']); - const spy = jest.spyOn(store, 'deleteBundlesBulk').mockReturnValue(undefined); - openAndCloseWith('selected'); - expect(spy).toHaveBeenCalledWith(['b1', 'b2']); - }); - - it('SUCCESS → store.purgeBundles with the SUCCESS status list', () => { - const spy = jest.spyOn(store, 'purgeBundles').mockReturnValue(undefined); - openAndCloseWith('success'); - expect(spy).toHaveBeenCalled(); - const statuses = spy.mock.calls[0][0] as readonly string[]; - expect(statuses).toEqual(expect.arrayContaining(['SUCCESS', 'SUCCESS_WITH_WARNINGS'])); - }); - - it('FAILED → store.purgeBundles with the legacy 5-status FAILED list', () => { - const spy = jest.spyOn(store, 'purgeBundles').mockReturnValue(undefined); - openAndCloseWith('failed'); - expect(spy).toHaveBeenCalled(); - const statuses = spy.mock.calls[0][0] as readonly string[]; - expect(statuses).toEqual( - expect.arrayContaining([ - 'FAILED_TO_SEND_TO_ALL_GROUPS', - 'FAILED_TO_SEND_TO_SOME_GROUPS', - 'FAILED_TO_BUNDLE', - 'FAILED_TO_SENT', - 'FAILED_TO_PUBLISH' - ]) - ); - // Must NOT include the 3 newer statuses (per legacy /api/bundle/all/fail) - expect(statuses).toEqual( - expect.not.arrayContaining([ - 'FAILED_INTEGRITY_CHECK', - 'INVALID_TOKEN', - 'LICENSE_REQUIRED' - ]) - ); + spectator.component.confirmDeleteBundles(); + expect(confirmationService.confirm).toHaveBeenCalled(); }); - it('ALL → confirmation dialog; purgeBundles() with no statuses on accept', () => { - const purgeSpy = jest.spyOn(store, 'purgeBundles').mockReturnValue(undefined); + it('calls store.deleteBundlesBulk with the selected ids on accept', () => { + store.setBundlesSelection(['b1', 'b2']); + const spy = jest.spyOn(store, 'deleteBundlesBulk').mockReturnValue(undefined); confirmationService.confirm.mockImplementation((cfg) => { cfg.accept?.(); return confirmationService; }); - openAndCloseWith('all'); - expect(confirmationService.confirm).toHaveBeenCalled(); - expect(purgeSpy).toHaveBeenCalledWith(); + spectator.component.confirmDeleteBundles(); + expect(spy).toHaveBeenCalledWith(['b1', 'b2']); }); - it('ALL → no purge if the user rejects the confirmation', () => { - const purgeSpy = jest.spyOn(store, 'purgeBundles').mockReturnValue(undefined); + it('does NOT delete on reject', () => { + store.setBundlesSelection(['b1']); + const spy = jest.spyOn(store, 'deleteBundlesBulk').mockReturnValue(undefined); confirmationService.confirm.mockImplementation((cfg) => { cfg.reject?.(); return confirmationService; }); - openAndCloseWith('all'); - expect(purgeSpy).not.toHaveBeenCalled(); + spectator.component.confirmDeleteBundles(); + expect(spy).not.toHaveBeenCalled(); }); }); }); diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.ts index 4eeb4019d991..a5593b8e0a40 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.ts @@ -13,18 +13,10 @@ import { DotPublishingQueueToolbarComponent } from '../components/dot-publishing import { DotPublishingQueueAssetListDialogHeaderComponent } from '../dialogs/dot-publishing-queue-asset-list-dialog/dot-publishing-queue-asset-list-dialog-header.component'; import { DotPublishingQueueAssetListDialogComponent } from '../dialogs/dot-publishing-queue-asset-list-dialog/dot-publishing-queue-asset-list-dialog.component'; import { DotPublishingQueueBundleDetailsDialogComponent } from '../dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component'; -import { - DeleteBundlesScope, - DotPublishingQueueDeleteDialogComponent -} from '../dialogs/dot-publishing-queue-delete-dialog/dot-publishing-queue-delete-dialog.component'; import { DotPublishingQueueSelectBundleDialogComponent } from '../dialogs/dot-publishing-queue-select-bundle-dialog/dot-publishing-queue-select-bundle-dialog.component'; import { DotPublishingQueueUploadDialogComponent } from '../dialogs/dot-publishing-queue-upload-dialog/dot-publishing-queue-upload-dialog.component'; import { DotPublishingQueueTableComponent } from '../dot-publishing-queue-table/dot-publishing-queue-table.component'; -import { - DotPublishingQueueStore, - PURGE_FAILED_STATUSES, - PURGE_SUCCESS_STATUSES -} from '../store/dot-publishing-queue.store'; +import { DotPublishingQueueStore } from '../store/dot-publishing-queue.store'; /** Statuses for which the bundle hasn't yet been packed — assets can still be * edited from the asset list dialog. Anything else is read-only (already in @@ -58,7 +50,6 @@ export class DotPublishingQueueShellComponent { private detailRef: DynamicDialogRef | null = null; private uploadRef: DynamicDialogRef | null = null; private assetListRef: DynamicDialogRef | null = null; - private deleteRef: DynamicDialogRef | null = null; private selectBundleRef: DynamicDialogRef | null = null; constructor() { @@ -115,57 +106,30 @@ export class DotPublishingQueueShellComponent { }); } - /** Opens the "Select Bundles to Delete" dialog; on close, dispatches the - * chosen scope to the store. The ALL scope is gated by a destructive-confirm - * step to match the legacy JSP's pre-call `confirm("This cannot be undone")`. */ - openDeleteBundles(): void { - if (this.deleteRef) { + /** Confirms bulk removal of the currently-selected bundles. The toolbar only + * surfaces the trigger when there's a selection, so we never reach here with + * an empty list under normal use — but we still guard, since signals can + * change between the click and the accept callback. */ + confirmDeleteBundles(): void { + const bundleIds = this.store.bundlesSelectedIds(); + if (bundleIds.length === 0) { return; } - this.deleteRef = this.dialogService.open(DotPublishingQueueDeleteDialogComponent, { - header: this.dotMessageService.get('bundle.delete.title'), - width: '500px', + this.confirmationService.confirm({ + header: this.dotMessageService.get('publishing-queue.bulk-remove.header'), + message: this.dotMessageService.get( + 'publishing-queue.bulk-remove.message', + `${bundleIds.length}` + ), + acceptLabel: this.dotMessageService.get('publishing-queue.remove'), + rejectLabel: this.dotMessageService.get('publishing-queue.cancel'), + acceptButtonStyleClass: 'p-button-primary', + rejectButtonStyleClass: 'p-button-text', + defaultFocus: 'reject', closable: true, closeOnEscape: true, - draggable: false, - position: 'center' + accept: () => this.store.deleteBundlesBulk(this.store.bundlesSelectedIds()) }); - this.deleteRef.onClose.pipe(take(1)).subscribe((scope: DeleteBundlesScope | undefined) => { - this.deleteRef = null; - if (scope) { - this.dispatchDelete(scope); - } - }); - } - - private dispatchDelete(scope: DeleteBundlesScope): void { - switch (scope) { - case 'selected': - this.store.deleteBundlesBulk(this.store.bundlesSelectedIds()); - break; - case 'all': - this.confirmationService.confirm({ - header: this.dotMessageService.get('publishing-queue.delete.confirm.header'), - message: this.dotMessageService.get('bundle.delete.all.confirmation'), - acceptLabel: this.dotMessageService.get( - 'publishing-queue.history.kebab.delete' - ), - rejectLabel: this.dotMessageService.get('publishing-queue.cancel'), - acceptButtonStyleClass: 'p-button-primary', - rejectButtonStyleClass: 'p-button-text', - defaultFocus: 'reject', - closable: true, - closeOnEscape: true, - accept: () => this.store.purgeBundles() - }); - break; - case 'success': - this.store.purgeBundles(PURGE_SUCCESS_STATUSES); - break; - case 'failed': - this.store.purgeBundles(PURGE_FAILED_STATUSES); - break; - } } private syncAssetList(bundleId: string | null): void { diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 1b5853e6fd5f..7b0d0ebe7966 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -3873,6 +3873,8 @@ publishing-queue.delete.confirm.header=Delete publishing-queue.delete.confirm.message=Are you sure you want to delete "{0}"? This action cannot be undone. publishing-queue.history.bulk-remove.header=Remove {0} bundles? publishing-queue.history.bulk-remove.message=This will permanently remove {0} bundles from the history. This action cannot be undone. +publishing-queue.bulk-remove.header=Remove bundles +publishing-queue.bulk-remove.message=Are you sure you want to remove {0} bundle(s)? Bundles are removed in the background — this action cannot be undone. publishing-queue.detail.title=Bundle details publishing-queue.detail.status=Status publishing-queue.detail.scheduled-for=Scheduled for From 556c955cdd807b0f793b30c8fd1b6b3488e2ec11 Mon Sep 17 00:00:00 2001 From: hmoreras <31667212+hmoreras@users.noreply.github.com> Date: Wed, 1 Jul 2026 09:50:55 -0600 Subject: [PATCH 37/43] feat(publishing-queue): asset-list dialog polish + allow remove on SCHEDULED MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cluster of small UX fixes for the "View Contents" dialog: - Row click opens the contentlet in a new tab (same DotContentletEditUrlService pattern as Select Bundle). Non-contentlet rows stay non-clickable. - Row hover uses PrimeNG's rowHover; cursor-pointer only turns on for rows that have a resolved edit URL, so users see which rows are linkable. - Footer Close button wired to DynamicDialogRef.close. - Trash button used focus:opacity-100, which kept the icon visible on the last-clicked row after mouse-out. Switch to focus-visible so mouse clicks don't leave it stuck on; keyboard tab still surfaces it for a11y. - Extra vertical spacing so the search input doesn't crowd the table. - SCHEDULED bundles are queued but not in-flight, so the shell now includes them in EDITABLE_ASSET_STATUSES — users can prune assets before the cron picks the bundle up. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...ing-queue-asset-list-dialog.component.html | 23 ++++-- ...-queue-asset-list-dialog.component.spec.ts | 68 +++++++++++++++- ...shing-queue-asset-list-dialog.component.ts | 81 +++++++++++++++++-- .../dot-publishing-queue-shell.component.ts | 7 +- .../WEB-INF/messages/Language.properties | 1 + 5 files changed, 163 insertions(+), 17 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.html b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-asset-list-dialog/dot-publishing-queue-asset-list-dialog.component.html index f842ff7b50a9..3ef741c3925b 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.html +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-asset-list-dialog/dot-publishing-queue-asset-list-dialog.component.html @@ -1,4 +1,4 @@ -
+
@if (showAssetSearch()) { @@ -20,11 +20,12 @@ }
@@ -51,20 +52,24 @@ - + {{ asset.title }} @if (allowRemove) { - +
+ +
+ +
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.spec.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.spec.ts index c492e3bd3a45..243a068dc20d 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.spec.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.spec.ts @@ -1,11 +1,12 @@ import { byTestId, createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; +import { of } from 'rxjs'; import { signal } from '@angular/core'; import { ConfirmationService } from 'primeng/api'; -import { DynamicDialogConfig } from 'primeng/dynamicdialog'; +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; -import { DotMessageService } from '@dotcms/data-access'; +import { DotContentletEditUrlService, DotMessageService } from '@dotcms/data-access'; import { BundleAssetView } from '@dotcms/dotcms-models'; import { MockDotMessageService } from '@dotcms/utils-testing'; @@ -14,7 +15,7 @@ import { DotPublishingQueueAssetListDialogComponent } from './dot-publishing-que import { DotPublishingQueueStore } from '../../store/dot-publishing-queue.store'; const ASSETS: BundleAssetView[] = [ - { asset: 'a1', title: 'Asset 1', type: 'contentlet' }, + { asset: 'a1', title: 'Asset 1', type: 'contentlet', inode: 'i1', content_type_name: 'Blog' }, { asset: 'a2', title: 'Asset 2', type: 'template' } ]; @@ -22,6 +23,7 @@ describe('DotPublishingQueueAssetListDialogComponent', () => { let spectator: Spectator; let store: ReturnType; let confirmationService: jest.Mocked; + let dialogRef: jest.Mocked; const selectedAssets = signal([]); const assetListStatus = signal<'init' | 'loading' | 'loaded' | 'error'>('loading'); @@ -41,6 +43,10 @@ describe('DotPublishingQueueAssetListDialogComponent', () => { componentProviders: [mockProvider(DotPublishingQueueStore, storeStub())], providers: [ ConfirmationService, + mockProvider(DynamicDialogRef, { close: jest.fn() }), + mockProvider(DotContentletEditUrlService, { + resolveEditUrl: jest.fn().mockReturnValue(of('/dotAdmin/#/edit/inode/i1')) + }), { provide: DotMessageService, useValue: new MockDotMessageService({ @@ -55,7 +61,8 @@ describe('DotPublishingQueueAssetListDialogComponent', () => { 'publishing-queue.detail.search-assets': 'Search assets', 'publishing-queue.detail.assets-no-matches': 'No assets match your search.', 'publishing-queue.remove': 'Remove', - 'publishing-queue.cancel': 'Cancel' + 'publishing-queue.cancel': 'Cancel', + 'publishing-queue.close': 'Close' }) } ] @@ -72,6 +79,7 @@ describe('DotPublishingQueueAssetListDialogComponent', () => { confirmationService = spectator.inject( ConfirmationService ) as jest.Mocked; + dialogRef = spectator.inject(DynamicDialogRef) as jest.Mocked; jest.spyOn(confirmationService, 'confirm').mockImplementation((cfg) => { cfg.accept?.(); return confirmationService; @@ -117,6 +125,54 @@ describe('DotPublishingQueueAssetListDialogComponent', () => { expect(headers.length).toBe(3); }); + describe('row click → open editor', () => { + beforeEach(() => { + selectedAssets.set(ASSETS); + assetListStatus.set('loaded'); + spectator.detectChanges(); + }); + + it('opens the resolved contentlet URL in a new tab', () => { + const openSpy = jest.spyOn(window, 'open').mockReturnValue(null); + // First asset is a contentlet with a resolved URL (see mock in providers). + spectator.component.onAssetRowClick(ASSETS[0]); + expect(openSpy).toHaveBeenCalledWith( + '/dotAdmin/#/edit/inode/i1', + '_blank', + 'noopener' + ); + openSpy.mockRestore(); + }); + + it('is a no-op for non-contentlet assets (no URL resolved)', () => { + const openSpy = jest.spyOn(window, 'open').mockReturnValue(null); + spectator.component.onAssetRowClick(ASSETS[1]); + expect(openSpy).not.toHaveBeenCalled(); + openSpy.mockRestore(); + }); + + it('marks rows with resolved URLs as clickable via cursor-pointer', () => { + const rows = spectator.queryAll(byTestId('pq-asset-list-row')); + // Contentlet row (a1) gets cursor-pointer; template row (a2) does not. + expect(rows[0].classList.contains('cursor-pointer')).toBe(true); + expect(rows[1].classList.contains('cursor-pointer')).toBe(false); + }); + }); + + describe('close button', () => { + it('renders a Close button in the footer', () => { + selectedAssets.set(ASSETS); + assetListStatus.set('loaded'); + spectator.detectChanges(); + expect(spectator.query(byTestId('pq-asset-list-close-btn'))).toBeTruthy(); + }); + + it('closes the dialog when clicked', () => { + spectator.component.closeDialog(); + expect(dialogRef.close).toHaveBeenCalled(); + }); + }); + describe('per-row remove asset', () => { beforeEach(() => { selectedAssets.set(ASSETS); @@ -227,6 +283,10 @@ describe('DotPublishingQueueAssetListDialogComponent — read-only (allowRemove= ], providers: [ ConfirmationService, + mockProvider(DynamicDialogRef, { close: jest.fn() }), + mockProvider(DotContentletEditUrlService, { + resolveEditUrl: jest.fn().mockReturnValue(of('/dotAdmin/#/edit/inode/i1')) + }), { provide: DynamicDialogConfig, useValue: { data: { allowRemove: false } } }, { provide: DotMessageService, useValue: new MockDotMessageService({}) } ] 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 c6541cffebb4..ae111c2069e7 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 @@ -16,7 +16,7 @@ import { FormsModule } from '@angular/forms'; import { ConfirmationService } from 'primeng/api'; import { ButtonModule } from 'primeng/button'; import { ConfirmDialogModule } from 'primeng/confirmdialog'; -import { DynamicDialogConfig } from 'primeng/dynamicdialog'; +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; import { IconFieldModule } from 'primeng/iconfield'; import { InputIconModule } from 'primeng/inputicon'; import { InputTextModule } from 'primeng/inputtext'; @@ -25,10 +25,10 @@ import { TableModule } from 'primeng/table'; import { TagModule } from 'primeng/tag'; import { TooltipModule } from 'primeng/tooltip'; -import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; +import { debounceTime, distinctUntilChanged, take } from 'rxjs/operators'; -import { DotMessageService } from '@dotcms/data-access'; -import { BundleAssetView } from '@dotcms/dotcms-models'; +import { DotContentletEditUrlService, DotMessageService } from '@dotcms/data-access'; +import { BundleAssetView, DotCMSContentlet } from '@dotcms/dotcms-models'; import { DotMessagePipe } from '@dotcms/ui'; import { DotPublishingQueueStore } from '../../store/dot-publishing-queue.store'; @@ -62,8 +62,10 @@ export class DotPublishingQueueAssetListDialogComponent { private readonly confirmationService = inject(ConfirmationService); private readonly dotMessageService = inject(DotMessageService); private readonly destroyRef = inject(DestroyRef); + private readonly editUrlService = inject(DotContentletEditUrlService); /** Optional — present only when opened via DialogService. */ private readonly dialogConfig = inject(DynamicDialogConfig, { optional: true }); + private readonly dialogRef = inject(DynamicDialogRef, { optional: true }); /** When opened from the History tab the bundle is already in `publish_audit` * and assets can no longer be removed — the dialog renders as read-only. @@ -77,6 +79,12 @@ export class DotPublishingQueueAssetListDialogComponent { readonly assetSearch = signal(''); private readonly searchSubject = new Subject(); + /** Per-asset edit URL, resolved by `DotContentletEditUrlService` after each + * asset list load. Non-contentlet assets (templates, languages, containers, + * etc.) are never resolved and stay plain text — same rule as the Select + * Bundle dialog. */ + readonly assetEditUrls = signal>(new Map()); + /** Search input only shows when the loaded asset list has more than ASSET_SEARCH_THRESHOLD items. */ readonly showAssetSearch = computed( () => this.store.selectedAssets().length > ASSET_SEARCH_THRESHOLD @@ -108,10 +116,27 @@ export class DotPublishingQueueAssetListDialogComponent { .pipe(debounceTime(300), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) .subscribe((value) => this.assetSearch.set(value)); - // Reset the input every time the dialog is reused for a different bundle. + // Reset input + resolved URLs every time the dialog is reused for a + // different bundle. The bundleId signal changes before assets stream in, + // so this only cleans up — resolution kicks off in the assets effect below. effect(() => { this.store.selectedBundleId(); - untracked(() => this.assetSearch.set('')); + untracked(() => { + this.assetSearch.set(''); + this.assetEditUrls.set(new Map()); + }); + }); + + // Resolve contentlet edit URLs once the store finishes loading. The + // service caches by content type, so many contentlets of the same type + // trigger a single metadata fetch. + effect(() => { + const status = this.store.assetListStatus(); + const assets = this.store.selectedAssets(); + if (status !== 'loaded') { + return; + } + untracked(() => this.resolveAssetEditUrls(assets)); }); } @@ -119,6 +144,50 @@ export class DotPublishingQueueAssetListDialogComponent { this.searchSubject.next(value); } + /** Opens the resolved contentlet editor URL in a new tab. No-op for assets + * without a resolved URL (non-contentlet types or still-loading). Mirrors the + * behavior of the Select Bundle dialog. */ + onAssetRowClick(asset: BundleAssetView): void { + const url = this.editUrlFor(asset); + if (url) { + window.open(url, '_blank', 'noopener'); + } + } + + /** Template helper — used to bind `cursor-pointer` on linkable rows and to + * short-circuit `onAssetRowClick` when the asset isn't linkable. */ + editUrlFor(asset: BundleAssetView): string | null { + return this.assetEditUrls().get(asset.asset) ?? null; + } + + /** Closes the dialog. Called from the footer Close button. */ + closeDialog(): void { + this.dialogRef?.close(); + } + + private resolveAssetEditUrls(assets: BundleAssetView[]): void { + const urls = new Map(); + + for (const asset of assets) { + if (asset.type !== 'contentlet' || !asset.inode) { + continue; + } + const partial = { + inode: asset.inode, + contentType: asset.content_type_name ?? '' + } as DotCMSContentlet; + + this.editUrlService + .resolveEditUrl(partial) + .pipe(take(1)) + .subscribe((url) => { + urls.set(asset.asset, url); + // Re-emit to notify signal consumers — Map mutation alone won't. + this.assetEditUrls.set(new Map(urls)); + }); + } + } + onRemoveAsset(asset: BundleAssetView): void { this.confirmationService.confirm({ header: this.dotMessageService.get('publishing-queue.asset-list.remove-confirm.header'), diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.ts index a5593b8e0a40..b54ca188bda4 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.ts @@ -20,11 +20,14 @@ import { DotPublishingQueueStore } from '../store/dot-publishing-queue.store'; /** Statuses for which the bundle hasn't yet been packed — assets can still be * edited from the asset list dialog. Anything else is read-only (already in - * `publish_audit`). */ + * `publish_audit`). SCHEDULED bundles are queued for a future publish date but + * haven't been picked up by the cron yet, so removing an asset is still safe — + * the BE only rejects removal for `in progress` bundles. */ const EDITABLE_ASSET_STATUSES = new Set([ null, PublishAuditStatus.BUNDLE_REQUESTED, - PublishAuditStatus.WAITING_FOR_PUBLISHING + PublishAuditStatus.WAITING_FOR_PUBLISHING, + PublishAuditStatus.SCHEDULED ]); @Component({ diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 7b0d0ebe7966..a665acbfd8f0 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -3856,6 +3856,7 @@ publishing-queue.tab.history=History publishing-queue.column.status=Status publishing-queue.accept=Accept publishing-queue.cancel=Cancel +publishing-queue.close=Close publishing-queue.remove=Remove publishing-queue.retry=Retry publishing-queue.retry-send=Retry Send From b3934bdf4549aecf8b505435ec5924570434ab21 Mon Sep 17 00:00:00 2001 From: hmoreras <31667212+hmoreras@users.noreply.github.com> Date: Wed, 1 Jul 2026 09:51:08 -0600 Subject: [PATCH 38/43] fix(publishing-queue): breathe some room around the Select Bundle trash icon The delete icon was flush with the dialog's right edge because the trash column was 3.5rem wide with only pr-3 of padding on the cell. Widen the column to 5rem and bump the padding to pr-5 so the icon sits away from the dialog border. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dot-publishing-queue-select-bundle-dialog.component.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 11cc00001971..9c19792530e7 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 @@ -179,7 +179,7 @@ class="text-color-secondary text-xs font-semibold tracking-wide uppercase"> {{ 'publishing-queue.select-bundle.col.type' | dm }} - + @@ -219,7 +219,7 @@ data-testid="pq-select-bundle-asset-type" /> Date: Thu, 2 Jul 2026 14:28:36 -0600 Subject: [PATCH 39/43] feat(publishing-queue): primary Close + unified date format on details dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes to the Bundle Details and View Contents dialogs: - Add a primary Close button to the Bundle Details footer (previously the only exit was the dialog's X). View Contents already had a text Close button; promoted it to primary to match. Both dialogs now expose the same terminal action. - Swap the timestamp cells in the details dialog from Angular's 'medium' format to `MMM d, y - h:mm:ss a` (e.g. "Jun 25, 2026 - 5:57:02 PM") for consistency with the design spec — same format now applies to Scheduled for / Bundle start-end / Publish start-end. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...ing-queue-asset-list-dialog.component.html | 1 - ...queue-bundle-details-dialog.component.html | 60 ++++++++++--------- ...g-queue-bundle-details-dialog.component.ts | 6 ++ 3 files changed, 37 insertions(+), 30 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.html b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-asset-list-dialog/dot-publishing-queue-asset-list-dialog.component.html index 3ef741c3925b..50c1dffb785c 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.html +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-asset-list-dialog/dot-publishing-queue-asset-list-dialog.component.html @@ -103,7 +103,6 @@
diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.html b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.html index c56d43891b25..bd7ab37632e5 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.html +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.html @@ -30,7 +30,7 @@ } @case ('scheduledFor') { - {{ (detail.scheduledPublishDate | date: 'medium') || '—' }} + {{ (detail.scheduledPublishDate | date: 'MMM d, y - h:mm:ss a') || '—' }} } @case ('bundleId') { @@ -38,16 +38,16 @@ } @case ('bundleStart') { - {{ (detail.timestamps.bundleStart | date: 'medium') || '—' }} + {{ (detail.timestamps.bundleStart | date: 'MMM d, y - h:mm:ss a') || '—' }} } @case ('bundleEnd') { - {{ (detail.timestamps.bundleEnd | date: 'medium') || '—' }} + {{ (detail.timestamps.bundleEnd | date: 'MMM d, y - h:mm:ss a') || '—' }} } @case ('publishStart') { - {{ (detail.timestamps.publishStart | date: 'medium') || '—' }} + {{ (detail.timestamps.publishStart | date: 'MMM d, y - h:mm:ss a') || '—' }} } @case ('publishEnd') { - {{ (detail.timestamps.publishEnd | date: 'medium') || '—' }} + {{ (detail.timestamps.publishEnd | date: 'MMM d, y - h:mm:ss a') || '—' }} } @case ('filter') { {{ detail.filterName || detail.filterKey || '—' }} @@ -113,30 +113,32 @@

} - @if (canDownloadBundle() || canDownloadManifest()) { - - } +
+ @if (canDownloadManifest()) { + + + {{ 'publishing-queue.detail.download-manifest' | dm }} + + } + @if (canDownloadBundle()) { + + + {{ 'publishing-queue.detail.download' | dm }} + + } + +
} @else if (store.detailStatus() === 'error') {
{{ 'publishing-queue.detail.load-error' | dm }} diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.ts index 711380804f7b..8912987ed7eb 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-bundle-details-dialog/dot-publishing-queue-bundle-details-dialog.component.ts @@ -2,6 +2,7 @@ import { DatePipe } from '@angular/common'; import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; import { ButtonModule } from 'primeng/button'; +import { DynamicDialogRef } from 'primeng/dynamicdialog'; import { SkeletonModule } from 'primeng/skeleton'; import { TableModule } from 'primeng/table'; @@ -59,6 +60,7 @@ export class DotPublishingQueueBundleDetailsDialogComponent { private readonly publishingService = inject(DotPublishingQueueService); private readonly dotMessageService = inject(DotMessageService); + private readonly dialogRef = inject(DynamicDialogRef, { optional: true }); /** Rows shown in the meta key/value table — the order here is the order * rendered. The body template switches on `key` to pick the right value @@ -156,4 +158,8 @@ export class DotPublishingQueueBundleDetailsDialogComponent { manifestHref(bundleId: string): string { return this.publishingService.getBundleManifestUrl(bundleId); } + + closeDialog(): void { + this.dialogRef?.close(); + } } From 700a9f77f70f807b61428277e622db71ada2194f Mon Sep 17 00:00:00 2001 From: hmoreras <31667212+hmoreras@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:28:53 -0600 Subject: [PATCH 40/43] feat(publishing-queue): table typography, date format, frozen edge columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related polish items on the main bundles table: - Unify column font size — drop text-xs from Bundle Id, Date Entered, and Last Update so all columns read at the same weight instead of the id + dates being visually demoted. - Adopt the new date format `MMM d, y - h:mm:ss a` on both Date Entered and the fallback in the Last Update dotRelativeDate call (the relative "N days ago" behavior is unchanged; it just uses the new format when the diff crosses the relative window). - Freeze the checkbox column (left) and the kebab-actions column (right) via pFrozenColumn. On narrow viewports the middle columns scroll horizontally while bulk-selection and per-row actions remain reachable. On wide screens PrimeNG doesn't render the sticky shadow, so no visual regression on desktop. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dot-publishing-queue-table.component.html | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) 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 b00aeace0269..f8732ff99065 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 @@ -23,7 +23,7 @@ data-testid="pq-bundles-table"> - + @@ -65,7 +65,11 @@ data-testid="pq-bundles-col-status"> {{ 'publishing-queue.column.status' | dm }} - + @@ -74,7 +78,7 @@ - + @@ -114,7 +118,7 @@ - + @@ -126,7 +130,7 @@ (click)="onRowClick(row)" (contextmenu)="onRowContextMenu($event, row)" data-testid="pq-bundles-row"> - + @@ -141,7 +145,6 @@ floating at the far edge of the column. -->
@@ -171,22 +174,26 @@ [value]="(row.assetCount ?? 0).toString()" /> - {{ (row.createDate | date: 'MM/dd/yyyy hh:mma') || '—' }} + {{ (row.createDate | date: 'MMM d, y - h:mm:ss a') || '—' }} {{ (row.statusUpdated || row.createDate - | dotRelativeDate: 'MM/dd/yyyy hh:mma') || '—' + | dotRelativeDate: 'MMM d, y - h:mm:ss a') || '—' }} - + Date: Thu, 2 Jul 2026 14:29:01 -0600 Subject: [PATCH 41/43] fix(publishing-queue): hide the Select Bundle trash icon until row hover The trash button on each asset row was persistently visible, competing with the row content for attention. Match the pattern already used by the View Contents dialog: opacity-0 by default, group-hover:opacity-100 when the row is hovered, focus-visible:opacity-100 so keyboard tabbing still surfaces it for a11y. The row's gets `group` to scope the hover. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dot-publishing-queue-select-bundle-dialog.component.html | 2 ++ 1 file changed, 2 insertions(+) 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 9c19792530e7..d4625b7adca1 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 @@ -191,6 +191,7 @@ } @else { @@ -227,6 +228,7 @@ [rounded]="true" size="small" severity="danger" + styleClass="opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-opacity" [pTooltip]=" 'publishing-queue.select-bundle.delete-tooltip' | dm From 3119267a0b8b67021fac0c06b0c246c282b1bf52 Mon Sep 17 00:00:00 2001 From: hmoreras <31667212+hmoreras@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:08:09 -0600 Subject: [PATCH 42/43] feat(publishing-queue): Select Bundle dialog polish (pagination, header, send) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cluster of related UX improvements to the Add Bundle wizard: - Pagination: drop BUNDLES_PER_PAGE from 10 to 6 so the list fits without scroll. The BE endpoint reports numRows = current page's row count (not the absolute total), so switch from numeric maxPage to cursor-style bundlesHasMore (true while pages come back full, false on partial). Also detect empty responses past page 1 and roll back to the previous page so users never land on a spurious "No bundles found" page after a Next click on a total that's an exact multiple of 6. - Dialog header: hide PrimeNG's built-in title (showHeader: false) and render a custom header inside the dialog body. When step === 'select' it reads "Select Bundle"; when step === 'configure' it swaps to "← Configure & Send [N bundle(s)]" — same content that used to live in a separate configure-header bar inside the body, now unified with the dialog chrome. - Send button behavior: no longer disabled by form validity. Clicking with an invalid or empty form surfaces the appropriate inline warning (form-invalid or select-one) via the existing validationWarningKey pattern. Only disabled while an upload is in-flight to guard against double-submit. - Add an "Failed bundles retry automatically up to 3×" hint row above the configure footer so users understand the retry policy before hitting Send. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...-queue-select-bundle-dialog.component.html | 126 ++++++++++++------ ...eue-select-bundle-dialog.component.spec.ts | 50 ++++++- ...ng-queue-select-bundle-dialog.component.ts | 69 +++++++--- .../dot-publishing-queue-shell.component.ts | 5 +- .../WEB-INF/messages/Language.properties | 2 + 5 files changed, 180 insertions(+), 72 deletions(-) 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 d4625b7adca1..9705deb20abc 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 @@ -1,4 +1,53 @@
+ +
+ @if (step() === 'select') { +

+ {{ 'publishing-queue.select-bundle.title' | dm }} +

+ } @else { +
+ +

+ {{ 'publishing-queue.select-bundle.configure-and-send' | dm }} +

+ +
+ } + +
+ @switch (step()) { @case ('select') {
@@ -151,7 +200,7 @@ [rounded]="true" size="small" severity="secondary" - [disabled]="bundlesPage() * bundlesPerPage >= bundlesTotal()" + [disabled]="!bundlesHasMore()" (onClick)="onBundlesPageNext()" [attr.aria-label]="'publishing-queue.select-bundle.next-page' | dm" data-testid="pq-select-bundle-next" /> @@ -359,36 +408,6 @@
} @case ('configure') { -
- -

- {{ 'publishing-queue.select-bundle.configure-and-send' | dm }} -

- -
-
@@ -399,20 +418,39 @@
+ + {{ 'publishing-queue.select-bundle.hint.auto-retry' | dm }} +
+ +
- - +
+ @if (validationWarningKey(); as warningKey) { + + + {{ warningKey | dm }} + + } +
+
+ + +
} } 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.spec.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.spec.ts index c4032dea5214..4f2e113385e8 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.spec.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.spec.ts @@ -559,12 +559,37 @@ describe('DotPublishingQueueSelectBundleDialogComponent', () => { spectator.component.onConfigureFormValid(true); } - it('Send is disabled until the embedded form reports valid', () => { + it('Send stays enabled while the form is invalid; clicking surfaces a warning', () => { spectator.detectChanges(); spectator.component.onCheckedChange([{ id: 'bundle-1', name: 'a' }]); spectator.component.onOpenConfigureStep(); - // form has not emitted (value=null, valid=false) → canSend = false - expect(spectator.component.canSend()).toBe(false); + spectator.detectChanges(); + + // Button is not disabled — only `isSending` disables it now. + const sendBtn = spectator + .query(byTestId('pq-select-bundle-send-btn')) + ?.querySelector('button'); + expect(sendBtn?.hasAttribute('disabled')).toBe(false); + + // Form has not emitted (value=null, valid=false) → clicking surfaces + // the form-invalid warning inline in the footer instead of firing. + spectator.component.onSend(); + expect(service.pushBundle).not.toHaveBeenCalled(); + expect(spectator.component.validationWarningKey()).toBe( + 'publishing-queue.select-bundle.warning.form-invalid' + ); + }); + + it('clears the form-invalid warning once the form reports valid', () => { + spectator.detectChanges(); + spectator.component.onCheckedChange([{ id: 'bundle-1', name: 'a' }]); + spectator.component.onOpenConfigureStep(); + spectator.component.onSend(); // triggers warning + expect(spectator.component.validationWarningKey()).toBe( + 'publishing-queue.select-bundle.warning.form-invalid' + ); + spectator.component.onConfigureFormValid(true); + expect(spectator.component.validationWarningKey()).toBeNull(); }); it('fans out one pushBundle call per checked bundle and closes the dialog on success', () => { @@ -606,17 +631,27 @@ describe('DotPublishingQueueSelectBundleDialogComponent', () => { expect(dialogRef.close).not.toHaveBeenCalled(); }); - it('is a no-op when nothing is checked OR the form is invalid', () => { + it('does not fire pushBundle when nothing checked OR form invalid; sets a warning', () => { spectator.detectChanges(); + + // Valid form, but no bundle checked → select-one warning. spectator.component.checkedBundleIds.set([]); + spectator.component.onConfigureFormValue({} as never); spectator.component.onConfigureFormValid(true); spectator.component.onSend(); expect(service.pushBundle).not.toHaveBeenCalled(); + expect(spectator.component.validationWarningKey()).toBe( + 'publishing-queue.select-bundle.warning.select-one' + ); + // Bundle checked, but form invalid → form-invalid warning takes precedence. spectator.component.onCheckedChange([{ id: 'bundle-1', name: 'a' }]); spectator.component.onConfigureFormValid(false); spectator.component.onSend(); expect(service.pushBundle).not.toHaveBeenCalled(); + expect(spectator.component.validationWarningKey()).toBe( + 'publishing-queue.select-bundle.warning.form-invalid' + ); }); it('toggles isSending around the network calls', () => { @@ -653,14 +688,17 @@ describe('DotPublishingQueueSelectBundleDialogComponent', () => { expect(spectator.queryAll(byTestId('pq-select-bundle-row')).length).toBe(2); }); - it('renders the configure header, body, and footer in the configure step', () => { + it('renders the configure title (in the dialog header), body, and footer in the configure step', () => { spectator.detectChanges(); spectator.component.onCheckedChange([ { id: 'bundle-1', name: 'Spring campaign refresh' } ]); spectator.component.onOpenConfigureStep(); spectator.detectChanges(); - expect(spectator.query(byTestId('pq-select-bundle-configure-header'))).toBeTruthy(); + // The step title lives in the dialog's custom header now, not in a + // separate configure-header bar. + expect(spectator.query(byTestId('pq-select-bundle-configure-title'))).toBeTruthy(); + expect(spectator.query(byTestId('pq-select-bundle-back-btn'))).toBeTruthy(); expect(spectator.query(byTestId('pq-select-bundle-configure-body'))).toBeTruthy(); expect(spectator.query(byTestId('pq-select-bundle-configure-footer'))).toBeTruthy(); // Select-step content is no longer in the DOM. 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 1eef8b191611..f1ca94fa86eb 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 @@ -83,7 +83,7 @@ const TYPE_ICONS: Record = { variant: 'pi pi-clone' }; -const BUNDLES_PER_PAGE = 10; +const BUNDLES_PER_PAGE = 6; const ASSETS_PER_PAGE = 10; /** @@ -138,7 +138,13 @@ export class DotPublishingQueueSelectBundleDialogComponent implements OnInit { private userId: string | null = null; readonly bundles = signal([]); - readonly bundlesTotal = signal(0); + /** Cursor-style "there is a next page" flag. The BE's `numRows` returns the + * size of the current page, not the total across all pages, so we can't + * compute a maxPage. Instead, `bundlesHasMore` is true when the current + * response returned a full page (=== BUNDLES_PER_PAGE items) — as soon as + * a partial page comes back, we're on the last page. Follow-up: extend the + * BE to include a real total count so we can go back to numeric pagination. */ + readonly bundlesHasMore = signal(false); readonly bundlesStatus = signal('init'); readonly bundlesPage = signal(1); readonly bundleSearch = signal(''); @@ -156,7 +162,6 @@ export class DotPublishingQueueSelectBundleDialogComponent implements OnInit { * content type, cached app-wide by the service). */ readonly assetEditUrls = signal>(new Map()); - readonly bundlesPerPage = BUNDLES_PER_PAGE; readonly assetsPerPage = ASSETS_PER_PAGE; readonly bundlesSkeleton = Array.from({ length: 6 }); @@ -319,8 +324,7 @@ export class DotPublishingQueueSelectBundleDialogComponent implements OnInit { } onBundlesPageNext(): void { - const maxPage = Math.max(1, Math.ceil(this.bundlesTotal() / BUNDLES_PER_PAGE)); - if (this.bundlesPage() < maxPage) { + if (this.bundlesHasMore()) { this.bundlesPage.update((p) => p + 1); this.loadBundles(); } @@ -448,9 +452,7 @@ export class DotPublishingQueueSelectBundleDialogComponent implements OnInit { return; } if (count > 1) { - this.validationWarningKey.set( - 'publishing-queue.select-bundle.download.single-only' - ); + this.validationWarningKey.set('publishing-queue.select-bundle.download.single-only'); return; } this.validationWarningKey.set(null); @@ -532,6 +534,7 @@ export class DotPublishingQueueSelectBundleDialogComponent implements OnInit { onBackToList(): void { this.step.set('select'); + this.validationWarningKey.set(null); } onConfigureFormValue(value: DotPushPublishData): void { @@ -540,6 +543,16 @@ export class DotPublishingQueueSelectBundleDialogComponent implements OnInit { onConfigureFormValid(valid: boolean): void { this.configureFormValid.set(valid); + // As soon as the form becomes valid, drop the "please complete required + // fields" warning that a prior Send click may have surfaced. + if (valid) { + this.validationWarningKey.set(null); + } + } + + /** Closes the dialog via the custom header's X button. */ + closeDialog(): void { + this.dialogRef?.close(); } /** @@ -550,7 +563,15 @@ export class DotPublishingQueueSelectBundleDialogComponent implements OnInit { onSend(): void { const ids = this.checkedBundleIds(); const value = this.configureFormValue(); - if (!value || ids.length === 0 || !this.configureFormValid()) { + // Send stays clickable even when the form is incomplete — clicking with + // an invalid form surfaces an inline warning instead of doing nothing + // silently, so the user gets clear feedback about what to fix. + if (!value || !this.configureFormValid()) { + this.validationWarningKey.set('publishing-queue.select-bundle.warning.form-invalid'); + return; + } + if (ids.length === 0) { + this.validationWarningKey.set('publishing-queue.select-bundle.warning.select-one'); return; } @@ -559,9 +580,7 @@ export class DotPublishingQueueSelectBundleDialogComponent implements OnInit { this.isSending.set(true); forkJoin( ids.map((id) => - this.publishingService - .pushBundle(id, form) - .pipe(catchError(() => of(null))) + this.publishingService.pushBundle(id, form).pipe(catchError(() => of(null))) ) ) .pipe( @@ -606,8 +625,22 @@ export class DotPublishingQueueSelectBundleDialogComponent implements OnInit { }) ) .subscribe((response) => { + // Cursor-style pagination can't tell "full last page" apart from + // "full non-last page" without asking the next one. If the user + // clicked Next past the end (total was an exact multiple of + // BUNDLES_PER_PAGE), the response comes back empty — roll back to + // the previous page and disable Next, so the empty "No bundles + // found" screen never renders. Only apply when past page 1; + // page 1 empty is a legitimate empty-state. + if (response.items.length === 0 && this.bundlesPage() > 1) { + this.bundlesPage.update((p) => p - 1); + this.bundlesHasMore.set(false); + this.bundlesStatus.set('loaded'); + return; + } this.bundles.set(response.items.map((item) => ({ id: item.id, name: item.name }))); - this.bundlesTotal.set(response.numRows ?? response.items.length); + // Cursor-style: a full page means "possibly more"; a partial page is the last. + this.bundlesHasMore.set(response.items.length === BUNDLES_PER_PAGE); this.bundlesStatus.set('loaded'); // Auto-select the first bundle on initial load so the right pane // isn't empty by default (matches the design's "first row active"). @@ -721,19 +754,13 @@ function toPushBundleForm(value: DotPushPublishData): PushBundleForm { if (operation === 'publish' || operation === 'publishexpire') { if (value.publishDate) { - form.publishDate = toIso8601WithOffset( - new Date(value.publishDate), - value.timezoneId - ); + form.publishDate = toIso8601WithOffset(new Date(value.publishDate), value.timezoneId); } } if (operation === 'expire' || operation === 'publishexpire') { if (value.expireDate) { - form.expireDate = toIso8601WithOffset( - new Date(value.expireDate), - value.timezoneId - ); + form.expireDate = toIso8601WithOffset(new Date(value.expireDate), value.timezoneId); } } diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.ts index b54ca188bda4..6242bb253d59 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-shell/dot-publishing-queue-shell.component.ts @@ -74,7 +74,10 @@ export class DotPublishingQueueShellComponent { this.selectBundleRef = this.dialogService.open( DotPublishingQueueSelectBundleDialogComponent, { - header: this.dotMessageService.get('publishing-queue.select-bundle.title'), + // Header is rendered inside the dialog body so its title can + // swap between "Select Bundle" and "Configure & Send" as the + // user moves through the wizard steps. + showHeader: false, width: 'min(95vw, 1100px)', contentStyle: { height: '70vh', padding: '0' }, closable: true, diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index a665acbfd8f0..e460eab690c8 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -3802,6 +3802,8 @@ publishing-queue.select-bundle.download.to-publish=To Publish publishing-queue.select-bundle.download.to-unpublish=To Unpublish publishing-queue.select-bundle.download.in-progress=Downloading… publishing-queue.select-bundle.warning.select-one=Select at least one bundle first. +publishing-queue.select-bundle.warning.form-invalid=Please complete all required fields before sending. +publishing-queue.select-bundle.hint.auto-retry=Failed bundles retry automatically up to 3× publishing-queue.select-bundle.configure-and-send=Configure & Send publishing-queue.select-bundle.bundle-count.singular={0} bundle publishing-queue.select-bundle.bundle-count.plural={0} bundles From d9b4e1b8b4280d4ed3f4ab835127b68889f6c31e Mon Sep 17 00:00:00 2001 From: hmoreras <31667212+hmoreras@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:08:32 -0600 Subject: [PATCH 43/43] =?UTF-8?q?feat(publishing-queue):=20Upload=20dialog?= =?UTF-8?q?=20=E2=80=94=20always-clickable=20Upload=20with=20inline=20warn?= =?UTF-8?q?ing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match the pattern just landed on Select Bundle's Send button: keep the Upload button clickable regardless of whether a file is selected, and surface the missing-file case as an inline error message instead of silently no-oping on the disabled button. The button only disables while an upload is in-flight, to guard against double-submit. Reuses the existing errorMessage signal + p-message channel that already handled BE errors — server errors and client validation share the same visual affordance. Auto-clears when the user picks a valid bundle. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...publishing-queue-upload-dialog.component.html | 2 +- ...lishing-queue-upload-dialog.component.spec.ts | 16 +++++++++++++++- ...t-publishing-queue-upload-dialog.component.ts | 10 +++++++++- .../webapp/WEB-INF/messages/Language.properties | 1 + 4 files changed, 26 insertions(+), 3 deletions(-) diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-upload-dialog/dot-publishing-queue-upload-dialog.component.html b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-upload-dialog/dot-publishing-queue-upload-dialog.component.html index 768e6fee7d5e..e64b9520f504 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-upload-dialog/dot-publishing-queue-upload-dialog.component.html +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-upload-dialog/dot-publishing-queue-upload-dialog.component.html @@ -73,7 +73,7 @@ data-testid="pq-upload-cancel" /> diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-upload-dialog/dot-publishing-queue-upload-dialog.component.spec.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-upload-dialog/dot-publishing-queue-upload-dialog.component.spec.ts index f48786bfe711..8a042c342a82 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-upload-dialog/dot-publishing-queue-upload-dialog.component.spec.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-upload-dialog/dot-publishing-queue-upload-dialog.component.spec.ts @@ -77,9 +77,23 @@ describe('DotPublishingQueueUploadDialogComponent', () => { }); describe('submit', () => { - it('is a no-op when nothing is selected', () => { + it('does not call the service when nothing is selected; surfaces a warning instead', () => { spectator.component.onSubmit(); expect(service.uploadBundle).not.toHaveBeenCalled(); + // The button is no longer disabled — clicking without a file must + // surface the file-required message inline so the user knows why. + expect(spectator.component.errorMessage()).toBe( + 'publishing-queue.upload.warning.file-required' + ); + }); + + it('clears the file-required warning once a valid bundle is selected', () => { + spectator.component.onSubmit(); + expect(spectator.component.errorMessage()).toBe( + 'publishing-queue.upload.warning.file-required' + ); + spectator.component.onFileSelect({ files: [bundleFile()] } as never); + expect(spectator.component.errorMessage()).toBeNull(); }); it('calls service.uploadBundle, refreshes the store, and closes with uploaded:true', () => { diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-upload-dialog/dot-publishing-queue-upload-dialog.component.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-upload-dialog/dot-publishing-queue-upload-dialog.component.ts index 22e54db68e60..e7cad537cac6 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-upload-dialog/dot-publishing-queue-upload-dialog.component.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dialogs/dot-publishing-queue-upload-dialog/dot-publishing-queue-upload-dialog.component.ts @@ -10,7 +10,7 @@ import { MessageModule } from 'primeng/message'; import { catchError, take } from 'rxjs/operators'; -import { DotPublishingQueueService } from '@dotcms/data-access'; +import { DotMessageService, DotPublishingQueueService } from '@dotcms/data-access'; import { DotMessagePipe } from '@dotcms/ui'; import { DotPublishingQueueStore } from '../../store/dot-publishing-queue.store'; @@ -38,6 +38,7 @@ export class DotPublishingQueueUploadDialogComponent { readonly dialogRef = inject(DynamicDialogRef); private readonly service = inject(DotPublishingQueueService); private readonly store = inject(DotPublishingQueueStore); + private readonly dotMessageService = inject(DotMessageService); readonly selectedFile = signal(null); readonly uploading = signal(false); @@ -56,7 +57,14 @@ export class DotPublishingQueueUploadDialogComponent { onSubmit(): void { const file = this.selectedFile(); + // Upload stays clickable at all times — clicking without a file surfaces + // the file-required warning inline via `errorMessage` instead of doing + // nothing silently. Auto-clears when the user picks a valid file + // (see `onFileSelect`). if (!file) { + this.errorMessage.set( + this.dotMessageService.get('publishing-queue.upload.warning.file-required') + ); return; } diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index e460eab690c8..77da4e189d73 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -3908,6 +3908,7 @@ publishing-queue.upload.submit=Upload publishing-queue.upload.dropzone.prefix=Drag and drop a bundle here, or publishing-queue.upload.dropzone.suffix=to upload. publishing-queue.upload.file-types=.tar.gz · .tgz +publishing-queue.upload.warning.file-required=Please choose a .tar.gz or .tgz bundle file before uploading. publishing-queue.filter.status=Status publishing-queue.empty.bundles.title=No bundles yet publishing-queue.empty.bundles.subtitle=Bundles you send to environments will show up here.