Skip to content

feat(tracker): Gantt — #9 inline filter chips + advanced search + view-option toggles#10872

Draft
MichaelUray wants to merge 335 commits into
hcengineering:developfrom
MichaelUray:feat/tracker-filter-search-redesign
Draft

feat(tracker): Gantt — #9 inline filter chips + advanced search + view-option toggles#10872
MichaelUray wants to merge 335 commits into
hcengineering:developfrom
MichaelUray:feat/tracker-filter-search-redesign

Conversation

@MichaelUray

@MichaelUray MichaelUray commented May 21, 2026

Copy link
Copy Markdown
Contributor

Summary

Successor to the closed #10849. Same Plan-2 substance (inline filter chips + advanced search bar + view-option toggles for the Tracker viewlets) plus several rounds of encoder hardening based on review feedback after #10849 was closed. Includes the follow-up bugfix round from live testing on our staging deployment.

The 14 commits in this PR break into three groups:

  1. Plan-2 core (7 commits) — the same content as feat(tracker): inline filter chips + advanced search + view-option toggles #10849: All/Active/Backlog inline filter chips, advanced search with field-prefix support (title:, id:, comments:), Customize-View popover with Search in... scope + highlight-match + quick-filter toggles, empty-state card with reset actions, match-highlight in list and Gantt sidebar.
  2. Search-encoder hardening (5 commits) — found while extending Plan-2 to a Gantt viewlet. Each fixes a real Elasticsearch query_string parse failure that surfaced as silent zero-hit results:
    • 2461879b4b — fixes three independent issues: (a) IssuesView search state leaking across route switches, (b) prefix-targeted values with Lucene-reserved chars (title:C++, comments:foo/bar) crashing the parser, (c) Saved Gantt View reload being a no-op when re-selecting the active view. Includes a 12-line saved-view-reload fix in GanttView.svelte that was discovered while testing the Plan-2 search-state leak; bundled here for atomicity rather than carried in a follow-up PR.
    • 19024b8a65 — colon-in-value escape (title:POC:123, title:12:30) so the inner colon doesn't re-enter field-targeted parsing.
    • ff1bf76105 — broaden orphan-token escape from : only to the full reserved set, plus quoted-phrase-aware tokenizer.
    • 56326b81ef — capture attached parens/brackets as part of the bare value (title:foo(bar), title:list[0]).
    • 28c9712e01 — refactor: replace the 2-pass regex encoder with a single typed-tokenizer + per-type renderer. Each token kind has exactly one rendering rule; future edge cases need only either a new token kind or a tweak to the relevant renderer, not a cross-cutting regex re-think. Behavior preserved bit-for-bit (all 30 existing encoder tests pass without modification).
  3. Final correctness + cleanup (2 commits)
    • a5a6018851 — escape leading hyphens in prefix-routed search tokens. Without this, title:-foo and title:meeting -cancelled were sent verbatim to ES and interpreted as Lucene NOT-operator clauses.
    • 3f4c4342cc — neutralize internal-review section headers in the encoder tests (cosmetic, no behavior change).

Test coverage

  • packages/ui SearchInputAdvanced test suite: 33 cases (up from 27 before the encoder hardening rounds), covering reserved-char wrapping, colon-in-value, orphan-token escape, attached parens/brackets, leading-hyphen escape, quoted-phrase passthrough, AND/OR/NOT preservation, and */? wildcard preservation.
  • All packages/ui (62), plugins/tracker-resources (663), and plugins/view-resources (19) tests pass — 744 total.
  • 0 svelte-check errors across all three packages.

Spec + design

Design captured in docs/superpowers/specs/2026-05-12-tracker-filter-search-redesign-design.md in the related infra repo. The encoder design is documented inline in the SearchInputAdvanced.encoder.ts header JSDoc and the tokenize/renderToken function comments.

DCO

All 14 commits are Signed-off-by: Michael Uray.

Follow-up

A separate PR for the Gantt bar-coloring feature (overlays + color modes + sub-issue progress + saved-view bar-color snapshot) will open after this PR merges; the bar-colors branch will be rebased onto upstream develop so that follow-up PR shows a clean diff that does not duplicate this PR's content. The companion docs PR huly-docs#70 covers both this PR's surface and the bar-coloring follow-up.

@huly-github-staging

Copy link
Copy Markdown

Connected to Huly®: UBERF-16462

@MichaelUray

Copy link
Copy Markdown
Contributor Author

Stack update — companion PR10 is now open as Draft: #10873 (Gantt bar coloring + overlays + sub-issue progress). It is stacked on top of this PR. When this PR merges, PR10 will be rebased to drop the duplicated content and the diff there collapses to bar-colors only.

…s (PR4a)

Reactive Map<Ref<Issue>, BarRect> computed from the same row geometry
that positions GanttBar; threaded into GanttDependencyLayer so arrows
can anchor without a separate measurement pass.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Conditional cell rendered when ganttShowPredecessors ViewOption is on;
shows formatPredecessors(issue, relations, issueNumberOf) with ellipsis
truncation at 14ch.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
- IssueRelation live query (\$or both directions, scoped by space).
- hoveredIssue / hoveredEdge state + connectedIds reactive derivation.
- Connector reducer handlers (down/move/up); mouseup branches:
  · drawing without target → idle (drag cancelled).
  · target-hover → wouldCreateCycle gate → addCollection or error toast.
- showPredecessors ViewOption threaded into GanttSidebar.
- handleOpenEditor resolves canEdit from canEditIssue(sourceIssue) and
  showPopup(DependencyEditor, ...).

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Task 1 placed the same 13 keys in both plugins/tracker/src/index.ts AND
plugins/tracker-resources/src/plugin.ts. mergeIds() in the tracker-resources
plugin then threw 'identify overwrites DependencyKindFS for tracker:string'
during workspace upgrade (tool image bundle load).

The keys are UI-only (DependencyEditor labels, predecessor column,
cycle-toast, ViewOption label). Following the existing convention
(e.g. GanttShowIssueCode is in tracker-resources only, GanttDragFailed
is in tracker only), the dependency keys all belong in
tracker-resources/src/plugin.ts. Removed from the base tracker namespace.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…eIds (PR4a)

CockroachDB adapter rejects {$in: []} with 'unsupported comparison
operator: <string> = <string[]>' which surfaces as an error toast and
blocks the entire Gantt render. Empty array also semantically means
'no relations possible' — set relations = [] and skip the query
until issues load.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…R4a)

The Huly/CockroachDB adapter doesn't translate top-level $or — it
serializes the operator as a JSONB path (`data#>>'{$or}'`) and crashes
with 'unsupported comparison operator: <string> = <string[]>'.

Query the entire space's relations and let GanttDependencyLayer filter
client-side via barRects (only relations whose endpoints are in
visible barRects render). Relations are typically sparse so the
performance cost is negligible. predecessor-format does its own
client-side filter.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…dencies

- viewport.computeAdaptivePxPerDay stretches the time-scale when the
  data range is narrower than the visible canvas so the right side
  fills correctly. New unit test covers the stretch + identity cases.
- createTimeScale accepts an optional pxPerDayOverride for the
  adaptive path (default falls through to ZoomLevel-derived value).
- Optimistic dependency creation: GanttView prepends the new
  IssueRelation to a local list right after addCollection() so the
  arrow renders before the live query roundtrip. Rolled back on
  commit failure and de-duplicated when the canonical doc arrives.
- Connector-dot moves to the bar's bottom-right corner with a 12px
  outset + 10px hit area, plus a fallback native pointer-listener
  registered at document level (covers shadow-DOM / portal edge cases
  where Svelte's event-forwarding path doesn't surface mousedown).

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Wrapping the live-preview path computation in livePath() hid dragState
from Svelte's reactivity tracker, so the bezier between the connector
dot and the cursor never redrew while the user dragged. Inline the
condition into the $: block so dragState.kind, dragState.originPx,
and dragState.cursorPx are all visible direct dependencies.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
… const

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Three-layer plumb for the disabled state:
- IModeSelector gets two new optional fields (disabled, disabledReason).
- SwitcherBase forwards disabled to the radio input + paints the wrapper
  with a 0.4-opacity overlay + not-allowed cursor.
- Switcher propagates disabled to every SwitcherBase, plus a group-level
  tooltip that wins over per-item tooltips while disabled.
- ModeSelector reads props.disabled + props.disabledReason and threads
  them into the Switcher, plus blocks the on:select dispatch when
  disabled.

Wired into Tracker's IssuesView: Issues.svelte detects an active
Status-filter via $filterStore and sets modeSelectorProps.disabled +
disabledReason accordingly. The ModeSelector now also renders INLINE in
the Gantt-mode search slot (right after the FilterButton in markup
order, which puts it visually between Filter and Lupe in the
row-reverse cluster). The legacy SpaceHeader extra-slot render is
suppressed in Gantt mode by passing modeSelectorProps=undefined when
isGanttMode is true.

13 lang files updated with the new IntlString.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…+ highlight

Three new ViewOptions exposed in the Customize-View popup for every Issue
viewlet (List, Kanban, Gantt):

- showQuickModeSelector (toggle, default true) — hides the inline
  All/Active/Backlog ModeSelector when the user wants a more minimal
  toolbar. IssuesView gates the inline render on this toggle.
- searchScope (dropdown, default 'all') — three values: title /
  title-description / all. Drives the SearchInputAdvanced encoder in T6
  (this commit just registers the option; the encoder consumes it next).
- searchHighlight (toggle, default true) — turns the match highlighting
  in T9 on/off. T9 reads the same flag.

Added to both:
- issuesOptions() (shared by List + Kanban + the Issues 'allIssues'
  view).
- The Gantt-specific ViewOptionsModel block (it doesn't go through
  issuesOptions, but exposes its own 'other' array).

8 new IntlStrings (incl. the 2 from T3 + T4 backfilled for non-en/de
locales — they were originally added only to en+de). All 13 tracker-
assets lang files now carry every new key with English fallback.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…ring branch

Client side:
- New `SearchInputAdvanced` component in packages/ui wraps SearchInput's
  visual style verbatim, accepts a `scope` prop, dispatches change with
  { raw, encoded } so consumers can keep the user-facing input pristine
  while routing the encoded wire-form to the server.
- New `encodeSearch` pure helper (encoder.ts + 9 jest tests). Aliases
  user-friendly prefixes (title:, id:, comments:) to actual ES fields
  (searchTitle, identifier, comments.message). Bare-term scope wrapping
  applies only when no prefix is present.
- Shared `rawSearchTextStore` + `resultIssueCountStore` declared in
  plugins/view-resources/src/stores.ts. The store lives in view-resources
  (the lower dependency layer) so tracker-resources can import without
  inverting the package-dependency graph. T8 + T9 read+write these stores.

Server side:
- foundations/server/packages/elastic/src/adapter.ts: when $search
  contains a `:` token (Lucene field:value syntax), route via
  `query_string` with per-field boosts (searchTitle^3, searchShortTitle^2,
  identifier^2, description.plain, comments.message^0.7, fulltextSummary).
  Without `:` the legacy `simple_query_string` path is preserved exactly
  so existing non-Tracker consumers are unaffected.

Tracker integration:
- IssuesView now keeps two parallel search states:
    - `search` (legacy, written by SpaceHeader's built-in input for
      List + Kanban)
    - `searchRaw` + `searchEncoded` (Gantt-mode slot override). The
      reactive `effectiveSearch` picks the right one and writes it
      into $search. `rawSearchTextStore` is fed from `searchRaw` so
      the upcoming T9 HighlightedText can read the user-typed form
      (not the encoded form).

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
When the user types a search term and zero matches come back, the empty
grid was previously confusing — was it filtered to empty, broken, or
still loading? Now we render a SearchEmptyState card on top with:
- 🔍 icon + 'No issues found for {query}' headline.
- Active-filter chips listed (so the user sees what's narrowing the
  result set).
- 'Suche ohne Filter' button: clears all filterStore entries, keeps
  the search text.
- 'In allen Projekten suchen' button: navigates to the All-Issues
  special view with the search text preserved.

Wiring:
- Pure shouldShowEmptyState() helper + 4 jest tests in
  tracker-resources/src/components/SearchEmptyState.helpers.ts.
- SearchEmptyState.svelte renders the card; mounted in IssuesView as an
  absolutely-positioned overlay on top of the ViewletContentView. The
  viewlet stays mounted (otherwise its onDestroy reset would loop with
  the empty-state's mount/unmount).
- Each viewlet writes its result-count into the shared
  resultIssueCountStore (declared in T6, view-resources/stores.ts) and
  resets to -1 on destroy:
    - view-resources/list/List.svelte writes `docs.length`.
    - tracker-resources/KanbanView.svelte writes `tasks.length`.
    - tracker-resources/gantt/GanttView.svelte writes
      `issues.length + milestones.length`.

4 new IntlStrings (SearchEmpty{Title,ActiveFilters,ClearFilters,
AllProjects}) in all 13 tracker-assets lang files with English/German
canonical translations.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
When the user has typed something in the Lupe, the matching substring
inside Issue identifiers + Gantt-sidebar titles is now wrapped in a
<mark> for visual scanning.

- New HighlightedText component in packages/ui (Svelte 4) takes
  { text, query } and renders alternating <mark>/text spans. Reads the
  RAW user text from rawSearchTextStore (NOT the encoded $search wire-
  form) — see helpers comment for the graceful fallback if an encoded
  string ever slips through.
- splitHighlightSegments() pure helper with 7 jest tests including the
  documented v1 limitation: only the leading user-prefix (title:, id:,
  comments:) is stripped, multi-prefix queries fall back to no-match.
- IssuePresenter wraps issue.identifier in HighlightedText so 'id:OSKOS-'
  searches highlight the prefix.
- GanttSidebarColumn wraps issue.title in HighlightedText for the title
  column (extended-sidebar mode).

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…al-render height

The flex wrapper around ViewletContentView gave the viewlet area no
defined height in the parent layout context, so GanttView (height:100%)
collapsed on first paint. Filter toggle reflowed and patched it. Render
SearchEmptyState as a top-level sibling instead — when it shows the
viewlet is empty anyway, so no overlay tricks are needed.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…ow-ups

Bundled set of fixes that surfaced during live-deploy verification of the
InlineFilterChips feature commit (8638208cb). Squashed together here as a
single follow-up commit so the public history reflects the user-visible
delta rather than a debugging trail.

Issues addressed:

- The "+ Add filter" affordance is preserved when filters are active.
  Previously the FilterButton flipped to "Clear filters" once any filter
  was added, leaving no UI path to add a second filter. Clear-all now
  lives on InlineFilterChips as a trailing ghost button.

- InlineFilterChips is mounted in List / Kanban as well as Gantt mode.
  The filter→resultQuery reactive path was originally Gantt-only.

- visibleCount is seeded to filterStore.length on each store mutation so
  the first paint renders every chip (otherwise chipEls.widths is [] and
  visibleCount stays at 0 → chips invisible).

- recompute() skips while containerWidth=0 (ResizeObserver hasn't fired
  yet on first paint) and resets hiddenCount on every filterStore change
  to avoid stale +N badges.

- Chip cluster has a max-width: 22rem cap. Without it, the cluster grew
  to its content's natural width and pushed Group-by / date-nav / zoom /
  undo-redo / hamburger / fullscreen off the viewport edge.

- Measured chip widths are cached by filter.index, not by array slot.
  Unmounted chips report width=0 from getBoundingClientRect, which would
  feed back into computeOverflow as "everything fits" → chips re-expand
  → ResizeObserver fires on the new container → loop. Per-index caching
  breaks the oscillation.

- The +N badge is a solid pill (using --theme-state-attention-color) and
  the overflow popover uses --theme-popup-color + --theme-popup-shadow +
  backdrop-filter blur so the Gantt week headers and grid lines no
  longer bleed through. HiddenFilters IntlString added for all 13
  supported locales (en, de, es, fr, it, pt, pt-br, ru, cs, tr, ja, ko, zh).

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Blockers from the independent review (HOLD verdict):

C1 — Restore FilterBar chip render for the 17 non-Tracker consumers
(lead/contact/drive/recruit/training/controlled-documents/workbench/
chunter/task plus Tracker's MilestoneBrowser/ComponentBrowser/
IssueTemplatesView). Add hideChips:boolean prop that IssuesView sets
true; everyone else keeps the legacy chip strip. Move the
makeFilterQuery data path back into FilterBar (debounced via
reduceCalls) and turn InlineFilterChips into a presentational-only
component so the dispatch never doubles.

C2 — Strip local-deployment fixtures from shipped files. Delete
plugins/view-resources/src/__tests__/README-search-rootcause.md
(internal investigation pointer; the full report lives in
docs/test-reports/). Rename OSKOS- → HULY- in tests and in the
elastic adapter doc-comment.

C3 + Codex#1 — Wire SearchInputAdvanced for every Tracker viewlet,
not just Gantt. The slot override now runs for List/Kanban too, so
prefix operators (title:/id:/comments:), searchScope, the empty-state
card, and the rawSearchTextStore-driven match highlight all work
uniformly.

Codex#2 — Empty-state is now rendered INSTEAD of ViewletContentView
when shouldShowEmptyState() is true, not below it. List/Kanban/Gantt
delay the resultIssueCount store write until their first LiveQuery
response, so the empty-state card no longer flashes during initial
load before the query has resolved.

Codex M1 — SearchInputAdvanced gains a Clear (×) button parallel to
SearchInput.svelte:87, plus Escape-to-clear keybinding. Drop the dead
`value = _search` line that confused the one-way binding contract.

Codex M2 — SwitcherBase disabled keeps pointer-events so the
disabled-reason tooltip stays reachable on hover; only the underlying
<input type=radio disabled> blocks selection.

ES query_string + encoder — Restrict field-targeted routing to the
known field names. Stray colons (URLs, times, `POC: review`) no
longer trip the parser and silently return zero hits.

Plus assorted polish:
- I3: ModeSelector resets to 'all' on the rising edge of
  hasStatusFilter (Spec §7.4).
- I4/I5: InlineFilterChips caches widths by filter.index instead of
  by array slot, so add/remove cycles don't trash the cache.
- N1: DE 'Title' → 'Titel' / 'Description' → 'Beschreibung' /
  'Comments' → 'Kommentare' in the SearchScope labels.
- N2: ko.json reformatted back to upstream's 4-space indentation.
- N4: viewlets.ts extracts SEARCH_VIEW_OPTIONS to a single const so
  the List/Kanban and Gantt blocks can't drift.
- N6: gotoAllIssues() guards against non-tracker routes.
- N8: cloneQuery prefers structuredClone when available.
- N9: HighlightedText accepts an enabled prop; IssuesView mirrors
  viewOptions.searchHighlight into a store so the toggle works.
- O5: aria-label/aria-haspopup/aria-expanded on the +N badge, role +
  aria-hidden on SearchEmptyState's icon, <h2> for the title.
- +N badge moved from --theme-state-negative-color (reserved for
  destructive) to --theme-state-attention-color.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Three independent issues addressed in one commit because they came out
of the same review pass over the filter+search redesign on a Gantt
viewlet.

1. IssuesView search-state leaked across route/space switches.
   The local searchRaw was reset to '' on mount, but the shared
   rawSearchTextStore was only written from onSearchChange(). A new
   view therefore mounted with an empty input field while the global
   store still held the PREVIOUS view's text — match-highlight and
   Empty-State could trigger on a stale query the user never typed.

   Fix in plugins/tracker-resources/src/components/issues/IssuesView.svelte:
   reactive '$: rawSearchTextStore.set(searchRaw)' so the store mirrors
   the LOCAL searchRaw on every change (including mount-to-empty), and
   onDestroy() clears the store so route-out cannot leak either.

2. Prefix-targeted search (title:C++, comments:foo/bar) blew up in the
   Elasticsearch query_string parser. The encoder returned aliased
   prefix terms RAW without escaping Lucene-reserved chars, so values
   containing '+', '!', parens, brackets, etc. produced parse errors
   surfacing as zero hits.

   Fix in packages/ui/src/components/SearchInputAdvanced.encoder.ts:
   new escapePrefixValues() runs a single regex over the aliased
   string, matching '<es-field>:<value>' clauses with three value
   shapes (paren-wrapped / quoted / bare). For bare values we wrap +
   escape ONLY when reserved chars are present (preserves the readable
   'searchTitle:loader' wire string for the common case), quoted values
   pass through as ES phrase literals, paren-wrapped values get their
   inner content escaped. Whitespace and boolean operators (AND/OR/NOT)
   between clauses pass through verbatim.

   Reserved-char set is narrower than escapeForQueryString — excludes
   '-' (ES tolerates mid-term), '*'/'?' (legitimate wildcards), and ':'
   (cannot appear in a value at this stage by construction).

3. Reloading the active Saved Gantt View after local edits was a no-op.
   The Apply '$:' block at GanttView.svelte:596 short-circuits when
   lastAppliedSavedViewId equals the loaded view's _id — desired
   behaviour for stale selectedFilterStore traffic from other viewlets,
   but broken when the user EXPLICITLY re-selected the same view in the
   Load dialog to REVERT their dirty state.

   Fix in plugins/tracker-resources/src/components/gantt/GanttView.svelte:
   the load-dialog click handler now resets lastAppliedSavedViewId = null
   BEFORE selectedFilterStore.set(next), so the guard sees a fresh state
   and applies the loaded view's options.

Tests:
- 4 new cases in packages/ui/src/components/SearchInputAdvanced.test.ts:
  - Reserved-char wrapping (C++ → C\+\+, foo/bar → foo\/bar)
  - Clean values stay bare (title:loader → searchTitle:loader)
  - Quoted phrases pass through
  - Boolean operators between prefix clauses preserved
  - User-typed parens treated as ES grouping (escape inner only)
- All 686 tracker-resources, 19 view-resources, 46 ui tests pass.
- 0 svelte-check errors across all three packages.

User-visible: the All/Active/Backlog/InlineFilterChips redesign now
behaves correctly under heavy navigation + power-user search syntax.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Follow-up to the prefix-value escape pass: that initial pass excluded
`:` from PREFIX_VALUE_RESERVED_RE on the (wrong) assumption that the
tokenizer guarantees no colon in a value. In fact the bare-value
pattern is greedy across non-whitespace so a value can absolutely
contain its own colon — e.g. `title:POC:123` lands at the encoder as
field=searchTitle, value=POC:123. Without escaping that, ES
query_string re-parses the inner colon as another field-targeted
clause, and the whole query crashes.

Two changes:

1. Add ':' to PREFIX_VALUE_RESERVED_RE so any colon in a value triggers
   wrap+escape. `title:POC:123` → `searchTitle:(POC\:123)`,
   `title:12:30` → `searchTitle:(12\:30)`.

2. Add a second pass that escapes orphan colons in tokens that AREN'T
   known-field clauses. Example: `title:meeting 12:30` — pass 1 emits
   `searchTitle:meeting 12:30` because `12:30` has no known-field
   anchor and the regex skips it. Pass 2 escapes that orphan colon so
   ES doesn't try to parse `12` as a field name.

Two new test cases lock in the new behaviour:
- 'wraps + escapes colons inside prefix values' covers title:POC:123,
  title:12:30, comments:bug:fix
- 'escapes orphan colons in bare tokens that follow a prefix clause'
  covers title:meeting 12:30

All 49 ui tests pass.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Follow-up to the orphan-token escape pass: that pass only escaped
colons in orphan bare tokens, but once any known prefix appears the
adapter routes the whole string through ES `query_string` — so EVERY
reserved char in a trailing bare token can break the parser, not just
colons. Examples that previously failed:

- `title:meeting C++` → ES parsed `C++` as `C + +` → parse error
- `title:meeting foo/bar` → `/` parsed as regex delimiter
- `title:bug list[0]` → `[`/`]` parsed as range query

Two changes in escapePrefixValues:

1. Broaden the second-pass escape set from just `:` to the full
   PREFIX_VALUE_RESERVED_RE (`+!(){}[]^"~\\/:`). Excludes `-`, `*`,
   `?` — ES tolerates `-` mid-term and the others are legitimate
   wildcards. Excludes AND/OR/NOT as standalone tokens (boolean
   operators), and standalone quoted phrases (ES phrase literals).

2. Replace the `\\S+` tokenizer with a stateful walker that respects
   quoted phrases. Pass 1's output for `title:"foo bar"` is the
   well-formed `searchTitle:"foo bar"` — the naive `\\S+` regex would
   split it into `searchTitle:"foo` + `bar"` at the inner whitespace
   and then escape the trailing `"`, breaking the phrase. The new
   walker tracks quote state per char so `"foo bar"` survives as one
   token regardless of where it sits.

Seven new test cases:
- C++ in orphan position → `C\\+\\+`
- foo/bar in orphan position → `foo\\/bar`
- foo) / list[0] → escaped parens/brackets
- AND/OR/NOT verbatim between tokens
- "release notes" quoted phrase passes through
- *, ? wildcards preserved
- bug-fix hyphen preserved (ES tolerant mid-term)

All 56 ui, 686 tracker-resources, 19 view-resources tests pass.
0 svelte-check errors.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
`title:foo(bar)` slipped through both escape passes. Pass 1's
bare-value pattern was [^\s()]+ which stopped at the first '(' — so
the regex matched value='foo', emitted 'searchTitle:foo', and the
attached '(bar)' became part of the same whitespace-glued token. Pass 2
then saw a token starting with a known field prefix and passed it
through verbatim, leaving 'searchTitle:foo(bar)' in the wire string
where ES query_string parses it ambiguously.

Fix: widen the bare-value pattern from [^\s()]+ to [^\s]+ so attached
parens / brackets / etc. become part of the captured value. The
paren-wrapped (\([^()]*\)) branch still runs first in the alternation,
so 'title:(scope)' remains correctly captured as paren-wrapped (no
double-escape). Only the case where parens are ATTACHED to a bare
value picks them up in the bare branch and routes through the
reserved-char wrap+escape path.

Trace for the new cases:
- title:foo(bar)  → bare='foo(bar)', '(' reserved → searchTitle:(foo\(bar\))
- title:foo)     → bare='foo)',     ')' reserved → searchTitle:(foo\))
- title:list[0]   → bare='list[0]',  '[' reserved → searchTitle:(list\[0\])
- title:(scope)   → paren-branch matches first (unchanged) → searchTitle:(scope)
- title:loader   → bare='loader',  clean       → searchTitle:loader (unchanged)

Three new test cases:
- 'wraps + escapes prefix values with attached parens' (foo(bar), foo))
- 'wraps + escapes prefix values with attached brackets' (list[0])
- 'keeps user-wrapped parens distinct from attached parens'
  (regression-tests that title:(scope) still passes through bare)

All 59 ui tests pass (+30 from the recent escape-hardening rounds).
0 svelte-check errors.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
After several rounds of escape-hardening for the prefix-routed
search-encoder the regex-based approach was becoming brittle. Each
prior fix (colon-in-value, attached parens, quoted-phrase splitting,
broader orphan reserved chars) added another corner case by tweaking
one of the two passes, and the surface had grown hard to reason about.

This commit replaces the 2-pass design with a single tokenizer plus
a per-type renderer.

The previous escapePrefixValues stacked:
1. A regex-replace pass over <field>:<value> clauses
2. A stateful char-walker pass for orphan tokens

New design — single-pass tokenizer + per-type renderer:

  Token grammar:
    ws           whitespace run
    bool-op      AND | OR | NOT (uppercase, standalone)
    quoted       "..."  — pass through unchanged
    field-clause <known-field>:<value> with value kind paren | quoted | bare
    bare         any other non-whitespace run

  Rendering:
    - ws, bool-op, quoted → raw passthrough
    - field-clause / paren    → ${field}:(escapeForQueryString(inner))
    - field-clause / quoted   → ${field}:"${inner}"
    - field-clause / bare     → bare if clean, else wrap+escape
    - bare                    → escape PREFIX_VALUE_RESERVED_RE chars

Each token type now has exactly ONE rendering rule. Future edge cases
(if any) need only either a new token kind or a tweak to the relevant
renderer, not a cross-cutting regex re-think.

Behavior preserved bit-for-bit: all 30 existing encoder tests pass
without modification. 0 svelte-check errors in any of the three
packages (ui / tracker-resources / view-resources).

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Prevents Lucene NOT-operator interpretation for inputs like \`title:-foo\`
or \`title:meeting -cancelled\` which were previously sent verbatim to the
Elasticsearch query_string parser and treated as boolean NOT clauses.

Two changes in renderToken (SearchInputAdvanced.encoder.ts):

1. field-clause / bare branch: when value.raw starts with '-', force
   the wrap+escape path so the minus becomes \\- inside parens. Example:
   title:-foo -> searchTitle:(\\-foo). Mid-token hyphens are untouched
   because they only hit this branch when value.raw does NOT start
   with '-' (so HULY-51 still passes through bare).

2. bare branch (orphan token): when tok.raw starts with '-', escape
   the leading minus explicitly, then run the regular reserved-char
   escape over the rest of the token. Example:
   title:meeting -cancelled -> searchTitle:meeting \\-cancelled.

Three new test groups in SearchInputAdvanced.test.ts:
- 'wraps + escapes prefix values starting with a hyphen'
  covers title:-foo and id:-WORK-1
- 'escapes a leading hyphen in orphan bare tokens after a prefix clause'
  covers title:meeting -cancelled
- 'preserves mid-token hyphens in field values and orphan tokens'
  regression for id:HULY-51 and title:meeting bug-fix

All 33 ui SearchInputAdvanced tests pass.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Replace review-round-specific section headers in
SearchInputAdvanced.test.ts with neutral descriptions of what each
suite covers. No behavior change; all 33 tests continue to pass.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…esign

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
@MichaelUray MichaelUray force-pushed the feat/tracker-filter-search-redesign branch from 1d14749 to f0d1ce9 Compare July 9, 2026 08:01
…import type block

svelte-check (ts) error at GanttView.svelte:41 after develop rebase:
'The type modifier cannot be used on a named import when import type
is used on its import statement.' The named members inside the
'import type { ... }' block carried redundant per-member 'type'
keywords. Drop them so svelte-check passes.

github-resources svelte-check failure was an unrelated CI flake
(branch touches no github files; green locally).

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Two blockers surfaced by 'rush fast-format --branch develop':

- packages/importer/src/importer/importer.ts: two
  @typescript-eslint/strict-boolean-expressions errors ('Unexpected any
  value in conditional') on success flags returned as 'any' by
  controlled-documents helpers. Use explicit '!== true' comparison. These
  lines are pre-existing on develop but were latent; the branch's edits to
  the importer package trigger a full-package lint that exposes them.

- GanttView.svelte: prettier reformatting (import indentation, per-member
  import splits, arrow-body expansion, redundant parens) using the repo
  rig prettier config.

fast-format now exits 0 with a clean working tree (only these two files).

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
The previous commit changed two 'if (!success)' checks to '!== true' to
silence a strict-boolean-expressions error. That error was a local
false positive: with @hcengineering/controlled-documents type outputs
missing locally, 'success' inferred as 'any'. In CI (types built)
'success' is boolean, so '!success' is correct and eslint's
no-unnecessary-boolean-literal-compare auto-reverts '!== true' → '!success',
leaving a non-empty git diff that fails the formatting job.

Keep only the GanttView.svelte prettier fix, which CI accepts.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…edesign

- FilterBar: restore "Clear filters" affordance for non-Tracker consumers
  (Chat/Contacts/Documents/Drive/Lead/Recruit/Task). The shared FilterButton
  redesign made the button always-add and moved clear-all into the
  Tracker-only InlineFilterChips, leaving those views with no way to drop
  all filters at once.
- InlineFilterChips: add `fill` mode for the full-width below-header strip
  (List/Kanban). The Gantt toolbar 22rem cap + flex-basis:0 measurement
  mis-read the row width as ~0 and collapsed still-fitting chips into the
  "+N" badge, hiding active filters and removing div.filter-section that the
  E2E selectors assert on. IssuesView passes `fill` for the below-header
  mount; Gantt keeps the capped/collapsing behaviour.
- tests(tracker.utils): setViewOrder now targets the first .ordering row;
  the new searchScope ViewOption adds a second .ordering entry to the
  View-Options popup, which made the old locator ambiguous.

Repairs uitest: chat filter-channels, tracker Component/Modified-by/Label
filter and layout ordering specs; unblocks the downstream issues/mentions
specs that were starved by the filter-test timeouts.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
The re-sync reactive guarded on `value !== _search`, which reads _search and
therefore re-runs on every local edit. Because the parent `value` prop lags
behind (updated only after the debounced emit), the block clobbered the
just-typed value back to the stale prop, erasing input mid-typing. Under
Playwright this surfaced as fill() then inputValue() == empty, so the tracker
search input never submitted (if (v === issueName) press Enter was skipped) and
the whole create->search->open E2E cluster timed out.

Extract the sync decision into propSyncValue (unit-tested) and drive it from a
reactive that depends only on the prop + a lastValue sentinel, never on _search,
so local input is preserved and the prop only overwrites it when it truly
changed.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Resolve FilterBar.svelte: adopt upstream canSaveFilteredView + AccountRole
(now gating the SaveAs row) while keeping the branch's filter-search redesign
(hideChips, inline chips, void-typed onChange). Drop the unused getResource
import.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
The filter-search redesign adds a searchScope dropdown to the View-Options
popup, so `.ordering button` now matches two buttons (Order-by + searchScope)
and trips Playwright strict mode. Target the first (Order-by), mirroring the
existing fix in tracker.utils.ts.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…it search

On a zero-hit search the SearchEmptyState card replaces the viewlet. That
overrode the explicit "show empty groups" view option: with shouldShowAll on
and a search that matches nothing, the (empty) status groups / Kanban columns
were hidden behind the card. Make the user's option authoritative — the card
only replaces the viewlet when shouldShowAll is not true. Extracted the policy
into shouldReplaceViewletWithEmptyState (unit-tested).

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
checkIssuePresenceInTabs asserted on the whole panel, so on a zero-hit tab it
matched the SearchEmptyState card's echoed search term ("No issues found for
<name>"). Assert on the actual result anchor (issueAnchorByName) instead.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant