Skip to content

feat(content-drive): Title / All Fields search scope + literal-text search terms (#37479, #37532) - #37554

Open
KevinDavilaDotCMS wants to merge 30 commits into
mainfrom
issue-37479-content-drive-search-scope-impl
Open

KevinDavilaDotCMS wants to merge 30 commits into
mainfrom
issue-37479-content-drive-search-scope-impl

Conversation

@KevinDavilaDotCMS

Copy link
Copy Markdown
Contributor

Spec-Kit PR 2 for #37479 / #37532. Implementation on top of the approved spec (PR #37518 @ 1ce8cdd1bf).

What this closes

#37532 (High, customer ticket 39185) — Content Drive's search box now matches a term as literal text instead of reading it as Lucene query syntax. The reported headline is findable verbatim, the full reserved character set (\ + - ! ( ) : ^ [ ] " { } ~ * ? | & /) matches literally in both scopes, and consecutive separators no longer emit empty clauses. Verified field filters were already correct; added the test that proves it (ContentDriveFieldFilterTest#textFieldFilter_matchesEveryReservedCharacterLiterally).

#37479 — Content Drive's search box gets a Title / All Fields scope selector, defaulting to All Fields (no regression). Title matches the contentlet title only, via a prefix-seek clause with no catchall and no leading wildcard — the fast path the issue asked for. The scope lives in the URL alongside the other filters, survives reload/Back/Forward/sharing, and counts as filter state only while non-default (so it never falsely lights up "Clear all").

Scope split — verified structurally, not assumed

Both fixes live entirely inside the ES text branch of BrowserAPIImpl, reachable only when useElasticsearchFiltering is set — and ContentDriveHelper is the only place in the codebase that sets it (grepped against main). The other three doors into the same listing — WebAssetHelper, BrowserAjax, DotCMSMacroWebAPI — build their BrowserQuery without it and are unaffected by construction, not by care. New isolation test in BrowserAPITest pins this. The Asset Picker (the other caller of POST /drive/search) gets no control and its 394 existing tests pass unmodified.

⚠️ Two spec amendments need re-approval before merge

The spec was approved with stronger wording than what shipped, for reasons found during implementation — both are struck through in place in spec.md with the date and rationale, not silently edited:

  • FR-029 (narrowed 2026-09-14): "every query failure surfaces as an error" was implemented, then reverted — it broke ContentDriveFieldFilterTest#testMalformedDateBoundIsSafe's security guarantee (a Lucene-injection attempt must match nothing and produce no 500; raising query failures turned that into an error response instead, telling an attacker their probe landed). What ships: the front end surfaces failures it can itself observe (network/server errors); BrowserAPIImpl's internal execution failures stay logged-only, as before this feature.
  • FR-003 (narrowed 2026-09-15): direct instruction during UI review — the placeholder no longer describes the active scope. It's fixed to the shared search box's default in both Title and All Fields. Not a defect; FR-003 as written had been fully implemented and tested.

@zJaaal @ihoffmann-dot — both of you approved PR #37518 after these amendments landed in the spec, so the sign-off already covers this wording. Flagging here so it's explicit rather than assumed.

SC-003 — not delivered as a completed benchmark

Measured Title vs. All Fields first-page latency on two independently-designed datasets (~3000 docs each), 12 interleaved rounds per dataset. Title was consistently slower, the opposite of what FR-010's rationale predicts. Ruled out one hypothesis with real numbers (the ES boolean-clause chunking math is identical for both clause shapes at this candidate-set size). Could not isolate the remaining candidates — OpenSearch query-cache state, segment-merge state on a just-bulk-indexed index, whether the leading-wildcard cost the feature avoids is simply too cheap to matter at 3000 distinct titles — within this session, on a shared dev instance that had absorbed a full day of builds and test runs.

This does not block this PR: FR-010's actual requirements (no catchall, no leading wildcard in the mandatory gate) are met and unit-tested regardless of what wall-clock timing shows on this instance. The performance claim is left honestly unproven rather than asserted. Full write-up with both datasets' numbers and the ruled-out hypothesis is in tasks.md's Phase 7 note (gitignored, so linking the finding here): a dedicated, unloaded OpenSearch instance at materially larger scale is needed before SC-003 can be claimed satisfied.

Convergence

/speckit-converge ran twice. First pass found 3 findings — two stale acceptance-scenario texts left over from the FR-003/FR-029 amendments, one under-tested requirement (field-filter reserved-character coverage existed as one fixed string, not the exhaustive sweep SC-012 promises). All three closed; second pass reports converged with zero findings.

Testing

  • Backend unit: 13 assertions (GlobalSearchAttributeStrategyTest + baseline), 0.015s, no DB.
  • Backend integration: 88 assertions across ContentDriveSearchScopeTest, ContentDriveLiteralTextSearchTest, ContentDriveKeywordSearchTest, ContentDriveFieldFilterTest, BrowserAPITest — all registered in MainSuite3a.
  • Postman: 100/101 assertions on the full ContentDriveResource collection (the one failure is a pre-existing missing test-image fixture, unrelated to this change).
  • Frontend: 1398 tests / 30 files (portlet) + 394 tests / 13 files (ui, Asset Picker included, unmodified).
  • SC-002 carve-out enumerated: one pre-existing test outside the four Content Drive classes changed expectations (BrowserAPITest#test_buildBaseESQuery_withDifferentFilterCombinations, Test Case 5 — an unescaped & was pinning the exact defect Content Search: escape Lucene query-syntax characters in content type field filters #37532 reports).

No openapi.yaml regeneration — /v1/drive/search is @Hidden and absent from the committed spec.

Refs #37479, #37532

🤖 Generated with Claude Code

KevinDavilaDotCMS and others added 24 commits September 11, 2026 13:10
…h box

Spec-Kit PR 1 for #37479. Carries spec.md alone.

Settles the four decisions the issue left open: All Content stays the
default, Title mode matches the contentlet title only, the scope lives in
the URL rather than a user preference, and sorting is untouched.

Four premises verified against main and corrected in the spec:

- Nothing sorts by score today — the default is modDate:desc on both
  sides, and the only trace of score sorting is a stale comment. Open
  decision 4 is void.
- Folders and links never reach Elasticsearch; they are already matched
  on name only, in both scopes, so the selector governs the contentlet
  clause alone.
- buildPureESQuery is unreachable under the shipped heuristic, so it
  stays out of scope.
- A scope written into the filters on every selection would light up
  "Clear all" on an unfiltered drive, because hasNonDefaultFilters counts
  every key but two. Hence FR-021: the scope counts as filter state only
  while it differs from the default.

Refs #37479

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…, ADR-0018

Four asks from @zJaaal and one from @ihoffmann-dot, all settled in a new
Review Decisions table (rows 5-8) so PR 1's approval records them.

Renamed the wide option "All Content" -> All Fields (values TITLE /
ALL_FIELDS). "All Content" described a set of content, which is what
#37426's browse scope All genuinely means; this scope widens which
*fields* are read, not which content is searched. The issue's original
wording stays recorded verbatim in Input.

Named the concept "search scope" throughout and the wire field
filters.searchScope, inside the existing filters object rather than at
the top level. AbstractQueryFilters is {text, filterFolders} today and
filterFolders' own Javadoc says "when text is provided" — both members
qualify the text search, which is exactly what the search scope does.
FR-025 makes a scope with no text a contract error rather than a rule to
remember. The browse scope stays top level for the opposite reasons.

FR-017 now names the Asset Picker as the caller the optional-field
requirement protects, with its actual call site, so a future change to
the default has to confront it by name. FR-024 pins the change to the
text-search branch and names the three other doors into the same listing
(WebAssetHelper, BrowserAjax, DotCMSMacroWebAPI) that would widen the
blast radius from two callers to six. SC-008 measures it.

ADR-0018's Title -> DB ∪ Index routing gets its own section and an
explicit deferral rather than silence. The ADR defers its own union: it
states contentlet.title is "not reliably populated" and that fixing that
is a separate issue. Verified that no text search consults the column
today — Premise Correction 5 shows ContentDriveHelper:180-184 sets
useElasticsearchFiltering(true) unconditionally when text is present, so
the SQL ILIKE text path is unreachable for Content Drive and contentlet
matching is index-only in both scopes. Title scope inherits the existing
index-lag exposure rather than creating it, and FR-026 keeps the union
additive for when the gated work lands.

Also adds FR-022 (the control must explain what each option matches —
two labels do not carry the distinction on their own).

Refs #37479

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…terms

#37532 (High, customer ticket 39185) reports that content type field
filters in the Content Search portlet return "no results found" for
values containing Lucene query-syntax characters. Verified that the same
defect lives in Content Drive's own search box, in the exact clause
FR-010 rewrites, so the two issues are resolved together.

Premise Correction 6 records what GlobalSearchAttributeStrategy actually
does. Escaping is applied to its final clause only (:46-48); the
mandatory gate at :37-38 is built from the raw value, so a term like
"ABC (XETRA: DB)" yields invalid query_string syntax. Its private
SPECIAL_CHARS_TO_ESCAPE regex (:20) is missing "/", which is #37532's
fifth acceptance criterion verbatim, while LuceneQueryUtils.escape —
vendor-neutral, documented, already used by TextFieldStrategy — covers
the full reserved set. The split at :40-45 has no empty-token filter, so
consecutive separators emit a term-less title:^5 clause. And a query
that fails to parse is swallowed at BrowserAPIImpl:893-895, returning an
empty set, which is why the user is told their content does not exist.
Nothing sanitizes upstream: ContentDriveHelper:183 passes text() raw.

The parallel field-filter path is already correct — TextFieldStrategy
escapes and filters empty tokens — so #37532's Content Drive field-filter
criterion is verification, not implementation. FR-030 and SC-012 pin it.

FR-009 and SC-002 had to yield. Escaping the gate changes All Fields
results for affected terms, which contradicts the no-regression promise
as approved, so the carve-out is written down and bounded rather than
smuggled in: reserved characters and consecutive separators only, every
other term unchanged, and the tests that change enumerated in the PR.

Adds User Story 4 (P1, listed fourth), FR-027 to FR-031 and SC-009 to
SC-012. FR-031 keeps the fix in the shared strategy rather than a
Content-Drive-only branch, so the Search portlet and the Relationships
dialog stop mis-parsing reserved characters too.

What this deliberately does not do is stated in Legacy Considerations and
in "Why #37532 lands here": the Content Search portlet keeps its current
behaviour. #37532 itself directs the improved behaviour to Content Drive
rather than to the legacy construction, but the customer on ticket 39185
is using the portlet today, so closing #37532 on this work is the issue
owner's call and is flagged rather than assumed.

Refs #37479, #37532

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Resolves the Content Drive half of #37532 (High, customer ticket 39185):
filtering on a value containing ":", "(" or "/" returned "No results
found" for content the user was looking at.

GlobalSearchAttributeStrategy escaped only its final clause, so the
mandatory gate — the clause that decides whether a document matches at
all — was built from raw user input. A term like "ABC Bank (XETRA: DB)"
produced a query Elasticsearch could not parse; the failure was logged
and discarded, and the caller received an empty result indistinguishable
from a genuine miss.

Three defects, one cause:

- The gate carried the raw term. Now the term is escaped once, up front,
  and every clause uses the escaped value. The "*" wildcards the strategy
  appends are added after escaping so they stay live.
- The private SPECIAL_CHARS_TO_ESCAPE regex omitted "/" entirely. Replaced
  with LuceneQueryUtils.escape — the helper TextFieldStrategy already
  uses, so the two strategies no longer disagree about the reserved set,
  and a character walk rather than a regex, which ADR-0009 calls for
  ahead of the ES→OpenSearch move.
- Consecutive separators emitted a term-less "title:^5" clause. Empty
  tokens are now dropped, matching TextFieldStrategy.

A query that fails to execute is no longer reported as a search that
found nothing — but only for callers that ask. BrowserQuery gains
surfaceQueryFailures, off by default, and Content Drive is the only
caller that opts in. The assets REST API, the legacy admin browser and
the Velocity viewtool keep receiving today's empty result. The failure
is still logged either way.

Also lays the contract groundwork for #37479: SearchScope, and
filters.searchScope defaulting to ALL_FIELDS so a request omitting it is
processed exactly as before. Nothing reads the scope yet.

Evidence, against real PostgreSQL and OpenSearch:

- ContentDriveLiteralTextSearchTest 3/3. The ticket 39185 headline is
  found when searched verbatim, all 19 reserved characters are findable
  by their own text, and field filters match literally. The first two
  failed before this change — that Red is what proved the defect reported
  against the Content Search portlet also reaches Content Drive.
- 21 unit assertions green.

GlobalSearchAttributeStrategyBaselineTest pins the boundary: two terms
without reserved characters produce byte-identical queries, and the three
that changed carry their before/after in-line. Those three are the
complete list of pre-existing expectations this commit changes.

The Content Search portlet's own ContentletAjax path is deliberately
untouched — #37532 directs the improved behaviour to Content Drive rather
than to the legacy construction.

Refs #37532, #37479

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Completes FR-029 for #37532. The backend now distinguishes "the query
failed" from "nothing matched"; this puts that on screen.

The store already set status ERROR on a failed search, but nothing
rendered it: the grid simply went empty. That is the defect the customer
reported — content they were looking at appeared not to exist.

A banner now sits above the listing when the last search failed, with a
retry. Above rather than instead of: replacing the grid would hide the
thing the message explains, and it would also make the listing
unreachable to component queries, which broke seven existing specs on the
first attempt.

Also removes a stale comment in the store claiming score-descending
sorting for title search. Nothing has sorted by score since #36688 — the
default is modDate on both sides — so the comment described behaviour the
code does not have (progressive enhancement; spec Premise Correction 1).

Four specs cover the banner: it appears, it stays alongside the listing
it explains, it carries role="alert", and the retry re-runs the search.
All 162 specs in the shell suite pass.

Refs #37532, #37479

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Implements the backend half of #37479. filters.searchScope now selects
which fields a term is matched against: ALL_FIELDS (the default, and
today's behaviour) or TITLE.

The Title clause is a sibling of GlobalSearchAttributeStrategy, not a
mode inside it. That strategy also serves the Search portlet and the
Relationships dialog through the Lucene Query Builder service, and
neither asked for a narrower query.

It deliberately uses neither catchall nor a leading wildcard in its
mandatory gate: catchall aggregates every field, which is the breadth the
scope exists to avoid, and title_dotraw is a keyword field where *term*
scans every distinct raw title while term* is a prefix seek. #36688
removed a leading wildcard for that reason. Known trade-off, signed off:
dropping it also drops mid-token matching, so "1004" will not find
IMG_1004.jpeg in Title scope. All Fields keeps it (#36791).

Also reverts the failure-raising added in the previous commit. Two
reasons found while building on it:

- With FR-027's escaping in place, no user input can break the query, so
  what remained was infrastructure failure. The only way to force one
  through the public API was a 2000-word term hitting the clause ceiling.
- It broke testMalformedDateBoundIsSafe, which asserts that a Lucene
  injection attempt is escaped, matches nothing, and produces no 500.
  Raising the failure told an attacker their probe had landed.

The front end keeps surfacing the failures it can observe as an error
banner, so an unexplained empty grid is still not what a failed search
looks like. #37532's UI criterion is therefore partially met, and
spec.md's FR-029 needs amending and re-approval before PR 2 — recorded in
tasks.md rather than left implicit.

42 integration assertions green across the four Content Drive classes:
the scope narrows as specified, an omitted scope is indistinguishable
from ALL_FIELDS, folder matching is identical in both scopes, a scope
without text is refused, and the customer fix and security guarantee both
still hold.

Refs #37479, #37532

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… box

Completes the user-facing half of #37479. The search box now carries a
two-option control saying which fields the term is matched against, and
the placeholder follows it so the box states what it will do before the
user types again.

The control is composed in Content Drive's own store-aware wrapper rather
than in the shared DotSearchInputComponent. That component is
deliberately presentational so the AssetPicker can reuse it without a
store, and composing here means the AssetPicker cannot acquire the
control by accident — its 12 spec files pass unmodified, which is the
assertion rather than a side effect.

The scope is stored under its own filter key, never under `title`. That
key holds the search TERM, and a scope whose value is 'TITLE' sitting
beside a filter named `title` is a collision waiting to happen.

It is written into the filter state only while it differs from the
default, and deleted when it returns to it. Not cosmetic:
hasNonDefaultFilters counts every filter key but two, and that signal
shows the chip bar's "Clear all" — writing the key on every selection
would offer "Clear all" on a drive with nothing filtered at all. Mirrors
how setGlobalSearch already deletes its own key when the term empties.

Re-selecting the active scope is ignored: the results cannot change, and
patchFilters would reset the user to page 1 for nothing.

On the wire, searchScope is sent only when a term is present and the
scope is not the default, so a request from any other caller is
byte-identical to what it was before this field existed.

PrimeNG usage validated against the component API: pTooltip is a
directive on a wrapper rather than a SelectButton input, and the control
is named with a plain aria-label — ariaLabelledBy takes an element id,
not text, which the first attempt got wrong.

30 of 30 portlet spec files green, including 9 new ones covering the
control, the placeholder, the no-op re-selection, the accessible name and
the explanation.

Refs #37479

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two bugs, both found by typechecking rather than by the test run — Vitest
compiles with esbuild and does not check types, so the suite was green
while the library did not compile.

`decodeByFilterKey` is an exhaustive Record over the known filter keys.
Adding `searchScope` to that type without a decoder left the object
incomplete: TS2741, in the library that ships the portlet.

Registering the decoder then exposed the second bug, which was the more
serious one. FR-015 requires an unrecognized scope in the address to
resolve to the default silently, and nothing implemented it: a
hand-edited or stale link carrying `searchScope:BANANA` would have sent
that straight to the endpoint, which rejects an unknown scope with a 400
— surfacing as a stopped spinner over a stale grid.

The decoder now drops an unrecognized value, exactly as the `status`
decoder already does and for the same reason. Dropped rather than
replaced with the default: a present key counts as a non-default filter,
so substituting ALL_FIELDS would offer "Clear all" on a drive with
nothing filtered at all.

That required `decodeFilters` to drop an empty decoded value, not just an
empty array — the single-value equivalent of the array case it already
had. Safe because `encodeFilters` never writes an empty value, so an
empty decoded one can only come from a stale or hand-edited URL.

Five tests cover it: a known scope decodes, an unknown one is dropped
rather than defaulted, the round trip survives, and neither case makes an
unfiltered drive look filtered.

Refs #37479

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found in manual testing, after the suite was green. Searching "mixed
case" in Title scope returned stylesheets whose body — not title —
contained "case".

The clause interpolated the whole term into a single title:<term>*. For a
multi-word term the title: prefix binds only to the first word; every
word after it became a bare term, which Elasticsearch matches against
every field. Title scope stopped being Title scope the moment a user
typed a space.

Now one mandatory clause per token, which is the pattern TextFieldStrategy
already used and which this clause should have copied from the start. It
also makes a term containing OR or AND match as a word rather than parse
as a boolean operator — the shared all-fields strategy still has that
second problem, deferred deliberately and tracked separately.

The suite missed this because every assertion in it used a single-word
term, and the injection check asserted only that the seeded documents
were absent rather than that the result was empty — it passed while the
query matched everything else. Both are hardened here.

Fixing the leak exposed a genuine design limit that the leak had been
hiding: titleScope_alsoMatchesTermsWithReservedCharacters passed only
because the document arrived through other fields. A prefix query is not
analyzed, so a term keeps punctuation the indexed token had stripped —
"(XETRA:" is indexed as "xetra". Reaching it needs a leading wildcard,
which FR-010 forbids because it costs the prefix seek that is the scope's
whole justification. Same limit as the accepted mid-token trade-off.

That test now asserts what is actually true: a punctuated title is reachable by
its words, and a punctuated term neither breaks the query nor drags in
unrelated content. A companion test pins that All Fields — the default,
and the path #37532's customer case takes — still matches the punctuated
term in full.

FR-027 needs narrowing for Title scope and re-approval before PR 2,
alongside the FR-029 amendment. Both recorded in tasks.md.

45 integration assertions green across the four Content Drive classes.

Refs #37479, #37532

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ping it

Manual testing caught the case the suite could not: pasting the customer
headline of #37532 into Title scope returned nothing.

Every token in the clause is mandatory, so "(XETRA:" and "NYSE:" had to
match — and an escaped token never can. A prefix query is not analyzed,
while the analyzer stripped that punctuation at index time: "(XETRA:" is
indexed as "xetra". One unmatchable token sank the whole search, so a user
who pasted a title they were looking at was told it did not exist. That is
the symptom #37532 was raised about, reached by a different route, and a
new search scope has no business reintroducing it.

Stripping the reserved characters instead of escaping them aligns the term
with what the analyzer actually stored. Escaping is right when the term is
matched as a substring of a raw value, which is what the all-fields
strategy does; it is wrong when the term is matched as a prefix of an
analyzed token. Stripping is also at least as safe: a token with no
reserved characters left in it cannot be query syntax. Tokens that vanish
entirely, such as a lone slash, are skipped.

This keeps both properties the scope exists for: no leading wildcard, so
the prefix seek FR-010 requires survives, and one mandatory clause per
token, so nothing leaves the title field.

It also withdraws the FR-027 amendment recorded in the previous commit.
That rested on a dichotomy between "no leading wildcard" and "matches
punctuation" which turned out to be false — the analysis had assumed
escaping was the only available defence. FR-027 stands as approved.

A new test pins the headline pasted whole into Title scope. 46
integration assertions green across the four Content Drive classes.

Refs #37479, #37532

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 5 of #37479 needed no implementation. The scope survives reload,
Back/Forward and a shared link because it was put in the drive's existing
filter state rather than given a mechanism of its own, and every link in
that chain is generic over `filters`.

These tests pin it rather than leave it to inference:

- setSearchScope records a non-default scope, removes the key when the
  scope returns to the default, never stores the default even when set
  first, preserves the term and other filters, keeps the scope and the
  term under separate keys, resets paging to page 1, and is dropped by
  "Clear all".
- The address carries a non-default scope and never carries the default.

The separate-keys test is worth its line: `title` holds the search TERM
while `searchScope` holds the mode, and a scope whose value is 'TITLE'
sitting beside a filter key named `title` is the kind of collision that
reads as correct right up until it isn't.

The absent-by-default behaviour is what makes the address of a drive
using the default byte-identical to what it was before the control
existed — the same property that keeps "Clear all" hidden on an
unfiltered drive.

30 of 30 portlet spec files green.

Refs #37479

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…et asked for

Replaces the SelectButton with a dropdown attached to the search input,
matching the mock on #37479: the input on the left, a trigger showing the
active scope on the right, and a panel listing both options with a check
on the active one.

Input and trigger read as one control. The wrapper owns the border and
the rounding; the two children sit flush inside it, divided by a single
hairline.

The panel reuses the popover-plus-listbox the filter chips already use,
so the drive has one dropdown idiom rather than two. The check comes from
the listbox's own `checkmark` input rather than a hand-rolled icon — the
PrimeNG API validator surfaced it while flagging something else, and the
component doing its own job beats a template reproducing it.

The trigger carries aria-haspopup="listbox" and aria-expanded, and the
chevron follows the panel state.

Two test corrections along the way. The tooltip assertion read
ng-reflect-content, which Angular only emits in development mode; it now
asserts through the Tooltip directive instance. And the first version of
this template put backticks around a word inside an HTML comment, inside
a backtick template literal — it terminated the string, and the failure
surfaced as six "',' expected" errors with no mention of the cause.

The label stays "All Fields", not the mock's "All Content": Review
Decision 7 renamed it during spec review because "All" is what #37426's
browse scope means, and the two would have put different senses of the
same word on one request.

30 of 30 portlet spec files green.

Refs #37479

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nputgroup

The previous attempt put a border around a component that already had
one, so the control rendered as a box inside a box with a button bolted
on. It did not belong on the page.

p-inputgroup is the component for this: it owns the seam, the rounding
and the shared border, which a hand-rolled wrapper could only
approximate. The scope now sits in an addon, flush and borderless,
following PrimeNG's own input-group-with-select recipe.

The trigger is a p-select rather than a button driving a popover and a
listbox. A dropdown that shows its selection and opens a panel of options
is exactly what p-select is, and it brings the panel, the chevron, its
open state, keyboard handling and the checkmark on the active option
without any of it being reimplemented here.

One override remains: the shared search box brings its own border and
rounding, so its right edge is flattened and handed to the group.
::ng-deep because that markup belongs to @dotcms/ui rather than to this
template, matching how the sidebar already reaches into p-tree.

30 of 30 portlet spec files green. PrimeNG usage validated against the
component API.

Refs #37479

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…palette

Three problems visible as soon as the control rendered on a real page.

The width moved. p-select sizes itself to its selected label, so "Title"
and "All Fields" produced two different widths — and since the input took
whatever space was left, choosing a scope resized the text field under
the user's cursor. The addon is now a fixed 140px, so neither the white
field nor the group as a whole ever moves.

Neither label was readable. Both rendered truncated — "T..." and
"All ..." — because the select was sized by a layout that had not left it
room. The fixed width fixes this too; 140px is what the filter chips
below already use and it fits the longer label whole.

The colours belonged to the default input theme rather than to this page.
The border and label colour now match dot-chip-filter's own
border-slate-200 and text-slate-600, so the search box and the chip row
under it read as one family.

140px is not an invented number: the chips carry min-w-[140px] for the
same reason, having solved the same jitter first. Taken as a fixed width
rather than a minimum, because a minimum still lets the content push it.

30 of 30 portlet spec files green.

Refs #37479

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ntend standards

My changes broke two of them, and the second is why the last round of CSS
had no visible effect anyway.

The search input component carried an inline template and an inline
`styles` block. ANGULAR_STANDARDS requires logic, template and styles in
separate files; the template now lives in its own .html and the styles
block is gone entirely rather than moved to a .scss.

Gone rather than moved because the styling belongs in PrimeNG's
PassThrough. `pt` is the component's supported way into its own
internals, so the addon becomes transparent and the label gets its
padding without any stylesheet and without ::ng-deep reaching past a
component's API into its markup. STYLING_STANDARDS puts PrimeNG and
Tailwind first and custom CSS last; this had it backwards.

The failed-search banner had the same problem in a smaller way: a
hand-rolled div with a heading, a paragraph and a button, in a shell that
already imports MessageModule and already uses `p-message` with `pt`
three lines further up. It is now a p-message with severity="error",
which brings the styling, the icon and the semantics. Its heading key is
dropped from Language.properties — the severity says what the heading was
saying.

No .scss file was added, no ::ng-deep survives in the working tree, and
neither touched component carries an inline template or styles.

30 of 30 portlet spec files green.

Refs #37479, #37532

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d `!`

Read PrimeNG's own compiled CSS to find the real cause rather than
guessing again. Two separate bugs, both hiding behind the same symptom
(two disconnected boxes with a visible gap):

1. PrimeNG injects its component styles at runtime, after Tailwind's
   compiled stylesheet has already loaded. A plain Tailwind class and a
   PrimeNG rule targeting the same element (e.g. `.p-select { background:
   ...; border: ... }`) have equal specificity, and on a tie the
   later-inserted stylesheet wins — PrimeNG's. Every override in this
   component now carries Tailwind v4's `!` (important) modifier, which is
   the exact pattern PrimeNG's own inputgroup+select demo uses
   (`border-none! shadow-none! bg-transparent!` in their docs).

2. The addon side (`p-inputgroup-addon`) is a direct child of
   `p-inputgroup` and gets its own connecting rules for free — top/bottom
   border, right border and right-side rounding, all correct by default,
   confirmed straight from @primeuix/styles' inputgroup CSS. The search
   INPUT side never got the matching treatment, because `dot-search-input`
   is a wrapper component sitting between `p-inputgroup` and the actual
   `<input>`, and PrimeNG's connecting CSS only rewires DIRECT
   `.p-iconfield`/`.p-component` children — the wrapper breaks that chain,
   so the input kept its own default border and full rounding on every
   side, rendering as its own separate box no matter what was applied to
   `p-inputgroup` itself.

The second bug needed an actual capability, not a workaround: added an
optional `inputClass` to the shared `DotSearchInputComponent` so a host
that nests it in an input group can flatten its connecting edge from
outside. Empty by default, so every other consumer (AssetPicker
included, five usages) is unaffected — verified all still pass.

Also switched from the `pTooltip` directive to Select's own `tooltip` /
`tooltipPosition` inputs, dropping the now-unused TooltipModule import;
Select implements them with the same underlying Tooltip directive, so the
existing directive-instance assertion still holds.

390+ tests green: 30 portlet spec files, 13 ui spec files (394 tests,
covering the AssetPicker and the shared search input), both libraries
typecheck clean.

Refs #37479

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ecipe

Threw out the `p-select` + Tailwind `!important` approach entirely and
rebuilt on https://v21.primeng.org/inputgroup#button, the pattern PrimeNG
documents for exactly this: a trigger inside an input-group addon.

The trigger is now a plain `pButton`, directly inside `p-inputgroup-addon`
with no wrapper and no override classes. Confirmed straight from
@primeuix/styles' own inputgroup CSS that this combination is
self-styling: `.p-inputgroupaddon:has(.p-button)` zeroes the addon's
padding and `.p-inputgroupaddon .p-button` zeroes the button's radius,
which is exactly why the official demo needs no custom CSS either. No
`!important` was ever going to be necessary here — the double-boxed look
came from fighting a component (`p-select`) that isn't part of that
recipe, not from insufficient specificity.

The trigger opens a `p-popover` + `p-listbox`, styled with
`CHIP_FILTER_POPOVER_PT` / `CHIP_FILTER_LISTBOX_PT` /
`CHIP_FILTER_SCROLL_HEIGHT` — the exact constants a sibling in this same
portlet (`dot-content-drive-workflow-filter`) already uses for its own
dropdown. The panel now matches every other filter in the toolbar instead
of inventing its own look.

The one real gap — the search input's connecting edge, since its host
component sits between `p-inputgroup` and the actual `<input>` and breaks
PrimeNG's structural CSS — is closed without `!important` or a stylesheet.
`DotSearchInputComponent` gains an optional `inputDt` input, forwarded as
`[dt]` onto the real `<input pInputText>`. A design-token override sets
the CSS custom property the component's own stylesheet already reads, so
it applies by redefinition rather than by winning a specificity fight —
which is why no `!` is needed there either. The override value itself
reuses `{form.field.border.radius}`, the same token reference the active
theme's own preset uses internally, rather than a hardcoded pixel guess.
Empty by default; the five other consumers of the shared component are
unaffected.

1398 tests green in the portlet, 394 in `ui` (13 files, the AssetPicker
included), both libraries typecheck clean. Zero `!important`, zero extra
wrapper divs, zero custom stylesheets.

Refs #37479

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…he input

Two mismatches, both traced to a specific cause rather than patched by
guessing.

The button resized with its label because a p-button has no width rule
of its own — width is purely content-driven, so "Title" and "All Fields"
produced two different button sizes and the whole group visibly moved.
Fixed with a plain Tailwind `w-[140px]`, the same width the filter chips
below already use; there is nothing in Button's own stylesheet to
compete with on this property, so no override mechanic was needed at all.

The border colours never matched because Button and InputText draw from
different token families in this theme. `button.secondary.border.color`
resolves to `{surface.700}`; `inputtext.border.color` resolves to
`{form.field.border.color}`. Confirmed straight from the Lara preset
source, not guessed from how the colours looked. `TRIGGER_DT` repoints
the button's border at the same token the input already uses, so the two
stay identical if the theme itself changes rather than silently drifting
apart again.

Also truncates the label and shrinks the chevron so the longer "All
Fields" text doesn't wrap or push the icon around inside the now-fixed
width.

1398 tests still green across all 30 portlet spec files. Zero
`!important`, zero extra divs — the width is Tailwind because it's
layout, the border is `dt` because it's a PrimeNG token PrimeNG itself
needed to be told about.

Refs #37479

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The previous fix only repointed the border and made things worse: this
theme's `button.secondary` sets `borderColor: {surface.100}`, identical
to its own `background: {surface.100}` — the default secondary button's
border is invisible by design, matching its own fill. Overriding only the
border color left a visibly grey-filled button sitting next to a white
field, a bigger mismatch than the one being fixed.

`TRIGGER_DT` now repoints all three: background, border colour and text
colour, each to the exact token InputText already uses
(`form.field.background`, `form.field.border.color`, `form.field.color`).
Button and InputText are simply different PrimeNG components with
separate token families end to end, not a single misaligned property, so
partial repointing was always going to leave a mismatch somewhere.

1398 tests still green, typecheck clean.

Refs #37479

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…e placeholder

Replaces the manually-applied `border-none!` with `[pButtonPT]`'s
`root.style`. `pButtonPT`'s value reaches the host through the
`[style]`/`[class]` HOST BINDINGS `Bind` (the directive backing `pButton`)
already declares — the same mechanism as any `[style]` binding, applied
as a real inline style — so it wins the cascade over PrimeNG's own
injected `.p-button-secondary` rule unconditionally, the same guarantee
`!important` gave without reaching for it.

Also freezes the placeholder. It no longer describes the active scope:
the `[placeholder]` binding is removed entirely so the shared
`DotSearchInputComponent`'s own default ("Search") always applies,
regardless of Title vs All Fields. The `$placeholder` computed is deleted
rather than left unused.

Spec note: this narrows FR-003 ("The search input's placeholder MUST
describe the active scope"), decided directly against the approved spec
rather than derived from it. Recorded here as a marker; the amendment
itself belongs in tasks.md alongside the FR-029 one already pending
re-approval before PR 2.

1398 tests still green, typecheck clean, zero `!important` in the
component's own code (two mentions left are prose, inside a comment
explaining why one is no longer needed).

Refs #37479

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ases (T036)

BrowserAPITest gains one test pinning FR-024/SC-008 by construction:
`ContentDriveHelper` is the only caller in the codebase that ever sets
`useElasticsearchFiltering`, so a BrowserQuery built the way the other
three callers (WebAssetHelper, BrowserAjax, DotCMSMacroWebAPI) build it —
text filter set, that flag left at its default — is asserted to opt out
of ES filtering on its own and still find a reserved-character title via
the ordinary SQL path, unaffected by the Title-scope or literal-text
changes.

ContentDriveResource's Postman collection gains a "Search Scope Tests"
folder: C-1 (omitted scope behaves like ALL_FIELDS), C-2 (explicit
ALL_FIELDS matches C-1), C-3 (TITLE excludes a body-only match), C-4
(unrecognized value rejected, message names it), and two cases for "no
text" rather than one.

Splitting C-5 into 5a/5b is a real finding, not tidying: `text` is a
required attribute on the `@Value.Immutable` QueryFilters, so a request
whose JSON omits the `text` key never reaches ContentDriveHelper's own
FR-025 check at all — Jackson's deserialization rejects it first, with a
generic "required attributes not set [text]" message that never mentions
`searchScope`. Only `text: ""` (present but empty) reaches the
FR-025-specific message. Both are legitimate 400s; the wording differs by
route, so testing only the message assumed by the first draft would have
asserted something Jackson's own error never says. Verified against the
running endpoint directly (both return 400, with the expected wording
each) before writing the fix, not guessed.

The corresponding Java integration test's Javadoc is extended to record
why it only covers the ContentDriveHelper-direct route: it constructs
QueryFilters through the builder, bypassing Jackson entirely, so the
omitted-key case is deliberately left to the endpoint layer that actually
parses raw JSON.

46 integration assertions still green across the four Content Drive test
classes (ContentDriveSearchScopeTest, ContentDriveLiteralTextSearchTest,
ContentDriveKeywordSearchTest, ContentDriveFieldFilterTest), plus the new
BrowserAPITest case. Postman collection verified with 26/27 assertions
passing on the first run (the one failure was the C-5 wording gap fixed
here) and the corrected C-5a/C-5b verified directly against the running
endpoint.

Refs #37479, #37532

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two requirements narrowed during implementation, both struck through in
place and replaced with the amended wording, dated, and reasoned — never
silently edited.

FR-029 (narrowed 2026-09-14): the stronger "every query failure surfaces
as an error" was implemented, then reverted. It cost a security
guarantee — ContentDriveFieldFilterTest#testMalformedDateBoundIsSafe
requires a Lucene-injection attempt to be escaped, match nothing, and
produce no 500, and raising query failures turned that into an error
response instead. What ships: the front end surfaces failures it can
itself observe; BrowserAPIImpl's internal failures stay logged-only, as
before this feature.

FR-003 (narrowed 2026-09-15): the placeholder no longer describes the
active scope. Direct instruction from the issue owner during UI review,
not a defect — FR-003 as written was fully implementable and had been
implemented and tested. The placeholder is fixed to the shared search
box's own default in both scopes.

Header updated to flag both amendments and that re-approval is required
before PR 2 — the spec was approved on PR #37518 at 1ce8cdd, which
predates both.

Full narrative for each amendment already lived in tasks.md as it was
decided; this is the spec.md side of the same record, so the approved
document reflects what actually shipped rather than diverging from it
silently.

Refs #37479, #37532

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…2 carve-out)

Found while running the quickstart's Level 3 (T068): a preexisting test,
`test_buildBaseESQuery_withDifferentFilterCombinations`, asserted that an
unescaped "&" survived into the generated query verbatim
(`result.contains("test & special")`). "&" is a Lucene query_string
reserved character, and that assertion was pinning the exact defect
#37532 reports — just with a different symbol than the customer's
":"/"("/"/" . It is precisely SC-002's enumerated carve-out: an ordinary
term stays byte-identical, a term with a reserved character does not.

Now asserts the escaped form (`test \& special`) and explicitly asserts
the raw form no longer survives, with the change reasoned in a comment
rather than silently edited.

This is the entry SC-002 and the plan's "enumerate in the PR" requirement
ask for — the one preexisting test outside the four Content Drive classes
whose expectations this feature changed. All four Content Drive classes
plus this one are green (88 assertions total).

Refs #37479, #37532

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…g field-filter sweep

/speckit-converge (2026-09-15) found three gaps, none blocking a P1 story
or violating the constitution — two spec self-consistency issues and one
test-rigor gap. All three closed here (T073-T075).

T073: User Story 1's Acceptance Scenario 1 still read "the placeholder
describes an all-fields search" — the pre-amendment behaviour FR-003 no
longer requires. Corrected in place, struck through with the reason,
matching how the FR-level amendments were already recorded rather than
silently edited. FR-003's own amendment note also mis-cited this as
"acceptance scenario 2"; it's scenario 1, fixed.

T074: User Story 4's Acceptance Scenario 5 and SC-011 both still stated an
unqualified "a search request that fails to execute" / "100% of
attempts" surfaces an error, without FR-029's amendment carve-out — only
front-end-observable failures (network/server errors reaching the
browser) are covered; BrowserAPIImpl's internal execution failures stay
logged-only, exactly as before this feature. Both narrowed to match, same
strikethrough-with-reason treatment.

T075: FR-030/SC-012 promise field filters get "the same reserved-set
coverage as SC-010," but the two existing tests
(ContentDriveFieldFilterTest#testMalformedDateBoundIsSafe,
ContentDriveLiteralTextSearchTest#fieldFilterValue_withReservedCharacters_matchesLiterally)
each check one fixed string with a handful of reserved characters, not an
exhaustive sweep. Added
textFieldFilter_matchesEveryReservedCharacterLiterally, mirroring the
search-box sweep: one seeded contentlet per character in the full
reserved set, found via userSearchable on a Text field. 27/27 green,
including the new test — confirms the field-filter path was already
correct, the gap was in coverage rather than behaviour.

Refs #37479, #37532

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@KevinDavilaDotCMS

Copy link
Copy Markdown
Contributor Author

⚠️ SC-003 (Title scope performance) — measured slower than All Fields, not faster. Flagging for backend input.

Tagging this separately from the PR description because it needs eyes from whoever owns query performance here, not just a merge-blocking checkbox.

What SC-003 asks

The spec's success criterion: a Title search should return its first page faster than the same term in All Fields scope, on a dataset large enough for the difference to exceed noise. It's framed as a recorded measurement, not an automated test — the deliverable is the comparison itself.

What I measured

Two independently-designed datasets, seeded on a local dev dotCMS + single-node OpenSearch, 12 interleaved rounds each (24 comparisons total), first-page timing via curl -w %{time_total} against POST /v1/drive/search:

  1. First dataset (3000 docs, term in every body, in no title): All Fields 22–50ms, Title 52–98ms.
  2. Second dataset, redesigned to match the actual use case — one document whose title carries the term, 3000 others where it's only a casual body mention (All Fields has hasMoreContent: true, the target isn't even on page 1; Title finds exactly the one document): All Fields 22–47ms, Title 55–77ms.

Same direction, same order of magnitude, in both designs. Title is consistently slower — the opposite of what FR-010's rationale predicts (no catchall aggregate, no leading wildcard in the mandatory gate — both prefix seeks, which should be cheaper).

What I ruled out with numbers, not guesswork

Suspected BrowserAPIImpl#calculateMaxInodesPerESQuery's clause-count estimate might chunk the two clause shapes differently (All Fields's query string is longer — more OR/:). Computed it directly for both actual clause strings at 3001 candidates: identical — 4 batches of 876 for both. Chunking is not the cause.

What I could not verify in this session

  • OpenSearch query-cache state (repeated identical queries per scope, but caches are typically per-index and this was a fresh index each time)
  • Segment-merge state on a just-bulk-indexed corpus
  • Whether the leading-wildcard cost the Title clause exists specifically to avoid (title_dotraw:*term*, a full term-dictionary scan) is simply too cheap to matter at ~3000 distinct titles — the scale might need to be an order of magnitude+ larger for the win to show
  • This dev instance ran a full day of builds, integration suites and Postman runs during this same session — a shared, contended environment is a real confound I didn't rule out

Where this leaves things

Not blocking this PR. FR-010's actual, testable requirements (no catchall, no leading wildcard in the mandatory gate) are met and unit-tested regardless of what this wall-clock measurement shows. The behavioral correctness of Title scope doesn't depend on this outcome — only the performance claim does, and I'm leaving it honestly unproven rather than asserting a win I can't back up.

What I'd want from someone who knows this index's real-world behavior:

  • Does this match a known characteristic of the ES/OpenSearch query planner at this candidate-set scale, or does it point at something worth profiling (_profile API, hot-thread capture)?
  • Is there a dedicated, unloaded OpenSearch instance where this is worth re-measuring at 10x–100x the scale before treating SC-003 as settled either way?

Full write-up (exact clause strings, the chunking math, both raw timing tables) is in specs/37479-content-drive-search-scope/tasks.md's Phase 7 note — that file is gitignored per this repo's Spec-Kit convention (process artifact, not committed), so it's not in the diff here. Happy to paste it inline if useful, or pair with whoever picks this up.

KevinDavilaDotCMS and others added 3 commits September 15, 2026 11:10
The trigger's tooltip named both scopes at once, which read oddly for a
control that only ever shows one of them. Splitting it into a per-option
tooltip in the panel lets each option's own explanation stand on its own,
and satisfies FR-022 without requiring a specific location for it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@github-actions github-actions Bot added Area : Backend PR changes Java/Maven backend code Area : Frontend PR changes Angular/TypeScript frontend code labels Sep 15, 2026
…search scope handling

Updated the search input component to utilize a predefined input style object, improving maintainability. Enhanced the store logic to ensure that the search scope is cleared when the search term is emptied, preventing stale state issues. Added tests to verify the correct behavior of search scope management, particularly for cases involving hyphenated terms and query syntax characters.

Refs #37479, #37532
@KevinDavilaDotCMS KevinDavilaDotCMS added the PR: docker image Build & push a per-PR test image to dotcms/dotcms-test label Sep 15, 2026
@github-actions

github-actions Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

🐳 PR Docker test image

Latest build for commit 07f0da9 pushed to dotcms/dotcms-test:

docker pull dotcms/dotcms-test:pr-37554-issue-37479-content-drive-search-scope-impl
docker pull dotcms/dotcms-test:pr-37554-issue-37479-content-drive-search-scope-impl_07f0da9

* <p>Folder and link name matching is unaffected: those never reach the search index and are
* narrowed on their own name in both scopes.</p>
*/
TITLE

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we have plans to support this kind of search for other searchable fields?

@KevinDavilaDotCMS KevinDavilaDotCMS Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question — out of scope for this PR. Title scope is deliberately limited to the contentlet title field only (spec.md Decision 2: "Contentlet title only — not fileName, not metadata.name", to keep the query to a single field so the performance argument holds). Scoping to other searchable fields would need its own spec — nothing planned yet, but it's a reasonable follow-up once this lands.

Comment thread dotCMS/src/main/java/com/dotcms/browser/BrowserAPIImpl.java
* same `{form.field.border.color}` alias `inputtext` uses, confirmed straight from the Lara
* preset source. Only the background differs (`{surface.50}`, a light grey).
*/
protected readonly ADDON_STYLE = { background: 'var(--p-inputtext-background)' };

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This field is never used — the addon's background isn't actually being repointed.

The template renders <p-inputgroup-addon> with no [style] binding, so ADDON_STYLE is read by nothing. It looks like it survived the styling rework that landed later in the branch (the trigger ended up using TRIGGER_PT for its border instead).

Worth resolving one way or the other, because the 15-line comment above it reads as a description of shipped behaviour. A reviewer coming to this file in six months will believe the addon background is pinned to --p-inputtext-background when nothing does that.

Either bind it on the addon, or delete the field and its comment.


🤖 Review comment written by Claude Code, posted from @zJaaal's account.

@KevinDavilaDotCMS KevinDavilaDotCMS Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7f39e67 — removed the dead ADDON_STYLE field and its stale 15-line comment.

Comment thread dotCMS/src/main/java/com/dotcms/browser/BrowserQuery.java
Comment thread dotCMS/src/main/java/com/dotcms/browser/BrowserAPIImpl.java

@zJaaal zJaaal left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also check claude comments, remember to add some e2e for this feature

KevinDavilaDotCMS and others added 2 commits September 16, 2026 16:32
- Fix the scope selector silently dropping to All Fields: p-listbox
  toggles (metaKeySelection=false) and emits null when re-clicking the
  already-active option, which slipped past the "already active" guard
  and got written into filters as a real scope change, lighting up
  "Clear all" on an unfiltered drive with no way back from the panel.
- Scope the #37532 literal-text escaping fix to Content Drive only.
  GlobalSearchAttributeStrategy is reverted to its pre-PR behavior
  (byte-identical), since it also backs the Search portlet's and the
  Relationships dialog's global search — this matches what spec.md's
  "Why #37532 lands here" section already said the Content Search
  portlet would keep. Content Drive gets its own
  BrowserAPIImpl#buildAllFieldsScopedQuery instead, forked rather than
  shared, with the same escaping.
- Add <, >, = to the Title-scope reserved-character split set so a term
  like "Sales > 2024" isn't misread by Elasticsearch as a range query.
- Default dot-search-input's inputDt to undefined instead of {}, so
  PrimeNG doesn't load an unused scoped theme + change listener for
  every consumer that passes no override (AssetPicker included).
- Resolve the search-scope option labels eagerly instead of piping them
  in the item template: PrimeNG's own aria-label on each option reads
  the raw option.label, which was the untranslated i18n key. Also add
  aria-expanded to the scope trigger.
- Remove the dead ADDON_STYLE field and its stale 15-line comment
  describing styling that nothing binds.
- Soften comments in processSingleESQuery/processMultipleESQueries that
  overstated the error banner's coverage: a failure inside those
  methods still returns HTTP 200 with a partial result, which the
  banner cannot see.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ords

title_dotraw is the whole raw title as one keyword term, so a prefix
match against it can only succeed when the fragment is a prefix of the
ENTIRE title — true, at best, for the first word of a multi-word search
term, and never for the ones after it. The old per-word clause carried
title_dotraw on every word regardless, paying for a prefix search over
a near-one-term-per-document keyword dictionary on words where it could
never contribute a match, against title's much smaller per-word
vocabulary.

Only the first fragment now keeps the title/title_dotraw OR-clause;
every later word is title-only. Match set is unchanged (the dropped
alternative was structurally unable to match past the first word); this
is the follow-up suggested against the PR #37554 SC-003 finding that
Title scope measured slower than All Fields, contrary to FR-010's
rationale.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@KevinDavilaDotCMS

KevinDavilaDotCMS commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

🔬 Performance follow-up on SC-003 — from a suggestion by @nollymar

@nollymar pointed out that title is a text field while title_dotraw is its keyword sibling, and asked whether using title instead of title_dotraw could help Title scope's timing — worth writing up separately since it settles part of the SC-003 finding from the PR description (Title scope measuring slower than All Fields, the opposite of what FR-010's rationale predicts).

The idea, and where it actually applies

title_dotraw stores the whole raw title as one keyword term, so its cardinality is close to one distinct term per document. title is tokenized into words, so its term dictionary is far smaller (titles share vocabulary). A prefix query against a smaller term dictionary is cheaper — that part of the suggestion is correct.

But it's not "replace title_dotraw with title everywhere" — buildTitleScopedQuery's per-word clause is +(title:word* title_dotraw:word*), and title_dotraw:word* can only ever match when word is a prefix of the entire raw title. That's true, at best, for the first word of a multi-word search term — never for the ones after it. So dropping title_dotraw outright would risk losing a real match on the first word; the actual fix is to drop it from every word except the first, where it was structurally guaranteed to never contribute a match anyway.

Verified against real OpenSearch, not just query-shape unit tests

The local dev index only has 681 docs — too small to show a timing difference at this scale (same wall the original SC-003 investigation hit). So I stood up a temporary index on the same local OpenSearch instance, with the exact os-content-mapping.json / os-content-settings.json analyzer config, and a synthetic 150k-doc dataset:

  • 140 interleaved paired comparisons (7 multi-word terms × 20 rounds, alternating query order each round to cancel drift): median took 2ms → 1ms, mean 2.11ms → 1.18ms (~44% reduction). Zero result-count mismatches across all 140 pairs — identical result sets, just cheaper to compute.
  • _profile breakdown on one representative 5-word query: the four title_dotraw:<word>* clauses removed each cost 0.26–0.41ms; total profiled query time went 5.36ms → 1.44ms (~73% less) for that term.

Applied

07f0da97f8perf(content-drive): drop title_dotraw from non-leading Title-scope words. BrowserAPIImplTest updated to pin the new clause shape, plus a dedicated test (buildTitleScopedQuery_multiWordTerm_onlyFirstWordCarriesTitleDotraw) documenting why.

Doesn't fully settle SC-003 on its own — the original measurement compared Title vs. All Fields end-to-end, and this only cuts the cost of one piece of Title's query, not the whole picture (OpenSearch query-cache state, segment-merge state, etc. are still unmeasured, as the original write-up notes). But it's a real, measured win, correctness-neutral, and it came directly from @nollymar's suggestion — thanks for the pointer.

@zJaaal zJaaal linked an issue Sep 17, 2026 that may be closed by this pull request
12 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Area : Backend PR changes Java/Maven backend code Area : Frontend PR changes Angular/TypeScript frontend code PR: docker image Build & push a per-PR test image to dotcms/dotcms-test

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

[TASK] Content Drive: add a Title / All Content scope selector to the search box

4 participants