diff --git a/core-web/apps/dotcms-ui/src/app/api/services/dot-remote-module-wrapper.component.spec.ts b/core-web/apps/dotcms-ui/src/app/api/services/dot-remote-module-wrapper.component.spec.ts new file mode 100644 index 000000000000..830a41a8fcaa --- /dev/null +++ b/core-web/apps/dotcms-ui/src/app/api/services/dot-remote-module-wrapper.component.spec.ts @@ -0,0 +1,99 @@ +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; + +import { DotRemoteModuleWrapperComponent } from './dot-remote-module-wrapper.component'; + +describe('DotRemoteModuleWrapperComponent', () => { + let fixture: ComponentFixture; + let component: DotRemoteModuleWrapperComponent; + let mockCleanup: jest.Mock; + let mockMount: jest.Mock; + + function createComponent(mountFn?: unknown): void { + TestBed.configureTestingModule({ + imports: [DotRemoteModuleWrapperComponent], + providers: [ + { + provide: ActivatedRoute, + useValue: { + snapshot: { + data: mountFn !== undefined ? { mount: mountFn } : {} + } + } + } + ] + }); + + fixture = TestBed.createComponent(DotRemoteModuleWrapperComponent); + component = fixture.componentInstance; + } + + beforeEach(() => { + mockCleanup = jest.fn(); + mockMount = jest.fn().mockResolvedValue(mockCleanup); + }); + + it('should call mount with the container element', fakeAsync(() => { + createComponent(mockMount); + fixture.detectChanges(); + tick(); + + expect(mockMount).toHaveBeenCalledWith(component.container.nativeElement); + })); + + it('should store and call cleanup on destroy', fakeAsync(() => { + createComponent(mockMount); + fixture.detectChanges(); + tick(); + + component.ngOnDestroy(); + + expect(mockCleanup).toHaveBeenCalled(); + })); + + it('should not throw when mount is not provided in route data', fakeAsync(() => { + createComponent(); + fixture.detectChanges(); + tick(); + + expect(mockMount).not.toHaveBeenCalled(); + expect(() => component.ngOnDestroy()).not.toThrow(); + })); + + it('should handle mount error gracefully', fakeAsync(() => { + const errorMount = jest.fn().mockRejectedValue(new Error('mount failed')); + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + createComponent(errorMount); + fixture.detectChanges(); + tick(); + + expect(consoleSpy).toHaveBeenCalledWith( + '[DotRemoteModuleWrapper] Failed to mount remote module:', + expect.any(Error) + ); + + // cleanup should not have been set + expect(() => component.ngOnDestroy()).not.toThrow(); + consoleSpy.mockRestore(); + })); + + it('should handle cleanup error gracefully', fakeAsync(() => { + const throwingCleanup = jest.fn().mockImplementation(() => { + throw new Error('cleanup failed'); + }); + const errorMount = jest.fn().mockResolvedValue(throwingCleanup); + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + createComponent(errorMount); + fixture.detectChanges(); + tick(); + + expect(() => component.ngOnDestroy()).not.toThrow(); + expect(consoleSpy).toHaveBeenCalledWith( + '[DotRemoteModuleWrapper] Failed to unmount remote module:', + expect.any(Error) + ); + consoleSpy.mockRestore(); + })); +}); diff --git a/core-web/apps/dotcms-ui/src/app/api/services/dot-remote-module-wrapper.component.ts b/core-web/apps/dotcms-ui/src/app/api/services/dot-remote-module-wrapper.component.ts new file mode 100644 index 000000000000..4cac3687413b --- /dev/null +++ b/core-web/apps/dotcms-ui/src/app/api/services/dot-remote-module-wrapper.component.ts @@ -0,0 +1,64 @@ +import { + AfterViewInit, + Component, + ElementRef, + inject, + NgZone, + OnDestroy, + ViewChild +} from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; + +type MountFn = (el: HTMLElement) => Promise<() => void>; + +/** + * Wrapper component that hosts a remote Module Federation module. + * + * Remote modules export a `mount(element)` function that bootstraps + * their own Angular app inside the provided DOM element. This component + * provides that element and manages the mount/unmount lifecycle. + * + * The mount is run outside of the host's NgZone to prevent zone conflicts + * between the host and remote Angular runtimes. + */ +@Component({ + selector: 'dot-remote-module-wrapper', + standalone: true, + template: '
' +}) +export class DotRemoteModuleWrapperComponent implements AfterViewInit, OnDestroy { + @ViewChild('container', { static: true }) container!: ElementRef; + + private readonly route = inject(ActivatedRoute); + private readonly ngZone = inject(NgZone); + private destroyFn?: () => void; + + ngAfterViewInit(): void { + const mountFn = this.route.snapshot.data['mount'] as MountFn | undefined; + + if (mountFn) { + // Run outside the host's NgZone so the remote Angular app + // bootstraps with its own zone and doesn't conflict. + this.ngZone.runOutsideAngular(() => { + mountFn(this.container.nativeElement) + .then((cleanup) => { + this.destroyFn = cleanup; + }) + .catch((err) => { + console.error( + '[DotRemoteModuleWrapper] Failed to mount remote module:', + err + ); + }); + }); + } + } + + ngOnDestroy(): void { + try { + this.destroyFn?.(); + } catch (err) { + console.error('[DotRemoteModuleWrapper] Failed to unmount remote module:', err); + } + } +} diff --git a/core-web/apps/dotcms-ui/src/app/api/services/dynamic-route-initializer.service.spec.ts b/core-web/apps/dotcms-ui/src/app/api/services/dynamic-route-initializer.service.spec.ts new file mode 100644 index 000000000000..8d33263958a7 --- /dev/null +++ b/core-web/apps/dotcms-ui/src/app/api/services/dynamic-route-initializer.service.spec.ts @@ -0,0 +1,114 @@ +import { of, throwError } from 'rxjs'; + +import { TestBed } from '@angular/core/testing'; + +import { LoggerService } from '@dotcms/dotcms-js'; + +import { DotMenuService } from './dot-menu.service'; +import { DynamicRouteInitializerService } from './dynamic-route-initializer.service'; +import { DynamicRouteService } from './dynamic-route.service'; + +describe('DynamicRouteInitializerService', () => { + let service: DynamicRouteInitializerService; + let menuService: DotMenuService; + let dynamicRouteService: DynamicRouteService; + + const mockMenus = [ + { + id: 'menu-1', + name: 'Menu', + tabDescription: '', + tabName: '', + tabOrder: 0, + url: '', + menuItems: [ + { + id: 'portlet-1', + label: 'Portlet', + url: '/portlet-1', + ajax: false, + angular: true, + initParams: { + 'angular-module': 'remote:http://localhost:4201/remoteEntry.js|p|./Routes' + } + } + ] + } + ]; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + DynamicRouteInitializerService, + { + provide: DotMenuService, + useValue: { + loadMenu: jest.fn().mockReturnValue(of(mockMenus)) + } + }, + { + provide: DynamicRouteService, + useValue: { + registerRoutesFromMenuItems: jest.fn().mockReturnValue(1), + getRegisteredRoutes: jest.fn().mockReturnValue(['portlet-1']) + } + }, + { + provide: LoggerService, + useValue: { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn() + } + } + ] + }); + + service = TestBed.inject(DynamicRouteInitializerService); + menuService = TestBed.inject(DotMenuService); + dynamicRouteService = TestBed.inject(DynamicRouteService); + }); + + it('should register routes on first initialization', async () => { + const count = await service.initialize(); + + expect(menuService.loadMenu).toHaveBeenCalledWith(false); + expect(dynamicRouteService.registerRoutesFromMenuItems).toHaveBeenCalledWith( + mockMenus[0].menuItems + ); + expect(count).toBe(1); + expect(service.isInitialized()).toBe(true); + }); + + it('should be a no-op on repeated calls without force', async () => { + await service.initialize(); + jest.clearAllMocks(); + + const count = await service.initialize(); + + expect(menuService.loadMenu).not.toHaveBeenCalled(); + expect(dynamicRouteService.registerRoutesFromMenuItems).not.toHaveBeenCalled(); + expect(count).toBe(0); + }); + + it('should re-initialize when force=true', async () => { + await service.initialize(); + jest.clearAllMocks(); + + (dynamicRouteService.registerRoutesFromMenuItems as jest.Mock).mockReturnValue(2); + const count = await service.initialize(true); + + expect(menuService.loadMenu).toHaveBeenCalledWith(true); + expect(dynamicRouteService.registerRoutesFromMenuItems).toHaveBeenCalled(); + expect(count).toBe(2); + }); + + it('should resolve to 0 on error', async () => { + (menuService.loadMenu as jest.Mock).mockReturnValue(throwError(() => new Error('fail'))); + + const count = await service.initialize(); + + expect(count).toBe(0); + expect(service.isInitialized()).toBe(false); + }); +}); diff --git a/core-web/apps/dotcms-ui/src/app/api/services/dynamic-route-initializer.service.ts b/core-web/apps/dotcms-ui/src/app/api/services/dynamic-route-initializer.service.ts new file mode 100644 index 000000000000..8c312d327d60 --- /dev/null +++ b/core-web/apps/dotcms-ui/src/app/api/services/dynamic-route-initializer.service.ts @@ -0,0 +1,84 @@ +import { inject, Injectable } from '@angular/core'; + +import { filter, take } from 'rxjs/operators'; + +import { LoggerService } from '@dotcms/dotcms-js'; +import { DotMenu } from '@dotcms/dotcms-models'; + +import { DotMenuService } from './dot-menu.service'; +import { DynamicRouteService } from './dynamic-route.service'; + +/** + * Service that initializes dynamic routes from the menu API. + * Call `initialize()` after user authentication to register any + * dynamic Angular portlets defined in the backend. + * + * @example + * // In a component or service after login + * const count = await this.dynamicRouteInitializer.initialize(); + * console.log(`Registered ${count} dynamic routes`); + */ +@Injectable({ providedIn: 'root' }) +export class DynamicRouteInitializerService { + private readonly menuService = inject(DotMenuService); + private readonly dynamicRouteService = inject(DynamicRouteService); + private readonly logger = inject(LoggerService); + + private initialized = false; + + /** + * Initialize dynamic routes from the menu API. + * This should be called once after user authentication. + * + * @param force - Force re-initialization even if already done + * @returns Promise that resolves with the number of routes registered + */ + initialize(force = false): Promise { + if (this.initialized && !force) { + this.logger.info( + this, + 'Dynamic routes already initialized. Use force=true to re-initialize.' + ); + + return Promise.resolve(0); + } + + return new Promise((resolve) => { + this.menuService + .loadMenu(force) + .pipe( + filter((menus): menus is DotMenu[] => !!menus), + take(1) + ) + .subscribe({ + next: (menus) => { + const allMenuItems = menus.flatMap((menu) => menu.menuItems); + const count = + this.dynamicRouteService.registerRoutesFromMenuItems(allMenuItems); + + this.initialized = true; + this.logger.info(this, `Initialized ${count} dynamic routes from menu`); + resolve(count); + }, + error: (err) => { + this.logger.error(this, 'Failed to initialize dynamic routes:', err); + resolve(0); + } + }); + }); + } + + /** + * Check if dynamic routes have been initialized. + */ + isInitialized(): boolean { + return this.initialized; + } + + /** + * Get list of currently registered dynamic routes. + */ + getRegisteredRoutes(): string[] { + return this.dynamicRouteService.getRegisteredRoutes(); + } +} diff --git a/core-web/apps/dotcms-ui/src/app/api/services/dynamic-route.service.spec.ts b/core-web/apps/dotcms-ui/src/app/api/services/dynamic-route.service.spec.ts new file mode 100644 index 000000000000..3f39cee33e9f --- /dev/null +++ b/core-web/apps/dotcms-ui/src/app/api/services/dynamic-route.service.spec.ts @@ -0,0 +1,191 @@ +import { createServiceFactory, SpectatorService } from '@ngneat/spectator/jest'; + +import { Component } from '@angular/core'; +import { Route, Router } from '@angular/router'; + +import { LoggerService } from '@dotcms/dotcms-js'; + +import { DynamicRouteService } from './dynamic-route.service'; +import { MenuGuardService } from './guards/menu-guard.service'; + +@Component({ template: '' }) +class MockComponent {} + +describe('DynamicRouteService', () => { + let spectator: SpectatorService; + let service: DynamicRouteService; + let router: Router; + + const mockMainRoute: Route = { + path: '', + canActivate: [{ name: 'AuthGuardService' } as never], + children: [] + }; + + const createService = createServiceFactory({ + service: DynamicRouteService, + mocks: [LoggerService], + providers: [ + { + provide: Router, + useValue: { + config: [mockMainRoute], + resetConfig: jest.fn() + } + } + ] + }); + + beforeEach(() => { + spectator = createService(); + service = spectator.service; + router = spectator.inject(Router); + + // Reset children array before each test + mockMainRoute.children = []; + }); + + describe('registerRoute', () => { + it('should register a route with a component', () => { + const result = service.registerRoute({ + path: 'test-portlet', + component: MockComponent + }); + + expect(result).toBe(true); + expect(mockMainRoute.children).toHaveLength(1); + expect(mockMainRoute.children![0].path).toBe('test-portlet'); + expect(mockMainRoute.children![0].component).toBe(MockComponent); + }); + + it('should register a route with loadComponent', () => { + const loadFn = () => Promise.resolve(MockComponent); + + const result = service.registerRoute({ + path: 'lazy-portlet', + loadComponent: loadFn + }); + + expect(result).toBe(true); + expect(mockMainRoute.children![0].loadComponent).toBe(loadFn); + }); + + it('should add MenuGuardService by default', () => { + service.registerRoute({ + path: 'guarded-portlet', + component: MockComponent + }); + + expect(mockMainRoute.children![0].canActivate).toContain(MenuGuardService); + expect(mockMainRoute.children![0].canActivateChild).toContain(MenuGuardService); + }); + + it('should not add guards when canActivate is false', () => { + service.registerRoute({ + path: 'unguarded-portlet', + component: MockComponent, + canActivate: false + }); + + expect(mockMainRoute.children![0].canActivate).toBeUndefined(); + }); + + it('should not register duplicate routes', () => { + service.registerRoute({ + path: 'duplicate-portlet', + component: MockComponent + }); + + const result = service.registerRoute({ + path: 'duplicate-portlet', + component: MockComponent + }); + + expect(result).toBe(false); + expect(mockMainRoute.children).toHaveLength(1); + }); + + it('should reset router config after registration', () => { + service.registerRoute({ + path: 'test-portlet', + component: MockComponent + }); + + expect(router.resetConfig).toHaveBeenCalledWith(router.config); + }); + + it('should include custom data in route', () => { + service.registerRoute({ + path: 'data-portlet', + component: MockComponent, + data: { customKey: 'customValue' } + }); + + expect(mockMainRoute.children![0].data).toEqual({ + reuseRoute: false, + customKey: 'customValue' + }); + }); + }); + + describe('unregisterRoute', () => { + beforeEach(() => { + service.registerRoute({ + path: 'removable-portlet', + component: MockComponent + }); + }); + + it('should remove a registered route', () => { + const result = service.unregisterRoute('removable-portlet'); + + expect(result).toBe(true); + expect(mockMainRoute.children).toHaveLength(0); + }); + + it('should return false for non-existent routes', () => { + const result = service.unregisterRoute('non-existent'); + + expect(result).toBe(false); + }); + + it('should reset router config after unregistration', () => { + jest.clearAllMocks(); + service.unregisterRoute('removable-portlet'); + + expect(router.resetConfig).toHaveBeenCalled(); + }); + }); + + describe('isRouteRegistered', () => { + it('should return true for registered routes', () => { + service.registerRoute({ + path: 'check-portlet', + component: MockComponent + }); + + expect(service.isRouteRegistered('check-portlet')).toBe(true); + }); + + it('should return false for unregistered routes', () => { + expect(service.isRouteRegistered('unknown-portlet')).toBe(false); + }); + }); + + describe('getRegisteredRoutes', () => { + it('should return all registered route paths', () => { + service.registerRoute({ path: 'portlet-1', component: MockComponent }); + service.registerRoute({ path: 'portlet-2', component: MockComponent }); + + const routes = service.getRegisteredRoutes(); + + expect(routes).toContain('portlet-1'); + expect(routes).toContain('portlet-2'); + expect(routes).toHaveLength(2); + }); + + it('should return empty array when no routes registered', () => { + expect(service.getRegisteredRoutes()).toEqual([]); + }); + }); +}); diff --git a/core-web/apps/dotcms-ui/src/app/api/services/dynamic-route.service.ts b/core-web/apps/dotcms-ui/src/app/api/services/dynamic-route.service.ts new file mode 100644 index 000000000000..031ec602785c --- /dev/null +++ b/core-web/apps/dotcms-ui/src/app/api/services/dynamic-route.service.ts @@ -0,0 +1,536 @@ +import { inject, Injectable, Type } from '@angular/core'; +import { Route, Router } from '@angular/router'; + +import { LoggerService } from '@dotcms/dotcms-js'; +import { DotMenuItem } from '@dotcms/dotcms-models'; + +import { DotRemoteModuleWrapperComponent } from './dot-remote-module-wrapper.component'; +import { MenuGuardService } from './guards/menu-guard.service'; + +export interface DynamicRouteConfig { + path: string; + component?: Type; + loadComponent?: () => Promise>; + loadChildren?: () => Promise; + canActivate?: boolean; + data?: Record; +} + +/** Tracks containers that have already been initialized to avoid double-init errors. */ +const initializedContainers = new Set(); + +/** + * Registry of known Angular module paths to their import functions. + * Add entries here for modules that can be dynamically loaded. + */ +const ANGULAR_MODULE_REGISTRY: Record Promise> = { + // Example entries - add your dynamic portlet modules here + // '@dotcms/portlets/my-custom': () => import('@dotcms/portlets/my-custom').then(m => m.routes) +}; + +/** + * Dynamically loads a remote module using Module Federation. + * Use this for external plugins that are deployed separately. + * + * @param remoteEntry - URL to the remote entry file (e.g., 'http://localhost:4201/remoteEntry.js') + * @param remoteName - Name of the remote module + * @param exposedModule - Name of the exposed module (e.g., './Routes') + */ +async function loadRemoteModule( + remoteEntry: string, + remoteName: string, + exposedModule: string +): Promise { + // Ensure webpack Module Federation globals exist before loading the remote. + // The remote's webpack runtime references these when resolving shared modules. + // If the host doesn't use MF, we provide no-op stubs so the remote + // falls back to its own bundled dependencies. + ensureWebpackSharingGlobals(); + + // Dynamically load the remote entry script + await loadRemoteEntry(remoteEntry, remoteName); + + // Wait for the container to be initialized. + // Webpack's library type hoists the declaration (creating the key on window) + // but the actual assignment happens asynchronously after chunks load. + const container = await waitForContainer(remoteName); + + if (!container) { + console.error( + `[DynamicRoute] Container '${remoteName}' not found on window after timeout.` + ); + + return []; + } + + // Initialize the container exactly once — calling init() twice throws. + if (!initializedContainers.has(remoteName)) { + const win = window as unknown as Record; + const shareScopes = win['__webpack_share_scopes__'] as Record; + await container.init(shareScopes['default'] || {}); + initializedContainers.add(remoteName); + } + + // Get the exposed module + const factory = await container.get(exposedModule); + const Module = factory(); + + // Check if the remote exports a mount function (mount/unmount pattern). + // This is used when the remote has its own Angular runtime and needs + // to bootstrap independently inside a host-provided DOM element. + const mountFn = Module['mount'] as ((el: HTMLElement) => Promise<() => void>) | undefined; + + if (typeof mountFn === 'function') { + return [ + { + path: '', + component: DotRemoteModuleWrapperComponent, + data: { mount: mountFn } + } + ]; + } + + // Fall back to standard route exports + return (Module['remoteRoutes'] || Module['routes'] || Module['default'] || []) as Route[]; +} + +/** + * Ensures the webpack Module Federation sharing globals exist on window. + * Remote entries reference __webpack_init_sharing__ and __webpack_share_scopes__ + * during their initialization. If the host doesn't use MF, we provide stubs + * so the remote falls back to its own bundled dependencies. + */ +function ensureWebpackSharingGlobals(): void { + const win = window as unknown as Record; + + if (typeof win['__webpack_init_sharing__'] !== 'function') { + win['__webpack_share_scopes__'] = { default: {} }; + win['__webpack_init_sharing__'] = (name: string) => { + if (!(win['__webpack_share_scopes__'] as Record)[name]) { + (win['__webpack_share_scopes__'] as Record)[name] = {}; + } + + return Promise.resolve(); + }; + } +} + +/** + * Waits for a Module Federation container to be assigned on window. + * The var library type hoists the declaration synchronously, but the + * assignment can be deferred until webpack finishes loading chunks. + */ +function waitForContainer( + remoteName: string, + timeout = 5000 +): Promise< + | { + init: (shareScope: unknown) => Promise; + get: (module: string) => Promise<() => Record>; + } + | undefined +> { + const win = window as unknown as Record; + + // Already available + if (win[remoteName]) { + return Promise.resolve( + win[remoteName] as { + init: (shareScope: unknown) => Promise; + get: (module: string) => Promise<() => Record>; + } + ); + } + + // Poll until available or timeout + return new Promise((resolve) => { + const interval = 50; + let elapsed = 0; + const timer = setInterval(() => { + elapsed += interval; + + if (win[remoteName]) { + clearInterval(timer); + resolve( + win[remoteName] as { + init: (shareScope: unknown) => Promise; + get: (module: string) => Promise<() => Record>; + } + ); + } else if (elapsed >= timeout) { + clearInterval(timer); + resolve(undefined); + } + }, interval); + }); +} + +/** + * Loads the remote entry script into the page. + */ +function loadRemoteEntry(remoteEntry: string, remoteName: string): Promise { + return new Promise((resolve, reject) => { + // Check if already loaded + if ((window as unknown as Record)[remoteName]) { + resolve(); + + return; + } + + // Basic scheme validation to prevent javascript:/data: URLs + const trimmedRemoteEntry = remoteEntry.trim(); + const lowerCased = trimmedRemoteEntry.toLowerCase(); + if (lowerCased.startsWith('javascript:') || lowerCased.startsWith('data:')) { + reject(new Error('Invalid remote entry URL scheme.')); + + return; + } + + // Parse and validate URL: require same-origin or allowlisted https origin + let url: URL; + + try { + url = new URL(trimmedRemoteEntry, window.location.origin); + } catch { + reject(new Error('Invalid remote entry URL.')); + + return; + } + + const isSameOrigin = url.origin === window.location.origin; + const allowlist = (window as unknown as { DOT_REMOTE_ENTRY_ALLOWLIST?: string[] }) + .DOT_REMOTE_ENTRY_ALLOWLIST; + const isAllowlistedOrigin = Array.isArray(allowlist) && allowlist.includes(url.origin); + + // Require https for cross-origin remotes + if (!isSameOrigin && url.protocol !== 'https:') { + reject(new Error('Remote entry must use HTTPS when loaded from a different origin.')); + + return; + } + + // If an allowlist is configured, require the origin to be in it when cross-origin + if (!isSameOrigin && Array.isArray(allowlist) && !isAllowlistedOrigin) { + reject(new Error('Remote entry origin is not allowed.')); + + return; + } + + const script = document.createElement('script'); + script.src = url.toString(); + script.type = 'text/javascript'; + script.async = true; + script.onload = () => resolve(); + script.onerror = () => reject(new Error(`Failed to load remote entry: ${url.toString()}`)); + document.head.appendChild(script); + }); +} + +/** + * Service for dynamically registering Angular routes at runtime. + * Use this to add custom portlet routes without rebuilding the application. + * + * @example + * // Register a component directly + * dynamicRouteService.registerRoute({ + * path: 'my-portlet', + * component: MyPortletComponent + * }); + * + * @example + * // Register with lazy loading + * dynamicRouteService.registerRoute({ + * path: 'my-portlet', + * loadComponent: () => import('./my-portlet.component').then(m => m.MyPortletComponent) + * }); + */ +@Injectable({ providedIn: 'root' }) +export class DynamicRouteService { + private readonly router = inject(Router); + private readonly logger = inject(LoggerService); + private readonly registeredRoutes = new Set(); + + /** + * Register a new Angular route dynamically. + * The route will be added to the main authenticated layout. + * + * @param config - The route configuration + * @returns true if the route was registered, false if it already exists + */ + registerRoute(config: DynamicRouteConfig): boolean { + if (this.registeredRoutes.has(config.path)) { + this.logger.warn(this, `Route '${config.path}' is already registered. Skipping.`); + + return false; + } + + const mainRoute = this.findMainLayoutRoute(); + + if (!mainRoute?.children) { + this.logger.error( + this, + 'Could not find main layout route. Dynamic route registration failed.' + ); + + return false; + } + + const newRoute = this.buildRoute(config); + + // Insert at the beginning to take precedence over catch-all routes + mainRoute.children.unshift(newRoute); + this.registeredRoutes.add(config.path); + + // Reset router configuration to apply changes + this.router.resetConfig(this.router.config); + + this.logger.info(this, `Registered dynamic route: '${config.path}'`); + + return true; + } + + /** + * Unregister a previously registered dynamic route. + * + * @param path - The path of the route to remove + * @returns true if the route was removed, false if it wasn't found + */ + unregisterRoute(path: string): boolean { + if (!this.registeredRoutes.has(path)) { + return false; + } + + const mainRoute = this.findMainLayoutRoute(); + + if (mainRoute?.children) { + const index = mainRoute.children.findIndex((r) => r.path === path); + + if (index !== -1) { + mainRoute.children.splice(index, 1); + this.registeredRoutes.delete(path); + this.router.resetConfig(this.router.config); + this.logger.info(this, `Unregistered dynamic route: '${path}'`); + + return true; + } + } + + return false; + } + + /** + * Check if a route is already registered. + * + * @param path - The path to check + * @returns true if the route exists + */ + isRouteRegistered(path: string): boolean { + return this.registeredRoutes.has(path) || this.routeExistsInConfig(path); + } + + /** + * Get all dynamically registered route paths. + * + * @returns Array of registered paths + */ + getRegisteredRoutes(): string[] { + return Array.from(this.registeredRoutes); + } + + /** + * Register a module loader for a specific Angular module path. + * This allows runtime registration of lazy-loadable modules. + * + * @param modulePath - The module path identifier (e.g., "@dotcms/portlets/my-custom") + * @param loader - Function that returns a Promise of routes + */ + registerModuleLoader(modulePath: string, loader: () => Promise): void { + ANGULAR_MODULE_REGISTRY[modulePath] = loader; + this.logger.info(this, `Registered module loader for: '${modulePath}'`); + } + + /** + * Register routes from menu items that have angularModule defined. + * This processes menu items from the backend and registers their routes dynamically. + * + * Supports two formats for angularModule: + * 1. Local module: "@dotcms/portlets/my-custom" + * 2. Remote module: "remote:http://localhost:4201/remoteEntry.js|myPlugin|./Routes" + * + * @param menuItems - Array of menu items from the backend + * @returns Number of routes successfully registered + */ + registerRoutesFromMenuItems(menuItems: DotMenuItem[]): number { + let registered = 0; + + for (const item of menuItems) { + const angularModule = item.initParams?.['angular-module']; + + if (item.angular && angularModule) { + const path = this.extractPathFromUrl(item.url); + let success = false; + + if (angularModule.startsWith('remote:')) { + // Remote Module Federation format: + // remote:|| + success = this.registerRemoteModuleFromString(path, angularModule, { + portletId: item.id, + label: item.label + }); + } else { + // Local module format: @dotcms/portlets/my-custom + const loader = ANGULAR_MODULE_REGISTRY[angularModule]; + + if (loader) { + success = this.registerRoute({ + path, + loadChildren: loader, + data: { + portletId: item.id, + label: item.label + } + }); + } else { + this.logger.warn( + this, + `No module loader registered for '${angularModule}' (portlet: ${item.id})` + ); + } + } + + if (success) { + registered++; + } + } + } + + return registered; + } + + /** + * Parse and register a remote module from the angular-module string format. + * Format: remote:|| + * + * @param path - The route path + * @param angularModule - The angular-module string from backend + * @param data - Additional route data + * @returns true if successfully registered + */ + private registerRemoteModuleFromString( + path: string, + angularModule: string, + data?: Record + ): boolean { + // Parse: remote:http://localhost:4201/remoteEntry.js|myPlugin|./Routes + const parts = angularModule.substring('remote:'.length).split('|'); + + if (parts.length !== 3) { + this.logger.error( + this, + `Invalid remote module format: '${angularModule}'. Expected: remote:||` + ); + + return false; + } + + const [remoteEntry, remoteName, exposedModule] = parts; + + return this.registerRemoteModule({ + path, + remoteEntry, + remoteName, + exposedModule, + data + }); + } + + /** + * Extract the path from a URL (removes leading slash). + */ + private extractPathFromUrl(url: string): string { + return url.startsWith('/') ? url.substring(1) : url; + } + + /** + * Register a remote module from an external Module Federation plugin. + * This allows loading Angular modules from separately deployed applications. + * + * @param config - Configuration for the remote module + * @returns true if registration was successful + * + * @example + * dynamicRouteService.registerRemoteModule({ + * path: 'my-external-portlet', + * remoteEntry: 'http://localhost:4201/remoteEntry.js', + * remoteName: 'myPlugin', + * exposedModule: './Routes' + * }); + */ + registerRemoteModule(config: { + path: string; + remoteEntry: string; + remoteName: string; + exposedModule: string; + data?: Record; + }): boolean { + return this.registerRoute({ + path: config.path, + loadChildren: () => + loadRemoteModule(config.remoteEntry, config.remoteName, config.exposedModule), + data: config.data + }); + } + + /** + * Find the main authenticated layout route where portlets are mounted. + * This is the route with path '' that has children (PORTLETS_IFRAME + PORTLETS_ANGULAR). + */ + private findMainLayoutRoute(): Route | undefined { + return this.router.config.find( + (route) => + route.path === '' && + Array.isArray(route.children) && + route.canActivate && + route.canActivate.length > 0 + ); + } + + /** + * Check if a route already exists in the router configuration. + */ + private routeExistsInConfig(path: string): boolean { + const mainRoute = this.findMainLayoutRoute(); + + return mainRoute?.children?.some((r) => r.path === path) ?? false; + } + + /** + * Build a Route object from the configuration. + */ + private buildRoute(config: DynamicRouteConfig): Route { + const route: Route = { + path: config.path, + data: { + reuseRoute: false, + ...config.data + } + }; + + // Add menu guard if requested (default: true) + if (config.canActivate !== false) { + route.canActivate = [MenuGuardService]; + route.canActivateChild = [MenuGuardService]; + } + + // Set up the route loader + if (config.component) { + route.component = config.component; + } else if (config.loadComponent) { + route.loadComponent = config.loadComponent; + } else if (config.loadChildren) { + route.loadChildren = config.loadChildren; + } + + return route; + } +} diff --git a/core-web/apps/dotcms-ui/src/app/api/services/guards/default-guard.service.spec.ts b/core-web/apps/dotcms-ui/src/app/api/services/guards/default-guard.service.spec.ts index 07362f5a3333..7375c50345db 100644 --- a/core-web/apps/dotcms-ui/src/app/api/services/guards/default-guard.service.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/api/services/guards/default-guard.service.spec.ts @@ -1,27 +1,109 @@ +import { of } from 'rxjs'; + import { TestBed } from '@angular/core/testing'; +import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; import { DotRouterService } from '@dotcms/data-access'; import { DefaultGuardService } from './default-guard.service'; import { DOTTestBed } from '../../../test/dot-test-bed'; +import { DotMenuService } from '../dot-menu.service'; +import { DynamicRouteService } from '../dynamic-route.service'; -describe('ValidDefaultGuardService', () => { - let defaultGuardService: DefaultGuardService; +describe('DefaultGuardService', () => { + let guard: DefaultGuardService; let dotRouterService: DotRouterService; + let router: Router; + let dynamicRouteService: DynamicRouteService; + let dotMenuService: DotMenuService; + + const mockMenus = [ + { + id: 'menu-1', + name: 'Test Menu', + tabDescription: '', + tabName: '', + tabOrder: 0, + url: '', + menuItems: [ + { + id: 'my-dynamic-portlet', + label: 'Dynamic Portlet', + url: '/my-dynamic-portlet', + ajax: false, + angular: true, + initParams: { + 'angular-module': + 'remote:http://localhost:4201/remoteEntry.js|myPlugin|./Routes' + } + } + ] + } + ]; + + const mockRoute = {} as ActivatedRouteSnapshot; beforeEach(() => { DOTTestBed.configureTestingModule({ - providers: [DefaultGuardService] + providers: [ + DefaultGuardService, + { + provide: DotMenuService, + useValue: { + loadMenu: jest.fn().mockReturnValue(of(mockMenus)) + } + }, + { + provide: DynamicRouteService, + useValue: { + registerRoutesFromMenuItems: jest.fn().mockReturnValue(1), + isRouteRegistered: jest.fn() + } + } + ] }); - defaultGuardService = TestBed.inject(DefaultGuardService); + guard = TestBed.inject(DefaultGuardService); dotRouterService = TestBed.inject(DotRouterService); + router = TestBed.inject(Router); + dynamicRouteService = TestBed.inject(DynamicRouteService); + dotMenuService = TestBed.inject(DotMenuService); + + jest.spyOn(router, 'navigateByUrl').mockImplementation(); + }); + + it('should redirect to dynamic route when URL matches a registered dynamic route', (done) => { + (dynamicRouteService.isRouteRegistered as jest.Mock).mockReturnValue(true); + const state = { url: '/my-dynamic-portlet' } as RouterStateSnapshot; + + guard.canActivate(mockRoute, state).subscribe((result) => { + expect(dotMenuService.loadMenu).toHaveBeenCalled(); + expect(dynamicRouteService.registerRoutesFromMenuItems).toHaveBeenCalledWith( + mockMenus[0].menuItems + ); + expect(dynamicRouteService.isRouteRegistered).toHaveBeenCalledWith( + 'my-dynamic-portlet' + ); + expect(router.navigateByUrl).toHaveBeenCalledWith('/my-dynamic-portlet'); + expect(dotRouterService.goToMain).not.toHaveBeenCalled(); + expect(result).toBe(false); + done(); + }); }); - it('should redirect to to Main Portlet always', () => { - const result = defaultGuardService.canActivate(); - expect(dotRouterService.goToMain).toHaveBeenCalled(); - expect(result).toBe(true); + it('should redirect to main when URL does not match a dynamic route', (done) => { + (dynamicRouteService.isRouteRegistered as jest.Mock).mockReturnValue(false); + const state = { url: '/some-unknown-path' } as RouterStateSnapshot; + + guard.canActivate(mockRoute, state).subscribe((result) => { + expect(dotMenuService.loadMenu).toHaveBeenCalled(); + expect(dynamicRouteService.registerRoutesFromMenuItems).toHaveBeenCalled(); + expect(dynamicRouteService.isRouteRegistered).toHaveBeenCalledWith('some-unknown-path'); + expect(router.navigateByUrl).not.toHaveBeenCalled(); + expect(dotRouterService.goToMain).toHaveBeenCalled(); + expect(result).toBe(true); + done(); + }); }); }); diff --git a/core-web/apps/dotcms-ui/src/app/api/services/guards/default-guard.service.ts b/core-web/apps/dotcms-ui/src/app/api/services/guards/default-guard.service.ts index aa08579f3867..1f5dd311544b 100644 --- a/core-web/apps/dotcms-ui/src/app/api/services/guards/default-guard.service.ts +++ b/core-web/apps/dotcms-ui/src/app/api/services/guards/default-guard.service.ts @@ -1,18 +1,52 @@ -import { Injectable, inject } from '@angular/core'; -import { CanActivate } from '@angular/router'; +import { Observable } from 'rxjs'; + +import { inject, Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from '@angular/router'; + +import { map } from 'rxjs/operators'; import { DotRouterService } from '@dotcms/data-access'; +import { DotMenuService } from '../dot-menu.service'; +import { DynamicRouteService } from '../dynamic-route.service'; + /** - * Route Guard the only function is to redirect to the Main Portlet. + * Route Guard for the wildcard (**) route. + * Waits for the menu to load and dynamic routes to register before deciding + * whether to redirect. This prevents a race condition where dynamic portlet + * routes aren't registered yet on page refresh. */ @Injectable() export class DefaultGuardService implements CanActivate { - private router = inject(DotRouterService); + private dotRouterService = inject(DotRouterService); + private router = inject(Router); + private dotMenuService = inject(DotMenuService); + private dynamicRouteService = inject(DynamicRouteService); + + canActivate(_route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return this.dotMenuService.loadMenu().pipe( + map((menus) => { + // Ensure dynamic routes are registered (idempotent) + const allMenuItems = menus.flatMap((menu) => menu.menuItems); + this.dynamicRouteService.registerRoutesFromMenuItems(allMenuItems); + + // Check if the attempted URL now matches a registered dynamic route + const url = state.url; + const path = url.startsWith('/') ? url.substring(1) : url; + const basePath = path.split('/')[0].split('?')[0]; + + if (this.dynamicRouteService.isRouteRegistered(basePath)) { + // Route exists now — re-navigate so the router matches it + this.router.navigateByUrl(url); + + return false; + } - canActivate(): boolean { - this.router.goToMain(); + // No matching dynamic route, redirect to main + this.dotRouterService.goToMain(); - return true; + return true; + }) + ); } } 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 d97a37faaf39..49800803527c 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 @@ -23,6 +23,7 @@ import { LoginServiceMock } from '@dotcms/utils-testing'; import { DotNavigationService } from './dot-navigation.service'; import { DotMenuService } from '../../../../api/services/dot-menu.service'; +import { DynamicRouteService } from '../../../../api/services/dynamic-route.service'; class RouterMock { _events: Subject = new Subject(); @@ -254,6 +255,13 @@ describe('DotNavigationService', () => { provide: DotSystemConfigService, useValue: { getSystemConfig: () => of({}) } }, + { + provide: DynamicRouteService, + useValue: { + registerRoutesFromMenuItems: jest.fn().mockReturnValue(0), + getRegisteredRoutes: jest.fn().mockReturnValue([]) + } + }, GlobalStore, provideHttpClient(), provideHttpClientTesting() 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 5d272d696d68..a43be04b3990 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 @@ -1,6 +1,6 @@ import { Observable } from 'rxjs'; -import { Injectable, inject } from '@angular/core'; +import { inject, Injectable } from '@angular/core'; import { Title } from '@angular/platform-browser'; import { Event, NavigationEnd, Router } from '@angular/router'; @@ -12,6 +12,7 @@ import { DotMenu } from '@dotcms/dotcms-models'; import { GlobalStore } from '@dotcms/store'; import { DotMenuService } from '../../../../api/services/dot-menu.service'; +import { DynamicRouteService } from '../../../../api/services/dynamic-route.service'; @Injectable() export class DotNavigationService { @@ -19,6 +20,7 @@ export class DotNavigationService { private dotMenuService = inject(DotMenuService); private dotRouterService = inject(DotRouterService); private dotcmsEventsService = inject(DotcmsEventsService); + private dynamicRouteService = inject(DynamicRouteService); private loginService = inject(LoginService); private router = inject(Router); private titleService = inject(Title); @@ -30,6 +32,7 @@ export class DotNavigationService { // Load initial menu - store handles menu link processing and entity transformation this.dotMenuService.loadMenu().subscribe((menus: DotMenu[]) => { + this.registerDynamicRoutes(menus); this.#globalStore.loadMenu(menus); if (this.dotRouterService.currentPortlet.id) { @@ -82,6 +85,7 @@ export class DotNavigationService { .reloadMenu() .pipe(take(1)) .subscribe((menus: DotMenu[]) => { + this.registerDynamicRoutes(menus); this.#globalStore.loadMenu(menus); if (this.dotRouterService.currentPortlet.id) { @@ -101,6 +105,7 @@ export class DotNavigationService { switchMap(() => this.dotMenuService.reloadMenu()) ) .subscribe((menus: DotMenu[]) => { + this.registerDynamicRoutes(menus); this.#globalStore.loadMenu(menus); this.goToFirstPortlet(); }); @@ -140,4 +145,9 @@ export class DotNavigationService { this.dotIframeService.reload(); } } + + private registerDynamicRoutes(menus: DotMenu[]): void { + const allMenuItems = menus.flatMap((menu) => menu.menuItems); + this.dynamicRouteService.registerRoutesFromMenuItems(allMenuItems); + } } diff --git a/core-web/libs/dotcms-models/src/lib/navigation/menu-item.model.ts b/core-web/libs/dotcms-models/src/lib/navigation/menu-item.model.ts index 34229c2f7eec..d6a5457ffd48 100644 --- a/core-web/libs/dotcms-models/src/lib/navigation/menu-item.model.ts +++ b/core-web/libs/dotcms-models/src/lib/navigation/menu-item.model.ts @@ -11,4 +11,10 @@ export interface DotMenuItem { */ labelParent?: string; parentMenuId: string; + /** + * Init parameters from the portlet's configuration. + * May contain 'angular-module' for dynamic lazy loading of Angular modules. + * Example: { 'angular-module': 'remote:http://localhost:4201/remoteEntry.js|myPlugin|./Routes' } + */ + initParams?: Record; } diff --git a/core-web/libs/edit-content-bridge/package.json b/core-web/libs/edit-content-bridge/package.json index 1d0610d61a6c..d17ff8f5f698 100644 --- a/core-web/libs/edit-content-bridge/package.json +++ b/core-web/libs/edit-content-bridge/package.json @@ -3,8 +3,8 @@ "version": "1.0.0", "dependencies": { "rxjs": "~6.6.3", - "@angular/core": "20.3.15", - "@angular/forms": "20.3.15", + "@angular/core": "20.3.16", + "@angular/forms": "20.3.16", "vite": "7.2.7", "primeng": "17.18.11", "@nx/vite": "21.6.9" diff --git a/core-web/package.json b/core-web/package.json index 3da03fb7ef02..466c525deb5c 100644 --- a/core-web/package.json +++ b/core-web/package.json @@ -47,16 +47,16 @@ "private": false, "dependencies": { "@angular-devkit/core": "20.3.13", - "@angular/animations": "20.3.15", + "@angular/animations": "20.3.16", "@angular/cdk": "20.2.9", - "@angular/common": "20.3.15", - "@angular/compiler": "20.3.15", - "@angular/core": "20.3.15", - "@angular/elements": "20.3.15", - "@angular/forms": "20.3.15", - "@angular/platform-browser": "20.3.15", - "@angular/platform-browser-dynamic": "20.3.15", - "@angular/router": "20.3.15", + "@angular/common": "20.3.16", + "@angular/compiler": "20.3.16", + "@angular/core": "20.3.16", + "@angular/elements": "20.3.16", + "@angular/forms": "20.3.16", + "@angular/platform-browser": "20.3.16", + "@angular/platform-browser-dynamic": "20.3.16", + "@angular/router": "20.3.16", "@ctrl/tinycolor": "^3.1.7", "@date-fns/tz": "^1.4.0", "@jitsu/sdk-js": "^3.1.5", @@ -158,8 +158,8 @@ "@angular-eslint/schematics": "20.7.0", "@angular-eslint/template-parser": "20.7.0", "@angular/cli": "20.3.13", - "@angular/compiler-cli": "20.3.15", - "@angular/language-service": "20.3.15", + "@angular/compiler-cli": "20.3.16", + "@angular/language-service": "20.3.16", "@babel/core": "^7.14.5", "@babel/plugin-proposal-class-properties": "^7.18.6", "@babel/plugin-proposal-private-methods": "^7.18.6", diff --git a/core-web/yarn.lock b/core-web/yarn.lock index aca70050b400..89001a5b1f88 100644 --- a/core-web/yarn.lock +++ b/core-web/yarn.lock @@ -415,10 +415,10 @@ dependencies: "@angular-eslint/bundled-angular-compiler" "20.7.0" -"@angular/animations@20.3.15": - version "20.3.15" - resolved "https://registry.yarnpkg.com/@angular/animations/-/animations-20.3.15.tgz#4acd599812e0d409278207ad7afa1829a39fdea4" - integrity sha512-ikyKfhkxoqQA6JcBN0B9RaN6369sM1XYX81Id0lI58dmWCe7gYfrTp8ejqxxKftl514psQO3pkW8Gn1nJ131Gw== +"@angular/animations@20.3.16": + version "20.3.16" + resolved "https://registry.npmjs.org/@angular/animations/-/animations-20.3.16.tgz#10ecaa0bf5245c490ded05c6008fb1d0905c46af" + integrity sha512-N83/GFY5lKNyWgPV3xHHy2rb3/eP1ZLzSVI+dmMVbf3jbqwY1YPQcMiAG8UDzaILY1Dkus91kWLF8Qdr3nHAzg== dependencies: tslib "^2.3.0" @@ -488,17 +488,17 @@ yargs "18.0.0" zod "4.1.13" -"@angular/common@20.3.15": - version "20.3.15" - resolved "https://registry.yarnpkg.com/@angular/common/-/common-20.3.15.tgz#a261e9bb14124db6e791a59ca17f0dea2f938b1c" - integrity sha512-k4mCXWRFiOHK3bUKfWkRQQ8KBPxW8TAJuKLYCsSHPCpMz6u0eA1F0VlrnOkZVKWPI792fOaEAWH2Y4PTaXlUHw== +"@angular/common@20.3.16": + version "20.3.16" + resolved "https://registry.npmjs.org/@angular/common/-/common-20.3.16.tgz#d2f5f1bbc8d65b81989b16b02c74b2529f4541e0" + integrity sha512-GRAziNlntwdnJy3F+8zCOvDdy7id0gITjDnM6P9+n2lXvtDuBLGJKU3DWBbvxcCjtD6JK/g/rEX5fbCxbUHkQQ== dependencies: tslib "^2.3.0" -"@angular/compiler-cli@20.3.15": - version "20.3.15" - resolved "https://registry.yarnpkg.com/@angular/compiler-cli/-/compiler-cli-20.3.15.tgz#71c30ac343ee708efabb99d08305c4b3e8e6899a" - integrity sha512-8sJoxodxsfyZ8eJ5r6Bx7BCbazXYgsZ1+dE8t5u5rTQ6jNggwNtYEzkyReoD5xvP+MMtRkos3xpwq4rtFnpI6A== +"@angular/compiler-cli@20.3.16": + version "20.3.16" + resolved "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-20.3.16.tgz#cc3de2ccc20e75322e4d8cd071e461d7116ae03a" + integrity sha512-l3xF/fXfJAl/UrNnH9Ufkr79myjMgXdHq1mmmph2UnpeqilRB1b8lC9sLBV9MipQHVn3dwocxMIvtrcryfOaXw== dependencies: "@babel/core" "7.28.3" "@jridgewell/sourcemap-codec" "^1.4.14" @@ -509,10 +509,10 @@ tslib "^2.3.0" yargs "^18.0.0" -"@angular/compiler@20.3.15": - version "20.3.15" - resolved "https://registry.yarnpkg.com/@angular/compiler/-/compiler-20.3.15.tgz#4220734ef6a59b28a23bb5dde376d5f341dfc227" - integrity sha512-lMicIAFAKZXa+BCZWs3soTjNQPZZXrF/WMVDinm8dQcggNarnDj4UmXgKSyXkkyqK5SLfnLsXVzrX6ndVT6z7A== +"@angular/compiler@20.3.16": + version "20.3.16" + resolved "https://registry.npmjs.org/@angular/compiler/-/compiler-20.3.16.tgz#9970a72d4c7dac5b5bf1b2e9292e55eb22046623" + integrity sha512-Pt9Ms9GwTThgzdxWBwMfN8cH1JEtQ2DK5dc2yxYtPSaD+WKmG9AVL1PrzIYQEbaKcWk2jxASUHpEWSlNiwo8uw== dependencies: tslib "^2.3.0" @@ -521,10 +521,10 @@ resolved "https://registry.yarnpkg.com/@angular/compiler/-/compiler-9.0.0.tgz#87e0bef4c369b6cadae07e3a4295778fc93799d5" integrity sha512-ctjwuntPfZZT2mNj2NDIVu51t9cvbhl/16epc5xEwyzyDt76pX9UgwvY+MbXrf/C/FWwdtmNtfP698BKI+9leQ== -"@angular/core@20.3.15": - version "20.3.15" - resolved "https://registry.yarnpkg.com/@angular/core/-/core-20.3.15.tgz#a3984ccbb21f85df9ff7482f1c90dbdf62f5de67" - integrity sha512-NMbX71SlTZIY9+rh/SPhRYFJU0pMJYW7z/TBD4lqiO+b0DTOIg1k7Pg9ydJGqSjFO1Z4dQaA6TteNuF99TJCNw== +"@angular/core@20.3.16": + version "20.3.16" + resolved "https://registry.npmjs.org/@angular/core/-/core-20.3.16.tgz#c30038600e190ece0fa38452ef729e7b46416897" + integrity sha512-KSFPKvOmWWLCJBbEO+CuRUXfecX2FRuO0jNi9c54ptXMOPHlK1lIojUnyXmMNzjdHgRug8ci9qDuftvC2B7MKg== dependencies: tslib "^2.3.0" @@ -533,43 +533,43 @@ resolved "https://registry.yarnpkg.com/@angular/core/-/core-9.0.0.tgz#227dc53e1ac81824f998c6e76000b7efc522641e" integrity sha512-6Pxgsrf0qF9iFFqmIcWmjJGkkCaCm6V5QNnxMy2KloO3SDq6QuMVRbN9RtC8Urmo25LP+eZ6ZgYqFYpdD8Hd9w== -"@angular/elements@20.3.15": - version "20.3.15" - resolved "https://registry.yarnpkg.com/@angular/elements/-/elements-20.3.15.tgz#885ece954b9bbfbeb1363175ac047675cbe5491f" - integrity sha512-+wjqUBprhrssXOCVA8R1TzsBvGQr5tvh5zvVYK/TAmrd1st2j0dywXkhxJrtyblumELiFSQfbIn7CMrMNTBR6g== +"@angular/elements@20.3.16": + version "20.3.16" + resolved "https://registry.npmjs.org/@angular/elements/-/elements-20.3.16.tgz#aaa6e28bfa330261372d605a743b5c0fdc29326b" + integrity sha512-WOduq+F/rRT6VRqTrF+TnruIOEG4S7o4eoFSHt9LBRCWlxQgHp5uY7TUpz3h2X9/zj66fr7ALGskj2Nk7wSFTA== dependencies: tslib "^2.3.0" -"@angular/forms@20.3.15": - version "20.3.15" - resolved "https://registry.yarnpkg.com/@angular/forms/-/forms-20.3.15.tgz#64e8deb982c0ac5ea2036d1c0ab556777db0382e" - integrity sha512-gS5hQkinq52pm/7mxz4yHPCzEcmRWjtUkOVddPH0V1BW/HMni/p4Y6k2KqKBeGb9p8S5EAp6PDxDVLOPukp3mg== +"@angular/forms@20.3.16": + version "20.3.16" + resolved "https://registry.npmjs.org/@angular/forms/-/forms-20.3.16.tgz#dcb055688ea6d4646229733fa23498ca6d1c5a48" + integrity sha512-1yzbXpExTqATpVcqA3wGrq4ACFIP3mRxA4pbso5KoJU+/4JfzNFwLsDaFXKpm5uxwchVnj8KM2vPaDOkvtp7NA== dependencies: tslib "^2.3.0" -"@angular/language-service@20.3.15": - version "20.3.15" - resolved "https://registry.yarnpkg.com/@angular/language-service/-/language-service-20.3.15.tgz#d66d71076f342a96e5e73dd8eec0c57a7e4c33bc" - integrity sha512-oD5rvAsZYzNqdJqMTYYp6T9yITG6axTI/j64v3qxHe+Y/PlHKfNHXcjENpA+LcR5wq0wtIE+s96APykCq9ouEQ== +"@angular/language-service@20.3.16": + version "20.3.16" + resolved "https://registry.npmjs.org/@angular/language-service/-/language-service-20.3.16.tgz#8d570c24ca2347a2c884b96be34077e181e08ff3" + integrity sha512-0A/tSQPq5geIz2mMcZA5fzzbzT39v+ADQksnfPr8htNxtkYWy+EI5+d0+++k59NuvjLY4uTBqhRTRB9b1PKrjw== -"@angular/platform-browser-dynamic@20.3.15": - version "20.3.15" - resolved "https://registry.yarnpkg.com/@angular/platform-browser-dynamic/-/platform-browser-dynamic-20.3.15.tgz#8b406429afb1e2e48396c243529341cda9dc6e7f" - integrity sha512-RizuRdBt0d6ongQ2y8cr8YsXFyjF8f91vFfpSNw+cFj+oiEmRC1txcWUlH5bPLD9qSDied8qazUi0Tb8VPQDGw== +"@angular/platform-browser-dynamic@20.3.16": + version "20.3.16" + resolved "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-20.3.16.tgz#95cd996ca4ed9154e080e97313418bd23f068ffd" + integrity sha512-5mECCV9YeKH6ue239GXRTGeDSd/eTbM1j8dDejhm5cGnPBhTxRw4o+GgSrWTYtb6VmIYdwUGBTC+wCBphiaQ2A== dependencies: tslib "^2.3.0" -"@angular/platform-browser@20.3.15": - version "20.3.15" - resolved "https://registry.yarnpkg.com/@angular/platform-browser/-/platform-browser-20.3.15.tgz#90c9108069207865fb3fdfafe0b30b7cabe9f699" - integrity sha512-TxRM/wTW/oGXv/3/Iohn58yWoiYXOaeEnxSasiGNS1qhbkcKtR70xzxW6NjChBUYAixz2ERkLURkpx3pI8Q6Dw== +"@angular/platform-browser@20.3.16": + version "20.3.16" + resolved "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-20.3.16.tgz#08eb56c9ba35b19399a15531422edd597734e082" + integrity sha512-YsrLS6vyS77i4pVHg4gdSBW74qvzHjpQRTVQ5Lv/OxIjJdYYYkMmjNalCNgy1ZuyY6CaLIB11ccxhrNnxfKGOQ== dependencies: tslib "^2.3.0" -"@angular/router@20.3.15": - version "20.3.15" - resolved "https://registry.yarnpkg.com/@angular/router/-/router-20.3.15.tgz#76db627a97a170cb6a4bfe514242bc5ad1daeddc" - integrity sha512-6+qgk8swGSoAu7ISSY//GatAyCP36hEvvUgvjbZgkXLLH9yUQxdo77ij05aJ5s0OyB25q/JkqS8VTY0z1yE9NQ== +"@angular/router@20.3.16": + version "20.3.16" + resolved "https://registry.npmjs.org/@angular/router/-/router-20.3.16.tgz#c986039bf5546ffe59314bbf139d4561cbed78b6" + integrity sha512-e1LiQFZaajKqc00cY5FboIrWJZSMnZ64GDp5R0UejritYrqorQQQNOqP1W85BMuY2owibMmxVfX+dJg/Mc8PuQ== dependencies: tslib "^2.3.0" @@ -23619,7 +23619,7 @@ string-length@^4.0.1, string-length@^4.0.2: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0": +"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -23637,15 +23637,6 @@ string-width@^1.0.1: is-fullwidth-code-point "^1.0.0" strip-ansi "^3.0.0" -"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - string-width@^2.0.0, string-width@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" @@ -25803,7 +25794,7 @@ worker-farm@^1.6.0, worker-farm@^1.7.0: dependencies: errno "~0.1.7" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -25830,15 +25821,6 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/menu/MenuHelper.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/menu/MenuHelper.java index b02149a2c891..6ce96514bc6c 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/menu/MenuHelper.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/menu/MenuHelper.java @@ -15,11 +15,11 @@ import com.liferay.portlet.VelocityPortlet; import com.liferay.util.StringPool; import io.vavr.control.Try; - import java.io.Serializable; import java.util.ArrayList; import java.util.List; import java.util.Locale; +import java.util.Map; /** * Helper for the {@link MenuResource} @@ -32,6 +32,7 @@ public class MenuHelper implements Serializable { private static final String PORTLET_DOESNT_EXIST_ERROR_MSG = "Portlet ID '%s' does not exist"; private static final String PORTLET_KEY_PREFIX="com.dotcms.repackage.javax.portlet.title."; + private static final String ANGULAR_MODULE_INIT_PARAM = "angular-module"; private MenuHelper() {} /** @@ -48,14 +49,23 @@ public List getMenuItems(MenuContext menuContext) List portletIds = menuContext.getLayout().getPortletIds(); for (String portletId : portletIds) { + if (null == portletId) { + continue; + } + Portlet portlet = APILocator.getPortletAPI().findPortlet(portletId); + if (null == portlet) { + Logger.warn(this, String.format("Portlet %s not found", portletId)); + continue; + } menuContext.setPortletId( portletId ); String url = getUrl(menuContext); Locale locale = Try.of(()-> new Locale(menuContext.getHttpServletRequest().getSession().getAttribute("com.dotcms.repackage.org.apache.struts.action.LOCALE").toString())).getOrElse(Locale.US); String linkName = normalizeLinkName(LanguageUtil.get(locale, PORTLET_KEY_PREFIX + portletId)); - boolean isAngular = isAngular( portletId ); - boolean isAjax = isAjax( portletId ); + boolean isAngular = isAngular(portlet); + boolean isAjax = isAjax(portlet); + Map initParams = portlet.getInitParams(); - menuItems.add ( new MenuItem(portletId, url, linkName, isAngular, isAjax) ); + menuItems.add(new MenuItem(portletId, url, linkName, isAngular, isAjax, initParams)); } return menuItems; } @@ -84,48 +94,61 @@ else if (!linkName.startsWith(PORTLET_KEY_PREFIX)) { /** * Determines if the portlet is an Angular-based portlet by checking if it implements PortletController * - * @param portletId ID of the portlet to check + * @param portlet ID of the portlet to check * @return true if the portlet is an Angular portlet, false if not * @throws ClassNotFoundException if the portlet class cannot be found */ - public boolean isAngular(final String portletId) throws ClassNotFoundException { - final Portlet portlet = APILocator.getPortletAPI().findPortlet(portletId); + boolean isAngular(final Portlet portlet) throws ClassNotFoundException { if (null != portlet) { final String portletClass = portlet.getPortletClass(); final Class classs = Class.forName(portletClass); return PortletController.class.isAssignableFrom(classs); } else { - Logger.error(this, String.format(PORTLET_DOESNT_EXIST_ERROR_MSG, portletId)); + Logger.error(this, String.format(PORTLET_DOESNT_EXIST_ERROR_MSG, portlet)); } return false; } /** * Validate if the portlet is a BaseRestPortlet - * @param portletId Id of the portlet + * @param portlet Id of the portlet * @return true if the portlet is a BaseRestPortlet portlet, false if not * @throws ClassNotFoundException */ - public boolean isAjax(final String portletId) throws ClassNotFoundException { - final Portlet portlet = APILocator.getPortletAPI().findPortlet(portletId); + boolean isAjax(final Portlet portlet) throws ClassNotFoundException { + if (null != portlet) { final String portletClass = portlet.getPortletClass(); final Class classs = Class.forName(portletClass); return BaseRestPortlet.class.isAssignableFrom(classs); } else { - Logger.error(this, String.format(PORTLET_DOESNT_EXIST_ERROR_MSG, portletId)); + Logger.error(this, String.format(PORTLET_DOESNT_EXIST_ERROR_MSG, portlet)); } return false; } + /** + * Gets the Angular module path for dynamic lazy loading from the portlet's init-params. This allows the frontend to + * dynamically import and register Angular portlet modules at runtime. + * + * @param portlet ID of the portlet to check + * @return the Angular module path (e.g., "@dotcms/portlets/my-custom"), or null if not configured + */ + String getAngularModule(final Portlet portlet) { + if (null != portlet && null != portlet.getInitParams()) { + return portlet.getInitParams().get(ANGULAR_MODULE_INIT_PARAM); + } + return null; + } + /** * Get the url of the menucontext * @param menuContext * @return the portlet url * @throws ClassNotFoundException */ - public String getUrl(final MenuContext menuContext) throws ClassNotFoundException { + String getUrl(final MenuContext menuContext) throws ClassNotFoundException { final Portlet portlet = APILocator.getPortletAPI().findPortlet( menuContext.getPortletId() ); if (null != portlet) { diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/menu/MenuItem.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/menu/MenuItem.java index e79e8235f4bf..3c1767bd1482 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/menu/MenuItem.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/menu/MenuItem.java @@ -1,5 +1,7 @@ package com.dotcms.rest.api.v1.menu; +import java.util.Map; + /** * Created by freddyrodriguez on 18/5/16. */ @@ -10,21 +12,38 @@ public class MenuItem { private String label; private boolean angular = false; private boolean isAjax = false; + private Map initParams; /** * Generate a Submenu portlet * @param id Portlet id * @param url Portlet url * @param label Portlet label - * @param isAngular if the portlet is an PortletController portlet + * @param isAngular if the portlet is an PortletController portlet * @param isAjax if the portlet is an BaseRestPortlet portlet */ public MenuItem(String id, String url, String label, boolean isAngular, boolean isAjax) { + this(id, url, label, isAngular, isAjax, Map.of()); + } + + /** + * Generate a Submenu portlet with dynamic Angular module loading support + * + * @param id Portlet id + * @param url Portlet url + * @param label Portlet label + * @param isAngular if the portlet is an PortletController portlet + * @param isAjax if the portlet is an BaseRestPortlet portlet + * @param initParams the Angular module path for dynamic lazy loading (e.g., "@dotcms/portlets/my-custom") + */ + public MenuItem(String id, String url, String label, boolean isAngular, boolean isAjax, + Map initParams) { this.url = url; this.id = id; this.label = label; this.angular = isAngular; this.isAjax = isAjax; + this.initParams = initParams; } /** @@ -66,4 +85,14 @@ public boolean isAngular() { public boolean isAjax() { return isAjax; } + + /** + * Get the Angular module path for dynamic lazy loading. This is used by the frontend to dynamically import and + * register the portlet's Angular module. + * + * @return the Angular module path (e.g., "@dotcms/portlets/my-custom"), or null if not set + */ + public Map getInitParams() { + return initParams; + } } diff --git a/dotCMS/src/main/java/com/dotmarketing/portlets/workflows/business/WorkflowAPIImpl.java b/dotCMS/src/main/java/com/dotmarketing/portlets/workflows/business/WorkflowAPIImpl.java index 24f9f8eb7895..b82b2367f843 100644 --- a/dotCMS/src/main/java/com/dotmarketing/portlets/workflows/business/WorkflowAPIImpl.java +++ b/dotCMS/src/main/java/com/dotmarketing/portlets/workflows/business/WorkflowAPIImpl.java @@ -661,9 +661,9 @@ public void saveSchemeIdsForContentType(final ContentType contentType, try { Logger.info(WorkflowAPIImpl.class, String.format("Saving Schemas [ %s ] for Content type '%s'", - String.join(",", schemesIds), contentType.inode())); + String.join(",", schemesIds), contentType.variable())); SecurityLogger.logInfo(this.getClass(), ()-> String.format("Saving Schemas [ %s ] for Content type '%s'", - String.join(",", schemesIds), contentType.inode())); + String.join(",", schemesIds), contentType.variable())); workFlowFactory.saveSchemeIdsForContentType(contentType.inode(), schemesIds.stream().map(this::getLongIdForScheme).collect(Collectors.toSet()), @@ -676,7 +676,7 @@ public void saveSchemeIdsForContentType(final ContentType contentType, } catch (final DotDataException | DotSecurityException e) { Logger.error(WorkflowAPIImpl.class, String.format("Error saving Schemas [ %s ] for Content Type '%s': %s", - String.join(",", schemesIds), contentType.inode(), e.getMessage())); + String.join(",", schemesIds), contentType.variable(), e.getMessage())); } } @@ -728,7 +728,7 @@ public List findSchemesForContentType(final ContentType contentT } else { try { - Logger.debug(this, () -> "Finding the schemes for: " + contentType); + Logger.debug(this, () -> "Finding the schemes for: " + contentType.variable()); final List contentTypeSchemes = hasValidLicense() ? this.workFlowFactory.findSchemesForStruct(contentType.inode()) : Arrays.asList(workFlowFactory.findSystemWorkflow()) ; diff --git a/dotCMS/src/main/java/com/dotmarketing/util/Logger.java b/dotCMS/src/main/java/com/dotmarketing/util/Logger.java index 94a49d5622af..acfa2c33ce9a 100644 --- a/dotCMS/src/main/java/com/dotmarketing/util/Logger.java +++ b/dotCMS/src/main/java/com/dotmarketing/util/Logger.java @@ -11,8 +11,6 @@ import com.dotcms.rest.api.v1.system.logger.ChangeLoggerLevelEvent; import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; -import com.github.benmanes.caffeine.cache.RemovalCause; -import com.github.benmanes.caffeine.cache.RemovalListener; import com.google.common.base.Objects; import com.liferay.util.StringPool; import io.vavr.Lazy; @@ -40,12 +38,6 @@ public class Logger { Caffeine.newBuilder() .maximumSize(10000) .expireAfterAccess(6,TimeUnit.HOURS) - .removalListener(new RemovalListener() { - @Override - public void onRemoval(String key, org.apache.logging.log4j.Logger value, RemovalCause cause) { - System.out.println("removing Logger :" + key + " due to " + cause); - } - }) .build(); public static void clearLoggers() { diff --git a/dotCMS/src/test/java/com/dotmarketing/util/UtilMethodsTest.java b/dotCMS/src/test/java/com/dotmarketing/util/UtilMethodsTest.java index ce1515a2192c..718997a76716 100644 --- a/dotCMS/src/test/java/com/dotmarketing/util/UtilMethodsTest.java +++ b/dotCMS/src/test/java/com/dotmarketing/util/UtilMethodsTest.java @@ -11,6 +11,7 @@ import com.dotcms.UnitTestBase; import com.dotmarketing.portlets.contentlet.model.Contentlet; +import com.dotmarketing.portlets.htmlpageasset.model.HTMLPageAsset; import com.liferay.portal.model.User; import java.util.Base64; import org.junit.Assert; @@ -22,6 +23,31 @@ */ public class UtilMethodsTest extends UnitTestBase { + + @Test + public void testIsNPEThrown() { + + HTMLPageAsset page = null; + try { + assertFalse(UtilMethods.isSet(page::getIdentifier)); + } catch (NullPointerException e) { + assertTrue("Throws an NPE", true); + return; + } + assertTrue("Should have thrown an NPE!", false); + } + + @Test + public void testNoNPEThrown() { + HTMLPageAsset page = null; + assertFalse(UtilMethods.isSet(() -> page.getIdentifier())); + + } + + + + + @Test public void testIsValidURL_Valid() { diff --git a/justfile b/justfile index 2d3735b2c810..2ebc8e7845e5 100644 --- a/justfile +++ b/justfile @@ -156,7 +156,7 @@ build-core-only: # Prepares the environment for running integration tests in an IDE test-integration-ide: - ./mvnw -pl :dotcms-integration pre-integration-test -Dcoreit.test.skip=false -Dopensearch.upgrade.test=true -Dtomcat.port=8080 + ./mvnw -pl :dotcms-integration pre-integration-test -Dcoreit.test.skip=false -Dopensearch.upgrade.test=true -Dtomcat.port=8080 -Dmaven.build.cache.enabled=false # Stops integration test services test-integration-stop: