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 beba61a2f00..88f49afc866 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 89274d86fce..8dca7f425ac 100644 --- a/core-web/libs/data-access/src/index.ts +++ b/core-web/libs/data-access/src/index.ts @@ -50,6 +50,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 00000000000..b88874ee1f1 --- /dev/null +++ b/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.spec.ts @@ -0,0 +1,343 @@ +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 } }); + }); + + it('omits the status param when statuses is undefined (BE returns all)', () => { + service.listPublishingJobs({}).subscribe(); + + const req = httpMock.expectOne((request) => request.url === '/api/v1/publishing'); + expect(req.request.params.has('status')).toBe(false); + req.flush({ entity: [], pagination: { currentPage: 1, perPage: 50, totalEntries: 0 } }); + }); + + it('omits the status param when statuses is an empty array', () => { + service.listPublishingJobs({ statuses: [] }).subscribe(); + + const req = httpMock.expectOne((request) => request.url === '/api/v1/publishing'); + expect(req.request.params.has('status')).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[] = [ + { asset: '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); + }); + }); + + 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('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 = { + 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 }); + }); + }); + + 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); + }); + }); + + // 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 new file mode 100644 index 00000000000..1b0634d6e4f --- /dev/null +++ b/core-web/libs/data-access/src/lib/dot-publishing-queue/dot-publishing-queue.service.ts @@ -0,0 +1,350 @@ +import { Observable, of } from 'rxjs'; + +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; + +import { catchError, map } from 'rxjs/operators'; + +import { + BundleAssetView, + DotCMSResponse, + PublishAuditStatus, + PublishingJobDetailView, + PublishingJobsResponse, + PushBundleForm, + PushBundleResultView, + RemoveAssetResultView, + RetryBundleResultView, + UnsentBundlesResponse +} from '@dotcms/dotcms-models'; + +export type PublishingSortField = 'bundle_name' | 'status' | 'created' | 'modified'; +export type PublishingSortDirection = 'asc' | 'desc'; + +export interface ListPublishingJobsParams { + /** Empty/omitted = all statuses (BE returns every row in publish_audit). */ + statuses?: readonly PublishAuditStatus[]; + page?: number; + perPage?: number; + filter?: string; + sort?: PublishingSortField; + sortDirection?: PublishingSortDirection; +} + +export type RetryDeliveryStrategy = 'ALL_ENDPOINTS' | 'FAILED_ENDPOINTS'; + +export interface RetryBundlesPayload { + bundleIds: string[]; + forcePush?: boolean; + deliveryStrategy?: RetryDeliveryStrategy; +} + +/** + * Backs the Publishing Queue Angular portlet. + * + * 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/retry` — retry bundles (bulk) + * - `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) + * + * 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' +}) +export class DotPublishingQueueService { + private http = inject(HttpClient); + + listPublishingJobs(params: ListPublishingJobsParams): Observable { + let httpParams = new HttpParams(); + + // Only send status when the caller selected one or more — omitting the + // param tells the BE "all statuses" and makes the FE forward-compatible + // with new server-side statuses (e.g. SCHEDULED, see #36267). + if (params.statuses && params.statuses.length > 0) { + httpParams = 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); + } + + 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)); + } + + retryBundles(payload: RetryBundlesPayload): Observable { + return this.http + .post>('/api/v1/publishing/retry', payload) + .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}`); + } + + /** + * 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 { + 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 }> { + 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}`; + } + + /** + * 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`; + } + + /** + * 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. + * + * 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); + + return this.http.get(`/api/bundle/${bundleId}/assets`, { + params + }); + } + + /** + * 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. + */ + removeAssetsFromBundle( + bundleId: string, + assetIds: string[] + ): Observable { + return this.http + .request< + DotCMSResponse + >('DELETE', `/api/v1/bundles/${bundleId}/assets`, { body: { assetIds } }) + .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/dotcms-models/src/index.ts b/core-web/libs/dotcms-models/src/index.ts index 2395fb26533..dd538908419 100644 --- a/core-web/libs/dotcms-models/src/index.ts +++ b/core-web/libs/dotcms-models/src/index.ts @@ -61,6 +61,10 @@ 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-job-detail.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 00000000000..fc3fba6e625 --- /dev/null +++ b/core-web/libs/dotcms-models/src/lib/bundle-asset-view.model.ts @@ -0,0 +1,24 @@ +/** + * 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 exactly as named below + * (snake_case for the multi-word ones — these field names mirror the wire format). + * + * `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 { + asset: string; + title: string; + type: string; + inode?: string; + content_type_name?: string; + language_code?: string; + country_code?: string; + operation?: number; +} 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 00000000000..50751521421 --- /dev/null +++ b/core-web/libs/dotcms-models/src/lib/publishing-job-detail.model.ts @@ -0,0 +1,75 @@ +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; + /** 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`. */ +export interface RemoveAssetResultView { + assetId: string; + success: boolean; + message: string; +} + +/** 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; +} 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 00000000000..f0db4b7352e --- /dev/null +++ b/core-web/libs/dotcms-models/src/lib/publishing-job.model.ts @@ -0,0 +1,91 @@ +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. + * + * 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 | null; + filterName: string | null; + filterKey: string | null; + assetCount: number; + assetPreview: AssetPreviewView[]; + environmentCount: number; + createDate: string; + statusUpdated: string | null; + 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; + perPage: number; + totalEntries: number; +} + +/** Full envelope returned by `GET /api/v1/publishing`. */ +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/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 00000000000..0351c058ef1 --- /dev/null +++ b/core-web/libs/dotcms-models/src/lib/publishing-status.model.ts @@ -0,0 +1,48 @@ +/** + * 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', + /** + * 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. */ +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 00000000000..ef536cdfaf3 --- /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 00000000000..084450cae46 --- /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 00000000000..2bc80520bb1 --- /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 00000000000..44c9365302f --- /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-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 00000000000..6d81232541b --- /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.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 00000000000..33712ecbb05 --- /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 new file mode 100644 index 00000000000..12e72714513 --- /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,143 @@ +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'; + +/** + * 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, + PublishAuditStatus.SENDING_TO_ENDPOINTS, + PublishAuditStatus.PUBLISHING_BUNDLE, + PublishAuditStatus.RECEIVED_BUNDLE, + PublishAuditStatus.SUCCESS, + 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, + PublishAuditStatus.FAILED_TO_SENT, + PublishAuditStatus.FAILED_TO_PUBLISH, + PublishAuditStatus.FAILED_INTEGRITY_CHECK, + PublishAuditStatus.INVALID_TOKEN, + 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, + 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'; + + /** Listbox options, deduplicated by translated label. Order follows + * `STATUS_ORDER` (first occurrence wins). */ + protected readonly $options: StatusOption[] = this.buildOptions(); + + /** 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 { + 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 + })); + } +} 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 00000000000..c9a8236a419 --- /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,57 @@ + +
+ + + + + + + +
+ @if (hasBulkActions()) { + + } + + @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 new file mode 100644 index 00000000000..fcf8f861ee2 --- /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,201 @@ +import { byTestId, createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; + +import { CUSTOM_ELEMENTS_SCHEMA, signal } from '@angular/core'; + +import { DotMessageService } from '@dotcms/data-access'; +import { MockDotMessageService } from '@dotcms/utils-testing'; + +import { DotPublishingQueueToolbarComponent } from './dot-publishing-queue-toolbar.component'; + +import { DotPublishingQueueStore } from '../../store/dot-publishing-queue.store'; +import { DotPublishingQueueStatusFilterComponent } from '../dot-publishing-queue-status-filter/dot-publishing-queue-status-filter.component'; + +describe('DotPublishingQueueToolbarComponent', () => { + let spectator: Spectator; + let store: ReturnType; + + const bundlesSelectedIds = signal([]); + const bundlesTotal = signal(0); + + function makeStoreStub() { + return { + search: jest.fn().mockReturnValue(''), + setSearch: jest.fn(), + refresh: jest.fn(), + bundlesSelectedIds, + bundlesTotal, + retryBundles: jest.fn() + }; + } + + const createComponent = createComponentFactory({ + component: DotPublishingQueueToolbarComponent, + overrideComponents: [ + [ + DotPublishingQueueStatusFilterComponent, + { + set: { + template: '
', + imports: [] + } + } + ] + ], + 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.retry-send': 'Retry Send', + 'publishing-queue.delete-bundles': 'Remove', + 'publishing-queue.selected': 'selected' + }) + } + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA] + }); + + beforeEach(() => { + jest.useFakeTimers(); + bundlesSelectedIds.set([]); + bundlesTotal.set(0); + spectator = createComponent(); + store = spectator.inject(DotPublishingQueueStore, true) as unknown as ReturnType< + typeof makeStoreStub + >; + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('layout', () => { + it('renders search, status filter, refresh, add bundle dropdown', () => { + 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-add-bundle-btn'))).toBeTruthy(); + }); + }); + + describe('Add Bundle dropdown', () => { + it('exposes two menu items: Select Bundle + Upload', () => { + expect(spectator.component.addBundleItems.length).toBe(2); + expect(spectator.component.addBundleItems[0].icon).toBe('pi pi-table'); + expect(spectator.component.addBundleItems[1].icon).toBe('pi pi-upload'); + }); + + it('Upload item → emits uploadClick', () => { + const emit = jest.fn(); + spectator.component.uploadClick.subscribe(emit); + spectator.component.addBundleItems[1].command?.({} as never); + expect(emit).toHaveBeenCalled(); + }); + + it('Select Bundle item → emits selectBundleClick (placeholder for future dialog)', () => { + const emit = jest.fn(); + spectator.component.selectBundleClick.subscribe(emit); + spectator.component.addBundleItems[0].command?.({} as never); + expect(emit).toHaveBeenCalled(); + }); + }); + + 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(); + }); + }); + + 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-add-bundle-btn'))).toBeTruthy(); + }); + + 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-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', () => { + bundlesSelectedIds.set(['b1', 'b2']); + spectator.detectChanges(); + 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 when nothing is selected', () => { + bundlesSelectedIds.set([]); + spectator.detectChanges(); + expect(spectator.query(byTestId('pq-bulk-delete'))).toBeFalsy(); + }); + + it('shows when there is a selection', () => { + bundlesSelectedIds.set(['b1']); + spectator.detectChanges(); + expect(spectator.query(byTestId('pq-bulk-delete'))).toBeTruthy(); + }); + + it('emits deleteClick when clicked', () => { + bundlesSelectedIds.set(['b1']); + spectator.detectChanges(); + const emit = jest.fn(); + spectator.component.deleteClick.subscribe(emit); + 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 new file mode 100644 index 00000000000..400ac7629ef --- /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,89 @@ +import { Subject } from 'rxjs'; + +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + computed, + inject, + output +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormsModule } from '@angular/forms'; + +import { MenuItem } from 'primeng/api'; +import { ButtonModule } from 'primeng/button'; +import { IconFieldModule } from 'primeng/iconfield'; +import { InputIconModule } from 'primeng/inputicon'; +import { InputTextModule } from 'primeng/inputtext'; +import { MenuModule } from 'primeng/menu'; +import { ToolbarModule } from 'primeng/toolbar'; + +import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; + +import { DotMessageService } from '@dotcms/data-access'; +import { DotMessagePipe } from '@dotcms/ui'; + +import { DotPublishingQueueStore } from '../../store/dot-publishing-queue.store'; +import { DotPublishingQueueStatusFilterComponent } from '../dot-publishing-queue-status-filter/dot-publishing-queue-status-filter.component'; + +@Component({ + selector: 'dot-publishing-queue-toolbar', + standalone: true, + imports: [ + FormsModule, + ButtonModule, + IconFieldModule, + InputIconModule, + InputTextModule, + MenuModule, + ToolbarModule, + DotMessagePipe, + DotPublishingQueueStatusFilterComponent + ], + templateUrl: './dot-publishing-queue-toolbar.component.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DotPublishingQueueToolbarComponent { + readonly store = inject(DotPublishingQueueStore); + readonly uploadClick = output(); + readonly selectBundleClick = output(); + readonly deleteClick = output(); + + private readonly destroyRef = inject(DestroyRef); + private readonly dotMessageService = inject(DotMessageService); + private searchSubject = new Subject(); + + /** Bulk actions appear only when the user has explicitly checked one or more rows. */ + readonly hasBulkActions = computed(() => this.store.bundlesSelectedIds().length > 0); + + /** "Add Bundle" dropdown items. "Select Bundle" is reserved for a future + * dialog (issue #36040 follow-up); for now it just emits the output so the + * shell can wire it up when ready. */ + readonly addBundleItems: MenuItem[] = [ + { + label: this.dotMessageService.get('publishing-queue.add-bundle.select'), + icon: 'pi pi-table', + command: () => this.selectBundleClick.emit() + }, + { + label: this.dotMessageService.get('publishing-queue.add-bundle.upload'), + icon: 'pi pi-upload', + command: () => this.uploadClick.emit() + } + ]; + + constructor() { + this.searchSubject + .pipe(debounceTime(300), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) + .subscribe((value) => this.store.setSearch(value)); + } + + onSearch(value: string): void { + this.searchSubject.next(value); + } + + onBulkRetry(): void { + this.store.retryBundles({ bundleIds: this.store.bundlesSelectedIds() }); + } +} 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 00000000000..4f0fc3c4fca --- /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 00000000000..fc4f923079f --- /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,106 @@ +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.SCHEDULED, '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 error', + '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 00000000000..6c4923880da --- /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,72 @@ +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', + // info: future-dated bundle, not yet picked up by the publisher job + [PublishAuditStatus.SCHEDULED]: 'info', + + // warning: actively being packed/sent (in-flight) + [PublishAuditStatus.BUNDLING]: 'warning', + [PublishAuditStatus.SENDING_TO_ENDPOINTS]: 'warning', + [PublishAuditStatus.PUBLISHING_BUNDLE]: 'warning', + [PublishAuditStatus.RECEIVED_BUNDLE]: 'warning' +}; + +/** 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; + }); + + /** 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/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 00000000000..1ebb038ed9a --- /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 new file mode 100644 index 00000000000..50c1dffb785 --- /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,110 @@ +
+ @if (showAssetSearch()) { + +
+ + + + +
+ } + +
+ + + + {{ 'publishing-queue.column.name' | dm }} + {{ 'publishing-queue.column.type' | dm }} + @if (allowRemove) { + + } + + + + + @for (_ of assetSkeletonRows; track $index) { + + + + @if (allowRemove) { + + } + + } + + + + + {{ asset.title }} + + + + @if (allowRemove) { + + + + } + + + + + + + @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 new file mode 100644 index 00000000000..243a068dc20 --- /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,315 @@ +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, DynamicDialogRef } from 'primeng/dynamicdialog'; + +import { DotContentletEditUrlService, 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 '../../store/dot-publishing-queue.store'; + +const ASSETS: BundleAssetView[] = [ + { asset: 'a1', title: 'Asset 1', type: 'contentlet', inode: 'i1', content_type_name: 'Blog' }, + { asset: 'a2', title: 'Asset 2', type: 'template' } +]; + +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'); + const selectedBundleId = signal(null); + + function storeStub() { + return { + selectedAssets, + assetListStatus, + selectedBundleId, + removeBundleAsset: jest.fn() + }; + } + + const createComponent = createComponentFactory({ + component: 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({ + 'publishing-queue.column.name': 'Name', + 'publishing-queue.column.type': 'Type', + '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', + 'publishing-queue.close': 'Close' + }) + } + ] + }); + + 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; + dialogRef = spectator.inject(DynamicDialogRef) 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 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 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', () => { + selectedAssets.set(ASSETS); + assetListStatus.set('loaded'); + 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 Name + Type columns plus an action column for the trash button', () => { + selectedAssets.set(ASSETS); + assetListStatus.set('loaded'); + spectator.detectChanges(); + const headers = spectator.queryAll('th'); + // 2 visible (Name, Type) + 1 empty (action column for the trash icon) = 3 + 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); + 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(''); + }); + }); +}); + +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, + 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({}) } + ] + }); + + 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 new file mode 100644 index 00000000000..ae111c2069e --- /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,208 @@ +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 { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; +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 { debounceTime, distinctUntilChanged, take } from 'rxjs/operators'; + +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'; + +/** 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: [ + FormsModule, + ButtonModule, + ConfirmDialogModule, + IconFieldModule, + InputIconModule, + InputTextModule, + SkeletonModule, + TableModule, + TagModule, + 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); + 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. + * 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. */ + readonly assetSkeletonRows = Array.from({ length: 8 }); + + 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 + ); + + /** 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 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(''); + 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)); + }); + } + + onSearch(value: string): void { + 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'), + 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 new file mode 100644 index 00000000000..bd7ab37632e --- /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,147 @@ +
+ @if (store.detailStatus() === 'loading') { + + + + } @else if (store.detail(); as detail) { + + + + + {{ row.label }} + + + @switch (row.key) { + @case ('title') { + {{ detail.bundleName || '—' }} + @if (detail.assetCount > 0) { + + · {{ detail.assetCount }} + {{ 'publishing-queue.detail.assets-suffix' | dm }} + + } + } + @case ('status') { + + } + @case ('scheduledFor') { + {{ (detail.scheduledPublishDate | date: 'MMM d, y - h:mm:ss a') || '—' }} + } + @case ('bundleId') { + + {{ detail.bundleId }} + + } + @case ('bundleStart') { + {{ (detail.timestamps.bundleStart | date: 'MMM d, y - h:mm:ss a') || '—' }} + } + @case ('bundleEnd') { + {{ (detail.timestamps.bundleEnd | date: 'MMM d, y - h:mm:ss a') || '—' }} + } + @case ('publishStart') { + {{ (detail.timestamps.publishStart | date: 'MMM d, y - h:mm:ss a') || '—' }} + } + @case ('publishEnd') { + {{ (detail.timestamps.publishEnd | date: 'MMM d, y - h:mm:ss a') || '—' }} + } + @case ('filter') { + {{ detail.filterName || detail.filterKey || '—' }} + } + @case ('assets') { + {{ detail.assetCount }} + } + } + + + + + +
+

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

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

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

+ } @else { + + + + {{ '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 }} + + + + + {{ row.envName }} + {{ row.endpoint.serverName }} + + @if (endpointAddress(row.endpoint); as address) { + {{ address }} + } @else { + + } + + + @if (row.endpoint.status) { + + } @else { + + } + + + {{ row.endpoint.statusMessage || '—' }} + + + + + } +
+ +
+ @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.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 00000000000..7ca4763ea84 --- /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,312 @@ +import { byTestId, createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; + +import { signal } from '@angular/core'; + +import { DotMessageService, DotPublishingQueueService } from '@dotcms/data-access'; +import { + EndpointDetailView, + 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 '../../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, + scheduledPublishDate: null, + ...overrides +}); + +describe('DotPublishingQueueBundleDetailsDialogComponent', () => { + let spectator: Spectator; + + 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, + canDownloadBundle, + canDownloadManifest + }), + mockProvider(DotPublishingQueueService, { + getBundleDownloadUrl: jest.fn((id: string) => `/api/bundle/_download/${id}`), + getBundleManifestUrl: jest.fn((id: string) => `/api/bundle/${id}/manifest`) + }), + { provide: DotMessageService, useValue: new MockDotMessageService({}) } + ] + }); + + beforeEach(() => { + detail.set(null); + detailStatus.set('loading'); + canDownloadBundle.set(null); + canDownloadManifest.set(null); + 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); + }); + + describe('download buttons (probe-driven)', () => { + beforeEach(() => { + detail.set(detailFixture()); + detailStatus.set('loaded'); + }); + + 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', () => { + detail.set(detailFixture({ environments: [] })); + detailStatus.set('loaded'); + spectator.detectChanges(); + expect(spectator.query(byTestId('pq-detail-endpoints-empty'))).toBeTruthy(); + }); + + 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-assets-shell'))).toBeFalsy(); + expect(spectator.query(byTestId('pq-detail-assets-table'))).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(); + + // 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('endpointAddress', () => { + function makeEndpoint(over: Partial = {}): EndpointDetailView { + return { + id: 'x', + serverName: 's', + address: '', + port: '', + protocol: '', + status: null, + statusMessage: null, + stackTrace: null, + ...over + }; + } + + beforeEach(() => { + detail.set(detailFixture()); + detailStatus.set('loaded'); + spectator.detectChanges(); + }); + + 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('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('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' + ); + }); + }); + + 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'); + 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 00000000000..8912987ed7e --- /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,165 @@ +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'; + +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 '../../store/dot-publishing-queue.store'; + +/** 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; +} + +/** 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' + | 'scheduledFor' + | 'bundleId' + | 'bundleStart' + | 'bundleEnd' + | 'publishStart' + | 'publishEnd' + | 'filter' + | 'assets'; + +export interface MetaRow { + key: MetaKey; + label: string; +} + +@Component({ + selector: 'dot-publishing-queue-bundle-details-dialog', + standalone: true, + imports: [ + DatePipe, + ButtonModule, + SkeletonModule, + TableModule, + DotMessagePipe, + DotPublishingStatusChipComponent + ], + templateUrl: './dot-publishing-queue-bundle-details-dialog.component.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DotPublishingQueueBundleDetailsDialogComponent { + readonly store = inject(DotPublishingQueueStore); + + 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 + * 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 + * 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. */ + 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; + }); + + /** 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 { + return this.publishingService.getBundleDownloadUrl(bundleId); + } + + manifestHref(bundleId: string): string { + return this.publishingService.getBundleManifestUrl(bundleId); + } + + closeDialog(): void { + this.dialogRef?.close(); + } +} 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 new file mode 100644 index 00000000000..9705deb20ab --- /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.html @@ -0,0 +1,459 @@ +
+ +
+ @if (step() === 'select') { +

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

+ } @else { +
+ +

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

+ +
+ } + +
+ + @switch (step()) { + @case ('select') { +
+ +
+
+ + + + +
+ +
+ + + + + + + + {{ '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 { + + +
+ + + {{ 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() }} + + + +
+
+
+ + + +
+
+ @if (validationWarningKey(); as warningKey) { + + + {{ warningKey | dm }} + + } +
+
+ + + + +
+
+ } + @case ('configure') { +
+ +
+ +
+ + {{ '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 new file mode 100644 index 00000000000..4f2e113385e --- /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,708 @@ +import { byTestId, createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; +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, + DotPushPublishFiltersService, + PushPublishService +} from '@dotcms/data-access'; +import { DotcmsConfigService } from '@dotcms/dotcms-js'; +import { MockDotMessageService } from '@dotcms/utils-testing'; +import { DotParseHtmlService } from '@services/dot-parse-html/dot-parse-html.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' } +]; + +// Mock the blob-download helper so the click side-effect is observable in tests. +const mockAnchorClick = jest.fn(); +jest.mock('@dotcms/utils', () => { + const actual = jest.requireActual('@dotcms/utils'); + return { + ...actual, + getDownloadLink: jest.fn(() => ({ click: mockAnchorClick }) as unknown as HTMLAnchorElement) + }; +}); + +describe('DotPublishingQueueSelectBundleDialogComponent', () => { + let spectator: Spectator; + let service: jest.Mocked; + let confirmationService: jest.Mocked; + let dialogRef: jest.Mocked; + let globalMessage: jest.Mocked; + let httpErrorManager: 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' })), + pushBundle: jest.fn().mockReturnValue( + of({ + bundleId: 'bundle-1', + operation: 'publish', + publishDate: null, + expireDate: null, + environments: ['env-1'], + filterKey: 'default.yml' + }) + ), + generateBundle: jest + .fn() + .mockReturnValue(of({ blob: new Blob(['x']), filename: 'bundle.tar.gz' })) + }), + mockProvider(DotCurrentUserService, { + getCurrentUser: jest + .fn() + .mockReturnValue(of({ userId: 'dotcms.org.1', email: 'admin@dotcms.com' })) + }), + mockProvider(DotHttpErrorManagerService, { handle: 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). Both the + // embedded AND the inline Download menu call + // .get() on it during ngOnInit. + mockProvider(DotPushPublishFiltersService, { + get: jest.fn().mockReturnValue( + of([ + { + key: 'ForcePush.yml', + title: 'Force Push Everything', + defaultFilter: false + }, + { + key: 'OnlySelected.yml', + title: 'Only Selected Items', + defaultFilter: false + }, + { + key: 'ContentDeps.yml', + title: 'Content, Assets and Pages', + defaultFilter: true + } + ]) + ) + }), + // 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() }), + // PushPublishEnvSelectorComponent (rendered inside the embedded form) + // injects PushPublishService for the "remember last push" env list. + mockProvider(PushPublishService, { + getEnvironments: jest.fn().mockReturnValue(of([])), + pushPublishContent: jest.fn().mockReturnValue(of({})) + }), + { provide: DotMessageService, useValue: new MockDotMessageService({}) } + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA, NO_ERRORS_SCHEMA] + }); + + beforeEach(() => { + mockAnchorClick.mockClear(); + spectator = createComponent(); + service = spectator.inject( + DotPublishingQueueService + ) 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 + ) 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); + }); + + it('starts in the select step', () => { + spectator.detectChanges(); + expect(spectator.component.step()).toBe('select'); + }); + }); + + 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('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(); + (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('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' + ); + }); + }); + + describe('inline download menu', () => { + beforeEach(() => { + spectator.detectChanges(); + spectator.component.onCheckedChange([{ id: 'bundle-2', name: 'Blog content sync' }]); + }); + + 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.onDownloadOption('1', ''); + expect(service.generateBundle).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.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(); + }); + }); + + 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 → 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"', () => { + 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 stays enabled while the form is invalid; clicking surfaces a warning', () => { + spectator.detectChanges(); + spectator.component.onCheckedChange([{ id: 'bundle-1', name: 'a' }]); + spectator.component.onOpenConfigureStep(); + 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', () => { + 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('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', () => { + 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 in the select step', () => { + 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); + }); + + 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(); + // 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. + 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 new file mode 100644 index 00000000000..f1ca94fa86e --- /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,825 @@ +import { EMPTY, Subject, forkJoin, of } from 'rxjs'; + +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + OnInit, + computed, + inject, + signal, + viewChild +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormsModule } from '@angular/forms'; + +import { ConfirmationService, MenuItem } 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'; +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 */ +// `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 { + DotContentletEditUrlService, + DotCurrentUserService, + DotGlobalMessageService, + DotHttpErrorManagerService, + DotMessageService, + DotPublishingQueueService, + DotPushPublishFilter, + DotPushPublishFiltersService +} from '@dotcms/data-access'; +import { + BundleAssetView, + DotCMSContentlet, + DotPushPublishData, + DotPushPublishDialogData, + PushBundleForm, + PushBundleOperation +} from '@dotcms/dotcms-models'; +import { DotCopyButtonComponent, DotMessagePipe } from '@dotcms/ui'; +import { getDownloadLink } from '@dotcms/utils'; + +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 = 6; +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, + TieredMenuModule, + TooltipModule, + DotCopyButtonComponent, + DotMessagePipe, + DotPushPublishFormComponent + ], + // `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' } +}) +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 filtersService = inject(DotPushPublishFiltersService); + private readonly editUrlService = inject(DotContentletEditUrlService); + private readonly globalMessage = inject(DotGlobalMessageService); + private readonly dialogRef = inject(DynamicDialogRef, { optional: true }); + + private userId: string | null = null; + + readonly bundles = signal([]); + /** 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(''); + /** 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 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); + + /** 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()); + + /** 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); + + /** 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 + * 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 + * 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. */ + 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(); + }); + + // 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 { + this.bundleSearchSubject.next(value); + } + + onBundlesPagePrev(): void { + if (this.bundlesPage() > 1) { + this.bundlesPage.update((p) => p - 1); + this.loadBundles(); + } + } + + onBundlesPageNext(): void { + if (this.bundlesHasMore()) { + 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)); + // 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 { + 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) { + 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( + '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(); + }); + } + }); + } + + /** + * Captures the trigger button reference for the menu flip-up logic, then + * 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); + } + + /** + * 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 || this.isDownloading()) { + return; + } + 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 { + if (!this.hasChecked()) { + this.validationWarningKey.set('publishing-queue.select-bundle.warning.select-one'); + return; + } + this.validationWarningKey.set(null); + this.step.set('configure'); + } + + onBackToList(): void { + this.step.set('select'); + this.validationWarningKey.set(null); + } + + onConfigureFormValue(value: DotPushPublishData): void { + this.configureFormValue.set(value); + } + + 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(); + } + + /** + * 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(); + // 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; + } + + 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 { + 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) => { + // 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 }))); + // 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"). + 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; + } + + /** 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'); + } + } +} + +/** + * 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/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 00000000000..e64b9520f50 --- /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,81 @@ +
+ @if (errorMessage()) { + + {{ errorMessage() }} + + } + +

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

+ +
+ + + +
+
+ +
+ +

+ {{ '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 new file mode 100644 index 00000000000..8a042c342a8 --- /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,164 @@ +import { byTestId, createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; +import { of, throwError } from 'rxjs'; + +import { HttpErrorResponse } from '@angular/common/http'; + +import { DynamicDialogRef } from 'primeng/dynamicdialog'; + +import { DotMessageService, DotPublishingQueueService } from '@dotcms/data-access'; +import { MockDotMessageService } from '@dotcms/utils-testing'; + +import { DotPublishingQueueUploadDialogComponent } from './dot-publishing-queue-upload-dialog.component'; + +import { DotPublishingQueueStore } from '../../store/dot-publishing-queue.store'; + +describe('DotPublishingQueueUploadDialogComponent', () => { + let spectator: Spectator; + let dialogRef: jest.Mocked; + let service: jest.Mocked; + let store: jest.Mocked<{ refresh: jest.Mock }>; + + const createComponent = createComponentFactory({ + component: DotPublishingQueueUploadDialogComponent, + providers: [ + 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(() => { + spectator = createComponent(); + dialogRef = spectator.inject(DynamicDialogRef) as jest.Mocked; + service = spectator.inject( + DotPublishingQueueService + ) as jest.Mocked; + store = spectator.inject(DotPublishingQueueStore) as unknown as jest.Mocked<{ + refresh: jest.Mock; + }>; + jest.clearAllMocks(); + }); + + 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(); + }); + }); + + describe('submit', () => { + 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', () => { + 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); + }); + }); + + 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 without a result', () => { + spectator.component.onCancel(); + 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 new file mode 100644 index 00000000000..e7cad537cac --- /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,118 @@ +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 { catchError, take } from 'rxjs/operators'; + +import { DotMessageService, DotPublishingQueueService } from '@dotcms/data-access'; +import { DotMessagePipe } from '@dotcms/ui'; + +import { DotPublishingQueueStore } from '../../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, DotMessagePipe], + templateUrl: './dot-publishing-queue-upload-dialog.component.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +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); + readonly errorMessage = signal(null); + + onFileSelect(event: FileSelectEvent): void { + const file = event.files?.[0] ?? null; + this.selectedFile.set(this.isBundleFile(file) ? file : null); + this.errorMessage.set(null); + } + + onFileClear(): void { + this.selectedFile.set(null); + this.errorMessage.set(null); + } + + 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; + } + + 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-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 00000000000..a4f00c3a81b --- /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,8 @@ + + + + + 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 00000000000..c68fdfd2dc8 --- /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,156 @@ +import { createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; +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 */ + +import { + 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 '../store/dot-publishing-queue.store'; + +describe('DotPublishingQueueShellComponent', () => { + let spectator: Spectator; + let dialogService: jest.Mocked; + let confirmationService: jest.Mocked; + let store: InstanceType; + + let onCloseSubject = new Subject(); + const dialogRef = { + close: jest.fn(), + get onClose() { + return onCloseSubject; + } + } as unknown as DynamicDialogRef; + + const createComponent = createComponentFactory({ + component: DotPublishingQueueShellComponent, + componentProviders: [ + DotPublishingQueueStore, + ConfirmationService, + mockProvider(DialogService, { open: jest.fn().mockReturnValue(dialogRef) }) + ], + providers: [ + mockProvider(DotPublishingQueueService, { + listPublishingJobs: jest.fn().mockReturnValue( + of({ + entity: [], + pagination: { currentPage: 1, perPage: 10, totalEntries: 0 } + }) + ), + getBundleAssets: 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() }), + mockProvider(DotPushPublishDialogService, { open: jest.fn() }), + mockProvider(DotDownloadBundleDialogService, { open: jest.fn() }), + { provide: DotMessageService, useValue: new MockDotMessageService({}) } + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA, NO_ERRORS_SCHEMA] + }); + + 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); + }); + + it('renders the toolbar', () => { + 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'); + 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('upload', () => { + it('opens dialog when openUpload is called', () => { + spectator.component.openUpload(); + expect(dialogService.open).toHaveBeenCalled(); + }); + }); + + describe('confirmDeleteBundles', () => { + it('does nothing when there is no selection (defensive guard)', () => { + jest.spyOn(store, 'deleteBundlesBulk'); + spectator.component.confirmDeleteBundles(); + expect(confirmationService.confirm).not.toHaveBeenCalled(); + expect(store.deleteBundlesBulk).not.toHaveBeenCalled(); + }); + + it('opens a ConfirmDialog when there is a selection', () => { + store.setBundlesSelection(['b1', 'b2']); + spectator.component.confirmDeleteBundles(); + expect(confirmationService.confirm).toHaveBeenCalled(); + }); + + 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; + }); + spectator.component.confirmDeleteBundles(); + expect(spy).toHaveBeenCalledWith(['b1', 'b2']); + }); + + 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; + }); + 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 new file mode 100644 index 00000000000..6242bb253d5 --- /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,196 @@ +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 { take } from 'rxjs/operators'; + +import { DotMessageService } from '@dotcms/data-access'; +import { PublishAuditStatus } from '@dotcms/dotcms-models'; + +import { DotPublishingQueueToolbarComponent } from '../components/dot-publishing-queue-toolbar/dot-publishing-queue-toolbar.component'; +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 { 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 } 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`). 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.SCHEDULED +]); + +@Component({ + selector: 'dot-publishing-queue-shell', + standalone: true, + imports: [ + ConfirmDialogModule, + DotPublishingQueueToolbarComponent, + DotPublishingQueueTableComponent + ], + 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' } +}) +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 selectBundleRef: DynamicDialogRef | null = null; + + constructor() { + effect(() => { + const bundleId = this.store.selectedBundleId(); + untracked(() => this.syncAssetList(bundleId)); + }); + + effect(() => { + const bundleId = this.store.detailBundleId(); + untracked(() => this.syncDetail(bundleId)); + }); + } + + openSelectBundle(): void { + if (this.selectBundleRef) { + return; + } + this.selectBundleRef = this.dialogService.open( + DotPublishingQueueSelectBundleDialogComponent, + { + // 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, + 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; + } + 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; + }); + } + + /** 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.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, + accept: () => this.store.deleteBundlesBulk(this.store.bundlesSelectedIds()) + }); + } + + private syncAssetList(bundleId: string | null): void { + if (bundleId && !this.assetListRef) { + // 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); + const bundleName = row?.bundleName ?? null; + + this.assetListRef = this.dialogService.open( + DotPublishingQueueAssetListDialogComponent, + { + templates: { + header: DotPublishingQueueAssetListDialogHeaderComponent + }, + width: '700px', + closable: true, + closeOnEscape: true, + draggable: false, + position: 'center', + data: { allowRemove, bundleName } + } + ); + 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; + } + } +} 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 new file mode 100644 index 00000000000..f8732ff9906 --- /dev/null +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-table/dot-publishing-queue-table.component.html @@ -0,0 +1,243 @@ + +
+ + + + + + + + {{ 'publishing-queue.column.bundle-name' | dm }} + + + {{ 'publishing-queue.column.bundle-id' | dm }} + + + {{ 'publishing-queue.column.filter' | dm }} + + + {{ 'publishing-queue.column.items' | dm }} + + + + {{ 'publishing-queue.column.data-entered' | dm }} + + + {{ 'publishing-queue.column.last-update' | dm }} + + + {{ 'publishing-queue.column.status' | dm }} + + + + + + + @if (store.bundlesStatus() === 'loading') { + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + } @else { + + + + + +
+ {{ row.bundleName || '—' }} +
+ + + +
+ + {{ truncateBundleId(row.bundleId) }} + + +
+ + +
+ {{ row.filterName || row.filterKey || '—' }} +
+ + + + + + {{ (row.createDate | date: 'MMM d, y - h:mm:ss a') || '—' }} + + + {{ + (row.statusUpdated || row.createDate + | dotRelativeDate: 'MMM d, y - h:mm:ss a') || '—' + }} + + + + + + +
+ +
+ + + } +
+ + + + +
+ +
+ + +
+
+
+ + + 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 new file mode 100644 index 00000000000..31ae15d04e5 --- /dev/null +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-table/dot-publishing-queue-table.component.spec.ts @@ -0,0 +1,297 @@ +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 { + 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'; +import { MockDotMessageService } from '@dotcms/utils-testing'; +import { DotDownloadBundleDialogService } from '@services/dot-download-bundle-dialog/dot-download-bundle-dialog.service'; + +import { DotPublishingQueueTableComponent } from './dot-publishing-queue-table.component'; + +import { DotPublishingQueueStore } from '../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('DotPublishingQueueTableComponent', () => { + let spectator: Spectator; + let store: ReturnType; + + const bundlesRows = signal([]); + const bundlesStatus = signal<'init' | 'loading' | 'loaded' | 'error'>('loaded'); + const bundlesTotal = signal(0); + const bundlesPage = signal(1); + const bundlesSort = signal(null); + const bundlesSortDirection = signal<'asc' | 'desc'>('desc'); + const bundlesSelectedIds = signal([]); + const rowsPerPage = signal(10); + + function makeStoreStub() { + return { + bundlesRows, + bundlesStatus, + bundlesTotal, + bundlesPage, + bundlesSort, + bundlesSortDirection, + 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([])), + openDetail: jest.fn(), + openAssetList: jest.fn(), + deleteBundle: jest.fn(), + retryBundles: jest.fn() + }; + } + + let confirmationService: jest.Mocked; + let clipboard: jest.Mocked; + let globalMessage: jest.Mocked; + let pushPublishService: jest.Mocked; + let downloadService: jest.Mocked; + + const createComponent = createComponentFactory({ + component: DotPublishingQueueTableComponent, + componentProviders: [ + mockProvider(DotPublishingQueueStore, makeStoreStub()), + ConfirmationService, + mockProvider(DotClipboardUtil, { + copy: jest.fn().mockResolvedValue(true) + }) + ], + providers: [ + { provide: DotMessageService, useValue: new MockDotMessageService({}) }, + mockProvider(DotGlobalMessageService, { error: jest.fn() }), + mockProvider(DotPushPublishDialogService, { open: jest.fn() }), + mockProvider(DotDownloadBundleDialogService, { open: jest.fn() }), + mockProvider(DotFormatDateService) + ] + }); + + beforeEach(() => { + bundlesRows.set([row('b1'), row('b2', PublishAuditStatus.FAILED_TO_PUBLISH)]); + bundlesStatus.set('loaded'); + bundlesTotal.set(2); + bundlesPage.set(1); + bundlesSelectedIds.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; + pushPublishService = spectator.inject( + DotPushPublishDialogService + ) as jest.Mocked; + downloadService = spectator.inject( + DotDownloadBundleDialogService + ) as jest.Mocked; + jest.spyOn(confirmationService, 'confirm').mockImplementation((cfg) => { + cfg.accept?.(); + return confirmationService; + }); + jest.clearAllMocks(); + }); + + it('renders the table', () => { + expect(spectator.query(byTestId('pq-bundles-table'))).toBeTruthy(); + }); + + 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-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', () => { + spectator.component.onRowClick(row('b1')); + 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); + }); + + 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); + 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-bundles-kebab-btn')).length).toBe(2); + }); + + it('kebabFor returns the SAME array reference across change-detection cycles', () => { + const r = bundlesRows()[0]; + const a = spectator.component.kebabFor(r); + spectator.detectChanges(); + const b = spectator.component.kebabFor(r); + expect(b).toBe(a); + }); + + it('SUCCESS row → View details, View Contents, Generate/download, separator, Delete', () => { + const items = spectator.component.bundlesKebabFor( + row('b1', PublishAuditStatus.SUCCESS) + ); + // 2 (view) + 1 (download) + 1 (separator) + 1 (delete) = 5 + expect(items.length).toBe(5); + expect(items[items.length - 1].styleClass).toBe('p-menuitem-danger'); + }); + + it('FAILED row → adds a Retry item', () => { + const items = spectator.component.bundlesKebabFor( + row('b1', PublishAuditStatus.FAILED_TO_PUBLISH) + ); + const labels = items.map((i) => i.label); + expect(labels.some((l) => l && l.toLowerCase().includes('retry'))).toBe(true); + }); + + it('WAITING_FOR_PUBLISHING row → adds a Configure & send item', () => { + const items = spectator.component.bundlesKebabFor( + row('b1', PublishAuditStatus.WAITING_FOR_PUBLISHING) + ); + const labels = items.map((i) => i.label); + expect(labels.some((l) => l && l.toLowerCase().includes('configure'))).toBe(true); + }); + + it('View details → store.openDetail', () => { + const items = spectator.component.bundlesKebabFor(row('b1')); + items[0].command?.({} as never); + expect(store.openDetail).toHaveBeenCalledWith('b1'); + }); + + it('View Contents → store.openAssetList', () => { + const items = spectator.component.bundlesKebabFor(row('b1')); + items[1].command?.({} as never); + expect(store.openAssetList).toHaveBeenCalledWith('b1'); + }); + + it('Retry → store.retryBundles with [bundleId]', () => { + const items = spectator.component.bundlesKebabFor( + row('b1', PublishAuditStatus.FAILED_TO_PUBLISH) + ); + const retry = items.find((i) => i.label?.toLowerCase().includes('retry')); + retry?.command?.({} as never); + expect(store.retryBundles).toHaveBeenCalledWith({ bundleIds: ['b1'] }); + }); + + it('Configure & send → push publish dialog', () => { + const items = spectator.component.bundlesKebabFor( + row('b1', PublishAuditStatus.WAITING_FOR_PUBLISHING) + ); + const configure = items.find((i) => i.label?.toLowerCase().includes('configure')); + configure?.command?.({} as never); + expect(pushPublishService.open).toHaveBeenCalled(); + }); + + it('Generate/download → download dialog', () => { + const items = spectator.component.bundlesKebabFor(row('b1')); + const download = items.find((i) => i.label?.toLowerCase().includes('download')); + download?.command?.({} as never); + expect(downloadService.open).toHaveBeenCalledWith('b1'); + }); + + it('Delete → confirmation, then store.deleteBundle on accept', () => { + const items = spectator.component.bundlesKebabFor(row('b1')); + const del = items[items.length - 1]; + del.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-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 new file mode 100644 index 00000000000..2b729863104 --- /dev/null +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/dot-publishing-queue-table/dot-publishing-queue-table.component.ts @@ -0,0 +1,297 @@ +import { DatePipe } from '@angular/common'; +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 */ +// `DotDownloadBundleDialogService` lives in apps/dotcms-ui (not yet promoted to a +// shared lib). Tracked alongside the v1 consolidation work (#36048). + +import { DotGlobalMessageService, DotMessageService } from '@dotcms/data-access'; +import { DotPushPublishDialogService } from '@dotcms/dotcms-js'; +import { PublishAuditStatus, PublishingJobView } from '@dotcms/dotcms-models'; +import { + DotClipboardUtil, + DotEmptyContainerComponent, + DotMessagePipe, + DotRelativeDatePipe, + PrincipalConfiguration +} from '@dotcms/ui'; +import { DotDownloadBundleDialogService } from '@services/dot-download-bundle-dialog/dot-download-bundle-dialog.service'; + +import { DotPublishingStatusChipComponent } from '../components/dot-publishing-status-chip/dot-publishing-status-chip.component'; +import { DotPublishingQueueStore } from '../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; + +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 ACTIVE_STATUSES = new Set([ + PublishAuditStatus.BUNDLE_REQUESTED, + PublishAuditStatus.WAITING_FOR_PUBLISHING, + PublishAuditStatus.BUNDLING, + PublishAuditStatus.SENDING_TO_ENDPOINTS, + PublishAuditStatus.PUBLISHING_BUNDLE, + PublishAuditStatus.RECEIVED_BUNDLE +]); + +@Component({ + selector: 'dot-publishing-queue-table', + standalone: true, + imports: [ + DatePipe, + ButtonModule, + ConfirmDialogModule, + ContextMenuModule, + MenuModule, + SkeletonModule, + TableModule, + TagModule, + TooltipModule, + DotEmptyContainerComponent, + DotMessagePipe, + DotPublishingStatusChipComponent, + DotRelativeDatePipe + ], + providers: [ConfirmationService, DotClipboardUtil], + templateUrl: './dot-publishing-queue-table.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'flex flex-col h-full min-h-0 flex-1' } +}) +export class DotPublishingQueueTableComponent { + readonly store = inject(DotPublishingQueueStore); + private readonly dotMessageService = inject(DotMessageService); + private readonly confirmationService = inject(ConfirmationService); + private readonly clipboard = inject(DotClipboardUtil); + private readonly globalMessage = inject(DotGlobalMessageService); + private readonly dotPushPublishDialogService = inject(DotPushPublishDialogService); + private readonly dotDownloadBundleDialogService = inject(DotDownloadBundleDialogService); + + 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 + * 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.bundlesRows().length === 0 + ? { height: '100%', width: '100%' } + : { width: 'auto' }) + } + } + })); + + readonly bundlesEmpty: PrincipalConfiguration = { + icon: 'pi-inbox', + title: this.dotMessageService.get('publishing-queue.empty.bundles.title'), + subtitle: this.dotMessageService.get('publishing-queue.empty.bundles.subtitle') + }; + + readonly selectedRows = computed(() => { + const selectedIds = new Set(this.store.bundlesSelectedIds()); + return this.store.bundlesRows().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 bundlesKebabFor = (row: PublishingJobView): MenuItem[] => { + const items: 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) + } + ]; + + if (row.status && ACTIVE_STATUSES.has(row.status)) { + items.push({ + label: this.dotMessageService.get('publishing-queue.kebab.configure-send'), + command: () => this.openPushPublish(row) + }); + } + + if (row.status && FAILURE_STATUSES.has(row.status)) { + items.push({ + label: this.dotMessageService.get('publishing-queue.retry-send'), + command: () => this.store.retryBundles({ bundleIds: [row.bundleId] }) + }); + } + + items.push({ + label: this.dotMessageService.get('publishing-queue.kebab.generate-download'), + command: () => this.dotDownloadBundleDialogService.open(row.bundleId) + }); + + items.push({ separator: true }); + items.push({ + label: this.dotMessageService.get('publishing-queue.history.kebab.delete'), + styleClass: 'p-menuitem-danger', + command: () => this.confirmRemove(row) + }); + + return items; + }; + + /** 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. */ + private readonly kebabMenus = computed(() => { + const map = new Map(); + for (const row of this.store.bundlesRows()) { + map.set(row.bundleId, this.bundlesKebabFor(row)); + } + return map; + }); + + kebabFor(row: PublishingJobView): MenuItem[] { + 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); + } + + /** 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; + const page = Math.floor(first / rows) + 1; + + // 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); + } + } + + onSelectionChange(rows: PublishingJobView[]): void { + this.store.setBundlesSelection(rows.map((r) => r.bundleId)); + } + + onRowClick(row: PublishingJobView): void { + this.store.openDetail(row.bundleId); + } + + /** Inline copy-to-clipboard for the Bundle Id column. Same approach used in + * `dot-es-search-page`: a `` + `DotClipboardUtil` + global error + * toast. Lighter than wrapping `` for 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)}…`; + } + + /** + * 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(row: PublishingJobView): void { + this.dotPushPublishDialogService.open({ + assetIdentifier: row.bundleId, + title: row.bundleName || row.bundleId, + isBundle: true + }); + } + + 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'), + 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/lib.routes.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/lib.routes.ts new file mode 100644 index 00000000000..b72f2ffb9c0 --- /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/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 new file mode 100644 index 00000000000..5bd2d66531d --- /dev/null +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/store/dot-publishing-queue.store.spec.ts @@ -0,0 +1,340 @@ +import { createServiceFactory, mockProvider, SpectatorService } from '@ngneat/spectator/jest'; +import { of, throwError } from 'rxjs'; + +import { DotHttpErrorManagerService, DotPublishingQueueService } from '@dotcms/data-access'; +import { + BundleAssetView, + PublishAuditStatus, + PublishingJobDetailView, + PublishingJobsResponse, + PublishingJobView +} 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 BUNDLES_RESPONSE: PublishingJobsResponse = { + entity: [ + buildJob({ bundleId: 'bundle-A', status: PublishAuditStatus.BUNDLING }), + buildJob({ bundleId: 'bundle-B', status: PublishAuditStatus.SUCCESS }) + ], + pagination: { currentPage: 1, perPage: 10, totalEntries: 2 } +}; + +const MOCK_ASSETS: BundleAssetView[] = [ + { asset: 'a1', title: 'Asset 1', type: 'contentlet' }, + { asset: 'a2', title: 'Asset 2', type: 'template' } +]; + +const MOCK_DETAIL: PublishingJobDetailView = { + bundleId: 'bundle-A', + 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, + scheduledPublishDate: null +}; + +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().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' }])), + 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' } })) + }), + mockProvider(DotHttpErrorManagerService) + ] + }); + + beforeEach(() => { + spectator = createService(); + store = spectator.service; + service = spectator.inject( + DotPublishingQueueService + ) as jest.Mocked; + httpErrorManager = spectator.inject( + DotHttpErrorManagerService + ) as jest.Mocked; + spectator.flushEffects(); + }); + + afterEach(() => { + store.stopPolling(); + }); + + describe('onInit', () => { + it('loads bundles once on init with no status filter (BE returns all)', () => { + expect(service.listPublishingJobs).toHaveBeenCalledTimes(1); + expect(service.listPublishingJobs).toHaveBeenCalledWith( + expect.objectContaining({ statuses: undefined }) + ); + expect(store.bundlesStatus()).toBe('loaded'); + expect(store.bundlesRows().length).toBe(2); + }); + }); + + describe('setSearch', () => { + it('resets page and selection, then refetches with the search filter', () => { + store.setBundlesPage(3); + store.setBundlesSelection(['x']); + spectator.flushEffects(); + (service.listPublishingJobs as jest.Mock).mockClear(); + + store.setSearch('term'); + spectator.flushEffects(); + + expect(store.search()).toBe('term'); + expect(store.bundlesPage()).toBe(1); + expect(store.bundlesSelectedIds()).toEqual([]); + expect(service.listPublishingJobs).toHaveBeenCalledWith( + expect.objectContaining({ filter: 'term' }) + ); + }); + }); + + describe('setStatusFilter', () => { + it('forwards only the chosen statuses on the next list call', () => { + const filter = [PublishAuditStatus.BUNDLING, PublishAuditStatus.WAITING_FOR_PUBLISHING]; + (service.listPublishingJobs as jest.Mock).mockClear(); + store.setStatusFilter(filter); + spectator.flushEffects(); + + expect(service.listPublishingJobs).toHaveBeenCalledWith( + expect.objectContaining({ statuses: filter }) + ); + expect(store.bundlesPage()).toBe(1); + expect(store.bundlesSelectedIds()).toEqual([]); + }); + + it('omits the statuses param when the filter is empty so BE returns every status', () => { + store.setStatusFilter([PublishAuditStatus.SUCCESS]); + spectator.flushEffects(); + (service.listPublishingJobs as jest.Mock).mockClear(); + + store.setStatusFilter([]); + spectator.flushEffects(); + + expect(service.listPublishingJobs).toHaveBeenCalledWith( + expect.objectContaining({ statuses: undefined }) + ); + }); + }); + + describe('cycleBundlesSort', () => { + it('cycles asc → desc → off for the same field', () => { + store.cycleBundlesSort('bundle_name'); + expect(store.bundlesSort()).toBe('bundle_name'); + expect(store.bundlesSortDirection()).toBe('asc'); + + store.cycleBundlesSort('bundle_name'); + expect(store.bundlesSortDirection()).toBe('desc'); + + store.cycleBundlesSort('bundle_name'); + expect(store.bundlesSort()).toBeNull(); + }); + + it('switching field starts asc again', () => { + store.cycleBundlesSort('bundle_name'); + store.cycleBundlesSort('status'); + expect(store.bundlesSort()).toBe('status'); + expect(store.bundlesSortDirection()).toBe('asc'); + }); + }); + + describe('refresh', () => { + it('reloads bundles', () => { + (service.listPublishingJobs as jest.Mock).mockClear(); + store.refresh(); + expect(service.listPublishingJobs).toHaveBeenCalledTimes(1); + }); + }); + + 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('closes clears state', () => { + store.openAssetList('B-X'); + store.closeAssetList(); + expect(store.selectedBundleId()).toBeNull(); + expect(store.selectedAssets()).toEqual([]); + }); + }); + + describe('removeBundleAsset', () => { + beforeEach(() => { + (service.removeAssetsFromBundle as jest.Mock).mockClear(); + }); + + it('calls service.removeAssetsFromBundle with [assetId] and refetches assets', () => { + store.openAssetList('B-X'); + (service.getBundleAssets as jest.Mock).mockClear(); + store.removeBundleAsset('a1'); + expect(service.removeAssetsFromBundle).toHaveBeenCalledWith('B-X', ['a1']); + expect(service.getBundleAssets).toHaveBeenCalledWith('B-X'); + }); + + it('is a no-op when no bundle is currently selected', () => { + store.removeBundleAsset('a1'); + expect(service.removeAssetsFromBundle).not.toHaveBeenCalled(); + }); + + it('error path → httpErrorManager.handle called', () => { + const error = new Error('boom'); + (service.removeAssetsFromBundle as jest.Mock).mockReturnValueOnce( + throwError(() => error) + ); + store.openAssetList('B-X'); + store.removeBundleAsset('a1'); + expect(httpErrorManager.handle).toHaveBeenCalledWith(error); + }); + }); + + describe('openDetail / loadDetail / closeDetail', () => { + it('loads details + assets when opened', () => { + store.openDetail('B-Y'); + expect(service.getPublishingJobDetails).toHaveBeenCalledWith('B-Y'); + expect(service.getBundleAssets).toHaveBeenCalledWith('B-Y'); + expect(store.detail()).toEqual(MOCK_DETAIL); + expect(store.detailStatus()).toBe('loaded'); + expect(store.detailAssets()).toEqual(MOCK_ASSETS); + expect(store.detailAssetsStatus()).toBe('loaded'); + }); + + it('closeDetail clears state including detail assets', () => { + store.openDetail('B-Y'); + store.closeDetail(); + expect(store.detailBundleId()).toBeNull(); + expect(store.detail()).toBeNull(); + expect(store.detailAssets()).toEqual([]); + expect(store.detailAssetsStatus()).toBe('init'); + }); + + it('loadDetailAssets error → handle + status reset to loaded', () => { + const error = new Error('boom'); + (service.getBundleAssets as jest.Mock).mockReturnValueOnce(throwError(() => error)); + store.openDetail('B-Z'); + expect(httpErrorManager.handle).toHaveBeenCalledWith(error); + expect(store.detailAssetsStatus()).toBe('loaded'); + }); + }); + + describe('retryBundles / deleteBundle / deleteBundlesBulk / purgeBundles', () => { + 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 hits the bulk service in one call and clears selection', () => { + store.setBundlesSelection(['a', 'b']); + store.deleteBundlesBulk(['a', 'b']); + expect(service.deleteBundles).toHaveBeenCalledTimes(1); + expect(service.deleteBundles).toHaveBeenCalledWith(['a', 'b']); + expect(store.bundlesSelectedIds()).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.setBundlesSelection(['a']); + const statuses = [PublishAuditStatus.SUCCESS, PublishAuditStatus.SUCCESS_WITH_WARNINGS]; + store.purgeBundles(statuses); + expect(service.purgeBundles).toHaveBeenCalledWith(statuses); + expect(store.bundlesSelectedIds()).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('polling', () => { + it('startPolling / stopPolling do not throw', () => { + store.stopPolling(); + store.startPolling(); + store.stopPolling(); + }); + }); + + describe('error handling', () => { + it('loadBundles error → handle + status=error', () => { + const error = new Error('boom'); + (service.listPublishingJobs as jest.Mock).mockReturnValueOnce(throwError(() => error)); + store.loadBundles(); + expect(httpErrorManager.handle).toHaveBeenCalledWith(error); + expect(store.bundlesStatus()).toBe('error'); + }); + + it('loadDetail error → handle + status=error', () => { + const error = new Error('boom'); + (service.getPublishingJobDetails as jest.Mock).mockReturnValueOnce( + throwError(() => error) + ); + store.openDetail('y'); + expect(httpErrorManager.handle).toHaveBeenCalledWith(error); + expect(store.detailStatus()).toBe('error'); + }); + }); +}); 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 new file mode 100644 index 00000000000..241e5a85690 --- /dev/null +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/store/dot-publishing-queue.store.ts @@ -0,0 +1,555 @@ +import { patchState, signalStore, withHooks, withMethods, withState } from '@ngrx/signals'; +import { EMPTY } from 'rxjs'; + +import { DestroyRef, effect, inject, untracked } from '@angular/core'; + +import { catchError, take } from 'rxjs/operators'; + +import { + DotHttpErrorManagerService, + DotPublishingQueueService, + PublishingSortDirection, + PublishingSortField, + RetryBundlesPayload +} from '@dotcms/data-access'; +import { + BundleAssetView, + PublishAuditStatus, + PublishingJobDetailView, + PublishingJobView +} from '@dotcms/dotcms-models'; + +type LoadStatus = 'init' | 'loading' | 'loaded' | 'error'; + +/** 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; + +interface DotPublishingQueueState { + bundlesRows: PublishingJobView[]; + bundlesPage: number; + bundlesTotal: number; + bundlesStatus: LoadStatus; + bundlesSort: PublishingSortField | null; + bundlesSortDirection: PublishingSortDirection; + bundlesSelectedIds: string[]; + + rowsPerPage: number; + search: string; + /** Status chips checked in the toolbar filter. Empty = no filter (all statuses). */ + statusFilter: PublishAuditStatus[]; + + selectedBundleId: string | null; + selectedAssets: BundleAssetView[]; + assetListStatus: LoadStatus; + + detailBundleId: string | null; + detail: PublishingJobDetailView | null; + detailStatus: LoadStatus; + /** Asset list shown inside the Bundle Details modal (separate from the standalone + * 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 = { + bundlesRows: [], + bundlesPage: 1, + bundlesTotal: 0, + bundlesStatus: 'init', + bundlesSort: null, + bundlesSortDirection: 'desc', + bundlesSelectedIds: [], + + rowsPerPage: 20, + search: '', + statusFilter: [], + + selectedBundleId: null, + selectedAssets: [], + assetListStatus: 'init', + + detailBundleId: null, + detail: null, + detailStatus: 'init', + detailAssets: [], + detailAssetsStatus: 'init', + canDownloadBundle: null, + canDownloadManifest: null +}; + +export const DotPublishingQueueStore = signalStore( + withState(initialState), + withMethods((store) => { + const service = inject(DotPublishingQueueService); + const httpErrorManager = inject(DotHttpErrorManagerService); + const destroyRef = inject(DestroyRef); + + let pollHandle: ReturnType | null = null; + + /** + * 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(); + + service + .listPublishingJobs({ + // Omit `statuses` entirely when nothing is selected so the BE returns + // every status. Forward-compatible with new server-side statuses (e.g. + // SCHEDULED, see #36267) without an FE update. + statuses: filter.length > 0 ? filter : undefined, + page: store.bundlesPage(), + perPage: store.rowsPerPage(), + filter: store.search() || undefined, + sort: store.bundlesSort() ?? undefined, + sortDirection: store.bundlesSortDirection() + }) + .pipe( + take(1), + catchError((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; + }) + ) + .subscribe((response) => { + patchState(store, { + bundlesRows: response.entity, + bundlesTotal: response.pagination?.totalEntries ?? 0, + bundlesStatus: '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' + }); + }); + } + + 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' }); + }); + } + + /** + * 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 + * full list — `/api/v1/publishing/{bundleId}` only carries metadata + + * endpoints + a 3-item assetPreview). + */ + function loadDetailAssets() { + const bundleId = store.detailBundleId(); + if (!bundleId) { + return; + } + + patchState(store, { detailAssetsStatus: 'loading', detailAssets: [] }); + service + .getBundleAssets(bundleId) + .pipe( + take(1), + catchError((error) => { + httpErrorManager.handle(error); + patchState(store, { detailAssetsStatus: 'loaded' }); + + return EMPTY; + }) + ) + .subscribe((assets) => { + patchState(store, { + detailAssets: assets, + detailAssetsStatus: 'loaded' + }); + }); + } + + function refresh() { + loadBundles(); + } + + function startPolling() { + stopPolling(); + pollHandle = setInterval(() => { + if (document.hidden) { + return; + } + // Silent: keep existing rows on screen, only swap when the + // response arrives. No skeleton flash every 15 s. + loadBundles(true); + }, POLL_INTERVAL_MS); + } + + function stopPolling() { + if (pollHandle !== null) { + clearInterval(pollHandle); + pollHandle = null; + } + } + + destroyRef.onDestroy(() => stopPolling()); + + return { + loadBundles, + loadAssets, + loadDetail, + refresh, + startPolling, + stopPolling, + + setSearch(search: string) { + patchState(store, { + search, + bundlesPage: 1, + bundlesSelectedIds: [] + }); + }, + + setStatusFilter(statuses: PublishAuditStatus[]) { + patchState(store, { + statusFilter: statuses, + bundlesPage: 1, + bundlesSelectedIds: [] + }); + }, + + setBundlesPage(page: number) { + 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(); + if (current !== field) { + patchState(store, { + bundlesSort: field, + bundlesSortDirection: 'asc', + bundlesPage: 1 + }); + return; + } + if (dir === 'asc') { + patchState(store, { bundlesSortDirection: 'desc', bundlesPage: 1 }); + return; + } + patchState(store, { + bundlesSort: null, + bundlesSortDirection: 'desc', + bundlesPage: 1 + }); + }, + + setBundlesSelection(ids: string[]) { + patchState(store, { bundlesSelectedIds: ids }); + }, + + clearBundlesSelection() { + patchState(store, { bundlesSelectedIds: [] }); + }, + + openAssetList(bundleId: string) { + patchState(store, { + selectedBundleId: bundleId, + selectedAssets: [], + assetListStatus: 'init' + }); + loadAssets(); + }, + + closeAssetList() { + patchState(store, { + selectedBundleId: null, + selectedAssets: [], + assetListStatus: 'init' + }); + }, + + /** + * Removes a single asset from the currently-open bundle and refetches + * the asset list so the row disappears. Backend returns 409 if the + * bundle is already in progress — surfaced via httpErrorManager toast. + */ + removeBundleAsset(assetId: string, onDone?: () => void) { + const bundleId = store.selectedBundleId(); + if (!bundleId) { + return; + } + service + .removeAssetsFromBundle(bundleId, [assetId]) + .pipe( + take(1), + catchError((error) => { + httpErrorManager.handle(error); + return EMPTY; + }) + ) + .subscribe(() => { + loadAssets(); + refresh(); + onDone?.(); + }); + }, + + openDetail(bundleId: string) { + patchState(store, { + detailBundleId: bundleId, + detail: null, + detailStatus: 'init', + detailAssets: [], + detailAssetsStatus: 'init', + canDownloadBundle: null, + canDownloadManifest: null + }); + loadDetail(); + loadDetailAssets(); + probeDownloads(); + }, + + loadDetailAssets, + + closeDetail() { + patchState(store, { + detailBundleId: null, + detail: null, + detailStatus: 'init', + detailAssets: [], + detailAssetsStatus: 'init', + canDownloadBundle: null, + canDownloadManifest: null + }); + }, + + 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?.(); + }); + }, + + /** + * 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) { + if (bundleIds.length === 0) { + onDone?.(); + return; + } + service + .deleteBundles(bundleIds) + .pipe( + take(1), + catchError((error) => { + httpErrorManager.handle(error); + return EMPTY; + }) + ) + .subscribe(() => { + patchState(store, { bundlesSelectedIds: [] }); + 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; + }) + ) + .subscribe(() => { + patchState(store, { bundlesSelectedIds: [] }); + refresh(); + onDone?.(); + }); + } + }; + }), + withHooks((store) => { + return { + onInit() { + effect(() => { + store.search(); + store.statusFilter(); + store.bundlesPage(); + store.bundlesSort(); + store.bundlesSortDirection(); + store.rowsPerPage(); + untracked(() => store.loadBundles()); + }); + + store.startPolling(); + } + }; + }) +); 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 00000000000..791354086e1 --- /dev/null +++ b/core-web/libs/portlets/dot-publishing-queue/src/test-setup.ts @@ -0,0 +1,32 @@ +import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone'; + +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 +}); + +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/core-web/libs/portlets/dot-publishing-queue/tsconfig.json b/core-web/libs/portlets/dot-publishing-queue/tsconfig.json new file mode 100644 index 00000000000..8e2e06fb8ff --- /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 00000000000..a9e4700b870 --- /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 00000000000..48633a8d638 --- /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 2f5017b3290..bd82c596fe7 100644 --- a/core-web/package.json +++ b/core-web/package.json @@ -283,6 +283,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/pnpm-lock.yaml b/core-web/pnpm-lock.yaml index efa775b1270..446afebf157 100644 --- a/core-web/pnpm-lock.yaml +++ b/core-web/pnpm-lock.yaml @@ -713,6 +713,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 diff --git a/core-web/tsconfig.base.json b/core-web/tsconfig.base.json index 671e829959e..d29eb5d42a6 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" ], @@ -103,6 +106,8 @@ "@shared/*": ["apps/dotcms-ui/src/app/shared/*"], "@tests/*": ["apps/dotcms-ui/src/app/test/*"], "sdk-create-app": ["libs/sdk/create-app/src/index.ts"], + "portlet": ["libs/portlets/dot-publishing-queue/src/index.ts"], + "@dotcms/agentic-tools": ["libs/agentic-tools/src/index.ts"] "@dotcms/image-editor": ["libs/image-editor/src/index.ts"], "@dotcms/ai/runtime": ["libs/sdk/ai/src/runtime.ts"], "@dotcms/ai/sandbox": ["libs/sdk/ai/src/sandbox/index.ts"], 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 4747cf76924..9c971e90c10 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 @@ -486,6 +486,7 @@ public Response deletePublishingJob( .requiredFrontendUser(false) .requestAndResponse(request, response) .rejectWhenNoUser(true) + .requiredPortlet("publishing-queue") .init(); final User user = initData.getUser(); @@ -950,6 +951,7 @@ public ResponseEntityPurgeView purgePublishingJobs( .requiredFrontendUser(false) .requestAndResponse(request, response) .rejectWhenNoUser(true) + .requiredPortlet("publishing-queue") .init(); final User user = initData.getUser(); diff --git a/dotCMS/src/main/java/com/dotmarketing/util/PortletID.java b/dotCMS/src/main/java/com/dotmarketing/util/PortletID.java index 9191ba93c09..f44d2a67079 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 1d40d02331c..1c77d844435 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 @@ -3753,6 +3754,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 @@ -3776,6 +3778,144 @@ 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 +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.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.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 +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 +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 +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.items-count.singular={0} item +publishing-queue.asset-list.items-count.plural={0} items +publishing-queue.asset-list.empty=No items in this bundle. +publishing-queue.asset-list.remove=Remove from bundle +publishing-queue.asset-list.remove-confirm.header=Remove asset from bundle? +publishing-queue.asset-list.remove-confirm.message=Are you sure you want to remove "{0}" from this bundle? +publishing-queue.send=Send +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=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 +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=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 +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 +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 +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.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 +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.total-assets=Total assets +publishing-queue.detail.assets-suffix=assets +publishing-queue.detail.endpoints=Endpoints +publishing-queue.detail.environment=Environment +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.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. +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 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-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. 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 77c2b0c8987..8047924a829 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 + +