From ec8fb39528c3a86596794377df7c03abcf9af27f Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Mon, 27 Jul 2026 08:49:31 +0000 Subject: [PATCH 1/2] 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 --- .../server/packages/elastic/src/adapter.ts | 70 +++- models/tracker/src/migration.ts | 48 ++- models/tracker/src/viewlets.ts | 34 +- .../src/components/HighlightedText.helpers.ts | 43 ++ .../ui/src/components/HighlightedText.svelte | 37 ++ .../ui/src/components/HighlightedText.test.ts | 61 +++ .../ui/src/components/ModeSelector.svelte | 5 + .../components/SearchInputAdvanced.encoder.ts | 378 ++++++++++++++++++ .../src/components/SearchInputAdvanced.svelte | 255 ++++++++++++ .../components/SearchInputAdvanced.sync.ts | 33 ++ .../components/SearchInputAdvanced.test.ts | 251 ++++++++++++ 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 | 240 ++++++++++- .../src/components/issues/KanbanView.svelte | 15 +- 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 +- .../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/InlineFilterChips.test.ts | 35 ++ .../filter/InlineFilterChipsOverflow.svelte | 62 +++ .../src/components/list/List.svelte | 33 +- plugins/view-resources/src/filter.ts | 13 +- .../src/filter/query-builder.test.ts | 87 ++++ .../src/filter/query-builder.ts | 117 ++++++ plugins/view-resources/src/index.ts | 5 + plugins/view-resources/src/plugin.ts | 5 +- .../src/searchEmptyState.test.ts | 41 ++ .../view-resources/src/searchEmptyState.ts | 34 ++ plugins/view-resources/src/stores.ts | 27 ++ .../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 +- 70 files changed, 3001 insertions(+), 193 deletions(-) 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/HighlightedText.test.ts 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 packages/ui/src/components/SearchInputAdvanced.test.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/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/InlineFilterChips.test.ts create mode 100644 plugins/view-resources/src/components/filter/InlineFilterChipsOverflow.svelte create mode 100644 plugins/view-resources/src/filter/query-builder.test.ts create mode 100644 plugins/view-resources/src/filter/query-builder.ts create mode 100644 plugins/view-resources/src/searchEmptyState.test.ts create mode 100644 plugins/view-resources/src/searchEmptyState.ts create mode 100644 plugins/view-resources/src/stores.ts diff --git a/foundations/server/packages/elastic/src/adapter.ts b/foundations/server/packages/elastic/src/adapter.ts index 1da9e3711e6..6cefedce0eb 100644 --- a/foundations/server/packages/elastic/src/adapter.ts +++ b/foundations/server/packages/elastic/src/adapter.ts @@ -314,17 +314,71 @@ 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. + // KEEP IN SYNC with packages/ui SearchInputAdvanced.encoder.ts + // ES_NATIVE_FIELDS (and vice versa). The client only emits a `field:value` + // clause for a field it believes the server recognises; if the two lists + // drift, a client-routed clause hits a field the adapter does not treat as + // query_string and silently fails to parse. + // + // 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. + // + const KNOWN_FIELD_RE = + /(^|\s)(searchTitle|searchShortTitle|identifier|description\.plain|comments\.message|fulltextSummary)\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 a150a2947d6..45b25f3fe88 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 509e00d7d0e..31bf0f6b05d 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/components/HighlightedText.helpers.ts b/packages/ui/src/components/HighlightedText.helpers.ts new file mode 100644 index 00000000000..58e2375d921 --- /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 00000000000..1608429523a --- /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/HighlightedText.test.ts b/packages/ui/src/components/HighlightedText.test.ts new file mode 100644 index 00000000000..478e7d5551a --- /dev/null +++ b/packages/ui/src/components/HighlightedText.test.ts @@ -0,0 +1,61 @@ +import { splitHighlightSegments } from './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/components/ModeSelector.svelte b/packages/ui/src/components/ModeSelector.svelte index 62bfd1fecd9..48f14fcd25e 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 00000000000..a7f472249fd --- /dev/null +++ b/packages/ui/src/components/SearchInputAdvanced.encoder.ts @@ -0,0 +1,378 @@ +// +// 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. + */ +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 ES-native fields users may type directly. + * + * KEEP IN SYNC with elastic/src/adapter.ts KNOWN_FIELD_RE (and vice versa). + * Both sides must carry the identical field set so the client only routes a + * `field:value` clause to query_string when the server-side adapter also + * recognises that field; otherwise the clause silently fails to parse. + */ +const ES_NATIVE_FIELDS = new Set([ + 'searchTitle', + 'searchShortTitle', + 'identifier', + 'description.plain', + 'comments.message', + 'fulltextSummary' +]) +/** 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] + // escape dots in `description.plain`, `comments.message` + .map((f) => f.replace(/\./g, '\\.')) + .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. + */ +const PREFIX_VALUE_RESERVED_RE = /[+!(){}[\]^"~\\/:&|<>=]/ + +/** + * 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(/[+!(){}[\]^"~\\/:&|<>=]/g, '\\$&') + } + if (!PREFIX_VALUE_RESERVED_RE.test(tok.raw)) return tok.raw + return tok.raw.replace(/[+!(){}[\]^"~\\/:&|<>=]/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 00000000000..27bee7591ef --- /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 00000000000..ac0d4a5cd61 --- /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/SearchInputAdvanced.test.ts b/packages/ui/src/components/SearchInputAdvanced.test.ts new file mode 100644 index 00000000000..0b43e10d4eb --- /dev/null +++ b/packages/ui/src/components/SearchInputAdvanced.test.ts @@ -0,0 +1,251 @@ +import { encodeSearch } from './SearchInputAdvanced.encoder' +import { propSyncValue } from './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/Switcher.svelte b/packages/ui/src/components/Switcher.svelte index 00d2288e02a..f49ebf51015 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 5fdfc60c3b9..88361ea44a7 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 -