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..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 @@ -1,13 +1,28 @@ 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 { Injectable, signal } 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, + DotSiteService +} from '@dotcms/data-access'; +import { DotCMSContentlet, DotPagination, DotSite } from '@dotcms/dotcms-models'; +import { GlobalStore } from '@dotcms/store'; import { MenuGuardService } from './menu-guard.service'; @@ -39,6 +54,7 @@ describe('ValidMenuGuardService', () => { imports: [HttpClientTestingModule], providers: [ MenuGuardService, + provideRouter([]), { provide: DotRouterService, useValue: { @@ -51,7 +67,29 @@ describe('ValidMenuGuardService', () => { provide: DotNavigationService, useClass: MockDotNavigationService }, - mockProvider(DotSessionStorageService) + 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 })) + }) ] }); @@ -64,7 +102,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 +114,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 +127,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 +139,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 +189,299 @@ 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 | undefined; + 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 | undefined) => + 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 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']); + + 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(); + }); + + 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']); + + 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 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']); + + 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('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']); + + 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..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 @@ -1,15 +1,52 @@ -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, + 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'; 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 +56,229 @@ 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 dotSiteService = inject(DotSiteService); + private globalStore = inject(GlobalStore); + 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. + * + * The variant id is cleared whenever the requested portlet is rejected, since the user leaves + * it either way. * - * @param string url - * @returns boolean + * @param url the requested route + * @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 { + 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) { - this.dotSessionStorageService.removeVariantId(); + switchMap( + (isValidPortlet): Observable => + isValidPortlet ? of(true) : this.getContentDriveRedirect(url) + ), + map((result) => { + if (result === true) { + return true; + } + + this.dotSessionStorageService.removeVariantId(); + + if (result === null) { this.dotNavigationService.goToFirstPortlet(); + + return false; } - 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 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, 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 { + const urlTree = this.router.parseUrl(url); + const replaced = this.parseReplacedUrl(urlTree); + const isLeavingContentDrive = this.router.url.startsWith(CONTENT_DRIVE_URL); + + if (!replaced || (replaced.kind === 'edit' && isLeavingContentDrive)) { + return of(null); + } + + return this.dotMenuService + .isPortletInMenu(CONTENT_DRIVE_PORTLET_ID) + .pipe( + switchMap( + (hasContentDrive): Observable => + hasContentDrive + ? this.getContentDriveUrl(replaced, urlTree.queryParams) + : of(null) + ) + ); + } + + /** + * 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. 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. + * - 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' && 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 = queryParams['filter']; + + 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)))) + ) + ); + } + + /** + * 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..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 @@ -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 } } : {}); } /** 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..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 @@ -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: currentSite ?? SYSTEM_HOST, + 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(); + } }); }