diff --git a/core-web/apps/dotcdn/src/app/app.module.ts b/core-web/apps/dotcdn/src/app/app.module.ts index 567eb91cf2fe..79a83c4e192f 100644 --- a/core-web/apps/dotcdn/src/app/app.module.ts +++ b/core-web/apps/dotcdn/src/app/app.module.ts @@ -15,9 +15,6 @@ import { TextareaModule } from 'primeng/textarea'; import { DotcmsConfigService, - DotcmsEventsService, - DotEventsSocket, - DotEventsSocketURL, LoggerService, LoginService, SiteService, @@ -28,13 +25,6 @@ import { DotIconComponent, DotSpinnerComponent } from '@dotcms/ui'; import { AppComponent } from './app.component'; import { DotCDNStore } from './dotcdn.component.store'; -const dotEventSocketURLFactory = () => { - return new DotEventsSocketURL( - `${window.location.hostname}:${window.location.port}/api/ws/v1/system/events`, - window.location.protocol === 'https:' - ); -}; - @NgModule({ declarations: [AppComponent], imports: [ @@ -59,9 +49,6 @@ const dotEventSocketURLFactory = () => { StringUtils, SiteService, LoginService, - DotEventsSocket, - DotcmsEventsService, - { provide: DotEventsSocketURL, useFactory: dotEventSocketURLFactory }, DotcmsConfigService, DotCDNStore ], diff --git a/core-web/apps/dotcms-ui/src/app/api/services/dot-custom-event-handler/dot-custom-event-handler.service.spec.ts b/core-web/apps/dotcms-ui/src/app/api/services/dot-custom-event-handler/dot-custom-event-handler.service.spec.ts index 57106dacc661..aedfe5b78582 100644 --- a/core-web/apps/dotcms-ui/src/app/api/services/dot-custom-event-handler/dot-custom-event-handler.service.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/api/services/dot-custom-event-handler/dot-custom-event-handler.service.spec.ts @@ -33,9 +33,6 @@ import { import { ApiRoot, DotcmsConfigService, - DotcmsEventsService, - DotEventsSocket, - DotEventsSocketURL, DotPushPublishDialogService, LoggerService, LoginService, @@ -52,7 +49,7 @@ import { import { DotCustomEventHandlerService } from './dot-custom-event-handler.service'; -import { dotEventSocketURLFactory, MockDotUiColorsService } from '../../../test/dot-test-bed'; +import { MockDotUiColorsService } from '../../../test/dot-test-bed'; import { DotContentletEditorService } from '../../../view/components/dot-contentlet-editor/services/dot-contentlet-editor.service'; import { DotDownloadBundleDialogService } from '../dot-download-bundle-dialog/dot-download-bundle-dialog.service'; import { DotMenuService } from '../dot-menu.service'; @@ -94,10 +91,7 @@ describe('DotCustomEventHandlerService', () => { { provide: DotFormatDateService, useClass: DotFormatDateServiceMock }, UserModel, StringUtils, - DotcmsEventsService, LoggerService, - DotEventsSocket, - { provide: DotEventsSocketURL, useFactory: dotEventSocketURLFactory }, DotcmsConfigService, LoggerService, DotCurrentUserService, diff --git a/core-web/apps/dotcms-ui/src/app/api/services/dot-site-navigation/dot-site-navigation.effect.spec.ts b/core-web/apps/dotcms-ui/src/app/api/services/dot-site-navigation/dot-site-navigation.effect.spec.ts new file mode 100644 index 000000000000..e7b474dbd795 --- /dev/null +++ b/core-web/apps/dotcms-ui/src/app/api/services/dot-site-navigation/dot-site-navigation.effect.spec.ts @@ -0,0 +1,53 @@ +import { + createServiceFactory, + mockProvider, + SpectatorService, + SpyObject +} from '@ngneat/spectator/jest'; +import { Subject } from 'rxjs'; + +import { DotRouterService } from '@dotcms/data-access'; +import { DotSite } from '@dotcms/dotcms-models'; +import { GlobalStore } from '@dotcms/store'; +import { MockDotRouterService, mockSites } from '@dotcms/utils-testing'; + +import { DotSiteNavigationEffect } from './dot-site-navigation.effect'; + +describe('DotSiteNavigationEffect', () => { + let spectator: SpectatorService; + let dotRouterService: SpyObject; + let switchSiteSubject: Subject; + + const createService = createServiceFactory({ + service: DotSiteNavigationEffect, + providers: [{ provide: DotRouterService, useClass: MockDotRouterService }] + }); + + beforeEach(() => { + // switchSiteSubject must be assigned before createService() so the effect + // constructor receives the correct observable when it subscribes on instantiation. + switchSiteSubject = new Subject(); + + spectator = createService({ + providers: [ + mockProvider(GlobalStore, { + switchSiteEvent$: jest.fn().mockReturnValue(switchSiteSubject.asObservable()) + }) + ] + }); + + dotRouterService = spectator.inject(DotRouterService); + }); + + it('should navigate to site browser when SWITCH_SITE fires on edit page', () => { + jest.spyOn(dotRouterService, 'isEditPage').mockReturnValue(true); + switchSiteSubject.next(mockSites[0] as unknown as DotSite); + expect(dotRouterService.goToSiteBrowser).toHaveBeenCalled(); + }); + + it('should NOT navigate when SWITCH_SITE fires on a non-edit page', () => { + jest.spyOn(dotRouterService, 'isEditPage').mockReturnValue(false); + switchSiteSubject.next(mockSites[0] as unknown as DotSite); + expect(dotRouterService.goToSiteBrowser).not.toHaveBeenCalled(); + }); +}); diff --git a/core-web/apps/dotcms-ui/src/app/api/services/dot-site-navigation/dot-site-navigation.effect.ts b/core-web/apps/dotcms-ui/src/app/api/services/dot-site-navigation/dot-site-navigation.effect.ts new file mode 100644 index 000000000000..505c11ad9079 --- /dev/null +++ b/core-web/apps/dotcms-ui/src/app/api/services/dot-site-navigation/dot-site-navigation.effect.ts @@ -0,0 +1,28 @@ +import { Injectable, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; + +import { DotRouterService } from '@dotcms/data-access'; +import { GlobalStore } from '@dotcms/store'; + +/** + * App-level effect that navigates away from the edit page whenever + * another user/tab switches the current site via WebSocket. + * + * Provided eagerly in app.config.ts so it is active for the full + * application lifetime without being tied to any particular component. + */ +@Injectable({ providedIn: 'root' }) +export class DotSiteNavigationEffect { + readonly #dotRouterService = inject(DotRouterService); + + constructor() { + inject(GlobalStore) + .switchSiteEvent$() + .pipe(takeUntilDestroyed()) + .subscribe(() => { + if (this.#dotRouterService.isEditPage()) { + this.#dotRouterService.goToSiteBrowser(); + } + }); + } +} diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/container-list/container-list.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/container-list/container-list.component.spec.ts index 3da6ae11cd92..8ac56cd8be57 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/container-list/container-list.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/container-list/container-list.component.spec.ts @@ -1,4 +1,4 @@ -import { of } from 'rxjs'; +import { of, Subject } from 'rxjs'; import { CommonModule } from '@angular/common'; import { HttpClient, provideHttpClient } from '@angular/common/http'; @@ -36,9 +36,6 @@ import { } from '@dotcms/data-access'; import { DotcmsConfigService, - DotcmsEventsService, - DotEventsSocket, - DotEventsSocketURL, DotPushPublishDialogService, LoggerService, LoginService, @@ -46,7 +43,13 @@ import { SiteService, StringUtils } from '@dotcms/dotcms-js'; -import { CONTAINER_SOURCE, DotActionBulkResult, DotContainer } from '@dotcms/dotcms-models'; +import { + CONTAINER_SOURCE, + DotActionBulkResult, + DotContainer, + DotSite +} from '@dotcms/dotcms-models'; +import { GlobalStore } from '@dotcms/store'; import { DotActionMenuButtonComponent, DotAddToBundleComponent, @@ -67,7 +70,6 @@ import { ContainerListComponent } from './container-list.component'; import { DotContainerListStore } from './store/dot-container-list.store'; import { DotContainersService } from '../../../api/services/dot-containers/dot-containers.service'; -import { dotEventSocketURLFactory } from '../../../test/dot-test-bed'; import { DotEmptyStateComponent } from '../../../view/components/_common/dot-empty-state/dot-empty-state.component'; import { DotContentTypeSelectorComponent } from '../../../view/components/dot-content-type-selector/dot-content-type-selector.component'; import { ActionHeaderComponent } from '../../../view/components/dot-listing-data-table/action-header/action-header.component'; @@ -226,10 +228,13 @@ describe('ContainerListComponent', () => { let siteService: SiteServiceMock; let store: DotContainerListStore; let paginatorService: PaginatorService; + let switchSiteSubject: Subject; const messageServiceMock = new MockDotMessageService(messages); beforeEach(async () => { + switchSiteSubject = new Subject(); + await TestBed.configureTestingModule({ declarations: [], imports: [ @@ -258,10 +263,8 @@ describe('ContainerListComponent', () => { DialogService, DotAlertConfirmService, DotcmsConfigService, - DotcmsEventsService, DotContainerListStore, DotContainersService, - DotEventsSocket, DotHttpErrorManagerService, DotSiteBrowserService, HttpClient, @@ -290,11 +293,16 @@ describe('ContainerListComponent', () => { } }, { provide: DotMessageService, useValue: messageServiceMock }, - { provide: DotEventsSocketURL, useFactory: dotEventSocketURLFactory }, { provide: DotFormatDateService, useClass: DotFormatDateServiceMock }, { provide: DotMessageDisplayService, useClass: DotMessageDisplayServiceMock + }, + { + provide: GlobalStore, + useValue: { + switchSiteEvent$: () => switchSiteSubject.asObservable() + } } ], schemas: [CUSTOM_ELEMENTS_SCHEMA, NO_ERRORS_SCHEMA] @@ -515,8 +523,9 @@ describe('ContainerListComponent', () => { it("should fetch containers when site is changed and it's not the first time", () => { jest.spyOn(paginatorService, 'setExtraParams'); + jest.spyOn(paginatorService, 'getFirstPage').mockReturnValue(of(containersMock)); - siteService.setFakeCurrentSite(mockSites[1]); + switchSiteSubject.next(mockSites[1] as unknown as DotSite); fixture.detectChanges(); @@ -524,7 +533,7 @@ describe('ContainerListComponent', () => { 'host', mockSites[1].identifier ); - expect(paginatorService.get).toHaveBeenCalled(); + expect(paginatorService.getFirstPage).toHaveBeenCalled(); }); }); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/container-list/container-list.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/container-list/container-list.component.ts index d970777e5577..a5ef4d6c06ee 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/container-list/container-list.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/container-list/container-list.component.ts @@ -25,7 +25,6 @@ import { DotMessageService, DotSiteBrowserService } from '@dotcms/data-access'; -import { SiteService } from '@dotcms/dotcms-js'; import { CONTAINER_SOURCE, DotActionBulkResult, @@ -36,6 +35,7 @@ import { DotMessageSeverity, DotMessageType } from '@dotcms/dotcms-models'; +import { GlobalStore } from '@dotcms/store'; import { DotActionMenuButtonComponent, DotAddToBundleComponent, @@ -84,7 +84,7 @@ export class ContainerListComponent implements OnDestroy { private dotMessageService = inject(DotMessageService); private dotMessageDisplayService = inject(DotMessageDisplayService); private dialogService = inject(DialogService); - private siteService = inject(SiteService); + readonly #globalStore = inject(GlobalStore); @ViewChild('actionsMenu') actionsMenu: Menu; @@ -106,9 +106,10 @@ export class ContainerListComponent implements OnDestroy { this.selectedContainers = []; }); - this.siteService.switchSite$.subscribe(({ identifier }) => - this.#store.getContainersByHost(identifier) - ); + this.#globalStore + .switchSiteEvent$() + .pipe(takeUntil(this.destroy$)) + .subscribe(({ identifier }) => this.#store.getContainersByHost(identifier)); } ngOnDestroy(): void { diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-code/dot-add-variable/dot-add-variable.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-code/dot-add-variable/dot-add-variable.component.spec.ts index f114c17565e2..501b562dd91c 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-code/dot-add-variable/dot-add-variable.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-code/dot-add-variable/dot-add-variable.component.spec.ts @@ -31,15 +31,7 @@ import { DotSiteBrowserService, DotGlobalMessageService } from '@dotcms/data-access'; -import { - DotcmsConfigService, - DotcmsEventsService, - DotEventsSocket, - DotEventsSocketURL, - LoggerService, - LoginService, - StringUtils -} from '@dotcms/dotcms-js'; +import { DotcmsConfigService, LoggerService, LoginService, StringUtils } from '@dotcms/dotcms-js'; import { DotCMSContentType } from '@dotcms/dotcms-models'; import { DotMessagePipe } from '@dotcms/ui'; import { @@ -52,8 +44,6 @@ import { DotAddVariableComponent } from './dot-add-variable.component'; import { FilteredFieldTypes } from './dot-add-variable.models'; import { DOT_CONTENT_MAP, DotFieldsService } from './services/dot-fields.service'; -import { dotEventSocketURLFactory } from '../../../../../test/dot-test-bed'; - @Component({ selector: 'dot-form-dialog', template: '', @@ -227,13 +217,10 @@ describe('DotAddVariableComponent', () => { } } }, - { provide: DotEventsSocketURL, useFactory: dotEventSocketURLFactory }, StringUtils, DotHttpErrorManagerService, DotAlertConfirmService, ConfirmationService, - DotcmsEventsService, - DotEventsSocket, DotcmsConfigService, { provide: DotMessageDisplayService, diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-create.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-create.component.spec.ts index cf7735bc31ab..a5b6c78b8af3 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-create.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-create.component.spec.ts @@ -17,20 +17,12 @@ import { DotMessageDisplayService, DotRouterService } from '@dotcms/data-access'; -import { - DotcmsEventsService, - DotEventsSocket, - DotEventsSocketURL, - LoggerService, - StringUtils -} from '@dotcms/dotcms-js'; +import { LoggerService, StringUtils } from '@dotcms/dotcms-js'; import { CONTAINER_SOURCE } from '@dotcms/dotcms-models'; import { DotMessagePipe } from '@dotcms/ui'; import { DotContainerCreateComponent } from './dot-container-create.component'; -import { dotEventSocketURLFactory } from '../../../test/dot-test-bed'; - @Pipe({ name: 'dm' }) @@ -91,12 +83,9 @@ describe('ContainerCreateComponent', () => { DotGlobalMessageService, DotHttpErrorManagerService, DotMessageDisplayService, - DotcmsEventsService, - DotEventsSocket, LoggerService, StringUtils, - DotContentTypeService, - { provide: DotEventsSocketURL, useFactory: dotEventSocketURLFactory } + DotContentTypeService ] }) .overrideComponent(DotContainerCreateComponent, { diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-properties/dot-container-properties.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-properties/dot-container-properties.component.spec.ts index 18b5bfa2b180..b133e04e3dd4 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-properties/dot-container-properties.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-properties/dot-container-properties.component.spec.ts @@ -42,15 +42,7 @@ import { DotRouterService, DotSiteBrowserService } from '@dotcms/data-access'; -import { - DotcmsConfigService, - DotcmsEventsService, - DotEventsSocket, - DotEventsSocketURL, - LoggerService, - LoginService, - StringUtils -} from '@dotcms/dotcms-js'; +import { DotcmsConfigService, LoggerService, LoginService, StringUtils } from '@dotcms/dotcms-js'; import { DotCMSContentType } from '@dotcms/dotcms-models'; import { DotActionMenuButtonComponent, @@ -72,7 +64,6 @@ import { import { DotContainerPropertiesComponent } from './dot-container-properties.component'; import { DotContainersService } from '../../../../api/services/dot-containers/dot-containers.service'; -import { dotEventSocketURLFactory } from '../../../../test/dot-test-bed'; import { DotActionButtonComponent } from '../../../../view/components/_common/dot-action-button/dot-action-button.component'; @Component({ @@ -253,7 +244,6 @@ describe('DotContainerPropertiesComponent', () => { provideHttpClient(), provideHttpClientTesting(), { provide: DotMessageService, useValue: messageServiceMock }, - { provide: DotEventsSocketURL, useFactory: dotEventSocketURLFactory }, { provide: ActivatedRoute, useValue: { data: of(containerMockData) } }, { provide: DotRouterService, useValue: mockRouterService }, StringUtils, @@ -262,8 +252,6 @@ describe('DotContainerPropertiesComponent', () => { DotAlertConfirmService, ConfirmationService, LoginService, - DotcmsEventsService, - DotEventsSocket, DotcmsConfigService, { provide: DotMessageDisplayService, useClass: DotMessageDisplayServiceMock }, DialogService, diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.spec.ts index 6a6b0379719f..3fe120d99546 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.spec.ts @@ -32,7 +32,6 @@ import { } from '@dotcms/data-access'; import { DotcmsConfigService, - DotcmsEventsService, DotPushPublishDialogService, LoggerService, LoginService, @@ -52,7 +51,6 @@ import { DotcmsConfigServiceMock, dotcmsContentletMock, dotcmsContentTypeBasicMock, - DotcmsEventsServiceMock, DotCurrentUserServiceMock, DotLanguagesServiceMock, DotLicenseServiceMock, @@ -163,7 +161,6 @@ describe('DotPageStore', () => { DotLocalstorageService, DotPushPublishDialogService, { provide: DialogService, useClass: DialogServiceMock }, - { provide: DotcmsEventsService, useClass: DotcmsEventsServiceMock }, { provide: DotCurrentUserService, useClass: DotCurrentUserServiceMock }, { provide: DotMessageDisplayService, diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-table/dot-pages-table.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-table/dot-pages-table.component.spec.ts index 8c17be842f3e..8cf46c00c23b 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-table/dot-pages-table.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-table/dot-pages-table.component.spec.ts @@ -14,7 +14,6 @@ import { TableModule } from 'primeng/table'; import { TooltipModule } from 'primeng/tooltip'; import { DotFormatDateService, DotMessageService } from '@dotcms/data-access'; -import { DotcmsEventsService } from '@dotcms/dotcms-js'; import { DotCMSContentlet, DotSystemLanguage } from '@dotcms/dotcms-models'; import { DotAutofocusDirective, @@ -22,7 +21,6 @@ import { DotMessagePipe, DotRelativeDatePipe } from '@dotcms/ui'; -import { DotcmsEventsServiceMock } from '@dotcms/utils-testing'; import { DotPagesTableComponent } from './dot-pages-table.component'; @@ -162,11 +160,7 @@ describe('DotPagesTableComponent', () => { MockProvider(DotFormatDateService), MockProvider(DotPageActionsService, { getItems: jest.fn().mockReturnValue(of([])) - }), - { - provide: DotcmsEventsService, - useClass: DotcmsEventsServiceMock - } + }) ] }); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-porlet-detail/dot-contentlets/dot-contentlets.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-porlet-detail/dot-contentlets/dot-contentlets.component.spec.ts index 4348fa49dc6a..6e5328fd736d 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-porlet-detail/dot-contentlets/dot-contentlets.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-porlet-detail/dot-contentlets/dot-contentlets.component.spec.ts @@ -34,9 +34,6 @@ import { import { ApiRoot, DotcmsConfigService, - DotcmsEventsService, - DotEventsSocket, - DotEventsSocketURL, LoggerService, LoginService, StringUtils, @@ -52,7 +49,7 @@ import { DotContentletsComponent } from './dot-contentlets.component'; import { DotCustomEventHandlerService } from '../../../api/services/dot-custom-event-handler/dot-custom-event-handler.service'; import { DotDownloadBundleDialogService } from '../../../api/services/dot-download-bundle-dialog/dot-download-bundle-dialog.service'; -import { dotEventSocketURLFactory, MockDotUiColorsService } from '../../../test/dot-test-bed'; +import { MockDotUiColorsService } from '../../../test/dot-test-bed'; import { IframeOverlayService } from '../../../view/components/_common/iframe/service/iframe-overlay.service'; import { DotEditContentletComponent } from '../../../view/components/dot-contentlet-editor/components/dot-edit-contentlet/dot-edit-contentlet.component'; import { DotContentletEditorService } from '../../../view/components/dot-contentlet-editor/services/dot-contentlet-editor.service'; @@ -101,10 +98,7 @@ describe('DotContentletsComponent', () => { DotFormatDateService, UserModel, StringUtils, - DotcmsEventsService, LoggerService, - DotEventsSocket, - { provide: DotEventsSocketURL, useFactory: dotEventSocketURLFactory }, { provide: DotcmsConfigService, useClass: DotcmsConfigServiceMock }, DotCurrentUserService, DotMessageDisplayService, diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-porlet-detail/dot-portlet-detail.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-porlet-detail/dot-portlet-detail.component.spec.ts index 3650bcaa019d..6047abf273be 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-porlet-detail/dot-portlet-detail.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-porlet-detail/dot-portlet-detail.component.spec.ts @@ -33,9 +33,6 @@ import { import { ApiRoot, DotcmsConfigService, - DotcmsEventsService, - DotEventsSocket, - DotEventsSocketURL, DotPushPublishDialogService, LoggerService, LoginService, @@ -49,7 +46,7 @@ import { DotPortletDetailComponent } from './dot-portlet-detail.component'; import { DotCustomEventHandlerService } from '../../api/services/dot-custom-event-handler/dot-custom-event-handler.service'; import { DotDownloadBundleDialogService } from '../../api/services/dot-download-bundle-dialog/dot-download-bundle-dialog.service'; import { DotMenuService } from '../../api/services/dot-menu.service'; -import { dotEventSocketURLFactory, MockDotUiColorsService } from '../../test/dot-test-bed'; +import { MockDotUiColorsService } from '../../test/dot-test-bed'; import { DotDownloadBundleDialogComponent } from '../../view/components/_common/dot-download-bundle-dialog/dot-download-bundle-dialog.component'; import { IframeOverlayService } from '../../view/components/_common/iframe/service/iframe-overlay.service'; import { DotContentletEditorService } from '../../view/components/dot-contentlet-editor/services/dot-contentlet-editor.service'; @@ -77,10 +74,7 @@ describe('DotPortletDetailComponent', () => { DotFormatDateService, UserModel, StringUtils, - DotcmsEventsService, LoggerService, - DotEventsSocket, - { provide: DotEventsSocketURL, useFactory: dotEventSocketURLFactory }, DotcmsConfigService, LoggerService, DotCurrentUserService, diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-porlet-detail/dot-workflow-task/dot-workflow-task.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-porlet-detail/dot-workflow-task/dot-workflow-task.component.spec.ts index b01cf1740bdc..80b200526f2d 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-porlet-detail/dot-workflow-task/dot-workflow-task.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-porlet-detail/dot-workflow-task/dot-workflow-task.component.spec.ts @@ -38,9 +38,6 @@ import { import { ApiRoot, DotcmsConfigService, - DotcmsEventsService, - DotEventsSocket, - DotEventsSocketURL, LoggerService, LoginService, StringUtils, @@ -58,7 +55,7 @@ import { DotWorkflowTaskComponent } from './dot-workflow-task.component'; import { DotCustomEventHandlerService } from '../../../api/services/dot-custom-event-handler/dot-custom-event-handler.service'; import { DotDownloadBundleDialogService } from '../../../api/services/dot-download-bundle-dialog/dot-download-bundle-dialog.service'; import { DotMenuService } from '../../../api/services/dot-menu.service'; -import { dotEventSocketURLFactory, MockDotUiColorsService } from '../../../test/dot-test-bed'; +import { MockDotUiColorsService } from '../../../test/dot-test-bed'; import { IframeOverlayService } from '../../../view/components/_common/iframe/service/iframe-overlay.service'; import { DotContentletEditorService } from '../../../view/components/dot-contentlet-editor/services/dot-contentlet-editor.service'; import { DotWorkflowTaskDetailComponent } from '../../../view/components/dot-workflow-task-detail/dot-workflow-task-detail.component'; @@ -133,9 +130,6 @@ describe('DotWorkflowTaskComponent', () => { provideHttpClientTesting(), DotCurrentUserService, DotMessageDisplayService, - DotcmsEventsService, - DotEventsSocket, - { provide: DotEventsSocketURL, useFactory: dotEventSocketURLFactory }, DotcmsConfigService, DotWizardService, DotHttpErrorManagerService, diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-create-edit.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-create-edit.component.spec.ts index cc47477a135b..3b76a00a7c3b 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-create-edit.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-create-edit.component.spec.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { BehaviorSubject, Observable, of } from 'rxjs'; +import { BehaviorSubject, Observable, of, Subject } from 'rxjs'; import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; @@ -31,12 +31,12 @@ import { DotWorkflowActionsFireService, PaginatorService } from '@dotcms/data-access'; -import { DotcmsEventsService, SiteService } from '@dotcms/dotcms-js'; -import { DotSystemConfig } from '@dotcms/dotcms-models'; +import { SiteService } from '@dotcms/dotcms-js'; +import { DotSite, DotSystemConfig } from '@dotcms/dotcms-models'; +import { GlobalStore } from '@dotcms/store'; import { DotFormDialogComponent, DotMessagePipe, DotApiLinkComponent } from '@dotcms/ui'; import { DotCurrentUserServiceMock, - DotcmsEventsServiceMock, MockDotMessageService, MockDotRouterService, mockDotThemes, @@ -196,6 +196,12 @@ describe('DotTemplateCreateEditComponent', () => { let store: DotTemplateStore; let templateStoreValue: TemplateStoreValueType; const siteServiceMock = new SiteServiceMock(); + const switchSiteSubject = new Subject(); + + const globalStoreMock = { + switchSiteEvent$: () => switchSiteSubject.asObservable(), + addNewBreadcrumb: jest.fn() + }; beforeEach(async () => { await TestBed.configureTestingModule({ @@ -300,12 +306,9 @@ describe('DotTemplateCreateEditComponent', () => { get: jest.fn().mockReturnValue(of(mockDotThemes[1])) } }, - { - provide: DotcmsEventsService, - useValue: new DotcmsEventsServiceMock() - }, { provide: DotSystemConfigService, useClass: MockDotSystemConfigService }, - { provide: DotRouterService, useClass: MockDotRouterService } + { provide: DotRouterService, useClass: MockDotRouterService }, + { provide: GlobalStore, useValue: globalStoreMock } ] }) .overrideComponent(DotTemplateCreateEditComponent, { @@ -715,7 +718,7 @@ describe('DotTemplateCreateEditComponent', () => { it('should go to listing if page site changes', () => { fixture.detectChanges(); // Initialize component and subscriptions - siteServiceMock.setFakeCurrentSite(mockSites[1]); // switching the site + switchSiteSubject.next(mockSites[1] as unknown as DotSite); // switching the site expect(store.goToTemplateList).toHaveBeenCalledTimes(1); }); }); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-create-edit.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-create-edit.component.ts index ff8149963827..bd59ff721908 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-create-edit.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-create-edit.component.ts @@ -15,7 +15,6 @@ import { DialogService, DynamicDialogModule } from 'primeng/dynamicdialog'; import { takeUntil, tap } from 'rxjs/operators'; import { DotMessageService } from '@dotcms/data-access'; -import { SiteService } from '@dotcms/dotcms-js'; import { DotLayout, DotTemplate } from '@dotcms/dotcms-models'; import { GlobalStore } from '@dotcms/store'; import { DotApiLinkComponent, DotMessagePipe } from '@dotcms/ui'; @@ -49,7 +48,6 @@ export class DotTemplateCreateEditComponent implements OnInit, OnDestroy { private fb = inject(UntypedFormBuilder); private dialogService = inject(DialogService); private dotMessageService = inject(DotMessageService); - private dotSiteService = inject(SiteService); readonly #store = inject(DotTemplateStore); readonly #globalStore = inject(GlobalStore); @@ -246,9 +244,12 @@ export class DotTemplateCreateEditComponent implements OnInit, OnDestroy { } private setSwitchSiteListener(): void { - this.dotSiteService.switchSite$.pipe(takeUntil(this.destroy$)).subscribe(() => { - this.#store.goToTemplateList(); - }); + this.#globalStore + .switchSiteEvent$() + .pipe(takeUntil(this.destroy$)) + .subscribe(() => { + this.#store.goToTemplateList(); + }); } private formatTemplateItem({ layout, body, themeId }: DotTemplate): DotTemplateItem { diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-props/dot-template-props.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-props/dot-template-props.component.spec.ts index b33e85c974c9..0b1ec00e778e 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-props/dot-template-props.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-props/dot-template-props.component.spec.ts @@ -14,14 +14,13 @@ import { ButtonModule } from 'primeng/button'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; import { DotMessageService } from '@dotcms/data-access'; -import { DotcmsEventsService } from '@dotcms/dotcms-js'; import { DotFieldRequiredDirective, DotFieldValidationMessageComponent, DotMessagePipe, DotThemeComponent } from '@dotcms/ui'; -import { DotcmsEventsServiceMock, MockDotMessageService } from '@dotcms/utils-testing'; +import { MockDotMessageService } from '@dotcms/utils-testing'; import { DotTemplatePropsComponent } from './dot-template-props.component'; import { DotTemplateThumbnailFieldComponent } from './dot-template-thumbnail-field/dot-template-thumbnail-field.component'; @@ -116,10 +115,6 @@ describe('DotTemplatePropsComponent', () => { provide: DotMessageService, useValue: messageServiceMock }, - { - provide: DotcmsEventsService, - useValue: new DotcmsEventsServiceMock() - }, { provide: DynamicDialogRef, useValue: { diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-list/dot-template-list.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-list/dot-template-list.component.spec.ts index 6d088ac3d06d..86d4498ec62a 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-list/dot-template-list.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-list/dot-template-list.component.spec.ts @@ -30,9 +30,6 @@ import { } from '@dotcms/data-access'; import { DotcmsConfigService, - DotcmsEventsService, - DotEventsSocket, - DotEventsSocketURL, DotPushPublishDialogService, LoggerService, LoginService, @@ -44,8 +41,10 @@ import { DotContentState, DotMessageSeverity, DotMessageType, + DotSite, DotTemplate } from '@dotcms/dotcms-models'; +import { GlobalStore } from '@dotcms/store'; import { DotActionMenuButtonComponent, DotAddToBundleComponent, @@ -99,7 +98,6 @@ afterAll(() => { }); import { DotTemplatesService } from '../../../api/services/dot-templates/dot-templates.service'; -import { dotEventSocketURLFactory } from '../../../test/dot-test-bed'; import { DotActionButtonComponent } from '../../../view/components/_common/dot-action-button/dot-action-button.component'; import { DotBulkInformationComponent } from '../../../view/components/_common/dot-bulk-information/dot-bulk-information.component'; @@ -451,6 +449,10 @@ describe('DotTemplateListComponent', () => { const dialogRefClose = new Subject(); const siteServiceMock = new SiteServiceMock(); + const switchSiteSubject = new Subject(); + const globalStoreMock = { + switchSiteEvent$: () => switchSiteSubject.asObservable() + }; beforeEach(async () => { // Create spies for services that will be injected @@ -488,7 +490,6 @@ describe('DotTemplateListComponent', () => { provide: ActivatedRoute, useClass: ActivatedRouteMock }, - { provide: DotEventsSocketURL, useFactory: dotEventSocketURLFactory }, { provide: DotRouterService, useValue: dotRouterServiceSpy @@ -501,8 +502,6 @@ describe('DotTemplateListComponent', () => { DotHttpErrorManagerService, DotAlertConfirmService, ConfirmationService, - DotcmsEventsService, - DotEventsSocket, DotcmsConfigService, DotMessageDisplayService, { provide: DialogService, useValue: dialogServiceSpy }, @@ -516,7 +515,8 @@ describe('DotTemplateListComponent', () => { { provide: PushPublishService, useValue: { getEnvironments: jest.fn().mockReturnValue(of([])) } - } + }, + { provide: GlobalStore, useValue: globalStoreMock } ], imports: [ DotTemplateListComponent, @@ -601,10 +601,9 @@ describe('DotTemplateListComponent', () => { it('should reload portlet only when the site change', () => { fixture.detectChanges(); // Initialize component and subscriptions - siteServiceMock.setFakeCurrentSite(mockSites[1]); // switching the site + switchSiteSubject.next(mockSites[1] as unknown as DotSite); // switching the site expect(dotRouterService.gotoPortlet).toHaveBeenCalledWith('templates'); expect(dotRouterService.gotoPortlet).toHaveBeenCalledTimes(1); - expect(dotRouterService.gotoPortlet).toHaveBeenCalledTimes(1); }); it('should set table state (columns, sortField, sortOrder)', () => { diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-list/dot-template-list.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-list/dot-template-list.component.ts index 06c8f2d51105..2c1f5f5596f9 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-list/dot-template-list.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-list/dot-template-list.component.ts @@ -33,7 +33,7 @@ import { DotSiteBrowserService, PushPublishService } from '@dotcms/data-access'; -import { DotPushPublishDialogService, SiteService } from '@dotcms/dotcms-js'; +import { DotPushPublishDialogService } from '@dotcms/dotcms-js'; import { DotActionBulkResult, DotActionMenuItem, @@ -43,6 +43,7 @@ import { DotMessageType, DotTemplate } from '@dotcms/dotcms-models'; +import { GlobalStore } from '@dotcms/store'; import { DotAddToBundleComponent, DotContentletStatusChipComponent, @@ -105,7 +106,7 @@ export class DotTemplateListComponent implements OnInit { private dotMessageService = inject(DotMessageService); private dotPushPublishDialogService = inject(DotPushPublishDialogService); private dotRouterService = inject(DotRouterService); - private dotSiteService = inject(SiteService); + readonly #globalStore = inject(GlobalStore); private dotTemplatesService = inject(DotTemplatesService); private pushPublishService = inject(PushPublishService); private destroyRef = inject(DestroyRef); @@ -168,10 +169,12 @@ export class DotTemplateListComponent implements OnInit { // Load initial templates this.loadTemplates(); - // Listen for site changes using SiteService - this.dotSiteService.switchSite$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => { - this.dotRouterService.gotoPortlet('templates'); - }); + this.#globalStore + .switchSiteEvent$() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.dotRouterService.gotoPortlet('templates'); + }); } /** diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-drop-zone/content-type-fields-drop-zone.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-drop-zone/content-type-fields-drop-zone.component.spec.ts index f17d1c8b2f9f..5701d69dd65f 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-drop-zone/content-type-fields-drop-zone.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-drop-zone/content-type-fields-drop-zone.component.spec.ts @@ -26,7 +26,7 @@ import { DotMessageDisplayService, DotMessageService } from '@dotcms/data-access'; -import { DotEventsSocket, LoginService } from '@dotcms/dotcms-js'; +import { LoginService } from '@dotcms/dotcms-js'; import { DotCMSClazzes, DotCMSContentType, @@ -281,7 +281,6 @@ describe('ContentTypeFieldsDropZoneComponent', () => { provide: DotLoadingIndicatorService, useValue: dotLoadingIndicatorServiceMock }, - DotEventsSocket, LoginService, DotFormatDateService, FieldService, @@ -585,7 +584,6 @@ describe('Load fields and drag and drop', () => { }, DotFormatDateService, LoginService, - DotEventsSocket, { provide: DotMessageService, useValue: messageServiceMock }, { provide: FieldDragDropService, useValue: testFieldDragDropService }, { provide: Router, useValue: mockRouter }, diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/form/content-types-form.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/form/content-types-form.component.spec.ts index 8771e3d703ce..c6e2da60eeff 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/form/content-types-form.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/form/content-types-form.component.spec.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-empty-function */ import { createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; -import { EMPTY, Observable, of } from 'rxjs'; +import { Observable, of } from 'rxjs'; import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; @@ -17,7 +17,6 @@ import { DotWorkflowsActionsService, DotWorkflowService } from '@dotcms/data-access'; -import { DotcmsEventsService } from '@dotcms/dotcms-js'; import { DotCMSClazzes, DotCMSContentTypeLayoutRow, @@ -162,10 +161,6 @@ describe('ContentTypesFormComponent', () => { { provide: DotWorkflowService, useClass: DotWorkflowServiceMock }, { provide: DotLicenseService, useClass: MockDotLicenseService }, { provide: ActivatedRoute, useValue: mockActivatedRoute }, - { - provide: DotcmsEventsService, - useValue: { subscribeToEvents: jest.fn().mockReturnValue(EMPTY) } - }, mockProvider(DotHttpErrorManagerService), mockProvider(DotWorkflowsActionsService), { diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/layout/content-types-layout.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/layout/content-types-layout.component.spec.ts index 20309467bbec..90d25e174e85 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/layout/content-types-layout.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/layout/content-types-layout.component.spec.ts @@ -39,7 +39,7 @@ import { DotRouterService, DotUiColorsService } from '@dotcms/data-access'; -import { DotcmsEventsService, LoggerService, LoginService } from '@dotcms/dotcms-js'; +import { LoggerService, LoginService } from '@dotcms/dotcms-js'; import { DotCMSContentType } from '@dotcms/dotcms-models'; import { DotApiLinkComponent, @@ -199,13 +199,6 @@ describe('ContentTypesLayoutComponent', () => { useValue: { currentPortlet: { id: 'test-portlet-id' } } }, { provide: DotUiColorsService, useValue: { setColors: jest.fn() } }, - { - provide: DotcmsEventsService, - useValue: { - subscribeTo: jest.fn().mockReturnValue(of({})), - subscribeToEvents: jest.fn().mockReturnValue(of({})) - } - }, { provide: DotLoadingIndicatorService, useValue: { diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-listing/components/dot-content-type-copy-dialog/dot-content-type-copy-dialog.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-listing/components/dot-content-type-copy-dialog/dot-content-type-copy-dialog.component.spec.ts index 7c7a5f2de20d..bbbe3bb3f587 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-listing/components/dot-content-type-copy-dialog/dot-content-type-copy-dialog.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-listing/components/dot-content-type-copy-dialog/dot-content-type-copy-dialog.component.spec.ts @@ -1,4 +1,4 @@ -import { EMPTY, of } from 'rxjs'; +import { of } from 'rxjs'; import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; @@ -9,7 +9,6 @@ import { By } from '@angular/platform-browser'; import { provideAnimations } from '@angular/platform-browser/animations'; import { DotMessageService, DotSiteService } from '@dotcms/data-access'; -import { DotcmsEventsService } from '@dotcms/dotcms-js'; import { DotFieldValidationMessageComponent, DotMessagePipe, DotSiteComponent } from '@dotcms/ui'; import { MockDotMessageService } from '@dotcms/utils-testing'; @@ -68,10 +67,6 @@ describe('DotContentTypeCopyDialogComponent', () => { getSites: jest.fn().mockReturnValue(of({})) } }, - { - provide: DotcmsEventsService, - useValue: { subscribeToEvents: jest.fn().mockReturnValue(EMPTY) } - }, provideHttpClient(), provideHttpClientTesting(), provideAnimations() diff --git a/core-web/apps/dotcms-ui/src/app/providers.ts b/core-web/apps/dotcms-ui/src/app/providers.ts index 77a2a8405624..672e2f3686cf 100644 --- a/core-web/apps/dotcms-ui/src/app/providers.ts +++ b/core-web/apps/dotcms-ui/src/app/providers.ts @@ -1,4 +1,10 @@ -import { InjectionToken, Provider } from '@angular/core'; +import { + EnvironmentProviders, + inject, + InjectionToken, + Provider, + provideAppInitializer +} from '@angular/core'; import { TitleStrategy } from '@angular/router'; import { ConfirmationService } from 'primeng/api'; @@ -37,9 +43,6 @@ import { ApiRoot, BrowserUtil, DotcmsConfigService, - DotcmsEventsService, - DotEventsSocket, - DotEventsSocketURL, DotPushPublishDialogService, LoggerService, LoginService, @@ -52,6 +55,7 @@ import { DotAccountService } from './api/services/dot-account-service'; import { DotDownloadBundleDialogService } from './api/services/dot-download-bundle-dialog/dot-download-bundle-dialog.service'; import { DotMenuService } from './api/services/dot-menu.service'; import { DotParseHtmlService } from './api/services/dot-parse-html/dot-parse-html.service'; +import { DotSiteNavigationEffect } from './api/services/dot-site-navigation/dot-site-navigation.effect'; import { AuthGuardService } from './api/services/guards/auth-guard.service'; import { ContentletGuardService } from './api/services/guards/contentlet-guard.service'; import { DefaultGuardService } from './api/services/guards/default-guard.service'; @@ -71,14 +75,7 @@ import { DotLoginPageStateService } from './view/components/login/shared/service export const LOCATION_TOKEN = new InjectionToken('Window location object'); -const dotEventSocketURLFactory = () => { - return new DotEventsSocketURL( - `${window.location.hostname}:${window.location.port}/api/ws/v1/system/events`, - window.location.protocol === 'https:' - ); -}; - -const PROVIDERS: Provider[] = [ +const PROVIDERS: (Provider | EnvironmentProviders)[] = [ { provide: LOCATION_TOKEN, useValue: window.location }, EmaAppConfigurationService, DotAccountService, @@ -123,11 +120,8 @@ const PROVIDERS: Provider[] = [ DotEventsService, DotNavigationService, DotcmsConfigService, - DotcmsEventsService, LoggerService, LoginService, - { provide: DotEventsSocketURL, useFactory: dotEventSocketURLFactory }, - DotEventsSocket, StringUtils, UserModel, // Data-access services @@ -145,7 +139,9 @@ const PROVIDERS: Provider[] = [ useClass: DotTitleStrategy }, GlobalStore, - DotSystemConfigService + DotSystemConfigService, + DotSiteNavigationEffect, + provideAppInitializer(() => void inject(DotSiteNavigationEffect)) ]; export const ENV_PROVIDERS = [...PROVIDERS]; diff --git a/core-web/apps/dotcms-ui/src/app/shared/shared.module.ts b/core-web/apps/dotcms-ui/src/app/shared/shared.module.ts index be1809586053..4e37bf03a9c1 100644 --- a/core-web/apps/dotcms-ui/src/app/shared/shared.module.ts +++ b/core-web/apps/dotcms-ui/src/app/shared/shared.module.ts @@ -7,9 +7,6 @@ import { ApiRoot, BrowserUtil, DotcmsConfigService, - DotcmsEventsService, - DotEventsSocket, - DotEventsSocketURL, LoggerService, LoginService, StringUtils, @@ -19,13 +16,6 @@ import { import { DotNavigationComponent } from '../view/components/dot-navigation/dot-navigation.component'; import { DotNavigationService } from '../view/components/dot-navigation/services/dot-navigation.service'; -const dotEventSocketURLFactory = () => { - return new DotEventsSocketURL( - `${window.location.hostname}:${window.location.port}/api/ws/v1/system/events`, - window.location.protocol === 'https:' - ); -}; - @NgModule({ declarations: [], imports: [CommonModule, DotNavigationComponent], @@ -45,11 +35,8 @@ export class SharedModule { DotEventsService, DotNavigationService, DotcmsConfigService, - DotcmsEventsService, LoggerService, LoginService, - { provide: DotEventsSocketURL, useFactory: dotEventSocketURLFactory }, - DotEventsSocket, StringUtils, UserModel ] diff --git a/core-web/apps/dotcms-ui/src/app/test/dot-test-bed.ts b/core-web/apps/dotcms-ui/src/app/test/dot-test-bed.ts index fb9f543416fe..45f84eb8dc22 100644 --- a/core-web/apps/dotcms-ui/src/app/test/dot-test-bed.ts +++ b/core-web/apps/dotcms-ui/src/app/test/dot-test-bed.ts @@ -26,9 +26,6 @@ import { ApiRoot, BrowserUtil, DotcmsConfigService, - DotcmsEventsService, - DotEventsSocket, - DotEventsSocketURL, DotPushPublishDialogService, LoggerService, StringUtils, @@ -83,13 +80,6 @@ export class MockGlobalStore { }; } -export const dotEventSocketURLFactory = () => { - return new DotEventsSocketURL( - `${window.location.hostname}:${window.location.port}/api/ws/v1/system/events`, - window.location.protocol === 'https:' - ); -}; - /** * DOTTestBed its deprecated * @deprecated This class is deprecated @@ -125,10 +115,7 @@ export class DOTTestBed { DotHttpErrorManagerService, DotIframeService, DotMessageService, - DotEventsSocket, - { provide: DotEventsSocketURL, useFactory: dotEventSocketURLFactory }, DotcmsConfigService, - DotcmsEventsService, DotFormatDateService, LoggerService, StringUtils, diff --git a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-wizard/dot-wizard.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-wizard/dot-wizard.component.spec.ts index 79262adff27c..b8f22d24f9d3 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-wizard/dot-wizard.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/_common/dot-wizard/dot-wizard.component.spec.ts @@ -26,13 +26,7 @@ import { DotWizardService, PushPublishService } from '@dotcms/data-access'; -import { - DotcmsConfigService, - DotcmsEventsService, - LoggerService, - LoginService, - StringUtils -} from '@dotcms/dotcms-js'; +import { DotcmsConfigService, LoggerService, LoginService, StringUtils } from '@dotcms/dotcms-js'; import { DotPushPublishDialogData, DotWizardInput, DotWizardStep } from '@dotcms/dotcms-models'; import { LoginServiceMock, MockDotMessageService } from '@dotcms/utils-testing'; @@ -148,7 +142,6 @@ describe('DotWizardComponent', () => { DotPushPublishFiltersService, DotParseHtmlService, DotcmsConfigService, - DotcmsEventsService, DotWizardService ] }); diff --git a/core-web/apps/dotcms-ui/src/app/view/components/_common/iframe/iframe-component/iframe.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/_common/iframe/iframe-component/iframe.component.spec.ts index 1a3bebe38f70..a46945552a3f 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/_common/iframe/iframe-component/iframe.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/_common/iframe/iframe-component/iframe.component.spec.ts @@ -1,19 +1,22 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { Subject } from 'rxjs'; + import { Component, DebugElement } from '@angular/core'; import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { RouterTestingModule } from '@angular/router/testing'; -import { DotIframeService, DotRouterService, DotUiColorsService } from '@dotcms/data-access'; -import { DotcmsEventsService, LoggerService, LoginService, StringUtils } from '@dotcms/dotcms-js'; +import { + DotEventsSocket, + DotIframeService, + DotRouterService, + DotUiColorsService +} from '@dotcms/data-access'; +import { LoggerService, LoginService, StringUtils } from '@dotcms/dotcms-js'; import { DotMessagePipe, DotSafeHtmlPipe } from '@dotcms/ui'; import { DotLoadingIndicatorService } from '@dotcms/utils'; -import { - DotcmsEventsServiceMock, - LoginServiceMock, - MockDotRouterService -} from '@dotcms/utils-testing'; +import { LoginServiceMock, MockDotRouterService } from '@dotcms/utils-testing'; import { IframeOverlayService } from './../service/iframe-overlay.service'; import { IframeComponent } from './iframe.component'; @@ -40,9 +43,23 @@ describe('IframeComponent', () => { let iframeEl: DebugElement; let dotIframeService: DotIframeService; let dotUiColorsService: DotUiColorsService; - const dotcmsEventsService = new DotcmsEventsServiceMock(); let dotRouterService: DotRouterService; + let eventSubjects: Record> = {}; + const mockDotEventsSocket = { + on: jest.fn((eventType: string) => { + if (!eventSubjects[eventType]) { + eventSubjects[eventType] = new Subject(); + } + return eventSubjects[eventType].asObservable(); + }) + }; + + beforeEach(() => { + eventSubjects = {}; + mockDotEventsSocket.on.mockClear(); + }); + beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ declarations: [MockDotLoadingIndicatorComponent], @@ -59,7 +76,7 @@ describe('IframeComponent', () => { IframeOverlayService, DotIframeService, { provide: LoginService, useClass: LoginServiceMock }, - { provide: DotcmsEventsService, useValue: dotcmsEventsService }, + { provide: DotEventsSocket, useValue: mockDotEventsSocket }, { provide: DotRouterService, useClass: MockDotRouterService }, { provide: DotUiColorsService, useClass: MockDotUiColorsService }, LoggerService, @@ -107,18 +124,14 @@ describe('IframeComponent', () => { }); it('should reload on DELETE_BUNDLE and on publishing-queue portlet websocket event', () => { - dotcmsEventsService.triggerSubscribeTo('DELETE_BUNDLE', { - name: 'DELETE_BUNDLE' - }); + eventSubjects['DELETE_BUNDLE'].next({ name: 'DELETE_BUNDLE' }); expect(comp.iframeElement.nativeElement.contentWindow.postMessage).toHaveBeenCalledWith( 'reload' ); }); it('should reload on PAGE_RELOAD websocket event', () => { - dotcmsEventsService.triggerSubscribeTo('PAGE_RELOAD', { - name: 'PAGE_RELOAD' - }); + eventSubjects['PAGE_RELOAD'].next({ name: 'PAGE_RELOAD' }); expect(comp.iframeElement.nativeElement.contentWindow.postMessage).toHaveBeenCalledWith( 'reload' ); @@ -300,9 +313,7 @@ describe('IframeComponent', () => { } } }; - dotcmsEventsService.triggerSubscribeTo('OSGI_BUNDLES_LOADED', { - name: 'OSGI_BUNDLES_LOADED' - }); + eventSubjects['OSGI_BUNDLES_LOADED'].next(undefined); tick(4500); expect(comp.iframeElement.nativeElement.contentWindow.getBundlesData).toHaveBeenCalledTimes( 1 diff --git a/core-web/apps/dotcms-ui/src/app/view/components/_common/iframe/iframe-component/iframe.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/_common/iframe/iframe-component/iframe.component.ts index b06c2888835e..71a962f0fdff 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/_common/iframe/iframe-component/iframe.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/_common/iframe/iframe-component/iframe.component.ts @@ -1,4 +1,4 @@ -import { Subject } from 'rxjs'; +import { merge, Subject } from 'rxjs'; import { ChangeDetectorRef, @@ -15,10 +15,15 @@ import { ViewChild } from '@angular/core'; -import { debounceTime, filter, takeUntil } from 'rxjs/operators'; +import { debounceTime, filter, map, takeUntil } from 'rxjs/operators'; -import { DotIframeService, DotRouterService, DotUiColorsService } from '@dotcms/data-access'; -import { DotcmsEventsService, DotEventTypeWrapper, LoggerService } from '@dotcms/dotcms-js'; +import { + DotEventsSocket, + DotIframeService, + DotRouterService, + DotUiColorsService +} from '@dotcms/data-access'; +import { DotEventTypeWrapper, LoggerService } from '@dotcms/dotcms-js'; import { DotFunctionInfo } from '@dotcms/dotcms-models'; import { DotLoadingIndicatorService } from '@dotcms/utils'; @@ -46,7 +51,7 @@ export class IframeComponent implements OnInit, OnDestroy { private dotIframeService = inject(DotIframeService); private dotRouterService = inject(DotRouterService); private dotUiColorsService = inject(DotUiColorsService); - private dotcmsEventsService = inject(DotcmsEventsService); + private dotEventsSocket = inject(DotEventsSocket); private ngZone = inject(NgZone); private cdr = inject(ChangeDetectorRef); dotLoadingIndicatorService = inject(DotLoadingIndicatorService); @@ -177,23 +182,29 @@ export class IframeComponent implements OnInit, OnDestroy { 'PAGE_RELOAD' ]; - const webSocketEvents$ = this.dotcmsEventsService - .subscribeToEvents(events) - .pipe(takeUntil(this.destroy$)); + const webSocketEvents$ = merge( + ...events.map((eventType) => + this.dotEventsSocket + .on(eventType) + .pipe( + map((data) => ({ data, name: eventType }) as DotEventTypeWrapper) + ) + ) + ).pipe(takeUntil(this.destroy$)); webSocketEvents$ .pipe(filter(() => this.dotRouterService.currentPortlet.id === 'site-browser')) - .subscribe((event: DotEventTypeWrapper) => { + .subscribe((event) => { this.loggerService.debug('Capturing Site Browser event', event.name, event.data); }); webSocketEvents$ .pipe( filter( - (event: DotEventTypeWrapper) => + (event) => (this.iframeElement.nativeElement.contentWindow && event.name === 'DELETE_BUNDLE') || - event.name === 'PAGE_RELOAD' // Provinding this event so backend devs can reload the jsp easily + event.name === 'PAGE_RELOAD' // Providing this event so backend devs can reload the jsp easily ) ) .subscribe(() => { @@ -202,12 +213,12 @@ export class IframeComponent implements OnInit, OnDestroy { /** * The debouncetime is required because when the websocket event is received, - * the list of plugins still cannot be updated, thi is because the framework (OSGI) + * the list of plugins still cannot be updated, this is because the framework (OSGI) * needs to restart before the list can be refreshed. * Currently, an event cannot be emitted when the framework finishes restarting. */ - this.dotcmsEventsService - .subscribeTo('OSGI_BUNDLES_LOADED') + this.dotEventsSocket + .on('OSGI_BUNDLES_LOADED') .pipe(takeUntil(this.destroy$), debounceTime(4000)) .subscribe(() => { this.dotIframeService.run({ name: 'getBundlesData' }); diff --git a/core-web/apps/dotcms-ui/src/app/view/components/_common/iframe/iframe-porlet-legacy/iframe-porlet-legacy.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/_common/iframe/iframe-porlet-legacy/iframe-porlet-legacy.component.spec.ts index 031d5e25ed2c..6def81845cbd 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/_common/iframe/iframe-porlet-legacy/iframe-porlet-legacy.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/_common/iframe/iframe-porlet-legacy/iframe-porlet-legacy.component.spec.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { of } from 'rxjs'; +import { EMPTY, of } from 'rxjs'; import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; @@ -17,6 +17,7 @@ import { DotContentTypeService, DotCurrentUserService, DotEventsService, + DotEventsSocket as DotEventsSocketDataAccess, DotFormatDateService, DotGlobalMessageService, DotHttpErrorManagerService, @@ -33,9 +34,6 @@ import { import { ApiRoot, DotcmsConfigService, - DotcmsEventsService, - DotEventsSocket, - DotEventsSocketURL, DotPushPublishDialogService, LoggerService, LoginService, @@ -49,7 +47,7 @@ import { IframePortletLegacyComponent } from './iframe-porlet-legacy.component'; import { DotCustomEventHandlerService } from '../../../../../api/services/dot-custom-event-handler/dot-custom-event-handler.service'; import { DotMenuService } from '../../../../../api/services/dot-menu.service'; -import { dotEventSocketURLFactory, MockDotUiColorsService } from '../../../../../test/dot-test-bed'; +import { MockDotUiColorsService } from '../../../../../test/dot-test-bed'; import { DotContentletEditorService } from '../../../dot-contentlet-editor/services/dot-contentlet-editor.service'; import { DotDownloadBundleDialogComponent } from '../../dot-download-bundle-dialog/dot-download-bundle-dialog.component'; import { IFrameModule } from '../index'; @@ -116,9 +114,10 @@ xdescribe('IframePortletLegacyComponent', () => { StringUtils, DotCurrentUserService, DotMessageDisplayService, - DotcmsEventsService, - DotEventsSocket, - { provide: DotEventsSocketURL, useFactory: dotEventSocketURLFactory }, + { + provide: DotEventsSocketDataAccess, + useValue: { on: jest.fn().mockReturnValue(EMPTY) } + }, DotcmsConfigService, DotFormatDateService, DotWizardService, diff --git a/core-web/apps/dotcms-ui/src/app/view/components/_common/iframe/iframe-porlet-legacy/iframe-porlet-legacy.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/_common/iframe/iframe-porlet-legacy/iframe-porlet-legacy.component.ts index 0c92e86bf7cc..60686362b035 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/_common/iframe/iframe-porlet-legacy/iframe-porlet-legacy.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/_common/iframe/iframe-porlet-legacy/iframe-porlet-legacy.component.ts @@ -6,9 +6,15 @@ import { ActivatedRoute, RouterModule, UrlSegment } from '@angular/router'; import { map, mergeMap, takeUntil, withLatestFrom } from 'rxjs/operators'; -import { DotContentTypeService, DotIframeService, DotRouterService } from '@dotcms/data-access'; -import { DotcmsEventsService, LoggerService, SiteService } from '@dotcms/dotcms-js'; +import { + DotContentTypeService, + DotEventsSocket, + DotIframeService, + DotRouterService +} from '@dotcms/data-access'; +import { LoggerService } from '@dotcms/dotcms-js'; import { UI_STORAGE_KEY } from '@dotcms/dotcms-models'; +import { GlobalStore } from '@dotcms/store'; import { DotNotLicenseComponent } from '@dotcms/ui'; import { DotLoadingIndicatorService } from '@dotcms/utils'; @@ -30,8 +36,8 @@ export class IframePortletLegacyComponent implements OnInit, OnDestroy { private route = inject(ActivatedRoute); private dotCustomEventHandlerService = inject(DotCustomEventHandlerService); loggerService = inject(LoggerService); - siteService = inject(SiteService); - private dotcmsEventsService = inject(DotcmsEventsService); + readonly #globalStore = inject(GlobalStore); + private dotEventsSocket = inject(DotEventsSocket); private dotIframeService = inject(DotIframeService); canAccessPortlet: boolean; @@ -46,15 +52,14 @@ export class IframePortletLegacyComponent implements OnInit, OnDestroy { this.reloadIframePortlet(portletId); } }); - /** - * skip first - to avoid subscription when page loads due login user subscription: - * https://github.com/dotCMS/core-web/blob/main/projects/dotcms-js/src/lib/core/site.service.ts#L58 - */ - this.siteService.switchSite$.pipe(takeUntil(this.destroy$)).subscribe(() => { - if (this.url.getValue() !== '') { - this.reloadIframePortlet(); - } - }); + this.#globalStore + .switchSiteEvent$() + .pipe(takeUntil(this.destroy$)) + .subscribe(() => { + if (this.url.getValue() !== '') { + this.reloadIframePortlet(); + } + }); this.route.data .pipe( @@ -168,8 +173,8 @@ export class IframePortletLegacyComponent implements OnInit, OnDestroy { with the function refreshFakeJax defined in view_contentlets_js_inc.jsp. */ private subscribeToAIGeneration(): void { - this.dotcmsEventsService - .subscribeTo('AI_CONTENT_PROMPT') + this.dotEventsSocket + .on('AI_CONTENT_PROMPT') .pipe(takeUntil(this.destroy$)) .subscribe(() => { this.dotIframeService.run({ name: 'refreshFakeJax' }); diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-add-persona-dialog/dot-add-persona-dialog.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-add-persona-dialog/dot-add-persona-dialog.component.spec.ts index c3c375ba9b90..f1f2f1e68bab 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-add-persona-dialog/dot-add-persona-dialog.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-add-persona-dialog/dot-add-persona-dialog.component.spec.ts @@ -1,5 +1,5 @@ import { createComponentFactory, Spectator, byTestId, mockProvider } from '@ngneat/spectator/jest'; -import { EMPTY, of, throwError } from 'rxjs'; +import { of, throwError } from 'rxjs'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; @@ -9,7 +9,7 @@ import { DotMessageService, DotWorkflowActionsFireService } from '@dotcms/data-access'; -import { DotcmsEventsService, LoginService, SiteService } from '@dotcms/dotcms-js'; +import { LoginService, SiteService } from '@dotcms/dotcms-js'; import { GlobalStore } from '@dotcms/store'; import { DotMessageDisplayServiceMock, @@ -47,11 +47,7 @@ describe('DotAddPersonaDialogComponent', () => { { provide: DotMessageService, useValue: messageServiceMock }, { provide: LoginService, useClass: LoginServiceMock }, { provide: SiteService, useValue: new SiteServiceMock() }, - mockProvider(GlobalStore, { currentSiteId: jest.fn().mockReturnValue('demo') }), - { - provide: DotcmsEventsService, - useValue: { subscribeToEvents: jest.fn().mockReturnValue(EMPTY) } - } + mockProvider(GlobalStore, { currentSiteId: jest.fn().mockReturnValue('demo') }) ] }); diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-add-contentlet/dot-add-contentlet.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-add-contentlet/dot-add-contentlet.component.spec.ts index 32859ae52192..71fef74d932c 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-add-contentlet/dot-add-contentlet.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-add-contentlet/dot-add-contentlet.component.spec.ts @@ -23,9 +23,6 @@ import { import { ApiRoot, DotcmsConfigService, - DotcmsEventsService, - DotEventsSocket, - DotEventsSocketURL, LoggerService, LoginService, StringUtils, @@ -42,7 +39,7 @@ import { DotAddContentletComponent } from './dot-add-contentlet.component'; import { DotCustomEventHandlerService } from '../../../../../api/services/dot-custom-event-handler/dot-custom-event-handler.service'; import { DotMenuService } from '../../../../../api/services/dot-menu.service'; -import { dotEventSocketURLFactory, MockDotUiColorsService } from '../../../../../test/dot-test-bed'; +import { MockDotUiColorsService } from '../../../../../test/dot-test-bed'; import { IframeOverlayService } from '../../../_common/iframe/service/iframe-overlay.service'; import { DotIframeDialogComponent } from '../../../dot-iframe-dialog/dot-iframe-dialog.component'; import { DotContentletEditorService } from '../../services/dot-contentlet-editor.service'; @@ -96,9 +93,6 @@ describe('DotAddContentletComponent', () => { toggle: jest.fn() } }, - DotcmsEventsService, - DotEventsSocket, - { provide: DotEventsSocketURL, useFactory: dotEventSocketURLFactory }, DotcmsConfigService, LoggerService, StringUtils, diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-contentlet-wrapper/dot-contentlet-wrapper.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-contentlet-wrapper/dot-contentlet-wrapper.component.spec.ts index f8a10f4e90f8..15feb53f1c37 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-contentlet-wrapper/dot-contentlet-wrapper.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-contentlet-wrapper/dot-contentlet-wrapper.component.spec.ts @@ -21,15 +21,7 @@ import { DotRouterService, DotUiColorsService } from '@dotcms/data-access'; -import { - DotcmsConfigService, - DotcmsEventsService, - DotEventsSocket, - DotEventsSocketURL, - LoggerService, - LoginService, - StringUtils -} from '@dotcms/dotcms-js'; +import { DotcmsConfigService, LoggerService, LoginService, StringUtils } from '@dotcms/dotcms-js'; import { LoginServiceMock, MockDotMessageService, @@ -40,7 +32,7 @@ import { DotContentletWrapperComponent } from './dot-contentlet-wrapper.componen import { DotCustomEventHandlerService } from '../../../../../api/services/dot-custom-event-handler/dot-custom-event-handler.service'; import { DotMenuService } from '../../../../../api/services/dot-menu.service'; -import { dotEventSocketURLFactory, MockDotUiColorsService } from '../../../../../test/dot-test-bed'; +import { MockDotUiColorsService } from '../../../../../test/dot-test-bed'; import { IframeOverlayService } from '../../../_common/iframe/service/iframe-overlay.service'; import { DotIframeDialogComponent } from '../../../dot-iframe-dialog/dot-iframe-dialog.component'; import { DotContentletEditorService } from '../../services/dot-contentlet-editor.service'; @@ -82,13 +74,10 @@ describe('DotContentletWrapperComponent', () => { DotEventsService, IframeOverlayService, ConfirmationService, - DotcmsEventsService, - DotEventsSocket, DotcmsConfigService, LoggerService, StringUtils, Title, - { provide: DotEventsSocketURL, useFactory: dotEventSocketURLFactory }, { provide: DotHttpErrorManagerService, useValue: { diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-create-contentlet/dot-create-contentlet.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-create-contentlet/dot-create-contentlet.component.spec.ts index 26f6704a5ff8..0692661feb90 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-create-contentlet/dot-create-contentlet.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-create-contentlet/dot-create-contentlet.component.spec.ts @@ -18,12 +18,8 @@ import { DotRouterService, DotUiColorsService } from '@dotcms/data-access'; -import { DotcmsEventsService, LoggerService, LoginService, StringUtils } from '@dotcms/dotcms-js'; -import { - DotcmsEventsServiceMock, - LoginServiceMock, - MockDotRouterService -} from '@dotcms/utils-testing'; +import { LoggerService, LoginService, StringUtils } from '@dotcms/dotcms-js'; +import { LoginServiceMock, MockDotRouterService } from '@dotcms/utils-testing'; import { DotCreateContentletComponent } from './dot-create-contentlet.component'; @@ -82,7 +78,6 @@ describe('DotCreateContentletComponent', () => { provide: DotRouterService, useClass: MockDotRouterService }, - { provide: DotcmsEventsService, useClass: DotcmsEventsServiceMock }, { provide: ActivatedRoute, useValue: { diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-reorder-menu/dot-reorder-menu.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-reorder-menu/dot-reorder-menu.component.spec.ts index a3024b1d5865..4c5086237fb8 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-reorder-menu/dot-reorder-menu.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-reorder-menu/dot-reorder-menu.component.spec.ts @@ -13,7 +13,7 @@ import { DotUiColorsService, DotLoadingIndicatorService } from '@dotcms/data-access'; -import { LoginService, LoggerService, StringUtils, DotcmsEventsService } from '@dotcms/dotcms-js'; +import { LoginService, LoggerService, StringUtils } from '@dotcms/dotcms-js'; import { DotMessagePipe } from '@dotcms/ui'; import { LoginServiceMock, @@ -24,6 +24,7 @@ import { import { DotReorderMenuComponent } from './dot-reorder-menu.component'; +import { MockDotUiColorsService } from '../../../../../test/dot-test-bed'; import { IframeOverlayService } from '../../../_common/iframe/service/iframe-overlay.service'; import { DotIframeDialogComponent } from '../../../dot-iframe-dialog/dot-iframe-dialog.component'; @@ -74,13 +75,6 @@ describe('DotReorderMenuComponent', () => { overlay: new Subject() } }, - { - provide: DotcmsEventsService, - useValue: { - subscribeToEvents: jest.fn().mockReturnValue(of({})), - subscribeTo: jest.fn().mockReturnValue(of({})) - } - }, { provide: LoggerService, useValue: { debug: jest.fn() } }, { provide: StringUtils, useValue: { to: jest.fn() } } ] diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-iframe-dialog/dot-iframe-dialog.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-iframe-dialog/dot-iframe-dialog.component.spec.ts index b979b809362a..1b0905fc6fe4 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-iframe-dialog/dot-iframe-dialog.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-iframe-dialog/dot-iframe-dialog.component.spec.ts @@ -11,14 +11,7 @@ import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { RouterTestingModule } from '@angular/router/testing'; import { DotIframeService, DotRouterService, DotUiColorsService } from '@dotcms/data-access'; -import { - DotcmsEventsService, - DotEventsSocket, - DotEventsSocketURL, - LoggerService, - LoginService, - StringUtils -} from '@dotcms/dotcms-js'; +import { LoggerService, LoginService, StringUtils } from '@dotcms/dotcms-js'; import { DotLoadingIndicatorService } from '@dotcms/utils'; import { LoginServiceMock } from '@dotcms/utils-testing'; @@ -68,16 +61,6 @@ describe('DotIframeDialogComponent', () => { DotIframeService, DotRouterService, DotUiColorsService, - DotcmsEventsService, - DotEventsSocket, - { - provide: DotEventsSocketURL, - useFactory: () => - new DotEventsSocketURL( - `${typeof window !== 'undefined' ? window.location.hostname : ''}:${typeof window !== 'undefined' ? window.location.port : ''}/api/ws/v1/system/events`, - typeof window !== 'undefined' && window.location.protocol === 'https:' - ) - }, DotLoadingIndicatorService, LoggerService, StringUtils, diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-large-message-display/dot-large-message-display.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-large-message-display/dot-large-message-display.component.spec.ts index 31ab50242ed2..52bd73b5f689 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-large-message-display/dot-large-message-display.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-large-message-display/dot-large-message-display.component.spec.ts @@ -1,9 +1,9 @@ import { createComponentFactory, Spectator } from '@ngneat/spectator/jest'; +import { Subject } from 'rxjs'; import { fakeAsync, tick } from '@angular/core/testing'; -import { DotcmsEventsService } from '@dotcms/dotcms-js'; -import { DotcmsEventsServiceMock } from '@dotcms/utils-testing'; +import { DotEventsSocket } from '@dotcms/data-access'; import { DotLargeMessageDisplayComponent } from './dot-large-message-display.component'; @@ -11,29 +11,28 @@ import { DotParseHtmlService } from '../../../api/services/dot-parse-html/dot-pa describe('DotLargeMessageDisplayComponent', () => { let spectator: Spectator; - let dotcmsEventsServiceMock: DotcmsEventsServiceMock; + const largeMessageSubject = new Subject(); + const mockDotEventsSocket = { + on: jest.fn().mockReturnValue(largeMessageSubject.asObservable()) + }; const createComponent = createComponentFactory({ component: DotLargeMessageDisplayComponent, detectChanges: false, imports: [], providers: [ - { provide: DotcmsEventsService, useClass: DotcmsEventsServiceMock }, + { provide: DotEventsSocket, useValue: mockDotEventsSocket }, DotParseHtmlService ] }); beforeEach(() => { spectator = createComponent(); - dotcmsEventsServiceMock = spectator.inject( - DotcmsEventsService - ) as unknown as DotcmsEventsServiceMock; - jest.spyOn(dotcmsEventsServiceMock, 'subscribeTo'); }); it('should create DotLargeMessageDisplayComponent', fakeAsync(() => { spectator.fixture.detectChanges(false); // run ngOnInit so component subscribes - dotcmsEventsServiceMock.triggerSubscribeTo('LARGE_MESSAGE', { + largeMessageSubject.next({ title: 'title Test', height: '200', width: '1000', @@ -50,7 +49,7 @@ describe('DotLargeMessageDisplayComponent', () => { true ); expect(spectator.component.messages[0].code?.content).toBe('codeTest'); - expect(dotcmsEventsServiceMock.subscribeTo).toHaveBeenCalledTimes(1); + expect(mockDotEventsSocket.on).toHaveBeenCalledWith('LARGE_MESSAGE'); tick(0); spectator.fixture.detectChanges(false); @@ -59,7 +58,7 @@ describe('DotLargeMessageDisplayComponent', () => { it('should render script tag from body', fakeAsync(() => { spectator.fixture.detectChanges(false); - dotcmsEventsServiceMock.triggerSubscribeTo('LARGE_MESSAGE', { + largeMessageSubject.next({ title: 'title Test', body: '

Hello World

' }); @@ -74,7 +73,7 @@ describe('DotLargeMessageDisplayComponent', () => { it('should render script tag from script property', fakeAsync(() => { spectator.fixture.detectChanges(false); - dotcmsEventsServiceMock.triggerSubscribeTo('LARGE_MESSAGE', { + largeMessageSubject.next({ title: 'title Test', body: '

Hello World

', script: 'console.log("script from prop")' @@ -91,7 +90,7 @@ describe('DotLargeMessageDisplayComponent', () => { it('should remove dialog when it is close', fakeAsync(() => { spectator.fixture.detectChanges(false); - dotcmsEventsServiceMock.triggerSubscribeTo('LARGE_MESSAGE', { + largeMessageSubject.next({ title: 'title Test', body: '

Hello World

', script: 'console.log("script from prop")' @@ -109,7 +108,7 @@ describe('DotLargeMessageDisplayComponent', () => { it('should set default height and width', fakeAsync(() => { spectator.fixture.detectChanges(false); - dotcmsEventsServiceMock.triggerSubscribeTo('LARGE_MESSAGE', { + largeMessageSubject.next({ title: 'title Test', body: 'bodyTest', code: { lang: 'eng', content: 'codeTest' } @@ -126,7 +125,7 @@ describe('DotLargeMessageDisplayComponent', () => { it('should show two dialogs', fakeAsync(() => { spectator.fixture.detectChanges(false); - dotcmsEventsServiceMock.triggerSubscribeTo('LARGE_MESSAGE', { + largeMessageSubject.next({ title: 'title Test', body: 'bodyTest', code: { lang: 'eng', content: 'codeTest' } @@ -136,7 +135,7 @@ describe('DotLargeMessageDisplayComponent', () => { expect(spectator.component.messages.length).toBe(1); - dotcmsEventsServiceMock.triggerSubscribeTo('LARGE_MESSAGE', { + largeMessageSubject.next({ title: 'title Test 2', body: 'bodyTest 2', code: { lang: 'eng', content: 'codeTest 2' } diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-large-message-display/dot-large-message-display.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-large-message-display/dot-large-message-display.component.ts index c04a4cd72db3..cc4d64237808 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-large-message-display/dot-large-message-display.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-large-message-display/dot-large-message-display.component.ts @@ -14,7 +14,7 @@ import { DialogModule, Dialog } from 'primeng/dialog'; import { filter, takeUntil } from 'rxjs/operators'; -import { DotcmsEventsService } from '@dotcms/dotcms-js'; +import { DotEventsSocket } from '@dotcms/data-access'; import { DotParseHtmlService } from '../../../api/services/dot-parse-html/dot-parse-html.service'; @@ -38,7 +38,7 @@ interface DotLargeMessageDisplayParams { providers: [DotParseHtmlService] }) export class DotLargeMessageDisplayComponent implements OnInit, OnDestroy, AfterViewInit { - private dotcmsEventsService = inject(DotcmsEventsService); + private dotEventsSocket = inject(DotEventsSocket); private dotParseHtmlService = inject(DotParseHtmlService); @ViewChildren(Dialog) dialogs: QueryList; @@ -114,8 +114,8 @@ export class DotLargeMessageDisplayComponent implements OnInit, OnDestroy, After } private getMessages(): Observable { - return this.dotcmsEventsService - .subscribeTo('LARGE_MESSAGE') + return this.dotEventsSocket + .on('LARGE_MESSAGE') .pipe(filter((data: DotLargeMessageDisplayParams) => !!data)); } } diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-navigation/components/dot-sub-nav/dot-sub-nav.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-navigation/components/dot-sub-nav/dot-sub-nav.component.spec.ts index 53ed818171e7..f134c0fb3336 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-navigation/components/dot-sub-nav/dot-sub-nav.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-navigation/components/dot-sub-nav/dot-sub-nav.component.spec.ts @@ -1,4 +1,4 @@ -import { createComponentFactory, Spectator } from '@ngneat/spectator/jest'; +import { createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; @@ -39,7 +39,7 @@ describe('DotSubNavComponent', () => { provide: DotSystemConfigService, useValue: { getSystemConfig: () => ({ of: jest.fn() }) } }, - GlobalStore, + mockProvider(GlobalStore), provideHttpClient(), provideHttpClientTesting() ] diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-navigation/services/dot-navigation.service.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-navigation/services/dot-navigation.service.spec.ts index 6a2ed20060c2..a80450ebc08e 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-navigation/services/dot-navigation.service.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-navigation/services/dot-navigation.service.spec.ts @@ -16,7 +16,7 @@ import { DotRouterService, DotSystemConfigService } from '@dotcms/data-access'; -import { Auth, DotcmsEventsService, LoginService } from '@dotcms/dotcms-js'; +import { Auth, LoginService } from '@dotcms/dotcms-js'; import { DotMenu } from '@dotcms/dotcms-models'; import { GlobalStore } from '@dotcms/store'; import { DotCurrentUserServiceMock, LoginServiceMock } from '@dotcms/utils-testing'; @@ -89,18 +89,6 @@ class TitleServiceMock { setTitle = jest.fn(); } -class DotcmsEventsServiceMock { - _events: Subject = new Subject(); - - subscribeTo() { - return this._events; - } - - trigger() { - this._events.next(); - } -} - export const dotMenuMock = () => { return { active: false, @@ -177,7 +165,6 @@ describe('DotNavigationService', () => { let service: DotNavigationService; let dotRouterService: DotRouterService; - let dotcmsEventsService: DotcmsEventsService; let dotEventService: DotEventsService; let dotMenuService: DotMenuService; let loginService: LoginService; @@ -222,10 +209,6 @@ describe('DotNavigationService', () => { providers: [ DotEventsService, DotNavigationService, - { - provide: DotcmsEventsService, - useClass: DotcmsEventsServiceMock - }, { provide: Title, useClass: TitleServiceMock @@ -265,6 +248,14 @@ describe('DotNavigationService', () => { }, { provide: DotCurrentUserService, useClass: DotCurrentUserServiceMock }, GlobalStore, + { + useValue: { + connect: jest.fn().mockReturnValue(of(null)), + status$: jest.fn().mockReturnValue(of('connected')), + on: jest.fn().mockReturnValue(of()), + destroy: jest.fn() + } + }, provideHttpClient(), provideHttpClientTesting() ], @@ -273,7 +264,6 @@ describe('DotNavigationService', () => { service = TestBed.inject(DotNavigationService); dotRouterService = TestBed.inject(DotRouterService); - dotcmsEventsService = TestBed.inject(DotcmsEventsService); dotMenuService = TestBed.inject(DotMenuService); loginService = TestBed.inject(LoginService); dotEventService = TestBed.inject(DotEventsService); @@ -371,10 +361,4 @@ describe('DotNavigationService', () => { done(); }, 100); }); - - // TODO: needs to fix this, looks like the dotcmsEventsService instance is different here not sure why. - xit('should subscribe to UPDATE_PORTLET_LAYOUTS websocket event', () => { - expect(dotcmsEventsService.subscribeTo).toHaveBeenCalledWith('UPDATE_PORTLET_LAYOUTS'); - expect(dotcmsEventsService.subscribeTo).toHaveBeenCalledTimes(1); - }); }); diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-navigation/services/dot-navigation.service.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-navigation/services/dot-navigation.service.ts index a43be04b3990..e09650ed04e9 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-navigation/services/dot-navigation.service.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-navigation/services/dot-navigation.service.ts @@ -7,7 +7,7 @@ import { Event, NavigationEnd, Router } from '@angular/router'; import { filter, map, switchMap, take, tap } from 'rxjs/operators'; import { DotIframeService, DotRouterService } from '@dotcms/data-access'; -import { Auth, DotcmsEventsService, LoginService } from '@dotcms/dotcms-js'; +import { Auth, LoginService } from '@dotcms/dotcms-js'; import { DotMenu } from '@dotcms/dotcms-models'; import { GlobalStore } from '@dotcms/store'; @@ -19,7 +19,6 @@ export class DotNavigationService { private dotIframeService = inject(DotIframeService); private dotMenuService = inject(DotMenuService); private dotRouterService = inject(DotRouterService); - private dotcmsEventsService = inject(DotcmsEventsService); private dynamicRouteService = inject(DynamicRouteService); private loginService = inject(LoginService); private router = inject(Router); @@ -79,24 +78,22 @@ export class DotNavigationService { ) .subscribe(); - // Handle portlet layout updates - this.dotcmsEventsService.subscribeTo('UPDATE_PORTLET_LAYOUTS').subscribe(() => { - this.dotMenuService - .reloadMenu() - .pipe(take(1)) - .subscribe((menus: DotMenu[]) => { - this.registerDynamicRoutes(menus); - this.#globalStore.loadMenu(menus); - - if (this.dotRouterService.currentPortlet.id) { - this.#globalStore.setActiveMenu({ - portletId: this.dotRouterService.currentPortlet.id, - shortParentMenuId: this.dotRouterService.queryParams['mId'], - breadcrumbs: this.#globalStore.breadcrumbs() - }); - } - }); - }); + // Handle portlet layout updates from the global store WebSocket feature + this.#globalStore + .portletLayoutUpdated$() + .pipe(switchMap(() => this.dotMenuService.reloadMenu().pipe(take(1)))) + .subscribe((menus: DotMenu[]) => { + this.registerDynamicRoutes(menus); + this.#globalStore.loadMenu(menus); + + if (this.dotRouterService.currentPortlet.id) { + this.#globalStore.setActiveMenu({ + portletId: this.dotRouterService.currentPortlet.id, + shortParentMenuId: this.dotRouterService.queryParams['mId'], + breadcrumbs: this.#globalStore.breadcrumbs() + }); + } + }); // Handle login/auth changes this.loginService.auth$ diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-persona-selector/dot-persona-selector.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-persona-selector/dot-persona-selector.component.spec.ts index 36c6b46e40c0..2fbd77cdf2eb 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-persona-selector/dot-persona-selector.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-persona-selector/dot-persona-selector.component.spec.ts @@ -1,5 +1,5 @@ import { createHostFactory, SpectatorHost } from '@ngneat/spectator/jest'; -import { EMPTY, of } from 'rxjs'; +import { of } from 'rxjs'; import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; @@ -20,7 +20,7 @@ import { DotWorkflowActionsFireService, PaginatorService } from '@dotcms/data-access'; -import { DotcmsEventsService, LoginService, SiteService } from '@dotcms/dotcms-js'; +import { LoginService, SiteService } from '@dotcms/dotcms-js'; import { DotPersona, DotSystemConfig } from '@dotcms/dotcms-models'; import { cleanUpDialog, @@ -126,11 +126,7 @@ describe('DotPersonaSelectorComponent', () => { DotWorkflowActionsFireService, ConfirmationService, DotAlertConfirmService, - DotEventsService, - { - provide: DotcmsEventsService, - useValue: { subscribeToEvents: jest.fn().mockReturnValue(EMPTY) } - } + DotEventsService ], detectChanges: false }); diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/components/dot-toolbar-notifications/components/dot-notification-item/dot-notification-item.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/components/dot-toolbar-notifications/components/dot-notification-item/dot-notification-item.component.spec.ts index 749c4cf30229..edee930e88ea 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/components/dot-toolbar-notifications/components/dot-notification-item/dot-notification-item.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/components/dot-toolbar-notifications/components/dot-notification-item/dot-notification-item.component.spec.ts @@ -4,25 +4,11 @@ import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { DotMessageService } from '@dotcms/data-access'; -import { - DotcmsConfigService, - DotcmsEventsService, - DotEventsSocketURL, - LoggerService, - StringUtils, - DotEventsSocket -} from '@dotcms/dotcms-js'; +import { DotcmsConfigService, LoggerService, StringUtils } from '@dotcms/dotcms-js'; import { MockDotMessageService } from '@dotcms/utils-testing'; import { DotNotificationItemComponent } from './dot-notification-item.component'; -export const dotEventSocketURLFactory = () => { - return new DotEventsSocketURL( - `${window.location.hostname}:${window.location.port}/api/ws/v1/system/events`, - window.location.protocol === 'https:' - ); -}; - describe('DotNotificationItemComponent', () => { let spectator: Spectator; let component: DotNotificationItemComponent; @@ -37,12 +23,9 @@ describe('DotNotificationItemComponent', () => { provideHttpClient(), provideHttpClientTesting(), { provide: DotMessageService, useValue: messageServiceMock }, - { provide: DotEventsSocketURL, useFactory: dotEventSocketURLFactory }, - DotcmsEventsService, DotcmsConfigService, LoggerService, - StringUtils, - DotEventsSocket + StringUtils ], detectChanges: false }); diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/components/dot-toolbar-notifications/dot-toolbar-notifications.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/components/dot-toolbar-notifications/dot-toolbar-notifications.component.ts index ffc9dd1d25e7..6212ed79352c 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/components/dot-toolbar-notifications/dot-toolbar-notifications.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/components/dot-toolbar-notifications/dot-toolbar-notifications.component.ts @@ -11,7 +11,8 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ButtonModule } from 'primeng/button'; -import { DotcmsEventsService, LoginService } from '@dotcms/dotcms-js'; +import { DotEventsSocket } from '@dotcms/data-access'; +import { LoginService } from '@dotcms/dotcms-js'; import { DotMessagePipe } from '@dotcms/ui'; import { DotNotificationItemComponent } from './components/dot-notification-item/dot-notification-item.component'; @@ -34,7 +35,7 @@ import { DotToolbarBtnOverlayComponent } from '../dot-toolbar-overlay/dot-toolba export class DotToolbarNotificationsComponent implements OnInit { readonly #notificationService = inject(NotificationsService); readonly #destroyRef = inject(DestroyRef); - readonly #dotcmsEventsService = inject(DotcmsEventsService); + readonly #dotEventsSocket = inject(DotEventsSocket); readonly #loginService = inject(LoginService); readonly $overlayPanel = viewChild.required('overlayPanel'); @@ -93,11 +94,12 @@ export class DotToolbarNotificationsComponent implements OnInit { } #subscribeToNotifications(): void { - this.#dotcmsEventsService - .subscribeTo('NOTIFICATION') + this.#dotEventsSocket + .on('NOTIFICATION') + .pipe(takeUntilDestroyed(this.#destroyRef)) .subscribe((data: INotification) => { this.$notifications.update((state) => ({ - data: [data, ...state.data], + data: [data, ...state.data].slice(0, 25), unreadCount: state.unreadCount + 1, hasMore: false })); diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/components/dot-toolbar-user/store/dot-toolbar-user.store.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/components/dot-toolbar-user/store/dot-toolbar-user.store.spec.ts index 887354fe70fa..93830e72edcd 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/components/dot-toolbar-user/store/dot-toolbar-user.store.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/components/dot-toolbar-user/store/dot-toolbar-user.store.spec.ts @@ -13,15 +13,7 @@ import { DotSystemConfigService, DotIframeService } from '@dotcms/data-access'; -import { - DotcmsConfigService, - DotcmsEventsService, - DotEventsSocket, - DotEventsSocketURL, - LoggerService, - LoginService, - StringUtils -} from '@dotcms/dotcms-js'; +import { DotcmsConfigService, LoggerService, LoginService, StringUtils } from '@dotcms/dotcms-js'; import { GlobalStore } from '@dotcms/store'; import { DotCurrentUserServiceMock, LoginServiceMock, mockAuth } from '@dotcms/utils-testing'; @@ -29,7 +21,6 @@ import { DotToolbarUserStore } from './dot-toolbar-user.store'; import { DotMenuService } from '../../../../../../api/services/dot-menu.service'; import { LOCATION_TOKEN } from '../../../../../../providers'; -import { dotEventSocketURLFactory } from '../../../../../../test/dot-test-bed'; import { DotNavigationService } from '../../../../dot-navigation/services/dot-navigation.service'; describe('DotToolbarUserStore', () => { @@ -49,8 +40,6 @@ describe('DotToolbarUserStore', () => { DotEventsService, DotIframeService, DotMenuService, - DotcmsEventsService, - DotEventsSocket, DotcmsConfigService, StringUtils, DotRouterService, @@ -63,7 +52,6 @@ describe('DotToolbarUserStore', () => { } } }, - { provide: DotEventsSocketURL, useFactory: dotEventSocketURLFactory }, { provide: LoginService, useClass: LoginServiceMock }, { provide: DotSystemConfigService, diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/dot-toolbar.component.html b/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/dot-toolbar.component.html index d50f15b6184c..6ecad74013cc 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/dot-toolbar.component.html +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/dot-toolbar.component.html @@ -6,7 +6,7 @@
('ARCHIVE_SITE') - .pipe(takeUntilDestroyed(this.#destroyRef)) - .subscribe((archivedSite: DotSite) => { - if (archivedSite.identifier !== this.globalStore.siteDetails()?.identifier) { - return; - } - - // Current site was archived — backend auto-switches; fetch the new current site - this.#siteService - .getCurrentSite() - .pipe(take(1)) - .subscribe((site) => this.globalStore.setCurrentSite(site)); - }); - } + $currentSite = this.#globalStore.siteDetails; siteChange(identifier: string | null): void { if (identifier) { - this.#siteService - .switchSite(identifier) - .pipe( - switchMap(() => this.#siteService.getCurrentSite()), - take(1), - takeUntilDestroyed(this.#destroyRef) - ) - .subscribe((site: DotSite) => { - if (this.#dotRouterService.isEditPage()) { - this.#dotRouterService.goToSiteBrowser(); - } - this.globalStore.setCurrentSite(site); - }); + this.#globalStore.switchCurrentSite(identifier); } } } diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/dot-toolbar.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/dot-toolbar.spec.ts index 9e3310d4c8c6..9103d4d652e2 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/dot-toolbar.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/dot-toolbar.spec.ts @@ -1,11 +1,11 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { createComponentFactory, mockProvider, Spectator, SpyObject } from '@ngneat/spectator/jest'; import { MockComponent } from 'ng-mocks'; -import { of } from 'rxjs'; +import { of, Subject } from 'rxjs'; import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; -import { Component, Injectable } from '@angular/core'; +import { Component, Injectable, signal } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { ToolbarModule } from 'primeng/toolbar'; @@ -13,28 +13,13 @@ import { ToolbarModule } from 'primeng/toolbar'; import { DotCurrentUserService, DotEventsService, + DotEventsSocket, DotPropertiesService, DotRouterService, - DotSiteService, DotSystemConfigService } from '@dotcms/data-access'; -import { - DotcmsConfigService, - DotcmsEventsService, - DotEventsSocket, - DotEventsSocketURL, - LoggerService, - SiteService, - StringUtils -} from '@dotcms/dotcms-js'; import { GlobalStore } from '@dotcms/store'; -import { DotSiteComponent } from '@dotcms/ui'; -import { - DotCurrentUserServiceMock, - MockDotRouterService, - mockSites, - SiteServiceMock -} from '@dotcms/utils-testing'; +import { DotCurrentUserServiceMock, MockDotRouterService, mockSites } from '@dotcms/utils-testing'; import { DotToolbarAnnouncementsComponent } from './components/dot-toolbar-announcements/dot-toolbar-announcements.component'; import { DotToolbarNotificationsComponent } from './components/dot-toolbar-notifications/dot-toolbar-notifications.component'; @@ -42,6 +27,7 @@ import { DotToolbarUserComponent } from './components/dot-toolbar-user/dot-toolb import { DotToolbarComponent } from './dot-toolbar.component'; import { DotNavLogoService } from '../../../api/services/dot-nav-logo/dot-nav-logo.service'; +import { DotSiteNavigationEffect } from '../../../api/services/dot-site-navigation/dot-site-navigation.effect'; import { DotShowHideFeatureDirective } from '../../../shared/directives/dot-show-hide-feature/dot-show-hide-feature.directive'; import { IframeOverlayService } from '../_common/iframe/service/iframe-overlay.service'; import { DotCrumbtrailComponent } from '../dot-crumbtrail/dot-crumbtrail.component'; @@ -74,22 +60,13 @@ class MockToolbarNotificationsComponent {} }) class MockToolbarAnnouncementsComponent {} -export const dotEventSocketURLFactory = () => { - return new DotEventsSocketURL( - `${window.location.hostname}:${window.location.port}/api/ws/v1/system/events`, - window.location.protocol === 'https:' - ); -}; - describe('DotToolbarComponent', () => { let spectator: Spectator; let dotRouterService: SpyObject; let dotPropertiesService: SpyObject; let iframeOverlayService: IframeOverlayService; - let dotSiteService: SpyObject; let globalStore: SpyObject>; - const siteServiceMock = new SiteServiceMock(); const siteMock = mockSites[0]; const createComponent = createComponentFactory({ @@ -108,49 +85,22 @@ describe('DotToolbarComponent', () => { mockProvider(DotPropertiesService, { getFeatureFlag: jest.fn().mockImplementation(() => of(true)) }), - mockProvider(DotSiteService, { - getCurrentSite: jest.fn().mockReturnValue(of(siteMock)), - // switchSite API returns { hostSwitched: true }; toolbar then calls getCurrentSite() for the site - switchSite: jest.fn().mockReturnValue(of({ hostSwitched: true })), - getSites: jest.fn().mockReturnValue( - of({ - sites: mockSites, - pagination: { - currentPage: 1, - perPage: 10, - totalRecords: mockSites.length - } - }) - ), - getSiteById: jest - .fn() - .mockImplementation((id: string) => - of(mockSites.find((s) => s.identifier === id) || siteMock) - ) - }), mockProvider(GlobalStore, { - setCurrentSite: jest.fn(), - siteDetails: jest.fn().mockReturnValue(siteMock) + siteDetails: signal(siteMock), + switchCurrentSite: jest.fn(), + switchSiteEvent$: jest.fn().mockReturnValue(new Subject()) }), + mockProvider(DotSiteNavigationEffect), + mockProvider(DotEventsSocket, { on: jest.fn().mockReturnValue(new Subject()) }), { provide: DotNavigationService, useClass: MockDotNavigationService }, - { provide: SiteService, useValue: siteServiceMock }, - mockProvider(ActivatedRoute, { - snapshot: { - _routerState: { - url: 'any/url' - } - } - }), + { + provide: ActivatedRoute, + useValue: { snapshot: { _routerState: { url: 'any/url' } } } + }, { provide: DotRouterService, useClass: MockDotRouterService }, - { provide: DotEventsSocketURL, useFactory: dotEventSocketURLFactory }, DotEventsService, - DotcmsEventsService, IframeOverlayService, DotNavLogoService, - DotEventsSocket, - DotcmsConfigService, - LoggerService, - StringUtils, { provide: DotSystemConfigService, useValue: { getSystemConfig: () => of({}) } @@ -168,93 +118,59 @@ describe('DotToolbarComponent', () => { dotRouterService = spectator.inject(DotRouterService); dotPropertiesService = spectator.inject(DotPropertiesService); iframeOverlayService = spectator.inject(IframeOverlayService); - dotSiteService = spectator.inject(DotSiteService); globalStore = spectator.inject(GlobalStore); jest.spyOn(spectator.component, 'siteChange'); jest.spyOn(iframeOverlayService, 'show'); jest.spyOn(iframeOverlayService, 'hide'); - // Reset feature flag mock to return true by default dotPropertiesService.getFeatureFlag.mockReturnValue(of(true)); }); it(`should has a dot-crumbtrail`, () => { spectator.detectChanges(); - - const crumbtrail = spectator.query('dot-crumbtrail'); - expect(crumbtrail).not.toBeNull(); + expect(spectator.query('dot-crumbtrail')).not.toBeNull(); }); it(`should has a dot-toolbar-notifications`, () => { spectator.detectChanges(); - - const dotToolbarNotifications = spectator.query('dot-toolbar-notifications'); - expect(dotToolbarNotifications).not.toBeNull(); + expect(spectator.query('dot-toolbar-notifications')).not.toBeNull(); }); it(`should has a dot-toolbar-user`, () => { spectator.detectChanges(); - - const dotToolbarUser = spectator.query('dot-toolbar-user'); - expect(dotToolbarUser).not.toBeNull(); + expect(spectator.query('dot-toolbar-user')).not.toBeNull(); }); it(`should has a dot-toolbar-announcements`, () => { dotPropertiesService.getFeatureFlag.mockReturnValue(of(true)); spectator.detectChanges(); - - const dotToolbarAnnouncements = spectator.query('dot-toolbar-announcements'); - expect(dotToolbarAnnouncements).not.toBeNull(); + expect(spectator.query('dot-toolbar-announcements')).not.toBeNull(); }); it(`should has not a dot-toolbar-announcements with feature flag disabled`, () => { dotPropertiesService.getFeatureFlag.mockReturnValue(of(false)); spectator.detectChanges(); - - const dotToolbarAnnouncements = spectator.query('dot-toolbar-announcements'); - expect(dotToolbarAnnouncements).toBeNull(); + expect(spectator.query('dot-toolbar-announcements')).toBeNull(); }); - it(`should NOT go to site browser when site change in any portlet but edit page`, () => { - jest.spyOn(dotRouterService, 'isEditPage').mockReturnValue(false); - spectator.detectChanges(); - spectator.triggerEventHandler('dot-site', 'onChange', siteMock.identifier); - expect(dotRouterService.goToSiteBrowser).not.toHaveBeenCalled(); - expect(spectator.component.siteChange).toHaveBeenCalledWith(siteMock.identifier); - expect(dotSiteService.switchSite).toHaveBeenCalledWith(siteMock.identifier); - expect(dotSiteService.getCurrentSite).toHaveBeenCalled(); - expect(globalStore.setCurrentSite).toHaveBeenCalledWith(siteMock); - }); + describe('siteChange()', () => { + it(`should call switchCurrentSite and NOT navigate when not on edit page`, () => { + jest.spyOn(dotRouterService, 'isEditPage').mockReturnValue(false); + spectator.detectChanges(); + spectator.triggerEventHandler('dot-site', 'onChange', siteMock.identifier); - it(`should go to site-browser when site change on edit page url`, () => { - Object.defineProperty(dotRouterService, 'currentPortlet', { - value: { - id: 'edit-page', - url: '' - }, - writable: true + expect(spectator.component.siteChange).toHaveBeenCalledWith(siteMock.identifier); + expect(globalStore.switchCurrentSite).toHaveBeenCalledWith(siteMock.identifier); + expect(dotRouterService.goToSiteBrowser).not.toHaveBeenCalled(); }); - jest.spyOn(dotRouterService, 'isEditPage').mockReturnValue(true); - spectator.detectChanges(); - spectator.triggerEventHandler('dot-site', 'onChange', siteMock.identifier); - - expect(dotRouterService.goToSiteBrowser).toHaveBeenCalled(); - expect(spectator.component.siteChange).toHaveBeenCalledWith(siteMock.identifier); - expect(dotSiteService.switchSite).toHaveBeenCalledWith(siteMock.identifier); - expect(dotSiteService.getCurrentSite).toHaveBeenCalled(); - expect(globalStore.setCurrentSite).toHaveBeenCalledWith(siteMock); - }); - - it(`should call switchSite then getCurrentSite and setCurrentSite when site changes`, () => { - spectator.detectChanges(); - dotSiteService.switchSite.mockClear(); - dotSiteService.getCurrentSite.mockClear(); - (globalStore.setCurrentSite as jest.Mock).mockClear(); - spectator.triggerEventHandler('dot-site', 'onChange', siteMock.identifier); + it(`should call switchCurrentSite when on edit page (navigation handled by DotSiteNavigationEffect)`, () => { + jest.spyOn(dotRouterService, 'isEditPage').mockReturnValue(true); + spectator.detectChanges(); + spectator.triggerEventHandler('dot-site', 'onChange', siteMock.identifier); - expect(dotSiteService.switchSite).toHaveBeenCalledWith(siteMock.identifier); - expect(dotSiteService.getCurrentSite).toHaveBeenCalled(); - expect(globalStore.setCurrentSite).toHaveBeenCalledWith(siteMock); + expect(spectator.component.siteChange).toHaveBeenCalledWith(siteMock.identifier); + expect(globalStore.switchCurrentSite).toHaveBeenCalledWith(siteMock.identifier); + }); }); describe('dot-site component integration', () => { @@ -264,27 +180,18 @@ describe('DotToolbarComponent', () => { expect(siteComponent).not.toBeNull(); expect(siteComponent).toHaveClass('w-64'); - // Verify that value is bound to current site identifier via globalStore - expect(globalStore.siteDetails()).toEqual(siteMock); - }); - - it(`should bind showSystemHost="false" to dot-site so the system host is hidden`, () => { - spectator.detectChanges(); - const dotSite = spectator.query(DotSiteComponent); - expect(dotSite.showSystemHost()).toBe(false); + expect(spectator.component.$currentSite()).toEqual(siteMock); }); it(`should call iframeOverlayService.show() when dot-site onShow event is triggered`, () => { spectator.detectChanges(); spectator.triggerEventHandler('dot-site', 'onShow', null); - expect(iframeOverlayService.show).toHaveBeenCalled(); }); it(`should call iframeOverlayService.hide() when dot-site onHide event is triggered`, () => { spectator.detectChanges(); spectator.triggerEventHandler('dot-site', 'onHide', null); - expect(iframeOverlayService.hide).toHaveBeenCalled(); }); }); diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-workflow-task-detail/dot-workflow-task-detail.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-workflow-task-detail/dot-workflow-task-detail.component.spec.ts index d66e1f0a0f86..2d3c91f4ab9b 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-workflow-task-detail/dot-workflow-task-detail.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-workflow-task-detail/dot-workflow-task-detail.component.spec.ts @@ -6,22 +6,13 @@ import { provideHttpClientTesting } from '@angular/common/http/testing'; import { ActivatedRoute } from '@angular/router'; import { DotIframeService, DotRouterService, DotUiColorsService } from '@dotcms/data-access'; -import { - DotcmsConfigService, - DotcmsEventsService, - DotEventsSocket, - DotEventsSocketURL, - LoggerService, - LoginService, - StringUtils -} from '@dotcms/dotcms-js'; +import { DotcmsConfigService, LoggerService, LoginService, StringUtils } from '@dotcms/dotcms-js'; import { LoginServiceMock, MockDotRouterService } from '@dotcms/utils-testing'; import { DotWorkflowTaskDetailComponent } from './dot-workflow-task-detail.component'; import { DotWorkflowTaskDetailService } from './services/dot-workflow-task-detail.service'; import { DotMenuService } from '../../../api/services/dot-menu.service'; -import { dotEventSocketURLFactory } from '../../../test/dot-test-bed'; import { IframeOverlayService } from '../_common/iframe/service/iframe-overlay.service'; import { DotIframeDialogComponent } from '../dot-iframe-dialog/dot-iframe-dialog.component'; @@ -39,12 +30,9 @@ describe('DotWorkflowTaskDetailComponent', () => { DotIframeService, DotUiColorsService, IframeOverlayService, - DotcmsEventsService, - DotEventsSocket, DotcmsConfigService, LoggerService, StringUtils, - { provide: DotEventsSocketURL, useFactory: dotEventSocketURLFactory }, { provide: LoginService, useClass: LoginServiceMock }, { provide: DotRouterService, useClass: MockDotRouterService }, { diff --git a/core-web/apps/dotcms-ui/src/app/view/components/main-legacy/main-legacy.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/main-legacy/main-legacy.component.spec.ts index 9f2cbc5f1dbb..36286385afc1 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/main-legacy/main-legacy.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/main-legacy/main-legacy.component.spec.ts @@ -35,9 +35,6 @@ import { import { ApiRoot, DotcmsConfigService, - DotcmsEventsService, - DotEventsSocket, - DotEventsSocketURL, DotPushPublishDialogService, LoggerService, LoginService, @@ -54,7 +51,7 @@ import { DotDownloadBundleDialogService } from '../../../api/services/dot-downlo import { DotMenuService } from '../../../api/services/dot-menu.service'; import { NotificationsService } from '../../../api/services/notifications-service'; import { LOCATION_TOKEN } from '../../../providers'; -import { dotEventSocketURLFactory, MockDotUiColorsService } from '../../../test/dot-test-bed'; +import { MockDotUiColorsService } from '../../../test/dot-test-bed'; import { DotDownloadBundleDialogComponent } from '../_common/dot-download-bundle-dialog/dot-download-bundle-dialog.component'; import { DotWizardComponent } from '../_common/dot-wizard/dot-wizard.component'; import { IframeOverlayService } from '../_common/iframe/service/iframe-overlay.service'; @@ -145,9 +142,6 @@ describe('MainLegacyComponent', () => { DotFormatDateService, DotAlertConfirmService, ConfirmationService, - DotcmsEventsService, - DotEventsSocket, - { provide: DotEventsSocketURL, useFactory: dotEventSocketURLFactory }, DotcmsConfigService, LoggerService, StringUtils, diff --git a/core-web/libs/data-access/src/index.ts b/core-web/libs/data-access/src/index.ts index 51adc88bca24..24e5d0f81d1f 100644 --- a/core-web/libs/data-access/src/index.ts +++ b/core-web/libs/data-access/src/index.ts @@ -1,12 +1,13 @@ export * from './lib/add-to-bundle/add-to-bundle.service'; export * from './lib/can-deactivate/can-deactivate-guard.service'; export * from './lib/dot-ai/dot-ai.service'; -export * from './lib/dot-apps/dot-apps.service'; export * from './lib/dot-alert-confirm/dot-alert-confirm.service'; export * from './lib/dot-analytics-search/dot-analytics-search.service'; export * from './lib/dot-analytics-tracker/dot-analytics-tracker.service'; +export * from './lib/dot-apps/dot-apps.service'; export * from './lib/dot-categories/dot-categories.service'; export * from './lib/dot-containers/dot-containers.service'; +export * from './lib/dot-content-drive/dot-content-drive.service'; export * from './lib/dot-content-search/dot-content-search.service'; export * from './lib/dot-content-type/dot-content-type.service'; export * from './lib/dot-content-types-info/dot-content-types-info.service'; @@ -21,6 +22,7 @@ export * from './lib/dot-edit-page/dot-edit-page.service'; export * from './lib/dot-es-content/dot-es-content.service'; export * from './lib/dot-events/dot-events.service'; export * from './lib/dot-experiments/dot-experiments.service'; +export * from './lib/dot-favorite-contenttype/dot-favorite-contenttype.service'; export * from './lib/dot-favorite-page/dot-favorite-page.service'; export * from './lib/dot-field/dot-field.service'; export * from './lib/dot-folder/dot-folder.service'; @@ -34,6 +36,9 @@ export * from './lib/dot-license/dot-license.service'; export * from './lib/dot-localstorage/dot-localstorage.service'; export * from './lib/dot-message-display/dot-message-display.service'; export * from './lib/dot-messages/dot-messages.service'; +export * from './lib/dot-osgi/bundle-map.model'; +export * from './lib/dot-osgi/dot-osgi.service'; +export * from './lib/dot-page-contenttype/dot-page-contenttype.service'; export * from './lib/dot-page-layout/dot-page-layout.service'; export * from './lib/dot-page-render/dot-page-render.service'; export * from './lib/dot-page-state/dot-page-state.service'; @@ -60,7 +65,10 @@ export * from './lib/dot-themes/dot-themes.service'; export * from './lib/dot-ui-colors/dot-ui-colors.service'; export * from './lib/dot-upload-file/dot-upload-file.service'; export * from './lib/dot-upload/dot-upload.service'; +export * from './lib/dot-usage/dot-usage.service'; export * from './lib/dot-versionable/dot-versionable.service'; +export * from './lib/dot-websocket/dot-event-message.model'; +export * from './lib/dot-websocket/dot-events-socket.service'; export * from './lib/dot-wizard/dot-wizard.service'; export * from './lib/dot-workflow-actions-fire/dot-workflow-actions-fire.service'; export * from './lib/dot-workflow-event-handler/dot-workflow-event-handler.service'; @@ -69,9 +77,3 @@ export * from './lib/dot-workflows-actions/dot-workflows-actions.service'; export * from './lib/ema-app-configuration/ema-app-configuration.service'; export * from './lib/paginator/paginator.service'; export * from './lib/push-publish/push-publish.service'; -export * from './lib/dot-page-contenttype/dot-page-contenttype.service'; -export * from './lib/dot-favorite-contenttype/dot-favorite-contenttype.service'; -export * from './lib/dot-content-drive/dot-content-drive.service'; -export * from './lib/dot-usage/dot-usage.service'; -export * from './lib/dot-osgi/bundle-map.model'; -export * from './lib/dot-osgi/dot-osgi.service'; diff --git a/core-web/libs/data-access/src/lib/dot-message-display/dot-message-display.service.spec.ts b/core-web/libs/data-access/src/lib/dot-message-display/dot-message-display.service.spec.ts index b8f253de6e4a..5071d1f948c2 100644 --- a/core-web/libs/data-access/src/lib/dot-message-display/dot-message-display.service.spec.ts +++ b/core-web/libs/data-access/src/lib/dot-message-display/dot-message-display.service.spec.ts @@ -1,18 +1,21 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { describe, it, expect } from '@jest/globals'; +import { Subject } from 'rxjs'; import { TestBed } from '@angular/core/testing'; -import { DotcmsEventsService } from '@dotcms/dotcms-js'; import { DotMessage, DotMessageSeverity, DotMessageType } from '@dotcms/dotcms-models'; -import { DotcmsEventsServiceMock } from '@dotcms/utils-testing'; import { DotMessageDisplayService } from './dot-message-display.service'; import { DotRouterService } from '../dot-router/dot-router.service'; +import { DotEventsSocket } from '../dot-websocket/dot-events-socket.service'; describe('DotMessageDisplayService', () => { - const mockDotcmsEventsService: DotcmsEventsServiceMock = new DotcmsEventsServiceMock(); + const messageSubject = new Subject(); + const mockDotEventsSocket = { + on: jest.fn().mockReturnValue(messageSubject.asObservable()) + }; let dotMessageDisplayService: DotMessageDisplayService; @@ -28,7 +31,7 @@ describe('DotMessageDisplayService', () => { TestBed.configureTestingModule({ providers: [ DotMessageDisplayService, - { provide: DotcmsEventsService, useValue: mockDotcmsEventsService }, + { provide: DotEventsSocket, useValue: mockDotEventsSocket }, { provide: DotRouterService, useValue: { @@ -44,7 +47,7 @@ describe('DotMessageDisplayService', () => { dotMessageDisplayService = TestBed.inject(DotMessageDisplayService); }); - xit('should emit a message', () => { + it('should emit a message', () => { dotMessageDisplayService.messages().subscribe((message: DotMessage) => { expect(message).toEqual({ ...messageExpected, @@ -53,7 +56,7 @@ describe('DotMessageDisplayService', () => { }); }); - mockDotcmsEventsService.triggerSubscribeTo('MESSAGE', messageExpected); + messageSubject.next(messageExpected); }); it('should push a message', () => { @@ -73,7 +76,7 @@ describe('DotMessageDisplayService', () => { dotMessageDisplayService.unsubscribe(); - mockDotcmsEventsService.triggerSubscribeTo('MESSAGE', messageExpected); + messageSubject.next(messageExpected); expect(wasCalled).toBe(false); }); @@ -90,7 +93,7 @@ describe('DotMessageDisplayService', () => { }); }); - mockDotcmsEventsService.triggerSubscribeTo('MESSAGE', messageExpected); + messageSubject.next(messageExpected); }); it('should not show message when currentPortlet is not in portletIdList ', () => { @@ -102,7 +105,7 @@ describe('DotMessageDisplayService', () => { wasCalled = true; }); - mockDotcmsEventsService.triggerSubscribeTo('MESSAGE', messageExpected); + messageSubject.next(messageExpected); expect(wasCalled).toBe(false); }); diff --git a/core-web/libs/data-access/src/lib/dot-message-display/dot-message-display.service.ts b/core-web/libs/data-access/src/lib/dot-message-display/dot-message-display.service.ts index 2756ec2160f8..5cf4a8cc8833 100644 --- a/core-web/libs/data-access/src/lib/dot-message-display/dot-message-display.service.ts +++ b/core-web/libs/data-access/src/lib/dot-message-display/dot-message-display.service.ts @@ -4,10 +4,10 @@ import { Injectable, inject } from '@angular/core'; import { filter, takeUntil } from 'rxjs/operators'; -import { DotcmsEventsService } from '@dotcms/dotcms-js'; import { DotMessage, DotMessageSeverity } from '@dotcms/dotcms-models'; import { DotRouterService } from '../dot-router/dot-router.service'; +import { DotEventsSocket } from '../dot-websocket/dot-events-socket.service'; /** * Handle message send by the Backend, this message are sended as Event through the {@link DotcmsEventsService} @@ -18,16 +18,14 @@ import { DotRouterService } from '../dot-router/dot-router.service'; @Injectable() export class DotMessageDisplayService { private dotRouterService = inject(DotRouterService); - private dotcmsEventsService = inject(DotcmsEventsService); + private dotEventsSocket = inject(DotEventsSocket); private messages$: Observable; private destroy$: Subject = new Subject(); private localMessage$: Subject = new Subject(); constructor() { - const webSocketMessage = ( - this.dotcmsEventsService.subscribeTo('MESSAGE') as Observable - ).pipe( + const webSocketMessage = this.dotEventsSocket.on('MESSAGE').pipe( takeUntil(this.destroy$), filter((data: DotMessage) => this.hasPortletIdList(data)) ); diff --git a/core-web/libs/data-access/src/lib/dot-websocket/dot-event-message.model.ts b/core-web/libs/data-access/src/lib/dot-websocket/dot-event-message.model.ts new file mode 100644 index 000000000000..2d6eca1ff606 --- /dev/null +++ b/core-web/libs/data-access/src/lib/dot-websocket/dot-event-message.model.ts @@ -0,0 +1,4 @@ +export interface DotEventMessage { + event: string; + payload?: { data?: unknown }; +} diff --git a/core-web/libs/data-access/src/lib/dot-websocket/dot-events-socket.service.spec.ts b/core-web/libs/data-access/src/lib/dot-websocket/dot-events-socket.service.spec.ts new file mode 100644 index 000000000000..583fa0ff7d61 --- /dev/null +++ b/core-web/libs/data-access/src/lib/dot-websocket/dot-events-socket.service.spec.ts @@ -0,0 +1,350 @@ +import { createServiceFactory, SpectatorService } from '@ngneat/spectator/jest'; + +import { DotEventsSocket, WebSocketStatus } from './dot-events-socket.service'; + +// --------------------------------------------------------------------------- +// Minimal WebSocket mock — exposes handlers so tests can trigger them +// --------------------------------------------------------------------------- +class MockWebSocket { + static instances: MockWebSocket[] = []; + static readonly CONNECTING = 0; + static readonly OPEN = 1; + static readonly CLOSING = 2; + static readonly CLOSED = 3; + + onopen: (() => void) | null = null; + onmessage: ((ev: Partial) => void) | null = null; + onclose: ((ev: Partial) => void) | null = null; + onerror: (() => void) | null = null; + readyState: number = WebSocket.CONNECTING; + + constructor(public url: string) { + MockWebSocket.instances.push(this); + } + + close(): void { + this.readyState = WebSocket.CLOSED; + } + + /** Test helper — simulate a successful connection */ + triggerOpen(): void { + this.readyState = WebSocket.OPEN; + this.onopen?.(); + } + + /** Test helper — simulate a close event */ + triggerClose(code = 1006): void { + this.readyState = WebSocket.CLOSED; + this.onclose?.({ code }); + } + + /** Test helper — simulate an incoming message */ + triggerMessage(data: unknown): void { + this.onmessage?.({ data: JSON.stringify(data) }); + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// Jest's jsdom sets window.location to http://localhost/, so the service builds: +const WS_URL = 'ws://localhost/api/ws/v1/system/events'; + +function latestSocket(): MockWebSocket { + return MockWebSocket.instances[MockWebSocket.instances.length - 1]; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- +describe('DotEventsSocket', () => { + let spectator: SpectatorService; + let service: DotEventsSocket; + + const createService = createServiceFactory({ + service: DotEventsSocket + }); + + beforeEach(() => { + jest.useFakeTimers(); + MockWebSocket.instances = []; + (global as unknown as { WebSocket: unknown }).WebSocket = MockWebSocket; + + spectator = createService(); + service = spectator.service; + }); + + afterEach(() => { + jest.useRealTimers(); + service.destroy(); + }); + + // ----------------------------------------------------------------------- + // connect() + // ----------------------------------------------------------------------- + describe('connect()', () => { + it('should open a WebSocket to the configured URL', () => { + service.connect().subscribe(); + + expect(MockWebSocket.instances.length).toBe(1); + expect(latestSocket().url).toBe(WS_URL); + }); + + it('should emit and complete immediately', (done) => { + let emitted = false; + service.connect().subscribe({ + next: () => { + emitted = true; + }, + complete: () => { + expect(emitted).toBe(true); + done(); + } + }); + }); + + it('should set status to "connecting" on first connect', () => { + const statuses: WebSocketStatus[] = []; + service.status$().subscribe((s) => statuses.push(s)); + + service.connect().subscribe(); + + expect(statuses).toContain('connecting'); + }); + + it('should set status to "connected" when socket opens', () => { + const statuses: WebSocketStatus[] = []; + service.status$().subscribe((s) => statuses.push(s)); + + service.connect().subscribe(); + latestSocket().triggerOpen(); + + expect(statuses[statuses.length - 1]).toBe('connected'); + }); + + it('should report connected after a successful reconnect', () => { + service.connect().subscribe(); + latestSocket().triggerOpen(); + latestSocket().triggerClose(); + jest.advanceTimersByTime(2000); + + latestSocket().triggerOpen(); + + expect(service.isConnected()).toBe(true); + }); + }); + + // ----------------------------------------------------------------------- + // on() — message filtering + // ----------------------------------------------------------------------- + describe('on()', () => { + it('should emit payload for matching event type', (done) => { + service.connect().subscribe(); + latestSocket().triggerOpen(); + + service.on<{ name: string }>('PUBLISH_SITE').subscribe((data) => { + expect(data).toEqual({ name: 'demo.dotcms.com' }); + done(); + }); + + latestSocket().triggerMessage({ + event: 'PUBLISH_SITE', + payload: { data: { name: 'demo.dotcms.com' } } + }); + }); + + it('should not emit for non-matching event type', () => { + service.connect().subscribe(); + latestSocket().triggerOpen(); + + const received: unknown[] = []; + service.on('OTHER_EVENT').subscribe((d) => received.push(d)); + + latestSocket().triggerMessage({ + event: 'PUBLISH_SITE', + payload: { data: {} } + }); + + expect(received).toHaveLength(0); + }); + + it('should ignore unparseable messages', () => { + service.connect().subscribe(); + latestSocket().triggerOpen(); + + const received: unknown[] = []; + service.on('ANY').subscribe((d) => received.push(d)); + + // Trigger invalid JSON via onmessage directly + latestSocket().onmessage?.({ data: 'not-json' }); + + expect(received).toHaveLength(0); + }); + }); + + // ----------------------------------------------------------------------- + // messages() + // ----------------------------------------------------------------------- + describe('messages()', () => { + it('should emit all raw messages', (done) => { + service.connect().subscribe(); + latestSocket().triggerOpen(); + + service.messages().subscribe((msg) => { + expect(msg.event).toBe('UPDATE_SITE'); + done(); + }); + + latestSocket().triggerMessage({ event: 'UPDATE_SITE', payload: { data: {} } }); + }); + }); + + // ----------------------------------------------------------------------- + // isConnected() + // ----------------------------------------------------------------------- + describe('isConnected()', () => { + it('should return false before connection opens', () => { + service.connect().subscribe(); + + expect(service.isConnected()).toBe(false); + }); + + it('should return true after socket opens', () => { + service.connect().subscribe(); + latestSocket().triggerOpen(); + + expect(service.isConnected()).toBe(true); + }); + + it('should return false after destroy', () => { + service.connect().subscribe(); + latestSocket().triggerOpen(); + service.destroy(); + + expect(service.isConnected()).toBe(false); + }); + }); + + // ----------------------------------------------------------------------- + // Reconnection + // ----------------------------------------------------------------------- + describe('reconnection', () => { + it('should reconnect after socket closes unexpectedly', () => { + service.connect().subscribe(); + latestSocket().triggerOpen(); + latestSocket().triggerClose(1006); + + jest.advanceTimersByTime(3000); + + expect(MockWebSocket.instances.length).toBe(2); + }); + + it('should set status to "reconnecting" after first disconnect', () => { + const statuses: WebSocketStatus[] = []; + service.status$().subscribe((s) => statuses.push(s)); + + service.connect().subscribe(); + latestSocket().triggerOpen(); + latestSocket().triggerClose(1006); + + expect(statuses).toContain('reconnecting'); + }); + + it('should NOT reconnect after destroy', () => { + service.connect().subscribe(); + latestSocket().triggerOpen(); + service.destroy(); + latestSocket().triggerClose(1006); + + jest.advanceTimersByTime(5000); + + expect(MockWebSocket.instances.length).toBe(1); + }); + + it('should not reconnect on close code 1001 (going away)', () => { + service.connect().subscribe(); + latestSocket().triggerOpen(); + latestSocket().triggerClose(1001); + + jest.advanceTimersByTime(5000); + + expect(MockWebSocket.instances.length).toBe(1); + }); + + it('should use exponential backoff — second retry waits longer than first', () => { + // Pin jitter to 0 so delays are deterministic: + // retry 1 (retryCount=1): 1000 * 2^1 = 2000ms + // retry 2 (retryCount=2): 1000 * 2^2 = 4000ms + const randomSpy = jest.spyOn(Math, 'random').mockReturnValue(0); + + service.connect().subscribe(); + latestSocket().triggerOpen(); + + // First disconnect — schedules retry at 2000ms + latestSocket().triggerClose(1006); + const countAfterFirst = MockWebSocket.instances.length; + jest.advanceTimersByTime(2100); // enough for first retry (2000ms) + const countAfterFirstRetry = MockWebSocket.instances.length; + + // Second disconnect — schedules retry at 4000ms + latestSocket().triggerClose(1006); + jest.advanceTimersByTime(3000); // NOT enough for second retry (4000ms) + const countAfterShortWait = MockWebSocket.instances.length; + + jest.advanceTimersByTime(1100); // now enough (4100ms > 4000ms) + const countAfterLongWait = MockWebSocket.instances.length; + + randomSpy.mockRestore(); + + expect(countAfterFirstRetry).toBeGreaterThan(countAfterFirst); + expect(countAfterShortWait).toBe(countAfterFirstRetry); // no new socket yet + expect(countAfterLongWait).toBeGreaterThan(countAfterShortWait); + }); + }); + + // ----------------------------------------------------------------------- + // destroy() + // ----------------------------------------------------------------------- + describe('destroy()', () => { + it('should set status to "closed"', () => { + const statuses: WebSocketStatus[] = []; + service.status$().subscribe((s) => statuses.push(s)); + + service.connect().subscribe(); + latestSocket().triggerOpen(); + service.destroy(); + + expect(statuses[statuses.length - 1]).toBe('closed'); + }); + + it('should close the underlying socket', () => { + service.connect().subscribe(); + latestSocket().triggerOpen(); + const socket = latestSocket(); + service.destroy(); + + expect(socket.readyState).toBe(WebSocket.CLOSED); + }); + + it('should do nothing if called without a prior connect()', () => { + expect(() => service.destroy()).not.toThrow(); + }); + }); + + // ----------------------------------------------------------------------- + // status$() deduplication + // ----------------------------------------------------------------------- + describe('status$()', () => { + it('should not emit duplicate statuses', () => { + const statuses: WebSocketStatus[] = []; + service.status$().subscribe((s) => statuses.push(s)); + + service.connect().subscribe(); + // Both openSocket calls set 'connecting' — only one emission expected + service.connect().subscribe(); + + expect(statuses.filter((s) => s === 'connecting')).toHaveLength(1); + }); + }); +}); diff --git a/core-web/libs/data-access/src/lib/dot-websocket/dot-events-socket.service.ts b/core-web/libs/data-access/src/lib/dot-websocket/dot-events-socket.service.ts new file mode 100644 index 000000000000..fe7049d6cf82 --- /dev/null +++ b/core-web/libs/data-access/src/lib/dot-websocket/dot-events-socket.service.ts @@ -0,0 +1,175 @@ +import { BehaviorSubject, Observable, Subject, Subscription, timer } from 'rxjs'; + +import { Injectable } from '@angular/core'; + +import { distinctUntilChanged, filter, map } from 'rxjs/operators'; + +import { DotEventMessage } from './dot-event-message.model'; + +export type WebSocketStatus = 'connecting' | 'reconnecting' | 'connected' | 'closed'; + +/** + * Manages the WebSocket connection to the dotCMS server-sent events endpoint. + * + * Features: + * - Native WebSocket (no long-polling fallback — all modern browsers support WS) + * - Exponential backoff with jitter on reconnection + * - `status$()` — reactive connection state for UI indicators + * - `on(eventType)` — typed event subscription + */ +@Injectable({ providedIn: 'root' }) +export class DotEventsSocket { + private readonly socketURL = (() => { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + return `${protocol}//${window.location.host}/api/ws/v1/system/events`; + })(); + + private socket: WebSocket | null = null; + private status: WebSocketStatus = 'connecting'; + private retryCount = 0; + private destroyed = false; + private reconnectTimer: Subscription | null = null; + + private readonly _message = new Subject(); + private readonly _status = new BehaviorSubject('connecting'); + + private readonly MAX_RETRIES = 100_000; + private readonly INITIAL_RETRY_DELAY = 1_000; + private readonly MAX_RETRY_DELAY = 30_000; + + /** + * Opens the WebSocket connection. Call once at app startup. + * Returns an Observable that completes immediately after initiating the connection — + * subscribe in GlobalStore's withWebSocket feature. + */ + connect(): Observable { + return new Observable((subscriber) => { + this.destroyed = false; + this.reconnectTimer?.unsubscribe(); + this.reconnectTimer = null; + this.openSocket(); + subscriber.next(); + subscriber.complete(); + }); + } + + /** Closes the connection permanently (e.g. on logout). */ + destroy(): void { + this.destroyed = true; + this.reconnectTimer?.unsubscribe(); + this.reconnectTimer = null; + this.setStatus('closed'); + this.socket?.close(); + this.socket = null; + } + + /** Emits the typed payload of a specific event type. */ + on(eventType: string): Observable { + return this._message.asObservable().pipe( + filter((msg) => msg.event === eventType), + map((msg) => msg.payload?.data as T) + ); + } + + /** + * All raw messages from the server. + * + * @internal Consumers should use the typed `on(eventType)` API instead. + * This is kept public solely so `withWebSocket()` in @dotcms/global-store + * can pipe every message into the deprecated `DotcmsEventsService` bus. + */ + messages(): Observable { + return this._message.asObservable(); + } + + isConnected(): boolean { + return this.status === 'connected'; + } + + /** Emits only when the status actually changes. */ + status$(): Observable { + return this._status.asObservable().pipe(distinctUntilChanged()); + } + + private openSocket(): void { + if (this.destroyed) { + return; + } + + const state = this.socket?.readyState; + // Don't spawn a second socket if one is already live or still starting up. + // CLOSING (2) will fire onclose → scheduleReconnect, so no action needed here. + if (state === WebSocket.CONNECTING || state === WebSocket.OPEN) { + return; + } + + this.setStatus(this.retryCount === 0 ? 'connecting' : 'reconnecting'); + + try { + this.socket = new WebSocket(this.socketURL); + } catch { + this.scheduleReconnect(); + return; + } + + this.socket.onopen = () => { + this.retryCount = 0; + this.setStatus('connected'); + }; + + this.socket.onmessage = (ev: MessageEvent) => { + try { + this._message.next(JSON.parse(ev.data) as DotEventMessage); + } catch { + // Ignore unparseable messages + } + }; + + this.socket.onclose = (ev: CloseEvent) => { + if (!this.destroyed) { + // 1001 = "going away" (browser tab/window closing) — don't reconnect. + // All other codes (including 1000, normal closure) still reconnect + // since dotCMS may restart after a clean shutdown. + if (ev.code !== 1001) { + this.scheduleReconnect(); + } + } + }; + + this.socket.onerror = () => { + // onerror is always followed by onclose, so let onclose drive reconnection + }; + } + + private scheduleReconnect(): void { + if (this.retryCount >= this.MAX_RETRIES || this.destroyed) { + this.setStatus('closed'); + return; + } + + this.retryCount++; + this.setStatus('reconnecting'); + + this.reconnectTimer?.unsubscribe(); + this.reconnectTimer = timer(this.calculateDelay()).subscribe(() => { + this.reconnectTimer = null; + if (!this.destroyed) { + this.openSocket(); + } + }); + } + + private calculateDelay(): number { + const exponential = Math.min( + this.INITIAL_RETRY_DELAY * Math.pow(2, Math.min(this.retryCount, 10)), + this.MAX_RETRY_DELAY + ); + + return exponential + Math.random() * 1_000; + } + + private setStatus(status: WebSocketStatus): void { + this.status = status; + this._status.next(status); + } +} diff --git a/core-web/libs/data-access/src/lib/dot-workflow-event-handler/dot-workflow-event-handler.service.spec.ts b/core-web/libs/data-access/src/lib/dot-workflow-event-handler/dot-workflow-event-handler.service.spec.ts index 40db3a97e78a..8750da6e8141 100644 --- a/core-web/libs/data-access/src/lib/dot-workflow-event-handler/dot-workflow-event-handler.service.spec.ts +++ b/core-web/libs/data-access/src/lib/dot-workflow-event-handler/dot-workflow-event-handler.service.spec.ts @@ -3,7 +3,7 @@ import { of } from 'rxjs'; import { Injectable } from '@angular/core'; -import { DotEventsSocketURL, LoginService } from '@dotcms/dotcms-js'; +import { LoginService } from '@dotcms/dotcms-js'; import { DotActionBulkRequestOptions, DotActionBulkResult, @@ -37,13 +37,6 @@ import { DotWizardService } from '../dot-wizard/dot-wizard.service'; import { DotWorkflowActionsFireService } from '../dot-workflow-actions-fire/dot-workflow-actions-fire.service'; import { PushPublishService } from '../push-publish/push-publish.service'; -const dotEventSocketURLFactory = () => { - return new DotEventsSocketURL( - `${window.location.hostname}:${window.location.port}/api/ws/v1/system/events`, - window.location.protocol === 'https:' - ); -}; - @Injectable() export class MockPushPublishService { getEnvironments() { @@ -155,7 +148,6 @@ describe('DotWorkflowEventHandlerService', () => { provide: LoginService, useClass: LoginServiceMock }, - { provide: DotEventsSocketURL, useFactory: dotEventSocketURLFactory }, { provide: DotFormatDateService, useClass: DotFormatDateServiceMock } ] }); diff --git a/core-web/libs/dot-rules/src/lib/rule-engine.module.ts b/core-web/libs/dot-rules/src/lib/rule-engine.module.ts index 4fd15f8f16e8..fb0e4ed4b4d3 100644 --- a/core-web/libs/dot-rules/src/lib/rule-engine.module.ts +++ b/core-web/libs/dot-rules/src/lib/rule-engine.module.ts @@ -5,7 +5,6 @@ import { ApiRoot, BrowserUtil, DotcmsConfigService, - DotcmsEventsService, LoggerService, StringUtils, UserModel @@ -49,7 +48,6 @@ import { RuleViewService } from './services/ui/dot-view-rule-service'; ApiRoot, BrowserUtil, DotcmsConfigService, - DotcmsEventsService, LoggerService, StringUtils, UserModel, diff --git a/core-web/libs/dotcms-js/src/lib/core/dotcms-events.service.spec.ts b/core-web/libs/dotcms-js/src/lib/core/dotcms-events.service.spec.ts deleted file mode 100644 index 172baee4376f..000000000000 --- a/core-web/libs/dotcms-js/src/lib/core/dotcms-events.service.spec.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { Observable, Subject, of } from 'rxjs'; - -import { ReflectiveInjector } from '@angular/core'; - -import { DotcmsEventsService } from './dotcms-events.service'; -import { LoggerService } from './logger.service'; -import { DotEventTypeWrapper } from './models'; -import { StringUtils } from './string-utils.service'; -import { DotEventsSocket } from './util/dot-event-socket'; -import { DotEventMessage } from './util/models/dot-event-message'; - -class DotEventsSocketMock { - _messages: Subject = new Subject(); - _open: Subject = new Subject(); - private connected = false; - - connect(): Observable { - this.connected = true; - - return of(true); - } - - open(): Observable { - return this._open.asObservable(); - } - - messages(): Observable { - return this._messages.asObservable(); - } - - public sendMessage(message: DotEventMessage) { - this._messages.next(message); - } - - isConnected(): boolean { - return this.connected; - } - - destroy(): void {} -} - -describe('DotcmsEventsService', () => { - let socket: DotEventsSocketMock; - let dotcmsEventsService: DotcmsEventsService; - - let injector: ReflectiveInjector; - - beforeEach(() => { - socket = new DotEventsSocketMock(); - - injector = ReflectiveInjector.resolveAndCreate([ - { provide: DotEventsSocket, useValue: socket }, - StringUtils, - LoggerService, - DotcmsEventsService - ]); - - dotcmsEventsService = injector.get(DotcmsEventsService); - - spyOn(socket, 'connect').and.callThrough(); - spyOn(socket, 'destroy').and.callThrough(); - }); - - it('should create and connect a new socket', () => { - dotcmsEventsService.start(); - - expect(socket.connect).toHaveBeenCalled(); - }); - - it('should reuse socket', () => { - dotcmsEventsService.start(); - dotcmsEventsService.start(); - - expect(socket.connect).toHaveBeenCalledTimes(1); - }); - - it('should trigger open event', (done) => { - dotcmsEventsService.open().subscribe(() => { - done(); - }); - - socket._open.next(true); - }); - - it('should subscribe to a event', (done) => { - dotcmsEventsService.start(); - - dotcmsEventsService.subscribeTo('test_event').subscribe((dotEventData: any) => { - expect(dotEventData).toEqual('test payload'); - done(); - }); - - socket.sendMessage({ - event: 'test_event', - payload: { - data: 'test payload' - } - }); - }); - - it('should subscribe to several events', () => { - let count = 0; - - dotcmsEventsService.start(); - - dotcmsEventsService - .subscribeToEvents(['test_event_1', 'test_event_2']) - .subscribe((dotEventTypeWrapper: DotEventTypeWrapper) => { - if (dotEventTypeWrapper.name === 'test_event_1') { - expect(dotEventTypeWrapper.data).toEqual('test payload_1'); - } else if (dotEventTypeWrapper.name === 'test_event_2') { - expect(dotEventTypeWrapper.data).toEqual('test payload_2'); - } else { - expect(true).toBe(false); - } - - count++; - }); - - socket.sendMessage({ - event: 'test_event_1', - payload: { - data: 'test payload_1' - } - }); - - socket.sendMessage({ - event: 'test_event_2', - payload: { - data: 'test payload_2' - } - }); - - expect(count).toBe(2); - }); - - it('should destroy socket', () => { - dotcmsEventsService.start(); - dotcmsEventsService.destroy(); - - expect(socket.destroy).toHaveBeenCalled(); - }); - - it('should destroy socket and connect', () => { - dotcmsEventsService.start(); - dotcmsEventsService.destroy(); - expect(socket.destroy).toHaveBeenCalled(); - - dotcmsEventsService.start(); - expect(socket.connect).toHaveBeenCalledTimes(1); - }); - - it('should stop emitting after destroy', () => { - const subscribeCallback = jasmine.createSpy('spy'); - - dotcmsEventsService.start(); - dotcmsEventsService.subscribeTo('test_event').subscribe(subscribeCallback); - dotcmsEventsService.destroy(); - - socket.sendMessage({ - event: 'test_event', - payload: 'test payload' - }); - - expect(subscribeCallback).not.toHaveBeenCalled(); - }); -}); diff --git a/core-web/libs/dotcms-js/src/lib/core/dotcms-events.service.ts b/core-web/libs/dotcms-js/src/lib/core/dotcms-events.service.ts index 697ce4e6a1ed..21ad07ae8efe 100644 --- a/core-web/libs/dotcms-js/src/lib/core/dotcms-events.service.ts +++ b/core-web/libs/dotcms-js/src/lib/core/dotcms-events.service.ts @@ -1,72 +1,36 @@ -import { Observable, Subscription, Subject } from 'rxjs'; +import { Observable, Subject } from 'rxjs'; -import { Injectable, inject } from '@angular/core'; +import { Injectable } from '@angular/core'; -import { switchMap } from 'rxjs/operators'; - -import { LoggerService } from './logger.service'; import { DotEventTypeWrapper } from './models'; -import { DotEventsSocket } from './util/dot-event-socket'; -import { DotEventMessage } from './util/models/dot-event-message'; -@Injectable() +/** + * @deprecated Use DotEventsSocket from @dotcms/data-access directly. + * + * This is a pure Subject-based event bus with no WebSocket logic. + * Messages are fed into it by withWebSocket() (global-store) via feedMessage(), + * keeping dotcms-js free of any data-access imports and avoiding circular deps. + * + * start() and destroy() are intentional no-ops — DotEventsSocket owns the connection. + */ +@Injectable({ providedIn: 'root' }) export class DotcmsEventsService { - private dotEventsSocket = inject(DotEventsSocket); - private loggerService = inject(LoggerService); - - private subjects = []; - private messagesSub: Subscription; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private subjects: Record> = {}; - /** - * Close the socket - * - * @memberof DotcmsEventsService - */ - destroy(): void { - this.dotEventsSocket.destroy(); - this.messagesSub.unsubscribe(); + /** Called by withWebSocket() on each incoming message to fan out to subscribers. */ + feedMessage(event: string, data: unknown): void { + if (this.subjects[event]) { + this.subjects[event].next(data); + } } - /** - * Start the socket - * - * @memberof DotcmsEventsService - */ - start(): void { - this.loggerService.debug('start DotcmsEventsService', this.dotEventsSocket.isConnected()); - if (!this.dotEventsSocket.isConnected()) { - this.loggerService.debug('Connecting with socket'); - - this.messagesSub = this.dotEventsSocket - .connect() - .pipe(switchMap(() => this.dotEventsSocket.messages())) - .subscribe( - ({ event, payload }: DotEventMessage) => { - if (!this.subjects[event]) { - this.subjects[event] = new Subject(); - } + // eslint-disable-next-line @typescript-eslint/no-empty-function + start(): void {} - this.subjects[event].next(payload.data); - }, - (e) => { - this.loggerService.debug( - 'Error in the System Events service: ' + e.message - ); - }, - () => { - this.loggerService.debug('Completed'); - } - ); - } - } + // eslint-disable-next-line @typescript-eslint/no-empty-function + destroy(): void {} - /** - * This method will be called by clients that want to receive notifications - * regarding incoming system events. The events they will receive will be - * based on the type of event clients register for. - * - * @memberof DotcmsEventsService - */ subscribeTo(clientEventType: string): Observable { if (!this.subjects[clientEventType]) { this.subjects[clientEventType] = new Subject(); @@ -75,33 +39,15 @@ export class DotcmsEventsService { return this.subjects[clientEventType].asObservable(); } - /** - * Subscribe to multiple events from the DotCMS WebSocket - * - * @memberof DotcmsEventsService - */ subscribeToEvents(clientEventTypes: string[]): Observable> { - const subject: Subject> = new Subject>(); + const subject = new Subject>(); - clientEventTypes.forEach((eventType: string) => { - this.subscribeTo(eventType).subscribe((data: T) => { - subject.next({ - data: data, - name: eventType - }); + clientEventTypes.forEach((eventType) => { + this.subscribeTo(eventType).subscribe((data) => { + subject.next({ data, name: eventType }); }); }); return subject.asObservable(); } - - /** - * Listen when the socket is opened - * - * @returns Observable - * @memberof DotcmsEventsService - */ - open(): Observable { - return this.dotEventsSocket.open(); - } } diff --git a/core-web/libs/dotcms-js/src/lib/core/login.service.ts b/core-web/libs/dotcms-js/src/lib/core/login.service.ts index 6230ece2e740..ef1186eafb84 100644 --- a/core-web/libs/dotcms-js/src/lib/core/login.service.ts +++ b/core-web/libs/dotcms-js/src/lib/core/login.service.ts @@ -41,8 +41,6 @@ export class LoginService { private urls: Record; constructor() { - const dotcmsEventsService = this.dotcmsEventsService; - this._loginAsUsersList$ = new Subject(); this.urls = { @@ -57,13 +55,12 @@ export class LoginService { current: '/api/v1/users/current/' }; - // when the session is expired/destroyed - dotcmsEventsService.subscribeTo('SESSION_DESTROYED').subscribe(() => { + this.dotcmsEventsService.subscribeTo('SESSION_DESTROYED').subscribe(() => { this.logOutUser(); this.clearExperimentPersistence(); }); - dotcmsEventsService.subscribeTo('SESSION_LOGOUT').subscribe(() => { + this.dotcmsEventsService.subscribeTo('SESSION_LOGOUT').subscribe(() => { this.clearExperimentPersistence(); }); } @@ -301,8 +298,6 @@ export class LoginService { // When not logged user we need to fire the observable chain if (!auth.user) { this._logout$.next(); - } else { - this.dotcmsEventsService.start(); } } diff --git a/core-web/libs/dotcms-js/src/lib/core/site.service.ts b/core-web/libs/dotcms-js/src/lib/core/site.service.ts index 7dbdfe0acc13..ba41cee5a0c0 100644 --- a/core-web/libs/dotcms-js/src/lib/core/site.service.ts +++ b/core-web/libs/dotcms-js/src/lib/core/site.service.ts @@ -7,7 +7,6 @@ import { filter, map, skip, startWith, switchMap, take, tap } from 'rxjs/operato import { DotCMSResponse } from '@dotcms/dotcms-models'; -import { DotcmsEventsService } from './dotcms-events.service'; import { LoggerService } from './logger.service'; import { LoginService } from './login.service'; import { DotEventTypeWrapper } from './models/dot-events/dot-event-type-wrapper'; @@ -48,7 +47,6 @@ export class SiteService { constructor() { const loginService = inject(LoginService); - const dotcmsEventsService = inject(DotcmsEventsService); this.urls = { currentSiteUrl: '/api/v1/site/currentSite', @@ -56,18 +54,6 @@ export class SiteService { switchSiteUrl: '/api/v1/site/switch' }; - dotcmsEventsService - .subscribeToEvents(['ARCHIVE_SITE', 'UPDATE_SITE']) - .subscribe((event: DotEventTypeWrapper) => this.eventResponse(event)); - - dotcmsEventsService - .subscribeToEvents(this.events) - .subscribe(({ data }: DotEventTypeWrapper) => this.siteEventsHandler(data)); - - dotcmsEventsService - .subscribeToEvents(['SWITCH_SITE']) - .subscribe(({ data }: DotEventTypeWrapper) => this.setCurrentSite(data)); - loginService.watchUser(() => this.loadCurrentSite()); } diff --git a/core-web/libs/dotcms-js/src/lib/core/util/dot-event-socket.spec.ts b/core-web/libs/dotcms-js/src/lib/core/util/dot-event-socket.spec.ts deleted file mode 100644 index 354b0e460fa5..000000000000 --- a/core-web/libs/dotcms-js/src/lib/core/util/dot-event-socket.spec.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { Server } from 'mock-socket'; -import { Observable, of } from 'rxjs'; - -import { provideHttpClient } from '@angular/common/http'; -import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; -import { TestBed } from '@angular/core/testing'; - -import { DotEventsSocket } from './dot-event-socket'; -import { DotEventMessage } from './models/dot-event-message'; -import { DotEventsSocketURL } from './models/dot-event-socket-url'; - -import { ConfigParams, DotcmsConfigService } from '../dotcms-config.service'; -import { LoggerService } from '../logger.service'; -import { StringUtils } from '../string-utils.service'; - -class DotcmsConfigMock { - getConfig(): Observable { - return of({ - colors: {}, - emailRegex: '', - license: {}, - menu: [], - paginatorLinks: 1, - paginatorRows: 2, - websocket: { - websocketReconnectTime: 0, - disabledWebsockets: false - } - }); - } -} - -describe('DotEventsSocket', () => { - let dotEventsSocket: DotEventsSocket; - let httpTesting: HttpTestingController; - const url = new DotEventsSocketURL('localhost/testing', false); - - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [ - provideHttpClient(), - provideHttpClientTesting(), - { provide: DotcmsConfigService, useClass: DotcmsConfigMock }, - { provide: DotEventsSocketURL, useValue: url }, - StringUtils, - LoggerService, - DotEventsSocket - ] - }); - - dotEventsSocket = TestBed.inject(DotEventsSocket); - httpTesting = TestBed.inject(HttpTestingController); - }); - - describe('WebSocket', () => { - let mockwebSocketServer: Server; - - beforeEach(() => { - mockwebSocketServer = new Server(url.getWebSocketURL()); - }); - - it('should connect', (done) => { - mockwebSocketServer.on('connection', () => { - done(); - }); - - dotEventsSocket.connect().subscribe(() => {}); - }); - - it('should catch a message', (done) => { - const expectedMessage: DotEventMessage = { - event: 'event', - payload: 'message' - }; - - mockwebSocketServer.on('connection', (socket) => { - socket.send(JSON.stringify(expectedMessage)); - done(); - }); - - dotEventsSocket.messages().subscribe((message) => { - expect(message).toEqual(expectedMessage); - }); - dotEventsSocket.connect().subscribe(() => {}); - }); - - afterEach(() => { - mockwebSocketServer.close(); - }); - }); - - describe('LongPolling', () => { - const longPollingUrl = 'http://localhost/testing'; - - it('should connect', (done) => { - dotEventsSocket.connect().subscribe(() => {}); - - dotEventsSocket.open().subscribe(() => { - done(); - }); - - const req = httpTesting.expectOne(longPollingUrl); - dotEventsSocket.destroy(); - req.flush({ entity: { message: 'message' } }); - }); - - it('should catch a message', (done) => { - dotEventsSocket.connect().subscribe(() => {}); - - dotEventsSocket.messages().subscribe((message) => { - dotEventsSocket.destroy(); - expect(message).toEqual({ - event: 'event', - payload: 'message' - }); - done(); - }); - - const req = httpTesting.expectOne(longPollingUrl); - req.flush({ entity: { event: 'event', payload: 'message' } }); - }); - - it('should fallback to long polling after websocket error and catch message', (done) => { - dotEventsSocket.connect().subscribe(() => {}); - - dotEventsSocket.messages().subscribe((message) => { - dotEventsSocket.destroy(); - expect(message).toEqual({ - event: 'event', - payload: 'message' - }); - done(); - }); - - const req1 = httpTesting.expectOne(longPollingUrl); - req1.flush(null, { status: 500, statusText: 'Server Error' }); - - const req2 = httpTesting.expectOne(longPollingUrl); - req2.flush({ entity: { event: 'event', payload: 'message' } }); - }); - }); -}); diff --git a/core-web/libs/dotcms-js/src/lib/core/util/dot-event-socket.ts b/core-web/libs/dotcms-js/src/lib/core/util/dot-event-socket.ts deleted file mode 100644 index 7bd04d7dbe7a..000000000000 --- a/core-web/libs/dotcms-js/src/lib/core/util/dot-event-socket.ts +++ /dev/null @@ -1,240 +0,0 @@ -import { Subject, Observable, timer } from 'rxjs'; - -import { HttpClient } from '@angular/common/http'; -import { Injectable, inject } from '@angular/core'; - -import { tap } from 'rxjs/operators'; - -import { LongPollingProtocol } from './long-polling-protocol'; -import { DotEventMessage } from './models/dot-event-message'; -import { DotEventsSocketURL } from './models/dot-event-socket-url'; -import { Protocol } from './protocol'; -import { WebSocketProtocol } from './websockets-protocol'; - -import { ConfigParams, DotcmsConfigService, WebSocketConfigParams } from '../dotcms-config.service'; -import { LoggerService } from '../logger.service'; - -enum ConnectionStatus { - NONE, - CONNECTING, - RECONNECTING, - CONNECTED, - CLOSED -} - -/** - * It is a socket to receive notifications when a event is triggered by the server, first this try to establish a web socket - * connection if it fails then try a Long polling connection instead. - * - * If the connection is lost at any point it will try to reconnect automatically after a time set by configuration parameters. - * It implements an exponential backoff strategy with jitter for reconnection attempts. - * - * @export - */ -@Injectable() -export class DotEventsSocket { - private dotEventsSocketURL = inject(DotEventsSocketURL); - private dotcmsConfigService = inject(DotcmsConfigService); - private loggerService = inject(LoggerService); - private http = inject(HttpClient); - - private protocolImpl: Protocol; - - private status: ConnectionStatus = ConnectionStatus.NONE; - private _message: Subject = new Subject(); - private _open: Subject = new Subject(); - private webSocketConfigParams: WebSocketConfigParams; - private readonly MAX_RETRIES = 100000; - private readonly INITIAL_RETRY_DELAY = 1000; - private readonly MAX_RETRY_DELAY = 30000; // 30 seconds max delay - private retryCount = 0; - - /** - * Connect to a Event socket using Web Socket protocol, - * if a Web Socket connection can be stablish then try again with a Long Polling connection. - * - * @returns Observable - * @memberof DotEventsSocket - */ - connect(): Observable { - // Using the init method and making sure the return type is correct - return this.dotcmsConfigService.getConfig().pipe( - tap((config) => { - this.webSocketConfigParams = config.websocket; - this.protocolImpl = - this.isWebSocketsBrowserSupport() && - !this.webSocketConfigParams.disabledWebsockets - ? this.getWebSocketProtocol() - : this.getLongPollingProtocol(); - this.status = ConnectionStatus.CONNECTING; - this.connectProtocol(); - }) - ); - } - - /** - * Destroy the Event socket - * - * @memberof DotEventsSocket - */ - destroy(): void { - // On logout, meaning no authenticated user lets try to close the socket - if (this.protocolImpl) { - this.loggerService.debug('Closing socket'); - this.status = ConnectionStatus.CLOSED; - this.protocolImpl.close(); - } - } - - /** - * Trigger when a message is received - * - * @returns Observable - * @memberof DotEventsSocket - */ - messages(): Observable { - return this._message.asObservable(); - } - - /** - * Trigger when a connect is open - * - * @returns Observable - * @memberof DotEventsSocket - */ - open(): Observable { - return this._open.asObservable(); - } - - /** - * Return true if the socket is connected otherwise return false - * - * @returns boolean - * @memberof DotEventsSocket - */ - isConnected(): boolean { - return this.status === ConnectionStatus.CONNECTED; - } - - private connectProtocol(): void { - this.protocolImpl.open$().subscribe(() => { - this.status = ConnectionStatus.CONNECTED; - this._open.next(true); - // Reset retry counter on successful connection - this.retryCount = 0; - }); - - this.protocolImpl.error$().subscribe(() => { - if (this.shouldTryWithLongPooling()) { - this.loggerService.info( - 'Error connecting with Websockets, trying again with long polling' - ); - - this.protocolImpl.destroy(); - this.protocolImpl = this.getLongPollingProtocol(); - this.connectProtocol(); - } else { - this.reconnect(); - } - }); - - this.protocolImpl.close$().subscribe((_event) => { - if (this.status !== ConnectionStatus.CLOSED) { - this.loggerService.info('Connection closed unexpectedly, attempting to reconnect'); - this.reconnect(); - } else { - this.loggerService.debug('Connection closed normally'); - } - }); - - this.protocolImpl.message$().subscribe( - (res) => this._message.next(res), - (e) => { - this.loggerService.debug('Error in the System Events service: ' + e.message); - this.reconnect(); - }, - () => this.loggerService.debug('Completed') - ); - - this.protocolImpl.connect(); - } - - private reconnect(): void { - // Don't attempt more reconnections than MAX_RETRIES (which is a big number because we always want to reconnect) - if (this.retryCount >= this.MAX_RETRIES) { - this.loggerService.info( - `Maximum reconnection attempts (${this.MAX_RETRIES}) reached. Giving up.` - ); - this.status = ConnectionStatus.CLOSED; - - return; - } - - this.status = this.getAfterErrorStatus(); - this.retryCount++; - - const delay = this.calculateReconnectDelay(); - - this.loggerService.info( - `Scheduling reconnection attempt ${this.retryCount}/${this.MAX_RETRIES} in ${delay}ms` - ); - - timer(delay).subscribe(() => { - if (this.status !== ConnectionStatus.CLOSED) { - this.loggerService.info('Attempting to reconnect'); - this.protocolImpl.connect(); - } - }); - } - - private calculateReconnectDelay(): number { - // Use configured time or default delay with a random jitter to prevent thundering herd - const baseDelay = - this.webSocketConfigParams?.websocketReconnectTime || this.INITIAL_RETRY_DELAY; - - // Exponential backoff with jitter, capped at MAX_RETRY_DELAY - const exponentialDelay = Math.min( - baseDelay * Math.pow(2, Math.min(this.retryCount, 10)), // Cap exponential growth - this.MAX_RETRY_DELAY - ); - - // Add random jitter to prevent reconnection thundering herd problems - const jitter = Math.random() * 1000; // Up to 1 second of jitter - - return exponentialDelay + jitter; - } - - private getAfterErrorStatus(): ConnectionStatus { - return this.status === ConnectionStatus.CONNECTING - ? ConnectionStatus.CONNECTING - : ConnectionStatus.RECONNECTING; - } - - private shouldTryWithLongPooling(): boolean { - return ( - this.isWebSocketProtocol() && - this.status !== ConnectionStatus.CONNECTED && - this.status !== ConnectionStatus.RECONNECTING - ); - } - - private getWebSocketProtocol(): WebSocketProtocol { - return new WebSocketProtocol(this.dotEventsSocketURL.getWebSocketURL(), this.loggerService); - } - - private getLongPollingProtocol(): LongPollingProtocol { - return new LongPollingProtocol( - this.dotEventsSocketURL.getLongPoolingURL(), - this.loggerService, - this.http - ); - } - - private isWebSocketsBrowserSupport(): boolean { - return 'WebSocket' in window || 'MozWebSocket' in window; - } - - private isWebSocketProtocol(): boolean { - return this.protocolImpl instanceof WebSocketProtocol; - } -} diff --git a/core-web/libs/dotcms-js/src/lib/core/util/long-polling-protocol.spec.ts b/core-web/libs/dotcms-js/src/lib/core/util/long-polling-protocol.spec.ts deleted file mode 100644 index ad1d91b6d6c5..000000000000 --- a/core-web/libs/dotcms-js/src/lib/core/util/long-polling-protocol.spec.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { of, throwError } from 'rxjs'; - -import { HttpClient } from '@angular/common/http'; -import { provideHttpClient } from '@angular/common/http'; -import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; -import { TestBed } from '@angular/core/testing'; - -import { LongPollingProtocol } from './long-polling-protocol'; - -import { LoggerService } from '../logger.service'; -import { StringUtils } from '../string-utils.service'; - -describe('LongPollingProtocol', () => { - let httpClient: HttpClient; - let httpTesting: HttpTestingController; - let longPollingProtocol: LongPollingProtocol; - const url = 'http://testing'; - - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [provideHttpClient(), provideHttpClientTesting(), StringUtils, LoggerService] - }); - - httpClient = TestBed.inject(HttpClient); - httpTesting = TestBed.inject(HttpTestingController); - - const loggerService = TestBed.inject(LoggerService); - longPollingProtocol = new LongPollingProtocol(url, loggerService, httpClient); - }); - - afterEach(() => { - httpTesting.verify(); - }); - - it('should connect', () => { - longPollingProtocol.connect(); - - const req = httpTesting.expectOne(url); - expect(req.request.method).toBe('GET'); - - longPollingProtocol.close(); - req.flush({ entity: { message: 'message' } }); - }); - - it('should trigger message', (done) => { - longPollingProtocol.message$().subscribe((message) => { - expect(message).toEqual({ message: 'message' }); - done(); - }); - - longPollingProtocol.connect(); - - const req = httpTesting.expectOne(url); - longPollingProtocol.close(); - req.flush({ entity: { message: 'message' } }); - }); - - it('should trigger message with lastCallback', (done) => { - let countRequest = 0; - - longPollingProtocol.message$().subscribe((message) => { - expect(message).toEqual({ message: 'message', creationDate: 1 }); - countRequest++; - - if (countRequest === 2) { - longPollingProtocol.close(); - done(); - } - }); - - longPollingProtocol.connect(); - - const req1 = httpTesting.expectOne(url); - req1.flush({ entity: [{ message: 'message', creationDate: 1 }] }); - - const req2 = httpTesting.expectOne(`${url}?lastCallBack=2`); - longPollingProtocol.close(); - req2.flush({ entity: [{ message: 'message', creationDate: 1 }] }); - }); - - it('should reconnect after a message', () => { - longPollingProtocol.connect(); - - const req1 = httpTesting.expectOne(url); - req1.flush({ entity: [{ message: 'message' }] }); - - const req2 = httpTesting.expectOne(url); - longPollingProtocol.close(); - req2.flush({ entity: [{ message: 'message' }] }); - }); - - it('should trigger a error', (done) => { - longPollingProtocol.error$().subscribe(() => { - done(); - }); - - longPollingProtocol.connect(); - - const req = httpTesting.expectOne(url); - req.flush(null, { status: 500, statusText: 'Server Error' }); - }); -}); diff --git a/core-web/libs/dotcms-js/src/lib/core/util/long-polling-protocol.ts b/core-web/libs/dotcms-js/src/lib/core/util/long-polling-protocol.ts deleted file mode 100644 index c2d9449d2d85..000000000000 --- a/core-web/libs/dotcms-js/src/lib/core/util/long-polling-protocol.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { HttpClient, HttpParams } from '@angular/common/http'; - -import { map, take } from 'rxjs/operators'; - -import { DotCMSResponse } from '@dotcms/dotcms-models'; - -import { Protocol } from './protocol'; - -import { LoggerService } from '../logger.service'; - -export class LongPollingProtocol extends Protocol { - private isClosed = false; - private isAlreadyOpen = false; - private lastCallback: number; - - constructor( - private url: string, - loggerService: LoggerService, - private http: HttpClient - ) { - super(loggerService); - } - - /** - * Connect to a Long Polling connection - */ - connect(): void { - this.connectLongPooling(); - } - - /** - * Close the connection - */ - close(): void { - this.loggerService.info('destroying long polling'); - this.isClosed = true; - this.isAlreadyOpen = false; - this._close.next(); - } - - private getLastCallback(data): number { - this.lastCallback = - data.length > 0 ? data[data.length - 1].creationDate + 1 : this.lastCallback; - - return this.lastCallback; - } - - private connectLongPooling(lastCallBack?: number): void { - this.isClosed = false; - this.loggerService.info('Starting long polling connection'); - - let params = new HttpParams(); - if (lastCallBack) { - params = params.set('lastCallBack', lastCallBack); - } - - this.http - .get(this.url, { params }) - .pipe( - map((res) => res.entity), - take(1) - ) - .subscribe( - (data) => { - this.loggerService.debug('new Events', data); - this.triggerOpen(); - - if (data instanceof Array) { - data.forEach((message) => { - this._message.next(message); - }); - } else { - this._message.next(data); - } - - if (!this.isClosed) { - this.connectLongPooling(this.getLastCallback(data)); - } - }, - (e) => { - this.loggerService.info('A error occur connecting through long polling'); - this._error.next(e); - } - ); - } - - private triggerOpen(): void { - if (!this.isAlreadyOpen) { - this._open.next(true); - this.isAlreadyOpen = true; - } - } -} diff --git a/core-web/libs/dotcms-js/src/lib/core/util/models/dot-event-socket-url.ts b/core-web/libs/dotcms-js/src/lib/core/util/models/dot-event-socket-url.ts deleted file mode 100644 index 9bcdb02e873d..000000000000 --- a/core-web/libs/dotcms-js/src/lib/core/util/models/dot-event-socket-url.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Represent a url to connect with a evend end point - * - * @export - */ -export class DotEventsSocketURL { - constructor( - private url: string, - private useSSL: boolean - ) {} - - /** - * Return the web socket url to connect with the Event end point - * - * @returns string - * @memberof DotEventsSocketURL - */ - public getWebSocketURL(): string { - return `${this.getWebSocketProtocol()}://${this.url}`; - } - - /** - * Return the long polling url to connect with the Event end point - * - * @returns string - * @memberof DotEventsSocketURL - */ - public getLongPoolingURL(): string { - return `${this.getHttpProtocol()}://${this.url}`; - } - - private getWebSocketProtocol(): string { - return `${this.useSSL ? 'wss' : 'ws'}`; - } - - private getHttpProtocol(): string { - return `${this.useSSL ? 'https' : 'http'}`; - } -} diff --git a/core-web/libs/dotcms-js/src/lib/core/util/websockets-protocol.spec.ts b/core-web/libs/dotcms-js/src/lib/core/util/websockets-protocol.spec.ts deleted file mode 100644 index a929b3187270..000000000000 --- a/core-web/libs/dotcms-js/src/lib/core/util/websockets-protocol.spec.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { Server } from 'mock-socket'; - -import { WebSocketProtocol } from './websockets-protocol'; - -import { LoggerService } from '../logger.service'; -import { StringUtils } from '../string-utils.service'; - -describe('WebSocketProtocol', () => { - let webSocketProtocol: WebSocketProtocol; - const url = 'wss://testing'; - let mockServer: Server; - - beforeEach(() => { - const loggerService = new LoggerService(new StringUtils()); - webSocketProtocol = new WebSocketProtocol(url, loggerService); - }); - - beforeEach(() => { - mockServer = new Server(url); - }); - - it('should connect and tigger open event', (done) => { - webSocketProtocol.open$().subscribe(() => { - done(); - }); - - webSocketProtocol.connect(); - }); - - it('should tigger message event', (done) => { - mockServer.on('connection', (socket) => { - socket.send( - JSON.stringify({ - data: 'testing' - }) - ); - }); - - webSocketProtocol.message$().subscribe((message) => { - expect(message).toEqual({ - data: 'testing' - }); - done(); - }); - - webSocketProtocol.connect(); - }); - - it('should tigger close event', (done) => { - webSocketProtocol.open$().subscribe(() => { - mockServer.close(); - }); - - webSocketProtocol.close$().subscribe(() => { - done(); - }); - - webSocketProtocol.error$().subscribe(() => { - expect(true).toBe(false, 'Should not trigger error event'); - }); - - webSocketProtocol.connect(); - }); - - afterEach(() => { - mockServer.close(); - }); -}); diff --git a/core-web/libs/dotcms-js/src/lib/core/util/websockets-protocol.ts b/core-web/libs/dotcms-js/src/lib/core/util/websockets-protocol.ts deleted file mode 100644 index bcb4f5fab68b..000000000000 --- a/core-web/libs/dotcms-js/src/lib/core/util/websockets-protocol.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { Subject } from 'rxjs'; - -import { Protocol } from './protocol'; - -import { LoggerService } from '../logger.service'; - -enum WEB_SOCKET_PROTOCOL_CODE { - NORMAL_CLOSE_CODE = 1000, - GO_AWAY_CODE = 1001 -} -export class WebSocketProtocol extends Protocol { - dataStream: Subject<{}> = new Subject(); - private socket: WebSocket; - private errorThrown: boolean; - - constructor( - private url: string, - loggerService: LoggerService - ) { - super(loggerService); - - const match = new RegExp('wss?://').test(url); - if (!match) { - throw new Error('Invalid url provided [' + url + ']'); - } - } - - connect(): void { - this.errorThrown = false; - this.loggerService.debug('Connecting with Web socket', this.url); - - try { - this.socket = new WebSocket(this.url); - - this.socket.onopen = (ev: Event) => { - this.loggerService.debug('Web socket connected', this.url); - this._open.next(ev); - }; - - this.socket.onmessage = (ev: MessageEvent) => { - this._message.next(JSON.parse(ev.data)); - }; - - this.socket.onclose = (ev: CloseEvent) => { - if (!this.errorThrown) { - if (ev.code === WEB_SOCKET_PROTOCOL_CODE.NORMAL_CLOSE_CODE) { - this._close.next(); - this._message.complete(); - } else { - this._error.next(ev); - } - } - }; - - this.socket.onerror = (ev: ErrorEvent) => { - this.errorThrown = true; - this._error.next(ev); - }; - } catch (error) { - this.loggerService.debug('Web EventsSocket connection error', error); - this._error.next(error); - } - } - - close(): void { - if (this.socket && this.socket.readyState !== 3) { - this.socket.close(); - } - } -} diff --git a/core-web/libs/dotcms-js/src/public_api.ts b/core-web/libs/dotcms-js/src/public_api.ts index 9cc178ff30b1..37ca59de87b3 100644 --- a/core-web/libs/dotcms-js/src/public_api.ts +++ b/core-web/libs/dotcms-js/src/public_api.ts @@ -11,19 +11,15 @@ export * from './lib/core/routing.service'; export * from './lib/core/site.service'; export * from './lib/core/string-utils.service'; export * from './lib/core/util/app.config'; -export * from './lib/core/util/dot-event-socket'; export * from './lib/core/util/http-code'; export * from './lib/core/util/http-request-utils'; export * from './lib/core/util/http-response-util'; export * from './lib/core/util/local-store.service'; -export * from './lib/core/util/long-polling-protocol'; export * from './lib/core/util/notification.service'; export * from './lib/core/util/protocol'; export * from './lib/core/util/response-view'; -export * from './lib/core/util/websockets-protocol'; // MODELS export * from './lib/core/models'; export * from './lib/core/shared/user.model'; export * from './lib/core/site.service.mock'; -export * from './lib/core/util/models/dot-event-socket-url'; diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.spec.ts b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.spec.ts index 069ddab6b6ab..571691c842db 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.spec.ts +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.spec.ts @@ -102,7 +102,6 @@ describe('DotFormComponent', () => { mockProvider(DotVersionableService), mockProvider(GlobalStore, { loadCurrentSite: jest.fn(), - setCurrentSite: jest.fn(), siteDetails: jest.fn().mockReturnValue(null), addNewBreadcrumb: jest.fn() }), diff --git a/core-web/libs/global-store/src/index.ts b/core-web/libs/global-store/src/index.ts index 9f445f8edd3c..fd334876c105 100644 --- a/core-web/libs/global-store/src/index.ts +++ b/core-web/libs/global-store/src/index.ts @@ -1 +1,2 @@ export * from './lib/store'; +export { WebSocketStatus } from '@dotcms/data-access'; diff --git a/core-web/libs/global-store/src/lib/features/with-websocket/with-websocket.feature.spec.ts b/core-web/libs/global-store/src/lib/features/with-websocket/with-websocket.feature.spec.ts new file mode 100644 index 000000000000..41d18a6f4c1e --- /dev/null +++ b/core-web/libs/global-store/src/lib/features/with-websocket/with-websocket.feature.spec.ts @@ -0,0 +1,63 @@ +import { signalStore, withState } from '@ngrx/signals'; +import { Subject, of } from 'rxjs'; + +import { TestBed } from '@angular/core/testing'; + +import { DotEventsSocket } from '@dotcms/data-access'; + +import { withWebSocket } from './with-websocket.feature'; + +describe('withWebSocket Feature', () => { + const TestStore = signalStore(withState({}), withWebSocket()); + + let store: InstanceType; + let statusSubject: Subject<'connecting' | 'reconnecting' | 'connected' | 'closed'>; + let mockEventsSocket: jest.Mocked>; + + beforeEach(() => { + statusSubject = new Subject(); + + mockEventsSocket = { + connect: jest.fn().mockReturnValue(of({})), + status$: jest.fn().mockReturnValue(statusSubject.asObservable()), + on: jest.fn().mockReturnValue(new Subject()), + destroy: jest.fn() + }; + + TestBed.configureTestingModule({ + providers: [TestStore, { provide: DotEventsSocket, useValue: mockEventsSocket }] + }); + + store = TestBed.inject(TestStore); + }); + + it('should initialize with connecting status', () => { + expect(store.wsStatus()).toBe('connecting'); + }); + + it('should call connect and trackStatus on init', () => { + expect(mockEventsSocket.connect).toHaveBeenCalled(); + expect(mockEventsSocket.status$).toHaveBeenCalled(); + }); + + it('should update wsStatus to connected when socket connects', () => { + statusSubject.next('connected'); + expect(store.wsStatus()).toBe('connected'); + }); + + it('should update wsStatus to reconnecting when socket reconnects', () => { + statusSubject.next('connected'); + statusSubject.next('reconnecting'); + expect(store.wsStatus()).toBe('reconnecting'); + }); + + it('should update wsStatus to closed when socket closes', () => { + statusSubject.next('closed'); + expect(store.wsStatus()).toBe('closed'); + }); + + it('should call destroy on store destroy', () => { + TestBed.resetTestingModule(); + expect(mockEventsSocket.destroy).toHaveBeenCalled(); + }); +}); diff --git a/core-web/libs/global-store/src/lib/features/with-websocket/with-websocket.feature.ts b/core-web/libs/global-store/src/lib/features/with-websocket/with-websocket.feature.ts new file mode 100644 index 000000000000..dc0896cfe86a --- /dev/null +++ b/core-web/libs/global-store/src/lib/features/with-websocket/with-websocket.feature.ts @@ -0,0 +1,106 @@ +import { patchState, signalStoreFeature, withHooks, withMethods, withState } from '@ngrx/signals'; +import { rxMethod } from '@ngrx/signals/rxjs-interop'; +import { merge, Observable, pipe } from 'rxjs'; + +import { inject } from '@angular/core'; + +import { switchMap, tap } from 'rxjs/operators'; + +import { DotEventsSocket, WebSocketStatus } from '@dotcms/data-access'; +import { DotcmsEventsService } from '@dotcms/dotcms-js'; +import { DotSite } from '@dotcms/dotcms-models'; + +export interface WebSocketState { + wsStatus: WebSocketStatus; +} + +const initialWebSocketState: WebSocketState = { + wsStatus: 'connecting' +}; + +/** + * Store feature that manages the WebSocket connection lifecycle and exposes + * its status as a signal. + * + * - Starts the connection automatically in `onInit` + * - Destroys the connection in `onDestroy` + * - Keeps `wsStatus` in sync via `status$()` + * + * Consumers read `globalStore.wsStatus()` — the UI indicator only shows when not 'connected'. + */ +export function withWebSocket() { + return signalStoreFeature( + withState(initialWebSocketState), + withMethods( + ( + store, + eventsSocket = inject(DotEventsSocket), + dotcmsEventsService: DotcmsEventsService = inject(DotcmsEventsService) + ) => ({ + startConnection: rxMethod(pipe(switchMap(() => eventsSocket.connect()))), + trackStatus: rxMethod( + pipe( + switchMap(() => + eventsSocket + .status$() + .pipe(tap((wsStatus) => patchState(store, { wsStatus }))) + ) + ) + ), + /** Pipes all raw WS messages into the legacy DotcmsEventsService Subject bus. */ + feedLegacyEventBus: rxMethod( + pipe( + switchMap(() => + eventsSocket + .messages() + .pipe( + tap(({ event, payload }) => + dotcmsEventsService.feedMessage(event, payload?.data) + ) + ) + ) + ) + ), + destroySocket: () => eventsSocket.destroy(), + /** + * Observable that emits when the backend sends UPDATE_PORTLET_LAYOUTS. + * Use this instead of the deprecated DotcmsEventsService. + */ + portletLayoutUpdated$: (): Observable => + eventsSocket.on('UPDATE_PORTLET_LAYOUTS'), + + /** + * Observable that emits whenever a site is created, published, + * archived, unarchived, or updated. Use this to refresh site lists. + */ + siteEvents$: (): Observable => + merge( + eventsSocket.on('SAVE_SITE'), + eventsSocket.on('PUBLISH_SITE'), + eventsSocket.on('UN_PUBLISH_SITE'), + eventsSocket.on('UPDATE_SITE'), + eventsSocket.on('ARCHIVE_SITE'), + eventsSocket.on('UN_ARCHIVE_SITE'), + eventsSocket.on('DELETE_SITE') + ), + + /** + * Observable that emits the new site when another user/tab switches + * the current site (SWITCH_SITE event). The payload contains the full + * DotSite object — no extra HTTP call needed. + */ + switchSiteEvent$: (): Observable => eventsSocket.on('SWITCH_SITE') + }) + ), + withHooks({ + onInit(store) { + store.startConnection(); + store.trackStatus(); + store.feedLegacyEventBus(); + }, + onDestroy(store) { + store.destroySocket(); + } + }) + ); +} diff --git a/core-web/libs/global-store/src/lib/store.spec.ts b/core-web/libs/global-store/src/lib/store.spec.ts index df5854b15168..f7f6754421b1 100644 --- a/core-web/libs/global-store/src/lib/store.spec.ts +++ b/core-web/libs/global-store/src/lib/store.spec.ts @@ -1,7 +1,13 @@ import { createServiceFactory, mockProvider, SpectatorService } from '@ngneat/spectator/jest'; -import { of } from 'rxjs'; +import { Subject, of } from 'rxjs'; -import { DotCurrentUserService, DotSiteService, DotSystemConfigService } from '@dotcms/data-access'; +import { + DotCurrentUserService, + DotEventsSocket, + DotSiteService, + DotSystemConfigService +} from '@dotcms/data-access'; +import { DotSite } from '@dotcms/dotcms-models'; import { GlobalStore } from './store'; import { mockSiteEntity } from './store.mock'; @@ -9,17 +15,33 @@ import { mockSiteEntity } from './store.mock'; describe('GlobalStore', () => { let spectator: SpectatorService>; let store: InstanceType; + let switchSiteSubject: Subject; const createService = createServiceFactory({ service: GlobalStore, providers: [ mockProvider(DotCurrentUserService), - mockProvider(DotSiteService), - mockProvider(DotSystemConfigService) + mockProvider(DotSiteService, { + getCurrentSite: jest.fn().mockReturnValue(of(null)), + switchSite: jest.fn().mockReturnValue(of({} as DotSite)) + }), + mockProvider(DotSystemConfigService), + mockProvider(DotEventsSocket, { + connect: () => of({}), + status$: () => new Subject(), + on: jest.fn().mockImplementation((event: string) => { + if (event === 'SWITCH_SITE') return switchSiteSubject.asObservable(); + + return new Subject(); + }) + }) ] }); beforeEach(() => { + // switchSiteSubject is assigned before createService() so the on() closure + // captures the correct subject by the time onInit subscribes to SWITCH_SITE. + switchSiteSubject = new Subject(); spectator = createService(); store = spectator.service; }); @@ -31,25 +53,39 @@ describe('GlobalStore', () => { }); }); - describe('Site Management', () => { - it('should call DotSiteService.getCurrentSite when loadCurrentSite is invoked', () => { - const mockService = spectator.inject(DotSiteService); - mockService.getCurrentSite.mockReturnValue(of(mockSiteEntity)); + describe('loadCurrentSite()', () => { + it('should call DotSiteService.getCurrentSite and update state', () => { + const siteService = spectator.inject(DotSiteService); + siteService.getCurrentSite.mockReturnValue(of(mockSiteEntity)); store.loadCurrentSite(); - expect(mockService.getCurrentSite).toHaveBeenCalled(); + expect(siteService.getCurrentSite).toHaveBeenCalled(); + expect(store.siteDetails()).toEqual(mockSiteEntity); + expect(store.currentSiteId()).toBe(mockSiteEntity.identifier); }); + }); - it('should properly update store state through setCurrentSite (verifies rxMethod target behavior)', () => { - // Initially store should be empty - expect(store.siteDetails()).toBeNull(); - expect(store.currentSiteId()).toBeNull(); + describe('switchCurrentSite()', () => { + it('should call switchSite then getCurrentSite and update siteDetails', () => { + const siteService = spectator.inject(DotSiteService); + const newSite: DotSite = { ...mockSiteEntity, identifier: 'new-site' }; + siteService.switchSite.mockReturnValue(of({} as DotSite)); + siteService.getCurrentSite.mockReturnValue(of(newSite)); - store.setCurrentSite(mockSiteEntity); + store.switchCurrentSite('new-site'); + + expect(siteService.switchSite).toHaveBeenCalledWith('new-site'); + expect(siteService.getCurrentSite).toHaveBeenCalled(); + expect(store.siteDetails()).toEqual(newSite); + }); + }); + + describe('SWITCH_SITE WebSocket event', () => { + it('should update siteDetails when SWITCH_SITE event fires', () => { + switchSiteSubject.next(mockSiteEntity); expect(store.siteDetails()).toEqual(mockSiteEntity); - expect(store.currentSiteId()).toBe(mockSiteEntity.identifier); }); }); }); diff --git a/core-web/libs/global-store/src/lib/store.ts b/core-web/libs/global-store/src/lib/store.ts index f99f96b03713..24cf47855d78 100644 --- a/core-web/libs/global-store/src/lib/store.ts +++ b/core-web/libs/global-store/src/lib/store.ts @@ -13,7 +13,7 @@ import { pipe } from 'rxjs'; import { computed, inject } from '@angular/core'; -import { switchMap } from 'rxjs/operators'; +import { switchMap, tap } from 'rxjs/operators'; import { DotSiteService } from '@dotcms/data-access'; import { DotSite } from '@dotcms/dotcms-models'; @@ -22,6 +22,7 @@ import { withBreadcrumbs } from './features/breadcrumb/breadcrumb.feature'; import { withMenu } from './features/menu/with-menu.feature'; import { withSystem } from './features/with-system/with-system.feature'; import { withUser } from './features/with-user/with-user.feature'; +import { withWebSocket } from './features/with-websocket/with-websocket.feature'; /** * Represents the global application state. @@ -85,6 +86,7 @@ export const GlobalStore = signalStore( { providedIn: 'root' }, withState(initialState), withSystem(), + withWebSocket(), withComputed(({ siteDetails }) => ({ /** * Computed signal that returns the current site identifier. @@ -126,9 +128,7 @@ export const GlobalStore = signalStore( siteService.getCurrentSite().pipe( tapResponse({ next: (siteDetails) => { - patchState(store, { - siteDetails - }); + patchState(store, { siteDetails }); }, error: (error) => { // TODO: Define a better error handling strategy for global store @@ -141,36 +141,44 @@ export const GlobalStore = signalStore( ), /** - * Sets the current site in the global store. - * - * This method updates the siteDetails property in the global state - * with the provided DotSite. - * - * @param site - The DotSite to set as the current site + * Switches the active site and updates the global store. * + * Calls DotSiteService.switchSite(), then fetches the now-current site + * and stores it. Use this when the user explicitly picks a site in the UI. */ - setCurrentSite: (site: DotSite) => { - patchState(store, { - siteDetails: site - }); - } + switchCurrentSite: rxMethod( + pipe( + switchMap((identifier) => + siteService.switchSite(identifier).pipe( + switchMap(() => siteService.getCurrentSite()), + tap((siteDetails) => patchState(store, { siteDetails })) + ) + ) + ) + ), + + /** + * Keeps siteDetails in sync when another user/tab switches the site. + * Lifetime is tied to the store via rxMethod — no manual teardown needed. + */ + syncSiteOnSwitchEvent: rxMethod( + pipe( + switchMap(() => + store + .switchSiteEvent$() + .pipe(tap((site) => patchState(store, { siteDetails: site }))) + ) + ) + ) }; }), withUser(), withMenu(), withFeature(({ menuItemsEntities }) => withBreadcrumbs(menuItemsEntities)), withHooks({ - /** - * Automatically loads the current site when the store is initialized. - * - * The system configuration is automatically loaded by the withSystem feature. - * This ensures the currentSiteId is available immediately after injecting - * the store in any component. - */ onInit(store) { - // Load current site on store initialization - // System configuration is automatically loaded by withSystem feature store.loadCurrentSite(); + store.syncSiteOnSwitchEvent(); } }) ); diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/dot-experiments-list.component.spec.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/dot-experiments-list.component.spec.ts index 9936d6e85c19..4c52897e1d92 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/dot-experiments-list.component.spec.ts +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/dot-experiments-list.component.spec.ts @@ -17,7 +17,6 @@ import { } from '@dotcms/data-access'; import { DotPushPublishDialogService, - DotcmsEventsService, LoginService, DotcmsConfigService, LoggerService @@ -68,7 +67,6 @@ describe('DotExperimentsListComponent', () => { DotHttpErrorManagerService, mockProvider(DotExperimentsService), mockProvider(DotPushPublishDialogService), - mockProvider(DotcmsEventsService), mockProvider(LoginService), mockProvider(LoggerService), mockProvider(DotFormatDateService), diff --git a/core-web/libs/portlets/dot-plugins/src/lib/dot-plugins-list/dot-plugins-list.component.spec.ts b/core-web/libs/portlets/dot-plugins/src/lib/dot-plugins-list/dot-plugins-list.component.spec.ts index 3ae30a29e201..e7ebc1b74a2c 100644 --- a/core-web/libs/portlets/dot-plugins/src/lib/dot-plugins-list/dot-plugins-list.component.spec.ts +++ b/core-web/libs/portlets/dot-plugins/src/lib/dot-plugins-list/dot-plugins-list.component.spec.ts @@ -8,12 +8,13 @@ import { DialogService } from 'primeng/dynamicdialog'; import { BUNDLE_STATE, + DotEventsSocket, DotHttpErrorManagerService, DotMessageDisplayService, DotMessageService, DotOsgiService } from '@dotcms/data-access'; -import { DotcmsEventsService, DotPushPublishDialogService } from '@dotcms/dotcms-js'; +import { DotPushPublishDialogService } from '@dotcms/dotcms-js'; import { DotEnvironment } from '@dotcms/dotcms-models'; import { DotPluginsListComponent } from './dot-plugins-list.component'; @@ -46,7 +47,7 @@ describe('DotPluginsListComponent', () => { mockProvider(DotHttpErrorManagerService), ConfirmationService, mockProvider(DotMessageDisplayService, { push: jest.fn() }), - mockProvider(DotcmsEventsService, { subscribeTo: jest.fn().mockReturnValue(EMPTY) }), + mockProvider(DotEventsSocket, { on: jest.fn().mockReturnValue(EMPTY) }), mockProvider(ActivatedRoute, { snapshot: { data: { pushPublishEnvironments: [], isEnterprise: false } } }), diff --git a/core-web/libs/portlets/dot-plugins/src/lib/dot-plugins-list/store/dot-plugins-list.store.spec.ts b/core-web/libs/portlets/dot-plugins/src/lib/dot-plugins-list/store/dot-plugins-list.store.spec.ts index 93d7c355fb12..074e5645a8d0 100644 --- a/core-web/libs/portlets/dot-plugins/src/lib/dot-plugins-list/store/dot-plugins-list.store.spec.ts +++ b/core-web/libs/portlets/dot-plugins/src/lib/dot-plugins-list/store/dot-plugins-list.store.spec.ts @@ -5,12 +5,12 @@ import { fakeAsync, tick } from '@angular/core/testing'; import { BundleMap, + DotEventsSocket, DotHttpErrorManagerService, DotMessageDisplayService, DotMessageService, DotOsgiService } from '@dotcms/data-access'; -import { DotcmsEventsService } from '@dotcms/dotcms-js'; import { DotCMSAPIResponse, DotMessageSeverity } from '@dotcms/dotcms-models'; import { DotPluginsListStore } from './dot-plugins-list.store'; @@ -96,8 +96,8 @@ describe('DotPluginsListStore', () => { mockProvider(DotHttpErrorManagerService, { handle: jest.fn() }), mockProvider(DotMessageDisplayService, { push: jest.fn() }), mockProvider(DotMessageService, { get: (key: string) => key }), - mockProvider(DotcmsEventsService, { - subscribeTo: jest.fn().mockImplementation((event: string) => { + mockProvider(DotEventsSocket, { + on: jest.fn().mockImplementation((event: string) => { if (event === 'OSGI_FRAMEWORK_RESTART') return osgiFrameworkRestartSubject.asObservable(); if (event === 'OSGI_BUNDLES_LOADED') diff --git a/core-web/libs/portlets/dot-plugins/src/lib/dot-plugins-list/store/dot-plugins-list.store.ts b/core-web/libs/portlets/dot-plugins/src/lib/dot-plugins-list/store/dot-plugins-list.store.ts index 7b189589c3af..3dd4a447873d 100644 --- a/core-web/libs/portlets/dot-plugins/src/lib/dot-plugins-list/store/dot-plugins-list.store.ts +++ b/core-web/libs/portlets/dot-plugins/src/lib/dot-plugins-list/store/dot-plugins-list.store.ts @@ -15,13 +15,13 @@ import { catchError, debounceTime, delay, take } from 'rxjs/operators'; import { BundleMap, + DotEventsSocket, DotHttpErrorManagerService, DotMessageDisplayService, DotMessageService, DotOsgiService, PluginRow } from '@dotcms/data-access'; -import { DotcmsEventsService } from '@dotcms/dotcms-js'; import { DotEnvironment, DotMessageSeverity, DotMessageType } from '@dotcms/dotcms-models'; /** Delay after OSGi mutating calls before reload; matches backend / websocket timing for bundle state to settle. */ @@ -268,20 +268,20 @@ export const DotPluginsListStore = signalStore( withHooks((store) => ({ /** Initial full load; listens for OSGi websocket events to update status and refresh data. */ onInit() { - const dotcmsEventsService = inject(DotcmsEventsService); + const eventsSocket = inject(DotEventsSocket); const dotMessageDisplayService = inject(DotMessageDisplayService); const dotMessageService = inject(DotMessageService); const destroyRef = inject(DestroyRef); store.loadAll(undefined, true); - dotcmsEventsService - .subscribeTo('OSGI_FRAMEWORK_RESTART') + eventsSocket + .on('OSGI_FRAMEWORK_RESTART') .pipe(takeUntilDestroyed(destroyRef)) .subscribe(() => patchState(store, { status: 'restarting' })); - dotcmsEventsService - .subscribeTo('OSGI_BUNDLES_LOADED') + eventsSocket + .on('OSGI_BUNDLES_LOADED') .pipe(debounceTime(OSGI_ACTION_DELAY_MS), takeUntilDestroyed(destroyRef)) .subscribe(() => store.loadAll( @@ -291,8 +291,8 @@ export const DotPluginsListStore = signalStore( ) ); - dotcmsEventsService - .subscribeTo('OSGI_BUNDLES_UPLOAD_FAILED') + eventsSocket + .on('OSGI_BUNDLES_UPLOAD_FAILED') .pipe(takeUntilDestroyed(destroyRef)) .subscribe(() => { patchState(store, { status: 'loaded' }); diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/components/dot-ema-dialog/dot-ema-dialog.component.spec.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/components/dot-ema-dialog/dot-ema-dialog.component.spec.ts index b3253467116a..d2a1d2b79ff9 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/components/dot-ema-dialog/dot-ema-dialog.component.spec.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/components/dot-ema-dialog/dot-ema-dialog.component.spec.ts @@ -27,15 +27,11 @@ import { DotWorkflowActionsFireService, PushPublishService } from '@dotcms/data-access'; -import { DotcmsConfigService, DotcmsEventsService } from '@dotcms/dotcms-js'; +import { DotcmsConfigService } from '@dotcms/dotcms-js'; import { DotCMSBaseTypesContentTypes } from '@dotcms/dotcms-models'; import { DotContentCompareComponent } from '@dotcms/portlets/dot-ema/ui'; import { DotCMSPage, DotCMSURLContentMap, DotCMSUVEAction } from '@dotcms/types'; -import { - DotcmsConfigServiceMock, - DotcmsEventsServiceMock, - MockDotMessageService -} from '@dotcms/utils-testing'; +import { DotcmsConfigServiceMock, MockDotMessageService } from '@dotcms/utils-testing'; import { DotEmaDialogComponent } from './dot-ema-dialog.component'; import { DotEmaDialogStore } from './store/dot-ema-dialog.store'; @@ -99,10 +95,6 @@ describe('DotEmaDialogComponent', () => { provide: DotcmsConfigService, useValue: new DotcmsConfigServiceMock() }, - { - provide: DotcmsEventsService, - useValue: new DotcmsEventsServiceMock() - }, { provide: PushPublishService, useValue: { diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/dot-ema-shell/dot-ema-shell.component.spec.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/dot-ema-shell/dot-ema-shell.component.spec.ts index d59e21335969..2e0bee22a565 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/dot-ema-shell/dot-ema-shell.component.spec.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/dot-ema-shell/dot-ema-shell.component.spec.ts @@ -36,13 +36,7 @@ import { DotWorkflowsActionsService, PushPublishService } from '@dotcms/data-access'; -import { - DotcmsConfigService, - DotcmsEventsService, - LoginService, - Site, - SiteService -} from '@dotcms/dotcms-js'; +import { DotcmsConfigService, LoginService, Site, SiteService } from '@dotcms/dotcms-js'; import { FeaturedFlags } from '@dotcms/dotcms-models'; import { DotPageScannerReportComponent, @@ -57,7 +51,6 @@ import { DotCurrentUserServiceMock, DotLanguagesServiceMock, DotcmsConfigServiceMock, - DotcmsEventsServiceMock, SiteServiceMock } from '@dotcms/utils-testing'; @@ -274,10 +267,6 @@ describe('DotEmaShellComponent', () => { provide: DotcmsConfigService, useValue: new DotcmsConfigServiceMock() }, - { - provide: DotcmsEventsService, - useValue: new DotcmsEventsServiceMock() - }, { provide: PushPublishService, useValue: { diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.spec.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.spec.ts index fc45ea633722..18aed44ae449 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.spec.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.spec.ts @@ -53,7 +53,7 @@ import { DotWorkflowsActionsService, PushPublishService } from '@dotcms/data-access'; -import { DotcmsConfigService, DotcmsEventsService, LoginService } from '@dotcms/dotcms-js'; +import { DotcmsConfigService, LoginService } from '@dotcms/dotcms-js'; import { DEFAULT_VARIANT_ID, FeaturedFlags } from '@dotcms/dotcms-models'; import { DotResultsSeoToolComponent } from '@dotcms/portlets/dot-ema/ui'; import { GlobalStore } from '@dotcms/store'; @@ -68,7 +68,6 @@ import { DotLanguagesServiceMock, DotPersonalizeServiceMock, DotcmsConfigServiceMock, - DotcmsEventsServiceMock, LoginServiceMock, MockDotHttpErrorManagerService, MockDotMessageService, @@ -349,10 +348,6 @@ const createRouting = () => provide: DotcmsConfigService, useValue: new DotcmsConfigServiceMock() }, - { - provide: DotcmsEventsService, - useValue: new DotcmsEventsServiceMock() - }, { provide: PushPublishService, useValue: { diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-ema-workflow-actions/dot-ema-workflow-actions.service.spec.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-ema-workflow-actions/dot-ema-workflow-actions.service.spec.ts index c4699fd084ef..d2dbf1df7f41 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-ema-workflow-actions/dot-ema-workflow-actions.service.spec.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-ema-workflow-actions/dot-ema-workflow-actions.service.spec.ts @@ -12,7 +12,7 @@ import { DotMessageService, DotFormatDateService } from '@dotcms/data-access'; -import { DotEventsSocketURL, LoginService } from '@dotcms/dotcms-js'; +import { LoginService } from '@dotcms/dotcms-js'; import { DotCMSWorkflowActionEvent, DotWizardStep, @@ -33,13 +33,6 @@ import { import { DotEmaWorkflowActionsService } from './dot-ema-workflow-actions.service'; -const dotEventSocketURLFactory = () => { - return new DotEventsSocketURL( - `${window.location.hostname}:${window.location.port}/api/ws/v1/system/events`, - window.location.protocol === 'https:' - ); -}; - @Injectable() export class MockPushPublishService { getEnvironments() { @@ -143,7 +136,6 @@ describe('DotEmaWorkflowActionsService', () => { provide: LoginService, useClass: LoginServiceMock }, - { provide: DotEventsSocketURL, useFactory: dotEventSocketURLFactory }, { provide: DotFormatDateService, useClass: DotFormatDateServiceMock } ] }); diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-actions/template-builder-actions.component.spec.ts b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-actions/template-builder-actions.component.spec.ts index ea9abf2e4f56..b8173de7590c 100644 --- a/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-actions/template-builder-actions.component.spec.ts +++ b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-actions/template-builder-actions.component.spec.ts @@ -8,10 +8,9 @@ import { DotMessageService, DotSystemConfigService } from '@dotcms/data-access'; -import { DotcmsEventsService } from '@dotcms/dotcms-js'; import { GlobalStore } from '@dotcms/store'; import { DotMessagePipe } from '@dotcms/ui'; -import { DotCurrentUserServiceMock, DotcmsEventsServiceMock } from '@dotcms/utils-testing'; +import { DotCurrentUserServiceMock } from '@dotcms/utils-testing'; import { TemplateBuilderActionsComponent } from './template-builder-actions.component'; @@ -42,10 +41,6 @@ describe('TemplateBuilderActionsComponent', () => { provide: GlobalStore, useValue: mockGlobalStore }, - { - provide: DotcmsEventsService, - useClass: DotcmsEventsServiceMock - }, DotTemplateBuilderStore ], imports: [HttpClientTestingModule, DotMessagePipe] diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.spec.ts b/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.spec.ts index 78572a21cc82..7e854c23e914 100644 --- a/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.spec.ts +++ b/core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.spec.ts @@ -17,11 +17,10 @@ import { DotMessageService, DotSystemConfigService } from '@dotcms/data-access'; -import { DotcmsEventsService, LoginService, SiteService } from '@dotcms/dotcms-js'; +import { LoginService, SiteService } from '@dotcms/dotcms-js'; import { GlobalStore } from '@dotcms/store'; import { containersMock, - DotcmsEventsServiceMock, DotContainersServiceMock, DotCurrentUserServiceMock, LoginServiceMock, @@ -101,10 +100,6 @@ describe('TemplateBuilderComponent', () => { provide: GlobalStore, useValue: { currentSiteId: () => null } }, - { - provide: DotcmsEventsService, - useClass: DotcmsEventsServiceMock - }, DotEventsService ] }); diff --git a/core-web/libs/ui/src/lib/components/dot-site/dot-site.component.spec.ts b/core-web/libs/ui/src/lib/components/dot-site/dot-site.component.spec.ts index e7855e5be3d3..65fa7c30d4b0 100644 --- a/core-web/libs/ui/src/lib/components/dot-site/dot-site.component.spec.ts +++ b/core-web/libs/ui/src/lib/components/dot-site/dot-site.component.spec.ts @@ -7,18 +7,17 @@ import { SpyObject } from '@ngneat/spectator/jest'; import { patchState } from '@ngrx/signals'; -import { EMPTY, of, throwError } from 'rxjs'; +import { of, Subject, throwError } from 'rxjs'; import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { Component } from '@angular/core'; -import { fakeAsync, tick, flush } from '@angular/core/testing'; +import { fakeAsync, flush, tick } from '@angular/core/testing'; import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { Select, SelectLazyLoadEvent } from 'primeng/select'; -import { DotSiteService } from '@dotcms/data-access'; -import { DotcmsEventsService } from '@dotcms/dotcms-js'; +import { DotEventsSocket, DotSiteService } from '@dotcms/data-access'; import { DotPagination, DotSite } from '@dotcms/dotcms-models'; import { DotSiteComponent } from './dot-site.component'; @@ -68,9 +67,7 @@ describe('DotSiteComponent', () => { imports: [ReactiveFormsModule], providers: [ mockProvider(DotSiteService), - mockProvider(DotcmsEventsService, { - subscribeToEvents: jest.fn().mockReturnValue(EMPTY) - }), + mockProvider(DotEventsSocket, { on: jest.fn().mockReturnValue(new Subject()) }), provideHttpClient(), provideHttpClientTesting() ] @@ -1062,6 +1059,146 @@ describe('DotSiteComponent', () => { expect(options.find((s) => s.identifier === 'site99')).toBeTruthy(); })); }); + + describe('WebSocket site events', () => { + let eventsSocket: SpyObject; + let siteEventSubjects: Record>; + + beforeEach(() => { + eventsSocket = spectator.inject(DotEventsSocket, true); + siteEventSubjects = {}; + + eventsSocket.on.mockImplementation((eventType: string) => { + siteEventSubjects[eventType] = new Subject<{ identifier: string }>(); + return siteEventSubjects[eventType].asObservable() as unknown as ReturnType< + typeof eventsSocket.on + >; + }); + + spectator.detectChanges(); + }); + + it('should call resetFilter after debounce when any site event fires', fakeAsync(() => { + jest.clearAllMocks(); + siteEventSubjects['PUBLISH_SITE'].next({ identifier: 'site1' }); + tick(299); + expect(siteService.getSites).not.toHaveBeenCalled(); + + tick(1); + expect(siteService.getSites).toHaveBeenCalled(); + })); + + it('should debounce multiple rapid events into a single resetFilter call', fakeAsync(() => { + jest.clearAllMocks(); + siteEventSubjects['SAVE_SITE'].next({ identifier: 'site1' }); + siteEventSubjects['UPDATE_SITE'].next({ identifier: 'site2' }); + siteEventSubjects['PUBLISH_SITE'].next({ identifier: 'site3' }); + tick(300); + + expect(siteService.getSites).toHaveBeenCalledTimes(1); + })); + + it('should re-fetch the selected site when a non-unavailable event fires for it', fakeAsync(() => { + patchState(spectator.component.$state, { pinnedOption: mockSites[0] }); + siteService.getSiteById.mockReturnValue(of(mockSites[0])); + jest.clearAllMocks(); + + siteEventSubjects['UPDATE_SITE'].next({ identifier: 'site1' }); + + expect(siteService.getSiteById).toHaveBeenCalledWith('site1'); + tick(300); + })); + + it('should NOT re-fetch when the event is for a different site', fakeAsync(() => { + patchState(spectator.component.$state, { pinnedOption: mockSites[0] }); + jest.clearAllMocks(); + + siteEventSubjects['UPDATE_SITE'].next({ identifier: 'site2' }); + tick(300); + + expect(siteService.getSiteById).not.toHaveBeenCalled(); + })); + + it('should switch to default site when ARCHIVE_SITE fires for the selected site', fakeAsync(() => { + const defaultSite: DotSite = { + hostname: 'default.com', + identifier: 'default', + archived: false, + aliases: null + }; + patchState(spectator.component.$state, { pinnedOption: mockSites[0] }); + siteService.switchSite.mockReturnValue(of(defaultSite as DotSite)); + jest.clearAllMocks(); + + siteEventSubjects['ARCHIVE_SITE'].next({ identifier: 'site1' }); + tick(300); + + expect(siteService.switchSite).toHaveBeenCalledWith(null); + expect(spectator.component.$state.pinnedOption()).toEqual(defaultSite); + })); + + it('should switch to default site when UN_PUBLISH_SITE fires for the selected site', fakeAsync(() => { + const defaultSite: DotSite = { + hostname: 'default.com', + identifier: 'default', + archived: false, + aliases: null + }; + patchState(spectator.component.$state, { pinnedOption: mockSites[0] }); + siteService.switchSite.mockReturnValue(of(defaultSite as DotSite)); + + siteEventSubjects['UN_PUBLISH_SITE'].next({ identifier: 'site1' }); + tick(300); + + expect(siteService.switchSite).toHaveBeenCalledWith(null); + expect(spectator.component.value()).toBe('default'); + })); + + it('should switch to default site when DELETE_SITE fires for the selected site', fakeAsync(() => { + const defaultSite: DotSite = { + hostname: 'default.com', + identifier: 'default', + archived: false, + aliases: null + }; + patchState(spectator.component.$state, { pinnedOption: mockSites[0] }); + siteService.switchSite.mockReturnValue(of(defaultSite as DotSite)); + + siteEventSubjects['DELETE_SITE'].next({ identifier: 'site1' }); + tick(300); + + expect(siteService.switchSite).toHaveBeenCalledWith(null); + })); + + it('should set pinnedOption to null when switchSite fails', fakeAsync(() => { + patchState(spectator.component.$state, { pinnedOption: mockSites[0] }); + siteService.switchSite.mockReturnValueOnce( + throwError(() => new Error('switchSite failed')) + ); + + siteEventSubjects['ARCHIVE_SITE'].next({ identifier: 'site1' }); + tick(300); + + expect(spectator.component.$state.pinnedOption()).toBeNull(); + })); + + it('should set pinnedOption to null when getSiteById fails on re-fetch', fakeAsync(() => { + patchState(spectator.component.$state, { pinnedOption: mockSites[0] }); + siteService.getSiteById.mockReturnValue(throwError(() => new Error('not found'))); + + siteEventSubjects['UPDATE_SITE'].next({ identifier: 'site1' }); + tick(300); + + expect(spectator.component.$state.pinnedOption()).toBeNull(); + })); + + it('should unsubscribe from site events on destroy', () => { + const sub = spectator.component['siteEventsSub']; + const unsubscribeSpy = jest.spyOn(sub, 'unsubscribe'); + spectator.component.ngOnDestroy(); + expect(unsubscribeSpy).toHaveBeenCalled(); + }); + }); }); describe('DotSiteComponent - ControlValueAccessor Integration', () => { @@ -1071,9 +1208,7 @@ describe('DotSiteComponent - ControlValueAccessor Integration', () => { imports: [ReactiveFormsModule], providers: [ mockProvider(DotSiteService), - mockProvider(DotcmsEventsService, { - subscribeToEvents: jest.fn().mockReturnValue(EMPTY) - }), + mockProvider(DotEventsSocket, { on: jest.fn().mockReturnValue(new Subject()) }), provideHttpClient(), provideHttpClientTesting() ], diff --git a/core-web/libs/ui/src/lib/components/dot-site/dot-site.component.ts b/core-web/libs/ui/src/lib/components/dot-site/dot-site.component.ts index 7fcb3c10927b..09ea5b331a2e 100644 --- a/core-web/libs/ui/src/lib/components/dot-site/dot-site.component.ts +++ b/core-web/libs/ui/src/lib/components/dot-site/dot-site.component.ts @@ -1,33 +1,33 @@ -import { signalState, patchState } from '@ngrx/signals'; +import { patchState, signalState } from '@ngrx/signals'; +import { merge, Subscription } from 'rxjs'; import { ChangeDetectionStrategy, Component, - DestroyRef, + computed, + effect, + forwardRef, + HostListener, + inject, + input, model, + OnDestroy, + OnInit, output, - input, - inject, signal, - effect, untracked, - forwardRef, - computed, - OnInit, - OnDestroy, - ViewChild, - HostListener + ViewChild } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms'; +import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms'; import { IconFieldModule } from 'primeng/iconfield'; import { InputIconModule } from 'primeng/inputicon'; import { InputTextModule } from 'primeng/inputtext'; -import { SelectLazyLoadEvent, SelectModule, Select } from 'primeng/select'; +import { Select, SelectLazyLoadEvent, SelectModule } from 'primeng/select'; + +import { debounceTime, map, tap } from 'rxjs/operators'; -import { DotSiteService } from '@dotcms/data-access'; -import { DotcmsEventsService } from '@dotcms/dotcms-js'; +import { DotEventsSocket, DotSiteService } from '@dotcms/data-access'; import { DotSite } from '@dotcms/dotcms-models'; interface ParsedSelectLazyLoadEvent extends SelectLazyLoadEvent { @@ -67,6 +67,9 @@ interface DotSiteState { filterValue: string; } +/** Events that mean the site is no longer accessible — switch to default when selected. */ +const SITE_UNAVAILABLE_EVENTS = new Set(['ARCHIVE_SITE', 'UN_PUBLISH_SITE', 'DELETE_SITE']); + @Component({ selector: 'dot-site', imports: [FormsModule, SelectModule, IconFieldModule, InputIconModule, InputTextModule], @@ -89,8 +92,8 @@ interface DotSiteState { }) export class DotSiteComponent implements ControlValueAccessor, OnInit, OnDestroy { private siteService = inject(DotSiteService); - readonly #eventsService = inject(DotcmsEventsService); - readonly #destroyRef = inject(DestroyRef); + private eventsSocket = inject(DotEventsSocket); + private siteEventsSub: Subscription | null = null; @HostListener('focus') onHostFocus(): void { @@ -299,24 +302,27 @@ export class DotSiteComponent implements ControlValueAccessor, OnInit, OnDestroy this.onLazyLoad({ first: 0, last: this.pageSize - 1 }); } - this.#eventsService - .subscribeToEvents([ - 'SAVE_SITE', - 'PUBLISH_SITE', - 'UN_ARCHIVE_SITE', - 'UPDATE_SITE', - 'UPDATE_SITE_PERMISSIONS', - 'ARCHIVE_SITE', - 'SWITCH_SITE' - ]) - .pipe(takeUntilDestroyed(this.#destroyRef)) - .subscribe(() => { - untracked(() => { - this.loadedPages.clear(); - patchState(this.$state, { sites: [], totalRecords: 0, filterValue: '' }); - this.onLazyLoad({ first: 0, last: this.pageSize - 1 }); - }); - }); + const tagEvent = (event: string) => + this.eventsSocket + .on<{ identifier: string }>(event) + .pipe(map((data) => ({ ...data, event }))); + + // Each event immediately refreshes the selected site; list reload is debounced + // to coalesce rapid bursts into a single resetFilter() call. + this.siteEventsSub = merge( + tagEvent('SAVE_SITE'), + tagEvent('PUBLISH_SITE'), + tagEvent('UPDATE_SITE'), + tagEvent('ARCHIVE_SITE'), + tagEvent('UN_ARCHIVE_SITE'), + tagEvent('UN_PUBLISH_SITE'), + tagEvent('DELETE_SITE') + ) + .pipe( + tap((siteData) => this.refreshSelectedSite(siteData)), + debounceTime(300) + ) + .subscribe(() => this.resetFilter()); } ngOnDestroy(): void { @@ -324,6 +330,8 @@ export class DotSiteComponent implements ControlValueAccessor, OnInit, OnDestroy clearTimeout(this.filterDebounceTimeout); this.filterDebounceTimeout = null; } + + this.siteEventsSub?.unsubscribe(); } // ControlValueAccessor callback functions @@ -678,4 +686,36 @@ export class DotSiteComponent implements ControlValueAccessor, OnInit, OnDestroy } }); } + + /** + * Refreshes the selected (pinned) site when a site event fires. + * - Unavailable events (archive, stop, delete): switches to the default site. + * - Other events: re-fetches the site to reflect any name/property changes. + * + * @private + * @param siteData The payload from the site WebSocket event + */ + private refreshSelectedSite( + siteData: { identifier: string; event?: string } | undefined + ): void { + const pinned = this.$state.pinnedOption(); + if (!pinned || siteData?.identifier !== pinned.identifier) { + return; + } + + if (SITE_UNAVAILABLE_EVENTS.has(siteData.event ?? '')) { + this.siteService.switchSite(null).subscribe({ + next: (defaultSite) => this.onSiteChange(defaultSite), + // Fall back to clearing the selection via the CVA path so the + // parent form sees the null value (not just a nulled pinnedOption). + error: () => this.onSiteChange(null) + }); + return; + } + + this.siteService.getSiteById(pinned.identifier).subscribe({ + next: (site) => patchState(this.$state, { pinnedOption: site }), + error: () => this.onSiteChange(null) + }); + } } diff --git a/core-web/libs/ui/src/lib/components/dot-theme/dot-theme.component.spec.ts b/core-web/libs/ui/src/lib/components/dot-theme/dot-theme.component.spec.ts index 557657581ecf..3bc2d59716ba 100644 --- a/core-web/libs/ui/src/lib/components/dot-theme/dot-theme.component.spec.ts +++ b/core-web/libs/ui/src/lib/components/dot-theme/dot-theme.component.spec.ts @@ -6,7 +6,7 @@ import { SpectatorHost, SpyObject } from '@ngneat/spectator/jest'; -import { EMPTY, of } from 'rxjs'; +import { of, Subject } from 'rxjs'; import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; @@ -17,8 +17,7 @@ import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { Button } from 'primeng/button'; import { DataView } from 'primeng/dataview'; -import { DotThemesService } from '@dotcms/data-access'; -import { DotcmsEventsService } from '@dotcms/dotcms-js'; +import { DotEventsSocket, DotThemesService } from '@dotcms/data-access'; import { DotPagination, DotTheme } from '@dotcms/dotcms-models'; import { GlobalStore } from '@dotcms/store'; @@ -89,10 +88,7 @@ describe('DotThemeComponent', () => { imports: [ReactiveFormsModule], providers: [ mockProvider(DotThemesService), - { - provide: DotcmsEventsService, - useValue: { subscribeToEvents: jest.fn().mockReturnValue(EMPTY) } - }, + mockProvider(DotEventsSocket, { on: jest.fn().mockReturnValue(new Subject()) }), provideHttpClient(), provideHttpClientTesting(), { @@ -354,10 +350,7 @@ describe('DotThemeComponent - ControlValueAccessor writeValue', () => { imports: [ReactiveFormsModule], providers: [ mockProvider(DotThemesService), - { - provide: DotcmsEventsService, - useValue: { subscribeToEvents: jest.fn().mockReturnValue(EMPTY) } - }, + mockProvider(DotEventsSocket, { on: jest.fn().mockReturnValue(new Subject()) }), provideHttpClient(), provideHttpClientTesting(), { @@ -465,10 +458,7 @@ describe('DotThemeComponent - ControlValueAccessor Integration', () => { imports: [ReactiveFormsModule], providers: [ mockProvider(DotThemesService), - { - provide: DotcmsEventsService, - useValue: { subscribeToEvents: jest.fn().mockReturnValue(EMPTY) } - }, + mockProvider(DotEventsSocket, { on: jest.fn().mockReturnValue(new Subject()) }), provideHttpClient(), provideHttpClientTesting(), { diff --git a/core-web/libs/utils-testing/src/index.ts b/core-web/libs/utils-testing/src/index.ts index c0cca0108bbf..177c443469ec 100644 --- a/core-web/libs/utils-testing/src/index.ts +++ b/core-web/libs/utils-testing/src/index.ts @@ -36,7 +36,6 @@ export * from './lib/dot-workflow-service.mock'; export * from './lib/dot-workflows-actions.mock'; export * from './lib/dotcms-config.service.mock'; export * from './lib/dotcms-contentlet.mock'; -export * from './lib/dotcms-events-service.mock'; export * from './lib/fake-event.mock'; export * from './lib/field-variable-service.mock'; export * from './lib/format-date-service.mock'; diff --git a/core-web/libs/utils-testing/src/lib/dotcms-events-service.mock.ts b/core-web/libs/utils-testing/src/lib/dotcms-events-service.mock.ts deleted file mode 100644 index 825b818d4236..000000000000 --- a/core-web/libs/utils-testing/src/lib/dotcms-events-service.mock.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Observable, Subject } from 'rxjs'; - -import { DotEventTypeWrapper } from '@dotcms/dotcms-js'; - -export class DotcmsEventsServiceMock { - private observers: Subject[] = []; - - subscribeTo(clientEventType: string): Observable> { - if (!this.observers[clientEventType]) { - this.observers[clientEventType] = new Subject(); - } - - return this.observers[clientEventType].asObservable(); - } - - subscribeToEvents(clientEventTypes: string[]): Observable> { - const subject: Subject> = new Subject< - DotEventTypeWrapper - >(); - - clientEventTypes.forEach((eventType) => - this.subscribeTo(eventType).subscribe((data) => subject.next(data)) - ); - - return subject.asObservable(); - } - - triggerSubscribeTo(clientEventType: string, data: unknown): void { - this.observers[clientEventType].next(data); - } - - triggerSubscribeToEvents(clientEventTypes: string[], data: unknown): void { - clientEventTypes.forEach((eventType) => { - this.observers[eventType].next({ - eventType: eventType, - data: data - }); - }); - } -} diff --git a/dotCMS/src/main/java/com/dotcms/api/system/event/SystemEventType.java b/dotCMS/src/main/java/com/dotcms/api/system/event/SystemEventType.java index 221a4aa4a329..cd520a41314f 100644 --- a/dotCMS/src/main/java/com/dotcms/api/system/event/SystemEventType.java +++ b/dotCMS/src/main/java/com/dotcms/api/system/event/SystemEventType.java @@ -56,13 +56,18 @@ public enum SystemEventType { /** * When a site is published */ - PUBLISH_SITE, // todo: not used + PUBLISH_SITE, /** * When a site is updated */ UPDATE_SITE, // todo: not used + /** + * When a site is unpublished (stopped) + */ + UN_PUBLISH_SITE, + /** * When a site is archived */