From 750e33d64511d26cd5b658d2fb1c4469b8f97127 Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Wed, 30 Sep 2026 13:16:06 -0300 Subject: [PATCH 1/4] fix(dotcms-ui): redirect Content Search and Site Browser routes to Content Drive (#37827) When a user's menu has Content Drive but not Content Search or Site Browser, MenuGuardService now sends their URLs to the Content Drive equivalent instead of the first portlet: - /c/content and /c/site-browser open Content Drive - /c/content?filter= opens it filtered by that content type - /c/content/ looks the content up and opens it by identifier and language (?editContent=&editContentLang=) - /c/content/new/ opens ?createContent= Edit and create links restore the CD_ params Content Drive adds when it hands off to the legacy editor. Edit and create links that come from Content Drive itself are not redirected, since Content Drive still sends legacy-editor content to those URLs and redirecting would loop. Co-Authored-By: Claude Opus 5.5 --- .../guards/menu-guard.service.spec.ts | 242 +++++++++++++++++- .../api/services/guards/menu-guard.service.ts | 186 +++++++++++++- 2 files changed, 407 insertions(+), 21 deletions(-) diff --git a/core-web/apps/dotcms-ui/src/app/api/services/guards/menu-guard.service.spec.ts b/core-web/apps/dotcms-ui/src/app/api/services/guards/menu-guard.service.spec.ts index a20ef1a21437..6d4774b373e9 100644 --- a/core-web/apps/dotcms-ui/src/app/api/services/guards/menu-guard.service.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/api/services/guards/menu-guard.service.spec.ts @@ -1,13 +1,26 @@ import { mockProvider } from '@openng/spectator/vitest'; -import { of as observableOf } from 'rxjs'; +import { of as observableOf, throwError } from 'rxjs'; import { vi } from 'vitest'; +import { HttpErrorResponse } from '@angular/common/http'; import { HttpClientTestingModule } from '@angular/common/http/testing'; import { Injectable } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; +import { + ActivatedRouteSnapshot, + Router, + RouterStateSnapshot, + UrlTree, + provideRouter +} from '@angular/router'; -import { DotRouterService, DotSessionStorageService } from '@dotcms/data-access'; +import { + DotContentletService, + DotHttpErrorManagerService, + DotRouterService, + DotSessionStorageService +} from '@dotcms/data-access'; +import { DotCMSContentlet } from '@dotcms/dotcms-models'; import { MenuGuardService } from './menu-guard.service'; @@ -39,6 +52,7 @@ describe('ValidMenuGuardService', () => { imports: [HttpClientTestingModule], providers: [ MenuGuardService, + provideRouter([]), { provide: DotRouterService, useValue: { @@ -51,7 +65,11 @@ describe('ValidMenuGuardService', () => { provide: DotNavigationService, useClass: MockDotNavigationService }, - mockProvider(DotSessionStorageService) + mockProvider(DotSessionStorageService), + mockProvider(DotContentletService), + mockProvider(DotHttpErrorManagerService, { + handle: vi.fn().mockReturnValue(observableOf({ redirected: false })) + }) ] }); @@ -64,7 +82,7 @@ describe('ValidMenuGuardService', () => { }); it('should allow access to Menu Portlets', () => { - let result: boolean; + let result: boolean | UrlTree; mockRouterStateSnapshot.url = '/test'; vi.spyOn(dotMenuService, 'isPortletInMenu').mockReturnValue(observableOf(true)); menuGuardService @@ -76,7 +94,7 @@ describe('ValidMenuGuardService', () => { }); it('should prevent access to Menu Portlets', () => { - let result: boolean; + let result: boolean | UrlTree; mockRouterStateSnapshot.url = '/test'; vi.spyOn(dotMenuService, 'isPortletInMenu').mockReturnValue(observableOf(false)); menuGuardService @@ -89,7 +107,7 @@ describe('ValidMenuGuardService', () => { }); it('should allow children access to Menu Portlets', () => { - let result: boolean; + let result: boolean | UrlTree; mockRouterStateSnapshot.url = '/test'; vi.spyOn(dotMenuService, 'isPortletInMenu').mockReturnValue(observableOf(true)); menuGuardService @@ -101,7 +119,7 @@ describe('ValidMenuGuardService', () => { }); it('should prevent children access to Menu Portlets', () => { - let result: boolean; + let result: boolean | UrlTree; mockRouterStateSnapshot.url = '/test'; vi.spyOn(dotMenuService, 'isPortletInMenu').mockReturnValue(observableOf(false)); menuGuardService @@ -151,4 +169,212 @@ describe('ValidMenuGuardService', () => { expect(dotNavigationService.goToFirstPortlet).toHaveBeenCalled(); })); }); + describe('Content Drive fallback', () => { + let router: Router; + + const givenMenu = (portletIds: string[]) => + vi + .spyOn(dotMenuService, 'isPortletInMenu') + .mockImplementation((id: string) => observableOf(portletIds.includes(id))); + + const runGuard = (url: string, portletId: string) => { + let result: boolean | UrlTree; + vi.spyOn(dotRouterService, 'getPortletId').mockReturnValue(portletId); + vi.spyOn(dotRouterService, 'isJSPPortletURL').mockReturnValue(true); + mockRouterStateSnapshot.url = url; + menuGuardService + .canActivateChild(mockActivatedRouteSnapshot, mockRouterStateSnapshot) + .subscribe((res) => (result = res)); + + return result; + }; + + const serialize = (result: boolean | UrlTree) => + result instanceof UrlTree ? router.serializeUrl(result) : result; + + beforeEach(() => { + router = TestBed.inject(Router); + }); + + it('should redirect Content Search to Content Drive when only Content Drive is in the menu', () => { + givenMenu(['content-drive']); + + expect(serialize(runGuard('/c/content', 'content'))).toBe('/content-drive'); + expect(dotNavigationService.goToFirstPortlet).not.toHaveBeenCalled(); + }); + + it('should carry the Content Search content type filter over to Content Drive', () => { + givenMenu(['content-drive']); + + expect(serialize(runGuard('/c/content?filter=Banner', 'content'))).toBe( + '/content-drive?filters=contentType:Banner' + ); + }); + + it('should drop Content Search params Content Drive has no equivalent for', () => { + givenMenu(['content-drive']); + + expect(serialize(runGuard('/c/content?mId=abcd', 'content'))).toBe('/content-drive'); + }); + + it('should redirect Site Browser to Content Drive when only Content Drive is in the menu', () => { + givenMenu(['content-drive']); + + expect(serialize(runGuard('/c/site-browser', 'site-browser'))).toBe('/content-drive'); + expect(dotNavigationService.goToFirstPortlet).not.toHaveBeenCalled(); + }); + + it('should keep Content Search when it is still in the menu', () => { + const spy = givenMenu(['content', 'content-drive']); + + expect(runGuard('/c/content?filter=Banner', 'content')).toBe(true); + expect(spy).not.toHaveBeenCalledWith('content-drive', expect.anything()); + }); + + it('should keep Site Browser when it is still in the menu', () => { + givenMenu(['site-browser', 'content-drive']); + + expect(runGuard('/c/site-browser', 'site-browser')).toBe(true); + }); + + it('should send the user to the first portlet when Content Drive is not in the menu either', () => { + givenMenu([]); + + expect(runGuard('/c/content', 'content')).toBe(false); + expect(dotNavigationService.goToFirstPortlet).toHaveBeenCalled(); + }); + + describe('edit links', () => { + let dotContentletService: DotContentletService; + + beforeEach(() => { + dotContentletService = TestBed.inject(DotContentletService); + vi.spyOn(dotContentletService, 'getContentletByInode').mockReturnValue( + observableOf({ + inode: 'inode-123', + identifier: 'identifier-456', + languageId: 2 + } as DotCMSContentlet) + ); + }); + + it('should open the content in Content Drive by identifier and language', () => { + givenMenu(['content-drive']); + + expect(serialize(runGuard('/c/content/inode-123', 'content'))).toBe( + '/content-drive?editContent=identifier-456&editContentLang=2' + ); + expect(dotContentletService.getContentletByInode).toHaveBeenCalledWith('inode-123'); + expect(dotNavigationService.goToFirstPortlet).not.toHaveBeenCalled(); + }); + + it('should restore the Content Drive params the link carries', () => { + givenMenu(['content-drive']); + + const result = runGuard( + '/c/content/inode-123?CD_path=%2Fimages&CD_filters=baseType:1&variantName=DEFAULT', + 'content' + ) as UrlTree; + + expect(result.queryParams).toEqual({ + path: '/images', + filters: 'baseType:1', + editContent: 'identifier-456', + editContentLang: '2' + }); + }); + + it('should open Content Drive and report the error when the lookup fails', () => { + const error = new HttpErrorResponse({ status: 404 }); + const dotHttpErrorManagerService = TestBed.inject(DotHttpErrorManagerService); + vi.spyOn(dotContentletService, 'getContentletByInode').mockReturnValue( + throwError(() => error) + ); + givenMenu(['content-drive']); + + expect(serialize(runGuard('/c/content/inode-123', 'content'))).toBe( + '/content-drive' + ); + expect(dotHttpErrorManagerService.handle).toHaveBeenCalledWith(error); + }); + + it('should not look the content up when Content Search is still in the menu', () => { + givenMenu(['content', 'content-drive']); + + expect(runGuard('/c/content/inode-123', 'content')).toBe(true); + expect(dotContentletService.getContentletByInode).not.toHaveBeenCalled(); + }); + + it('should not look the content up when Content Drive is not in the menu', () => { + givenMenu([]); + + expect(runGuard('/c/content/inode-123', 'content')).toBe(false); + expect(dotContentletService.getContentletByInode).not.toHaveBeenCalled(); + expect(dotNavigationService.goToFirstPortlet).toHaveBeenCalled(); + }); + }); + + describe('create links', () => { + it('should open the create flow in Content Drive for the content type', () => { + givenMenu(['content-drive']); + + expect(serialize(runGuard('/c/content/new/webPageContent', 'content'))).toBe( + '/content-drive?createContent=webPageContent' + ); + expect(dotNavigationService.goToFirstPortlet).not.toHaveBeenCalled(); + }); + + it('should restore the Content Drive params and drop the legacy folder inode', () => { + givenMenu(['content-drive']); + + const result = runGuard( + '/c/content/new/Blog?CD_path=%2Fblog&folder=folder-inode', + 'content' + ) as UrlTree; + + expect(result.queryParams).toEqual({ path: '/blog', createContent: 'Blog' }); + }); + + it('should open Content Drive when no content type is given', () => { + givenMenu(['content-drive']); + + expect(serialize(runGuard('/c/content/new', 'content'))).toBe('/content-drive'); + }); + }); + + describe('when leaving Content Drive', () => { + beforeEach(() => { + vi.spyOn(router, 'url', 'get').mockReturnValue('/content-drive?path=%2Fimages'); + }); + + it.each([ + ['an edit link', '/c/content/inode-123?CD_path=%2Fimages'], + ['a create link', '/c/content/new/Blog?CD_path=%2Fimages'] + ])( + 'should not send %s back to Content Drive, which would loop', + (_label: string, url: string) => { + const dotContentletService = TestBed.inject(DotContentletService); + vi.spyOn(dotContentletService, 'getContentletByInode'); + givenMenu(['content-drive']); + + expect(runGuard(url, 'content')).toBe(false); + expect(dotContentletService.getContentletByInode).not.toHaveBeenCalled(); + expect(dotNavigationService.goToFirstPortlet).toHaveBeenCalled(); + } + ); + + it('should still redirect a Content Search listing link', () => { + givenMenu(['content-drive']); + + expect(serialize(runGuard('/c/content', 'content'))).toBe('/content-drive'); + }); + }); + + it('should not redirect other portlets missing from the menu', () => { + givenMenu(['content-drive']); + + expect(runGuard('/c/workflow', 'workflow')).toBe(false); + expect(dotNavigationService.goToFirstPortlet).toHaveBeenCalled(); + }); + }); }); diff --git a/core-web/apps/dotcms-ui/src/app/api/services/guards/menu-guard.service.ts b/core-web/apps/dotcms-ui/src/app/api/services/guards/menu-guard.service.ts index 140640130663..0a0155a4d032 100644 --- a/core-web/apps/dotcms-ui/src/app/api/services/guards/menu-guard.service.ts +++ b/core-web/apps/dotcms-ui/src/app/api/services/guards/menu-guard.service.ts @@ -1,15 +1,50 @@ -import { Observable } from 'rxjs'; +import { Observable, of } from 'rxjs'; +import { HttpErrorResponse } from '@angular/common/http'; import { Injectable, inject } from '@angular/core'; -import { ActivatedRouteSnapshot, CanActivate, RouterStateSnapshot } from '@angular/router'; +import { + ActivatedRouteSnapshot, + CanActivate, + PRIMARY_OUTLET, + Params, + Router, + RouterStateSnapshot, + UrlTree +} from '@angular/router'; -import { map } from 'rxjs/operators'; +import { catchError, map, switchMap } from 'rxjs/operators'; -import { DotRouterService, DotSessionStorageService } from '@dotcms/data-access'; +import { + DotContentletService, + DotHttpErrorManagerService, + DotRouterService, + DotSessionStorageService +} from '@dotcms/data-access'; +import { mapParamsFromEditContentlet } from '@dotcms/utils'; import { DotNavigationService } from '../../../view/components/dot-navigation/services/dot-navigation.service'; import { DotMenuService } from '../dot-menu.service'; +const CONTENT_DRIVE_PORTLET_ID = 'content-drive'; +const CONTENT_DRIVE_URL = `/${CONTENT_DRIVE_PORTLET_ID}`; + +/** + * Portlets whose listing Content Drive replaces. Their bare listing URL (`/c/`) is redirected + * to Content Drive when the portlet is missing from the menu. + */ +const CONTENT_DRIVE_REPLACES = ['content', 'site-browser']; + +/** + * The Content Search URLs the guard knows a Content Drive equivalent for: + * - `listing`: `/c/content` or `/c/site-browser` + * - `edit`: `/c/content/` + * - `create`: `/c/content/new` and `/c/content/new/` + */ +type ReplacedUrl = + | { kind: 'listing'; portletId: string } + | { kind: 'edit'; inode: string } + | { kind: 'create'; contentType?: string }; + /** * Route Guard that checks if a User have access to the specified Menu portlet. */ @@ -19,37 +54,162 @@ export class MenuGuardService implements CanActivate { private dotRouterService = inject(DotRouterService); private dotNavigationService = inject(DotNavigationService); private dotSessionStorageService = inject(DotSessionStorageService); + private dotContentletService = inject(DotContentletService); + private dotHttpErrorManagerService = inject(DotHttpErrorManagerService); + private router = inject(Router); - canActivate(_route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + canActivate( + _route: ActivatedRouteSnapshot, + state: RouterStateSnapshot + ): Observable { return this.canAccessPortlet(state.url); } canActivateChild( _route: ActivatedRouteSnapshot, state: RouterStateSnapshot - ): Observable { + ): Observable { return this.canAccessPortlet(state.url); } /** - * Check if User has access to the requested route (url) based on the Menu, otherwise go to the 'First Portlet' and return false. + * Checks the requested portlet against the user's menu. When it is missing, a Content Search + * or Site Browser URL goes to its Content Drive equivalent if the user has Content Drive; + * anything else goes to the first portlet in the menu. * - * @param string url - * @returns boolean + * @param url the requested route + * @returns true to allow the route, a UrlTree to redirect, or false after navigating to the + * first portlet */ - private canAccessPortlet(url: string): Observable { + private canAccessPortlet(url: string): Observable { const id = this.dotRouterService.getPortletId(url); const checkJSPPortlet = this.dotRouterService.isJSPPortletURL(url); return this.dotMenuService.isPortletInMenu(id, checkJSPPortlet).pipe( - map((isValidPortlet) => { - if (!isValidPortlet) { + switchMap((isValidPortlet) => + isValidPortlet ? of(true) : this.getContentDriveRedirect(url) + ), + map((result) => { + if (result === false) { this.dotSessionStorageService.removeVariantId(); this.dotNavigationService.goToFirstPortlet(); } - return isValidPortlet; + return result; }) ); } + + /** + * Builds the Content Drive equivalent of a Content Search or Site Browser URL, when the user + * has Content Drive in their menu. + * + * Edit and create links coming from Content Drive itself are left alone: Content Drive sends + * content with the legacy editor to those URLs, and sending them back would loop. + * + * @param url the rejected route + * @returns the Content Drive UrlTree, or false when there is nothing to redirect to + */ + private getContentDriveRedirect(url: string): Observable { + const urlTree = this.router.parseUrl(url); + const replaced = this.parseReplacedUrl(urlTree); + const isLeavingContentDrive = this.router.url.startsWith(CONTENT_DRIVE_URL); + + if (!replaced || (replaced.kind !== 'listing' && isLeavingContentDrive)) { + return of(false); + } + + return this.dotMenuService + .isPortletInMenu(CONTENT_DRIVE_PORTLET_ID) + .pipe( + switchMap( + (hasContentDrive): Observable => + hasContentDrive + ? this.getContentDriveUrl(replaced, urlTree.queryParams) + : of(false) + ) + ); + } + + /** + * Reads which Content Search or Site Browser URL was requested. + * + * @param urlTree the rejected route + * @returns the replaced URL, or null when Content Drive has no equivalent for it + */ + private parseReplacedUrl(urlTree: UrlTree): ReplacedUrl | null { + const [root, portletId, asset, contentType, ...rest] = ( + urlTree.root.children[PRIMARY_OUTLET]?.segments ?? [] + ).map(({ path }) => path); + + if (root !== 'c' || !CONTENT_DRIVE_REPLACES.includes(portletId) || rest.length) { + return null; + } + + if (!asset) { + return { kind: 'listing', portletId }; + } + + if (portletId !== 'content') { + return null; + } + + if (asset === 'new') { + return { kind: 'create', contentType }; + } + + return contentType ? null : { kind: 'edit', inode: asset }; + } + + /** + * Builds the Content Drive URL for a replaced URL. + * + * - The Content Search `filter` param holds a content type variable, which Content Drive takes + * as its `contentType` filter. + * - Edit links carry an inode, while Content Drive opens content by identifier and language, + * so the content is looked up first. If the lookup fails, the error is reported and the user + * lands on Content Drive. + * - Create links pass the content type as `createContent`. + * - Edit and create links restore the `CD_`-prefixed params Content Drive adds when it sends + * the user to the legacy editor, so they land back on their folder and filters. Other params + * have no Content Drive equivalent and are dropped. + * + * @param replaced the replaced URL + * @param queryParams the rejected route's query params + * @returns the Content Drive UrlTree, or false when the error handler already navigated away + */ + private getContentDriveUrl( + replaced: ReplacedUrl, + queryParams: Params + ): Observable { + const toUrlTree = (params: Params) => + this.router.createUrlTree([CONTENT_DRIVE_URL], { queryParams: params }); + + if (replaced.kind === 'listing') { + const contentType = replaced.portletId === 'content' ? queryParams['filter'] : null; + + return of(toUrlTree(contentType ? { filters: `contentType:${contentType}` } : {})); + } + + const restored = mapParamsFromEditContentlet(new URLSearchParams(queryParams)); + + if (replaced.kind === 'create') { + const createContent = replaced.contentType + ? { createContent: replaced.contentType } + : {}; + + return of(toUrlTree({ ...restored, ...createContent })); + } + + return this.dotContentletService.getContentletByInode(replaced.inode).pipe( + map(({ identifier, languageId }) => + toUrlTree({ ...restored, editContent: identifier, editContentLang: languageId }) + ), + catchError((error: HttpErrorResponse) => + this.dotHttpErrorManagerService + .handle(error) + .pipe(map(({ redirected }) => (redirected ? false : toUrlTree(restored)))) + ) + ); + } } From 0936c76c1fcbb389d78c6dcb699163803b273907 Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Wed, 30 Sep 2026 14:20:22 -0300 Subject: [PATCH 2/4] fix(dotcms-ui): open file-based folders in Content Drive and stop the double navigation (#37759) - Containers and templates stored as files now pass their folder to goToSiteBrowser(path). The menu guard turns //// into Content Drive's ?path=, switching to that site first when it is not the current one. The legacy Site Browser still reads the folder from the session, so users who have it see no change. - When the inode lookup fails and the error handler has already navigated (a 401 goes to login), the guard now stops instead of also going to the first portlet. Rejections with no Content Drive equivalent are told apart with null. - The variant id is cleared on the Content Drive redirect too, not only when going to the first portlet. - The loop protection now covers edit links only. Nothing reads createContent yet, so a legacy create from Content Drive stays in Content Drive instead of jumping to the first portlet. - The Content Search filter goes over as contentType only, with no lookup: the Content Types chip now fills in the base type. Co-Authored-By: Claude Opus 5.5 --- .../guards/menu-guard.service.spec.ts | 143 +++++++++++++++--- .../api/services/guards/menu-guard.service.ts | 103 ++++++++++--- .../container-list.component.spec.ts | 3 + .../store/dot-container-list.store.ts | 2 +- .../dot-template-list.component.spec.ts | 4 + .../dot-template-list.component.ts | 4 +- .../lib/dot-router/dot-router.service.spec.ts | 12 ++ .../src/lib/dot-router/dot-router.service.ts | 14 +- 8 files changed, 245 insertions(+), 40 deletions(-) diff --git a/core-web/apps/dotcms-ui/src/app/api/services/guards/menu-guard.service.spec.ts b/core-web/apps/dotcms-ui/src/app/api/services/guards/menu-guard.service.spec.ts index 6d4774b373e9..5146be3ce17d 100644 --- a/core-web/apps/dotcms-ui/src/app/api/services/guards/menu-guard.service.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/api/services/guards/menu-guard.service.spec.ts @@ -4,7 +4,7 @@ import { vi } from 'vitest'; import { HttpErrorResponse } from '@angular/common/http'; import { HttpClientTestingModule } from '@angular/common/http/testing'; -import { Injectable } from '@angular/core'; +import { Injectable, signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { ActivatedRouteSnapshot, @@ -18,9 +18,11 @@ import { DotContentletService, DotHttpErrorManagerService, DotRouterService, - DotSessionStorageService + DotSessionStorageService, + DotSiteService } from '@dotcms/data-access'; -import { DotCMSContentlet } from '@dotcms/dotcms-models'; +import { DotCMSContentlet, DotPagination, DotSite } from '@dotcms/dotcms-models'; +import { GlobalStore } from '@dotcms/store'; import { MenuGuardService } from './menu-guard.service'; @@ -67,6 +69,24 @@ describe('ValidMenuGuardService', () => { }, mockProvider(DotSessionStorageService), mockProvider(DotContentletService), + mockProvider(GlobalStore, { + siteDetails: signal({ + identifier: 'demo-id', + hostname: 'demo.dotcms.com' + } as DotSite), + switchCurrentSite: vi.fn() + }), + mockProvider(DotSiteService, { + getSites: vi.fn().mockReturnValue( + observableOf({ + sites: [ + { identifier: 'other-prefix-id', hostname: 'other.com.ar' }, + { identifier: 'other-id', hostname: 'other.com' } + ] as DotSite[], + pagination: {} + }) + ) + }), mockProvider(DotHttpErrorManagerService, { handle: vi.fn().mockReturnValue(observableOf({ redirected: false })) }) @@ -211,6 +231,14 @@ describe('ValidMenuGuardService', () => { ); }); + it('should clear the variant when redirecting to Content Drive', () => { + givenMenu(['content-drive']); + + runGuard('/c/content', 'content'); + + expect(TestBed.inject(DotSessionStorageService).removeVariantId).toHaveBeenCalled(); + }); + it('should drop Content Search params Content Drive has no equivalent for', () => { givenMenu(['content-drive']); @@ -224,6 +252,66 @@ describe('ValidMenuGuardService', () => { expect(dotNavigationService.goToFirstPortlet).not.toHaveBeenCalled(); }); + describe('Site Browser folder links', () => { + it('should open the folder in Content Drive without the host when it is the current site', () => { + givenMenu(['content-drive']); + + const result = runGuard( + '/c/site-browser?path=%2F%2Fdemo.dotcms.com%2Fapplication%2Fcontainers%2Fdefault%2F', + 'site-browser' + ) as UrlTree; + + expect(result.queryParams).toEqual({ path: '/application/containers/default/' }); + expect(TestBed.inject(GlobalStore).switchCurrentSite).not.toHaveBeenCalled(); + }); + + it('should open a folder path that has no host on the current site', () => { + givenMenu(['content-drive']); + + const result = runGuard( + '/c/site-browser?path=%2Fapplication%2Fthemes%2Ftravel%2F', + 'site-browser' + ) as UrlTree; + + expect(result.queryParams).toEqual({ path: '/application/themes/travel/' }); + expect(TestBed.inject(DotSiteService).getSites).not.toHaveBeenCalled(); + }); + + it('should switch to the folder host first when it is another site', () => { + givenMenu(['content-drive']); + + const result = runGuard( + '/c/site-browser?path=%2F%2Fother.com%2Fapplication%2Ftemplates%2Fmain%2F', + 'site-browser' + ) as UrlTree; + + expect(TestBed.inject(DotSiteService).getSites).toHaveBeenCalledWith({ + filter: 'other.com' + }); + expect(TestBed.inject(GlobalStore).switchCurrentSite).toHaveBeenCalledWith( + 'other-id' + ); + expect(result.queryParams).toEqual({ path: '/application/templates/main/' }); + }); + + it('should open Content Drive at the root when the folder host cannot be found', () => { + vi.spyOn(TestBed.inject(DotSiteService), 'getSites').mockReturnValue( + observableOf({ sites: [] as DotSite[], pagination: {} as DotPagination }) + ); + givenMenu(['content-drive']); + + expect( + serialize( + runGuard( + '/c/site-browser?path=%2F%2Fgone.com%2Fapplication%2F', + 'site-browser' + ) + ) + ).toBe('/content-drive'); + expect(TestBed.inject(GlobalStore).switchCurrentSite).not.toHaveBeenCalled(); + }); + }); + it('should keep Content Search when it is still in the menu', () => { const spy = givenMenu(['content', 'content-drive']); @@ -298,6 +386,19 @@ describe('ValidMenuGuardService', () => { expect(dotHttpErrorManagerService.handle).toHaveBeenCalledWith(error); }); + it('should stop without a second navigation when the error handler already navigated', () => { + vi.spyOn(dotContentletService, 'getContentletByInode').mockReturnValue( + throwError(() => new HttpErrorResponse({ status: 401 })) + ); + vi.spyOn(TestBed.inject(DotHttpErrorManagerService), 'handle').mockReturnValue( + observableOf({ redirected: true, status: 401 }) + ); + givenMenu(['content-drive']); + + expect(runGuard('/c/content/inode-123', 'content')).toBe(false); + expect(dotNavigationService.goToFirstPortlet).not.toHaveBeenCalled(); + }); + it('should not look the content up when Content Search is still in the menu', () => { givenMenu(['content', 'content-drive']); @@ -347,21 +448,27 @@ describe('ValidMenuGuardService', () => { vi.spyOn(router, 'url', 'get').mockReturnValue('/content-drive?path=%2Fimages'); }); - it.each([ - ['an edit link', '/c/content/inode-123?CD_path=%2Fimages'], - ['a create link', '/c/content/new/Blog?CD_path=%2Fimages'] - ])( - 'should not send %s back to Content Drive, which would loop', - (_label: string, url: string) => { - const dotContentletService = TestBed.inject(DotContentletService); - vi.spyOn(dotContentletService, 'getContentletByInode'); - givenMenu(['content-drive']); - - expect(runGuard(url, 'content')).toBe(false); - expect(dotContentletService.getContentletByInode).not.toHaveBeenCalled(); - expect(dotNavigationService.goToFirstPortlet).toHaveBeenCalled(); - } - ); + it('should not send an edit link back to Content Drive, which would loop', () => { + const dotContentletService = TestBed.inject(DotContentletService); + vi.spyOn(dotContentletService, 'getContentletByInode'); + givenMenu(['content-drive']); + + expect(runGuard('/c/content/inode-123?CD_path=%2Fimages', 'content')).toBe(false); + expect(dotContentletService.getContentletByInode).not.toHaveBeenCalled(); + expect(dotNavigationService.goToFirstPortlet).toHaveBeenCalled(); + }); + + it('should still redirect a create link, which Content Drive does not act on yet', () => { + givenMenu(['content-drive']); + + const result = runGuard( + '/c/content/new/Blog?CD_path=%2Fimages', + 'content' + ) as UrlTree; + + expect(result.queryParams).toEqual({ path: '/images', createContent: 'Blog' }); + expect(dotNavigationService.goToFirstPortlet).not.toHaveBeenCalled(); + }); it('should still redirect a Content Search listing link', () => { givenMenu(['content-drive']); diff --git a/core-web/apps/dotcms-ui/src/app/api/services/guards/menu-guard.service.ts b/core-web/apps/dotcms-ui/src/app/api/services/guards/menu-guard.service.ts index 0a0155a4d032..aaa945830327 100644 --- a/core-web/apps/dotcms-ui/src/app/api/services/guards/menu-guard.service.ts +++ b/core-web/apps/dotcms-ui/src/app/api/services/guards/menu-guard.service.ts @@ -18,8 +18,10 @@ import { DotContentletService, DotHttpErrorManagerService, DotRouterService, - DotSessionStorageService + DotSessionStorageService, + DotSiteService } from '@dotcms/data-access'; +import { GlobalStore } from '@dotcms/store'; import { mapParamsFromEditContentlet } from '@dotcms/utils'; import { DotNavigationService } from '../../../view/components/dot-navigation/services/dot-navigation.service'; @@ -56,6 +58,8 @@ export class MenuGuardService implements CanActivate { private dotSessionStorageService = inject(DotSessionStorageService); private dotContentletService = inject(DotContentletService); private dotHttpErrorManagerService = inject(DotHttpErrorManagerService); + private dotSiteService = inject(DotSiteService); + private globalStore = inject(GlobalStore); private router = inject(Router); canActivate( @@ -77,22 +81,33 @@ export class MenuGuardService implements CanActivate { * or Site Browser URL goes to its Content Drive equivalent if the user has Content Drive; * anything else goes to the first portlet in the menu. * + * The variant id is cleared whenever the requested portlet is rejected, since the user leaves + * it either way. + * * @param url the requested route - * @returns true to allow the route, a UrlTree to redirect, or false after navigating to the - * first portlet + * @returns true to allow the route, a UrlTree to redirect, or false when the navigation stops, + * after going to the first portlet or because an error handler already navigated */ private canAccessPortlet(url: string): Observable { const id = this.dotRouterService.getPortletId(url); const checkJSPPortlet = this.dotRouterService.isJSPPortletURL(url); return this.dotMenuService.isPortletInMenu(id, checkJSPPortlet).pipe( - switchMap((isValidPortlet) => - isValidPortlet ? of(true) : this.getContentDriveRedirect(url) + switchMap( + (isValidPortlet): Observable => + isValidPortlet ? of(true) : this.getContentDriveRedirect(url) ), map((result) => { - if (result === false) { - this.dotSessionStorageService.removeVariantId(); + if (result === true) { + return true; + } + + this.dotSessionStorageService.removeVariantId(); + + if (result === null) { this.dotNavigationService.goToFirstPortlet(); + + return false; } return result; @@ -104,29 +119,34 @@ export class MenuGuardService implements CanActivate { * Builds the Content Drive equivalent of a Content Search or Site Browser URL, when the user * has Content Drive in their menu. * - * Edit and create links coming from Content Drive itself are left alone: Content Drive sends - * content with the legacy editor to those URLs, and sending them back would loop. + * Edit links coming from Content Drive itself are left alone: Content Drive sends content with + * the legacy editor to `/c/content/`, and opening `editContent` would send it there + * again, so the redirect would loop. Create links are still redirected: Content Drive also + * sends legacy creates to `/c/content/new/`, but nothing reads `createContent` yet, so + * there is nothing to loop on. Whatever reads it must open the create in Content Drive rather + * than navigate there. * * @param url the rejected route - * @returns the Content Drive UrlTree, or false when there is nothing to redirect to + * @returns the Content Drive UrlTree, false when an error handler already navigated away, or + * null when there is no Content Drive equivalent and the user goes to the first portlet */ - private getContentDriveRedirect(url: string): Observable { + private getContentDriveRedirect(url: string): Observable { const urlTree = this.router.parseUrl(url); const replaced = this.parseReplacedUrl(urlTree); const isLeavingContentDrive = this.router.url.startsWith(CONTENT_DRIVE_URL); - if (!replaced || (replaced.kind !== 'listing' && isLeavingContentDrive)) { - return of(false); + if (!replaced || (replaced.kind === 'edit' && isLeavingContentDrive)) { + return of(null); } return this.dotMenuService .isPortletInMenu(CONTENT_DRIVE_PORTLET_ID) .pipe( switchMap( - (hasContentDrive): Observable => + (hasContentDrive): Observable => hasContentDrive ? this.getContentDriveUrl(replaced, urlTree.queryParams) - : of(false) + : of(null) ) ); } @@ -165,7 +185,8 @@ export class MenuGuardService implements CanActivate { * Builds the Content Drive URL for a replaced URL. * * - The Content Search `filter` param holds a content type variable, which Content Drive takes - * as its `contentType` filter. + * as its `contentType` filter. The Content Types chip fills in the base type it belongs to. + * - The Site Browser `path` param holds the folder to open (see {@link getFolderPath}). * - Edit links carry an inode, while Content Drive opens content by identifier and language, * so the content is looked up first. If the lookup fails, the error is reported and the user * lands on Content Drive. @@ -185,8 +206,16 @@ export class MenuGuardService implements CanActivate { const toUrlTree = (params: Params) => this.router.createUrlTree([CONTENT_DRIVE_URL], { queryParams: params }); + if (replaced.kind === 'listing' && replaced.portletId === 'site-browser') { + const folder = queryParams['path']; + + return folder + ? this.getFolderPath(folder).pipe(map((path) => toUrlTree(path ? { path } : {}))) + : of(toUrlTree({})); + } + if (replaced.kind === 'listing') { - const contentType = replaced.portletId === 'content' ? queryParams['filter'] : null; + const contentType = queryParams['filter']; return of(toUrlTree(contentType ? { filters: `contentType:${contentType}` } : {})); } @@ -212,4 +241,44 @@ export class MenuGuardService implements CanActivate { ) ); } + + /** + * Turns a Site Browser folder into Content Drive's `path` param, which is a folder path inside + * the current site. + * + * Files stored in the Site Browser (containers, templates) give their folder with the host, + * as `////`. The host is stripped. When it is not the current site, the + * guard switches to that site first: Content Drive loads again when the site changes, and it + * reads the folder from the URL each time, so it lands on the folder in the right site. A path + * without a host is a folder in the current site and is passed as is. + * + * @param folder the Site Browser folder + * @returns the folder path in the current site, or null when its host cannot be found + */ + private getFolderPath(folder: string): Observable { + const [, hostname, path = '/'] = folder.match(/^\/\/([^/]+)(\/.*)?$/) ?? []; + + if (!hostname) { + return of(folder); + } + + if (hostname === this.globalStore.siteDetails()?.hostname) { + return of(path); + } + + return this.dotSiteService.getSites({ filter: hostname }).pipe( + map(({ sites }) => { + const site = sites.find((site) => site.hostname === hostname); + + if (!site) { + return null; + } + + this.globalStore.switchCurrentSite(site.identifier); + + return path; + }), + catchError(() => of(null)) + ); + } } 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 97fc91a6b5b8..f4a2f50b7911 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 @@ -448,6 +448,9 @@ describe('ContainerListComponent', () => { fileContainer.pathName ); expect(store['dotSiteBrowserService'].setSelectedFolder).toHaveBeenCalledTimes(1); + expect(store['dotRouterService'].goToSiteBrowser).toHaveBeenCalledWith( + fileContainer.pathName + ); expect(store['dotRouterService'].goToSiteBrowser).toHaveBeenCalledTimes(1); }); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/container-list/store/dot-container-list.store.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/container-list/store/dot-container-list.store.ts index 4b4322cd031a..f51fbe45e93d 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/container-list/store/dot-container-list.store.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-containers/container-list/store/dot-container-list.store.ts @@ -573,7 +573,7 @@ export class DotContainerListStore extends ComponentStore .setSelectedFolder(container.pathName) .pipe(take(1)) .subscribe(() => { - this.dotRouterService.goToSiteBrowser(); + this.dotRouterService.goToSiteBrowser(container.pathName); }) : this.dotRouterService.goToEditContainer(container.identifier); } 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 433c591732df..1ddda0d6e252 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 @@ -648,6 +648,7 @@ describe('DotTemplateListComponent', () => { link.nativeElement.click(); expect(mockGoToFolder).toHaveBeenCalledWith(expect.any(Event), 'test'); + expect(dotRouterService.goToSiteBrowser).toHaveBeenCalledWith('test'); })); it("should render 'System Theme' when the theme is SYSTEM_THEME", fakeAsync(() => { @@ -817,6 +818,9 @@ describe('DotTemplateListComponent', () => { expect(dotSiteBrowserService.setSelectedFolder).toHaveBeenCalledWith( templatesMock[4].identifier ); + expect(dotRouterService.goToSiteBrowser).toHaveBeenCalledWith( + templatesMock[4].identifier + ); expect(dotRouterService.goToSiteBrowser).toHaveBeenCalledTimes(1); })); 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 7bdca468f111..edbcd2f92d84 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 @@ -211,7 +211,7 @@ export class DotTemplateListComponent implements OnInit { editTemplate(template: DotTemplate): void { this.isTemplateAsFile(template) ? this.dotSiteBrowserService.setSelectedFolder(template.identifier).subscribe(() => { - this.dotRouterService.goToSiteBrowser(); + this.dotRouterService.goToSiteBrowser(template.identifier); }) : this.dotRouterService.goToEditTemplate(template.identifier); } @@ -314,7 +314,7 @@ export class DotTemplateListComponent implements OnInit { event.stopPropagation(); this.dotSiteBrowserService.setSelectedFolder(path).subscribe(() => { - this.dotRouterService.goToSiteBrowser(); + this.dotRouterService.goToSiteBrowser(path); }); // This takes one under the hood } diff --git a/core-web/libs/data-access/src/lib/dot-router/dot-router.service.spec.ts b/core-web/libs/data-access/src/lib/dot-router/dot-router.service.spec.ts index 35d7f21392ba..5b3066b0f7b8 100644 --- a/core-web/libs/data-access/src/lib/dot-router/dot-router.service.spec.ts +++ b/core-web/libs/data-access/src/lib/dot-router/dot-router.service.spec.ts @@ -141,6 +141,18 @@ describe('DotRouterService', () => { expect(router.navigate).toHaveBeenCalledWith(['/c/content']); }); + it('should go to Site Browser', () => { + service.goToSiteBrowser(); + expect(router.navigate).toHaveBeenCalledWith(['/c/site-browser'], {}); + }); + + it('should go to Site Browser with the folder path', () => { + service.goToSiteBrowser('//demo.dotcms.com/application/containers/default/'); + expect(router.navigate).toHaveBeenCalledWith(['/c/site-browser'], { + queryParams: { path: '//demo.dotcms.com/application/containers/default/' } + }); + }); + it('should go to edit page', () => { service.goToEditTemplate('123'); expect(router.navigate).toHaveBeenCalledWith(['/templates/edit/123']); diff --git a/core-web/libs/data-access/src/lib/dot-router/dot-router.service.ts b/core-web/libs/data-access/src/lib/dot-router/dot-router.service.ts index 5c6740239914..3f3501d196ba 100644 --- a/core-web/libs/data-access/src/lib/dot-router/dot-router.service.ts +++ b/core-web/libs/data-access/src/lib/dot-router/dot-router.service.ts @@ -176,8 +176,18 @@ export class DotRouterService { this.router.navigate(['/public/login'], this.addCacheBusting(navExtras)); } - goToSiteBrowser(): void { - this.router.navigate(['/c/site-browser']); + /** + * Redirects to the Site Browser. + * + * The legacy Site Browser opens the folder saved with `DotSiteBrowserService.setSelectedFolder`, + * so callers still call that first. The `path` query param is for users without Site Browser: + * the menu guard reads it to open the same folder in Content Drive. + * + * @param path the folder to open, as `////` or a path in the current site + * @memberof DotRouterService + */ + goToSiteBrowser(path?: string): void { + this.router.navigate(['/c/site-browser'], path ? { queryParams: { path } } : {}); } /** From 7cefdd247d665dc7e3701aad3df64c4ee70aeafb Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Wed, 30 Sep 2026 14:21:10 -0300 Subject: [PATCH 3/4] feat(ui): resolve a content type's base type in the Content Types chip (#37759) The chip lists content types under their selected base type, so a link that names only a content type (filters=contentType:Blog) filtered the grid but left the chip empty. The chip now adds the base types of the host's content types, resolved from the content types it already loads, so no extra request. Content types the server does not return are dropped from the host's selection after a successful load, so a deleted type or a typo in an old link does not keep filtering with nothing to clear. Content Drive's init effect no longer tracks the default language: it re-ran when the language landed and re-read the page's original URL over any filter changed in between, including that cleanup. loadDefaultLanguage already seeds the language into the current filters. Co-Authored-By: Claude Opus 5.5 --- .../lib/store/dot-content-drive.store.spec.ts | 12 ++++ .../src/lib/store/dot-content-drive.store.ts | 18 ++++-- .../dot-content-type-filter.component.spec.ts | 56 ++++++++++++++++++ .../dot-content-type-filter.component.ts | 58 ++++++++++++++----- 4 files changed, 123 insertions(+), 21 deletions(-) diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.spec.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.spec.ts index b5285c9af7f7..ba8b47525edc 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.spec.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.spec.ts @@ -1681,6 +1681,18 @@ describe('DotContentDriveStore - default language resolution', () => { expect(contentDriveService.search).toHaveBeenCalledTimes(1); }); + it('should keep a filter changed before the default language resolves', () => { + // The init effect used to re-run when the default language landed, re-reading the page's + // original URL and wiping any filter a chip had already corrected in between. + spectator.flushEffects(); + store.patchFilters({ contentType: ['Blog'] }); + + languages$.next(mockLocales); + spectator.flushEffects(); + + expect(store.filters()).toEqual(withSeeded({ contentType: ['Blog'], languageId: ['1'] })); + }); + it('should expose the environment languages for the Locale filter to render', () => { spectator.flushEffects(); languages$.next(mockLocales); diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.ts index fbd700ae5fcd..da4e2d55d19d 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.ts @@ -657,12 +657,18 @@ export const DotContentDriveStore = signalStore( const queryTreeExpanded = queryParams['isTreeExpanded'] ?? DEFAULT_TREE_EXPANDED.toString(); - store.initContentDrive({ - currentSite, - path, - filters, - isTreeExpanded: queryTreeExpanded == 'true' - }); + // Untracked: `initContentDrive` reads `defaultLanguageId`, and tracking it would + // re-run the init when the default language lands, re-reading the page's + // original URL over any filter changed in between. `loadDefaultLanguage` seeds + // the language into the current filters itself, so only the site re-runs this. + untracked(() => + store.initContentDrive({ + currentSite, + path, + filters, + isTreeExpanded: queryTreeExpanded == 'true' + }) + ); }); /** diff --git a/core-web/libs/ui/src/lib/components/dot-content-type-filter/dot-content-type-filter.component.spec.ts b/core-web/libs/ui/src/lib/components/dot-content-type-filter/dot-content-type-filter.component.spec.ts index 4dd0aff244a6..4c2297896014 100644 --- a/core-web/libs/ui/src/lib/components/dot-content-type-filter/dot-content-type-filter.component.spec.ts +++ b/core-web/libs/ui/src/lib/components/dot-content-type-filter/dot-content-type-filter.component.spec.ts @@ -298,6 +298,62 @@ describe('DotContentTypeFilterComponent', () => { }); }); + describe('Content types selected without their base type', () => { + it('should show the content type in the chip', () => { + spectator.setInput('selectedContentTypes', ['blog']); + spectator.detectChanges(); + + expect(spectator.component.$chipSelections()).toEqual(['Blog']); + }); + + it('should select the base type the content type belongs to', () => { + spectator.setInput('selectedContentTypes', ['blog', 'videoFile']); + spectator.detectChanges(); + + expect(spectator.component.$selectedBaseTypes()).toEqual(['CONTENT', 'FILEASSET']); + }); + + it('should keep the base types the host selected', () => { + spectator.setInput('selectedBaseTypes', ['HTMLPAGE']); + spectator.setInput('selectedContentTypes', ['blog']); + spectator.detectChanges(); + + expect(spectator.component.$selectedBaseTypes()).toEqual(['HTMLPAGE', 'CONTENT']); + expect(spectator.component.$chipSelections()).toEqual(['Page (All)', 'Blog']); + }); + + it('should not emit when every selected content type is found', () => { + spectator.setInput('selectedContentTypes', ['blog']); + spectator.detectChanges(); + + expect(selections).toEqual([]); + }); + + it('should drop the content types the server does not return', () => { + spectator.setInput('selectedContentTypes', ['blog', 'deletedType']); + spectator.detectChanges(); + + expect(lastSelection()).toEqual({ baseTypes: ['CONTENT'], contentTypes: ['blog'] }); + }); + + it('should clear the selection when none of the content types is found', () => { + spectator.setInput('selectedContentTypes', ['deletedType']); + spectator.detectChanges(); + + expect(lastSelection()).toEqual({ baseTypes: [], contentTypes: [] }); + }); + + it('should keep the selection when the content types cannot be loaded', () => { + contentTypeService.getContentTypesWithPagination.mockReturnValue( + throwError(() => new Error('network')) + ); + spectator.setInput('selectedContentTypes', ['blog']); + spectator.detectChanges(); + + expect(selections).toEqual([]); + }); + }); + describe('Chip label rules', () => { beforeEach(() => spectator.detectChanges()); diff --git a/core-web/libs/ui/src/lib/components/dot-content-type-filter/dot-content-type-filter.component.ts b/core-web/libs/ui/src/lib/components/dot-content-type-filter/dot-content-type-filter.component.ts index d57d455858e0..c3b3255f3e2f 100644 --- a/core-web/libs/ui/src/lib/components/dot-content-type-filter/dot-content-type-filter.component.ts +++ b/core-web/libs/ui/src/lib/components/dot-content-type-filter/dot-content-type-filter.component.ts @@ -220,28 +220,45 @@ export class DotContentTypeFilterComponent implements OnInit { */ readonly $popoverOpen = signal(false); + /** + * The host's content-type variables resolved through the cache — which holds every content + * type we have ever loaded, so selections survive focus changes and lazy-load pages that no + * longer contain them. + */ + readonly #hostContentTypes = computed(() => { + const variables = this.$contentTypes() ?? []; + if (!variables.length) return []; + const cache = this.#contentTypeCache(); + return cache.filter((ct) => variables.includes(ct.variable)); + }); + /** * Working copy of the base-type selection. Re-seeds whenever the host pushes a different set * (URL restore, "clear all"). * + * A selected content type implies its base type, so the base types of the host's content types + * are added when the host did not select them. A link that names only a content type + * (`contentType:Blog`) then shows in the chip and in the panel like a selection made here, and + * the next selection emitted carries both. + * * Note `allowedBaseTypes` gates what is *offered*, not what is *selected* — a host that seeds a * disallowed base type here would see it in the chip. Today they always agree (the AssetPicker * feeds both from the same config array), so this is a latent mismatch, not a live bug. */ - readonly $selectedBaseTypes = linkedSignal(() => this.$baseTypes() ?? []); + readonly $selectedBaseTypes = linkedSignal(() => { + const baseTypes = this.$baseTypes() ?? []; + const implied = this.#hostContentTypes() + .map((ct) => ct.baseType) + .filter((baseType) => !baseTypes.includes(baseType)); - /** - * Working copy of the content-type selection, resolved from the host's variables through the - * cache — which holds every content type we have ever loaded, so selections survive focus - * changes and lazy-load pages that no longer contain them. - */ - readonly $selectedContentTypes = linkedSignal(() => { - const variables = this.$contentTypes() ?? []; - if (!variables.length) return []; - const cache = this.#contentTypeCache(); - return cache.filter((ct) => variables.includes(ct.variable)); + return [...baseTypes, ...new Set(implied)]; }); + /** Working copy of the content-type selection, seeded from {@link #hostContentTypes}. */ + readonly $selectedContentTypes = linkedSignal(() => + this.#hostContentTypes() + ); + /** Left column options: ALL_CONTENT prepended to base types. */ protected readonly $leftOptions = computed(() => [ { @@ -574,6 +591,7 @@ export class DotContentTypeFilterComponent implements OnInit { // cache for $selectedContentTypes to resolve them). const variables = this.$contentTypes() ?? []; const ensure = variables.length ? variables.join(',') : undefined; + let loaded = true; // `loading` is already true from initial state; no pre-fetch tap needed. this.#contentTypesService .getContentTypesWithPagination({ @@ -582,12 +600,14 @@ export class DotContentTypeFilterComponent implements OnInit { per_page: ITEMS_PER_PAGE }) .pipe( - catchError(() => - of({ + catchError(() => { + loaded = false; + + return of({ contentTypes: [], pagination: { currentPage: 1, totalEntries: 0 } as DotPagination - }) - ), + }); + }), takeUntilDestroyed(this.#destroyRef) ) .subscribe(({ contentTypes, pagination }) => { @@ -603,6 +623,14 @@ export class DotContentTypeFilterComponent implements OnInit { // still need to resolve in $selectedContentTypes so #emitSelection // doesn't drop a restored filter on first user interaction. this.#cacheContentTypes(contentTypes); + + // `ensure` returns every host-selected content type that exists. The ones missing + // (deleted, or a typo in an old link) would keep filtering the list with nothing in + // the chip to clear, so they are dropped from the host's selection. Only after a + // successful load: a failed one returns nothing and would drop them all. + if (loaded && this.#hostContentTypes().length < variables.length) { + this.#emitSelection(); + } }); } From bbd5a22c1018fe32acfd5dff5afbb91212d62f39 Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Wed, 30 Sep 2026 15:31:14 -0300 Subject: [PATCH 4/4] fix(core-web): clear the strict-mode gate on this PR's lines (#37759) - menu-guard spec: the guard result starts undefined until the guard emits. - container list spec: narrow the file container found in the mock list. - Content Drive store: pass SYSTEM_HOST while the site has not loaded, as initContentDrive already falls back to. Co-Authored-By: Claude Opus 5.5 --- .../src/app/api/services/guards/menu-guard.service.spec.ts | 4 ++-- .../container-list/container-list.component.spec.ts | 2 +- .../portlet/src/lib/store/dot-content-drive.store.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/core-web/apps/dotcms-ui/src/app/api/services/guards/menu-guard.service.spec.ts b/core-web/apps/dotcms-ui/src/app/api/services/guards/menu-guard.service.spec.ts index 5146be3ce17d..4d015f6153bc 100644 --- a/core-web/apps/dotcms-ui/src/app/api/services/guards/menu-guard.service.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/api/services/guards/menu-guard.service.spec.ts @@ -198,7 +198,7 @@ describe('ValidMenuGuardService', () => { .mockImplementation((id: string) => observableOf(portletIds.includes(id))); const runGuard = (url: string, portletId: string) => { - let result: boolean | UrlTree; + let result: boolean | UrlTree | undefined; vi.spyOn(dotRouterService, 'getPortletId').mockReturnValue(portletId); vi.spyOn(dotRouterService, 'isJSPPortletURL').mockReturnValue(true); mockRouterStateSnapshot.url = url; @@ -209,7 +209,7 @@ describe('ValidMenuGuardService', () => { return result; }; - const serialize = (result: boolean | UrlTree) => + const serialize = (result: boolean | UrlTree | undefined) => result instanceof UrlTree ? router.serializeUrl(result) : result; beforeEach(() => { 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 f4a2f50b7911..738db35c1468 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 @@ -449,7 +449,7 @@ describe('ContainerListComponent', () => { ); expect(store['dotSiteBrowserService'].setSelectedFolder).toHaveBeenCalledTimes(1); expect(store['dotRouterService'].goToSiteBrowser).toHaveBeenCalledWith( - fileContainer.pathName + fileContainer?.pathName ); expect(store['dotRouterService'].goToSiteBrowser).toHaveBeenCalledTimes(1); }); diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.ts index da4e2d55d19d..faf81cf0ecbd 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.ts @@ -663,7 +663,7 @@ export const DotContentDriveStore = signalStore( // the language into the current filters itself, so only the site re-runs this. untracked(() => store.initContentDrive({ - currentSite, + currentSite: currentSite ?? SYSTEM_HOST, path, filters, isTreeExpanded: queryTreeExpanded == 'true'