diff --git a/core-web/apps/dotcms-ui/src/app/app.routes.ts b/core-web/apps/dotcms-ui/src/app/app.routes.ts index 9bebd28f450..e822fd33600 100644 --- a/core-web/apps/dotcms-ui/src/app/app.routes.ts +++ b/core-web/apps/dotcms-ui/src/app/app.routes.ts @@ -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], diff --git a/core-web/libs/data-access/src/index.ts b/core-web/libs/data-access/src/index.ts index 6f27e8bf502..8d38994d332 100644 --- a/core-web/libs/data-access/src/index.ts +++ b/core-web/libs/data-access/src/index.ts @@ -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'; diff --git a/core-web/libs/data-access/src/lib/dot-contentlet-edit-url/dot-contentlet-edit-url.service.spec.ts b/core-web/libs/data-access/src/lib/dot-contentlet-edit-url/dot-contentlet-edit-url.service.spec.ts new file mode 100644 index 00000000000..adaf82c5cf9 --- /dev/null +++ b/core-web/libs/data-access/src/lib/dot-contentlet-edit-url/dot-contentlet-edit-url.service.spec.ts @@ -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; + 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(); + }); + }); + }); + }); +}); diff --git a/core-web/libs/data-access/src/lib/dot-contentlet-edit-url/dot-contentlet-edit-url.service.ts b/core-web/libs/data-access/src/lib/dot-contentlet-edit-url/dot-contentlet-edit-url.service.ts new file mode 100644 index 00000000000..bb4c6edf009 --- /dev/null +++ b/core-web/libs/data-access/src/lib/dot-contentlet-edit-url/dot-contentlet-edit-url.service.ts @@ -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=&language_id=&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/` + * The new dotCMS content editor. + * + * 3. **Contentlet on a legacy content type** + * → `/dotAdmin/#/c/content/` + * 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(); + + /** + * 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 { + 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}`; +} diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/index.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/index.ts index 099e96c2657..9c86f8d9834 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/index.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/index.ts @@ -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'; diff --git a/core-web/libs/portlets/dot-es-search/src/lib/dot-es-search-page/dot-es-search-page.component.html b/core-web/libs/portlets/dot-es-search/src/lib/dot-es-search-page/dot-es-search-page.component.html index 2a07a52e52c..44598a33b76 100644 --- a/core-web/libs/portlets/dot-es-search/src/lib/dot-es-search-page/dot-es-search-page.component.html +++ b/core-web/libs/portlets/dot-es-search/src/lib/dot-es-search-page/dot-es-search-page.component.html @@ -218,29 +218,15 @@ {{ 'esSearch.results.tab' | dm }} - @if (store.returnedCount() > 0) { - - {{ store.returnedCount() }} - - } @if (store.hasAggregations()) { {{ 'esSearch.results.aggregations' | dm }} - - {{ $parsedAggregations().length }} - } @if (store.hasSuggestions()) { {{ 'esSearch.results.suggestions' | dm }} - - {{ $parsedSuggestions().length }} - } @@ -319,6 +305,9 @@ 'esSearch.table.copyIdentifier' | dm " tooltipPosition="top" + [attr.aria-label]=" + 'esSearch.table.copyIdentifier' | dm + " (onClick)=" copyToClipboard( contentlet['identifier'] @@ -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" /> { mockProvider(DotHttpErrorManagerService), mockProvider(DotGlobalMessageService, { error: jest.fn() }) ], - componentProviders: [{ provide: DotEsSearchStore, useFactory: () => buildStoreMock() }] + componentProviders: [ + { provide: DotEsSearchStore, useFactory: () => buildStoreMock() }, + mockProvider(DotClipboardUtil, { copy: jest.fn().mockResolvedValue(true) }) + ] }); beforeEach(() => { @@ -304,32 +308,55 @@ describe('DotEsSearchPageComponent', () => { }); }); - describe('buildCurlSnippet', () => { - it('should escape single quotes in the JSON body using POSIX shell quoting', () => { + describe('Share menu', () => { + const getClipboardCopy = (resolved = true) => { + const clipboard = spectator.inject(DotClipboardUtil, true); + const copy = clipboard.copy as jest.Mock; + // mockProvider's jest.fn() is shared across tests in the suite; + // clear call history so each test asserts only against its own calls. + copy.mockClear(); + copy.mockResolvedValue(resolved); + return copy; + }; + + it('Copy as cURL targets /api/es/search with depth=1 and the parsed query body', () => { const store = spectator.inject(DotEsSearchStore, true); store.query = jest.fn().mockReturnValue(`{"query":{"match":{"title":"it's"}}}`); store.params = jest.fn().mockReturnValue({ live: true, userid: '' }); + const copy = getClipboardCopy(); - // Access the private method via bracket notation for testing - const snippet = (spectator.component as unknown as Record string>)[ - 'buildCurlSnippet' - ](); + spectator.component.exportItems[0].command?.({} as never); - // POSIX escape: ' in JSON becomes '\'' inside the shell-quoted -d argument + const snippet = copy.mock.calls[0][0]; + expect(snippet).toMatch(/^curl -X POST "https?:.*\/api\/es\/search\?depth=1/); expect(snippet).toContain(`"title":"it'\\''s"`); - expect(snippet).not.toContain(`"title":"it's"`); }); - it('should produce a valid snippet when the query contains no single quotes', () => { + it('Copy as fetch emits a fetch() call against /api/es/search', () => { const store = spectator.inject(DotEsSearchStore, true); store.query = jest.fn().mockReturnValue('{"query":{"match_all":{}}}'); store.params = jest.fn().mockReturnValue({ live: true, userid: '' }); + const copy = getClipboardCopy(); - const snippet = (spectator.component as unknown as Record string>)[ - 'buildCurlSnippet' - ](); + spectator.component.exportItems[1].command?.({} as never); - expect(snippet).toContain(`-d '{"query":{"match_all":{}}}'`); + const snippet = copy.mock.calls[0][0]; + expect(snippet).toContain(`fetch('/api/es/search?depth=1&live=true'`); + expect(snippet).toContain(`credentials: 'include'`); + }); + + it('calls DotGlobalMessageService.error when clipboard.copy resolves false', async () => { + const store = spectator.inject(DotEsSearchStore, true); + store.query = jest.fn().mockReturnValue('{"query":{"match_all":{}}}'); + store.params = jest.fn().mockReturnValue({ live: true, userid: '' }); + const copy = getClipboardCopy(false); + const globalMessage = spectator.inject(DotGlobalMessageService); + + spectator.component.exportItems[1].command?.({} as never); + // Flush the awaited clipboard promise so the `if (!ok)` branch runs. + await copy.mock.results[0]?.value; + + expect(globalMessage.error).toHaveBeenCalled(); }); }); diff --git a/core-web/libs/portlets/dot-es-search/src/lib/dot-es-search-page/dot-es-search-page.component.ts b/core-web/libs/portlets/dot-es-search/src/lib/dot-es-search-page/dot-es-search-page.component.ts index aeaeac37b09..517da6f8db1 100644 --- a/core-web/libs/portlets/dot-es-search/src/lib/dot-es-search-page/dot-es-search-page.component.ts +++ b/core-web/libs/portlets/dot-es-search/src/lib/dot-es-search-page/dot-es-search-page.component.ts @@ -34,11 +34,15 @@ import { } from '@dotcms/data-access'; import { ComponentStatus, DotContentState } from '@dotcms/dotcms-models'; import { + DOT_MONACO_BASE_OPTIONS, + DOT_MONACO_RAW_OPTIONS, + DotClipboardUtil, DotContentletStatusChipComponent, DotEmptyContainerComponent, DotMessagePipe, PrincipalConfiguration } from '@dotcms/ui'; +import { buildCurlSnippet, buildFetchSnippet, getDownloadLink } from '@dotcms/utils'; import { DotEsSearchStore, ESSearchActiveTab, MAX_HITS } from './store/dot-es-search.store'; @@ -80,21 +84,9 @@ export interface ParsedSuggester { const BUCKET_RESERVED_KEYS = new Set(['key', 'key_as_string', 'doc_count']); const QUERY_EDITOR_OPTIONS = { - theme: 'vs', - language: 'json', - minimap: { enabled: false }, - lineNumbers: 'on', - scrollBeyondLastLine: false, - automaticLayout: true, - fontSize: 13, - fontFamily: 'JetBrains Mono, Fira Code, Consolas, monospace' -}; - -const RAW_EDITOR_OPTIONS = { - ...QUERY_EDITOR_OPTIONS, - readOnly: true, - lineNumbers: 'off' -}; + ...DOT_MONACO_BASE_OPTIONS, + language: 'json' +} as const; @Component({ selector: 'dot-es-search-page', @@ -120,7 +112,7 @@ const RAW_EDITOR_OPTIONS = { DotEmptyContainerComponent, DotMessagePipe ], - providers: [DotEsSearchStore, DotEsSearchService, DotCurrentUserService], + providers: [DotEsSearchStore, DotEsSearchService, DotCurrentUserService, DotClipboardUtil], templateUrl: './dot-es-search-page.component.html', changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'flex flex-col h-full min-h-0 bg-white' } @@ -130,6 +122,7 @@ export class DotEsSearchPageComponent { readonly #messageService = inject(DotMessageService); readonly #document = inject(DOCUMENT); readonly #globalMessage = inject(DotGlobalMessageService); + readonly #clipboard = inject(DotClipboardUtil); readonly exportMenu = viewChild('exportMenu'); readonly helpPopover = viewChild.required('helpPopoverEl'); @@ -138,7 +131,7 @@ export class DotEsSearchPageComponent { ...QUERY_EDITOR_OPTIONS, wordWrap: this.store.wrapCode() ? 'on' : 'off' })); - readonly RAW_EDITOR_OPTIONS = RAW_EDITOR_OPTIONS; + readonly RAW_EDITOR_OPTIONS = DOT_MONACO_RAW_OPTIONS; readonly MAX_HITS = MAX_HITS; readonly ComponentStatus = ComponentStatus; @@ -256,21 +249,21 @@ export class DotEsSearchPageComponent { this.helpPopover().hide(); } - copyQuery(query: string): void { - navigator.clipboard.writeText(query).catch(() => this.#globalMessage.error()); - } - copyToClipboard(value: unknown): void { - navigator.clipboard.writeText(String(value ?? '')).catch(() => this.#globalMessage.error()); + this.#copy(String(value ?? '')); } downloadRawJson(): void { - const a = this.#document.createElement('a'); - a.href = `data:application/json;charset=utf-8,${encodeURIComponent(this.store.rawJson())}`; - a.download = 'es-search-results.json'; - this.#document.body.appendChild(a); - a.click(); - this.#document.body.removeChild(a); + const blob = new Blob([this.store.rawJson()], { type: 'application/json' }); + const link = getDownloadLink(blob, 'es-search-results.json'); + this.#document.body.appendChild(link); + link.click(); + this.#document.body.removeChild(link); + } + + async #copy(text: string): Promise { + const ok = await this.#clipboard.copy(text); + if (!ok) this.#globalMessage.error(); } asContentState(contentlet: Record): DotContentState { @@ -342,42 +335,23 @@ export class DotEsSearchPageComponent { } private copyAs(format: 'curl' | 'fetch'): void { - const snippet = format === 'curl' ? this.buildCurlSnippet() : this.buildFetchSnippet(); - navigator.clipboard.writeText(snippet).catch(() => this.#globalMessage.error()); - } - - private buildCurlSnippet(): string { const qs = this.buildApiQueryString(); + const path = `/api/es/search${qs ? '?' + qs : ''}`; const origin = this.#document.defaultView?.location.origin ?? ''; - const url = `${origin}/api/es/search${qs ? '?' + qs : ''}`; - const safeBody = this.store.query().trim().replace(/'/g, `'\\''`); - return [ - `curl -X POST "${url}" \\`, - ` -H "Content-Type: application/json" \\`, - ` -H "Authorization: Bearer " \\`, - ` -d '${safeBody}'` - ].join('\n'); + const body = this.parseQueryBody(); + const snippet = + format === 'curl' + ? buildCurlSnippet({ url: `${origin}${path}`, body }) + : buildFetchSnippet({ url: path, body }); + this.#copy(snippet); } - private buildFetchSnippet(): string { - const qs = this.buildApiQueryString(); - const url = `/api/es/search${qs ? '?' + qs : ''}`; - let parsed: unknown; + private parseQueryBody(): unknown { try { - parsed = JSON.parse(this.store.query()); + return JSON.parse(this.store.query()); } catch { - parsed = {}; + return {}; } - const body = JSON.stringify(parsed, null, 2); - return [ - `const response = await fetch('${url}', {`, - ` method: 'POST',`, - ` credentials: 'include',`, - ` headers: { 'Content-Type': 'application/json' },`, - ` body: JSON.stringify(${body})`, - `});`, - `const data = await response.json();` - ].join('\n'); } private buildApiQueryString(): string { diff --git a/core-web/libs/portlets/dot-query-tool/.eslintrc.json b/core-web/libs/portlets/dot-query-tool/.eslintrc.json new file mode 100644 index 00000000000..ef536cdfaf3 --- /dev/null +++ b/core-web/libs/portlets/dot-query-tool/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["../../../.eslintrc.base.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/core-web/libs/portlets/dot-query-tool/jest.config.ts b/core-web/libs/portlets/dot-query-tool/jest.config.ts new file mode 100644 index 00000000000..fdd2ebd0946 --- /dev/null +++ b/core-web/libs/portlets/dot-query-tool/jest.config.ts @@ -0,0 +1,21 @@ +export default { + displayName: 'portlets-dot-query-tool-portlet', + preset: '../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../../coverage/libs/portlets/dot-query-tool', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$' + } + ] + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment' + ] +}; diff --git a/core-web/libs/portlets/dot-query-tool/project.json b/core-web/libs/portlets/dot-query-tool/project.json new file mode 100644 index 00000000000..6eb9b63e1bf --- /dev/null +++ b/core-web/libs/portlets/dot-query-tool/project.json @@ -0,0 +1,21 @@ +{ + "name": "portlets-dot-query-tool-portlet", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/portlets/dot-query-tool/src", + "prefix": "dot", + "projectType": "library", + "tags": ["type:feature", "scope:dotcms-ui", "portlet:query-tool"], + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/portlets/dot-query-tool/jest.config.ts", + "tsConfig": "libs/portlets/dot-query-tool/tsconfig.spec.json" + } + }, + "lint": { + "executor": "@nx/eslint:lint" + } + } +} diff --git a/core-web/libs/portlets/dot-query-tool/src/index.ts b/core-web/libs/portlets/dot-query-tool/src/index.ts new file mode 100644 index 00000000000..44c9365302f --- /dev/null +++ b/core-web/libs/portlets/dot-query-tool/src/index.ts @@ -0,0 +1 @@ +export * from './lib/lib.routes'; diff --git a/core-web/libs/portlets/dot-query-tool/src/lib/dot-query-tool-page/dot-query-tool-page.component.html b/core-web/libs/portlets/dot-query-tool/src/lib/dot-query-tool-page/dot-query-tool-page.component.html new file mode 100644 index 00000000000..8a16501ab8d --- /dev/null +++ b/core-web/libs/portlets/dot-query-tool/src/lib/dot-query-tool-page/dot-query-tool-page.component.html @@ -0,0 +1,379 @@ + + + +
+ + +
+ {{ 'queryTool.query.label' | dm }} + +
+
+
+ + + + + + + +
+
+ + +
+
+ + +
+
+ + +
+ @if (store.isAdmin()) { +
+ + +
+ } +
+
+ +
+ +
+
+
+ + + +
+ @if ( + store.status() === ComponentStatus.INIT || store.status() === ComponentStatus.ERROR + ) { + @let emptyConfig = store.emptyStateConfig(); + @if (emptyConfig) { + + } + } @else if (store.isLoading()) { +
+ @for (_ of [1, 2, 3, 4, 5]; track $index) { + + } +
+ } @else { +
+
+ + {{ 'queryTool.results.ready' | dm }} + · + + @if (store.resultsSize() > 0) { + {{ + 'queryTool.results.showing' + | dm + : [ + store.contentlets().length.toString(), + store.resultsSize().toString() + ] + }} + } @else { + 0 + {{ 'queryTool.results.count' | dm }} + } + + · + + {{ store.queryTimeMs() ?? 0 }} + {{ 'queryTool.results.timing' | dm }} + +
+ @if (store.hasLoadedResults()) { +
+ + + +
+ } +
+ + @if (store.limitWasCapped()) { + + {{ 'queryTool.results.sizeCapped' | dm: [MAX_RESULTS.toString()] }} + + } + + + + + {{ 'queryTool.results.tab' | dm }} + + + {{ 'queryTool.results.raw' | dm }} + + +
+ + + + @if (store.contentlets().length === 0) { + + } @else { + + + + + {{ 'queryTool.table.title' | dm }} + + + + {{ 'queryTool.table.type' | dm }} + + + + {{ 'queryTool.table.inode' | dm }} + + + + {{ 'queryTool.table.identifier' | dm }} + + + + + + + + + {{ contentlet['title'] || contentlet['inode'] }} + + + {{ contentlet['contentType'] }} + +
+ + {{ contentlet['inode'] }} + + +
+ + +
+ + {{ contentlet['identifier'] }} + + +
+ + +
+
+ } +
+ + + + +
+
+ } +
+
+
+ + +
+

+ {{ 'queryTool.help.title' | dm }} +

+
+ @for (example of helpExamples; track $index) { +
+
+ + {{ example.title | dm }} + +
+ + +
+
+ @if (example.description) { +

+ {{ example.description | dm }} +

+ } +
{{ example.query }}
+
+ } +
+
+
diff --git a/core-web/libs/portlets/dot-query-tool/src/lib/dot-query-tool-page/dot-query-tool-page.component.spec.ts b/core-web/libs/portlets/dot-query-tool/src/lib/dot-query-tool-page/dot-query-tool-page.component.spec.ts new file mode 100644 index 00000000000..b3596b3abc8 --- /dev/null +++ b/core-web/libs/portlets/dot-query-tool/src/lib/dot-query-tool-page/dot-query-tool-page.component.spec.ts @@ -0,0 +1,348 @@ +import { byTestId, createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; +import { of } from 'rxjs'; + +import { Location } from '@angular/common'; +import { ActivatedRoute, convertToParamMap } from '@angular/router'; + +import { + DotContentletEditUrlService, + DotCurrentUserService, + DotGlobalMessageService, + DotHttpErrorManagerService, + DotMessageService +} from '@dotcms/data-access'; +import { ComponentStatus } from '@dotcms/dotcms-models'; +import { DotClipboardUtil } from '@dotcms/ui'; + +import { DotQueryToolPageComponent } from './dot-query-tool-page.component'; +import { DEFAULT_LIMIT, DotQueryToolStore } from './store/dot-query-tool.store'; + +import { DotQueryToolService } from '../services/dot-query-tool.service'; + +const SAMPLE_CONTENTLET = { + inode: 'inode-1', + identifier: 'id-1', + title: 'Home', + contentType: 'htmlpageasset' +}; + +const buildStoreMock = (overrides: Partial> = {}) => ({ + query: jest.fn().mockReturnValue(''), + sort: jest.fn().mockReturnValue(''), + offset: jest.fn().mockReturnValue(0), + limit: jest.fn().mockReturnValue(DEFAULT_LIMIT), + userId: jest.fn().mockReturnValue(''), + isAdmin: jest.fn().mockReturnValue(false), + status: jest.fn().mockReturnValue(ComponentStatus.INIT), + response: jest.fn().mockReturnValue(null), + contentlets: jest.fn().mockReturnValue([]), + resultsSize: jest.fn().mockReturnValue(0), + queryTook: jest.fn().mockReturnValue(0), + contentTook: jest.fn().mockReturnValue(0), + rawJson: jest.fn().mockReturnValue(''), + queryTimeMs: jest.fn().mockReturnValue(null), + activeTab: jest.fn().mockReturnValue('results'), + isLoading: jest.fn().mockReturnValue(false), + hasLoadedResults: jest.fn().mockReturnValue(false), + showingFrom: jest.fn().mockReturnValue(0), + showingTo: jest.fn().mockReturnValue(0), + apiRequestBody: jest + .fn() + .mockReturnValue({ query: '', sort: '', offset: 0, limit: DEFAULT_LIMIT }), + limitWasCapped: jest.fn().mockReturnValue(false), + emptyStateConfig: jest + .fn() + .mockReturnValue({ title: 'Empty', icon: 'pi-search', subtitle: '' }), + setQuery: jest.fn(), + setSort: jest.fn(), + setOffset: jest.fn(), + setLimit: jest.fn(), + setUserId: jest.fn(), + setActiveTab: jest.fn(), + resetOffset: jest.fn(), + runSearch: jest.fn(), + ...overrides +}); + +describe('DotQueryToolPageComponent', () => { + let spectator: Spectator; + let locationReplaceStateSpy: jest.Mock; + let pendingStoreOverrides: Partial> = {}; + + const createComponent = createComponentFactory({ + component: DotQueryToolPageComponent, + overrideComponents: [ + [ + DotQueryToolPageComponent, + { + remove: { providers: [DotQueryToolStore, DotCurrentUserService] }, + add: {} + } + ] + ], + providers: [ + mockProvider(DotMessageService, { get: jest.fn().mockReturnValue('') }), + mockProvider(DotHttpErrorManagerService), + mockProvider(DotGlobalMessageService, { error: jest.fn() }), + mockProvider(DotQueryToolService), + mockProvider(DotContentletEditUrlService, { + resolveEditUrl: jest.fn() + }) + ], + componentProviders: [ + { provide: DotQueryToolStore, useFactory: () => buildStoreMock(pendingStoreOverrides) }, + DotClipboardUtil + ] + }); + + const setup = ( + params: Record = {}, + storeOverrides: Partial> = {} + ) => { + locationReplaceStateSpy = jest.fn(); + pendingStoreOverrides = storeOverrides; + spectator = createComponent({ + providers: [ + { + provide: ActivatedRoute, + useValue: { snapshot: { queryParamMap: convertToParamMap(params) } } + }, + { provide: Location, useValue: { replaceState: locationReplaceStateSpy } } + ] + }); + return spectator.inject(DotQueryToolStore, true); + }; + + afterEach(() => { + pendingStoreOverrides = {}; + }); + + it('creates the component', () => { + setup(); + expect(spectator.component).toBeTruthy(); + }); + + describe('URL state hydration', () => { + it('hydrates store from query params and auto-runs when q is present', () => { + setup({ + q: '+live:true', + offset: '40', + limit: '50', + sort: 'modDate desc', + userId: 'admin@dotcms.com' + }); + const store = spectator.inject(DotQueryToolStore, true); + expect(store.setQuery).toHaveBeenCalledWith('+live:true'); + expect(store.setOffset).toHaveBeenCalledWith(40); + expect(store.setLimit).toHaveBeenCalledWith(50); + expect(store.setSort).toHaveBeenCalledWith('modDate desc'); + expect(store.setUserId).toHaveBeenCalledWith('admin@dotcms.com'); + expect(store.runSearch).toHaveBeenCalled(); + }); + + it('does not auto-run when q is empty', () => { + setup({}); + const store = spectator.inject(DotQueryToolStore, true); + expect(store.runSearch).not.toHaveBeenCalled(); + }); + }); + + describe('Submit button', () => { + it('is a no-op when the query is empty (matches ES Search behavior)', () => { + const store = setup(); + spectator.component.onRun(); + expect(store.resetOffset).not.toHaveBeenCalled(); + expect(store.runSearch).not.toHaveBeenCalled(); + }); + + it('resets offset and triggers runSearch when clicked', () => { + const store = setup(); + store.query = jest.fn().mockReturnValue('+live:true'); + spectator.fixture.componentRef.changeDetectorRef.markForCheck(); + spectator.detectChanges(); + const btn = spectator.query(byTestId('query-tool-run-btn'))?.querySelector('button'); + expect(btn).toBeTruthy(); + if (btn) spectator.click(btn); + expect(store.resetOffset).toHaveBeenCalled(); + expect(store.runSearch).toHaveBeenCalled(); + }); + + it('syncs URL via Location.replaceState only after a search settles (LOADED)', () => { + // status = LOADED + non-default query produces a URL that differs from the + // current router URL, exercising the replaceState branch. + setup( + {}, + { + query: jest.fn().mockReturnValue('+live:true'), + status: jest.fn().mockReturnValue(ComponentStatus.LOADED) + } + ); + expect(locationReplaceStateSpy).toHaveBeenCalled(); + }); + + it('also syncs URL on ERROR so users can share the failing query', () => { + setup( + {}, + { + query: jest.fn().mockReturnValue('+broken:('), + status: jest.fn().mockReturnValue(ComponentStatus.ERROR) + } + ); + expect(locationReplaceStateSpy).toHaveBeenCalled(); + }); + + it('does not sync URL while the search is pending (LOADING) or before any run (INIT)', () => { + setup( + {}, + { + query: jest.fn().mockReturnValue('+live:true'), + status: jest.fn().mockReturnValue(ComponentStatus.LOADING) + } + ); + expect(locationReplaceStateSpy).not.toHaveBeenCalled(); + }); + + it('does not sync URL on first mount with no run yet (status INIT)', () => { + setup(); + expect(locationReplaceStateSpy).not.toHaveBeenCalled(); + }); + }); + + describe('Result title click', () => { + let windowOpenSpy: jest.SpyInstance; + let placeholderWindow: { location: { href: string }; close: jest.Mock }; + + beforeEach(() => { + placeholderWindow = { location: { href: '' }, close: jest.fn() }; + windowOpenSpy = jest + .spyOn(window, 'open') + .mockReturnValue(placeholderWindow as unknown as Window); + }); + + afterEach(() => { + windowOpenSpy.mockRestore(); + }); + + it('opens a placeholder tab synchronously, then assigns the resolved URL', () => { + setup(); + const resolver = spectator.inject(DotContentletEditUrlService); + (resolver.resolveEditUrl as jest.Mock).mockReturnValue( + of('/dotAdmin/#/edit-page/content?url=%2Fabout-us&language_id=1&mId=edit') + ); + + spectator.component.onResultClick(SAMPLE_CONTENTLET as never, new MouseEvent('click')); + + expect(windowOpenSpy).toHaveBeenCalledWith('about:blank', '_blank'); + expect(resolver.resolveEditUrl).toHaveBeenCalledWith(SAMPLE_CONTENTLET); + expect(placeholderWindow.location.href).toBe( + '/dotAdmin/#/edit-page/content?url=%2Fabout-us&language_id=1&mId=edit' + ); + }); + + it('forwards the contentlet to the resolver and assigns the new-editor URL', () => { + setup(); + const resolver = spectator.inject(DotContentletEditUrlService); + (resolver.resolveEditUrl as jest.Mock).mockReturnValue( + of('/dotAdmin/#/content/inode-1') + ); + + spectator.component.onResultClick(SAMPLE_CONTENTLET as never, new MouseEvent('click')); + + expect(placeholderWindow.location.href).toBe('/dotAdmin/#/content/inode-1'); + }); + + it('assigns the legacy-editor URL when the resolver returns the legacy path', () => { + setup(); + const resolver = spectator.inject(DotContentletEditUrlService); + (resolver.resolveEditUrl as jest.Mock).mockReturnValue( + of('/dotAdmin/#/c/content/inode-1') + ); + + spectator.component.onResultClick(SAMPLE_CONTENTLET as never, new MouseEvent('click')); + + expect(placeholderWindow.location.href).toBe('/dotAdmin/#/c/content/inode-1'); + }); + }); + + describe('User ID field gating', () => { + it('hides the User ID input when the user is not admin', () => { + setup(); + expect(spectator.query(byTestId('query-tool-userid-input'))).toBeFalsy(); + }); + }); + + describe('Help popover', () => { + it('renders 4 canonical Lucene example queries', () => { + setup(); + expect(spectator.component.helpExamples).toHaveLength(4); + const queries = spectator.component.helpExamples.map((e) => e.query); + expect(queries).toEqual( + expect.arrayContaining([ + expect.stringContaining('+contentType:htmlpageasset'), + expect.stringContaining('+contentType:fileAsset'), + expect.stringContaining('+title:*demo*'), + expect.stringContaining('+languageId:1') + ]) + ); + }); + }); + + describe('Share menu', () => { + const setupClipboardSpy = () => { + const clipboard = spectator.inject(DotClipboardUtil, true); + return jest.spyOn(clipboard, 'copy').mockResolvedValue(true); + }; + + it('exposes three items (URL, cURL, fetch) with no icons, each command bound', () => { + setup(); + expect(spectator.component.exportItems).toHaveLength(3); + for (const item of spectator.component.exportItems) { + expect(item.icon).toBeUndefined(); + expect(typeof item.command).toBe('function'); + } + }); + + it('Copy shareable URL writes location.href to the clipboard', () => { + setup(); + const copySpy = setupClipboardSpy(); + spectator.component.exportItems[0].command?.({} as never); + expect(copySpy).toHaveBeenCalledWith(window.location.href); + }); + + it('Copy as cURL targets the _search endpoint with the store request body', () => { + const store = setup(); + store.apiRequestBody = jest.fn().mockReturnValue({ + query: '+live:true', + sort: 'modDate desc', + limit: 50, + offset: 20, + userId: 'admin@dotcms.com' + }); + const copySpy = setupClipboardSpy(); + spectator.component.exportItems[1].command?.({} as never); + + expect(copySpy).toHaveBeenCalledTimes(1); + const snippet = copySpy.mock.calls[0][0]; + expect(snippet).toMatch(/^curl -X POST "https?:.*\/api\/v1\/content\/_search"/); + expect(snippet).toContain('"query":"+live:true"'); + expect(snippet).toContain('"sort":"modDate desc"'); + expect(snippet).toContain('"limit":50'); + expect(snippet).toContain('"offset":20'); + expect(snippet).toContain('"userId":"admin@dotcms.com"'); + }); + + it('Copy as fetch emits a fetch() call against the _search endpoint', () => { + const store = setup(); + store.apiRequestBody = jest + .fn() + .mockReturnValue({ query: '+live:true', sort: '', limit: 20, offset: 0 }); + const copySpy = setupClipboardSpy(); + spectator.component.exportItems[2].command?.({} as never); + + const snippet = copySpy.mock.calls[0][0]; + expect(snippet).toContain(`fetch('/api/v1/content/_search'`); + expect(snippet).toContain(`credentials: 'include'`); + expect(snippet).toContain(`"query": "+live:true"`); + }); + }); +}); diff --git a/core-web/libs/portlets/dot-query-tool/src/lib/dot-query-tool-page/dot-query-tool-page.component.ts b/core-web/libs/portlets/dot-query-tool/src/lib/dot-query-tool-page/dot-query-tool-page.component.ts new file mode 100644 index 00000000000..ccc41e5d859 --- /dev/null +++ b/core-web/libs/portlets/dot-query-tool/src/lib/dot-query-tool-page/dot-query-tool-page.component.ts @@ -0,0 +1,297 @@ +import { MonacoEditorModule } from '@materia-ui/ngx-monaco-editor'; + +import { DOCUMENT, Location } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + effect, + inject, + OnInit, + signal, + untracked, + viewChild +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { MenuItem } from 'primeng/api'; +import { ButtonModule } from 'primeng/button'; +import { InputNumberModule } from 'primeng/inputnumber'; +import { InputTextModule } from 'primeng/inputtext'; +import { Menu, MenuModule } from 'primeng/menu'; +import { MessageModule } from 'primeng/message'; +import { PanelModule } from 'primeng/panel'; +import { Popover, PopoverModule } from 'primeng/popover'; +import { SkeletonModule } from 'primeng/skeleton'; +import { SplitterModule } from 'primeng/splitter'; +import { TableModule } from 'primeng/table'; +import { TabsModule } from 'primeng/tabs'; +import { TooltipModule } from 'primeng/tooltip'; + +import { take } from 'rxjs/operators'; + +import { + DotContentletEditUrlService, + DotCurrentUserService, + DotGlobalMessageService, + DotMessageService +} from '@dotcms/data-access'; +import { ComponentStatus, DotCMSContentlet } from '@dotcms/dotcms-models'; +import { + DOT_MONACO_BASE_OPTIONS, + DOT_MONACO_RAW_OPTIONS, + DotClipboardUtil, + DotEmptyContainerComponent, + DotMessagePipe, + PrincipalConfiguration +} from '@dotcms/ui'; +import { buildCurlSnippet, buildFetchSnippet, getDownloadLink } from '@dotcms/utils'; + +import { + DEFAULT_LIMIT, + DEFAULT_OFFSET, + DotQueryToolStore, + MAX_RESULTS +} from './store/dot-query-tool.store'; + +import { QueryToolActiveTab, QueryToolHelpExample } from '../models/dot-query-tool.models'; + +const VALID_TABS = new Set(['results', 'raw']); +const SEARCH_ENDPOINT = '/api/v1/content/_search'; + +const QUERY_EDITOR_OPTIONS = { + ...DOT_MONACO_BASE_OPTIONS, + language: 'plaintext', + wordWrap: 'on' +} as const; + +@Component({ + selector: 'dot-query-tool-page', + imports: [ + FormsModule, + MonacoEditorModule, + SplitterModule, + PanelModule, + InputNumberModule, + InputTextModule, + ButtonModule, + TooltipModule, + TabsModule, + TableModule, + SkeletonModule, + MessageModule, + MenuModule, + PopoverModule, + DotEmptyContainerComponent, + DotMessagePipe + ], + providers: [DotQueryToolStore, DotCurrentUserService, DotClipboardUtil], + templateUrl: './dot-query-tool-page.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'flex flex-col h-full min-h-0 bg-white' } +}) +export class DotQueryToolPageComponent implements OnInit { + readonly store = inject(DotQueryToolStore); + readonly #messageService = inject(DotMessageService); + readonly #globalMessage = inject(DotGlobalMessageService); + readonly #clipboard = inject(DotClipboardUtil); + readonly #document = inject(DOCUMENT); + readonly #router = inject(Router); + readonly #route = inject(ActivatedRoute); + readonly #location = inject(Location); + readonly #editUrlResolver = inject(DotContentletEditUrlService); + + #lastSyncedUrl: string | null = null; + + // Mirrors store state into the address bar via Location.replaceState, but only + // once the search settles (LOADED or ERROR). Tying the sync to `status` instead + // of the inputs means typing in the param fields never touches the URL — only + // an actually-executed search updates the shareable address bar — and the + // user's browser back stack is never polluted (replaceState, not pushState). + readonly updateQueryParamsEffect = effect(() => { + const status = this.store.status(); + if (status !== ComponentStatus.LOADED && status !== ComponentStatus.ERROR) return; + // Read inputs untracked so this effect re-runs only on status transitions. + untracked(() => { + const queryParams: Record = { + q: this.store.query() || null, + offset: this.store.offset() !== DEFAULT_OFFSET ? this.store.offset() : null, + limit: this.store.limit() !== DEFAULT_LIMIT ? this.store.limit() : null, + sort: this.store.sort() || null, + userId: this.store.userId() || null + }; + const url = this.#router + .createUrlTree([], { + relativeTo: this.#route, + queryParams, + queryParamsHandling: 'merge' + }) + .toString(); + if (url === this.#lastSyncedUrl || url === this.#router.url) return; + this.#lastSyncedUrl = url; + this.#location.replaceState(url); + }); + }); + + readonly $helpPopover = viewChild.required('helpPopoverEl'); + readonly $exportMenu = viewChild('exportMenu'); + + readonly QUERY_EDITOR_OPTIONS = QUERY_EDITOR_OPTIONS; + readonly RAW_EDITOR_OPTIONS = DOT_MONACO_RAW_OPTIONS; + readonly ComponentStatus = ComponentStatus; + readonly MAX_RESULTS = MAX_RESULTS; + readonly DEFAULT_LIMIT = DEFAULT_LIMIT; + readonly DEFAULT_OFFSET = DEFAULT_OFFSET; + + readonly splitterPt = { root: { class: 'border-0! rounded-none!' } }; + readonly tabPanelsPt = { root: { class: 'flex-1 min-h-0 overflow-auto p-0!' } }; + readonly tabPanelPt = { root: { class: 'h-full p-0!' } }; + + readonly $paramsOpen = signal(true); + + readonly noHitsConfig: PrincipalConfiguration = { + title: this.#messageService.get('queryTool.results.noHits'), + subtitle: this.#messageService.get('queryTool.results.noHits.subtitle'), + icon: 'pi-search' + }; + + readonly exportItems: MenuItem[] = [ + { + label: this.#messageService.get('queryTool.share.url'), + command: () => this.#copyShareUrl() + }, + { + label: this.#messageService.get('queryTool.share.curl'), + command: () => this.#copyAs('curl') + }, + { + label: this.#messageService.get('queryTool.share.fetch'), + command: () => this.#copyAs('fetch') + } + ]; + + readonly helpExamples: QueryToolHelpExample[] = [ + { + title: 'queryTool.help.example.live.title', + description: 'queryTool.help.example.live.description', + query: '+contentType:htmlpageasset +live:true +languageId:1' + }, + { + title: 'queryTool.help.example.recent.title', + description: 'queryTool.help.example.recent.description', + query: '+contentType:fileAsset +deleted:false +modDate:[20250101 TO 20991231]' + }, + { + title: 'queryTool.help.example.wildcard.title', + description: 'queryTool.help.example.wildcard.description', + query: '+title:*demo* +working:true' + }, + { + title: 'queryTool.help.example.everything.title', + description: 'queryTool.help.example.everything.description', + query: '+languageId:1 +deleted:false' + } + ]; + + ngOnInit(): void { + const params = this.#route.snapshot.queryParamMap; + const query = params.get('q') ?? ''; + const offset = this.#parseInt(params.get('offset'), DEFAULT_OFFSET); + const limit = this.#parseInt(params.get('limit'), DEFAULT_LIMIT); + const sort = params.get('sort') ?? ''; + const userId = params.get('userId') ?? ''; + + this.store.setQuery(query); + this.store.setOffset(offset); + this.store.setLimit(limit); + this.store.setSort(sort); + this.store.setUserId(userId); + + if (query.trim()) { + this.store.runSearch(); + } + } + + onQueryChange(value: string): void { + this.store.setQuery(value); + } + + onTabChange(value: string): void { + if (VALID_TABS.has(value as QueryToolActiveTab)) { + this.store.setActiveTab(value as QueryToolActiveTab); + } + } + + onRun(): void { + if (!this.store.query().trim()) return; + this.store.resetOffset(); + this.store.runSearch(); + } + + onResultClick(contentlet: DotCMSContentlet, event: MouseEvent): void { + event.preventDefault(); + // Open the placeholder synchronously so the popup blocker accepts it; assign the + // resolved URL once DotContentletEditUrlService returns. The resolver may answer + // synchronously (HTMLPAGE / cached content type) — that's fine, subscribe still + // delivers on the same tick. + const placeholder = window.open('about:blank', '_blank'); + if (!placeholder) return; + this.#editUrlResolver + .resolveEditUrl(contentlet) + .pipe(take(1)) + .subscribe({ + next: (url) => (placeholder.location.href = url), + error: () => { + placeholder.close(); + this.#globalMessage.error(); + } + }); + } + + useExample(query: string): void { + this.store.setQuery(query); + this.$helpPopover().hide(); + } + + copyToClipboard(value: unknown): void { + this.#copy(String(value ?? '')); + } + + downloadRawJson(): void { + const blob = new Blob([this.store.rawJson()], { type: 'application/json' }); + const link = getDownloadLink(blob, 'query-tool-results.json'); + this.#document.body.appendChild(link); + link.click(); + this.#document.body.removeChild(link); + } + + toggleExportMenu(event: MouseEvent): void { + this.$exportMenu()?.toggle(event); + } + + #copyShareUrl(): void { + const href = this.#document.defaultView?.location.href; + if (href) this.#copy(href); + } + + #copyAs(format: 'curl' | 'fetch'): void { + const body = this.store.apiRequestBody(); + if (format === 'curl') { + const origin = this.#document.defaultView?.location.origin ?? ''; + this.#copy(buildCurlSnippet({ url: `${origin}${SEARCH_ENDPOINT}`, body })); + } else { + this.#copy(buildFetchSnippet({ url: SEARCH_ENDPOINT, body })); + } + } + + async #copy(text: string): Promise { + const ok = await this.#clipboard.copy(text); + if (!ok) this.#globalMessage.error(); + } + + #parseInt(value: string | null, fallback: number): number { + if (!value) return fallback; + const n = Number.parseInt(value, 10); + return Number.isFinite(n) ? n : fallback; + } +} diff --git a/core-web/libs/portlets/dot-query-tool/src/lib/dot-query-tool-page/store/dot-query-tool.store.spec.ts b/core-web/libs/portlets/dot-query-tool/src/lib/dot-query-tool-page/store/dot-query-tool.store.spec.ts new file mode 100644 index 00000000000..c63bc8aca5f --- /dev/null +++ b/core-web/libs/portlets/dot-query-tool/src/lib/dot-query-tool-page/store/dot-query-tool.store.spec.ts @@ -0,0 +1,209 @@ +import { createServiceFactory, mockProvider, SpectatorService } from '@ngneat/spectator/jest'; +import { EMPTY, of, throwError } from 'rxjs'; + +import { + DotCurrentUserService, + DotHttpErrorManagerService, + DotMessageService +} from '@dotcms/data-access'; +import { ComponentStatus } from '@dotcms/dotcms-models'; + +import { + DEFAULT_LIMIT, + DEFAULT_OFFSET, + DotQueryToolStore, + MAX_RESULTS +} from './dot-query-tool.store'; + +import { DotQueryToolService } from '../../services/dot-query-tool.service'; + +const MOCK_RESPONSE = { + resultsSize: 3, + queryTook: 12, + contentTook: 34, + jsonObjectView: { + contentlets: [ + { inode: 'a', title: 'A', contentType: 'Blog', identifier: 'id-a' } as never, + { inode: 'b', title: 'B', contentType: 'Blog', identifier: 'id-b' } as never + ] + } +}; + +describe('DotQueryToolStore', () => { + let spectator: SpectatorService>; + let searchSpy: jest.Mock; + + const createService = createServiceFactory({ + service: DotQueryToolStore, + providers: [ + mockProvider(DotQueryToolService, { + search: jest.fn().mockReturnValue(of(MOCK_RESPONSE)) + }), + mockProvider(DotHttpErrorManagerService, { handle: jest.fn() }), + mockProvider(DotMessageService, { get: jest.fn().mockReturnValue('') }), + mockProvider(DotCurrentUserService, { + getCurrentUser: jest.fn().mockReturnValue(of({ admin: true })) + }) + ] + }); + + beforeEach(() => { + spectator = createService(); + spectator.flushEffects(); + searchSpy = spectator.inject(DotQueryToolService).search as jest.Mock; + searchSpy.mockClear(); + searchSpy.mockReturnValue(of(MOCK_RESPONSE)); + }); + + it('initialises with INIT status and default pagination', () => { + expect(spectator.service.status()).toBe(ComponentStatus.INIT); + expect(spectator.service.response()).toBeNull(); + expect(spectator.service.offset()).toBe(DEFAULT_OFFSET); + expect(spectator.service.limit()).toBe(DEFAULT_LIMIT); + }); + + it('hydrates isAdmin from the current user service', () => { + expect(spectator.service.isAdmin()).toBe(true); + }); + + describe('setters', () => { + it('updates query', () => { + spectator.service.setQuery('+live:true'); + expect(spectator.service.query()).toBe('+live:true'); + }); + + it('clamps negative offsets to 0', () => { + spectator.service.setOffset(-50); + expect(spectator.service.offset()).toBe(0); + }); + + it('clamps limit below 1 to 1', () => { + spectator.service.setLimit(0); + expect(spectator.service.limit()).toBe(1); + }); + }); + + describe('runSearch', () => { + it('sends the current state and stores the response', () => { + spectator.service.setQuery('+live:true'); + spectator.service.setSort('modDate desc'); + spectator.service.setOffset(20); + spectator.service.setLimit(10); + + spectator.service.runSearch(); + + expect(searchSpy).toHaveBeenCalledWith({ + query: '+live:true', + sort: 'modDate desc', + offset: 20, + limit: 10 + }); + expect(spectator.service.status()).toBe(ComponentStatus.LOADED); + expect(spectator.service.response()).toEqual(MOCK_RESPONSE); + expect(spectator.service.resultsSize()).toBe(3); + }); + + it('forwards userId when set (server enforces admin gate)', () => { + spectator.service.setQuery('+live:true'); + spectator.service.setUserId('admin@dotcms.com'); + + spectator.service.runSearch(); + + expect(searchSpy).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'admin@dotcms.com' }) + ); + }); + + it('omits userId when blank', () => { + spectator.service.setQuery('+live:true'); + spectator.service.runSearch(); + const payload = searchSpy.mock.calls[0][0]; + expect(payload.userId).toBeUndefined(); + }); + + it('snaps limit to MAX_RESULTS and sets limitWasCapped when user runs an oversized search', () => { + spectator.service.setQuery('+live:true'); + spectator.service.setLimit(5000); + spectator.service.runSearch(); + expect(spectator.service.limit()).toBe(MAX_RESULTS); + expect(spectator.service.limitWasCapped()).toBe(true); + expect(searchSpy.mock.calls[0][0].limit).toBe(MAX_RESULTS); + }); + + it('does not flip limitWasCapped until a search actually runs', () => { + spectator.service.setLimit(MAX_RESULTS + 1); + expect(spectator.service.limitWasCapped()).toBe(false); + }); + + it('keeps limitWasCapped true after the user edits the limit, until the next runSearch', () => { + spectator.service.setQuery('+live:true'); + spectator.service.setLimit(5000); + spectator.service.runSearch(); + expect(spectator.service.limitWasCapped()).toBe(true); + // User edits the input; the warning must still describe the displayed results. + spectator.service.setLimit(50); + expect(spectator.service.limitWasCapped()).toBe(true); + // A fresh non-capped run clears the warning. + spectator.service.runSearch(); + expect(spectator.service.limitWasCapped()).toBe(false); + }); + + it('clears limitWasCapped synchronously on runSearch (no fade behind the loading state)', () => { + // Seed a capped state, then verify a fresh runSearch hides the warning before + // the response arrives — observed via a deferred search observable. + spectator.service.setQuery('+live:true'); + spectator.service.setLimit(5000); + spectator.service.runSearch(); + expect(spectator.service.limitWasCapped()).toBe(true); + + // Issue a non-capped run; mock the service to never resolve so we can + // observe the in-flight state. + spectator.service.setLimit(50); + searchSpy.mockReturnValueOnce(EMPTY); + spectator.service.runSearch(); + expect(spectator.service.limitWasCapped()).toBe(false); + expect(spectator.service.status()).toBe(ComponentStatus.LOADING); + }); + + it('leaves limit alone and keeps limitWasCapped false when under MAX_RESULTS', () => { + spectator.service.setQuery('+live:true'); + spectator.service.setLimit(50); + spectator.service.runSearch(); + expect(spectator.service.limit()).toBe(50); + expect(spectator.service.limitWasCapped()).toBe(false); + }); + + it('routes errors through DotHttpErrorManagerService and sets ERROR status', () => { + const error = { status: 500 } as unknown; + searchSpy.mockReturnValueOnce(throwError(() => error)); + const handler = spectator.inject(DotHttpErrorManagerService).handle as jest.Mock; + + spectator.service.setQuery('+live:true'); + spectator.service.runSearch(); + + expect(handler).toHaveBeenCalledWith(error); + expect(spectator.service.status()).toBe(ComponentStatus.ERROR); + }); + }); + + describe('computeds', () => { + beforeEach(() => { + spectator.service.setQuery('+live:true'); + spectator.service.runSearch(); + }); + + it('rawJson renders the response as pretty JSON', () => { + expect(spectator.service.rawJson()).toContain('"resultsSize"'); + }); + + it('showingFrom is offset+1 when there are hits', () => { + spectator.service.setOffset(10); + spectator.service.runSearch(); + expect(spectator.service.showingFrom()).toBe(3); + }); + + it('hasLoadedResults reflects loaded state plus non-empty contentlets', () => { + expect(spectator.service.hasLoadedResults()).toBe(true); + }); + }); +}); diff --git a/core-web/libs/portlets/dot-query-tool/src/lib/dot-query-tool-page/store/dot-query-tool.store.ts b/core-web/libs/portlets/dot-query-tool/src/lib/dot-query-tool-page/store/dot-query-tool.store.ts new file mode 100644 index 00000000000..65c5e150040 --- /dev/null +++ b/core-web/libs/portlets/dot-query-tool/src/lib/dot-query-tool-page/store/dot-query-tool.store.ts @@ -0,0 +1,208 @@ +import { tapResponse } from '@ngrx/operators'; +import { + patchState, + signalStore, + withComputed, + withHooks, + withMethods, + withState +} from '@ngrx/signals'; +import { rxMethod } from '@ngrx/signals/rxjs-interop'; +import { EMPTY, pipe } from 'rxjs'; + +import { HttpErrorResponse } from '@angular/common/http'; +import { computed, inject } from '@angular/core'; + +import { catchError, switchMap, take } from 'rxjs/operators'; + +import { + DotCurrentUserService, + DotHttpErrorManagerService, + DotMessageService +} from '@dotcms/data-access'; +import { ComponentStatus, DotCMSContentlet } from '@dotcms/dotcms-models'; +import { PrincipalConfiguration } from '@dotcms/ui'; + +import { + QueryToolActiveTab, + QueryToolSearchForm, + QueryToolSearchResponse +} from '../../models/dot-query-tool.models'; +import { DotQueryToolService } from '../../services/dot-query-tool.service'; + +export const DEFAULT_LIMIT = 20; +export const DEFAULT_OFFSET = 0; +export const MAX_RESULTS = 1000; + +export interface QueryToolState { + query: string; + sort: string; + offset: number; + limit: number; + userId: string; + isAdmin: boolean; + status: ComponentStatus; + response: QueryToolSearchResponse | null; + queryTimeMs: number | null; + activeTab: QueryToolActiveTab; + emptyStateConfig: PrincipalConfiguration | null; + // True iff the LAST executed search was capped at MAX_RESULTS. Set during + // runSearch, never during setLimit — the warning describes the displayed + // results and must persist while those results are on screen, even if the + // user is mid-edit on the limit input for a future search. Cleared on the + // next runSearch that does not need to cap. + limitWasCapped: boolean; +} + +const initialState: QueryToolState = { + query: '', + sort: '', + offset: DEFAULT_OFFSET, + limit: DEFAULT_LIMIT, + userId: '', + isAdmin: false, + status: ComponentStatus.INIT, + response: null, + queryTimeMs: null, + activeTab: 'results', + emptyStateConfig: null, + limitWasCapped: false +}; + +export const DotQueryToolStore = signalStore( + withState(initialState), + withComputed((store) => ({ + contentlets: computed( + () => store.response()?.jsonObjectView.contentlets ?? [] + ), + resultsSize: computed(() => store.response()?.resultsSize ?? 0), + queryTook: computed(() => store.response()?.queryTook ?? 0), + contentTook: computed(() => store.response()?.contentTook ?? 0), + isLoading: computed(() => store.status() === ComponentStatus.LOADING), + hasLoadedResults: computed( + () => + store.status() === ComponentStatus.LOADED && + (store.response()?.jsonObjectView.contentlets.length ?? 0) > 0 + ), + rawJson: computed(() => { + const response = store.response(); + return response ? JSON.stringify(response, null, 2) : ''; + }), + showingFrom: computed(() => { + const total = store.response()?.resultsSize ?? 0; + const returned = store.response()?.jsonObjectView.contentlets.length ?? 0; + return returned === 0 ? 0 : Math.min(store.offset() + 1, total); + }), + showingTo: computed(() => { + const returned = store.response()?.jsonObjectView.contentlets.length ?? 0; + return store.offset() + returned; + }), + apiRequestBody: computed(() => { + const limit = Math.min(store.limit(), MAX_RESULTS); + const userId = store.userId(); + return { + query: store.query(), + sort: store.sort(), + offset: store.offset(), + limit, + ...(userId ? { userId } : {}) + }; + }) + })), + withMethods( + ( + store, + queryToolService = inject(DotQueryToolService), + httpErrorManager = inject(DotHttpErrorManagerService) + ) => ({ + setQuery(query: string): void { + patchState(store, { query }); + }, + setSort(sort: string): void { + patchState(store, { sort }); + }, + setOffset(offset: number): void { + patchState(store, { offset: Math.max(0, offset) }); + }, + setLimit(limit: number): void { + patchState(store, { limit: Math.max(1, limit) }); + }, + setUserId(userId: string): void { + patchState(store, { userId }); + }, + setActiveTab(tab: QueryToolActiveTab): void { + patchState(store, { activeTab: tab }); + }, + resetOffset(): void { + patchState(store, { offset: 0 }); + }, + runSearch: rxMethod( + pipe( + switchMap(() => { + // Capture the user-typed limit *now*; we'll decide whether to snap + // and warn only after the response arrives, so the user sees the + // cap explanation alongside the capped results instead of an input + // value silently changing mid-loading. Meanwhile, apiRequestBody + // already clamps the request limit via Math.min, so the network + // call still respects MAX_RESULTS. + const userLimit = store.limit(); + const wasCapped = userLimit > MAX_RESULTS; + patchState(store, { + status: ComponentStatus.LOADING, + activeTab: 'results', + // Hide any stale warning immediately so the loading state isn't + // covered by a banner that describes the previous results. + limitWasCapped: false + }); + const start = Date.now(); + return queryToolService.search(store.apiRequestBody()).pipe( + tapResponse({ + next: (response) => { + patchState(store, { + status: ComponentStatus.LOADED, + response, + queryTimeMs: Date.now() - start, + // Snap the input + raise the warning together with + // the rendered results — one cohesive state change. + limit: wasCapped ? MAX_RESULTS : userLimit, + limitWasCapped: wasCapped + }); + }, + error: (error: HttpErrorResponse) => { + patchState(store, { status: ComponentStatus.ERROR }); + httpErrorManager.handle(error); + } + }) + ); + }) + ) + ) + }) + ), + withHooks({ + onInit(store) { + const messageService = inject(DotMessageService); + const currentUserService = inject(DotCurrentUserService); + const httpErrorManager = inject(DotHttpErrorManagerService); + + patchState(store, { + emptyStateConfig: { + title: messageService.get('queryTool.results.empty'), + icon: 'pi-search', + subtitle: messageService.get('queryTool.results.empty.hint') + } + }); + + currentUserService + .getCurrentUser() + .pipe( + take(1), + catchError((error: HttpErrorResponse) => { + httpErrorManager.handle(error); + return EMPTY; + }) + ) + .subscribe(({ admin }) => patchState(store, { isAdmin: admin })); + } + }) +); diff --git a/core-web/libs/portlets/dot-query-tool/src/lib/dot-query-tool-shell/dot-query-tool-shell.component.ts b/core-web/libs/portlets/dot-query-tool/src/lib/dot-query-tool-shell/dot-query-tool-shell.component.ts new file mode 100644 index 00000000000..8325b91cf30 --- /dev/null +++ b/core-web/libs/portlets/dot-query-tool/src/lib/dot-query-tool-shell/dot-query-tool-shell.component.ts @@ -0,0 +1,14 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; + +import { DotQueryToolPageComponent } from '../dot-query-tool-page/dot-query-tool-page.component'; + +@Component({ + selector: 'dot-query-tool-shell', + imports: [DotQueryToolPageComponent], + template: ` + + `, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'flex flex-col h-full min-h-0 block' } +}) +export class DotQueryToolShellComponent {} diff --git a/core-web/libs/portlets/dot-query-tool/src/lib/lib.routes.ts b/core-web/libs/portlets/dot-query-tool/src/lib/lib.routes.ts new file mode 100644 index 00000000000..e0577053d1f --- /dev/null +++ b/core-web/libs/portlets/dot-query-tool/src/lib/lib.routes.ts @@ -0,0 +1,10 @@ +import { Route } from '@angular/router'; + +import { DotQueryToolShellComponent } from './dot-query-tool-shell/dot-query-tool-shell.component'; + +export const dotQueryToolRoutes: Route[] = [ + { + path: '', + component: DotQueryToolShellComponent + } +]; diff --git a/core-web/libs/portlets/dot-query-tool/src/lib/models/dot-query-tool.models.ts b/core-web/libs/portlets/dot-query-tool/src/lib/models/dot-query-tool.models.ts new file mode 100644 index 00000000000..540f961bb05 --- /dev/null +++ b/core-web/libs/portlets/dot-query-tool/src/lib/models/dot-query-tool.models.ts @@ -0,0 +1,26 @@ +import { DotCMSContentlet } from '@dotcms/dotcms-models'; + +export interface QueryToolSearchForm { + query: string; + sort: string; + limit: number; + offset: number; + userId?: string; +} + +export interface QueryToolSearchResponse { + resultsSize: number; + queryTook: number; + contentTook: number; + jsonObjectView: { + contentlets: DotCMSContentlet[]; + }; +} + +export interface QueryToolHelpExample { + title: string; + query: string; + description?: string; +} + +export type QueryToolActiveTab = 'results' | 'raw'; diff --git a/core-web/libs/portlets/dot-query-tool/src/lib/services/dot-query-tool.service.spec.ts b/core-web/libs/portlets/dot-query-tool/src/lib/services/dot-query-tool.service.spec.ts new file mode 100644 index 00000000000..17d38418f6d --- /dev/null +++ b/core-web/libs/portlets/dot-query-tool/src/lib/services/dot-query-tool.service.spec.ts @@ -0,0 +1,54 @@ +import { createHttpFactory, HttpMethod, SpectatorHttp } from '@ngneat/spectator/jest'; + +import { DotQueryToolService } from './dot-query-tool.service'; + +describe('DotQueryToolService', () => { + let spectator: SpectatorHttp; + + const createService = createHttpFactory(DotQueryToolService); + + beforeEach(() => { + spectator = createService(); + }); + + it('should POST SearchForm to /api/v1/content/_search and unwrap entity', () => { + const form = { + query: '+contentType:htmlpageasset', + sort: 'modDate desc', + offset: 0, + limit: 20 + }; + const entity = { + resultsSize: 1, + queryTook: 12, + contentTook: 34, + jsonObjectView: { contentlets: [{ inode: 'abc' }] } + }; + + let received: unknown; + spectator.service.search(form).subscribe((response) => { + received = response; + }); + + const req = spectator.expectOne('/api/v1/content/_search', HttpMethod.POST); + expect(req.request.body).toEqual(form); + req.flush({ entity }); + + expect(received).toEqual(entity); + }); + + it('should forward the userId field when provided', () => { + spectator.service + .search({ + query: '+live:true', + sort: '', + offset: 0, + limit: 5, + userId: 'admin@dotcms.com' + }) + .subscribe(); + + const req = spectator.expectOne('/api/v1/content/_search', HttpMethod.POST); + expect(req.request.body.userId).toBe('admin@dotcms.com'); + }); +}); diff --git a/core-web/libs/portlets/dot-query-tool/src/lib/services/dot-query-tool.service.ts b/core-web/libs/portlets/dot-query-tool/src/lib/services/dot-query-tool.service.ts new file mode 100644 index 00000000000..60564e52d25 --- /dev/null +++ b/core-web/libs/portlets/dot-query-tool/src/lib/services/dot-query-tool.service.ts @@ -0,0 +1,23 @@ +import { Observable } from 'rxjs'; + +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; + +import { map } from 'rxjs/operators'; + +import { QueryToolSearchForm, QueryToolSearchResponse } from '../models/dot-query-tool.models'; + +interface SearchEnvelope { + entity: QueryToolSearchResponse; +} + +@Injectable({ providedIn: 'root' }) +export class DotQueryToolService { + readonly #http = inject(HttpClient); + + search(form: QueryToolSearchForm): Observable { + return this.#http + .post('/api/v1/content/_search', form) + .pipe(map((response) => response.entity)); + } +} diff --git a/core-web/libs/portlets/dot-query-tool/src/test-setup.ts b/core-web/libs/portlets/dot-query-tool/src/test-setup.ts new file mode 100644 index 00000000000..29b4a8b073c --- /dev/null +++ b/core-web/libs/portlets/dot-query-tool/src/test-setup.ts @@ -0,0 +1,18 @@ +import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone'; + +setupZoneTestEnv({ + errorOnUnknownElements: true, + errorOnUnknownProperties: true +}); + +class MockResizeObserver { + observe = jest.fn(); + unobserve = jest.fn(); + disconnect = jest.fn(); +} + +Object.defineProperty(window, 'ResizeObserver', { + writable: true, + configurable: true, + value: MockResizeObserver +}); diff --git a/core-web/libs/portlets/dot-query-tool/tsconfig.json b/core-web/libs/portlets/dot-query-tool/tsconfig.json new file mode 100644 index 00000000000..caf25f65cb5 --- /dev/null +++ b/core-web/libs/portlets/dot-query-tool/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "target": "es2022", + "moduleResolution": "bundler", + "useDefineForClassFields": false, + "forceConsistentCasingInFileNames": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "module": "preserve", + "lib": ["dom", "dom.iterable", "es2022"] + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "typeCheckHostBindings": true, + "strictTemplates": true + }, + "files": [], + "include": [], + "references": [{ "path": "./tsconfig.lib.json" }, { "path": "./tsconfig.spec.json" }] +} diff --git a/core-web/libs/portlets/dot-query-tool/tsconfig.lib.json b/core-web/libs/portlets/dot-query-tool/tsconfig.lib.json new file mode 100644 index 00000000000..a9e4700b870 --- /dev/null +++ b/core-web/libs/portlets/dot-query-tool/tsconfig.lib.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [], + "moduleResolution": "bundler" + }, + "exclude": ["src/**/*.spec.ts", "src/test-setup.ts", "jest.config.ts", "src/**/*.test.ts"], + "include": ["src/**/*.ts"] +} diff --git a/core-web/libs/portlets/dot-query-tool/tsconfig.spec.json b/core-web/libs/portlets/dot-query-tool/tsconfig.spec.json new file mode 100644 index 00000000000..ba660a89989 --- /dev/null +++ b/core-web/libs/portlets/dot-query-tool/tsconfig.spec.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "module": "preserve", + "target": "es2016", + "types": ["jest", "node"], + "moduleResolution": "bundler", + "isolatedModules": true + }, + "files": ["src/test-setup.ts"], + "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/core-web/libs/ui/src/index.ts b/core-web/libs/ui/src/index.ts index 559deb6e7c0..72b86bbe537 100644 --- a/core-web/libs/ui/src/index.ts +++ b/core-web/libs/ui/src/index.ts @@ -84,5 +84,8 @@ export * from './lib/validators/dotValidators'; // Animations export * from './lib/animations/fade.animations'; +// Monaco editor presets +export * from './lib/monaco/editor-options'; + // Theme export * from './lib/theme'; diff --git a/core-web/libs/ui/src/lib/monaco/editor-options.ts b/core-web/libs/ui/src/lib/monaco/editor-options.ts new file mode 100644 index 00000000000..7b5b67052e9 --- /dev/null +++ b/core-web/libs/ui/src/lib/monaco/editor-options.ts @@ -0,0 +1,25 @@ +/** + * Shared base options for `ngx-monaco-editor` instances used across dotCMS portlets. + * Consumers add `language`, `wordWrap`, etc. on top of this. + */ +export const DOT_MONACO_BASE_OPTIONS = { + theme: 'vs', + minimap: { enabled: false }, + lineNumbers: 'on', + scrollBeyondLastLine: false, + automaticLayout: true, + fontSize: 13, + fontFamily: 'JetBrains Mono, Fira Code, Consolas, monospace' +} as const; + +/** + * Read-only JSON viewer preset with bottom padding so the closing brace + * doesn't sit flush against the viewport edge when scrolled. + */ +export const DOT_MONACO_RAW_OPTIONS = { + ...DOT_MONACO_BASE_OPTIONS, + language: 'json', + readOnly: true, + lineNumbers: 'off', + padding: { top: 12, bottom: 24 } +} as const; diff --git a/core-web/libs/utils/src/lib/dot-utils.spec.ts b/core-web/libs/utils/src/lib/dot-utils.spec.ts index 9d9ae27ff78..20af5ac34a1 100644 --- a/core-web/libs/utils/src/lib/dot-utils.spec.ts +++ b/core-web/libs/utils/src/lib/dot-utils.spec.ts @@ -5,6 +5,8 @@ import { } from '@dotcms/dotcms-models'; import { + buildCurlSnippet, + buildFetchSnippet, getImageAssetUrl, ellipsizeText, getRunnableLink, @@ -539,4 +541,43 @@ describe('Dot Utils', () => { expect(mapParamsFromEditContentlet(cdParams)).toEqual({}); }); }); + + describe('buildCurlSnippet', () => { + it('produces a POST curl command with JSON body', () => { + const snippet = buildCurlSnippet({ + url: 'https://demo.dotcms.com/api/v1/content/_search', + body: { query: '+live:true', limit: 20 } + }); + + expect(snippet).toContain( + 'curl -X POST "https://demo.dotcms.com/api/v1/content/_search"' + ); + expect(snippet).toContain('"Content-Type: application/json"'); + expect(snippet).toContain(`-d '{"query":"+live:true","limit":20}'`); + }); + + it('escapes single quotes in the body using POSIX shell quoting', () => { + const snippet = buildCurlSnippet({ + url: 'https://x/y', + body: { title: "it's" } + }); + expect(snippet).toContain(`"title":"it'\\''s"`); + expect(snippet).not.toContain(`"title":"it's"`); + }); + }); + + describe('buildFetchSnippet', () => { + it('emits a fetch() POST with credentials and an indented body', () => { + const snippet = buildFetchSnippet({ + url: '/api/v1/content/_search', + body: { query: '+live:true', limit: 20 } + }); + + expect(snippet).toContain(`fetch('/api/v1/content/_search'`); + expect(snippet).toContain(`method: 'POST'`); + expect(snippet).toContain(`credentials: 'include'`); + expect(snippet).toContain(`"query": "+live:true"`); + expect(snippet).toContain(`"limit": 20`); + }); + }); }); diff --git a/core-web/libs/utils/src/lib/dot-utils.ts b/core-web/libs/utils/src/lib/dot-utils.ts index 8abd2ad2649..750cb2b3bed 100644 --- a/core-web/libs/utils/src/lib/dot-utils.ts +++ b/core-web/libs/utils/src/lib/dot-utils.ts @@ -18,6 +18,41 @@ export function getDownloadLink(blob: Blob, fileName: string): HTMLAnchorElement return anchor; } +export interface ApiSnippetParams { + url: string; + body: unknown; +} + +/** + * Build a curl POST snippet for a JSON API call. + * Escapes single quotes in the body using POSIX shell quoting. + */ +export function buildCurlSnippet({ url, body }: ApiSnippetParams): string { + const safeBody = JSON.stringify(body).replace(/'/g, `'\\''`); + return [ + `curl -X POST "${url}" \\`, + ` -H "Content-Type: application/json" \\`, + ` -H "Authorization: Bearer " \\`, + ` -d '${safeBody}'` + ].join('\n'); +} + +/** + * Build a browser fetch() POST snippet for a JSON API call. + */ +export function buildFetchSnippet({ url, body }: ApiSnippetParams): string { + const formatted = JSON.stringify(body, null, 2); + return [ + `const response = await fetch('${url}', {`, + ` method: 'POST',`, + ` credentials: 'include',`, + ` headers: { 'Content-Type': 'application/json' },`, + ` body: JSON.stringify(${formatted})`, + `});`, + `const data = await response.json();` + ].join('\n'); +} + // Replace {n} in the string with the strings in the args array export function formatMessage(message: string, args: string[]): string { return message.replace(/{(\d+)}/g, (match, number) => { diff --git a/core-web/tsconfig.base.json b/core-web/tsconfig.base.json index 134f232d2b1..f436ff3d191 100644 --- a/core-web/tsconfig.base.json +++ b/core-web/tsconfig.base.json @@ -73,6 +73,9 @@ ], "@dotcms/portlets/dot-plugins/portlet": ["libs/portlets/dot-plugins/src/index.ts"], "@dotcms/portlets/dot-es-search/portlet": ["libs/portlets/dot-es-search/src/index.ts"], + "@dotcms/portlets/dot-query-tool/portlet": [ + "libs/portlets/dot-query-tool/src/index.ts" + ], "@dotcms/portlets/dot-tags/portlet": ["libs/portlets/dot-tags/src/index.ts"], "@dotcms/portlets/dot-usage": ["libs/portlets/dot-usage/src/index.ts"], "@dotcms/query-builder": [ diff --git a/dotCMS/src/main/java/com/dotmarketing/util/PortletID.java b/dotCMS/src/main/java/com/dotmarketing/util/PortletID.java index 25131b82075..cbe89334f58 100644 --- a/dotCMS/src/main/java/com/dotmarketing/util/PortletID.java +++ b/dotCMS/src/main/java/com/dotmarketing/util/PortletID.java @@ -33,7 +33,8 @@ public enum PortletID { MY_ACCOUNT, PERSONAS, PUBLISHING_QUEUE, - QUERY_TOOL, + QUERY_TOOL, + QUERY_TOOL_LEGACY("query-tool-legacy"), TAGS, TAGS_LEGACY("tags-legacy"), TEMPLATES, diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 33667ecc4a3..2af698fc7c7 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -690,6 +690,7 @@ com.dotcms.repackage.javax.portlet.title.NetworkPortlet=Network com.dotcms.repackage.javax.portlet.title.personas=Personas com.dotcms.repackage.javax.portlet.title.publishing-queue=Publishing Queue com.dotcms.repackage.javax.portlet.title.query-tool=Query Tool +com.dotcms.repackage.javax.portlet.title.query-tool-legacy=Query Tool Legacy com.dotcms.repackage.javax.portlet.title.reports=Reports com.dotcms.repackage.javax.portlet.title.REST_EXAMPLE_PORTLET=Rest Example Portlet com.dotcms.repackage.javax.portlet.title.roles=Roles & Tools @@ -1667,7 +1668,7 @@ ES-QUERY-NOT-LICENSED=The ES Search Tool is a (seconds) diff --git a/dotCMS/src/main/webapp/WEB-INF/portlet.xml b/dotCMS/src/main/webapp/WEB-INF/portlet.xml index be9d86c98c7..527a931e783 100644 --- a/dotCMS/src/main/webapp/WEB-INF/portlet.xml +++ b/dotCMS/src/main/webapp/WEB-INF/portlet.xml @@ -236,7 +236,14 @@ query-tool - Lucene Test Tool + Query Tool + com.dotcms.spring.portlet.PortletController + /query-tool + + + + query-tool-legacy + Query Tool (Legacy) com.liferay.portlet.JSPPortlet view-jsp diff --git a/dotCMS/src/test/java/com/dotmarketing/business/portal/SerializationHelperTest.java b/dotCMS/src/test/java/com/dotmarketing/business/portal/SerializationHelperTest.java index 67dde365205..3441f954628 100644 --- a/dotCMS/src/test/java/com/dotmarketing/business/portal/SerializationHelperTest.java +++ b/dotCMS/src/test/java/com/dotmarketing/business/portal/SerializationHelperTest.java @@ -35,7 +35,7 @@ public void testFromXmlFile() throws IOException { Logger.debug(SerializationHelperTest.class,"Loaded src/main/webapp/WEB-INF/portlet.xml:"+portletList.toString()); Logger.info(SerializationHelperTest.class, "Loaded portlet.xml: found: " + portletList.getPortlets().size() + " portlets"); assertNotNull("Deserialized PortletList should not be null", portletList); - assertEquals("PortletList should contain exactly 52 portlets", 52, portletList.getPortlets().size()); + assertEquals("PortletList should contain exactly 53 portlets", 53, portletList.getPortlets().size()); // Check for specific portlets assertTrue("PortletList should contain 'categories' portlet", @@ -46,6 +46,8 @@ public void testFromXmlFile() throws IOException { portletList.getPortlets().stream().anyMatch(p -> p.getPortletId().equals("es-search"))); assertTrue("PortletList should contain 'es-search-legacy' portlet", portletList.getPortlets().stream().anyMatch(p -> p.getPortletId().equals("es-search-legacy"))); + assertTrue("PortletList should contain 'query-tool-legacy' portlet", + portletList.getPortlets().stream().anyMatch(p -> p.getPortletId().equals("query-tool-legacy"))); assertTrue("PortletList should contain 'dotai' portlet", portletList.getPortlets().stream().anyMatch(p -> p.getPortletId().equals("dotai"))); assertTrue("PortletList should contain 'analytics-search' portlet",