Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
063b740
feat(publishing-queue) #36040: Angular portlet shell, Queue tab, Asse…
hmoreras Jun 8, 2026
b01ed43
fix(publishing-queue) #36045: enforce publishing-queue portlet gate o…
hmoreras Jun 8, 2026
8c05085
feat(publishing-queue) #36040: History tab, modals (Details, Configur…
hmoreras Jun 8, 2026
4037903
feat(publishing-queue) #36040: UX iteration — empty state, status chi…
hmoreras Jun 13, 2026
dec6884
merge master
hmoreras Jun 15, 2026
1fba21e
refactor(publishing-queue) #36040: reuse project-wide push publish + …
hmoreras Jun 16, 2026
3d708df
refactor(publishing-queue) #36040: History table layout (dot-tags pat…
hmoreras Jun 16, 2026
a33928b
fix(publishing-queue) #36040: align history status labels with legacy…
hmoreras Jun 17, 2026
5f86b2d
feat(publishing-queue) #36040: add Bundle Name column to history table
hmoreras Jun 17, 2026
5e36a82
feat(publishing-queue) #36040: Select Bundles to Delete dialog (4 sco…
hmoreras Jun 17, 2026
9658986
refactor(publishing-queue) #36040: gate Delete Bundles button on sele…
hmoreras Jun 17, 2026
b11b749
refactor(publishing-queue) #36040: bundle details — single endpoints …
hmoreras Jun 18, 2026
8f983b5
feat(publishing-queue) #36040: upload bundle dialog — canonical drag-…
hmoreras Jun 18, 2026
549b63b
feat(publishing-queue) #36040: history row kebab + layout polish + de…
hmoreras Jun 18, 2026
a2efc00
style(publishing-queue) #36040: history table — drop per-cell font si…
hmoreras Jun 18, 2026
874232b
style(publishing-queue) #36040: smaller font for Bundle Id + dates; m…
hmoreras Jun 18, 2026
d796fdf
refactor(publishing-queue) #36040: single unified table + status chip…
hmoreras Jun 19, 2026
6ec96bb
refactor(publishing-queue) #36040: omit status param when filter is e…
hmoreras Jun 22, 2026
36b8937
feat(publishing-queue) #36040: Add Bundle dropdown + Select Bundle di…
hmoreras Jun 22, 2026
eb7bcb9
style(publishing-queue) #36040: shorter status chip labels + tighter …
hmoreras Jun 23, 2026
502ed82
fix(publishing-queue) #36040: Select Bundle asset table — name + type…
hmoreras Jun 23, 2026
12c16ab
Merge branch 'main' into issue-36040-publishing-queue-angular-portlet
hmoreras Jun 24, 2026
2c4774f
feat(publishing-queue): items column, custom dialog header, row conte…
hmoreras Jun 24, 2026
556366a
feat(publishing-queue): gate bundle + manifest download buttons on HE…
hmoreras Jun 24, 2026
c271d15
feat(publishing-queue): red bundle ids on failed rows, dotRelativeDat…
hmoreras Jun 24, 2026
041da18
Merge branch 'main' into issue-36040-publishing-queue-angular-portlet
hmoreras Jun 24, 2026
b379f31
merge manin
hmoreras Jun 25, 2026
fd17f4d
chore(core-web): regenerate pnpm-lock.yaml for jest-util
hmoreras Jun 25, 2026
d1ee134
feat(publishing-queue): surface the synthetic SCHEDULED status
hmoreras Jun 25, 2026
d8ff4db
feat(publishing-queue): inline Configure step in Select Bundle, push …
hmoreras Jun 25, 2026
c05873f
fix(publishing-queue): persist rows-per-page and match content-drive …
hmoreras Jun 25, 2026
ad5ddcc
fix(publishing-queue): silent polling — stop the every-15s skeleton f…
hmoreras Jun 26, 2026
464b911
fix(publishing-queue): status filter — include SCHEDULED, dedupe by l…
hmoreras Jun 26, 2026
7bdb68c
feat(publishing-queue): inline Download menu in Select Bundle dialog
hmoreras Jun 26, 2026
6e9a58b
feat(publishing-queue): make Select Bundle asset rows clickable, drop…
hmoreras Jun 26, 2026
bb080e8
fix(publishing-queue): drop sort UI from the bundles table — BE doesn…
hmoreras Jun 26, 2026
52261e6
feat(publishing-queue): inline warning instead of disabled buttons
hmoreras Jun 26, 2026
8b18c84
feat(publishing-queue): surface scheduledPublishDate in bundle details
hmoreras Jun 30, 2026
79b99bb
feat(publishing-queue): swap Add Bundle ↔ Retry Send on selection
hmoreras Jun 30, 2026
b4a6b70
feat(publishing-queue): replace 4-scope Remove dialog with a simple c…
hmoreras Jul 1, 2026
556c955
feat(publishing-queue): asset-list dialog polish + allow remove on SC…
hmoreras Jul 1, 2026
b3934bd
fix(publishing-queue): breathe some room around the Select Bundle tra…
hmoreras Jul 1, 2026
920f0a1
feat(publishing-queue): primary Close + unified date format on detail…
hmoreras Jul 2, 2026
700a9f7
feat(publishing-queue): table typography, date format, frozen edge co…
hmoreras Jul 2, 2026
ab81fdf
fix(publishing-queue): hide the Select Bundle trash icon until row hover
hmoreras Jul 2, 2026
3119267
feat(publishing-queue): Select Bundle dialog polish (pagination, head…
hmoreras Jul 2, 2026
d9b4e1b
feat(publishing-queue): Upload dialog — always-clickable Upload with …
hmoreras Jul 2, 2026
d6cd9f2
Merge branch 'main' into issue-36040-publishing-queue-angular-portlet
hmoreras Jul 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions core-web/apps/dotcms-ui/src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
1 change: 1 addition & 0 deletions core-web/libs/data-access/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -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' });
});
});
});
Loading
Loading