From 1be6047c8a7c7b7a6674c9495834777f301f3379 Mon Sep 17 00:00:00 2001 From: Michael Uray <25169478+MichaelUray@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:09:33 +0200 Subject: [PATCH] feat(tracker): field-prefixed search, inline filter chips and zero-hit empty state (#10998) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(tracker): field-prefixed search, inline filter chips and zero-hit empty state Adds SearchInputAdvanced (field:value prefixes routed to Elasticsearch query_string), match highlighting, inline filter chips with overflow popover, a reusable zero-hit empty state in view-resources, and search-scope/highlight view options for List and Kanban. Signed-off-by: Michael Uray * refactor(tracker-search): address review follow-ups on search/filter rework Shared, non-storage-specific field list, test-folder conventions, locale coverage, single regex source and an owner-token gate for the result count. - core: add shared `fullTextSearchFields` constant next to FullTextSearchContext as the single source of truth for the full-text fields exposed to `field:value` targeting. The client encoder derives ES_NATIVE_FIELDS from it and the elastic adapter derives KNOWN_FIELD_RE from it, replacing the two "KEEP IN SYNC" copies. Per-field boost weights stay a local adapter detail. - tests: move the five co-located tests into each package's existing test folder convention (ui `__test__`, view-resources `__tests__`) and fix the relative imports. - i18n: translate the new tracker search/filter strings in the remaining locales (zh, ja, ko, cs, es, fr, it, pt, pt-br, tr), reusing each file's existing terminology; ICU placeholders left unchanged. - encoder: hoist the reserved-character class into one constant and build two RegExp instances from it (non-global for `.test()`, global for `.replace()`) to avoid the shared-lastIndex trap. - view-resources: guard `resultIssueCountStore` writes with an owner-token gate so a superseded viewlet can no longer clobber the active viewlet's count; List/KanbanView claim and release, IssuesView resets through the current owner. Assumes a single active IssuesView surface. - view-resources: make result-count reporting opt-in via a new `reportResultCount` prop on List (default true). Embedded, non-primary List instances (sub-issues / related issues in the issue edit panel, routed through SubIssueList) pass false and never claim the owner token, so opening and closing an issue can no longer strand the primary Issues viewlet with a dead token — the zero-hit SearchEmptyState card renders again afterwards. Add a regression test covering the opted-out embedded consumer. - elastic: escape every regex metacharacter (not just `.`) when building KNOWN_FIELD_RE from `fullTextSearchFields`, so a future field name carrying another metacharacter cannot silently corrupt the alternation. Behaviour for the current fields is unchanged. Signed-off-by: Michael Uray * refactor(tracker-search): centralize regex escaping and lock down result-count store - Share escapeRegExp from @hcengineering/core so the client encoder and the server elastic adapter escape the fulltext field list identically, not just dots on the client. - Export resultIssueCountStore as a read-only Readable; the owner-token gate functions (setResultCount / resetResultCount / releaseResultCountOwner) are now the only write path. Signed-off-by: Michael Uray --------- Signed-off-by: Michael Uray Co-authored-by: Michael Uray Co-authored-by: Artyom Savchenko --- foundations/core/packages/core/src/classes.ts | 27 ++ foundations/core/packages/core/src/utils.ts | 10 + .../server/packages/elastic/src/adapter.ts | 81 +++- models/tracker/src/migration.ts | 48 ++- models/tracker/src/viewlets.ts | 34 +- .../ui/src/__test__/HighlightedText.test.ts | 61 +++ .../src/__test__/SearchInputAdvanced.test.ts | 251 +++++++++++ .../src/components/HighlightedText.helpers.ts | 43 ++ .../ui/src/components/HighlightedText.svelte | 37 ++ .../ui/src/components/ModeSelector.svelte | 5 + .../components/SearchInputAdvanced.encoder.ts | 389 ++++++++++++++++++ .../src/components/SearchInputAdvanced.svelte | 255 ++++++++++++ .../components/SearchInputAdvanced.sync.ts | 33 ++ packages/ui/src/components/Switcher.svelte | 11 +- .../ui/src/components/SwitcherBase.svelte | 28 +- packages/ui/src/index.ts | 3 + packages/ui/src/utils.ts | 4 + plugins/tracker-assets/lang/cs.json | 13 +- plugins/tracker-assets/lang/de.json | 13 +- plugins/tracker-assets/lang/en.json | 13 +- plugins/tracker-assets/lang/es.json | 13 +- plugins/tracker-assets/lang/fr.json | 13 +- plugins/tracker-assets/lang/it.json | 13 +- plugins/tracker-assets/lang/ja.json | 13 +- plugins/tracker-assets/lang/ko.json | 13 +- plugins/tracker-assets/lang/pt-br.json | 13 +- plugins/tracker-assets/lang/pt.json | 13 +- plugins/tracker-assets/lang/ru.json | 13 +- plugins/tracker-assets/lang/tr.json | 13 +- plugins/tracker-assets/lang/zh.json | 13 +- .../src/__tests__/lang-placeholders.test.ts | 144 +++++++ .../src/components/SearchEmptyState.svelte | 34 ++ .../components/issues/IssuePresenter.svelte | 15 +- .../src/components/issues/Issues.svelte | 25 +- .../src/components/issues/IssuesView.svelte | 246 ++++++++++- .../src/components/issues/KanbanView.svelte | 18 +- plugins/tracker-resources/src/plugin.ts | 13 +- plugins/view-assets/lang/cs.json | 5 +- plugins/view-assets/lang/de.json | 5 +- plugins/view-assets/lang/en.json | 5 +- plugins/view-assets/lang/es.json | 5 +- plugins/view-assets/lang/fr.json | 5 +- plugins/view-assets/lang/it.json | 5 +- plugins/view-assets/lang/ja.json | 5 +- plugins/view-assets/lang/ko.json | 5 +- plugins/view-assets/lang/pt-br.json | 5 +- plugins/view-assets/lang/pt.json | 5 +- plugins/view-assets/lang/ru.json | 5 +- plugins/view-assets/lang/tr.json | 5 +- plugins/view-assets/lang/zh.json | 5 +- plugins/view-resources/jest.config.js | 9 +- .../src/__mocks__/svelte-store.ts | 55 +++ .../src/__tests__/InlineFilterChips.test.ts | 35 ++ .../src/__tests__/query-builder.test.ts | 87 ++++ .../src/__tests__/resultCountStore.test.ts | 106 +++++ .../src/__tests__/searchEmptyState.test.ts | 41 ++ .../src/components/SearchEmptyState.svelte | 93 +++++ .../src/components/SpaceHeader.svelte | 26 +- .../src/components/ViewOptions.svelte | 7 +- .../src/components/filter/FilterBar.svelte | 186 ++++----- .../src/components/filter/FilterButton.svelte | 30 +- .../filter/InlineFilterChips.svelte | 312 ++++++++++++++ .../InlineFilterChips.svelte.helpers.ts | 42 ++ .../filter/InlineFilterChipsOverflow.svelte | 62 +++ .../src/components/list/List.svelte | 49 ++- .../src/components/list/ListView.svelte | 1 + plugins/view-resources/src/filter.ts | 13 +- .../src/filter/query-builder.ts | 117 ++++++ plugins/view-resources/src/index.ts | 14 + plugins/view-resources/src/plugin.ts | 5 +- .../view-resources/src/searchEmptyState.ts | 34 ++ plugins/view-resources/src/stores.ts | 105 +++++ .../model/tracker/common-tracker-page.ts | 5 + .../sanity/tests/model/tracker/issues-page.ts | 14 +- tests/sanity/tests/tracker/tracker.spec.ts | 31 +- tests/sanity/tests/tracker/tracker.utils.ts | 8 +- 76 files changed, 3341 insertions(+), 195 deletions(-) create mode 100644 packages/ui/src/__test__/HighlightedText.test.ts create mode 100644 packages/ui/src/__test__/SearchInputAdvanced.test.ts create mode 100644 packages/ui/src/components/HighlightedText.helpers.ts create mode 100644 packages/ui/src/components/HighlightedText.svelte create mode 100644 packages/ui/src/components/SearchInputAdvanced.encoder.ts create mode 100644 packages/ui/src/components/SearchInputAdvanced.svelte create mode 100644 packages/ui/src/components/SearchInputAdvanced.sync.ts create mode 100644 plugins/tracker-assets/src/__tests__/lang-placeholders.test.ts create mode 100644 plugins/tracker-resources/src/components/SearchEmptyState.svelte create mode 100644 plugins/view-resources/src/__mocks__/svelte-store.ts create mode 100644 plugins/view-resources/src/__tests__/InlineFilterChips.test.ts create mode 100644 plugins/view-resources/src/__tests__/query-builder.test.ts create mode 100644 plugins/view-resources/src/__tests__/resultCountStore.test.ts create mode 100644 plugins/view-resources/src/__tests__/searchEmptyState.test.ts create mode 100644 plugins/view-resources/src/components/SearchEmptyState.svelte create mode 100644 plugins/view-resources/src/components/filter/InlineFilterChips.svelte create mode 100644 plugins/view-resources/src/components/filter/InlineFilterChips.svelte.helpers.ts create mode 100644 plugins/view-resources/src/components/filter/InlineFilterChipsOverflow.svelte create mode 100644 plugins/view-resources/src/filter/query-builder.ts create mode 100644 plugins/view-resources/src/searchEmptyState.ts create mode 100644 plugins/view-resources/src/stores.ts diff --git a/foundations/core/packages/core/src/classes.ts b/foundations/core/packages/core/src/classes.ts index b9c62466d9..84a9555caa 100644 --- a/foundations/core/packages/core/src/classes.ts +++ b/foundations/core/packages/core/src/classes.ts @@ -752,6 +752,33 @@ export interface FullTextSearchContext extends Doc { forceIndex?: boolean } +/** + * @public + * + * The canonical set of document fields the full-text index exposes for + * `field:value` targeting in a `$search` query. This is the single source of + * truth shared by every layer that needs to know which prefixes are valid: + * + * - the client search-input encoder (which `field:` prefixes it may route to a + * field-targeted query instead of leaving as a bare term), and + * - the full-text backend adapter (which fields it recognises as a + * field-targeted clause vs. a plain query). + * + * Keeping it here — backend-agnostic, alongside {@link FullTextSearchContext} — + * means both sides derive from the same list, so the set can never drift out of + * sync between client and server. Storage-specific concerns (per-field boost + * weights, query syntax) stay in the respective adapter and are intentionally + * NOT part of this list. + */ +export const fullTextSearchFields: readonly string[] = [ + 'searchTitle', + 'searchShortTitle', + 'identifier', + 'description.plain', + 'comments.message', + 'fulltextSummary' +] + /** * @public */ diff --git a/foundations/core/packages/core/src/utils.ts b/foundations/core/packages/core/src/utils.ts index 7f0fd164f1..568c34a415 100644 --- a/foundations/core/packages/core/src/utils.ts +++ b/foundations/core/packages/core/src/utils.ts @@ -1008,3 +1008,13 @@ export function toRank (str: string | undefined): Rank | undefined { } return '0|' + str.replaceAll(/[-:_]/g, '').toLowerCase() } + +/** + * Escape a literal string for safe embedding inside a regular expression. + * Shared so that both the client search encoder and the server fulltext + * adapter derive their {@link fullTextSearchFields} regexes identically — + * keeping the field list AND its regex processing from drifting apart. + */ +export function escapeRegExp (value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} diff --git a/foundations/server/packages/elastic/src/adapter.ts b/foundations/server/packages/elastic/src/adapter.ts index 1da9e3711e..a33fafd10b 100644 --- a/foundations/server/packages/elastic/src/adapter.ts +++ b/foundations/server/packages/elastic/src/adapter.ts @@ -19,6 +19,8 @@ import { Class, Doc, DocumentQuery, + escapeRegExp, + fullTextSearchFields, MeasureContext, Ref, SearchOptions, @@ -314,17 +316,80 @@ class ElasticAdapter implements FullTextAdapter { from: number | undefined ): Promise { if (query.$search === undefined) return [] + const raw = String(query.$search) + // Route field-targeted queries (e.g. `searchTitle:value`, `identifier:HULY-`, + // `comments.message:foo`) through `query_string` so ES can parse the + // field-targeted clauses and apply per-field boosts. Restrict the + // detector to the set of fields we actually index — typing a bare colon + // such as `POC: design review` or a URL must NOT silently route to + // `query_string` (which would throw a parsing exception and surface as + // zero hits). Anything else falls back to `simple_query_string` for full + // backwards compatibility. + // The recognised field set is the shared `fullTextSearchFields` constant + // from @hcengineering/core — the same list the client encoder derives its + // ES_NATIVE_FIELDS from, so the two can never drift. The client only emits + // a `field:value` clause for a field it believes the server recognises; a + // drift would let a client-routed clause hit a field the adapter does not + // treat as query_string and silently fail to parse. The per-field boost + // weights below (`searchTitle^3`, …) are a query-syntax detail of this + // adapter and intentionally stay local, not part of the shared list. + // + // SECURITY: `query_string` lets a raw `field:value` clause target + // ANY indexed field (e.g. `space:`, `modifiedBy:`, `attachedTo:`), + // not just the `fields` whitelist below (that list only picks the DEFAULT + // fields for bare terms). This is intentionally NOT gated here because the + // hits this method returns are never authoritative results — they are only a + // candidate id-set. The two enclosing guards make field-targeting safe: + // 1. `workspaceId` is a hard `bool.must` term (see the request below), so + // no clause can reach another workspace's documents. + // 2. Within the workspace, every consumer re-filters by space ACL. The + // only caller is pods/fulltext `/api/v1/search` → FullTextMiddleware, + // which sits BELOW SpaceSecurityMiddleware in the pipeline. That + // middleware runs `provideFindAll({ _id: { $in: }, ...q })` + // where `q` still carries the space constraint SpaceSecurityMiddleware + // injected upstream (or, in its >85%-allowed fast path, post-filters + // the result via `clientFilterSpaces`). A forbidden-space doc surfaced + // by a crafted `space:` clause is therefore dropped at the DB findAll / + // result filter and never reaches the client — no content or existence + // oracle. Checked against the server-pipeline middleware order. + // + // Escape EVERY regex metacharacter in each field name, not just `.`. The + // current fields contain only `.`, so behaviour is unchanged today, but a + // future field name carrying another metacharacter would otherwise silently + // corrupt the alternation. + const KNOWN_FIELD_RE = new RegExp( + `(^|\\s)(${fullTextSearchFields.map((f) => escapeRegExp(f)).join('|')})\\s*:`, + 'i' + ) + const usesQueryString = KNOWN_FIELD_RE.test(raw) + const queryBlock: any = usesQueryString + ? { + query_string: { + query: raw, + fields: [ + 'searchTitle^3', + 'searchShortTitle^2', + 'identifier^2', + 'description.plain', + 'comments.message^0.7', + 'fulltextSummary' + ], + default_operator: 'AND', + allow_leading_wildcard: false + } + } + : { + simple_query_string: { + query: raw, + analyze_wildcard: true, + flags: 'OR|PREFIX|PHRASE|FUZZY|NOT|ESCAPE', + default_operator: 'and' + } + } const request: any = { bool: { must: [ - { - simple_query_string: { - query: query.$search, - analyze_wildcard: true, - flags: 'OR|PREFIX|PHRASE|FUZZY|NOT|ESCAPE', - default_operator: 'and' - } - }, + queryBlock, { term: { workspaceId diff --git a/models/tracker/src/migration.ts b/models/tracker/src/migration.ts index a150a2947d..45b25f3fe8 100644 --- a/models/tracker/src/migration.ts +++ b/models/tracker/src/migration.ts @@ -38,16 +38,13 @@ import { DOMAIN_SPACE } from '@hcengineering/model-core' import { DOMAIN_TASK, migrateDefaultStatusesBase } from '@hcengineering/model-task' import tags from '@hcengineering/tags' import task from '@hcengineering/task' -import tracker, { - type Issue, - type IssueStatus, - type Project, - TimeReportDayType, - trackerId -} from '@hcengineering/tracker' +import { type Issue, type IssueStatus, type Project, TimeReportDayType, trackerId } from '@hcengineering/tracker' +import view, { type ViewOptionModel } from '@hcengineering/view' import { classicIssueTaskStatuses } from '.' +import tracker from './plugin' import { DOMAIN_TRACKER } from './types' +import { issuesOptions } from './viewlets' async function createDefaultProject (tx: TxOperations): Promise { const current = await tx.findOne(tracker.class.Project, { @@ -181,6 +178,39 @@ export async function migrateAddStartDate (client: MigrationClient): Promise { + const txOp = new TxOperations(client, core.account.System) + + const targets = [ + { id: tracker.viewlet.IssueList, desired: issuesOptions(false).other ?? [] }, + { id: tracker.viewlet.IssueKanban, desired: issuesOptions(true).other ?? [] } + ] + + for (const t of targets) { + const viewlets = await client.findAll(view.class.Viewlet, { _id: t.id }) + for (const v of viewlets) { + const current = v.viewOptions ?? { groupBy: [], orderBy: [], other: [] } + const currentOther: ViewOptionModel[] = current.other ?? [] + const existingKeys = new Set(currentOther.map((o) => o.key)) + const missing = t.desired.filter((o) => !existingKeys.has(o.key)) + if (missing.length === 0) continue + + await txOp.update(v, { + viewOptions: { + ...current, + other: [...currentOther, ...missing] + } + }) + } + } +} + async function migrateDefaultStatuses (client: MigrationClient, logger: ModelLogger): Promise { const defaultTypeId = tracker.ids.ClassingProjectType const typeDescriptor = tracker.descriptors.ProjectType @@ -425,6 +455,10 @@ export const trackerOperation: MigrateOperation = { const tx = new TxOperations(client, core.account.System) await createDefaults(tx) } + }, + { + state: 'add-search-view-options', + func: addSearchViewOptions } ]) } diff --git a/models/tracker/src/viewlets.ts b/models/tracker/src/viewlets.ts index 509e00d7d0..31bf0f6b05 100644 --- a/models/tracker/src/viewlets.ts +++ b/models/tracker/src/viewlets.ts @@ -23,6 +23,37 @@ import tags from '@hcengineering/tags' import { type ViewOptionModel, type BuildModelKey, type ViewOptionsModel } from '@hcengineering/view' import tracker from './plugin' +// Shared Customize-View knobs reused by `issuesOptions()` (List + Kanban). +// Kept as a single named constant so the search-scope / quick-filter / +// highlight toggles stay consistent across every viewlet that opts in. +const SEARCH_VIEW_OPTIONS: ViewOptionModel[] = [ + { + key: 'showQuickModeSelector', + type: 'toggle', + defaultValue: true, + actionTarget: 'display', + label: tracker.string.ShowQuickModeSelector + }, + { + key: 'searchScope', + type: 'dropdown', + defaultValue: 'all', + values: [ + { id: 'title', label: tracker.string.SearchScopeTitle }, + { id: 'title-description', label: tracker.string.SearchScopeTitleDescription }, + { id: 'all', label: tracker.string.SearchScopeAll } + ], + label: tracker.string.SearchScopeLabel + }, + { + key: 'searchHighlight', + type: 'toggle', + defaultValue: true, + actionTarget: 'display', + label: tracker.string.SearchHighlight + } +] + export const issuesOptions = (kanban: boolean): ViewOptionsModel => ({ groupBy: [ 'status', @@ -75,7 +106,8 @@ export const issuesOptions = (kanban: boolean): ViewOptionsModel => ({ action: view.function.HideArchived, label: view.string.HideArchived }, - ...(!kanban ? [showColorsViewOption] : []) + ...(!kanban ? [showColorsViewOption] : []), + ...SEARCH_VIEW_OPTIONS ] }) diff --git a/packages/ui/src/__test__/HighlightedText.test.ts b/packages/ui/src/__test__/HighlightedText.test.ts new file mode 100644 index 0000000000..719fe84f0b --- /dev/null +++ b/packages/ui/src/__test__/HighlightedText.test.ts @@ -0,0 +1,61 @@ +import { splitHighlightSegments } from '../components/HighlightedText.helpers' + +describe('splitHighlightSegments', () => { + it('returns single segment when query is empty', () => { + expect(splitHighlightSegments('hello world', '')).toEqual([{ text: 'hello world', match: false }]) + }) + it('splits the string around a single match (case-insensitive)', () => { + expect(splitHighlightSegments('Telescopic loader — deliv', 'loader')).toEqual([ + { text: 'Telescopic ', match: false }, + { text: 'loader', match: true }, + { text: ' — deliv', match: false } + ]) + }) + it('handles multiple matches', () => { + expect(splitHighlightSegments('aaa bbb aaa', 'aaa')).toEqual([ + { text: '', match: false }, + { text: 'aaa', match: true }, + { text: ' bbb ', match: false }, + { text: 'aaa', match: true }, + { text: '', match: false } + ]) + }) + it('strips title: prefix from the query before matching', () => { + expect(splitHighlightSegments('Telescopic loader', 'title:loader')).toEqual([ + { text: 'Telescopic ', match: false }, + { text: 'loader', match: true }, + { text: '', match: false } + ]) + }) + it('strips id: prefix from the query before matching', () => { + expect(splitHighlightSegments('HULY-51 something', 'id:HULY-')).toEqual([ + { text: '', match: false }, + { text: 'HULY-', match: true }, + { text: '51 something', match: false } + ]) + }) + it('strips comments: prefix from the query before matching', () => { + expect(splitHighlightSegments('See comments below: fine', 'comments:fine')).toEqual([ + { text: 'See comments below: ', match: false }, + { text: 'fine', match: true }, + { text: '', match: false } + ]) + }) + it('strips ALL stacked leading prefixes, not just the first', () => { + // `title: id:loader` → strip `title:` then `id:` → highlight `loader`. + expect(splitHighlightSegments('Telescopic loader', 'title: id:loader')).toEqual([ + { text: 'Telescopic ', match: false }, + { text: 'loader', match: true }, + { text: '', match: false } + ]) + }) + it('highlights each term of a multi-word query independently', () => { + expect(splitHighlightSegments('foo bar baz', 'foo baz')).toEqual([ + { text: '', match: false }, + { text: 'foo', match: true }, + { text: ' bar ', match: false }, + { text: 'baz', match: true }, + { text: '', match: false } + ]) + }) +}) diff --git a/packages/ui/src/__test__/SearchInputAdvanced.test.ts b/packages/ui/src/__test__/SearchInputAdvanced.test.ts new file mode 100644 index 0000000000..8c22c5e20b --- /dev/null +++ b/packages/ui/src/__test__/SearchInputAdvanced.test.ts @@ -0,0 +1,251 @@ +import { encodeSearch } from '../components/SearchInputAdvanced.encoder' +import { propSyncValue } from '../components/SearchInputAdvanced.sync' + +describe('encodeSearch', () => { + // ─── No prefix: bare-term scope expansion ─────────────────────────────── + it('passes bare terms verbatim when scope=all', () => { + expect(encodeSearch('loader', 'all')).toBe('loader') + }) + it('wraps bare terms in searchTitle:(…) when scope=title', () => { + expect(encodeSearch('loader bar', 'title')).toBe('searchTitle:(loader bar)') + }) + it('wraps bare terms in OR-clause when scope=title-description', () => { + expect(encodeSearch('loader', 'title-description')).toBe('(searchTitle:(loader) OR description.plain:(loader))') + }) + + // ─── Prefix aliasing: user-shorthand → ES field name ──────────────────── + it('aliases title: → searchTitle: in the wire string', () => { + expect(encodeSearch('title:loader', 'all')).toBe('searchTitle:loader') + }) + it('aliases id: → identifier:', () => { + expect(encodeSearch('id:HULY-', 'all')).toBe('identifier:HULY-') + expect(encodeSearch('id:HULY-51', 'title')).toBe('identifier:HULY-51') + }) + it('aliases comments: → comments.message:', () => { + expect(encodeSearch('comments:foo', 'all')).toBe('comments.message:foo') + }) + it('preserves prefix-targeted terms when multiple are typed', () => { + expect(encodeSearch('title:loader id:HULY-', 'all')).toBe('searchTitle:loader identifier:HULY-') + }) + + // ─── Edge cases ───────────────────────────────────────────────────────── + it('returns empty string for empty input', () => { + expect(encodeSearch('', 'all')).toBe('') + expect(encodeSearch(' ', 'all')).toBe('') + }) + it('passes ES-native field syntax through unchanged', () => { + // Power users who already know ES fields can bypass the alias. + expect(encodeSearch('searchTitle:loader', 'all')).toBe('searchTitle:loader') + expect(encodeSearch('identifier:HULY-1', 'all')).toBe('identifier:HULY-1') + }) + it('treats unknown prefixes as bare terms (no field-routing)', () => { + // `POC:` is not a known prefix; the user typed a colon in their text, + // not a field-targeted query. The encoder leaves it intact for the + // simple_query_string path (scope=all) and Lucene-escapes the colon + // when wrapping into a query_string field clause (scope=title) so the + // adapter cannot accidentally re-parse it as a nested field selector. + expect(encodeSearch('POC: design review', 'all')).toBe('POC: design review') + expect(encodeSearch('POC: design review', 'title')).toBe('searchTitle:(POC\\: design review)') + }) + it('treats time-of-day "12:30" as bare text and escapes the colon when scoped', () => { + expect(encodeSearch('meeting 12:30', 'all')).toBe('meeting 12:30') + expect(encodeSearch('meeting 12:30', 'title')).toBe('searchTitle:(meeting 12\\:30)') + }) + it('escapes Lucene operators inside scope-wrapped bare terms', () => { + expect(encodeSearch('C++ developer', 'title')).toBe('searchTitle:(C\\+\\+ developer)') + expect(encodeSearch('foo (bar) [baz]', 'title')).toBe('searchTitle:(foo \\(bar\\) \\[baz\\])') + }) + + // ─── Prefix-value escaping ─────────────────────────────────────────────── + // Prefix-targeted inputs were previously sent verbatim to ES query_string, + // which crashes the parser on Lucene-reserved chars like `+` or `/`. + // We now wrap+escape ONLY when the value would otherwise blow up; clean + // values stay readable. + it('wraps + escapes prefix values containing Lucene reserved chars', () => { + expect(encodeSearch('title:C++', 'all')).toBe('searchTitle:(C\\+\\+)') + expect(encodeSearch('comments:foo/bar', 'all')).toBe('comments.message:(foo\\/bar)') + }) + it('treats user-typed parens around a prefix value as ES grouping', () => { + // `title:(scope)` is ambiguous between "literal parens in text" and + // "ES query_string grouping". We pick the latter (more useful to + // power users); a user who wants literal parens can quote the value: + // `title:"(scope)"`. + expect(encodeSearch('title:(scope)', 'all')).toBe('searchTitle:(scope)') + expect(encodeSearch('title:(C++)', 'all')).toBe('searchTitle:(C\\+\\+)') + }) + it('leaves clean prefix values bare so the wire string stays readable', () => { + // No reserved chars → no wrap. Preserves the simple common case. + expect(encodeSearch('title:loader', 'all')).toBe('searchTitle:loader') + expect(encodeSearch('id:HULY-51', 'all')).toBe('identifier:HULY-51') + expect(encodeSearch('comments:fixed', 'all')).toBe('comments.message:fixed') + }) + it('passes quoted prefix values through as phrase literals', () => { + // Quoted phrases are an ES query_string phrase literal — no escape + // needed even when the inner text would otherwise be reserved. + expect(encodeSearch('title:"foo bar"', 'all')).toBe('searchTitle:"foo bar"') + }) + it('preserves boolean operators between prefix clauses', () => { + // Power-user syntax: AND/OR between prefix-targeted clauses must + // pass through, only the values get wrapped when needed. + expect(encodeSearch('title:C++ OR id:HULY-1', 'all')).toBe('searchTitle:(C\\+\\+) OR identifier:HULY-1') + }) + + // ─── Colon-in-value handling ───────────────────────────────────────────── + // The bare-value regex is greedy across non-whitespace so a value can + // contain its own colon. Without escaping that, ES query_string would + // re-parse the inner colon as another field-targeted clause and the + // entire query crashes. The encoder now wraps+escapes such values. + it('wraps + escapes colons inside prefix values', () => { + expect(encodeSearch('title:POC:123', 'all')).toBe('searchTitle:(POC\\:123)') + expect(encodeSearch('title:12:30', 'all')).toBe('searchTitle:(12\\:30)') + expect(encodeSearch('comments:bug:fix', 'all')).toBe('comments.message:(bug\\:fix)') + }) + it('handles colon-in-value alongside other reserved chars', () => { + expect(encodeSearch('title:POC:C++', 'all')).toBe('searchTitle:(POC\\:C\\+\\+)') + }) + it('escapes orphan colons in bare tokens that follow a prefix clause', () => { + // 'title:meeting 12:30' — the 12:30 has no known-field anchor, but + // ES query_string still sees a colon there and tries to parse '12' + // as a field. Second pass escapes orphan colons so they read as + // literal text. + expect(encodeSearch('title:meeting 12:30', 'all')).toBe('searchTitle:meeting 12\\:30') + }) + + // ─── Orphan tokens with reserved chars ─────────────────────────────────── + // Once any prefix appears, the adapter routes via ES query_string so + // EVERY bare token must be parser-safe — not just colon-bearing ones. + it('escapes orphan + signs in tokens that follow a prefix clause', () => { + expect(encodeSearch('title:meeting C++', 'all')).toBe('searchTitle:meeting C\\+\\+') + }) + it('escapes orphan slashes in tokens that follow a prefix clause', () => { + expect(encodeSearch('title:meeting foo/bar', 'all')).toBe('searchTitle:meeting foo\\/bar') + }) + it('escapes orphan parens / brackets / braces in trailing tokens', () => { + expect(encodeSearch('title:meeting foo)', 'all')).toBe('searchTitle:meeting foo\\)') + expect(encodeSearch('title:bug list[0]', 'all')).toBe('searchTitle:bug list\\[0\\]') + }) + it('preserves boolean operators AND/OR/NOT verbatim between orphan tokens', () => { + expect(encodeSearch('title:meeting AND foo OR bar', 'all')).toBe('searchTitle:meeting AND foo OR bar') + }) + it('passes through quoted phrases as orphan tokens', () => { + expect(encodeSearch('title:meeting "release notes"', 'all')).toBe('searchTitle:meeting "release notes"') + }) + it('preserves wildcards * and ? in orphan tokens', () => { + // Wildcards are legitimate ES query_string syntax for prefix / + // single-char match. Leave them un-escaped so the user can type + // them on purpose. + expect(encodeSearch('title:meeting foo*', 'all')).toBe('searchTitle:meeting foo*') + expect(encodeSearch('title:meeting b?r', 'all')).toBe('searchTitle:meeting b?r') + }) + it('preserves hyphens mid-token in orphan tokens (ES tolerant)', () => { + expect(encodeSearch('title:meeting bug-fix', 'all')).toBe('searchTitle:meeting bug-fix') + }) + + // ─── Attached parens in prefix values ──────────────────────────────────── + // The bare-value regex previously stopped before `(` / `)`, so + // `title:foo(bar)` slipped through as `searchTitle:foo(bar)` raw — + // pass 2 saw a known-field prefix on the token and passed it through + // verbatim, never escaping the embedded parens. Now the bare-value + // pattern extends to whitespace, so attached parens get captured as + // part of the value and wrapped+escaped properly. + it('wraps + escapes prefix values with attached parens', () => { + expect(encodeSearch('title:foo(bar)', 'all')).toBe('searchTitle:(foo\\(bar\\))') + expect(encodeSearch('title:foo)', 'all')).toBe('searchTitle:(foo\\))') + }) + it('wraps + escapes prefix values with attached brackets', () => { + expect(encodeSearch('title:list[0]', 'all')).toBe('searchTitle:(list\\[0\\])') + }) + it('keeps user-wrapped parens distinct from attached parens', () => { + // `title:(scope)` — explicit paren-wrap, value is `scope` (no + // reserved chars after stripping the wrap) — passes through bare. + expect(encodeSearch('title:(scope)', 'all')).toBe('searchTitle:(scope)') + // `title:foo(bar)` — bare value with attached parens, wrap+escape. + expect(encodeSearch('title:foo(bar)', 'all')).toBe('searchTitle:(foo\\(bar\\))') + }) + + // ─── Leading-hyphen escape (Lucene NOT-operator) ───────────────────────── + // `-` mid-token (HULY-51, bug-fix) is tolerated by ES, but a leading + // '-' is interpreted as the Lucene NOT operator. Field-targeted values + // with leading '-' need to be wrap+escaped; orphan tokens with leading + // '-' need at minimum the minus escaped so they stay literal. + it('wraps + escapes prefix values starting with a hyphen', () => { + expect(encodeSearch('title:-foo', 'all')).toBe('searchTitle:(\\-foo)') + expect(encodeSearch('id:-WORK-1', 'all')).toBe('identifier:(\\-WORK\\-1)') + }) + it('escapes a leading hyphen in orphan bare tokens after a prefix clause', () => { + expect(encodeSearch('title:meeting -cancelled', 'all')).toBe('searchTitle:meeting \\-cancelled') + }) + it('preserves mid-token hyphens in field values and orphan tokens', () => { + // Regression: identifier-style values keep their internal hyphens. + expect(encodeSearch('id:HULY-51', 'all')).toBe('identifier:HULY-51') + // Regression: orphan token with mid-hyphen passes through untouched. + expect(encodeSearch('title:meeting bug-fix', 'all')).toBe('searchTitle:meeting bug-fix') + }) + + // ─── Whitespace after the field colon ───────────────────────────────────── + // `title: foo` (space after the colon) previously collapsed to an empty + // value; the bare `title:` token then had its colon escaped → 0 hits. The + // tokenizer now skips whitespace after the colon and routes the clause. + it('routes field-clauses with a space after the colon', () => { + expect(encodeSearch('title: foo', 'all')).toBe('searchTitle:foo') + expect(encodeSearch('title : foo', 'all')).toBe('searchTitle:foo') + expect(encodeSearch('id: HULY-1', 'all')).toBe('identifier:HULY-1') + }) + + // ─── Mixed-case ES-native + user prefixes ───────────────────────────────── + // A field prefix typed in any casing must canonicalise to the exact ES + // field name; otherwise the case-sensitive tokenizer/adapter miss it and + // the colon gets escaped → 0 hits. + it('canonicalises mixed-case field prefixes', () => { + expect(encodeSearch('Identifier:HULY-1', 'all')).toBe('identifier:HULY-1') + expect(encodeSearch('SearchTitle:foo', 'all')).toBe('searchTitle:foo') + expect(encodeSearch('TITLE:foo', 'all')).toBe('searchTitle:foo') + }) + + // ─── Lucene boolean-operator + comparison chars ─────────────────────────── + // `&& || < > =` open boolean/range parsing in ES query_string; when we wrap + // a bare term into a scope clause they must be escaped so the value stays a + // literal and never throws query_string_parsing_exception. + it('escapes && || < > = inside scope-wrapped bare terms', () => { + expect(encodeSearch('a && b', 'title')).toBe('searchTitle:(a \\&\\& b)') + expect(encodeSearch('a || b', 'title')).toBe('searchTitle:(a \\|\\| b)') + expect(encodeSearch('x <= y', 'title')).toBe('searchTitle:(x \\<\\= y)') + }) + it('neutralises a dangling boolean operator at clause end', () => { + // Non-prefix scope-wrap: `foo AND` is grouped verbatim (AND is a bare word + // here, not routed through the strict tokenizer path). + expect(encodeSearch('foo AND', 'title')).toBe('searchTitle:(foo AND)') + // Prefix path: a trailing operator with no operand would throw, so the + // tokenizer escapes it into a literal term. + expect(encodeSearch('title:foo NOT', 'all')).toBe('searchTitle:foo \\NOT') + }) +}) + +// The re-sync decision is extracted so it can be unit-tested here (the .svelte +// component itself cannot be mounted — ts-jest has no Svelte compiler). The +// behavioural proof of the whole flow lives in the tracker create→search→open +// E2E cluster. +describe('propSyncValue (search input re-sync)', () => { + it('syncs the new value when the parent prop changed', () => { + expect(propSyncValue('bar', 'foo')).toBe('bar') + expect(propSyncValue('x', undefined)).toBe('x') + }) + + it('returns undefined (no sync) when the parent prop is unchanged', () => { + expect(propSyncValue('foo', 'foo')).toBeUndefined() + expect(propSyncValue('', '')).toBeUndefined() + }) + + it('returns undefined when the parent prop is undefined', () => { + expect(propSyncValue(undefined, 'foo')).toBeUndefined() + }) + + // The anti-clobber invariant: while the debounced parent prop still lags at + // its previous value, the local input must not be re-synced — otherwise a + // just-typed value is erased, which broke tracker create→search→open + // (Playwright fill() then read == empty, so the search was never submitted). + it('never re-syncs an unchanged (debounce-lagged) prop', () => { + // parent prop still '' from before; user has typed into the input — no sync + expect(propSyncValue('', '')).toBeUndefined() + }) +}) diff --git a/packages/ui/src/components/HighlightedText.helpers.ts b/packages/ui/src/components/HighlightedText.helpers.ts new file mode 100644 index 0000000000..58e2375d92 --- /dev/null +++ b/packages/ui/src/components/HighlightedText.helpers.ts @@ -0,0 +1,43 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// +/** + * Strip user-typed prefixes (title:, id:, comments:) from the query before + * matching. The encoded wire-form (searchTitle:, identifier:, + * comments.message:, plus scope-wrapping like searchTitle:(loader)) NEVER + * reaches this function — IssuesView feeds the RAW input. + * + * If the caller ever DOES pass an encoded string by accident (a future + * refactor regresses the raw/encoded split), the helper degrades + * gracefully: it will match the wire-form literally against the title, + * find nothing, and return the full text unmarked. No exception thrown. + */ +const USER_PREFIX_RE = /^\s*(title|id|comments)\s*:\s*/i + +export interface Segment { + text: string + match: boolean +} + +export function splitHighlightSegments (text: string, query: string): Segment[] { + // Strip ALL stacked leading prefixes (e.g. "title: id: foo"), not + // just the first one. + let trimmed = query.trim() + let prev = '' + while (trimmed !== prev) { + prev = trimmed + trimmed = trimmed.replace(USER_PREFIX_RE, '').trim() + } + if (trimmed === '') return [{ text, match: false }] + // Highlight semantics match search semantics — a multi-word query + // highlights each term independently (alternation), not the phrase verbatim. + const terms = trimmed + .split(/\s+/) + .filter((t) => t.length > 0) + .map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + if (terms.length === 0) return [{ text, match: false }] + const re = new RegExp(`(${terms.join('|')})`, 'gi') + const parts = text.split(re) + return parts.map((p, i) => ({ text: p, match: i % 2 === 1 })) +} diff --git a/packages/ui/src/components/HighlightedText.svelte b/packages/ui/src/components/HighlightedText.svelte new file mode 100644 index 0000000000..1608429523 --- /dev/null +++ b/packages/ui/src/components/HighlightedText.svelte @@ -0,0 +1,37 @@ + + + + +{#each segments as seg} + {#if seg.match}{seg.text}{:else}{seg.text}{/if} +{/each} + + diff --git a/packages/ui/src/components/ModeSelector.svelte b/packages/ui/src/components/ModeSelector.svelte index 62bfd1fecd..48f14fcd25 100644 --- a/packages/ui/src/components/ModeSelector.svelte +++ b/packages/ui/src/components/ModeSelector.svelte @@ -28,9 +28,14 @@ name={'modeSelector'} items={modeList} selected={props.mode} + disabled={props.disabled ?? false} + tooltip={props.disabled === true && props.disabledReason !== undefined + ? { label: props.disabledReason, direction: 'bottom' } + : undefined} {kind} {onlyIcons} on:select={(result) => { + if (props.disabled === true) return if (result.detail !== undefined && result.detail.action) result.detail.action() }} /> diff --git a/packages/ui/src/components/SearchInputAdvanced.encoder.ts b/packages/ui/src/components/SearchInputAdvanced.encoder.ts new file mode 100644 index 0000000000..2f94140410 --- /dev/null +++ b/packages/ui/src/components/SearchInputAdvanced.encoder.ts @@ -0,0 +1,389 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// +/** + * Encode the raw Lupe input into the wire-string for $search. + * + * Two transformations applied: + * + * 1. **User-prefix → ES-field aliasing.** User-friendly shorthands map to + * the actual indexed field names: + * title:foo → searchTitle:foo + * id:foo → identifier:foo + * comments:foo → comments.message:foo + * ES-native field names (searchTitle:, identifier:, description.plain:, + * comments.message:) pass through verbatim — power users can use them + * directly. + * + * 2. **Bare-term scope wrapping.** When the input contains no prefix at all + * AND the Customize-View `searchScope` is restrictive, wrap the bare + * terms in the field-scope: + * scope=title → searchTitle:() + * scope=title-description → (searchTitle:() OR description.plain:()) + * scope=all → (untouched; the adapter falls back to + * simple_query_string across all indexed text) + * + * The two transformations are independent; an input that already contains + * a prefix is only aliased (transformation 1), never wrapped (transformation + * 2). This matches user intent — they specified a field explicitly. + */ +import { escapeRegExp, fullTextSearchFields } from '@hcengineering/core' + +export type SearchScope = 'title' | 'title-description' | 'all' + +/** Map user-friendly prefixes to ES field names. */ +const PREFIX_ALIAS: Record = { + title: 'searchTitle', + id: 'identifier', + comments: 'comments.message' +} + +/** + * Allowed native full-text fields users may type directly. + * + * Derived from the shared {@link fullTextSearchFields} constant in + * `@hcengineering/core` — the single source of truth also consumed by the + * server-side full-text adapter (elastic/src/adapter.ts KNOWN_FIELD_RE). The + * client only routes a `field:value` clause to a field-targeted query when the + * adapter recognises the same field, so deriving both from one list means the + * two can never drift apart. + */ +const ES_NATIVE_FIELDS = new Set(fullTextSearchFields) +/** Lowercase → canonical lookup so mixed-case ES-native prefixes normalise. */ +const ES_NATIVE_CANON = new Map([...ES_NATIVE_FIELDS].map((f) => [f.toLowerCase(), f])) +const USER_PREFIX_KEYS = new Set(Object.keys(PREFIX_ALIAS)) + +/** + * Matches `field:` tokens at the start of a substring. Restricted to known + * user-prefixes + ES-native fields so a stray colon (URLs, times, code) is + * NOT mistaken for a field-targeted query (which would route to + * query_string and silently fail on parse errors). Anything else is passed + * through verbatim and the adapter keeps the simple_query_string path. + */ +function buildKnownPrefixRe (): RegExp { + const fields = [...USER_PREFIX_KEYS, ...ES_NATIVE_FIELDS] + // Full regex escaping (shared with the server adapter via core) so a future + // field with any regex metacharacter cannot silently corrupt the alternation. + .map((f) => escapeRegExp(f)) + .join('|') + return new RegExp(`(^|\\s)(${fields})\\s*:`, 'gi') +} +const KNOWN_PREFIX_RE = buildKnownPrefixRe() + +function aliasPrefixes (input: string): string { + KNOWN_PREFIX_RE.lastIndex = 0 + return input.replace(KNOWN_PREFIX_RE, (_m, lead: string, field: string) => { + const lower = field.toLowerCase() + // Canonicalise both user-shorthands (title → searchTitle) AND + // mixed-case ES-native fields (Identifier → identifier, SearchTitle → + // searchTitle) so the downstream tokenizer's field match — and the ES + // adapter's KNOWN_FIELD_RE — see the exact canonical field name. + const aliased = PREFIX_ALIAS[lower] ?? ES_NATIVE_CANON.get(lower) ?? field + return `${lead}${aliased}:` + }) +} + +function hasKnownPrefix (input: string): boolean { + KNOWN_PREFIX_RE.lastIndex = 0 + return KNOWN_PREFIX_RE.test(input) +} + +/** + * Escape Lucene query_string reserved characters so a bare term we wrap + * in a scope clause (e.g. `searchTitle:(POC: design review)`) does not + * accidentally re-enter field-targeted parsing on the inner `:` (which + * would throw a query_string_parsing_exception in ES and surface as zero + * hits). Covers the full Lucene reserved set; the wrapping parens are + * added by the caller, not by user input, so they stay un-escaped here. + */ +function escapeForQueryString (s: string): string { + // `& | < > =` join the reserved set. `&&`/`||` are the Lucene + // boolean operators and `< > =` open range comparisons — all three throw a + // query_string_parsing_exception when they appear un-escaped inside a + // wrapped field value. Escaping each char keeps the value a literal token. + return s.replace(/[+\-!(){}[\]^"~*?:\\/&|<>=]/g, '\\$&') +} + +/** + * Reserved chars that BREAK ES `query_string` parsing inside a field-value + * position. Narrower than the full Lucene reserved set because we want to + * preserve readable, raw values for the common cases. Specifically: + * + * - `-` is omitted: ES treats `-` mid-term or at end-of-term as literal text + * (identifier-style values like `HULY-51` work without escaping; the only + * problematic position is the very start of a clause where `-` is the NOT + * operator, but a token starting with `-` would already be lexed as a + * bare term, never landing here as a field value). + * - `*` and `?` are omitted: users may legitimately type them as wildcards. + * + * What stays — these literally crash the parser when mid-value: + * `+` `!` `(` `)` `{` `}` `[` `]` `^` `"` `~` `\` `/` `:` + * + * `:` is included because the bare-value regex is greedy across non-whitespace + * (so `title:POC:123` lands here as value `POC:123`); without escaping ES + * query_string would re-parse the inner `:` as another field-targeted + * clause, blowing up the entire query. + */ +/** + * Character-class body (the part inside `[...]`) of the reserved set above. + * Single source of truth so the `.test()` check and the `.replace()` escaper + * can never diverge. Two distinct RegExp objects are built from it below: a + * global one is required for `.replace()` (escape every occurrence) while a + * non-global one is used for `.test()` — the same object must NOT serve both, + * because a global regexp carries a stateful `lastIndex` across `.test()` + * calls and would intermittently miss matches. + */ +const PREFIX_VALUE_RESERVED_CHARS = '+!(){}[\\]^"~\\\\/:&|<>=' +/** Non-global: safe for repeated `.test()` (no `lastIndex` carry-over). */ +const PREFIX_VALUE_RESERVED_RE = new RegExp(`[${PREFIX_VALUE_RESERVED_CHARS}]`) +/** Global: escape every reserved char in a `.replace()` pass. */ +const PREFIX_VALUE_RESERVED_RE_G = new RegExp(`[${PREFIX_VALUE_RESERVED_CHARS}]`, 'g') + +/** + * Single-pass tokenizer for the prefix-routed encode path. + * + * Background: previous iterations of `escapePrefixValues` stacked two + * regex passes (clause-replace + whitespace-split). Each new edge case + * (colon-in-value, attached parens, C++ in orphan position, quoted- + * phrase splitting) required another regex tweak and the logic became + * fragile. This tokenizer replaces both passes with one walker that + * emits typed tokens; each token type has exactly one rendering rule. + * + * Token grammar: + * + * ws whitespace run + * bool-op 'AND' | 'OR' | 'NOT' (uppercase, standalone) + * quoted `"...somestring..."` — pass through unchanged + * field-clause `:` where is one of + * - paren-wrapped `(...)` — re-emit with inner escaped + * - quoted phrase `"..."` — pass through + * - bare run `[^\s]+` — escape+wrap if reserved + * bare any other non-whitespace run — escape reserved chars + * + * The tokenizer attempts matches in that priority order at every + * token boundary (positions immediately after whitespace or at start + * of input). + */ + +type Token = + | { kind: 'ws', raw: string } + | { kind: 'bool-op', raw: 'AND' | 'OR' | 'NOT' } + | { kind: 'quoted', raw: string } // includes the surrounding quotes + | { kind: 'field-clause', field: string, value: ClauseValue } + | { kind: 'bare', raw: string } + +type ClauseValue = { kind: 'paren', inner: string } | { kind: 'quoted', inner: string } | { kind: 'bare', raw: string } + +const BOOL_OPS = new Set(['AND', 'OR', 'NOT']) + +function tokenize (input: string): Token[] { + const tokens: Token[] = [] + // Pre-sort field names longest-first so `description.plain` matches + // before `description` would (if ever added). Currently no overlap + // but defensive. + const fields = [...ES_NATIVE_FIELDS].sort((a, b) => b.length - a.length) + + let i = 0 + while (i < input.length) { + const ch = input[i] + + // ws + if (/\s/.test(ch)) { + let end = i + 1 + while (end < input.length && /\s/.test(input[end])) end++ + tokens.push({ kind: 'ws', raw: input.slice(i, end) }) + i = end + continue + } + + // standalone quoted phrase + if (ch === '"') { + const close = input.indexOf('"', i + 1) + if (close >= 0) { + // Boundary check: after close must be EOF or whitespace + if (close + 1 === input.length || /\s/.test(input[close + 1])) { + tokens.push({ kind: 'quoted', raw: input.slice(i, close + 1) }) + i = close + 1 + continue + } + } + // Unbalanced or attached — fall through to bare + } + + // field-clause: known field name at a token boundary, followed by `:` + let matched = false + for (const field of fields) { + if (i + field.length + 1 > input.length) continue + // Match the field name case-insensitively; we always emit the + // canonical `field` (never the typed casing) further down. + if (input.slice(i, i + field.length).toLowerCase() !== field.toLowerCase()) continue + if (input[i + field.length] !== ':') continue + // Skip whitespace after the colon so `title: foo` / `title : foo` + // route to a field-clause instead of collapsing to an empty value (which + // would leave the bare `field:` token to have its colon escaped → 0 hits). + let valueStart = i + field.length + 1 + while (valueStart < input.length && /\s/.test(input[valueStart])) valueStart++ + + // paren-wrapped value + if (input[valueStart] === '(') { + const close = input.indexOf(')', valueStart + 1) + if (close >= 0) { + tokens.push({ + kind: 'field-clause', + field, + value: { kind: 'paren', inner: input.slice(valueStart + 1, close) } + }) + i = close + 1 + matched = true + break + } + } + + // quoted value + if (input[valueStart] === '"') { + const close = input.indexOf('"', valueStart + 1) + if (close >= 0) { + tokens.push({ + kind: 'field-clause', + field, + value: { kind: 'quoted', inner: input.slice(valueStart + 1, close) } + }) + i = close + 1 + matched = true + break + } + } + + // bare value: run up to whitespace + let end = valueStart + while (end < input.length && !/\s/.test(input[end])) end++ + if (end > valueStart) { + tokens.push({ + kind: 'field-clause', + field, + value: { kind: 'bare', raw: input.slice(valueStart, end) } + }) + i = end + matched = true + break + } + } + if (matched) continue + + // boolean operator (uppercase, standalone — boundary on both sides) + for (const op of BOOL_OPS) { + if (input.slice(i, i + op.length) !== op) continue + const after = i + op.length + if (after !== input.length && !/\s/.test(input[after])) continue + tokens.push({ kind: 'bool-op', raw: op as 'AND' | 'OR' | 'NOT' }) + i = after + matched = true + break + } + if (matched) continue + + // bare token: run up to next whitespace + let end = i + while (end < input.length && !/\s/.test(input[end])) end++ + tokens.push({ kind: 'bare', raw: input.slice(i, end) }) + i = end + } + return tokens +} + +/** Render a single token into its ES-safe wire form. */ +function renderToken (tok: Token): string { + switch (tok.kind) { + case 'ws': + case 'bool-op': + case 'quoted': + return tok.raw + + case 'field-clause': { + const { field, value } = tok + switch (value.kind) { + case 'paren': + // User explicitly wrapped value in parens. Re-emit with the inner + // content escaped so any reserved chars become literal — the + // wrapping parens themselves are ES grouping syntax, not user + // text. + return `${field}:(${escapeForQueryString(value.inner)})` + case 'quoted': + // ES phrase literal; pass inner content through verbatim. + return `${field}:"${value.inner}"` + case 'bare': + // Leading '-' on a field-targeted value would be parsed by ES + // query_string as the Lucene NOT operator (NOT foo), not as + // literal text. Wrap+escape so the minus stays literal. Mid- + // token hyphens (HULY-51, bug-fix) are unaffected — they only + // hit this branch when value.raw does NOT start with '-'. + if (value.raw.startsWith('-')) { + return `${field}:(${escapeForQueryString(value.raw)})` + } + // Clean value: emit bare for a readable wire string. Reserved + // chars present: wrap in parens with full Lucene escape so ES + // query_string parses the value as a single literal token. + if (!PREFIX_VALUE_RESERVED_RE.test(value.raw)) { + return `${field}:${value.raw}` + } + return `${field}:(${escapeForQueryString(value.raw)})` + } + // ClauseValue is exhaustively handled above; this break keeps the + // outer switch free of implicit fall-through (eslint no-fallthrough). + break + } + + case 'bare': { + // Orphan bare token in a prefix-routed query. Once ANY known prefix + // appears, the adapter routes via ES query_string (strict parser), + // so reserved chars here also break the query. Escape per + // PREFIX_VALUE_RESERVED_RE (narrower than the full Lucene set — + // see the constant's JSDoc for why `-`, `*`, `?` are excluded). + // + // Special case: leading '-' would be parsed as Lucene NOT-operator + // even though mid-token '-' is tolerant. Escape the leading minus + // explicitly so the orphan token stays a literal term. + if (tok.raw.startsWith('-')) { + return '\\-' + tok.raw.slice(1).replace(PREFIX_VALUE_RESERVED_RE_G, '\\$&') + } + if (!PREFIX_VALUE_RESERVED_RE.test(tok.raw)) return tok.raw + return tok.raw.replace(PREFIX_VALUE_RESERVED_RE_G, '\\$&') + } + } +} + +function escapePrefixValues (aliased: string): string { + const tokens = tokenize(aliased) + // A trailing boolean operator (`foo AND`, `title:foo NOT`) has no + // right-hand operand, so ES query_string throws a parse exception. Find the + // last non-whitespace token; if it is a bool-op it is dangling → escape it + // into a literal term instead of emitting it as an operator. + let lastNonWs = -1 + for (let k = tokens.length - 1; k >= 0; k--) { + if (tokens[k].kind !== 'ws') { + lastNonWs = k + break + } + } + return tokens + .map((tok, idx) => (tok.kind === 'bool-op' && idx === lastNonWs ? `\\${tok.raw}` : renderToken(tok))) + .join('') +} + +export function encodeSearch (raw: string, scope: SearchScope): string { + const trimmed = raw.trim() + if (trimmed === '') return '' + const aliased = aliasPrefixes(trimmed) + if (hasKnownPrefix(trimmed)) return escapePrefixValues(aliased) + const safe = escapeForQueryString(aliased) + switch (scope) { + case 'title': + return `searchTitle:(${safe})` + case 'title-description': + return `(searchTitle:(${safe}) OR description.plain:(${safe}))` + case 'all': + default: + return aliased // scope=all routes via simple_query_string; no escaping needed + } +} diff --git a/packages/ui/src/components/SearchInputAdvanced.svelte b/packages/ui/src/components/SearchInputAdvanced.svelte new file mode 100644 index 0000000000..27bee7591e --- /dev/null +++ b/packages/ui/src/components/SearchInputAdvanced.svelte @@ -0,0 +1,255 @@ + + + + + + + diff --git a/packages/ui/src/components/SearchInputAdvanced.sync.ts b/packages/ui/src/components/SearchInputAdvanced.sync.ts new file mode 100644 index 0000000000..ac0d4a5cd6 --- /dev/null +++ b/packages/ui/src/components/SearchInputAdvanced.sync.ts @@ -0,0 +1,33 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +/** + * Decide the value to sync from the parent `value` prop into the local input. + * + * Returns the value to write into the input, or `undefined` to leave the local + * input untouched. + * + * The rule is: re-sync ONLY when the parent prop itself changed since the last + * sync (`value !== lastValue`). It must NOT depend on the current local input, + * because the reactive block that calls it would then re-run on every local + * edit; since the parent prop lags behind (updated only after the debounced + * emit), that would clobber the just-typed value back to the stale prop and + * erase input mid-typing (which broke the tracker create→search→open flow: + * Playwright fill() then read == empty, so the search was never submitted). + */ +export function propSyncValue (value: string | undefined, lastValue: string | undefined): string | undefined { + if (value === lastValue || value === undefined) return undefined + return value +} diff --git a/packages/ui/src/components/Switcher.svelte b/packages/ui/src/components/Switcher.svelte index 00d2288e02..f49ebf5101 100644 --- a/packages/ui/src/components/Switcher.svelte +++ b/packages/ui/src/components/Switcher.svelte @@ -5,7 +5,7 @@ // import { createEventDispatcher } from 'svelte' - import type { TabItem } from '../types' + import type { LabelAndProps, TabItem } from '../types' import SwitcherBase from './SwitcherBase.svelte' export let items: TabItem[] @@ -13,6 +13,11 @@ export let kind: 'nuance' | 'subtle' = 'nuance' export let name: string export let onlyIcons: boolean = false + /** When true, all items render disabled; click events are ignored. */ + export let disabled: boolean = false + /** Group-level tooltip; when set and `disabled` is true, every item + * shows this tooltip instead of its own. */ + export let tooltip: LabelAndProps | undefined = undefined const dispatch = createEventDispatcher() @@ -23,14 +28,16 @@ id={item.id} {name} {kind} + {disabled} checked={selected === item.id} icon={item.icon} color={item.color} title={onlyIcons ? undefined : item.label} label={onlyIcons ? undefined : item.labelIntl} labelParams={onlyIcons ? undefined : item.labelParams} - tooltip={item.tooltip ? { label: item.tooltip } : undefined} + tooltip={disabled && tooltip !== undefined ? tooltip : item.tooltip ? { label: item.tooltip } : undefined} on:change={() => { + if (disabled) return dispatch('select', item) if (item.action !== undefined) item.action() }} diff --git a/packages/ui/src/components/SwitcherBase.svelte b/packages/ui/src/components/SwitcherBase.svelte index 5fdfc60c3b..88361ea44a 100644 --- a/packages/ui/src/components/SwitcherBase.svelte +++ b/packages/ui/src/components/SwitcherBase.svelte @@ -21,12 +21,19 @@ export let name: string export let checked: boolean = false export let tooltip: LabelAndProps | undefined = undefined + export let disabled: boolean = false $: woTitle = title === undefined && label === undefined -