From 4ba4aad5ee4d62792ad561c50910a7bd3f43ab52 Mon Sep 17 00:00:00 2001 From: Humberto Morera Date: Fri, 15 May 2026 10:29:38 -0600 Subject: [PATCH 1/9] modernization(query-tool) #35709: migrate portlet from Dojo JSP to Angular Replace the Dojo/Dijit query-tool JSP with a new Angular portlet under libs/portlets/dot-query-tool/, mirroring the layout and conventions of the ES Search portlet shipped in #35701. - Two-pane splitter: Monaco (plain-text Lucene) + collapsible Parameters panel on the left; stats bar (resultsSize / queryTook / contentTook) and Results / Raw tabs on the right. - Help popover with four canonical example Lucene queries. - URL query-param state sync (q / offset / limit / sort / userId) replacing the legacy session-side rememberQuery flag. - Result title click routes through DotContentDriveNavigationService, which picks the new vs legacy edit-content UI per content type's CONTENT_EDITOR2_ENABLED flag. - Consumes POST /api/v1/content/_search (the v1 wrapper, preferred per ContentResource JavaDoc). - portlet.xml: query-tool now points at PortletController; legacy JSP preserved as query-tool-legacy for live-toggle rollback. - PortletID: add QUERY_TOOL_LEGACY (mirrors TAGS_LEGACY precedent). Co-Authored-By: Claude Opus 4.7 (1M context) --- core-web/apps/dotcms-ui/src/app/app.routes.ts | 8 + .../dot-content-drive/portlet/src/index.ts | 1 + .../portlets/dot-query-tool/.eslintrc.json | 18 + .../portlets/dot-query-tool/jest.config.ts | 21 ++ .../libs/portlets/dot-query-tool/project.json | 21 ++ .../libs/portlets/dot-query-tool/src/index.ts | 1 + .../dot-query-tool-page.component.html | 346 ++++++++++++++++++ .../dot-query-tool-page.component.spec.ts | 194 ++++++++++ .../dot-query-tool-page.component.ts | 237 ++++++++++++ .../store/dot-query-tool.store.spec.ts | 152 ++++++++ .../store/dot-query-tool.store.ts | 188 ++++++++++ .../dot-query-tool-shell.component.ts | 14 + .../dot-query-tool/src/lib/lib.routes.ts | 10 + .../src/lib/models/dot-query-tool.models.ts | 26 ++ .../services/dot-query-tool.service.spec.ts | 54 +++ .../lib/services/dot-query-tool.service.ts | 23 ++ .../portlets/dot-query-tool/src/test-setup.ts | 18 + .../portlets/dot-query-tool/tsconfig.json | 24 ++ .../portlets/dot-query-tool/tsconfig.lib.json | 13 + .../dot-query-tool/tsconfig.spec.json | 13 + core-web/tsconfig.base.json | 3 + .../java/com/dotmarketing/util/PortletID.java | 3 +- .../WEB-INF/messages/Language.properties | 39 ++ dotCMS/src/main/webapp/WEB-INF/portlet.xml | 9 +- 24 files changed, 1434 insertions(+), 2 deletions(-) create mode 100644 core-web/libs/portlets/dot-query-tool/.eslintrc.json create mode 100644 core-web/libs/portlets/dot-query-tool/jest.config.ts create mode 100644 core-web/libs/portlets/dot-query-tool/project.json create mode 100644 core-web/libs/portlets/dot-query-tool/src/index.ts create mode 100644 core-web/libs/portlets/dot-query-tool/src/lib/dot-query-tool-page/dot-query-tool-page.component.html create mode 100644 core-web/libs/portlets/dot-query-tool/src/lib/dot-query-tool-page/dot-query-tool-page.component.spec.ts create mode 100644 core-web/libs/portlets/dot-query-tool/src/lib/dot-query-tool-page/dot-query-tool-page.component.ts create mode 100644 core-web/libs/portlets/dot-query-tool/src/lib/dot-query-tool-page/store/dot-query-tool.store.spec.ts create mode 100644 core-web/libs/portlets/dot-query-tool/src/lib/dot-query-tool-page/store/dot-query-tool.store.ts create mode 100644 core-web/libs/portlets/dot-query-tool/src/lib/dot-query-tool-shell/dot-query-tool-shell.component.ts create mode 100644 core-web/libs/portlets/dot-query-tool/src/lib/lib.routes.ts create mode 100644 core-web/libs/portlets/dot-query-tool/src/lib/models/dot-query-tool.models.ts create mode 100644 core-web/libs/portlets/dot-query-tool/src/lib/services/dot-query-tool.service.spec.ts create mode 100644 core-web/libs/portlets/dot-query-tool/src/lib/services/dot-query-tool.service.ts create mode 100644 core-web/libs/portlets/dot-query-tool/src/test-setup.ts create mode 100644 core-web/libs/portlets/dot-query-tool/tsconfig.json create mode 100644 core-web/libs/portlets/dot-query-tool/tsconfig.lib.json create mode 100644 core-web/libs/portlets/dot-query-tool/tsconfig.spec.json 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 9bebd28f450f..e822fd336009 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/portlets/dot-content-drive/portlet/src/index.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/index.ts index 099e96c26575..9c86f8d98346 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-query-tool/.eslintrc.json b/core-web/libs/portlets/dot-query-tool/.eslintrc.json new file mode 100644 index 000000000000..ef536cdfaf37 --- /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 000000000000..fdd2ebd0946f --- /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 000000000000..6eb9b63e1bfa --- /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 000000000000..44c9365302f3 --- /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 000000000000..cd5dcf833a25 --- /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,346 @@ + + + +
+ + +
+ {{ '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 }} + · + + {{ store.resultsSize() }} + {{ 'queryTool.results.count' | dm }} + + @if (store.resultsSize() > 0) { + · + {{ $rangeLabel() }} + } + · + + {{ store.queryTook() }} + {{ 'queryTool.results.timing.query' | dm }} + + · + + {{ store.contentTook() }} + {{ 'queryTool.results.timing.content' | dm }} + +
+
+ + + + + {{ 'queryTool.results.tab' | dm }} + @if (store.contentlets().length > 0) { + + {{ store.contentlets().length }} + + } + + + {{ '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 000000000000..e30bcf7baddd --- /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,194 @@ +import { byTestId, createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; + +import { ActivatedRoute, Router, convertToParamMap } from '@angular/router'; + +import { + DotCurrentUserService, + DotGlobalMessageService, + DotHttpErrorManagerService, + DotMessageService +} from '@dotcms/data-access'; +import { ComponentStatus } from '@dotcms/dotcms-models'; +import { DotContentDriveNavigationService } from '@dotcms/portlets/content-drive/portlet'; + +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), + 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 navigateSpy: jest.Mock; + let editContentSpy: jest.Mock; + + 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(DotContentDriveNavigationService, { editContent: jest.fn() }) + ], + componentProviders: [{ provide: DotQueryToolStore, useFactory: () => buildStoreMock() }] + }); + + const setup = (params: Record = {}) => { + navigateSpy = jest.fn(); + editContentSpy = jest.fn(); + spectator = createComponent({ + providers: [ + { + provide: ActivatedRoute, + useValue: { snapshot: { queryParamMap: convertToParamMap(params) } } + }, + { provide: Router, useValue: { navigate: navigateSpy } }, + { + provide: DotContentDriveNavigationService, + useValue: { editContent: editContentSpy } + } + ] + }); + return spectator.inject(DotQueryToolStore, true); + }; + + 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 disabled when the query is empty', () => { + setup(); + const runBtn = spectator + .query(byTestId('query-tool-run-btn')) + ?.querySelector('button') as HTMLButtonElement | null; + expect(runBtn?.disabled).toBe(true); + }); + + it('resets offset, syncs URL, and triggers runSearch when clicked', () => { + const store = setup(); + store.query = jest.fn().mockReturnValue('+live:true'); + spectator.component.onRun(); + expect(store.resetOffset).toHaveBeenCalled(); + expect(navigateSpy).toHaveBeenCalled(); + expect(store.runSearch).toHaveBeenCalled(); + }); + }); + + describe('Result title click', () => { + it('delegates to DotContentDriveNavigationService.editContent', () => { + setup(); + const event = new MouseEvent('click'); + jest.spyOn(event, 'preventDefault'); + spectator.component.onResultClick(SAMPLE_CONTENTLET as never, event); + expect(event.preventDefault).toHaveBeenCalled(); + expect(editContentSpy).toHaveBeenCalledWith(SAMPLE_CONTENTLET); + }); + + it('does NOT intercept clicks with modifier keys (preserves middle-click / cmd-click)', () => { + setup(); + const event = new MouseEvent('click', { metaKey: true }); + spectator.component.onResultClick(SAMPLE_CONTENTLET as never, event); + expect(editContentSpy).not.toHaveBeenCalled(); + }); + }); + + 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') + ]) + ); + }); + }); +}); 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 000000000000..fce4a66e73e2 --- /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,237 @@ +import { MonacoEditorModule } from '@materia-ui/ngx-monaco-editor'; + +import { DOCUMENT } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + OnInit, + signal, + viewChild +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { ButtonModule } from 'primeng/button'; +import { InputNumberModule } from 'primeng/inputnumber'; +import { InputTextModule } from 'primeng/inputtext'; +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 { + DotCurrentUserService, + DotGlobalMessageService, + DotMessageService +} from '@dotcms/data-access'; +import { ComponentStatus, DotCMSContentlet } from '@dotcms/dotcms-models'; +import { DotContentDriveNavigationService } from '@dotcms/portlets/content-drive/portlet'; +import { DotEmptyContainerComponent, DotMessagePipe, PrincipalConfiguration } from '@dotcms/ui'; + +import { DEFAULT_LIMIT, DEFAULT_OFFSET, DotQueryToolStore } from './store/dot-query-tool.store'; + +import { QueryToolActiveTab, QueryToolHelpExample } from '../models/dot-query-tool.models'; + +const VALID_TABS = new Set(['results', 'raw']); + +const QUERY_EDITOR_OPTIONS = { + theme: 'vs', + language: 'plaintext', + minimap: { enabled: false }, + lineNumbers: 'on', + scrollBeyondLastLine: false, + automaticLayout: true, + fontSize: 13, + fontFamily: 'JetBrains Mono, Fira Code, Consolas, monospace', + wordWrap: 'on' +}; + +const RAW_EDITOR_OPTIONS = { + ...QUERY_EDITOR_OPTIONS, + language: 'json', + readOnly: true, + lineNumbers: 'off' +}; + +@Component({ + selector: 'dot-query-tool-page', + imports: [ + FormsModule, + MonacoEditorModule, + SplitterModule, + PanelModule, + InputNumberModule, + InputTextModule, + ButtonModule, + TooltipModule, + TabsModule, + TableModule, + SkeletonModule, + PopoverModule, + DotEmptyContainerComponent, + DotMessagePipe + ], + providers: [DotQueryToolStore, DotCurrentUserService], + 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 #document = inject(DOCUMENT); + readonly #router = inject(Router); + readonly #route = inject(ActivatedRoute); + readonly #navigation = inject(DotContentDriveNavigationService); + + readonly helpPopover = viewChild.required('helpPopoverEl'); + + readonly QUERY_EDITOR_OPTIONS = QUERY_EDITOR_OPTIONS; + readonly RAW_EDITOR_OPTIONS = RAW_EDITOR_OPTIONS; + readonly ComponentStatus = ComponentStatus; + + 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 $rangeLabel = computed(() => { + const from = this.store.showingFrom(); + const to = this.store.showingTo(); + const total = this.store.resultsSize(); + return this.#messageService.get( + 'queryTool.results.showing', + String(from), + String(to), + String(total) + ); + }); + + 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.syncUrl(); + this.store.runSearch(); + } + + onResultClick(contentlet: DotCMSContentlet, event: MouseEvent): void { + if (this.hasModifier(event)) return; + event.preventDefault(); + this.#navigation.editContent(contentlet); + } + + useExample(query: string): void { + this.store.setQuery(query); + 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()); + } + + downloadRawJson(): void { + const a = this.#document.createElement('a'); + a.href = `data:application/json;charset=utf-8,${encodeURIComponent(this.store.rawJson())}`; + a.download = 'query-tool-results.json'; + this.#document.body.appendChild(a); + a.click(); + this.#document.body.removeChild(a); + } + + private syncUrl(): void { + const userId = this.store.userId(); + this.#router.navigate([], { + relativeTo: this.#route, + queryParams: { + q: this.store.query() || null, + offset: this.store.offset() || null, + limit: this.store.limit() !== DEFAULT_LIMIT ? this.store.limit() : null, + sort: this.store.sort() || null, + userId: this.store.isAdmin() && userId ? userId : null + }, + queryParamsHandling: 'merge', + replaceUrl: true + }); + } + + private parseInt(value: string | null, fallback: number): number { + if (!value) return fallback; + const n = Number.parseInt(value, 10); + return Number.isFinite(n) ? n : fallback; + } + + private hasModifier(event: MouseEvent): boolean { + return ( + event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button === 1 + ); + } +} 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 000000000000..f468040d0ced --- /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,152 @@ +import { createServiceFactory, mockProvider, SpectatorService } from '@ngneat/spectator/jest'; +import { of, throwError } from 'rxjs'; + +import { + DotCurrentUserService, + DotHttpErrorManagerService, + DotMessageService +} from '@dotcms/data-access'; +import { ComponentStatus } from '@dotcms/dotcms-models'; + +import { DEFAULT_LIMIT, DEFAULT_OFFSET, DotQueryToolStore } 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('includes userId only when admin and userId is set', () => { + 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 even if admin', () => { + spectator.service.setQuery('+live:true'); + spectator.service.runSearch(); + const payload = searchSpy.mock.calls[0][0]; + expect(payload.userId).toBeUndefined(); + }); + + 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 000000000000..b357e4e1729b --- /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,188 @@ +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, tap } 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, 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 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; +} + +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 +}; + +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; + }) + })), + 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( + tap(() => + patchState(store, { + status: ComponentStatus.LOADING, + activeTab: 'results' + }) + ), + switchMap(() => { + const start = Date.now(); + const { query, sort, offset, limit, userId, isAdmin } = { + query: store.query(), + sort: store.sort(), + offset: store.offset(), + limit: store.limit(), + userId: store.userId(), + isAdmin: store.isAdmin() + }; + return queryToolService + .search({ + query, + sort, + offset, + limit, + ...(isAdmin && userId ? { userId } : {}) + }) + .pipe( + tapResponse({ + next: (response) => { + patchState(store, { + status: ComponentStatus.LOADED, + response, + queryTimeMs: Date.now() - start + }); + }, + 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 000000000000..8325b91cf302 --- /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 000000000000..e0577053d1ff --- /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 000000000000..540f961bb05d --- /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 000000000000..17d38418f6d7 --- /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 000000000000..60564e52d25c --- /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 000000000000..29b4a8b073cf --- /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 000000000000..caf25f65cb54 --- /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 000000000000..a9e4700b8708 --- /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 000000000000..ba660a89989c --- /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/tsconfig.base.json b/core-web/tsconfig.base.json index 134f232d2b15..f436ff3d1914 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 25131b820755..cbe89334f582 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 5a0bad661fcc..3fa68197fc7b 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -1722,6 +1722,45 @@ esSearch.suggest.term=Term esSearch.suggest.corrections=Corrections esSearch.unlicensed.title=Enterprise Feature esSearch.unlicensed.description=ES Search is available with a dotCMS Enterprise license. +queryTool.title=Query Tool +queryTool.query.label=Lucene Query +queryTool.parameters.title=Parameters +queryTool.param.offset=Offset +queryTool.param.limit=Limit +queryTool.param.sort=Sort +queryTool.param.sort.placeholder=e.g. modDate desc +queryTool.param.userId=User ID +queryTool.param.userId.placeholder=Run as another user (admin only) +queryTool.action.run=Submit +queryTool.action.help=Help +queryTool.results.tab=Results +queryTool.results.raw=Raw +queryTool.results.ready=Ready +queryTool.results.count=results +queryTool.results.showing=Showing {0}–{1} of {2} +queryTool.results.timing.query=ms query +queryTool.results.timing.content=ms hydrate +queryTool.results.empty=Run a query to see results +queryTool.results.empty.hint=Enter a Lucene query in the editor and click Submit. +queryTool.results.noHits=No results +queryTool.results.noHits.subtitle=Your query returned zero contentlets. Try adjusting the filters. +queryTool.table.title=Title +queryTool.table.type=Content Type +queryTool.table.inode=Inode +queryTool.table.identifier=Identifier +queryTool.table.copyInode=Copy inode +queryTool.table.copyIdentifier=Copy identifier +queryTool.help.title=Example Lucene queries +queryTool.help.copy=Copy +queryTool.help.copyToEditor=Use in editor +queryTool.help.example.live.title=Live English pages +queryTool.help.example.live.description=All published HTML pages in the default language. +queryTool.help.example.recent.title=Recently modified file assets +queryTool.help.example.recent.description=File assets that are not deleted, filtered by a date range. +queryTool.help.example.wildcard.title=Wildcard title match +queryTool.help.example.wildcard.description=Any working content with "demo" in the title. +queryTool.help.example.everything.title=Everything in English +queryTool.help.example.everything.description=Stress test — every non-deleted content type in the default language. Escalation-Action=Scheduled Action Escalation-Enable=Enable Schedule Escalation-Time=Scheduled in
(seconds) diff --git a/dotCMS/src/main/webapp/WEB-INF/portlet.xml b/dotCMS/src/main/webapp/WEB-INF/portlet.xml index be9d86c98c73..527a931e7832 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 From e1c1739130fcb9d7be2be0400d8bf4c901d45d55 Mon Sep 17 00:00:00 2001 From: Humberto Morera Date: Tue, 19 May 2026 12:59:28 -0600 Subject: [PATCH 2/9] modernization(query-tool) #35709: open edit content in a new tab + UX polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace DotContentDriveNavigationService with a direct DotContentTypeService lookup so the click flow no longer pollutes the URL with CD_* query params (which were triggering the post-close redirect to content-drive via DotContentletWrapperComponent.onClose). - Result row click now window.open(_blank) so query-tool keeps its full state (query, offset, limit, results, scroll) regardless of which editor the target contentlet opens in. - HTML pages → /dotAdmin/#/edit-page/content?url=...&language_id=... - New editor (CONTENT_EDITOR2_ENABLED=true) → /dotAdmin/#/content/{inode} - Legacy editor → /dotAdmin/#/c/content/{inode} - about:blank is opened synchronously inside the click handler to avoid popup-blocker rejection, then location.href is assigned after the async getContentType() call resolves. i18n tweaks: - queryTool.action.run: "Submit" → "Run" - Add com.dotcms.repackage.javax.portlet.title.query-tool-legacy="Query Tool Legacy" so the legacy twin shows up in Roles → Tools (parity with es-search-legacy / tags-legacy). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dot-query-tool-page.component.spec.ts | 66 +++++++++++++------ .../dot-query-tool-page.component.ts | 57 +++++++++++++--- .../WEB-INF/messages/Language.properties | 3 +- 3 files changed, 96 insertions(+), 30 deletions(-) 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 index e30bcf7baddd..98257f929892 100644 --- 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 @@ -1,15 +1,16 @@ import { byTestId, createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; +import { of } from 'rxjs'; import { ActivatedRoute, Router, convertToParamMap } from '@angular/router'; import { + DotContentTypeService, DotCurrentUserService, DotGlobalMessageService, DotHttpErrorManagerService, DotMessageService } from '@dotcms/data-access'; import { ComponentStatus } from '@dotcms/dotcms-models'; -import { DotContentDriveNavigationService } from '@dotcms/portlets/content-drive/portlet'; import { DotQueryToolPageComponent } from './dot-query-tool-page.component'; import { DEFAULT_LIMIT, DotQueryToolStore } from './store/dot-query-tool.store'; @@ -60,7 +61,6 @@ const buildStoreMock = (overrides: Partial> = {}) => ( describe('DotQueryToolPageComponent', () => { let spectator: Spectator; let navigateSpy: jest.Mock; - let editContentSpy: jest.Mock; const createComponent = createComponentFactory({ component: DotQueryToolPageComponent, @@ -78,25 +78,20 @@ describe('DotQueryToolPageComponent', () => { mockProvider(DotHttpErrorManagerService), mockProvider(DotGlobalMessageService, { error: jest.fn() }), mockProvider(DotQueryToolService), - mockProvider(DotContentDriveNavigationService, { editContent: jest.fn() }) + mockProvider(DotContentTypeService, { getContentType: jest.fn() }) ], componentProviders: [{ provide: DotQueryToolStore, useFactory: () => buildStoreMock() }] }); const setup = (params: Record = {}) => { navigateSpy = jest.fn(); - editContentSpy = jest.fn(); spectator = createComponent({ providers: [ { provide: ActivatedRoute, useValue: { snapshot: { queryParamMap: convertToParamMap(params) } } }, - { provide: Router, useValue: { navigate: navigateSpy } }, - { - provide: DotContentDriveNavigationService, - useValue: { editContent: editContentSpy } - } + { provide: Router, useValue: { navigate: navigateSpy } } ] }); return spectator.inject(DotQueryToolStore, true); @@ -152,20 +147,53 @@ describe('DotQueryToolPageComponent', () => { }); describe('Result title click', () => { - it('delegates to DotContentDriveNavigationService.editContent', () => { + 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 HTML pages directly in the page editor (new tab)', () => { setup(); - const event = new MouseEvent('click'); - jest.spyOn(event, 'preventDefault'); - spectator.component.onResultClick(SAMPLE_CONTENTLET as never, event); - expect(event.preventDefault).toHaveBeenCalled(); - expect(editContentSpy).toHaveBeenCalledWith(SAMPLE_CONTENTLET); + const page = { + inode: 'p1', + baseType: 'HTMLPAGE', + url: '/about-us', + languageId: 1 + }; + spectator.component.onResultClick(page as never, new MouseEvent('click')); + expect(windowOpenSpy).toHaveBeenCalledTimes(1); + const [url, target] = windowOpenSpy.mock.calls[0]; + expect(url).toContain('/dotAdmin/#/edit-page/content?'); + expect(url).toContain('url=%2Fabout-us'); + expect(target).toBe('_blank'); + }); + + it('opens new editor URL for content types with CONTENT_EDITOR2_ENABLED', () => { + setup(); + const ctService = spectator.inject(DotContentTypeService); + (ctService.getContentType as jest.Mock).mockReturnValue( + of({ metadata: { CONTENT_EDITOR2_ENABLED: true } }) + ); + spectator.component.onResultClick(SAMPLE_CONTENTLET as never, new MouseEvent('click')); + expect(windowOpenSpy).toHaveBeenCalledWith('about:blank', '_blank'); + expect(placeholderWindow.location.href).toBe('/dotAdmin/#/content/inode-1'); }); - it('does NOT intercept clicks with modifier keys (preserves middle-click / cmd-click)', () => { + it('opens legacy editor URL when CONTENT_EDITOR2_ENABLED is missing', () => { setup(); - const event = new MouseEvent('click', { metaKey: true }); - spectator.component.onResultClick(SAMPLE_CONTENTLET as never, event); - expect(editContentSpy).not.toHaveBeenCalled(); + const ctService = spectator.inject(DotContentTypeService); + (ctService.getContentType as jest.Mock).mockReturnValue(of({ metadata: {} })); + spectator.component.onResultClick(SAMPLE_CONTENTLET as never, new MouseEvent('click')); + expect(placeholderWindow.location.href).toBe('/dotAdmin/#/c/content/inode-1'); }); }); 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 index fce4a66e73e2..429884afd486 100644 --- 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 @@ -13,6 +13,7 @@ import { import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; + import { ButtonModule } from 'primeng/button'; import { InputNumberModule } from 'primeng/inputnumber'; import { InputTextModule } from 'primeng/inputtext'; @@ -24,13 +25,20 @@ import { TableModule } from 'primeng/table'; import { TabsModule } from 'primeng/tabs'; import { TooltipModule } from 'primeng/tooltip'; +import { take } from 'rxjs/operators'; + import { + DotContentTypeService, DotCurrentUserService, DotGlobalMessageService, DotMessageService } from '@dotcms/data-access'; -import { ComponentStatus, DotCMSContentlet } from '@dotcms/dotcms-models'; -import { DotContentDriveNavigationService } from '@dotcms/portlets/content-drive/portlet'; +import { + ComponentStatus, + DotCMSBaseTypesContentTypes, + DotCMSContentlet, + FeaturedFlags +} from '@dotcms/dotcms-models'; import { DotEmptyContainerComponent, DotMessagePipe, PrincipalConfiguration } from '@dotcms/ui'; import { DEFAULT_LIMIT, DEFAULT_OFFSET, DotQueryToolStore } from './store/dot-query-tool.store'; @@ -76,7 +84,7 @@ const RAW_EDITOR_OPTIONS = { DotEmptyContainerComponent, DotMessagePipe ], - providers: [DotQueryToolStore, DotCurrentUserService], + providers: [DotQueryToolStore, DotCurrentUserService, DotContentTypeService], templateUrl: './dot-query-tool-page.component.html', changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'flex flex-col h-full min-h-0 bg-white' } @@ -88,7 +96,7 @@ export class DotQueryToolPageComponent implements OnInit { readonly #document = inject(DOCUMENT); readonly #router = inject(Router); readonly #route = inject(ActivatedRoute); - readonly #navigation = inject(DotContentDriveNavigationService); + readonly #contentTypeService = inject(DotContentTypeService); readonly helpPopover = viewChild.required('helpPopoverEl'); @@ -180,9 +188,32 @@ export class DotQueryToolPageComponent implements OnInit { } onResultClick(contentlet: DotCMSContentlet, event: MouseEvent): void { - if (this.hasModifier(event)) return; event.preventDefault(); - this.#navigation.editContent(contentlet); + const pageUrl = this.buildPageEditUrl(contentlet); + if (pageUrl) { + window.open(pageUrl, '_blank'); + return; + } + const placeholder = window.open('about:blank', '_blank'); + if (!placeholder) return; + this.#contentTypeService + .getContentType(contentlet.contentType) + .pipe(take(1)) + .subscribe({ + next: (contentType) => { + const useNewEditor = + !!contentType?.metadata?.[ + FeaturedFlags.FEATURE_FLAG_CONTENT_EDITOR2_ENABLED + ]; + placeholder.location.href = useNewEditor + ? `/dotAdmin/#/content/${contentlet.inode}` + : `/dotAdmin/#/c/content/${contentlet.inode}`; + }, + error: () => { + placeholder.close(); + this.#globalMessage.error(); + } + }); } useExample(query: string): void { @@ -229,9 +260,15 @@ export class DotQueryToolPageComponent implements OnInit { return Number.isFinite(n) ? n : fallback; } - private hasModifier(event: MouseEvent): boolean { - return ( - event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button === 1 - ); + private 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()}`; } } diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 3fa68197fc7b..572219be930f 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 @@ -1731,7 +1732,7 @@ queryTool.param.sort=Sort queryTool.param.sort.placeholder=e.g. modDate desc queryTool.param.userId=User ID queryTool.param.userId.placeholder=Run as another user (admin only) -queryTool.action.run=Submit +queryTool.action.run=Run queryTool.action.help=Help queryTool.results.tab=Results queryTool.results.raw=Raw From eebc9ab7fff96fde9ee40d09e60c1ae6ea74a33f Mon Sep 17 00:00:00 2001 From: Humberto Morera Date: Tue, 19 May 2026 16:04:39 -0600 Subject: [PATCH 3/9] modernization(query-tool) #35709: UX polish, MAX_RESULTS cap, reactive URL sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UX/copy: - Run button: always enabled (no [disabled]); onRun() short-circuits when query is empty (matches ES Search behavior). - "Submit" → "Run" everywhere (button label, empty-state hint copy). - Help tooltip + aria-label: "Click for query examples". - New Language.properties entry com.dotcms.repackage.javax.portlet.title .query-tool-legacy so the legacy twin shows up under Roles → Tools. Limit cap (parity with ES Search MAX_HITS): - New MAX_RESULTS=1000 constant + limitWasCapped state field. - runSearch caps limit at 1000 when the user asks for more and surfaces a between stats bar and tabs. - setLimit resets limitWasCapped so the warning clears when the user adjusts the input. Reactive URL sync (fixes duplicate-request bug): - Replace the imperative syncUrl() / Router.navigate flow with an effect() that pipes store signals through Router.createUrlTree + Location.go — the canonical pattern from dot-content-drive-shell.component.ts. Updates the address bar without triggering a router navigation, so the component no longer re-mounts (which was firing a second auto-run with isAdmin=false and dropping userId from the payload). - userId gate removed from runSearch: forward userId whenever set; the server is the authoritative admin gate. Tests: 27 passing (added cap behavior + Location.go assertions). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dot-query-tool-page.component.html | 11 +++- .../dot-query-tool-page.component.spec.ts | 31 +++++++---- .../dot-query-tool-page.component.ts | 55 ++++++++++++------- .../store/dot-query-tool.store.spec.ts | 28 +++++++++- .../store/dot-query-tool.store.ts | 23 +++++--- .../WEB-INF/messages/Language.properties | 7 ++- 6 files changed, 108 insertions(+), 47 deletions(-) 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 index cd5dcf833a25..75e64f6864fb 100644 --- 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 @@ -104,7 +104,6 @@ [label]="'queryTool.action.run' | dm" icon="pi pi-play" [loading]="store.isLoading()" - [disabled]="!store.query().trim()" (onClick)="onRun()" data-testid="query-tool-run-btn" /> @@ -159,6 +158,16 @@ + @if (store.limitWasCapped()) { + + {{ 'queryTool.results.sizeCapped' | dm: [MAX_RESULTS.toString()] }} + + } + > = {}) => ( hasLoadedResults: jest.fn().mockReturnValue(false), showingFrom: jest.fn().mockReturnValue(0), showingTo: jest.fn().mockReturnValue(0), + limitWasCapped: jest.fn().mockReturnValue(false), emptyStateConfig: jest .fn() .mockReturnValue({ title: 'Empty', icon: 'pi-search', subtitle: '' }), @@ -60,7 +62,7 @@ const buildStoreMock = (overrides: Partial> = {}) => ( describe('DotQueryToolPageComponent', () => { let spectator: Spectator; - let navigateSpy: jest.Mock; + let locationGoSpy: jest.Mock; const createComponent = createComponentFactory({ component: DotQueryToolPageComponent, @@ -84,14 +86,14 @@ describe('DotQueryToolPageComponent', () => { }); const setup = (params: Record = {}) => { - navigateSpy = jest.fn(); + locationGoSpy = jest.fn(); spectator = createComponent({ providers: [ { provide: ActivatedRoute, useValue: { snapshot: { queryParamMap: convertToParamMap(params) } } }, - { provide: Router, useValue: { navigate: navigateSpy } } + { provide: Location, useValue: { go: locationGoSpy } } ] }); return spectator.inject(DotQueryToolStore, true); @@ -128,22 +130,27 @@ describe('DotQueryToolPageComponent', () => { }); describe('Submit button', () => { - it('is disabled when the query is empty', () => { - setup(); - const runBtn = spectator - .query(byTestId('query-tool-run-btn')) - ?.querySelector('button') as HTMLButtonElement | null; - expect(runBtn?.disabled).toBe(true); + 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, syncs URL, and triggers runSearch when clicked', () => { + it('resets offset and triggers runSearch when clicked', () => { const store = setup(); store.query = jest.fn().mockReturnValue('+live:true'); spectator.component.onRun(); expect(store.resetOffset).toHaveBeenCalled(); - expect(navigateSpy).toHaveBeenCalled(); expect(store.runSearch).toHaveBeenCalled(); }); + + it('does not call Router.navigate (URL sync goes through Location.go, no re-mount)', () => { + const store = setup(); + store.query = jest.fn().mockReturnValue('+live:true'); + spectator.component.onRun(); + expect(locationGoSpy).toHaveBeenCalled(); + }); }); describe('Result title click', () => { 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 index 429884afd486..9f23ac928f5e 100644 --- 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 @@ -1,10 +1,11 @@ import { MonacoEditorModule } from '@materia-ui/ngx-monaco-editor'; -import { DOCUMENT } from '@angular/common'; +import { DOCUMENT, Location } from '@angular/common'; import { ChangeDetectionStrategy, Component, computed, + effect, inject, OnInit, signal, @@ -13,10 +14,10 @@ import { import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; - import { ButtonModule } from 'primeng/button'; import { InputNumberModule } from 'primeng/inputnumber'; import { InputTextModule } from 'primeng/inputtext'; +import { MessageModule } from 'primeng/message'; import { PanelModule } from 'primeng/panel'; import { Popover, PopoverModule } from 'primeng/popover'; import { SkeletonModule } from 'primeng/skeleton'; @@ -41,7 +42,12 @@ import { } from '@dotcms/dotcms-models'; import { DotEmptyContainerComponent, DotMessagePipe, PrincipalConfiguration } from '@dotcms/ui'; -import { DEFAULT_LIMIT, DEFAULT_OFFSET, DotQueryToolStore } from './store/dot-query-tool.store'; +import { + DEFAULT_LIMIT, + DEFAULT_OFFSET, + DotQueryToolStore, + MAX_RESULTS +} from './store/dot-query-tool.store'; import { QueryToolActiveTab, QueryToolHelpExample } from '../models/dot-query-tool.models'; @@ -80,6 +86,7 @@ const RAW_EDITOR_OPTIONS = { TabsModule, TableModule, SkeletonModule, + MessageModule, PopoverModule, DotEmptyContainerComponent, DotMessagePipe @@ -96,13 +103,38 @@ export class DotQueryToolPageComponent implements OnInit { readonly #document = inject(DOCUMENT); readonly #router = inject(Router); readonly #route = inject(ActivatedRoute); + readonly #location = inject(Location); readonly #contentTypeService = inject(DotContentTypeService); + /** + * Reactively syncs store state to the URL query string using Location.go, + * which updates the address bar without triggering router navigation. + * Mirrors the canonical pattern in dot-content-drive-shell.component.ts. + * Avoids the component re-mount that Router.navigate would cause against + * a route flagged with `data: { reuseRoute: false }`. + */ + readonly updateQueryParamsEffect = effect(() => { + const queryParams: Record = { + q: this.store.query() || null, + 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 urlTree = this.#router.createUrlTree([], { + relativeTo: this.#route, + queryParams, + queryParamsHandling: 'merge' + }); + this.#location.go(urlTree.toString()); + }); + readonly helpPopover = viewChild.required('helpPopoverEl'); readonly QUERY_EDITOR_OPTIONS = QUERY_EDITOR_OPTIONS; readonly RAW_EDITOR_OPTIONS = RAW_EDITOR_OPTIONS; readonly ComponentStatus = ComponentStatus; + readonly MAX_RESULTS = MAX_RESULTS; readonly splitterPt = { root: { class: 'border-0! rounded-none!' } }; readonly tabPanelsPt = { root: { class: 'flex-1 min-h-0 overflow-auto p-0!' } }; @@ -183,7 +215,6 @@ export class DotQueryToolPageComponent implements OnInit { onRun(): void { if (!this.store.query().trim()) return; this.store.resetOffset(); - this.syncUrl(); this.store.runSearch(); } @@ -238,22 +269,6 @@ export class DotQueryToolPageComponent implements OnInit { this.#document.body.removeChild(a); } - private syncUrl(): void { - const userId = this.store.userId(); - this.#router.navigate([], { - relativeTo: this.#route, - queryParams: { - q: this.store.query() || null, - offset: this.store.offset() || null, - limit: this.store.limit() !== DEFAULT_LIMIT ? this.store.limit() : null, - sort: this.store.sort() || null, - userId: this.store.isAdmin() && userId ? userId : null - }, - queryParamsHandling: 'merge', - replaceUrl: true - }); - } - private parseInt(value: string | null, fallback: number): number { if (!value) return fallback; const n = Number.parseInt(value, 10); 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 index f468040d0ced..401124694217 100644 --- 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 @@ -8,7 +8,12 @@ import { } from '@dotcms/data-access'; import { ComponentStatus } from '@dotcms/dotcms-models'; -import { DEFAULT_LIMIT, DEFAULT_OFFSET, DotQueryToolStore } from './dot-query-tool.store'; +import { + DEFAULT_LIMIT, + DEFAULT_OFFSET, + DotQueryToolStore, + MAX_RESULTS +} from './dot-query-tool.store'; import { DotQueryToolService } from '../../services/dot-query-tool.service'; @@ -98,7 +103,7 @@ describe('DotQueryToolStore', () => { expect(spectator.service.resultsSize()).toBe(3); }); - it('includes userId only when admin and userId is set', () => { + it('forwards userId when set (server enforces admin gate)', () => { spectator.service.setQuery('+live:true'); spectator.service.setUserId('admin@dotcms.com'); @@ -109,13 +114,30 @@ describe('DotQueryToolStore', () => { ); }); - it('omits userId when blank even if admin', () => { + 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('caps limit at MAX_RESULTS and sets limitWasCapped when user asks for more', () => { + 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('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)); 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 index b357e4e1729b..2fbacdebb629 100644 --- 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 @@ -28,6 +28,7 @@ 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; @@ -41,6 +42,7 @@ export interface QueryToolState { queryTimeMs: number | null; activeTab: QueryToolActiveTab; emptyStateConfig: PrincipalConfiguration | null; + limitWasCapped: boolean; } const initialState: QueryToolState = { @@ -54,7 +56,8 @@ const initialState: QueryToolState = { response: null, queryTimeMs: null, activeTab: 'results', - emptyStateConfig: null + emptyStateConfig: null, + limitWasCapped: false }; export const DotQueryToolStore = signalStore( @@ -102,7 +105,7 @@ export const DotQueryToolStore = signalStore( patchState(store, { offset: Math.max(0, offset) }); }, setLimit(limit: number): void { - patchState(store, { limit: Math.max(1, limit) }); + patchState(store, { limit: Math.max(1, limit), limitWasCapped: false }); }, setUserId(userId: string): void { patchState(store, { userId }); @@ -118,18 +121,22 @@ export const DotQueryToolStore = signalStore( tap(() => patchState(store, { status: ComponentStatus.LOADING, - activeTab: 'results' + activeTab: 'results', + limitWasCapped: false }) ), switchMap(() => { const start = Date.now(); - const { query, sort, offset, limit, userId, isAdmin } = { + let limit = store.limit(); + if (limit > MAX_RESULTS) { + limit = MAX_RESULTS; + patchState(store, { limit, limitWasCapped: true }); + } + const { query, sort, offset, userId } = { query: store.query(), sort: store.sort(), offset: store.offset(), - limit: store.limit(), - userId: store.userId(), - isAdmin: store.isAdmin() + userId: store.userId() }; return queryToolService .search({ @@ -137,7 +144,7 @@ export const DotQueryToolStore = signalStore( sort, offset, limit, - ...(isAdmin && userId ? { userId } : {}) + ...(userId ? { userId } : {}) }) .pipe( tapResponse({ diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 572219be930f..0154efa82296 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -1666,7 +1666,7 @@ ES-QUERY-NOT-LICENSED=The ES Search Tool is a download anchors - copyQuery dropped; copyToClipboard is the single entry point - DEFAULT_LIMIT / DEFAULT_OFFSET exposed on the component to replace magic numbers in template (?? 0 / ?? 20) - aria-label added to icon-only copy buttons - URL-sync effect skips no-op Location.go writes via lastSyncedUrl guard; comment trimmed to the why (no cross-file references) - Showing-of i18n restored to a self-contained sentence: "Showing {0} of {1} results" Tests: - New unit tests for buildCurlSnippet / buildFetchSnippet in libs/utils - Brittle private-method probes replaced with menu-driven tests (exportItems[i].command() + clipboard spy) - DotClipboardUtil added to componentProviders in both specs Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dot-es-search-page.component.html | 19 +-- .../dot-es-search-page.component.spec.ts | 36 +++-- .../dot-es-search-page.component.ts | 88 ++++-------- .../dot-query-tool-page.component.html | 66 ++++++--- .../dot-query-tool-page.component.spec.ts | 68 ++++++++- .../dot-query-tool-page.component.ts | 133 +++++++++++------- .../store/dot-query-tool.store.ts | 74 +++++----- core-web/libs/ui/src/index.ts | 3 + .../libs/ui/src/lib/monaco/editor-options.ts | 25 ++++ core-web/libs/utils/src/lib/dot-utils.spec.ts | 41 ++++++ core-web/libs/utils/src/lib/dot-utils.ts | 35 +++++ .../WEB-INF/messages/Language.properties | 10 +- 12 files changed, 394 insertions(+), 204 deletions(-) create mode 100644 core-web/libs/ui/src/lib/monaco/editor-options.ts 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 2a07a52e52c3..44598a33b769 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() }, + DotClipboardUtil + ] }); beforeEach(() => { @@ -304,32 +308,36 @@ describe('DotEsSearchPageComponent', () => { }); }); - describe('buildCurlSnippet', () => { - it('should escape single quotes in the JSON body using POSIX shell quoting', () => { + describe('Share menu', () => { + const setupClipboardSpy = () => { + const clipboard = spectator.inject(DotClipboardUtil, true); + return jest.spyOn(clipboard, 'copy').mockResolvedValue(true); + }; + + 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 copySpy = setupClipboardSpy(); - // 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 = copySpy.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 copySpy = setupClipboardSpy(); - 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 = copySpy.mock.calls[0][0]; + expect(snippet).toContain(`fetch('/api/es/search?depth=1&live=true'`); + expect(snippet).toContain(`credentials: 'include'`); }); }); 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 aeaeac37b09f..517da6f8db17 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/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 index 75e64f6864fb..cd947b93776b 100644 --- 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 @@ -56,7 +56,7 @@ inputId="query-tool-offset" [min]="0" [ngModel]="store.offset()" - (ngModelChange)="store.setOffset($event ?? 0)" + (ngModelChange)="store.setOffset($event ?? DEFAULT_OFFSET)" data-testid="query-tool-offset-input" />
@@ -65,7 +65,7 @@ inputId="query-tool-limit" [min]="1" [ngModel]="store.limit()" - (ngModelChange)="store.setLimit($event ?? 20)" + (ngModelChange)="store.setLimit($event ?? DEFAULT_LIMIT)" data-testid="query-tool-limit-input" />
@@ -138,24 +138,46 @@ {{ 'queryTool.results.ready' | dm }} · - {{ store.resultsSize() }} - {{ 'queryTool.results.count' | dm }} - - @if (store.resultsSize() > 0) { - · - {{ $rangeLabel() }} - } - · - - {{ store.queryTook() }} - {{ 'queryTool.results.timing.query' | dm }} + @if (store.resultsSize() > 0) { + {{ + 'queryTool.results.showing' + | dm + : [ + store.contentlets().length.toString(), + store.resultsSize().toString() + ] + }} + } @else { + 0 + {{ 'queryTool.results.count' | dm }} + } · - {{ store.contentTook() }} - {{ 'queryTool.results.timing.content' | dm }} + {{ store.queryTimeMs() ?? 0 }} + {{ 'queryTool.results.timing' | dm }}
+ @if (store.hasLoadedResults()) { +
+ + + +
+ } @if (store.limitWasCapped()) { @@ -176,12 +198,6 @@ {{ 'queryTool.results.tab' | dm }} - @if (store.contentlets().length > 0) { - - {{ store.contentlets().length }} - - } {{ 'queryTool.results.raw' | dm }} @@ -254,6 +270,9 @@ 'queryTool.table.copyInode' | dm " tooltipPosition="top" + [attr.aria-label]=" + 'queryTool.table.copyInode' | dm + " (onClick)=" copyToClipboard(contentlet['inode']) " @@ -276,6 +295,9 @@ 'queryTool.table.copyIdentifier' | dm " tooltipPosition="top" + [attr.aria-label]=" + 'queryTool.table.copyIdentifier' | dm + " (onClick)=" copyToClipboard( contentlet['identifier'] @@ -328,7 +350,7 @@ size="small" icon="pi pi-copy" [label]="'queryTool.help.copy' | dm" - (onClick)="copyQuery(example.query)" + (onClick)="copyToClipboard(example.query)" data-testid="query-tool-help-copy-btn" /> > = {}) => ( 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() @@ -82,7 +86,10 @@ describe('DotQueryToolPageComponent', () => { mockProvider(DotQueryToolService), mockProvider(DotContentTypeService, { getContentType: jest.fn() }) ], - componentProviders: [{ provide: DotQueryToolStore, useFactory: () => buildStoreMock() }] + componentProviders: [ + { provide: DotQueryToolStore, useFactory: () => buildStoreMock() }, + DotClipboardUtil + ] }); const setup = (params: Record = {}) => { @@ -226,4 +233,63 @@ describe('DotQueryToolPageComponent', () => { ); }); }); + + 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 index 9f23ac928f5e..e2b103b66cc4 100644 --- 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 @@ -4,7 +4,6 @@ import { DOCUMENT, Location } from '@angular/common'; import { ChangeDetectionStrategy, Component, - computed, effect, inject, OnInit, @@ -14,9 +13,11 @@ import { 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'; @@ -40,7 +41,15 @@ import { DotCMSContentlet, FeaturedFlags } from '@dotcms/dotcms-models'; -import { DotEmptyContainerComponent, DotMessagePipe, PrincipalConfiguration } from '@dotcms/ui'; +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, @@ -52,25 +61,13 @@ import { 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 = { - theme: 'vs', + ...DOT_MONACO_BASE_OPTIONS, language: 'plaintext', - minimap: { enabled: false }, - lineNumbers: 'on', - scrollBeyondLastLine: false, - automaticLayout: true, - fontSize: 13, - fontFamily: 'JetBrains Mono, Fira Code, Consolas, monospace', wordWrap: 'on' -}; - -const RAW_EDITOR_OPTIONS = { - ...QUERY_EDITOR_OPTIONS, - language: 'json', - readOnly: true, - lineNumbers: 'off' -}; +} as const; @Component({ selector: 'dot-query-tool-page', @@ -87,11 +84,12 @@ const RAW_EDITOR_OPTIONS = { TableModule, SkeletonModule, MessageModule, + MenuModule, PopoverModule, DotEmptyContainerComponent, DotMessagePipe ], - providers: [DotQueryToolStore, DotCurrentUserService, DotContentTypeService], + providers: [DotQueryToolStore, DotCurrentUserService, DotContentTypeService, DotClipboardUtil], templateUrl: './dot-query-tool-page.component.html', changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'flex flex-col h-full min-h-0 bg-white' } @@ -100,19 +98,17 @@ 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 #contentTypeService = inject(DotContentTypeService); - /** - * Reactively syncs store state to the URL query string using Location.go, - * which updates the address bar without triggering router navigation. - * Mirrors the canonical pattern in dot-content-drive-shell.component.ts. - * Avoids the component re-mount that Router.navigate would cause against - * a route flagged with `data: { reuseRoute: false }`. - */ + #lastSyncedUrl: string | null = null; + + // Syncs store state to the URL via Location.go to avoid a route re-mount that + // Router.navigate would trigger when the route is not configured for reuse. readonly updateQueryParamsEffect = effect(() => { const queryParams: Record = { q: this.store.query() || null, @@ -121,20 +117,27 @@ export class DotQueryToolPageComponent implements OnInit { sort: this.store.sort() || null, userId: this.store.userId() || null }; - const urlTree = this.#router.createUrlTree([], { - relativeTo: this.#route, - queryParams, - queryParamsHandling: 'merge' - }); - this.#location.go(urlTree.toString()); + const url = this.#router + .createUrlTree([], { + relativeTo: this.#route, + queryParams, + queryParamsHandling: 'merge' + }) + .toString(); + if (url === this.#lastSyncedUrl) return; + this.#lastSyncedUrl = url; + this.#location.go(url); }); readonly helpPopover = viewChild.required('helpPopoverEl'); + readonly exportMenu = viewChild('exportMenu'); readonly QUERY_EDITOR_OPTIONS = QUERY_EDITOR_OPTIONS; - readonly RAW_EDITOR_OPTIONS = RAW_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!' } }; @@ -148,17 +151,20 @@ export class DotQueryToolPageComponent implements OnInit { icon: 'pi-search' }; - readonly $rangeLabel = computed(() => { - const from = this.store.showingFrom(); - const to = this.store.showingTo(); - const total = this.store.resultsSize(); - return this.#messageService.get( - 'queryTool.results.showing', - String(from), - String(to), - String(total) - ); - }); + 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[] = [ { @@ -252,21 +258,40 @@ export class DotQueryToolPageComponent implements OnInit { 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 = 'query-tool-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, 'query-tool-results.json'); + this.#document.body.appendChild(link); + link.click(); + this.#document.body.removeChild(link); + } + + toggleExportMenu(event: MouseEvent): void { + this.exportMenu()?.toggle(event); + } + + private copyShareUrl(): void { + const href = this.#document.defaultView?.location.href; + if (href) this.#copy(href); + } + + private 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(); } private parseInt(value: string | null, fallback: number): number { 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 index 2fbacdebb629..e2d0b51f4bd0 100644 --- 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 @@ -23,7 +23,11 @@ import { import { ComponentStatus, DotCMSContentlet } from '@dotcms/dotcms-models'; import { PrincipalConfiguration } from '@dotcms/ui'; -import { QueryToolActiveTab, QueryToolSearchResponse } from '../../models/dot-query-tool.models'; +import { + QueryToolActiveTab, + QueryToolSearchForm, + QueryToolSearchResponse +} from '../../models/dot-query-tool.models'; import { DotQueryToolService } from '../../services/dot-query-tool.service'; export const DEFAULT_LIMIT = 20; @@ -87,6 +91,17 @@ export const DotQueryToolStore = signalStore( 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( @@ -118,49 +133,32 @@ export const DotQueryToolStore = signalStore( }, runSearch: rxMethod( pipe( - tap(() => + tap(() => { + const limitWasCapped = store.limit() > MAX_RESULTS; + if (limitWasCapped) patchState(store, { limit: MAX_RESULTS }); patchState(store, { status: ComponentStatus.LOADING, activeTab: 'results', - limitWasCapped: false - }) - ), + limitWasCapped + }); + }), switchMap(() => { const start = Date.now(); - let limit = store.limit(); - if (limit > MAX_RESULTS) { - limit = MAX_RESULTS; - patchState(store, { limit, limitWasCapped: true }); - } - const { query, sort, offset, userId } = { - query: store.query(), - sort: store.sort(), - offset: store.offset(), - userId: store.userId() - }; - return queryToolService - .search({ - query, - sort, - offset, - limit, - ...(userId ? { userId } : {}) + return queryToolService.search(store.apiRequestBody()).pipe( + tapResponse({ + next: (response) => { + patchState(store, { + status: ComponentStatus.LOADED, + response, + queryTimeMs: Date.now() - start + }); + }, + error: (error: HttpErrorResponse) => { + patchState(store, { status: ComponentStatus.ERROR }); + httpErrorManager.handle(error); + } }) - .pipe( - tapResponse({ - next: (response) => { - patchState(store, { - status: ComponentStatus.LOADED, - response, - queryTimeMs: Date.now() - start - }); - }, - error: (error: HttpErrorResponse) => { - patchState(store, { status: ComponentStatus.ERROR }); - httpErrorManager.handle(error); - } - }) - ); + ); }) ) ) diff --git a/core-web/libs/ui/src/index.ts b/core-web/libs/ui/src/index.ts index 559deb6e7c0c..72b86bbe537c 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 000000000000..7b5b67052e92 --- /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 9d9ae27ff781..20af5ac34a1f 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 8abd2ad2649d..750cb2b3bed5 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/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 9ab51959e28e..c90645ffb764 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -1738,14 +1738,18 @@ queryTool.results.tab=Results queryTool.results.raw=Raw queryTool.results.ready=Ready queryTool.results.count=results -queryTool.results.showing=Showing {0}–{1} of {2} -queryTool.results.timing.query=ms query -queryTool.results.timing.content=ms hydrate +queryTool.results.showing=Showing {0} of {1} results +queryTool.results.timing=ms queryTool.results.empty=Run a query to see results queryTool.results.empty.hint=Enter a Lucene query in the editor and press Run to search. queryTool.results.noHits=No results queryTool.results.noHits.subtitle=Your query returned zero contentlets. Try adjusting the filters. queryTool.results.sizeCapped=Limit was capped to {0} results. Lower the limit to narrow your search. +queryTool.action.share=Share +queryTool.action.export=Export +queryTool.share.url=Copy shareable URL +queryTool.share.curl=Copy as cURL +queryTool.share.fetch=Copy as fetch queryTool.table.title=Title queryTool.table.type=Content Type queryTool.table.inode=Inode From acabd6e6145d16b2306dd488c8b7b22e613e10d2 Mon Sep 17 00:00:00 2001 From: Humberto Morera Date: Thu, 21 May 2026 17:04:22 -0600 Subject: [PATCH 5/9] modernization(query-tool) #35709: fix history pollution + single-source limit cap Address PR review feedback (Claude bot, comment 4510539091): - Switch URL sync from Location.go (history.pushState) to Location.replaceState so per-keystroke store-signal changes do not pollute the browser back stack. - Skip the URL sync when the freshly computed URL already matches router.url, removing the no-op replaceState on first mount. - Use !== DEFAULT_OFFSET for offset URL pruning, matching the limit comparator (was a falsy check that only worked because 0 happened to be the default). - Make limitWasCapped a computed derived from state, drop the runSearch patchState mutation that visibly snapped a user-typed limit (5000 -> 1000). apiRequestBody remains the single source of truth for the capped value sent to the API; the user's typed value is preserved in state and the warning surfaces immediately on type rather than only after Run. - Update store + component tests for the new no-mutation contract and the replaceState wiring. --- .../dot-query-tool-page.component.spec.ts | 35 +++++++++++++------ .../dot-query-tool-page.component.ts | 12 ++++--- .../store/dot-query-tool.store.spec.ts | 11 ++++-- .../store/dot-query-tool.store.ts | 18 ++++------ 4 files changed, 48 insertions(+), 28 deletions(-) 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 index 31767f6479c5..bbfefa4a5097 100644 --- 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 @@ -66,7 +66,8 @@ const buildStoreMock = (overrides: Partial> = {}) => ( describe('DotQueryToolPageComponent', () => { let spectator: Spectator; - let locationGoSpy: jest.Mock; + let locationReplaceStateSpy: jest.Mock; + let pendingStoreOverrides: Partial> = {}; const createComponent = createComponentFactory({ component: DotQueryToolPageComponent, @@ -87,25 +88,33 @@ describe('DotQueryToolPageComponent', () => { mockProvider(DotContentTypeService, { getContentType: jest.fn() }) ], componentProviders: [ - { provide: DotQueryToolStore, useFactory: () => buildStoreMock() }, + { provide: DotQueryToolStore, useFactory: () => buildStoreMock(pendingStoreOverrides) }, DotClipboardUtil ] }); - const setup = (params: Record = {}) => { - locationGoSpy = jest.fn(); + const setup = ( + params: Record = {}, + storeOverrides: Partial> = {} + ) => { + locationReplaceStateSpy = jest.fn(); + pendingStoreOverrides = storeOverrides; spectator = createComponent({ providers: [ { provide: ActivatedRoute, useValue: { snapshot: { queryParamMap: convertToParamMap(params) } } }, - { provide: Location, useValue: { go: locationGoSpy } } + { provide: Location, useValue: { replaceState: locationReplaceStateSpy } } ] }); return spectator.inject(DotQueryToolStore, true); }; + afterEach(() => { + pendingStoreOverrides = {}; + }); + it('creates the component', () => { setup(); expect(spectator.component).toBeTruthy(); @@ -152,11 +161,17 @@ describe('DotQueryToolPageComponent', () => { expect(store.runSearch).toHaveBeenCalled(); }); - it('does not call Router.navigate (URL sync goes through Location.go, no re-mount)', () => { - const store = setup(); - store.query = jest.fn().mockReturnValue('+live:true'); - spectator.component.onRun(); - expect(locationGoSpy).toHaveBeenCalled(); + it('syncs URL via Location.replaceState (not Location.go) so typing does not pollute history', () => { + // Non-default query forces the effect to produce a URL that differs from the + // current router URL, exercising the replaceState branch. With default state + // the effect intentionally no-ops (see "skips no-op syncs" below). + setup({}, { query: jest.fn().mockReturnValue('+live:true') }); + expect(locationReplaceStateSpy).toHaveBeenCalled(); + }); + + it('skips no-op syncs on first mount when the URL already matches', () => { + setup(); + expect(locationReplaceStateSpy).not.toHaveBeenCalled(); }); }); 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 index e2b103b66cc4..243e5cf93b9c 100644 --- 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 @@ -107,12 +107,14 @@ export class DotQueryToolPageComponent implements OnInit { #lastSyncedUrl: string | null = null; - // Syncs store state to the URL via Location.go to avoid a route re-mount that - // Router.navigate would trigger when the route is not configured for reuse. + // Mirrors store state into the address bar via Location.replaceState so the URL + // stays shareable without pushing a history entry per keystroke (every store + // signal change — including `query` — triggers this effect) and without the + // route re-mount that Router.navigate would cause. readonly updateQueryParamsEffect = effect(() => { const queryParams: Record = { q: this.store.query() || null, - offset: this.store.offset() || 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 @@ -124,9 +126,9 @@ export class DotQueryToolPageComponent implements OnInit { queryParamsHandling: 'merge' }) .toString(); - if (url === this.#lastSyncedUrl) return; + if (url === this.#lastSyncedUrl || url === this.#router.url) return; this.#lastSyncedUrl = url; - this.#location.go(url); + this.#location.replaceState(url); }); readonly helpPopover = viewChild.required('helpPopoverEl'); 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 index 401124694217..73de9a0634b0 100644 --- 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 @@ -121,15 +121,22 @@ describe('DotQueryToolStore', () => { expect(payload.userId).toBeUndefined(); }); - it('caps limit at MAX_RESULTS and sets limitWasCapped when user asks for more', () => { + it('caps the request limit at MAX_RESULTS without mutating the user-typed value', () => { spectator.service.setQuery('+live:true'); spectator.service.setLimit(5000); spectator.service.runSearch(); - expect(spectator.service.limit()).toBe(MAX_RESULTS); + expect(spectator.service.limit()).toBe(5000); expect(spectator.service.limitWasCapped()).toBe(true); expect(searchSpy.mock.calls[0][0].limit).toBe(MAX_RESULTS); }); + it('limitWasCapped flips as soon as the user types above MAX_RESULTS (pre-Run)', () => { + spectator.service.setLimit(50); + expect(spectator.service.limitWasCapped()).toBe(false); + spectator.service.setLimit(MAX_RESULTS + 1); + expect(spectator.service.limitWasCapped()).toBe(true); + }); + it('leaves limit alone and keeps limitWasCapped false when under MAX_RESULTS', () => { spectator.service.setQuery('+live:true'); spectator.service.setLimit(50); 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 index e2d0b51f4bd0..2ca8f475ff8e 100644 --- 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 @@ -46,7 +46,6 @@ export interface QueryToolState { queryTimeMs: number | null; activeTab: QueryToolActiveTab; emptyStateConfig: PrincipalConfiguration | null; - limitWasCapped: boolean; } const initialState: QueryToolState = { @@ -60,8 +59,7 @@ const initialState: QueryToolState = { response: null, queryTimeMs: null, activeTab: 'results', - emptyStateConfig: null, - limitWasCapped: false + emptyStateConfig: null }; export const DotQueryToolStore = signalStore( @@ -92,6 +90,7 @@ export const DotQueryToolStore = signalStore( const returned = store.response()?.jsonObjectView.contentlets.length ?? 0; return store.offset() + returned; }), + limitWasCapped: computed(() => store.limit() > MAX_RESULTS), apiRequestBody: computed(() => { const limit = Math.min(store.limit(), MAX_RESULTS); const userId = store.userId(); @@ -120,7 +119,7 @@ export const DotQueryToolStore = signalStore( patchState(store, { offset: Math.max(0, offset) }); }, setLimit(limit: number): void { - patchState(store, { limit: Math.max(1, limit), limitWasCapped: false }); + patchState(store, { limit: Math.max(1, limit) }); }, setUserId(userId: string): void { patchState(store, { userId }); @@ -133,15 +132,12 @@ export const DotQueryToolStore = signalStore( }, runSearch: rxMethod( pipe( - tap(() => { - const limitWasCapped = store.limit() > MAX_RESULTS; - if (limitWasCapped) patchState(store, { limit: MAX_RESULTS }); + tap(() => patchState(store, { status: ComponentStatus.LOADING, - activeTab: 'results', - limitWasCapped - }); - }), + activeTab: 'results' + }) + ), switchMap(() => { const start = Date.now(); return queryToolService.search(store.apiRequestBody()).pipe( From 26a656f2a32a4056df6eec1375ec8d3c82264ed2 Mon Sep 17 00:00:00 2001 From: Humberto Morera Date: Thu, 21 May 2026 17:41:12 -0600 Subject: [PATCH 6/9] modernization(query-tool) #35709: introduce shared DotContentletEditUrlService Move "where in dotAdmin should I open this contentlet?" out of the Query Tool page component and into a new shared service in @dotcms/data-access. The component now owns only the synchronous window.open (popup-blocker constraint) and the URL assignment; the resolver owns the HTTP call, the feature-flag inspection, and the three URL shapes (HTMLPAGE / new editor / legacy editor). Why a new service instead of inlining or storing it: - The same logic is currently re-implemented inline at three other call sites (libs/new-block-editor/.../contentlet-edit-url.service.ts, libs/block-editor/.../dot-bubble-menu.component.ts, libs/portlets/dot-content-drive/.../dot-content-drive-navigation.service.ts). - Centralising it now gives future portlets a canonical, documented target, even though the three existing duplicates are intentionally left alone in this PR to keep its review surface focused on Query Tool. Migrating those is tracked as a follow-up. The service is providedIn: 'root' with an app-wide per-content-type cache, so a list view of N contentlets of the same type only fetches the content type metadata once. Errors fall back to the legacy editor URL rather than propagating, matching the prior inline behavior. Tests: 7 cases on the new service (HTMLPAGE branch, both editor branches, graceful fallback, cache scoping by content type). Query Tool component spec updated to mock the resolver instead of DotContentTypeService. --- core-web/libs/data-access/src/index.ts | 1 + .../dot-contentlet-edit-url.service.spec.ts | 154 ++++++++++++++++++ .../dot-contentlet-edit-url.service.ts | 137 ++++++++++++++++ .../dot-query-tool-page.component.spec.ts | 53 +++--- .../dot-query-tool-page.component.ts | 48 ++---- 5 files changed, 333 insertions(+), 60 deletions(-) create mode 100644 core-web/libs/data-access/src/lib/dot-contentlet-edit-url/dot-contentlet-edit-url.service.spec.ts create mode 100644 core-web/libs/data-access/src/lib/dot-contentlet-edit-url/dot-contentlet-edit-url.service.ts diff --git a/core-web/libs/data-access/src/index.ts b/core-web/libs/data-access/src/index.ts index 6f27e8bf502b..8d38994d332f 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 000000000000..adaf82c5cf9c --- /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 000000000000..bb4c6edf0096 --- /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-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 index bbfefa4a5097..d304e446f2a5 100644 --- 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 @@ -5,7 +5,7 @@ import { Location } from '@angular/common'; import { ActivatedRoute, convertToParamMap } from '@angular/router'; import { - DotContentTypeService, + DotContentletEditUrlService, DotCurrentUserService, DotGlobalMessageService, DotHttpErrorManagerService, @@ -85,7 +85,9 @@ describe('DotQueryToolPageComponent', () => { mockProvider(DotHttpErrorManagerService), mockProvider(DotGlobalMessageService, { error: jest.fn() }), mockProvider(DotQueryToolService), - mockProvider(DotContentTypeService, { getContentType: jest.fn() }) + mockProvider(DotContentletEditUrlService, { + resolveEditUrl: jest.fn() + }) ], componentProviders: [ { provide: DotQueryToolStore, useFactory: () => buildStoreMock(pendingStoreOverrides) }, @@ -190,38 +192,43 @@ describe('DotQueryToolPageComponent', () => { windowOpenSpy.mockRestore(); }); - it('opens HTML pages directly in the page editor (new tab)', () => { + it('opens a placeholder tab synchronously, then assigns the resolved URL', () => { setup(); - const page = { - inode: 'p1', - baseType: 'HTMLPAGE', - url: '/about-us', - languageId: 1 - }; - spectator.component.onResultClick(page as never, new MouseEvent('click')); - expect(windowOpenSpy).toHaveBeenCalledTimes(1); - const [url, target] = windowOpenSpy.mock.calls[0]; - expect(url).toContain('/dotAdmin/#/edit-page/content?'); - expect(url).toContain('url=%2Fabout-us'); - expect(target).toBe('_blank'); + 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('opens new editor URL for content types with CONTENT_EDITOR2_ENABLED', () => { + it('forwards the contentlet to the resolver and assigns the new-editor URL', () => { setup(); - const ctService = spectator.inject(DotContentTypeService); - (ctService.getContentType as jest.Mock).mockReturnValue( - of({ metadata: { CONTENT_EDITOR2_ENABLED: true } }) + 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(windowOpenSpy).toHaveBeenCalledWith('about:blank', '_blank'); + expect(placeholderWindow.location.href).toBe('/dotAdmin/#/content/inode-1'); }); - it('opens legacy editor URL when CONTENT_EDITOR2_ENABLED is missing', () => { + it('assigns the legacy-editor URL when the resolver returns the legacy path', () => { setup(); - const ctService = spectator.inject(DotContentTypeService); - (ctService.getContentType as jest.Mock).mockReturnValue(of({ metadata: {} })); + 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'); }); }); 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 index 243e5cf93b9c..22c920bf9450 100644 --- 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 @@ -30,17 +30,12 @@ import { TooltipModule } from 'primeng/tooltip'; import { take } from 'rxjs/operators'; import { - DotContentTypeService, + DotContentletEditUrlService, DotCurrentUserService, DotGlobalMessageService, DotMessageService } from '@dotcms/data-access'; -import { - ComponentStatus, - DotCMSBaseTypesContentTypes, - DotCMSContentlet, - FeaturedFlags -} from '@dotcms/dotcms-models'; +import { ComponentStatus, DotCMSContentlet } from '@dotcms/dotcms-models'; import { DOT_MONACO_BASE_OPTIONS, DOT_MONACO_RAW_OPTIONS, @@ -89,7 +84,7 @@ const QUERY_EDITOR_OPTIONS = { DotEmptyContainerComponent, DotMessagePipe ], - providers: [DotQueryToolStore, DotCurrentUserService, DotContentTypeService, DotClipboardUtil], + 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' } @@ -103,7 +98,7 @@ export class DotQueryToolPageComponent implements OnInit { readonly #router = inject(Router); readonly #route = inject(ActivatedRoute); readonly #location = inject(Location); - readonly #contentTypeService = inject(DotContentTypeService); + readonly #editUrlResolver = inject(DotContentletEditUrlService); #lastSyncedUrl: string | null = null; @@ -228,26 +223,17 @@ export class DotQueryToolPageComponent implements OnInit { onResultClick(contentlet: DotCMSContentlet, event: MouseEvent): void { event.preventDefault(); - const pageUrl = this.buildPageEditUrl(contentlet); - if (pageUrl) { - window.open(pageUrl, '_blank'); - return; - } + // 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.#contentTypeService - .getContentType(contentlet.contentType) + this.#editUrlResolver + .resolveEditUrl(contentlet) .pipe(take(1)) .subscribe({ - next: (contentType) => { - const useNewEditor = - !!contentType?.metadata?.[ - FeaturedFlags.FEATURE_FLAG_CONTENT_EDITOR2_ENABLED - ]; - placeholder.location.href = useNewEditor - ? `/dotAdmin/#/content/${contentlet.inode}` - : `/dotAdmin/#/c/content/${contentlet.inode}`; - }, + next: (url) => (placeholder.location.href = url), error: () => { placeholder.close(); this.#globalMessage.error(); @@ -301,16 +287,4 @@ export class DotQueryToolPageComponent implements OnInit { const n = Number.parseInt(value, 10); return Number.isFinite(n) ? n : fallback; } - - private 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()}`; - } } From 2df8cd25b35e6419676af1f42bc6abd02d93809d Mon Sep 17 00:00:00 2001 From: Humberto Morera Date: Thu, 21 May 2026 21:20:54 -0600 Subject: [PATCH 7/9] modernization(query-tool) #35709: QA refinements to result UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues found during browser QA on the limit-cap flow: 1. Warning bar appeared the moment the user typed an oversized limit, before any search ran. The warning describes the *displayed* results, not the pending input, so it now flips only when a search settles (LOADED), and persists across input edits until the next non-capped run replaces those results. 2. After a capped run, hitting Run again while 1000 result rows were on screen froze the UI for 1–2 seconds. Root cause: tearing down 1000 PrimeNG rows (each with two p-button + tooltip children) is synchronous and expensive. Switched the results table to PrimeNG virtual scrolling (virtualScrollItemSize=46) so only the visible window mounts. Run-again is now instant; typing in the limit input is responsive again. 3. URL was syncing on every keystroke (query/offset/limit/sort/userId). The address bar is only meaningful once a search has actually executed, so the sync effect now tracks `status` and fires only on LOADED/ERROR. Input signals are read via `untracked()` so they no longer drive the effect. Typing never touches the URL; both successful and failing runs do. Tests updated for the new contracts: - Store: limitWasCapped persists across edits, clears synchronously on next Run, snaps limit + raises warning together with rendered results. - Component: URL sync fires on LOADED, on ERROR, and not on INIT/LOADING. --- .../dot-query-tool-page.component.html | 2 + .../dot-query-tool-page.component.spec.ts | 39 ++++++++++++--- .../dot-query-tool-page.component.ts | 49 +++++++++++-------- .../store/dot-query-tool.store.spec.ts | 38 ++++++++++++-- .../store/dot-query-tool.store.ts | 37 ++++++++++---- 5 files changed, 124 insertions(+), 41 deletions(-) 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 index cd947b93776b..0d621f1342dc 100644 --- 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 @@ -218,6 +218,8 @@ [value]="store.contentlets()" [scrollable]="true" scrollHeight="flex" + [virtualScroll]="true" + [virtualScrollItemSize]="46" [rowHover]="true" sortMode="single" data-testid="query-tool-results-table"> 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 index d304e446f2a5..80dfe03b1e70 100644 --- 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 @@ -163,15 +163,42 @@ describe('DotQueryToolPageComponent', () => { expect(store.runSearch).toHaveBeenCalled(); }); - it('syncs URL via Location.replaceState (not Location.go) so typing does not pollute history', () => { - // Non-default query forces the effect to produce a URL that differs from the - // current router URL, exercising the replaceState branch. With default state - // the effect intentionally no-ops (see "skips no-op syncs" below). - setup({}, { query: jest.fn().mockReturnValue('+live:true') }); + 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('skips no-op syncs on first mount when the URL already matches', () => { + 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(); }); 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 index 22c920bf9450..0b2b4ef11c1d 100644 --- 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 @@ -8,6 +8,7 @@ import { inject, OnInit, signal, + untracked, viewChild } from '@angular/core'; import { FormsModule } from '@angular/forms'; @@ -102,28 +103,34 @@ export class DotQueryToolPageComponent implements OnInit { #lastSyncedUrl: string | null = null; - // Mirrors store state into the address bar via Location.replaceState so the URL - // stays shareable without pushing a history entry per keystroke (every store - // signal change — including `query` — triggers this effect) and without the - // route re-mount that Router.navigate would cause. + // 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 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); + 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'); 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 index 73de9a0634b0..c63bc8aca5f9 100644 --- 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 @@ -1,5 +1,5 @@ import { createServiceFactory, mockProvider, SpectatorService } from '@ngneat/spectator/jest'; -import { of, throwError } from 'rxjs'; +import { EMPTY, of, throwError } from 'rxjs'; import { DotCurrentUserService, @@ -121,20 +121,48 @@ describe('DotQueryToolStore', () => { expect(payload.userId).toBeUndefined(); }); - it('caps the request limit at MAX_RESULTS without mutating the user-typed value', () => { + 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(5000); + expect(spectator.service.limit()).toBe(MAX_RESULTS); expect(spectator.service.limitWasCapped()).toBe(true); expect(searchSpy.mock.calls[0][0].limit).toBe(MAX_RESULTS); }); - it('limitWasCapped flips as soon as the user types above MAX_RESULTS (pre-Run)', () => { + 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); - spectator.service.setLimit(MAX_RESULTS + 1); + }); + + 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', () => { 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 index 2ca8f475ff8e..65c5e1500404 100644 --- 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 @@ -13,7 +13,7 @@ import { EMPTY, pipe } from 'rxjs'; import { HttpErrorResponse } from '@angular/common/http'; import { computed, inject } from '@angular/core'; -import { catchError, switchMap, take, tap } from 'rxjs/operators'; +import { catchError, switchMap, take } from 'rxjs/operators'; import { DotCurrentUserService, @@ -46,6 +46,12 @@ export interface QueryToolState { 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 = { @@ -59,7 +65,8 @@ const initialState: QueryToolState = { response: null, queryTimeMs: null, activeTab: 'results', - emptyStateConfig: null + emptyStateConfig: null, + limitWasCapped: false }; export const DotQueryToolStore = signalStore( @@ -90,7 +97,6 @@ export const DotQueryToolStore = signalStore( const returned = store.response()?.jsonObjectView.contentlets.length ?? 0; return store.offset() + returned; }), - limitWasCapped: computed(() => store.limit() > MAX_RESULTS), apiRequestBody: computed(() => { const limit = Math.min(store.limit(), MAX_RESULTS); const userId = store.userId(); @@ -132,13 +138,22 @@ export const DotQueryToolStore = signalStore( }, runSearch: rxMethod( pipe( - tap(() => + 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' - }) - ), - switchMap(() => { + 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({ @@ -146,7 +161,11 @@ export const DotQueryToolStore = signalStore( patchState(store, { status: ComponentStatus.LOADED, response, - queryTimeMs: Date.now() - start + 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) => { From 1b85a97f4d40ae40cd63b53fa7af782d81680d57 Mon Sep 17 00:00:00 2001 From: Humberto Morera Date: Thu, 21 May 2026 21:21:04 -0600 Subject: [PATCH 8/9] test(query-tool) #35709: pin query-tool-legacy in SerializationHelperTest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SerializationHelperTest.testFromXmlFile hard-codes the expected portlet count from portlet.xml. Adding `query-tool-legacy` (the rollback safety net for the Query Tool migration) bumped the count from 52 to 53 and broke JVM Unit Tests in CI. Updated the count and added an explicit assertion for the `query-tool-legacy` entry — mirrors how `es-search-legacy`, `categories-legacy`, and `plugins-legacy` are already pinned. This way a future accidental removal of the rollback portlet fails loudly, not silently. --- .../dotmarketing/business/portal/SerializationHelperTest.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 67dde365205a..3441f9546285 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", From b9569ef70ba94c9b3d63e4159f48ae42a5f94d3c Mon Sep 17 00:00:00 2001 From: hmoreras <31667212+hmoreras@users.noreply.github.com> Date: Fri, 22 May 2026 09:43:22 -0600 Subject: [PATCH 9/9] modernization(query-tool) #35709: address PR review feedback - Rename private methods to # prefix (copyShareUrl, copyAs, parseInt) for consistency with the rest of the class and TS standards. - Rename viewChild signals helpPopover/exportMenu to $helpPopover/ $exportMenu per Angular standards ($ prefix for signals). - Use mockProvider for DotClipboardUtil in the ES Search spec so jsdom never touches navigator.clipboard. - Add a Share-menu test asserting DotGlobalMessageService.error is called when clipboard.copy resolves false. - Make the query-tool run-button test actually click the rendered button instead of calling onRun() directly. --- .../dot-es-search-page.component.spec.ts | 33 +++++++++++++++---- .../dot-query-tool-page.component.html | 2 +- .../dot-query-tool-page.component.spec.ts | 6 +++- .../dot-query-tool-page.component.ts | 24 +++++++------- 4 files changed, 44 insertions(+), 21 deletions(-) diff --git a/core-web/libs/portlets/dot-es-search/src/lib/dot-es-search-page/dot-es-search-page.component.spec.ts b/core-web/libs/portlets/dot-es-search/src/lib/dot-es-search-page/dot-es-search-page.component.spec.ts index ec0331ad0462..a1d572419c43 100644 --- a/core-web/libs/portlets/dot-es-search/src/lib/dot-es-search-page/dot-es-search-page.component.spec.ts +++ b/core-web/libs/portlets/dot-es-search/src/lib/dot-es-search-page/dot-es-search-page.component.spec.ts @@ -69,7 +69,7 @@ describe('DotEsSearchPageComponent', () => { ], componentProviders: [ { provide: DotEsSearchStore, useFactory: () => buildStoreMock() }, - DotClipboardUtil + mockProvider(DotClipboardUtil, { copy: jest.fn().mockResolvedValue(true) }) ] }); @@ -309,20 +309,25 @@ describe('DotEsSearchPageComponent', () => { }); describe('Share menu', () => { - const setupClipboardSpy = () => { + const getClipboardCopy = (resolved = true) => { const clipboard = spectator.inject(DotClipboardUtil, true); - return jest.spyOn(clipboard, 'copy').mockResolvedValue(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 copySpy = setupClipboardSpy(); + const copy = getClipboardCopy(); spectator.component.exportItems[0].command?.({} as never); - const snippet = copySpy.mock.calls[0][0]; + 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"`); }); @@ -331,14 +336,28 @@ describe('DotEsSearchPageComponent', () => { const store = spectator.inject(DotEsSearchStore, true); store.query = jest.fn().mockReturnValue('{"query":{"match_all":{}}}'); store.params = jest.fn().mockReturnValue({ live: true, userid: '' }); - const copySpy = setupClipboardSpy(); + const copy = getClipboardCopy(); spectator.component.exportItems[1].command?.({} as never); - const snippet = copySpy.mock.calls[0][0]; + 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(); + }); }); describe('asContentState', () => { 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 index 0d621f1342dc..8a16501ab8d3 100644 --- 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 @@ -20,7 +20,7 @@ [pTooltip]="'queryTool.action.help' | dm" tooltipPosition="top" [attr.aria-label]="'queryTool.action.help' | dm" - (onClick)="helpPopover().toggle($event)" + (onClick)="$helpPopover().toggle($event)" data-testid="query-tool-help-btn" /> 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 index 80dfe03b1e70..b3596b3abc85 100644 --- 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 @@ -158,7 +158,11 @@ describe('DotQueryToolPageComponent', () => { it('resets offset and triggers runSearch when clicked', () => { const store = setup(); store.query = jest.fn().mockReturnValue('+live:true'); - spectator.component.onRun(); + 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(); }); 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 index 0b2b4ef11c1d..ccc41e5d859c 100644 --- 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 @@ -133,8 +133,8 @@ export class DotQueryToolPageComponent implements OnInit { }); }); - readonly helpPopover = viewChild.required('helpPopoverEl'); - readonly exportMenu = viewChild('exportMenu'); + readonly $helpPopover = viewChild.required('helpPopoverEl'); + readonly $exportMenu = viewChild('exportMenu'); readonly QUERY_EDITOR_OPTIONS = QUERY_EDITOR_OPTIONS; readonly RAW_EDITOR_OPTIONS = DOT_MONACO_RAW_OPTIONS; @@ -158,15 +158,15 @@ export class DotQueryToolPageComponent implements OnInit { readonly exportItems: MenuItem[] = [ { label: this.#messageService.get('queryTool.share.url'), - command: () => this.copyShareUrl() + command: () => this.#copyShareUrl() }, { label: this.#messageService.get('queryTool.share.curl'), - command: () => this.copyAs('curl') + command: () => this.#copyAs('curl') }, { label: this.#messageService.get('queryTool.share.fetch'), - command: () => this.copyAs('fetch') + command: () => this.#copyAs('fetch') } ]; @@ -196,8 +196,8 @@ export class DotQueryToolPageComponent implements OnInit { 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 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') ?? ''; @@ -250,7 +250,7 @@ export class DotQueryToolPageComponent implements OnInit { useExample(query: string): void { this.store.setQuery(query); - this.helpPopover().hide(); + this.$helpPopover().hide(); } copyToClipboard(value: unknown): void { @@ -266,15 +266,15 @@ export class DotQueryToolPageComponent implements OnInit { } toggleExportMenu(event: MouseEvent): void { - this.exportMenu()?.toggle(event); + this.$exportMenu()?.toggle(event); } - private copyShareUrl(): void { + #copyShareUrl(): void { const href = this.#document.defaultView?.location.href; if (href) this.#copy(href); } - private copyAs(format: 'curl' | 'fetch'): void { + #copyAs(format: 'curl' | 'fetch'): void { const body = this.store.apiRequestBody(); if (format === 'curl') { const origin = this.#document.defaultView?.location.origin ?? ''; @@ -289,7 +289,7 @@ export class DotQueryToolPageComponent implements OnInit { if (!ok) this.#globalMessage.error(); } - private parseInt(value: string | null, fallback: number): number { + #parseInt(value: string | null, fallback: number): number { if (!value) return fallback; const n = Number.parseInt(value, 10); return Number.isFinite(n) ? n : fallback;