Skip to content

modernization(query-tool) #35709: migrate Query Tool portlet from Dojo JSP to Angular#35792

Merged
hmoreras merged 13 commits into
mainfrom
issue-35709-query-tool-angular
May 24, 2026
Merged

modernization(query-tool) #35709: migrate Query Tool portlet from Dojo JSP to Angular#35792
hmoreras merged 13 commits into
mainfrom
issue-35709-query-tool-angular

Conversation

@hmoreras
Copy link
Copy Markdown
Member

@hmoreras hmoreras commented May 21, 2026

Summary

Migrates the Query Tool portlet from the legacy Dojo/Dijit JSP to a new Angular + PrimeNG implementation, with a query-tool-legacy rollback entry preserved in portlet.xml.

Resolves #35709 (sub-task of epic #34735).

Screenshare.-.2026-05-22.8_33_31.AM.mp4

What changed

New Angular portlet — core-web/libs/portlets/dot-query-tool/

  • Shell + page component + DotQueryToolStore (NgRx SignalStore) + data-access service
  • PrimeNG layout: two-pane splitter, Monaco editor (plain-text Lucene), collapsible Parameters panel, stats bar + Results / Raw tabs
  • Help popover with four canonical Lucene examples
  • URL query-param state sync (q, offset, limit, sort, userId) replacing the legacy rememberQuery session flag — implemented as a reactive effect() using Router.createUrlTree + Location.go (canonical dot-content-drive-shell pattern) so address-bar updates do not re-mount the component
  • MAX_RESULTS = 1000 cap mirroring ES Search's MAX_HITS, with a <p-message> warn when the user-supplied limit is capped
  • Result row click opens the target contentlet in a new tab:
    • 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 once the async getContentType() lookup resolves
  • Consumes POST /api/v1/content/_search (v1 wrapper, preferred per ContentResource JavaDoc)

Cross-portlet shared helpers (consumed by both Query Tool and ES Search)

  • libs/utils: buildCurlSnippet, buildFetchSnippet (POSIX-shell-safe) + unit tests
  • libs/ui: DOT_MONACO_BASE_OPTIONS, DOT_MONACO_RAW_OPTIONS
  • Store: apiRequestBody computed — single source of truth for the search payload so runSearch and the curl/fetch snippets cannot drift
  • DotClipboardUtil replaces hand-rolled navigator.clipboard.writeText; getDownloadLink replaces hand-rolled <a> download anchors

ES Search alignment

  • Stats bar format harmonized: Showing X of Y results · N ms
  • Share / Export button parity
  • Tab count-badges removed from both portlets; Raw editor gains bottom padding

Backend wiring

  • portlet.xml: query-tool now points at PortletController (Angular shell); query-tool-legacy entry preserved as live-toggle rollback to the existing JSP without redeploy
  • PortletID: QUERY_TOOL_LEGACY added (mirrors TAGS_LEGACY precedent)
  • Language.properties: com.dotcms.repackage.javax.portlet.title.query-tool-legacy = Query Tool Legacy so the legacy twin shows up under Roles → Tools (parity with es-search-legacy / tags-legacy)
  • queryTool.action.run: SubmitRun (button label + empty-state hint copy); help tooltip / aria-label: Click for query examples

Tests

  • Jest + Spectator coverage for shell, page, store, and service (27 passing) — limit-cap behavior, Location.go URL-sync assertions, menu-driven export tests via exportItems[i].command() + clipboard spy, error paths through httpErrorManager.handle
  • New unit tests for buildCurlSnippet / buildFetchSnippet in libs/utils

Definition of Done (from epic #34735)

  • Admin runs a valid Lucene query — matching contentlets render with contentTook / queryTook timings
  • Admin runs an invalid Lucene query — inline error surfaced via httpErrorManager; results table unchanged
  • Non-admin role blocked (server-side gate; userId forwarded only when set)
  • Unlicensed instance blocked (existing license gate preserved)
  • Rollback to legacy JSP without redeploy — query-tool-legacy portlet entry
  • Pagination across limit-size pages with URL-synced offset

🤖 Generated with Claude Code

This PR fixes: #35709

hmoreras and others added 5 commits May 15, 2026 10:29
…gular

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) <noreply@anthropic.com>
… polish

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) <noreply@anthropic.com>
…e URL sync

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 <p-message severity="warn"> 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) <noreply@anthropic.com>
…elpers

Query-tool stats bar now matches the ES Search format ("Showing X of Y
results · N ms"), with Share/Export buttons that mirror ES Search.
Tab count-badges removed from both portlets; Raw editor gains bottom
padding so the closing brace no longer sits flush against the viewport.

Cross-portlet refactors land the duplicated copy/download/snippet code
into shared modules:

- libs/utils: buildCurlSnippet, buildFetchSnippet (POSIX-shell-safe)
- libs/ui:   DOT_MONACO_BASE_OPTIONS, DOT_MONACO_RAW_OPTIONS
- query-tool store: apiRequestBody computed (single source of truth for
  the search payload; runSearch and the snippets read from the same
  computed so they cannot drift)

Component cleanup in both portlets:

- DotClipboardUtil replaces hand-rolled navigator.clipboard.writeText
- getDownloadLink replaces hand-rolled <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) <noreply@anthropic.com>
@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 21, 2026

Claude finished @hmoreras's task in 3m 52s —— View job


PR Review

Solid migration overall. The store/component split is clean, the URL sync via effect() + Location.replaceState is the right pattern, and the popup-blocker workaround on result clicks is thoughtful. A few real issues and a handful of nits below.


Bugs / Risks

1. Unreachable error handler in onResultClickdot-query-tool-page.component.ts:244-247

DotContentletEditUrlService.resolveEditUrl() swallows content-type lookup failures via catchError(() => of(null)) and always emits a URL. The synchronous HTMLPAGE branch (of(pageUrl)) never errors either. So the error callback that closes the placeholder tab and shows the global error toast is dead code — when the API actually fails, the user gets the legacy editor URL silently (which may or may not work). Two options:

  • Stop swallowing in the service (let the caller decide), OR
  • Remove the dead error branch and accept the silent legacy fallback as the contract.

Either way the current code documents an error path that cannot fire.

2. userId URL param is not gated by admin statusdot-query-tool-page.component.ts:202 + dot-query-tool.store.ts:100-109

ngOnInit reads userId from query params and stores it before any admin check. apiRequestBody then includes it in every search payload as long as it's non-empty. A non-admin who pastes (or is sent) a link like /query-tool?q=...&userId=admin@dotcms.com will issue searches with userId set. The PR description says the server enforces this, and that's the right primary defense — but the client should at minimum:

  • Not hydrate userId from the URL when !isAdmin, and
  • Not write userId into the URL via the sync effect for non-admins.

Otherwise the shareable URL leaks impersonation intent in both directions.

3. urlMap / url may not be stringsdot-contentlet-edit-url.service.ts:125

const url = (contentlet['urlMap'] as string) || (contentlet['url'] as string);

Both fields are accessed as unknown and cast. If a contentlet has a non-string urlMap (object, array, number from a misconfigured field), URLSearchParams will stringify it to [object Object] and the page editor opens with a broken URL. Add typeof === 'string' guards, or null-coalesce to null and fall through to the inode-based resolver.

4. url === this.#router.url short-circuit is misleadingdot-query-tool-page.component.ts:130

Location.replaceState intentionally does not update Angular Router.url (that's the whole point of using Location instead of Router.navigate — preserve the component instance). So after the first sync, this.#router.url keeps reflecting the original URL, never the synced one. The comparison is effectively dead after the first run; #lastSyncedUrl alone is doing the work. Either drop the router.url comparison or comment why it exists (handles the "first run already matches the URL we'd write" case). Right now it reads like a load-bearing check and isn't.


Smaller issues

5. Endpoint string duplicatedSEARCH_ENDPOINT = '/api/v1/content/_search' is hardcoded in both dot-query-tool-page.component.ts:60 (for snippet building) and dot-query-tool.service.ts:20 (actual call). If one ever moves, the copy/cURL snippets silently lie. Export from the service or a small constants file.

6. Missing test for the limit-cap input UX described in the doc — the store comment at dot-query-tool.store.ts:139-155 makes a careful UX claim ("user sees the cap explanation alongside the capped results instead of an input value silently changing mid-loading"). The store tests cover this. The component tests do not assert the p-message warning renders when limitWasCapped() is true — that's the user-visible piece of the contract. Worth a quick byTestId('query-tool-size-cap-warning') assertion.

7. No test for the error branch of onResultClick — even if you keep the dead handler (see #1), there's no test exercising it. Conversely if you remove it, no test needs updating. This is a tell that the branch isn't real.

8. Help examples assume htmlpageasset / fileAsset existdot-query-tool-page.component.ts:177,182 hard-codes content type variables. Customer instances that have renamed or removed these defaults will see help examples that return zero results. Not a blocker; consider a comment that flags it for future i18n/customization.

9. setLimit(NaN) keeps NaNdot-query-tool.store.ts:127: Math.max(1, NaN) is NaN. In practice the p-inputNumber won't send NaN, but the helper #parseInt already protects URL hydration. Belt-and-suspenders: Math.max(1, Number.isFinite(limit) ? limit : DEFAULT_LIMIT).

10. ES Search copyAs becomes private while Query Tool's becomes #private — for consistency with the recently-applied review (using # prefix in the new portlet), the same convention should apply to the older ES Search component (dot-es-search-page.component.ts:280,286,337,349,357). Since this PR is already touching that file, worth aligning. Not blocking.


Things I checked and they're fine

  • Backend SearchForm uses camelCase userId — matches frontend payload (the ES Search lowercase userid is a different endpoint convention; no drift here).
  • URLSearchParams properly encodes the url parameter into the page edit hash route — no XSS via the page URL.
  • inode is interpolated into hash routes only, not document URL — even a malicious inode can't escape the hash.
  • effect() reads status() tracked, everything else untracked() — no infinite-loop risk.
  • switchMap correctly cancels prior in-flight searches when Run is clicked twice.
  • tapResponse resolves the EMPTY observable test case as expected (status stays LOADING).
  • Portlet count assertion in SerializationHelperTest (52 → 53) matches the one new query-tool-legacy entry.

Suggested fixes

For the top two issues, here are direct hooks for Claude:

Fix #1 (dead error path) →

Fix #2 (userId admin gating) →


Net: one real bug (#1 dead error path produces silent failures), one security-shaped item (#2 client-side admin gating is missing), and a handful of polish items. Nothing that blocks the migration concept.
· branch: issue-35709-query-tool-angular

@github-actions github-actions Bot added Area : Backend PR changes Java/Maven backend code Area : Frontend PR changes Angular/TypeScript frontend code labels May 21, 2026
…ce 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.
…rlService

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.
hmoreras added 2 commits May 21, 2026 21:20
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.
…Test

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.
hmoreras added 2 commits May 22, 2026 11:05
- 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.
@hmoreras hmoreras added this pull request to the merge queue May 24, 2026
Merged via the queue into main with commit 744a9cb May 24, 2026
53 checks passed
@hmoreras hmoreras deleted the issue-35709-query-tool-angular branch May 24, 2026 04:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI: Safe To Rollback Area : Backend PR changes Java/Maven backend code Area : Frontend PR changes Angular/TypeScript frontend code

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

[Task] Query Tool: Angular implementation + backend wiring

2 participants