Skip to content
Open
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
8 changes: 8 additions & 0 deletions core-web/apps/dotcms-ui/src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,14 @@ const PORTLETS_ANGULAR: Route[] = [
data: { reuseRoute: false },
loadChildren: () => import('@dotcms/portlets/dot-tags/portlet').then((m) => m.dotTagsRoutes)
},
{
path: 'query-tool',
canActivate: [MenuGuardService],
canActivateChild: [MenuGuardService],
data: { reuseRoute: false },
loadChildren: () =>
import('@dotcms/portlets/dot-query-tool/portlet').then((m) => m.dotQueryToolRoutes)
},
{
path: 'plugins',
canActivate: [MenuGuardService],
Expand Down
1 change: 1 addition & 0 deletions core-web/libs/data-access/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export * from './lib/dot-containers/dot-containers.service';
export * from './lib/dot-content-search/dot-content-search.service';
export * from './lib/dot-content-type/dot-content-type.service';
export * from './lib/dot-content-types-info/dot-content-types-info.service';
export * from './lib/dot-contentlet-edit-url/dot-contentlet-edit-url.service';
export * from './lib/dot-contentlet-locker/dot-contentlet-locker.service';
export * from './lib/dot-contentlet/dot-contentlet.service';
export * from './lib/dot-copy-content/dot-copy-content.service';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { createServiceFactory, mockProvider, SpectatorService } from '@ngneat/spectator/jest';
import { of, throwError } from 'rxjs';

import {
DotCMSBaseTypesContentTypes,
DotCMSContentlet,
FeaturedFlags
} from '@dotcms/dotcms-models';

import { DotContentletEditUrlService } from './dot-contentlet-edit-url.service';

import { DotContentTypeService } from '../dot-content-type/dot-content-type.service';

const HTMLPAGE_CONTENTLET = {
inode: 'page-inode',
identifier: 'page-id',
contentType: 'htmlpageasset',
baseType: DotCMSBaseTypesContentTypes.HTMLPAGE,
url: '/about-us',
languageId: 1
} as unknown as DotCMSContentlet;

const REGULAR_CONTENTLET = {
inode: 'content-inode',
identifier: 'content-id',
contentType: 'Blog',
baseType: DotCMSBaseTypesContentTypes.CONTENT
} as unknown as DotCMSContentlet;

describe('DotContentletEditUrlService', () => {
let spectator: SpectatorService<DotContentletEditUrlService>;
let getContentTypeSpy: jest.Mock;

const createService = createServiceFactory({
service: DotContentletEditUrlService,
providers: [
mockProvider(DotContentTypeService, {
getContentType: jest.fn()
})
]
});

beforeEach(() => {
spectator = createService();
getContentTypeSpy = spectator.inject(DotContentTypeService).getContentType as jest.Mock;
// mockProvider's jest.fn() is captured once at factory definition, so it
// accumulates calls across spectator instances. Clear it per test so call-count
// assertions reflect only the test under inspection.
getContentTypeSpy.mockReset();
});

describe('HTMLPAGE branch', () => {
it('returns the page-editor URL without hitting the content-type service', (done) => {
spectator.service.resolveEditUrl(HTMLPAGE_CONTENTLET).subscribe((url) => {
expect(url).toContain('/dotAdmin/#/edit-page/content?');
expect(url).toContain('url=%2Fabout-us');
expect(url).toContain('language_id=1');
expect(url).toContain('mId=edit');
expect(getContentTypeSpy).not.toHaveBeenCalled();
done();
});
});

it('falls back to the inode-based resolver when an HTMLPAGE has no url/urlMap', (done) => {
getContentTypeSpy.mockReturnValue(of({ metadata: {} }));
const malformedPage = {
...HTMLPAGE_CONTENTLET,
url: undefined,
urlMap: undefined
} as unknown as DotCMSContentlet;

spectator.service.resolveEditUrl(malformedPage).subscribe((url) => {
expect(url).toBe('/dotAdmin/#/c/content/page-inode');
done();
});
});
});

describe('Contentlet branch', () => {
it('returns the new editor URL when CONTENT_EDITOR2_ENABLED is true', (done) => {
getContentTypeSpy.mockReturnValue(
of({
metadata: { [FeaturedFlags.FEATURE_FLAG_CONTENT_EDITOR2_ENABLED]: true }
})
);

spectator.service.resolveEditUrl(REGULAR_CONTENTLET).subscribe((url) => {
expect(url).toBe('/dotAdmin/#/content/content-inode');
done();
});
});

it('returns the legacy editor URL when CONTENT_EDITOR2_ENABLED is missing', (done) => {
getContentTypeSpy.mockReturnValue(of({ metadata: {} }));

spectator.service.resolveEditUrl(REGULAR_CONTENTLET).subscribe((url) => {
expect(url).toBe('/dotAdmin/#/c/content/content-inode');
done();
});
});

it('returns the legacy editor URL when getContentType errors (graceful fallback)', (done) => {
getContentTypeSpy.mockReturnValue(throwError(() => new Error('boom')));

spectator.service.resolveEditUrl(REGULAR_CONTENTLET).subscribe((url) => {
expect(url).toBe('/dotAdmin/#/c/content/content-inode');
done();
});
});
});

describe('Caching', () => {
it('only hits getContentType once for repeated resolutions of the same content type', (done) => {
getContentTypeSpy.mockReturnValue(
of({
metadata: { [FeaturedFlags.FEATURE_FLAG_CONTENT_EDITOR2_ENABLED]: true }
})
);

spectator.service.resolveEditUrl(REGULAR_CONTENTLET).subscribe(() => {
spectator.service
.resolveEditUrl({ ...REGULAR_CONTENTLET, inode: 'another-inode' })
.subscribe((url) => {
expect(url).toBe('/dotAdmin/#/content/another-inode');
expect(getContentTypeSpy).toHaveBeenCalledTimes(1);
done();
});
});
});

it('caches independently per content type', (done) => {
getContentTypeSpy.mockImplementation((ct: string) =>
of({
metadata: {
[FeaturedFlags.FEATURE_FLAG_CONTENT_EDITOR2_ENABLED]: ct === 'Modern'
}
})
);

spectator.service
.resolveEditUrl({ ...REGULAR_CONTENTLET, contentType: 'Modern' })
.subscribe((modernUrl) => {
expect(modernUrl).toBe('/dotAdmin/#/content/content-inode');
spectator.service
.resolveEditUrl({ ...REGULAR_CONTENTLET, contentType: 'Legacy' })
.subscribe((legacyUrl) => {
expect(legacyUrl).toBe('/dotAdmin/#/c/content/content-inode');
expect(getContentTypeSpy).toHaveBeenCalledTimes(2);
done();
});
});
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { Observable, of } from 'rxjs';

import { Injectable, inject } from '@angular/core';

import { catchError, map } from 'rxjs/operators';

import {
DotCMSBaseTypesContentTypes,
DotCMSContentlet,
FeaturedFlags
} from '@dotcms/dotcms-models';

import { DotContentTypeService } from '../dot-content-type/dot-content-type.service';

/**
* Canonical resolver for "where in dotAdmin should I open this contentlet for editing?".
*
* This service exists to consolidate logic that has been re-implemented inline at multiple
* call sites across the admin (Query Tool, Content Drive's `#editContentlet`, the legacy and
* new block-editor "edit contentlet" actions). Adopting this service means future changes —
* editor URL format updates, feature-flag renames, new fallback rules — land in one place
* instead of drifting across copies.
*
* ## What it resolves
*
* Given a contentlet, returns the dotAdmin hash route that should open it for editing.
* Three shapes, in priority order:
*
* 1. **HTMLPAGE** (`baseType === HTMLPAGE`)
* → `/dotAdmin/#/edit-page/content?url=<url>&language_id=<lang>&mId=edit`
* Uses the dedicated page editor route. Skips the content-type metadata lookup entirely
* because the routing decision is determined by `baseType`, not by per-type feature flags.
*
* 2. **Contentlet on a content type with `CONTENT_EDITOR2_ENABLED`**
* → `/dotAdmin/#/content/<inode>`
* The new dotCMS content editor.
*
* 3. **Contentlet on a legacy content type**
* → `/dotAdmin/#/c/content/<inode>`
* The legacy content editor (Dijit-era form).
*
* The caller decides what to do with the URL (open in new tab, `Router.navigate`, etc.) —
* this service only resolves "what is the URL?".
*
* ## Caching
*
* The content-type → `useNewEditor` boolean is cached for the lifetime of the application
* (the service is `providedIn: 'root'`). A list view rendering 50 contentlets of the same
* content type hits `/api/v1/contenttype/id/{type}` once, not 50 times.
*
* **Cache invalidation caveat:** if an admin toggles `CONTENT_EDITOR2_ENABLED` on a content
* type during the same session, cached entries go stale until a full page reload. This
* matches the behavior of the prior inline implementations and is acceptable because the
* flag is rarely toggled at runtime.
*
* ## Error fallback
*
* If the content-type lookup fails (4xx/5xx, network), the resolver returns the **legacy**
* editor URL rather than propagating the error. Rationale: the legacy editor handles every
* content type, so falling back keeps the user's edit flow working even when metadata
* reads transiently fail. The error is intentionally swallowed — callers that need to
* react to the failure should query `DotContentTypeService.getContentType()` directly.
*
* ## Existing duplicates (TODO migrations)
*
* Three older implementations of this pattern still exist and should migrate to this
* service when their owners next touch the surrounding code:
*
* - `libs/new-block-editor/src/lib/editor/services/contentlet-edit-url.service.ts`
* (component-scoped, no HTMLPAGE branch)
* - `libs/portlets/dot-content-drive/.../dot-content-drive-navigation.service.ts#editContentlet`
* (calls `Router.navigate` directly; would consume `resolveEditUrl(...)` and pass the
* result to `Router.navigateByUrl`)
* - `libs/block-editor/src/lib/elements/dot-bubble-menu/dot-bubble-menu.component.ts`
* (inline, no cache, legacy editor only)
*
* @example
* ```ts
* readonly #editUrl = inject(DotContentletEditUrlService);
*
* onEditClick(contentlet: DotCMSContentlet): void {
* this.#editUrl.resolveEditUrl(contentlet).subscribe((url) => window.open(url, '_blank'));
* }
* ```
*/
@Injectable({ providedIn: 'root' })
export class DotContentletEditUrlService {
readonly #contentTypeService = inject(DotContentTypeService);

// Cached per content-type variable: true ⇒ new editor, false ⇒ legacy editor.
readonly #editorFlagCache = new Map<string, boolean>();

/**
* Resolves the dotAdmin URL to open `contentlet` for editing. See the class JSDoc
* for the routing rules, caching behavior, and error fallback semantics.
*
* @param contentlet The contentlet the user wants to edit. Must have at least
* `inode`, `contentType`, and `baseType` populated; HTMLPAGE
* contentlets also need `url` (or `urlMap`) and `languageId`.
* @returns An observable that emits exactly one URL string and completes.
*/
resolveEditUrl(contentlet: DotCMSContentlet): Observable<string> {
const pageUrl = buildPageEditUrl(contentlet);
if (pageUrl) return of(pageUrl);

const cached = this.#editorFlagCache.get(contentlet.contentType);
if (cached !== undefined) {
return of(buildContentletEditUrl(cached, contentlet.inode));
}

return this.#contentTypeService.getContentType(contentlet.contentType).pipe(
catchError(() => of(null)),
map((ct) => {
const useNewEditor =
!!ct?.metadata?.[FeaturedFlags.FEATURE_FLAG_CONTENT_EDITOR2_ENABLED];
this.#editorFlagCache.set(contentlet.contentType, useNewEditor);
return buildContentletEditUrl(useNewEditor, contentlet.inode);
})
);
}
}

function buildPageEditUrl(contentlet: DotCMSContentlet): string | null {
if (contentlet.baseType !== DotCMSBaseTypesContentTypes.HTMLPAGE) return null;
const url = (contentlet['urlMap'] as string) || (contentlet['url'] as string);
if (!url) return null;
const params = new URLSearchParams({
url,
language_id: String(contentlet.languageId ?? 1),
mId: 'edit'
});
return `/dotAdmin/#/edit-page/content?${params.toString()}`;
}

function buildContentletEditUrl(useNewEditor: boolean, inode: string): string {
return useNewEditor ? `/dotAdmin/#/content/${inode}` : `/dotAdmin/#/c/content/${inode}`;
}
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './lib/dot-content-drive-shell/dot-content-drive-shell.component';
export * from './lib/lib.routes';
export * from './lib/shared/services/dot-content-drive-navigation.service';
Original file line number Diff line number Diff line change
Expand Up @@ -218,29 +218,15 @@
<p-tablist>
<p-tab value="results" data-testid="es-search-tab-results">
{{ 'esSearch.results.tab' | dm }}
@if (store.returnedCount() > 0) {
<span
class="ml-1.5 px-1.5 py-0.5 rounded-full text-xs font-bold bg-surface-300! text-color-secondary!">
{{ store.returnedCount() }}
</span>
}
</p-tab>
@if (store.hasAggregations()) {
<p-tab value="aggregations" data-testid="es-search-tab-aggregations">
{{ 'esSearch.results.aggregations' | dm }}
<span
class="ml-1.5 px-1.5 py-0.5 rounded-full text-xs font-bold bg-surface-300! text-color-secondary!">
{{ $parsedAggregations().length }}
</span>
</p-tab>
}
@if (store.hasSuggestions()) {
<p-tab value="suggestions" data-testid="es-search-tab-suggestions">
{{ 'esSearch.results.suggestions' | dm }}
<span
class="ml-1.5 px-1.5 py-0.5 rounded-full text-xs font-bold bg-surface-300! text-color-secondary!">
{{ $parsedSuggestions().length }}
</span>
</p-tab>
}
<p-tab value="raw" data-testid="es-search-tab-raw">
Expand Down Expand Up @@ -319,6 +305,9 @@
'esSearch.table.copyIdentifier' | dm
"
tooltipPosition="top"
[attr.aria-label]="
'esSearch.table.copyIdentifier' | dm
"
(onClick)="
copyToClipboard(
contentlet['identifier']
Expand Down Expand Up @@ -525,7 +514,7 @@
size="small"
icon="pi pi-copy"
[label]="'esSearch.help.copy' | dm"
(onClick)="copyQuery(example.query)"
(onClick)="copyToClipboard(example.query)"
data-testid="es-search-help-copy-btn" />
<p-button
text
Expand Down
Loading
Loading