Skip to content

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

Open
hmoreras wants to merge 11 commits into
mainfrom
issue-35709-query-tool-angular
Open

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

Conversation

@hmoreras
Copy link
Copy Markdown
Member

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).

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

Test plan

  • yarn nx test dot-query-tool — all unit tests pass
  • yarn nx test dot-es-search — ES Search regression suite passes (shared helpers)
  • yarn nx lint dot-query-tool clean
  • yarn nx serve dotcms-ui → open Query Tool portlet as admin:
    • Valid Lucene query renders results + timings
    • Invalid query surfaces inline error, results unchanged
    • Pagination forward/back updates offset and URL params
    • Limit > 1000 shows the cap warning and capped result set
    • Share / Export (curl, fetch, JSON) copy and download correctly
    • Clicking a result opens the correct editor in a new tab (HTML page, new editor, legacy editor)
    • URL params (q, offset, limit, sort, userId) round-trip on reload
  • Toggle portlet.xml to query-tool-legacy → legacy JSP renders without redeploy
  • Non-admin login → portlet access denied per existing gating

🤖 Generated with Claude Code

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 4m 0s —— View job


Review

Mostly clean. The big pieces (store, shared resolver, URL sync, portlet wiring, tests) hang together well. The findings below are minor — none are blockers, but a few merit a second look before merge.


Worth a second look

1. onResultClick error branch is effectively dead code
core-web/libs/portlets/dot-query-tool/src/lib/dot-query-tool-page/dot-query-tool-page.component.ts:231-249

DotContentletEditUrlService.resolveEditUrl already swallows errors via catchError(() => of(null)) (see dot-contentlet-edit-url.service.ts:112), so the error: branch in onResultClick.subscribe cannot fire in practice. The placeholder.close() + #globalMessage.error() lines look like a safety net but are unreachable. Either drop the error branch or — better — give callers a way to know the resolver hit a transient lookup failure (the JSDoc currently invites callers who care to "query DotContentTypeService.getContentType() directly", which is a worse contract than just propagating).

2. Popup-blocker rejection is silent
Same file, line 237-238:

const '_blank');
if (!placeholder) return;

When a popup blocker rejects the placeholder open, the click does nothing visible. At minimum show a #globalMessage.error() so the admin understands why the click "didn't work."

3. Race: in-flight runSearch will clobber the user's mid-edit limit value
core-web/libs/portlets/dot-query-tool/src/lib/dot-query-tool-page/store/dot-query-tool.store.ts:139-175

userLimit is captured at the start of switchMap. If the user changes the limit input while the search is in flight, the next: handler does:

limit: wasCapped ? MAX_RESULTS : userLimit

…which overwrites the user's current input with the value they had when they clicked Run. For a long-running query against a big index, this is a confusing UX paper-cut. Consider only patching limit when wasCapped is true (the cap-snap is the only reason this branch needs to exist) and leaving non-capped runs alone.

4. Share-URL vs cURL/fetch snippets are sourced from different states
dot-query-tool-page.component.ts:272-285

  • copyShareUrl() reads location.href, which only updates on LOADED/ERROR (via the updateQueryParamsEffect).
  • copyAs('curl'|'fetch') reads store.apiRequestBody() which reflects whatever's currently in the input fields — including unsaved edits.

So after a user edits the offset but doesn't re-run, the Share-URL and the cURL snippet disagree. Probably worth either (a) syncing the URL on input change, or (b) sourcing the share URL from apiRequestBody directly so both follow the same rule.

5. PR description vs code drift: Location.goLocation.replaceState
The PR body says the URL sync uses Router.createUrlTree + Location.go. The actual code uses Location.replaceState, and the inline comment + tests both confirm replaceState. replaceState is the correct choice (avoids polluting the back stack); just update the PR description so reviewers don't get whiplash.

6. License-gate claim in DoD
The DoD says "Unlicensed instance blocked (existing license gate preserved)". The route added in app.routes.ts:176-183 does not attach DotEnterpriseLicenseResolver (the way the es-search and analytics routes do), and the legacy lucene_search.jsp has no licens|enterprise|level text either — so "preserved" is trivially true (the legacy didn't gate either). If the intent really is unlicensed instance blocked, that needs to come from the /api/v1/content/_search server side; please verify, because the DoD reads like there is a UI gate when there isn't.


Smaller stuff

  • buildPageEditUrl falls back to urlMap for HTMLPAGE (dot-contentlet-edit-url.service.ts:125). urlMap is conceptually for URL-mapped detail pages, not HTML pages. If an HTMLPAGE ever happens to carry a urlMap value, the resolver will hand back a misleading URL. The url lookup should be sufficient for the HTMLPAGE branch — drop the urlMap fallback or document why it's there.
  • userId truthiness check is userId ? rather than userId.trim() ? (store.ts:108). A whitespace-only userId will be forwarded to the server. Trivial; the server gate makes it harmless.
  • parseInt is lax (dot-query-tool-page.component.ts:292-296). ?limit=1abc hydrates as 1, ?limit=1e5 as 1. Acceptable for an admin tool, but worth knowing.
  • buildFetchSnippet emits JSON.stringify({ … literal object … }) at runtime, which works because JSON-stringified output is also a valid JS object literal — but the wrapping JSON.stringify(...) call is redundant if the goal is a pasteable snippet. Stylistic.
  • emptyStateConfig lives in the store. core-web/libs/portlets/CLAUDE.md says the store shouldn't own UI state. This is a borderline case (the value is { title, icon, subtitle } from i18n — data, not behavior), but worth noting since the pattern propagates to other portlets.

Tasks

  • Read changed files (Angular portlet + backend wiring)
  • Review store, page component, service, and URL sync logic
  • Check shared helpers (buildCurlSnippet, buildFetchSnippet, DotContentletEditUrlService)
  • Check backend wiring (portlet.xml, PortletID, Language.properties, SerializationHelperTest)
  • Compile findings
    • 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.
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

1 participant