Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1086,7 +1086,7 @@ describe('DotEmaShellComponent', () => {
expect.objectContaining({
label: expect.any(String),
id: '123',
url: 'index'
url: expect.stringContaining('url=index')
})
);
});
Expand Down Expand Up @@ -1127,7 +1127,7 @@ describe('DotEmaShellComponent', () => {
expect.objectContaining({
label: 'Other Page',
id: '456',
url: '/other-page'
url: expect.stringContaining('url=%2Fother-page')
})
);
});
Expand All @@ -1140,7 +1140,7 @@ describe('DotEmaShellComponent', () => {

expect(mockGlobalStore.addNewBreadcrumb).toHaveBeenCalledWith(
expect.objectContaining({
url: INITIAL_PAGE_PARAMS.url
url: expect.stringMatching(/^\/dotAdmin\/#.*url=index/)
})
);
});
Expand All @@ -1161,45 +1161,41 @@ describe('DotEmaShellComponent', () => {
expect(() => spectator.detectChanges()).not.toThrow();
});

it('should not update breadcrumb with stale data while page is loading, then update once loaded', async () => {
// Initialize with the first page fully loaded
it('should replace breadcrumb on navigation, not accumulate stale entries', async () => {
// Page A fully loaded
spectator.detectChanges();
await spectator.fixture.whenStable();
spectator.detectChanges();
mockGlobalStore.addNewBreadcrumb.mockClear();

// Hold the HTTP response so we can inspect state during LOADING
// Hold the response to inspect the LOADING window
const pendingRequest$ = new Subject<typeof MOCK_RESPONSE_HEADLESS>();
jest.spyOn(dotPageApiService, 'get').mockReturnValue(pendingRequest$);

// Navigate to a new page
store.pageLoad({ ...INITIAL_PAGE_PARAMS, url: '/new-page' });
store.pageLoad({ ...INITIAL_PAGE_PARAMS, url: '/page-b' });
spectator.detectChanges();

// While status is LOADING, stale pageAsset is present but breadcrumb must not be updated
// While LOADING, stale Page A data is present breadcrumb must not fire
expect(mockGlobalStore.addNewBreadcrumb).not.toHaveBeenCalled();

// Resolve the HTTP response with the new page's data
const newPageResponse = {
// Resolve with Page B data
const pageBResponse = {
...MOCK_RESPONSE_HEADLESS,
page: {
...MOCK_RESPONSE_HEADLESS.page,
title: 'New Page Title',
identifier: '999'
}
page: { ...MOCK_RESPONSE_HEADLESS.page, title: 'Page B', identifier: '456' }
};
pendingRequest$.next(newPageResponse);
pendingRequest$.next(pageBResponse);
pendingRequest$.complete();

await spectator.fixture.whenStable();
spectator.detectChanges();

// After loading completes, breadcrumb must reflect the new page
// Called exactly once — with Page B data, never with stale Page A data
expect(mockGlobalStore.addNewBreadcrumb).toHaveBeenCalledTimes(1);
expect(mockGlobalStore.addNewBreadcrumb).toHaveBeenCalledWith(
expect.objectContaining({
label: 'New Page Title',
id: '999',
url: '/new-page'
label: 'Page B',
id: '456',
url: expect.stringContaining('url=%2Fpage-b')
})
);
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { patchState } from '@ngrx/signals';
import { patchState, signalMethod } from '@ngrx/signals';

import { Location } from '@angular/common';
import {
Expand All @@ -11,7 +11,6 @@ import {
OnDestroy,
OnInit,
signal,
untracked,
ViewChild
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
Expand All @@ -34,7 +33,7 @@ import {
PageScannerToolType
} from '@dotcms/portlets/dot-ema/ui';
import { GlobalStore } from '@dotcms/store';
import { UVE_MODE } from '@dotcms/types';
import { DotCMSPage, UVE_MODE } from '@dotcms/types';
import { DotInfoPageComponent, DotMessagePipe, DotNotLicenseComponent, InfoPage } from '@dotcms/ui';

import { EditEmaNavigationBarComponent } from './components/edit-ema-navigation-bar/edit-ema-navigation-bar.component';
Expand Down Expand Up @@ -207,24 +206,32 @@ export class DotEmaShellComponent implements OnInit, OnDestroy {
this.#updateLocation(cleanedParams);
});

readonly $updateBreadcrumbEffect = effect(() => {
readonly $breadcrumbPage = computed<DotCMSPage | null>(() => {
const page = this.uveStore.pageAsset()?.page;
const status = this.uveStore.uveStatus();

if (page && status === UVE_STATUS.LOADED) {
// untracked: (a) prevents URL-only changes from re-triggering the breadcrumb,
// (b) avoids a TypeError crash when ngOnDestroy calls resetPageParams() (pageParams = null)
// before Angular tears down the effect asynchronously.
const url = untracked(() => this.uveStore.pageParams()?.url);
return page && status === UVE_STATUS.LOADED ? page : null;
});

this.#globalStore.addNewBreadcrumb({
label: page.title,
url,
id: `${page.identifier}`
});
}
readonly $updateBreadcrumb = signalMethod<DotCMSPage | null>((page) => {
if (!page || !this.uveStore.pageParams()) return;

const params = this.uveStore.pageFriendlyParams();
const baseClientHost = this.#activatedRoute.snapshot.data?.uveConfig?.url;
const cleanedParams = normalizeQueryParams(params, baseClientHost);
const urlTree = this.#router.createUrlTree([], { queryParams: cleanedParams });

this.#globalStore.addNewBreadcrumb({
label: page.title,
url: `/dotAdmin/#${urlTree.toString()}`,
id: `${page.identifier}`
});
});

constructor() {
this.$updateBreadcrumb(this.$breadcrumbPage);
}

ngOnInit(): void {
const params = this.#getPageParams();
const viewParams = this.#getViewParams(params.mode);
Expand Down
Loading