feat(tracker): field-prefixed search, inline filter chips and zero-hit empty state (#10998)

* 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 <michaeluray@users.noreply.github.com>

* 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 <michaeluray@users.noreply.github.com>

* 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 <michaeluray@users.noreply.github.com>

---------

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Co-authored-by: Michael Uray <michaeluray@users.noreply.github.com>
Co-authored-by: Artyom Savchenko <armisav@gmail.com>
This commit is contained in:
Michael Uray
2026-08-11 19:09:33 +07:00
committed by GitHub
co-authored by Michael Uray Artyom Savchenko
parent 5a3d673e84
commit 1be6047c8a
76 changed files with 3341 additions and 195 deletions
@@ -752,6 +752,33 @@ export interface FullTextSearchContext extends Doc {
forceIndex?: boolean 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 * @public
*/ */
@@ -1008,3 +1008,13 @@ export function toRank (str: string | undefined): Rank | undefined {
} }
return '0|' + str.replaceAll(/[-:_]/g, '').toLowerCase() 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, '\\$&')
}
@@ -19,6 +19,8 @@ import {
Class, Class,
Doc, Doc,
DocumentQuery, DocumentQuery,
escapeRegExp,
fullTextSearchFields,
MeasureContext, MeasureContext,
Ref, Ref,
SearchOptions, SearchOptions,
@@ -314,17 +316,80 @@ class ElasticAdapter implements FullTextAdapter {
from: number | undefined from: number | undefined
): Promise<IndexedDoc[]> { ): Promise<IndexedDoc[]> {
if (query.$search === undefined) return [] 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:<id>`, `modifiedBy:<id>`, `attachedTo:<id>`),
// 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: <these ids> }, ...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 = { const request: any = {
bool: { bool: {
must: [ must: [
{ queryBlock,
simple_query_string: {
query: query.$search,
analyze_wildcard: true,
flags: 'OR|PREFIX|PHRASE|FUZZY|NOT|ESCAPE',
default_operator: 'and'
}
},
{ {
term: { term: {
workspaceId workspaceId
+41 -7
View File
@@ -38,16 +38,13 @@ import { DOMAIN_SPACE } from '@hcengineering/model-core'
import { DOMAIN_TASK, migrateDefaultStatusesBase } from '@hcengineering/model-task' import { DOMAIN_TASK, migrateDefaultStatusesBase } from '@hcengineering/model-task'
import tags from '@hcengineering/tags' import tags from '@hcengineering/tags'
import task from '@hcengineering/task' import task from '@hcengineering/task'
import tracker, { import { type Issue, type IssueStatus, type Project, TimeReportDayType, trackerId } from '@hcengineering/tracker'
type Issue, import view, { type ViewOptionModel } from '@hcengineering/view'
type IssueStatus,
type Project,
TimeReportDayType,
trackerId
} from '@hcengineering/tracker'
import { classicIssueTaskStatuses } from '.' import { classicIssueTaskStatuses } from '.'
import tracker from './plugin'
import { DOMAIN_TRACKER } from './types' import { DOMAIN_TRACKER } from './types'
import { issuesOptions } from './viewlets'
async function createDefaultProject (tx: TxOperations): Promise<void> { async function createDefaultProject (tx: TxOperations): Promise<void> {
const current = await tx.findOne(tracker.class.Project, { const current = await tx.findOne(tracker.class.Project, {
@@ -181,6 +178,39 @@ export async function migrateAddStartDate (client: MigrationClient): Promise<voi
) )
} }
// SEARCH_VIEW_OPTIONS (showQuickModeSelector / searchScope / searchHighlight)
// are appended to issuesOptions(), which feeds the stored List (IssueList) and
// Kanban (IssueKanban) viewlet docs. builder.createDoc is idempotent, so
// existing workspaces never pick up the new `other` entries on upgrade; merge
// the missing keys into both viewlets by `key`. Idempotent — a re-run (or a
// partially-migrated workspace) is a no-op.
async function addSearchViewOptions (client: MigrationUpgradeClient): Promise<void> {
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<void> { async function migrateDefaultStatuses (client: MigrationClient, logger: ModelLogger): Promise<void> {
const defaultTypeId = tracker.ids.ClassingProjectType const defaultTypeId = tracker.ids.ClassingProjectType
const typeDescriptor = tracker.descriptors.ProjectType const typeDescriptor = tracker.descriptors.ProjectType
@@ -425,6 +455,10 @@ export const trackerOperation: MigrateOperation = {
const tx = new TxOperations(client, core.account.System) const tx = new TxOperations(client, core.account.System)
await createDefaults(tx) await createDefaults(tx)
} }
},
{
state: 'add-search-view-options',
func: addSearchViewOptions
} }
]) ])
} }
+33 -1
View File
@@ -23,6 +23,37 @@ import tags from '@hcengineering/tags'
import { type ViewOptionModel, type BuildModelKey, type ViewOptionsModel } from '@hcengineering/view' import { type ViewOptionModel, type BuildModelKey, type ViewOptionsModel } from '@hcengineering/view'
import tracker from './plugin' 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 => ({ export const issuesOptions = (kanban: boolean): ViewOptionsModel => ({
groupBy: [ groupBy: [
'status', 'status',
@@ -75,7 +106,8 @@ export const issuesOptions = (kanban: boolean): ViewOptionsModel => ({
action: view.function.HideArchived, action: view.function.HideArchived,
label: view.string.HideArchived label: view.string.HideArchived
}, },
...(!kanban ? [showColorsViewOption] : []) ...(!kanban ? [showColorsViewOption] : []),
...SEARCH_VIEW_OPTIONS
] ]
}) })
@@ -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 }
])
})
})
@@ -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()
})
})
@@ -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 }))
}
@@ -0,0 +1,37 @@
<!--
// Copyright © 2026 Hardcore Engineering Inc.
// SPDX-License-Identifier: EPL-2.0
-->
<!--
Wraps occurrences of `query` inside `text` with a <mark> element.
Case-insensitive substring match. Consumer should pass the RAW user
search text (not the encoded $search wire-form) — see
HighlightedText.helpers.ts for the prefix-stripping fall-back when an
encoded string slips through.
-->
<script lang="ts">
import { splitHighlightSegments } from './HighlightedText.helpers'
export let text: string = ''
export let query: string = ''
/**
* `enabled=false` short-circuits the highlight pass — consumers wire this
* to the searchHighlight Customize-View toggle so users can opt out of
* the marker styling without losing search itself.
*/
export let enabled: boolean = true
$: segments = enabled ? splitHighlightSegments(text, query) : [{ text, match: false }]
</script>
{#each segments as seg}
{#if seg.match}<mark>{seg.text}</mark>{:else}{seg.text}{/if}
{/each}
<style>
mark {
background: var(--global-warning-BackgroundColor, #fff3a3);
color: inherit;
padding: 0 0.05em;
border-radius: 2px;
}
</style>
@@ -28,9 +28,14 @@
name={'modeSelector'} name={'modeSelector'}
items={modeList} items={modeList}
selected={props.mode} selected={props.mode}
disabled={props.disabled ?? false}
tooltip={props.disabled === true && props.disabledReason !== undefined
? { label: props.disabledReason, direction: 'bottom' }
: undefined}
{kind} {kind}
{onlyIcons} {onlyIcons}
on:select={(result) => { on:select={(result) => {
if (props.disabled === true) return
if (result.detail !== undefined && result.detail.action) result.detail.action() if (result.detail !== undefined && result.detail.action) result.detail.action()
}} }}
/> />
@@ -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:(<bare>)
* scope=title-description → (searchTitle:(<bare>) OR description.plain:(<bare>))
* scope=all → <bare> (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<string, string> = {
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 `<known-field>:<value>` where <value> 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
}
}
@@ -0,0 +1,255 @@
<!--
// Copyright © 2026 Hardcore Engineering Inc.
// SPDX-License-Identifier: EPL-2.0
-->
<!--
SearchInput with prefix-operator + scope-wrapping support.
Wraps the existing SearchInput visual style — copies its scoped
style block verbatim, since Svelte does not allow cross-component
@import of scoped styles, so the duplication is intentional.
Emits a `change` event with BOTH detail.raw (what the user typed) and
detail.encoded (the ES query_string form). IssuesView binds the input
to `raw` (so the input field never shows the encoded form) and pipes
`encoded` into the find-query.
-->
<script lang="ts">
import { createEventDispatcher, onDestroy, onMount } from 'svelte'
import type { IntlString } from '@hcengineering/platform'
import { translateCB } from '@hcengineering/platform'
import { themeStore } from '@hcengineering/theme'
import IconClose from './icons/Close.svelte'
import IconSearch from './icons/Search.svelte'
import plugin from '../plugin'
import { encodeSearch, type SearchScope } from './SearchInputAdvanced.encoder'
import { propSyncValue } from './SearchInputAdvanced.sync'
/**
* `value` is the RAW user input. NEVER write the encoded wire-string
* back into it — otherwise on the next debounce the user sees
* `searchTitle:(loader)` in their own input field, which is broken UX.
*
* The component dispatches a `change` event with BOTH:
* detail.raw — exactly what the user typed (after trim)
* detail.encoded — encodeSearch(raw, scope), the wire-form for $search
*/
export let value: string | undefined = undefined
export let placeholder: IntlString = plugin.string.Search
export let placeholderParam: any | undefined = undefined
export let collapsed: boolean = false
export let autoFocus: boolean = false
export let width: string | undefined = undefined
export let delay: number = 500
export let scope: SearchScope = 'all'
let input: HTMLInputElement
let phTranslate: string = ''
let _search = value ?? ''
const dispatch = createEventDispatcher<{ change: { raw: string, encoded: string } }>()
let timer: any
$: translateCB(placeholder, placeholderParam ?? {}, $themeStore.language, (res) => {
phTranslate = res
})
// Re-sync from the parent prop ONLY when the prop itself changes — the
// decision lives in `propSyncValue` (see its doc). This block must never read
// `_search`, or Svelte would re-run it on every local edit and clobber the
// just-typed value back to the debounce-lagged prop, erasing input mid-typing
// (broke tracker create→search→open).
let lastValue: string | undefined = value
$: {
const synced = propSyncValue(value, lastValue)
lastValue = value
if (synced !== undefined) _search = synced
}
// Re-emit when the scope prop changes so a Customize-View toggle from
// e.g. `all` to `title` immediately re-encodes the current input —
// otherwise the UI advertises "Title search" while the query is still
// running with the old encoding until the user types again. Guarded
// against the mount-time call (when there is no input yet) so we don't
// emit an empty initial event over a freshly bound parent.
//
// Compare + update must live in the SAME reactive block: Svelte
// topologically sorts `$:` blocks by dependency, so splitting compare
// and update into two `$:` lines lets Svelte run the assignment first
// (because the compare reads what the assignment writes), leaving the
// compare with `lastEmittedScope === scope` and skipping the emit.
let lastEmittedScope: SearchScope | undefined
$: {
if (lastEmittedScope !== undefined && lastEmittedScope !== scope && _search.trim() !== '') {
clearTimeout(timer)
emit()
}
lastEmittedScope = scope
}
function emit (): void {
const raw = _search.trim()
const encoded = encodeSearch(raw, scope)
dispatch('change', { raw, encoded })
}
function restart (): void {
clearTimeout(timer)
timer = setTimeout(emit, delay)
}
function clearSearch (): void {
_search = ''
clearTimeout(timer)
emit()
input?.focus()
}
onDestroy(() => {
clearTimeout(timer)
})
onMount(() => {
if (autoFocus && input != null) input.focus()
})
</script>
<label class="searchInput-wrapper" class:collapsed class:filled={_search !== ''} style:width>
<div class="searchInput-icon"><IconSearch size={'small'} /></div>
<input
bind:this={input}
type="text"
class="font-regular-14"
bind:value={_search}
placeholder={phTranslate}
autocomplete="off"
spellcheck="false"
on:input={restart}
on:keydown={(evt) => {
if (evt.key === 'Enter') {
clearTimeout(timer)
emit()
}
if (evt.key === 'Escape' && _search !== '') {
evt.preventDefault()
clearSearch()
}
}}
/>
<!-- Clear-button parallel to SearchInput.svelte:87. The :not(:placeholder-shown)
CSS selector below toggles visibility based on the input value. -->
<button type="button" class="searchInput-button" aria-label="Clear search" tabindex="-1" on:click={clearSearch}>
<div><IconClose size={'small'} /></div>
</button>
</label>
<style lang="scss">
.searchInput-wrapper {
display: flex;
justify-content: stretch;
align-items: center;
align-self: stretch;
padding: 0 var(--spacing-0_5) 0 0;
height: var(--global-small-Size);
min-width: var(--global-small-Size);
background-color: var(--theme-button-default);
border-radius: var(--small-BorderRadius);
box-shadow: inset 0 0 0 1px var(--theme-button-border);
transition: max-width 0.2s;
cursor: text;
.searchInput-icon,
.searchInput-button {
display: flex;
justify-content: center;
align-items: center;
flex-shrink: 0;
padding: 0;
background-color: transparent;
border: none;
div {
width: var(--global-min-Size);
height: var(--global-min-Size);
}
}
.searchInput-icon {
margin: 0 var(--spacing-0_5) 0 0;
width: var(--global-small-Size);
height: var(--global-small-Size);
color: var(--input-search-IconColor);
border-radius: var(--small-BorderRadius);
outline: none;
cursor: text;
&:active,
&:focus {
background-color: transparent;
}
}
.searchInput-button {
visibility: hidden;
width: var(--global-extra-small-Size);
height: var(--global-extra-small-Size);
color: var(--global-primary-TextColor);
border-radius: var(--extra-small-BorderRadius);
cursor: pointer;
&:hover {
background-color: var(--button-tertiary-hover-BackgroundColor);
}
&:active {
background-color: var(--button-tertiary-active-BackgroundColor);
border-color: var(--button-menu-active-BorderColor);
}
}
input {
margin: 0;
margin-right: var(--spacing-1_5);
padding: 0;
width: 100%;
height: 100%;
color: var(--input-TextColor);
caret-color: var(--global-focus-BorderColor);
background-color: transparent;
border: none;
outline: none;
appearance: none;
&::placeholder {
color: var(--theme-trans-color);
}
&:not(:placeholder-shown) + .searchInput-button {
visibility: visible;
}
}
&:hover {
background-color: var(--input-hover-BackgroundColor);
input::placeholder {
color: var(--theme-darker-color);
}
}
&:active,
&:focus-within {
padding: 0 var(--spacing-0_5) 0 0;
background-color: var(--input-BackgroundColor);
outline: 2px solid var(--global-focus-BorderColor);
outline-offset: 2px;
input::placeholder {
color: var(--theme-darker-color);
}
}
&.collapsed:not(:focus-within, :active, .filled) {
padding: 0;
max-width: var(--global-small-Size);
.searchInput-icon {
cursor: pointer;
}
input:not(:placeholder-shown) + .searchInput-button {
visibility: hidden;
}
}
}
</style>
@@ -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
}
+9 -2
View File
@@ -5,7 +5,7 @@
// //
import { createEventDispatcher } from 'svelte' import { createEventDispatcher } from 'svelte'
import type { TabItem } from '../types' import type { LabelAndProps, TabItem } from '../types'
import SwitcherBase from './SwitcherBase.svelte' import SwitcherBase from './SwitcherBase.svelte'
export let items: TabItem[] export let items: TabItem[]
@@ -13,6 +13,11 @@
export let kind: 'nuance' | 'subtle' = 'nuance' export let kind: 'nuance' | 'subtle' = 'nuance'
export let name: string export let name: string
export let onlyIcons: boolean = false 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() const dispatch = createEventDispatcher()
</script> </script>
@@ -23,14 +28,16 @@
id={item.id} id={item.id}
{name} {name}
{kind} {kind}
{disabled}
checked={selected === item.id} checked={selected === item.id}
icon={item.icon} icon={item.icon}
color={item.color} color={item.color}
title={onlyIcons ? undefined : item.label} title={onlyIcons ? undefined : item.label}
label={onlyIcons ? undefined : item.labelIntl} label={onlyIcons ? undefined : item.labelIntl}
labelParams={onlyIcons ? undefined : item.labelParams} 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={() => { on:change={() => {
if (disabled) return
dispatch('select', item) dispatch('select', item)
if (item.action !== undefined) item.action() if (item.action !== undefined) item.action()
}} }}
+26 -2
View File
@@ -21,12 +21,19 @@
export let name: string export let name: string
export let checked: boolean = false export let checked: boolean = false
export let tooltip: LabelAndProps | undefined = undefined export let tooltip: LabelAndProps | undefined = undefined
export let disabled: boolean = false
$: woTitle = title === undefined && label === undefined $: woTitle = title === undefined && label === undefined
</script> </script>
<label use:tp={tooltip} class="switcher-element__wrapper" data-view={tooltip?.label} data-id={`tab-${id}`}> <label
<input type="radio" class="switcher" {name} {checked} on:change /> use:tp={tooltip}
class="switcher-element__wrapper"
class:disabled
data-view={tooltip?.label}
data-id={`tab-${id}`}
>
<input type="radio" class="switcher" {name} {checked} {disabled} on:change />
<div class="switcher-element {kind}" class:woTitle> <div class="switcher-element {kind}" class:woTitle>
{#if icon}<div class="icon"><Icon {icon} size={'small'} fill={color} /></div>{/if} {#if icon}<div class="icon"><Icon {icon} size={'small'} fill={color} /></div>{/if}
{#if label}<span><Label {label} params={labelParams} /></span>{/if} {#if label}<span><Label {label} params={labelParams} /></span>{/if}
@@ -115,4 +122,21 @@
color: var(--global-primary-TextColor); color: var(--global-primary-TextColor);
} }
} }
/* Disabled state keeps pointer-events so the tooltip (which carries the
"why is this disabled" reason) stays reachable on hover. The native
`disabled` attribute on the underlying <input type="radio"> still
blocks the actual selection — see :30. */
.switcher-element__wrapper.disabled {
opacity: 0.4;
}
.switcher-element__wrapper.disabled .switcher-element {
cursor: not-allowed;
}
.switcher-element__wrapper.disabled:hover .switcher-element {
background-color: transparent;
}
.switcher-element__wrapper.disabled:hover .switcher-element .icon,
.switcher-element__wrapper.disabled:hover .switcher-element span {
color: var(--global-secondary-TextColor);
}
</style> </style>
+3
View File
@@ -113,6 +113,9 @@ export { default as EditWithIcon } from './components/EditWithIcon.svelte'
export { default as SearchEdit } from './components/SearchEdit.svelte' export { default as SearchEdit } from './components/SearchEdit.svelte'
export { default as SearchPicker } from './components/SearchPicker.svelte' export { default as SearchPicker } from './components/SearchPicker.svelte'
export { default as SearchInput } from './components/SearchInput.svelte' export { default as SearchInput } from './components/SearchInput.svelte'
export { default as SearchInputAdvanced } from './components/SearchInputAdvanced.svelte'
export { encodeSearch, type SearchScope } from './components/SearchInputAdvanced.encoder'
export { default as HighlightedText } from './components/HighlightedText.svelte'
export { default as Switcher } from './components/Switcher.svelte' export { default as Switcher } from './components/Switcher.svelte'
export { default as SwitcherBase } from './components/SwitcherBase.svelte' export { default as SwitcherBase } from './components/SwitcherBase.svelte'
export { default as Chip } from './components/Chip.svelte' export { default as Chip } from './components/Chip.svelte'
+4
View File
@@ -229,6 +229,10 @@ export interface IModeSelector<Mode extends string = string> {
mode: Mode mode: Mode
config: Array<[Mode, IntlString, object]> config: Array<[Mode, IntlString, object]>
onChange: (mode: Mode) => void onChange: (mode: Mode) => void
/** When set, all mode buttons render in a disabled style and ignore clicks. */
disabled?: boolean
/** Tooltip shown on hover when `disabled` is true. */
disabledReason?: IntlString
} }
/** /**
+12 -1
View File
@@ -283,7 +283,18 @@
"UnsetParentIssue": "Odebrat nadřazený úkol", "UnsetParentIssue": "Odebrat nadřazený úkol",
"ForbidCreateProjectPermission": "Zakázat vytvoření projektu", "ForbidCreateProjectPermission": "Zakázat vytvoření projektu",
"ForbidCreateProjectPermissionDescription": "Zakazuje uživatelům vytvářet nové projekty", "ForbidCreateProjectPermissionDescription": "Zakazuje uživatelům vytvářet nové projekty",
"AllowCreatingIssues": "Povolit vytváření úkolů" "AllowCreatingIssues": "Povolit vytváření úkolů",
"ModeSelectorDisabledByFilter": "Stav je řízen aktivním filtrem. Chcete-li použít tuto zkratku, odstraňte filtr stavu.",
"ShowQuickModeSelector": "Rychlý filtr: Vše / Aktivní / Návrhy",
"SearchScopeLabel": "Hledat v…",
"SearchScopeTitle": "Pouze název",
"SearchScopeTitleDescription": "Název + popis",
"SearchScopeAll": "Název + popis + komentáře",
"SearchHighlight": "Zvýraznit shody hledání",
"SearchEmptyTitle": "Pro „{query}\" nebyly nalezeny žádné úkoly",
"SearchEmptyActiveFilters": "Aktivní filtry: {filters}",
"SearchEmptyClearFilters": "Hledat bez filtrů",
"SearchEmptyAllProjects": "Hledat ve všech projektech"
}, },
"status": {} "status": {}
} }
+12 -1
View File
@@ -293,7 +293,18 @@
"UnsetParentIssue": "Übergeordnete Aufgabe entfernen", "UnsetParentIssue": "Übergeordnete Aufgabe entfernen",
"ForbidCreateProjectPermission": "Projekterstellung verbieten", "ForbidCreateProjectPermission": "Projekterstellung verbieten",
"ForbidCreateProjectPermissionDescription": "Verbietet Benutzern das Erstellen neuer Projekte", "ForbidCreateProjectPermissionDescription": "Verbietet Benutzern das Erstellen neuer Projekte",
"AllowCreatingIssues": "Erstellen von Aufgaben erlauben" "AllowCreatingIssues": "Erstellen von Aufgaben erlauben",
"ModeSelectorDisabledByFilter": "Status wird über aktive Filter gesteuert. Entferne den Status-Filter, um die Schnellauswahl zu nutzen.",
"ShowQuickModeSelector": "Schnellfilter: All / Active / Backlog",
"SearchScopeLabel": "Suche in…",
"SearchScopeTitle": "Nur Titel",
"SearchScopeTitleDescription": "Titel + Beschreibung",
"SearchScopeAll": "Titel + Beschreibung + Kommentare",
"SearchHighlight": "Treffer hervorheben",
"SearchEmptyTitle": "Keine Treffer für \"{query}\"",
"SearchEmptyActiveFilters": "Aktive Filter: {filters}",
"SearchEmptyClearFilters": "Suche ohne Filter",
"SearchEmptyAllProjects": "In allen Projekten suchen"
}, },
"status": {} "status": {}
} }
+12 -1
View File
@@ -293,7 +293,18 @@
"UnsetParentIssue": "Unset parent issue", "UnsetParentIssue": "Unset parent issue",
"ForbidCreateProjectPermission": "Forbid create project", "ForbidCreateProjectPermission": "Forbid create project",
"ForbidCreateProjectPermissionDescription": "Forbid users creating new projects", "ForbidCreateProjectPermissionDescription": "Forbid users creating new projects",
"AllowCreatingIssues": "Allow creating issues" "AllowCreatingIssues": "Allow creating issues",
"ModeSelectorDisabledByFilter": "Status is controlled by an active filter. Remove the status filter to use this shortcut.",
"ShowQuickModeSelector": "Quick filter: All / Active / Backlog",
"SearchScopeLabel": "Search in…",
"SearchScopeTitle": "Title only",
"SearchScopeTitleDescription": "Title + Description",
"SearchScopeAll": "Title + Description + Comments",
"SearchHighlight": "Highlight search matches",
"SearchEmptyTitle": "No issues found for \"{query}\"",
"SearchEmptyActiveFilters": "Active filters: {filters}",
"SearchEmptyClearFilters": "Search without filters",
"SearchEmptyAllProjects": "Search in all projects"
}, },
"status": {} "status": {}
} }
+12 -1
View File
@@ -276,7 +276,18 @@
"UnsetParentIssue": "Unset parent issue", "UnsetParentIssue": "Unset parent issue",
"ForbidCreateProjectPermission": "Prohibir crear proyecto", "ForbidCreateProjectPermission": "Prohibir crear proyecto",
"ForbidCreateProjectPermissionDescription": "Prohíbe a los usuarios crear nuevos proyectos", "ForbidCreateProjectPermissionDescription": "Prohíbe a los usuarios crear nuevos proyectos",
"AllowCreatingIssues": "Permitir crear incidencias" "AllowCreatingIssues": "Permitir crear incidencias",
"ModeSelectorDisabledByFilter": "El estado está controlado por un filtro activo. Elimina el filtro de estado para usar este atajo.",
"ShowQuickModeSelector": "Filtro rápido: Todos / Activos / Atrasados",
"SearchScopeLabel": "Buscar en…",
"SearchScopeTitle": "Solo título",
"SearchScopeTitleDescription": "Título + descripción",
"SearchScopeAll": "Título + descripción + comentarios",
"SearchHighlight": "Resaltar coincidencias de búsqueda",
"SearchEmptyTitle": "No se encontraron tareas para \"{query}\"",
"SearchEmptyActiveFilters": "Filtros activos: {filters}",
"SearchEmptyClearFilters": "Buscar sin filtros",
"SearchEmptyAllProjects": "Buscar en todos los proyectos"
}, },
"status": {} "status": {}
} }
+12 -1
View File
@@ -276,7 +276,18 @@
"UnsetParentIssue": "Désélectionner l'issue parent", "UnsetParentIssue": "Désélectionner l'issue parent",
"ForbidCreateProjectPermission": "Interdire la création de projet", "ForbidCreateProjectPermission": "Interdire la création de projet",
"ForbidCreateProjectPermissionDescription": "Interdit aux utilisateurs de créer de nouveaux projets", "ForbidCreateProjectPermissionDescription": "Interdit aux utilisateurs de créer de nouveaux projets",
"AllowCreatingIssues": "Autoriser la création d'issues" "AllowCreatingIssues": "Autoriser la création d'issues",
"ModeSelectorDisabledByFilter": "Le statut est contrôlé par un filtre actif. Supprimez le filtre de statut pour utiliser ce raccourci.",
"ShowQuickModeSelector": "Filtre rapide : Tous / Actif / Backlog",
"SearchScopeLabel": "Rechercher dans…",
"SearchScopeTitle": "Titre uniquement",
"SearchScopeTitleDescription": "Titre + description",
"SearchScopeAll": "Titre + description + commentaires",
"SearchHighlight": "Surligner les correspondances de recherche",
"SearchEmptyTitle": "Aucun problème trouvé pour \"{query}\"",
"SearchEmptyActiveFilters": "Filtres actifs : {filters}",
"SearchEmptyClearFilters": "Rechercher sans filtres",
"SearchEmptyAllProjects": "Rechercher dans tous les projets"
}, },
"status": {} "status": {}
} }
+12 -1
View File
@@ -276,7 +276,18 @@
"UnsetParentIssue": "Annulla l'issue genitore", "UnsetParentIssue": "Annulla l'issue genitore",
"ForbidCreateProjectPermission": "Vieta creazione progetto", "ForbidCreateProjectPermission": "Vieta creazione progetto",
"ForbidCreateProjectPermissionDescription": "Vieta agli utenti di creare nuovi progetti", "ForbidCreateProjectPermissionDescription": "Vieta agli utenti di creare nuovi progetti",
"AllowCreatingIssues": "Consenti la creazione di issue" "AllowCreatingIssues": "Consenti la creazione di issue",
"ModeSelectorDisabledByFilter": "Lo stato è controllato da un filtro attivo. Rimuovi il filtro di stato per usare questa scorciatoia.",
"ShowQuickModeSelector": "Filtro rapido: Tutti / Attivi / Backlog",
"SearchScopeLabel": "Cerca in…",
"SearchScopeTitle": "Solo titolo",
"SearchScopeTitleDescription": "Titolo + descrizione",
"SearchScopeAll": "Titolo + descrizione + commenti",
"SearchHighlight": "Evidenzia le corrispondenze di ricerca",
"SearchEmptyTitle": "Nessuna issue trovata per \"{query}\"",
"SearchEmptyActiveFilters": "Filtri attivi: {filters}",
"SearchEmptyClearFilters": "Cerca senza filtri",
"SearchEmptyAllProjects": "Cerca in tutti i progetti"
}, },
"status": {} "status": {}
} }
+12 -1
View File
@@ -276,7 +276,18 @@
"UnsetParentIssue": "親イシューの設定を解除", "UnsetParentIssue": "親イシューの設定を解除",
"ForbidCreateProjectPermission": "プロジェクト作成禁止", "ForbidCreateProjectPermission": "プロジェクト作成禁止",
"ForbidCreateProjectPermissionDescription": "ユーザーが新しいプロジェクトを作成することを禁止します", "ForbidCreateProjectPermissionDescription": "ユーザーが新しいプロジェクトを作成することを禁止します",
"AllowCreatingIssues": "イシューの作成を許可" "AllowCreatingIssues": "イシューの作成を許可",
"ModeSelectorDisabledByFilter": "ステータスはアクティブなフィルターで制御されています。このショートカットを使用するにはステータスフィルターを解除してください。",
"ShowQuickModeSelector": "クイックフィルター:すべて / アクティブ / バックログ",
"SearchScopeLabel": "検索対象…",
"SearchScopeTitle": "タイトルのみ",
"SearchScopeTitleDescription": "タイトル + 説明",
"SearchScopeAll": "タイトル + 説明 + コメント",
"SearchHighlight": "検索結果をハイライト表示",
"SearchEmptyTitle": "\"{query}\" に一致するイシューが見つかりません",
"SearchEmptyActiveFilters": "アクティブなフィルター:{filters}",
"SearchEmptyClearFilters": "フィルターなしで検索",
"SearchEmptyAllProjects": "すべてのプロジェクトを検索"
}, },
"status": {} "status": {}
} }
+12 -1
View File
@@ -276,7 +276,18 @@
"UnsetParentIssue": "상위 이슈 설정 해제", "UnsetParentIssue": "상위 이슈 설정 해제",
"ForbidCreateProjectPermission": "프로젝트 생성 금지", "ForbidCreateProjectPermission": "프로젝트 생성 금지",
"ForbidCreateProjectPermissionDescription": "사용자의 새 프로젝트 생성을 금지", "ForbidCreateProjectPermissionDescription": "사용자의 새 프로젝트 생성을 금지",
"AllowCreatingIssues": "이슈 생성 허용" "AllowCreatingIssues": "이슈 생성 허용",
"ModeSelectorDisabledByFilter": "상태가 활성 필터로 제어되고 있습니다. 이 단축키를 사용하려면 상태 필터를 제거하세요.",
"ShowQuickModeSelector": "빠른 필터: 전체 / 활성 / 백로그",
"SearchScopeLabel": "검색 범위…",
"SearchScopeTitle": "제목만",
"SearchScopeTitleDescription": "제목 + 설명",
"SearchScopeAll": "제목 + 설명 + 댓글",
"SearchHighlight": "검색어 강조 표시",
"SearchEmptyTitle": "\"{query}\"에 대한 이슈를 찾을 수 없습니다",
"SearchEmptyActiveFilters": "활성 필터: {filters}",
"SearchEmptyClearFilters": "필터 없이 검색",
"SearchEmptyAllProjects": "모든 프로젝트에서 검색"
}, },
"status": {} "status": {}
} }
+12 -1
View File
@@ -276,7 +276,18 @@
"UnsetParentIssue": "Desmarcar problema pai", "UnsetParentIssue": "Desmarcar problema pai",
"ForbidCreateProjectPermission": "Proibir criação de projeto", "ForbidCreateProjectPermission": "Proibir criação de projeto",
"ForbidCreateProjectPermissionDescription": "Proíbe os usuários de criar novos projetos", "ForbidCreateProjectPermissionDescription": "Proíbe os usuários de criar novos projetos",
"AllowCreatingIssues": "Permitir criar problemas" "AllowCreatingIssues": "Permitir criar problemas",
"ModeSelectorDisabledByFilter": "O estado é controlado por um filtro ativo. Remova o filtro de estado para usar este atalho.",
"ShowQuickModeSelector": "Filtro rápido: Todos / Ativos / Backlog",
"SearchScopeLabel": "Procurar em…",
"SearchScopeTitle": "Apenas título",
"SearchScopeTitleDescription": "Título + descrição",
"SearchScopeAll": "Título + descrição + comentários",
"SearchHighlight": "Realçar correspondências da procura",
"SearchEmptyTitle": "Nenhum problema encontrado para \"{query}\"",
"SearchEmptyActiveFilters": "Filtros ativos: {filters}",
"SearchEmptyClearFilters": "Procurar sem filtros",
"SearchEmptyAllProjects": "Procurar em todos os projetos"
}, },
"status": {} "status": {}
} }
+12 -1
View File
@@ -276,7 +276,18 @@
"UnsetParentIssue": "Desmarcar problema pai", "UnsetParentIssue": "Desmarcar problema pai",
"ForbidCreateProjectPermission": "Proibir criação de projeto", "ForbidCreateProjectPermission": "Proibir criação de projeto",
"ForbidCreateProjectPermissionDescription": "Proíbe os usuários de criar novos projetos", "ForbidCreateProjectPermissionDescription": "Proíbe os usuários de criar novos projetos",
"AllowCreatingIssues": "Permitir criar problemas" "AllowCreatingIssues": "Permitir criar problemas",
"ModeSelectorDisabledByFilter": "O estado é controlado por um filtro ativo. Remova o filtro de estado para usar este atalho.",
"ShowQuickModeSelector": "Filtro rápido: Todos / Ativos / Atraso",
"SearchScopeLabel": "Procurar em…",
"SearchScopeTitle": "Apenas título",
"SearchScopeTitleDescription": "Título + descrição",
"SearchScopeAll": "Título + descrição + comentários",
"SearchHighlight": "Realçar correspondências da procura",
"SearchEmptyTitle": "Nenhum problema encontrado para \"{query}\"",
"SearchEmptyActiveFilters": "Filtros ativos: {filters}",
"SearchEmptyClearFilters": "Procurar sem filtros",
"SearchEmptyAllProjects": "Procurar em todos os projetos"
}, },
"status": {} "status": {}
} }
+12 -1
View File
@@ -293,7 +293,18 @@
"UnsetParentIssue": "Снять родительскую задачу", "UnsetParentIssue": "Снять родительскую задачу",
"ForbidCreateProjectPermission": "Запретить создание проекта", "ForbidCreateProjectPermission": "Запретить создание проекта",
"ForbidCreateProjectPermissionDescription": "Запрещает пользователям создавать новые проекты", "ForbidCreateProjectPermissionDescription": "Запрещает пользователям создавать новые проекты",
"AllowCreatingIssues": "Разрешить создание задач" "AllowCreatingIssues": "Разрешить создание задач",
"ModeSelectorDisabledByFilter": "Статус задаётся активным фильтром. Уберите фильтр по статусу, чтобы воспользоваться этим переключателем.",
"ShowQuickModeSelector": "Быстрый фильтр: Все / Активные / Пул задач",
"SearchScopeLabel": "Искать в…",
"SearchScopeTitle": "Только заголовок",
"SearchScopeTitleDescription": "Заголовок + описание",
"SearchScopeAll": "Заголовок + описание + комментарии",
"SearchHighlight": "Подсвечивать совпадения",
"SearchEmptyTitle": "Не найдено задач по запросу «{query}»",
"SearchEmptyActiveFilters": "Активные фильтры: {filters}",
"SearchEmptyClearFilters": "Искать без фильтров",
"SearchEmptyAllProjects": "Искать во всех проектах"
}, },
"status": {} "status": {}
} }
+12 -1
View File
@@ -274,7 +274,18 @@
"IssueStatus": "Durum", "IssueStatus": "Durum",
"Extensions": "Uzantılar", "Extensions": "Uzantılar",
"UnsetParentIssue": "Üst sorunu kaldır", "UnsetParentIssue": "Üst sorunu kaldır",
"AllowCreatingIssues": "Sorun oluşturmaya izin ver" "AllowCreatingIssues": "Sorun oluşturmaya izin ver",
"ModeSelectorDisabledByFilter": "Durum, aktif bir filtre tarafından denetleniyor. Bu kısayolu kullanmak için durum filtresini kaldırın.",
"ShowQuickModeSelector": "Hızlı filtre: Tümü / Aktif / Backlog",
"SearchScopeLabel": "Şurada ara…",
"SearchScopeTitle": "Yalnızca başlık",
"SearchScopeTitleDescription": "Başlık + açıklama",
"SearchScopeAll": "Başlık + açıklama + yorumlar",
"SearchHighlight": "Arama eşleşmelerini vurgula",
"SearchEmptyTitle": "\"{query}\" için sorun bulunamadı",
"SearchEmptyActiveFilters": "Aktif filtreler: {filters}",
"SearchEmptyClearFilters": "Filtresiz ara",
"SearchEmptyAllProjects": "Tüm projelerde ara"
}, },
"status": {} "status": {}
} }
+12 -1
View File
@@ -293,7 +293,18 @@
"UnsetParentIssue": "取消父问题", "UnsetParentIssue": "取消父问题",
"ForbidCreateProjectPermission": "禁止创建项目", "ForbidCreateProjectPermission": "禁止创建项目",
"ForbidCreateProjectPermissionDescription": "禁止用户创建新项目", "ForbidCreateProjectPermissionDescription": "禁止用户创建新项目",
"AllowCreatingIssues": "允许创建问题" "AllowCreatingIssues": "允许创建问题",
"ModeSelectorDisabledByFilter": "状态由活动筛选器控制。移除状态筛选器以使用此快捷方式。",
"ShowQuickModeSelector": "快速筛选:全部 / 活跃 / 积压",
"SearchScopeLabel": "搜索范围…",
"SearchScopeTitle": "仅标题",
"SearchScopeTitleDescription": "标题 + 描述",
"SearchScopeAll": "标题 + 描述 + 评论",
"SearchHighlight": "高亮显示搜索匹配项",
"SearchEmptyTitle": "未找到与 \"{query}\" 匹配的问题",
"SearchEmptyActiveFilters": "活动筛选器:{filters}",
"SearchEmptyClearFilters": "不使用筛选器搜索",
"SearchEmptyAllProjects": "在所有项目中搜索"
}, },
"status": {} "status": {}
} }
@@ -0,0 +1,144 @@
//
// Copyright © 2026 Hardcore Engineering Inc.
// SPDX-License-Identifier: EPL-2.0
//
// Placeholder parity between the locale files.
//
// `makeLocalesTest` only checks that `ru` mirrors the *key structure* of `en`.
// It cannot catch a translation that keeps the key but changes the ICU
// arguments — e.g. a German string asking for `{issue}` while the component
// passes `{title}`, or a copy-pasted string asking for `{start}`/`{due}` while
// the caller only passes `{days}`. Those render as literal `{issue}` in the UI
// for every user of that locale, and nothing else in the build notices.
//
// So: for every key a locale defines, its ICU argument set must equal the
// English one.
import { readFileSync, readdirSync } from 'fs'
import { join } from 'path'
const langDir = join(__dirname, '../../lang')
/**
* Pre-existing gaps inherited from before this test was added. They are real
* bugs in those translations, but fixing them needs a native speaker, so they
* are pinned here instead of failing the build. Do not add new entries.
*/
const knownGaps = new Set(['tr:MoveAndDeleteMilestone'])
/**
* Collect the ICU argument names of a message.
*
* A regex is not enough: in `Delete {n, plural, =1 {issue} other {# issues}}`
* the `{issue}` is the *body* of a plural branch, not an argument. So this
* walks the message and only treats a `{` as an argument when it is in message
* position, recursing into sub-messages and honouring ICU's `'` escaping.
*/
function icuArguments (message: string): Set<string> {
const out = new Set<string>()
const end = message.length
function skipWs (i: number): number {
while (i < end && /\s/.test(message[i])) i++
return i
}
function readIdent (i: number): [string, number] {
const start = i
while (i < end && /[A-Za-z0-9_]/.test(message[i])) i++
return [message.slice(start, i), i]
}
function skipQuoted (i: number): number {
// `''` is a literal quote, `'...'` escapes braces.
if (i + 1 < end && message[i + 1] === "'") return i + 2
const close = message.indexOf("'", i + 1)
return close === -1 ? end : close + 1
}
// Reads an argument, `i` pointing just after its opening brace.
function parseArgument (i: number): number {
i = skipWs(i)
const [name, afterName] = readIdent(i)
i = afterName
if (name.length > 0) out.add(name)
i = skipWs(i)
if (i < end && message[i] === '}') return i + 1
if (i < end && message[i] === ',') {
i = skipWs(i + 1)
i = readIdent(i)[1] // argument type (plural, select, number, …)
i = skipWs(i)
if (i < end && message[i] === '}') return i + 1
if (i < end && message[i] === ',') {
i++
// Style / option list: every `{` from here starts a sub-message.
while (i < end) {
const c = message[i]
if (c === '{') {
i = parseMessage(i + 1, true)
continue
}
if (c === '}') return i + 1
i++
}
return i
}
}
while (i < end && message[i] !== '}') i++
return i + 1
}
// Reads a message. `nested` messages stop at their closing brace.
function parseMessage (i: number, nested: boolean): number {
while (i < end) {
const c = message[i]
if (c === "'") {
i = skipQuoted(i)
continue
}
if (c === '{') {
i = parseArgument(i + 1)
continue
}
if (c === '}' && nested) return i + 1
i++
}
return i
}
parseMessage(0, false)
return out
}
function loadStrings (file: string): Record<string, string> {
return JSON.parse(readFileSync(join(langDir, file), 'utf-8')).string
}
describe('tracker locales', () => {
const en = loadStrings('en.json')
const others = readdirSync(langDir)
.filter((f) => f.endsWith('.json') && f !== 'en.json')
.sort()
it('has locale files to compare', () => {
expect(others.length).toBeGreaterThan(0)
})
it.each(others)('%s uses the same ICU arguments as en', (file) => {
const lang = file.replace(/\.json$/, '')
const strings = loadStrings(file)
const mismatches: string[] = []
for (const [key, value] of Object.entries(strings)) {
const expected = en[key]
if (expected === undefined) continue // extra keys are makeLocalesTest's job
if (knownGaps.has(`${lang}:${key}`)) continue
const want = [...icuArguments(expected)].sort()
const got = [...icuArguments(value)].sort()
if (want.join(',') !== got.join(',')) {
mismatches.push(`${key}: en has {${want.join(', ')}}, ${lang} has {${got.join(', ')}}`)
}
}
expect(mismatches).toEqual([])
})
})
@@ -0,0 +1,34 @@
<!--
// Copyright © 2026 Hardcore Engineering Inc.
// SPDX-License-Identifier: EPL-2.0
-->
<script lang="ts">
import { getCurrentResolvedLocation, navigate } from '@hcengineering/ui'
import { SearchEmptyState } from '@hcengineering/view-resources'
import tracker from '../plugin'
export let searchText: string
export let activeFilters: string[] = []
// Tracker-specific broader-scope action: jump to the All Issues view.
// URL shape: /workbench/<ws>/tracker/<project|special>/<view> — replace the
// project-or-special slot with the 'allIssues' special view. The search
// text is intentionally not carried over (the view opens unfiltered).
function gotoAllIssues (): void {
const loc = getCurrentResolvedLocation()
if (loc.path.length < 3 || loc.path[2] !== 'tracker') return
loc.path[3] = 'allIssues'
loc.path.length = 4
navigate(loc)
}
</script>
<SearchEmptyState
{searchText}
{activeFilters}
titleLabel={tracker.string.SearchEmptyTitle}
activeFiltersLabel={tracker.string.SearchEmptyActiveFilters}
clearFiltersLabel={tracker.string.SearchEmptyClearFilters}
broaderScopeLabel={tracker.string.SearchEmptyAllProjects}
onBroaderScope={gotoAllIssues}
/>
@@ -18,8 +18,13 @@
import { taskTypeStore } from '@hcengineering/task-resources' import { taskTypeStore } from '@hcengineering/task-resources'
import TaskTypeIcon from '@hcengineering/task-resources/src/components/taskTypes/TaskTypeIcon.svelte' import TaskTypeIcon from '@hcengineering/task-resources/src/components/taskTypes/TaskTypeIcon.svelte'
import type { Issue } from '@hcengineering/tracker' import type { Issue } from '@hcengineering/tracker'
import { AnySvelteComponent, Icon, tooltip } from '@hcengineering/ui' import { AnySvelteComponent, HighlightedText, Icon, tooltip } from '@hcengineering/ui'
import { DocNavLink, ObjectMention } from '@hcengineering/view-resources' import {
DocNavLink,
ObjectMention,
rawSearchTextStore,
searchHighlightEnabledStore
} from '@hcengineering/view-resources'
import { ObjectPresenterType } from '@hcengineering/view' import { ObjectPresenterType } from '@hcengineering/view'
import tracker from '../../plugin' import tracker from '../../plugin'
@@ -65,7 +70,11 @@
</div> </div>
{/if} {/if}
<span class="overflow-label" class:select-text={!noSelect} title={value?.title}> <span class="overflow-label" class:select-text={!noSelect} title={value?.title}>
{value.identifier} <HighlightedText
text={value.identifier}
query={$rawSearchTextStore}
enabled={$searchHighlightEnabledStore}
/>
<slot name="details" /> <slot name="details" />
</span> </span>
</span> </span>
@@ -22,6 +22,7 @@
import { createEventDispatcher } from 'svelte' import { createEventDispatcher } from 'svelte'
import { TypeSelector, selectedTaskTypeStore, selectedTypeStore, taskTypeStore } from '@hcengineering/task-resources' import { TypeSelector, selectedTaskTypeStore, selectedTypeStore, taskTypeStore } from '@hcengineering/task-resources'
import { filterStore } from '@hcengineering/view-resources'
import tracker from '../../plugin' import tracker from '../../plugin'
import IssuesView from './IssuesView.svelte' import IssuesView from './IssuesView.svelte'
@@ -74,12 +75,34 @@
$: if (mode === undefined || (queries as any)[mode] === undefined) { $: if (mode === undefined || (queries as any)[mode] === undefined) {
;[[mode]] = config ;[[mode]] = config
} }
// ModeSelector ↔ Status-Filter conflict resolution. When the user has an
// explicit Status filter active (via FilterButton/InlineFilterChips), the
// ModeSelector greys out + shows a tooltip — status is now controlled by
// the filter chip rather than the All/Active/Backlog shortcut. Re-enables
// automatically when the status-filter is removed.
$: hasStatusFilter = $filterStore.some((f) => f.key.key === 'status')
// Reset the mode to 'all' on the RISING edge of hasStatusFilter
// so a "Status is Backlog" filter added while in Active mode doesn't leave
// the ModeSelector greyed-out with a stale "Active" highlight that lies
// about what is actually filtered. Falling-edge does nothing — the user's
// last explicit mode is preserved when the filter is removed.
let lastHadStatusFilter = false
$: {
if (hasStatusFilter && !lastHadStatusFilter && mode !== 'all') {
dispatch('action', { mode: 'all' })
}
lastHadStatusFilter = hasStatusFilter
}
$: if (mode !== undefined) { $: if (mode !== undefined) {
query = { ...(queries as any)[mode] } query = { ...(queries as any)[mode] }
modeSelectorProps = { modeSelectorProps = {
config, config,
mode, mode,
onChange: (newMode: string) => dispatch('action', { mode: newMode }) onChange: (newMode: string) => dispatch('action', { mode: newMode }),
disabled: hasStatusFilter,
disabledReason: tracker.string.ModeSelectorDisabledByFilter
} }
} }
@@ -3,11 +3,30 @@
import { Asset, IntlString, translateCB } from '@hcengineering/platform' import { Asset, IntlString, translateCB } from '@hcengineering/platform'
import { ComponentExtensions } from '@hcengineering/presentation' import { ComponentExtensions } from '@hcengineering/presentation'
import { Issue, TrackerEvents } from '@hcengineering/tracker' import { Issue, TrackerEvents } from '@hcengineering/tracker'
import { IModeSelector, themeStore } from '@hcengineering/ui' import { Button, IconAdd, IModeSelector, SearchInputAdvanced, showPopup, themeStore } from '@hcengineering/ui'
import { ViewOptions, Viewlet } from '@hcengineering/view' import { ViewOptions, Viewlet } from '@hcengineering/view'
import { FilterBar, SpaceHeader, ViewletContentView, ViewletSettingButton } from '@hcengineering/view-resources' import {
FilterBar,
FilterButton,
InlineFilterChips,
SpaceHeader,
ViewletContentView,
ViewletSettingButton,
filterStore,
rawSearchTextStore,
resultIssueCountStore,
resetResultCount,
searchHighlightEnabledStore,
shouldShowSearchEmptyState
} from '@hcengineering/view-resources'
import { onDestroy } from 'svelte'
import tracker from '../../plugin' import tracker from '../../plugin'
import CreateIssue from '../CreateIssue.svelte' import CreateIssue from '../CreateIssue.svelte'
import SearchEmptyState from '../SearchEmptyState.svelte'
function newIssue (): void {
showPopup(CreateIssue, { space, shouldSaveDraft: true }, 'top')
}
export let space: Ref<Space> | undefined = undefined export let space: Ref<Space> | undefined = undefined
export let query: DocumentQuery<Issue> = {} export let query: DocumentQuery<Issue> = {}
@@ -20,12 +39,38 @@
const viewlets: WithLookup<Viewlet>[] | undefined = undefined const viewlets: WithLookup<Viewlet>[] | undefined = undefined
let viewOptions: ViewOptions | undefined let viewOptions: ViewOptions | undefined
// Single search source-of-truth. The legacy `search` binding still
// exists for SpaceHeader's internal SearchInput (only used when
// overrideSearch=false — never reached today). The new path uses
// searchRaw + searchEncoded written by SearchInputAdvanced. The
// `rawSearchTextStore` mirrors searchRaw so HighlightedText consumers
// can read it without prop-drilling.
let search = '' let search = ''
let searchQuery: DocumentQuery<Issue> = { ...query } let searchRaw = ''
function updateSearchQuery (search: string): void { let searchEncoded = ''
searchQuery = search === '' ? { ...query } : { ...query, $search: search }
function onSearchChange (e: CustomEvent<{ raw: string, encoded: string }>): void {
searchRaw = e.detail.raw
searchEncoded = e.detail.encoded
} }
$: if (query) updateSearchQuery(search)
// Sync rawSearchTextStore reactively with the LOCAL searchRaw so that
// route/space changes that remount this component immediately reset
// the global store to the empty initial value. Previously the store
// was only written from onSearchChange(), so the new view mounted
// with an empty input field but the global store still held the
// PREVIOUS view's search text — Empty-State + match-highlight could
// then react to a stale query that the user never typed in this view.
$: rawSearchTextStore.set(searchRaw)
onDestroy(() => {
rawSearchTextStore.set('')
})
let searchQuery: DocumentQuery<Issue> = { ...query }
function updateSearchQuery (eff: string): void {
searchQuery = eff === '' ? { ...query } : { ...query, $search: eff }
}
$: if (query !== undefined) updateSearchQuery(searchEncoded)
let resultQuery: DocumentQuery<Issue> = { ...searchQuery } let resultQuery: DocumentQuery<Issue> = { ...searchQuery }
$: if (title) { $: if (title) {
@@ -33,6 +78,44 @@
label = res label = res
}) })
} }
// Mirror the Customize-View toggle into a store so HighlightedText
// consumers (IssuePresenter) can short-circuit to a no-op when the user
// turns highlighting off. Defaults to true on first mount so the toggle's
// default-on behaviour is honoured.
$: searchHighlightEnabledStore.set((viewOptions?.searchHighlight ?? true) !== false)
// Reset the result-count store to -1 on every search or filter change.
// Without this reset, a stale 0 from a previous query would leave the
// empty-state card stuck after the user retyped — the new query is
// already in flight but the card reads the old 0 until the viewlet's
// LiveQuery callback delivers the new count. The reset re-arms the
// sentinel so the card disappears immediately on input change and only
// re-appears when the new query confirms zero hits.
$: {
void searchEncoded
void $filterStore
// Reset to the pending sentinel without surrendering the viewlet's
// ownership — the mounted viewlet stays authoritative and re-populates the
// count once its new query resolves.
resetResultCount()
}
// Empty-state is shown only when the user has typed something AND the
// viewlet returned zero results. Until the viewlet writes a real count
// (List.svelte / KanbanView.svelte) the store stays at -1, so the "no
// hits" card cannot flash during initial load before the first query
// response.
// The card does NOT replace the viewlet: it renders as a non-suppressive
// sibling below the always-mounted viewlet (see the comment above
// .viewlet-wrap below). It is suppressed entirely when the user turned on
// "show empty groups" (shouldShowAll), which keeps the empty groups/columns
// visible (its explicit choice wins).
$: showSearchEmptyState = shouldShowSearchEmptyState(
$rawSearchTextStore,
$resultIssueCountStore,
viewOptions?.shouldShowAll as boolean | undefined
)
</script> </script>
<SpaceHeader <SpaceHeader
@@ -47,11 +130,25 @@
{space} {space}
{resultQuery} {resultQuery}
{modeSelectorProps} {modeSelectorProps}
overrideSearch={true}
> >
<svelte:fragment slot="header-tools"> <svelte:fragment slot="header-tools">
<ViewletSettingButton bind:viewOptions bind:viewlet /> <ViewletSettingButton bind:viewOptions bind:viewlet />
</svelte:fragment> </svelte:fragment>
<!-- Search slot is consumed by every Tracker viewlet (List / Kanban), so
SearchInputAdvanced + prefix-operators + searchScope + rawSearchTextStore
+ match-highlight + empty-state all work uniformly across viewlets. -->
<svelte:fragment slot="search">
<SearchInputAdvanced
value={searchRaw}
on:change={onSearchChange}
scope={viewOptions?.searchScope ?? 'all'}
collapsed
/>
<FilterButton _class={tracker.class.Issue} {space} />
</svelte:fragment>
<svelte:fragment slot="label_selector"> <svelte:fragment slot="label_selector">
<slot name="label_selector" /> <slot name="label_selector" />
</svelte:fragment> </svelte:fragment>
@@ -65,26 +162,139 @@
extension={tracker.extensions.IssueListHeader} extension={tracker.extensions.IssueListHeader}
props={{ size: 'small', kind: 'tertiary', space }} props={{ size: 'small', kind: 'tertiary', space }}
/> />
<Button
kind="primary"
icon={IconAdd}
iconProps={{ size: 'medium' }}
shape="round"
showTooltip={{ label: tracker.string.NewIssue }}
on:click={newIssue}
/>
</svelte:fragment> </svelte:fragment>
</SpaceHeader> </SpaceHeader>
<!-- FilterBar owns the filter→resultQuery data path (debounced via
reduceCalls, shared with non-Tracker consumers). hideChips=true
suppresses its chip render — chips are mounted separately by
InlineFilterChips below the header. -->
<FilterBar <FilterBar
_class={tracker.class.Issue} _class={tracker.class.Issue}
{space} {space}
query={searchQuery} query={searchQuery}
{viewOptions} {viewOptions}
hideChips={true}
on:change={(e) => (resultQuery = e.detail)} on:change={(e) => (resultQuery = e.detail)}
/> />
<slot name="afterHeader" /> <slot name="afterHeader" />
{#if viewlet && viewOptions} <!-- Render the chip strip below the SpaceHeader. Mounted unconditionally
<ViewletContentView so it stays available the instant the user adds a filter; the visual
_class={tracker.class.Issue} row hides when $filterStore is empty (via [data-empty='true']). -->
{viewlet} <div class="below-header-filters" data-empty={$filterStore.length === 0}>
query={resultQuery} <InlineFilterChips _class={tracker.class.Issue} {space} />
{space} </div>
{viewOptions} <!-- Viewlet stays mounted AND laid out regardless of the empty-state card.
createItemDialog={CreateIssue} Two earlier iterations broke live list updates:
createItemLabel={tracker.string.AddIssueTooltip} 1. Unmounting the viewlet on a zero-hit search created a self-lock —
createItemEvent={TrackerEvents.IssuePlusButtonClicked} with no viewlet around, resultIssueCountStore never updated on
createItemDialogProps={{ shouldSaveDraft: true }} retype so the card stuck.
/> 2. Keeping it mounted but toggling `display: none` starved the
virtualized viewlet (List uses a viewport-measured virtual scroller
since the row-virtualization tier): while hidden the scroller
measures a 0-height viewport and caches it, so when the count returns
to a positive value and the wrapper re-shows, the stale 0-height
measurement leaves ZERO rows rendered — a freshly created / searched
issue never appears in the list even though its LiveQuery already
delivered it. That is the uitest regression (issues + mentions
"create → search → open" timing out on the row locator).
The empty-state is therefore a non-suppressive OVERLAY: the live viewlet
is never collapsed, so its scroller always has a real viewport and always
renders its rows. The card only ever adds an informational panel; it can
never hide a populated list. `display: contents` is kept so
ViewletContentView stays a direct flex child of the page-level layout.
`showSearchEmptyState` therefore only decides whether the CARD renders:
with "show empty groups" (shouldShowAll) on it stays false, so the empty
groups / Kanban columns remain visible and the card is suppressed — the
user's explicit view option wins. -->
<div class="viewlet-wrap">
{#if viewlet && viewOptions}
<ViewletContentView
_class={tracker.class.Issue}
{viewlet}
query={resultQuery}
{space}
{viewOptions}
createItemDialog={CreateIssue}
createItemLabel={tracker.string.AddIssueTooltip}
createItemEvent={TrackerEvents.IssuePlusButtonClicked}
createItemDialogProps={{ shouldSaveDraft: true }}
/>
{/if}
</div>
{#if showSearchEmptyState}
<div class="search-empty-state-overlay">
<SearchEmptyState searchText={$rawSearchTextStore} activeFilters={$filterStore.map((f) => f.key.key)} />
</div>
{/if} {/if}
<style lang="scss">
.below-header-filters {
display: flex;
align-items: center;
padding: 0.25rem 0.75rem;
min-height: 1.75rem;
border-bottom: 1px solid var(--theme-divider-color);
}
.below-header-filters[data-empty='true'] {
display: none;
}
/* `display: contents` lets the wrapper disappear from layout so
ViewletContentView stays a direct flex item of the page-level chain.
The wrapper is NEVER switched to `display: none` — doing so starved the
virtualized viewlet's scroller of a viewport and left rows unrendered
after the empty-state dismissed itself (see the template comment above).
The empty-state card is a sibling, so the live viewlet's layout is always
intact. */
.viewlet-wrap {
display: contents;
}
/* Out-of-flow overlay centred on the panel.
Why an overlay and not an in-flow block: the card must not take layout
space away from the viewlet. Displacing or hiding the viewlet starves
its virtual scroller — it caches a 0-height viewport and renders zero
rows once results come back (the create → search → open regression, see
the template comment above). An absolutely positioned card leaves the
viewlet's box byte-for-byte identical, so the scroller keeps measuring a
real viewport the whole time.
Why out-of-flow is required at all: the enclosing panel is an
`overflow: hidden` flex column, so an in-flow sibling appended after a
full-height viewlet lands below the bottom edge and is clipped — the
overlay is what makes the card reliably visible.
Containing block: the panel (`.hulyComponent`) applies
`container-type: inline-size`, i.e. layout containment, which makes it
the containing block for absolutely positioned descendants. Should that
ever change, the fallback is the initial containing block (the viewport)
— still on screen, just centred on the window instead of the panel.
`pointer-events: none` keeps the header, view options and toolbar
clickable through the transparent area; the card itself re-enables them.
The card only renders at resultCount === 0, so there are no result rows
underneath that it could cover. */
.search-empty-state-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
/* Above the viewlet's sticky header cells / scrollbars (max 100), below
the global popup layer (450+). */
z-index: 101;
pointer-events: none;
}
.search-empty-state-overlay > :global(.search-empty-state) {
pointer-events: auto;
}
</style>
@@ -60,13 +60,16 @@
Menu, Menu,
noCategory, noCategory,
openDoc, openDoc,
claimResultCountOwner,
releaseResultCountOwner,
setResultCount,
SelectDirection, SelectDirection,
setGroupByValues, setGroupByValues,
showMenu, showMenu,
statusStore statusStore
} from '@hcengineering/view-resources' } from '@hcengineering/view-resources'
import { ChatMessagesPresenter } from '@hcengineering/chunter-resources' import { ChatMessagesPresenter } from '@hcengineering/chunter-resources'
import { onMount } from 'svelte' import { onDestroy, onMount } from 'svelte'
import tracker from '../../plugin' import tracker from '../../plugin'
import { activeProjects } from '../../utils' import { activeProjects } from '../../utils'
@@ -143,6 +146,18 @@
// Category information only // Category information only
let tasks: DocWithRank[] = [] let tasks: DocWithRank[] = []
// Feed the shared result-count store (via the owner-token gate) so IssuesView
// can show its SearchEmptyState card when the user's search yields zero hits.
// We claim ownership at init and release on destroy; the count is written
// from the same fast-query callback that sets the data source (see docsQuery
// below), not reactively off `tasks` — the old coupling read `tasks` (fast +
// lagging slow query) while gating on a flag set by the fast query alone, so
// a stale slow-query result could skew the count right after a search changed.
const resultCountOwner = claimResultCountOwner()
onDestroy(() => {
releaseResultCountOwner(resultCountOwner)
})
$: groupByDocs = groupBy(tasks, groupByKey, categories) $: groupByDocs = groupBy(tasks, groupByKey, categories)
let fastDocs: DocWithRank[] = [] let fastDocs: DocWithRank[] = []
@@ -171,6 +186,7 @@
(res) => { (res) => {
fastDocs = res fastDocs = res
fastQueryIds = new Set(res.map((it) => it._id)) fastQueryIds = new Set(res.map((it) => it._id))
setResultCount(resultCountOwner, res.length)
}, },
{ ...categoryQueryOptions, limit: 1000 } { ...categoryQueryOptions, limit: 1000 }
) )
+12 -1
View File
@@ -307,7 +307,18 @@ export default mergeIds(trackerId, tracker, {
UnsetParent: '' as IntlString, UnsetParent: '' as IntlString,
PreviousAssigned: '' as IntlString, PreviousAssigned: '' as IntlString,
EditRelatedTargets: '' as IntlString, EditRelatedTargets: '' as IntlString,
RelatedIssueTargetDescription: '' as IntlString RelatedIssueTargetDescription: '' as IntlString,
ModeSelectorDisabledByFilter: '' as IntlString,
ShowQuickModeSelector: '' as IntlString,
SearchScopeLabel: '' as IntlString,
SearchScopeTitle: '' as IntlString,
SearchScopeTitleDescription: '' as IntlString,
SearchScopeAll: '' as IntlString,
SearchHighlight: '' as IntlString,
SearchEmptyTitle: '' as IntlString,
SearchEmptyActiveFilters: '' as IntlString,
SearchEmptyClearFilters: '' as IntlString,
SearchEmptyAllProjects: '' as IntlString
}, },
component: { component: {
NopeComponent: '' as AnyComponent, NopeComponent: '' as AnyComponent,
+4 -1
View File
@@ -158,6 +158,9 @@
"RoleLabel": "Role: {role}", "RoleLabel": "Role: {role}",
"ForbidAttributeChanges": "Zakázat změny: ", "ForbidAttributeChanges": "Zakázat změny: ",
"AllowAttributeChanges": "Povolit změny: ", "AllowAttributeChanges": "Povolit změny: ",
"RelationshipTable": "Tabulka vztahů" "RelationshipTable": "Tabulka vztahů",
"FilterOverflowBadge": "+{count}",
"AddFilter": "Přidat filtr",
"HiddenFilters": "Skryté filtry"
} }
} }
+4 -1
View File
@@ -158,6 +158,9 @@
"RoleLabel": "Rolle: {role}", "RoleLabel": "Rolle: {role}",
"ForbidAttributeChanges": "Änderungen verbieten: ", "ForbidAttributeChanges": "Änderungen verbieten: ",
"AllowAttributeChanges": "Änderungen erlauben: ", "AllowAttributeChanges": "Änderungen erlauben: ",
"RelationshipTable": "Beziehungstabelle" "RelationshipTable": "Beziehungstabelle",
"FilterOverflowBadge": "+{count}",
"AddFilter": "Filter hinzufügen",
"HiddenFilters": "Versteckte Filter"
} }
} }
+4 -1
View File
@@ -158,6 +158,9 @@
"RoleLabel": "Role: {role}", "RoleLabel": "Role: {role}",
"ForbidAttributeChanges": "Forbid changes: ", "ForbidAttributeChanges": "Forbid changes: ",
"AllowAttributeChanges": "Allow changes: ", "AllowAttributeChanges": "Allow changes: ",
"RelationshipTable": "Traceability Matrix" "RelationshipTable": "Traceability Matrix",
"FilterOverflowBadge": "+{count}",
"AddFilter": "Add filter",
"HiddenFilters": "Hidden filters"
} }
} }
+4 -1
View File
@@ -153,6 +153,9 @@
"RoleLabel": "Role: {role}", "RoleLabel": "Role: {role}",
"ForbidAttributeChanges": "Prohibir cambios: ", "ForbidAttributeChanges": "Prohibir cambios: ",
"AllowAttributeChanges": "Permitir cambios: ", "AllowAttributeChanges": "Permitir cambios: ",
"RelationshipTable": "Tabla de relaciones" "RelationshipTable": "Tabla de relaciones",
"FilterOverflowBadge": "+{count}",
"AddFilter": "Añadir filtro",
"HiddenFilters": "Filtros ocultos"
} }
} }
+4 -1
View File
@@ -153,6 +153,9 @@
"RoleLabel": "Rôle : {role}", "RoleLabel": "Rôle : {role}",
"ForbidAttributeChanges": "Interdire les modifications: ", "ForbidAttributeChanges": "Interdire les modifications: ",
"AllowAttributeChanges": "Autoriser les modifications: ", "AllowAttributeChanges": "Autoriser les modifications: ",
"RelationshipTable": "Matrice de traçabilité" "RelationshipTable": "Matrice de traçabilité",
"FilterOverflowBadge": "+{count}",
"AddFilter": "Ajouter un filtre",
"HiddenFilters": "Filtres masqués"
} }
} }
+4 -1
View File
@@ -153,6 +153,9 @@
"RoleLabel": "Ruolo: {role}", "RoleLabel": "Ruolo: {role}",
"ForbidAttributeChanges": "Vieta modifiche: ", "ForbidAttributeChanges": "Vieta modifiche: ",
"AllowAttributeChanges": "Consenti modifiche: ", "AllowAttributeChanges": "Consenti modifiche: ",
"RelationshipTable": "Tabella delle relazioni" "RelationshipTable": "Tabella delle relazioni",
"FilterOverflowBadge": "+{count}",
"AddFilter": "Aggiungi filtro",
"HiddenFilters": "Filtri nascosti"
} }
} }
+4 -1
View File
@@ -153,6 +153,9 @@
"RoleLabel": "役割: {role}", "RoleLabel": "役割: {role}",
"ForbidAttributeChanges": "属性の変更を禁止: ", "ForbidAttributeChanges": "属性の変更を禁止: ",
"AllowAttributeChanges": "属性の変更を許可: ", "AllowAttributeChanges": "属性の変更を許可: ",
"RelationshipTable": "関係テーブル" "RelationshipTable": "関係テーブル",
"FilterOverflowBadge": "+{count}",
"AddFilter": "フィルターを追加",
"HiddenFilters": "非表示のフィルター"
} }
} }
+4 -1
View File
@@ -153,6 +153,9 @@
"RoleLabel": "역할: {role}", "RoleLabel": "역할: {role}",
"ForbidAttributeChanges": "변경 금지: ", "ForbidAttributeChanges": "변경 금지: ",
"AllowAttributeChanges": "변경 허용: ", "AllowAttributeChanges": "변경 허용: ",
"RelationshipTable": "추적 매트릭스" "RelationshipTable": "추적 매트릭스",
"FilterOverflowBadge": "+{count}",
"AddFilter": "필터 추가",
"HiddenFilters": "숨겨진 필터"
} }
} }
+4 -1
View File
@@ -153,6 +153,9 @@
"RoleLabel": "Cargo: {role}", "RoleLabel": "Cargo: {role}",
"ForbidAttributeChanges": "Proibir alterações: ", "ForbidAttributeChanges": "Proibir alterações: ",
"AllowAttributeChanges": "Permitir alterações: ", "AllowAttributeChanges": "Permitir alterações: ",
"RelationshipTable": "Tabela de relacionamentos" "RelationshipTable": "Tabela de relacionamentos",
"FilterOverflowBadge": "+{count}",
"AddFilter": "Adicionar filtro",
"HiddenFilters": "Filtros ocultos"
} }
} }
+4 -1
View File
@@ -153,6 +153,9 @@
"RoleLabel": "Cargo: {role}", "RoleLabel": "Cargo: {role}",
"ForbidAttributeChanges": "Proibir alterações: ", "ForbidAttributeChanges": "Proibir alterações: ",
"AllowAttributeChanges": "Permitir alterações: ", "AllowAttributeChanges": "Permitir alterações: ",
"RelationshipTable": "Tabela de relacionamentos" "RelationshipTable": "Tabela de relacionamentos",
"FilterOverflowBadge": "+{count}",
"AddFilter": "Adicionar filtro",
"HiddenFilters": "Filtros ocultos"
} }
} }
+4 -1
View File
@@ -155,6 +155,9 @@
"RoleLabel": "Роль: {role}", "RoleLabel": "Роль: {role}",
"ForbidAttributeChanges": "Запретить изменение: ", "ForbidAttributeChanges": "Запретить изменение: ",
"AllowAttributeChanges": "Разрешить изменение: ", "AllowAttributeChanges": "Разрешить изменение: ",
"RelationshipTable": "Таблица связей" "RelationshipTable": "Таблица связей",
"FilterOverflowBadge": "+{count}",
"AddFilter": "Добавить фильтр",
"HiddenFilters": "Скрытые фильтры"
} }
} }
+4 -1
View File
@@ -153,6 +153,9 @@
"RoleLabel": "Rol: {role}", "RoleLabel": "Rol: {role}",
"ForbidAttributeChanges": "Özellik yasakla: ", "ForbidAttributeChanges": "Özellik yasakla: ",
"AllowAttributeChanges": "Özellik izin ver: ", "AllowAttributeChanges": "Özellik izin ver: ",
"RelationshipTable": "İlişki tablosu" "RelationshipTable": "İlişki tablosu",
"FilterOverflowBadge": "+{count}",
"AddFilter": "Filtre ekle",
"HiddenFilters": "Gizli filtreler"
} }
} }
+4 -1
View File
@@ -158,6 +158,9 @@
"RoleLabel": "角色:{role}", "RoleLabel": "角色:{role}",
"ForbidAttributeChanges": "禁止更改属性: ", "ForbidAttributeChanges": "禁止更改属性: ",
"AllowAttributeChanges": "允许更改属性: ", "AllowAttributeChanges": "允许更改属性: ",
"RelationshipTable": "关系表" "RelationshipTable": "关系表",
"FilterOverflowBadge": "+{count}",
"AddFilter": "添加过滤器",
"HiddenFilters": "隐藏的过滤器"
} }
} }
+8 -1
View File
@@ -1,5 +1,12 @@
module.exports = { module.exports = {
preset: 'ts-jest', preset: 'ts-jest',
testEnvironment: 'node', testEnvironment: 'node',
testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'] testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'],
// stores.ts (exercised by the result-count owner-token test) imports
// `svelte/store`, which ships as ESM and cannot be required under the ts-jest
// CommonJS runtime with the repo's pnpm layout. Map it to a faithful local
// stand-in, mirroring the @hcengineering/presentation package.
moduleNameMapper: {
'^svelte/store$': '<rootDir>/src/__mocks__/svelte-store.ts'
}
} }
@@ -0,0 +1,55 @@
//
// Copyright © 2026 Hardcore Engineering Inc.
// SPDX-License-Identifier: EPL-2.0
//
// Minimal, faithful `svelte/store` stand-in for the Jest (node) environment.
// The real `svelte/store` ships as ESM and cannot be required under the ts-jest
// CommonJS runtime with the repo's pnpm layout, so — like the presentation
// package — we map `svelte/store` to this mock. It implements the `writable`
// and `get` semantics the store gate under test relies on (synchronous set +
// subscribe-on-register), so the test exercises the real gate logic.
export type Subscriber<T> = (value: T) => void
export type Unsubscriber = () => void
export type Updater<T> = (value: T) => T
export interface Readable<T> {
subscribe: (run: Subscriber<T>) => Unsubscriber
}
export interface Writable<T> extends Readable<T> {
set: (value: T) => void
update: (updater: Updater<T>) => void
}
export function writable<T> (initialValue: T): Writable<T> {
let value = initialValue
const subscribers = new Set<Subscriber<T>>()
return {
subscribe (run: Subscriber<T>): Unsubscriber {
subscribers.add(run)
run(value)
return () => subscribers.delete(run)
},
set (newValue: T): void {
value = newValue
subscribers.forEach((run) => {
run(value)
})
},
update (updater: Updater<T>): void {
this.set(updater(value))
}
}
}
export function get<T> (store: Readable<T>): T {
// Definite assignment: `subscribe` invokes the callback synchronously on
// registration, so `current` is always set before `unsub()` returns.
let current!: T
const unsub = store.subscribe((v) => {
current = v
})
unsub()
return current
}
@@ -0,0 +1,35 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { computeOverflow } from '../components/filter/InlineFilterChips.svelte.helpers'
describe('computeOverflow', () => {
it('shows all chips when container is wide enough', () => {
expect(computeOverflow([80, 100, 120], 500, 60)).toEqual({ visibleCount: 3, hiddenCount: 0 })
})
it('collapses trailing chips and reserves space for the +N button', () => {
expect(computeOverflow([120, 120, 120, 120, 120], 300, 60)).toEqual({ visibleCount: 2, hiddenCount: 3 })
})
it('hides all chips when none fit even with badge', () => {
expect(computeOverflow([400], 200, 60)).toEqual({ visibleCount: 0, hiddenCount: 1 })
})
it('does not collapse when only one chip overflows by less than badge width', () => {
expect(computeOverflow([120, 120, 50], 270, 60)).toEqual({ visibleCount: 1, hiddenCount: 2 })
})
// ─── Inter-chip flex gaps must count toward overflow ──────────────────────
it('shows all chips when container is wide enough (incl. gaps)', () => {
// 3 chips + 2 gaps(8) = 316 <= 500
expect(computeOverflow([80, 100, 120], 500, 32, 8)).toEqual({ visibleCount: 3, hiddenCount: 0 })
})
it('accounts for inter-chip gaps when deciding overflow', () => {
// widths sum 300 <= 300 but +2 gaps(8)=316 > 300 → must collapse
expect(computeOverflow([100, 100, 100], 300, 32, 8).hiddenCount).toBeGreaterThan(0)
})
it('reserves only the real badge width', () => {
expect(computeOverflow([120, 120, 120, 120, 120], 300, 32, 8)).toEqual({ visibleCount: 2, hiddenCount: 3 })
})
})
@@ -0,0 +1,87 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { DocumentQuery, Doc } from '@hcengineering/core'
import type { Filter, FilterMode } from '@hcengineering/view'
import { makeFilterQuery } from '../filter/query-builder'
const mockResult = async (filter: Filter): Promise<{ $in: unknown }> => ({ $in: filter.value })
const mockResolveResource = jest.fn(async () => mockResult)
const mockMode = { result: 'mock:resource:Result' as any } as unknown as FilterMode
const mockFilter = (key: string, value: unknown[], idx = 1): Filter => ({
key: { _class: '' as any, key, attribute: undefined as any, label: '' as any, component: '' as any },
mode: 'mock:mode:In' as any,
modes: [],
value: value as any,
index: idx
})
describe('makeFilterQuery', () => {
beforeEach(() => {
mockResolveResource.mockClear()
})
it('returns base query when filter list is empty', async () => {
const base: DocumentQuery<Doc> = { space: 'foo' as any }
const out = await makeFilterQuery(base, [], async () => mockMode, mockResolveResource as any)
expect(out).toEqual({ space: 'foo' })
expect(mockResolveResource).not.toHaveBeenCalled()
})
it('resolves the resource then AND-combines a single $in filter', async () => {
const base: DocumentQuery<Doc> = {}
const filters: Filter[] = [mockFilter('status', ['a', 'b'])]
const out = await makeFilterQuery(base, filters, async () => mockMode, mockResolveResource as any)
expect(out).toEqual({ status: { $in: ['a', 'b'] } })
expect(mockResolveResource).toHaveBeenCalledWith('mock:resource:Result')
})
it('intersects $in operators across two filters on same key', async () => {
const base: DocumentQuery<Doc> = {}
const filters: Filter[] = [mockFilter('status', ['a', 'b'], 1), mockFilter('status', ['b', 'c'], 2)]
const out = await makeFilterQuery(base, filters, async () => mockMode, mockResolveResource as any)
expect(out).toEqual({ status: { $in: ['b'] } })
})
it('intersects $gte/$lte date bounds across two filters on same key', async () => {
const base: DocumentQuery<Doc> = {}
// Each filter emits a { $gte, $lte } range from [gte, lte] — mimics the
// real before/after/dateToday date outputs.
const rangeResult = async (filter: Filter): Promise<{ $gte: unknown, $lte: unknown }> => ({
$gte: filter.value[0],
$lte: filter.value[1]
})
const resolveRange = jest.fn(async () => rangeResult)
const filters: Filter[] = [mockFilter('dueDate', [10, 100], 1), mockFilter('dueDate', [20, 80], 2)]
const out = await makeFilterQuery(base, filters, async () => mockMode, resolveRange as any)
// The tighter bound must win on each side: $gte = max(10, 20), $lte = min(100, 80).
expect(out).toEqual({ dueDate: { $gte: 20, $lte: 80 } })
})
it('preserves base query fields untouched by the filters', async () => {
const base: DocumentQuery<Doc> = { space: 'foo' as any, modifiedOn: 123 as any }
const filters: Filter[] = [mockFilter('status', ['x'])]
const out = await makeFilterQuery(base, filters, async () => mockMode, mockResolveResource as any)
expect(out).toEqual({ space: 'foo', modifiedOn: 123, status: { $in: ['x'] } })
})
it('skips filters whose mode resolver returns undefined', async () => {
const base: DocumentQuery<Doc> = {}
const filters: Filter[] = [mockFilter('status', ['x'])]
const out = await makeFilterQuery(base, filters, async () => undefined, mockResolveResource as any)
expect(out).toEqual({})
expect(mockResolveResource).not.toHaveBeenCalled()
})
it('does not mutate the caller-owned base query (deep clone)', async () => {
// The caller usually passes a stable `query` prop. If the helper mutated
// it the next reactive cycle would start from corrupt state. Verified by
// composing onto a base that already has a nested object at the filter's
// target key — the resulting `out` must merge, but `base` must survive
// unchanged.
const base: DocumentQuery<Doc> = { status: { $in: ['initial'] } as any }
const baseSnapshot = JSON.parse(JSON.stringify(base))
const filters: Filter[] = [mockFilter('status', ['x'])]
await makeFilterQuery(base, filters, async () => mockMode, mockResolveResource as any)
expect(base).toEqual(baseSnapshot)
})
})
@@ -0,0 +1,106 @@
//
// Copyright © 2026 Hardcore Engineering Inc.
// SPDX-License-Identifier: EPL-2.0
//
import { get } from 'svelte/store'
import {
claimResultCountOwner,
releaseResultCountOwner,
resetResultCount,
resultIssueCountStore,
setResultCount
} from '../stores'
describe('resultIssueCountStore owner-token gate', () => {
beforeEach(() => {
// Drop any ownership left over from a previous test and return the store to
// the `-1` sentinel: claim a throwaway token (becomes current), then
// release it (current → clears owner, resets to -1).
releaseResultCountOwner(claimResultCountOwner())
})
it('a new writer supersedes the previous owner', () => {
const first = claimResultCountOwner()
setResultCount(first, 5)
expect(get(resultIssueCountStore)).toBe(5)
const second = claimResultCountOwner()
// Proof of supersede: the first token can no longer write.
setResultCount(first, 99)
expect(get(resultIssueCountStore)).toBe(5)
// The second (current) token can.
setResultCount(second, 7)
expect(get(resultIssueCountStore)).toBe(7)
})
it('writes from a superseded writer are ignored', () => {
const first = claimResultCountOwner()
setResultCount(first, 3)
const second = claimResultCountOwner()
setResultCount(second, 10)
setResultCount(first, 42) // superseded → ignored
expect(get(resultIssueCountStore)).toBe(10)
})
it('release from a superseded writer does NOT reset the new owner count', () => {
const first = claimResultCountOwner()
const second = claimResultCountOwner()
setResultCount(second, 8)
releaseResultCountOwner(first) // no-op: first is not current
expect(get(resultIssueCountStore)).toBe(8)
})
it('release from the current writer resets to -1', () => {
const owner = claimResultCountOwner()
setResultCount(owner, 4)
releaseResultCountOwner(owner)
expect(get(resultIssueCountStore)).toBe(-1)
})
it('query reset (-1) then a current write repopulates the value', () => {
const owner = claimResultCountOwner()
setResultCount(owner, 6)
resetResultCount() // query/filter changed → pending sentinel, ownership kept
expect(get(resultIssueCountStore)).toBe(-1)
setResultCount(owner, 9) // still the current owner → value reappears
expect(get(resultIssueCountStore)).toBe(9)
})
// Regression (store-contract level; the List.svelte conditional itself has no
// component test harness in this package): an embedded, non-primary List
// (sub-issues / related issues in the issue panel, card-panel children, process
// extensions) used to unconditionally claim + release the owner token, which
// stranded the primary Issues viewlet with a dead token whose later writes
// became no-ops — the zero-hit SearchEmptyState card never rendered again after
// opening and closing a panel. The fix makes reporting opt-in: `reportResultCount`
// defaults to `false`, so only ListView (the primary viewlet) opts in and every
// embedded List stays out of the gate. This test models that contract: as long
// as the embedded consumer never claims, the primary viewlet remains the current
// owner throughout and its writes keep taking effect.
it('an opted-out embedded consumer (never claims) leaves the primary owner writable', () => {
// Primary Issues viewlet mounts and reports its search-result count.
const viewlet = claimResultCountOwner()
setResultCount(viewlet, 12)
expect(get(resultIssueCountStore)).toBe(12)
// User opens an issue/card: the embedded List defaults to reportResultCount=false,
// i.e. it NEVER calls claim/setResultCount/release.
// (Nothing to invoke here — the whole point is that it does not touch the gate.)
// A fresh search on the primary viewlet now returns zero hits.
setResultCount(viewlet, 0)
// Because ownership was never superseded, the write lands and the zero-hit
// card can render (count === 0, not the stuck -1 of the pre-fix bug).
expect(get(resultIssueCountStore)).toBe(0)
// Sanity: the primary owner remains authoritative for further writes.
setResultCount(viewlet, 4)
expect(get(resultIssueCountStore)).toBe(4)
})
})
@@ -0,0 +1,41 @@
import { shouldShowSearchEmptyState, shouldShowEmptyState } from '../searchEmptyState'
describe('shouldShowEmptyState', () => {
it('returns true when search has text and no results', () => {
expect(shouldShowEmptyState('loader', 0)).toBe(true)
})
it('returns false when search is empty even with 0 results', () => {
expect(shouldShowEmptyState('', 0)).toBe(false)
expect(shouldShowEmptyState(' ', 0)).toBe(false)
})
it('returns false when there are results', () => {
expect(shouldShowEmptyState('loader', 1)).toBe(false)
expect(shouldShowEmptyState('loader', 100)).toBe(false)
})
it('returns false during the not-yet-measured sentinel (-1)', () => {
expect(shouldShowEmptyState('loader', -1)).toBe(false)
})
})
describe('shouldShowSearchEmptyState', () => {
it('shows the card on a zero-hit search when shouldShowAll is off', () => {
expect(shouldShowSearchEmptyState('loader', 0, false)).toBe(true)
expect(shouldShowSearchEmptyState('loader', 0, undefined)).toBe(true)
})
it('suppresses the card (empty groups stay visible) when shouldShowAll is on', () => {
// The explicit "show empty groups" option wins over the empty-state card.
expect(shouldShowSearchEmptyState('loader', 0, true)).toBe(false)
})
it('suppresses the card when shouldShowAll is on', () => {
expect(shouldShowSearchEmptyState('foo', 0, true)).toBe(false)
expect(shouldShowSearchEmptyState('foo', 0, undefined)).toBe(true)
})
it('never shows the card when there are results, regardless of shouldShowAll', () => {
expect(shouldShowSearchEmptyState('loader', 3, false)).toBe(false)
expect(shouldShowSearchEmptyState('loader', 3, true)).toBe(false)
})
it('never shows the card without a search term', () => {
expect(shouldShowSearchEmptyState('', 0, false)).toBe(false)
expect(shouldShowSearchEmptyState(' ', 0, false)).toBe(false)
})
})
@@ -0,0 +1,93 @@
<!--
// Copyright © 2026 Hardcore Engineering Inc.
// SPDX-License-Identifier: EPL-2.0
-->
<!--
Generic card shown when the user has typed something but zero items match.
Replaces the previous behaviour (silent empty grid that left the user
unsure whether the search was running or just broken).
Mounted by a consumer when shouldShowEmptyState(searchRaw, count) is true,
inside an out-of-flow overlay so it never displaces the still-live viewlet
(see the overlay comment at the consumer). Because it floats above the
viewlet it carries its own surface (background/border/shadow) instead of
inheriting the page background.
Labels are injected by the consumer so the card stays domain-neutral:
`titleLabel` (params `{ query }`), `activeFiltersLabel` (params `{ filters }`)
and `clearFiltersLabel`. The optional broader-scope button is only rendered
when both `broaderScopeLabel` and `onBroaderScope` are provided; the consumer
decides what "broader scope" means (e.g. jumping to a project-wide view).
-->
<script lang="ts">
import { type IntlString } from '@hcengineering/platform'
import { Button, Label } from '@hcengineering/ui'
import { setFilters } from '../filter'
export let searchText: string
export let activeFilters: string[] = []
export let titleLabel: IntlString
export let activeFiltersLabel: IntlString
export let clearFiltersLabel: IntlString
export let broaderScopeLabel: IntlString | undefined = undefined
export let onBroaderScope: (() => void) | undefined = undefined
function clearFilters (): void {
setFilters([])
}
</script>
<div class="search-empty-state" role="region" aria-live="polite">
<div class="icon" aria-hidden="true">🔍</div>
<h2 class="title">
<Label label={titleLabel} params={{ query: searchText }} />
</h2>
{#if activeFilters.length > 0}
<div class="filters-info">
<Label label={activeFiltersLabel} params={{ filters: activeFilters.join(', ') }} />
</div>
{/if}
<div class="actions">
{#if activeFilters.length > 0}
<Button kind="primary" label={clearFiltersLabel} on:click={clearFilters} />
{/if}
{#if broaderScopeLabel !== undefined && onBroaderScope !== undefined}
<Button kind="ghost" label={broaderScopeLabel} on:click={onBroaderScope} />
{/if}
</div>
</div>
<style lang="scss">
.search-empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-2);
padding: var(--spacing-6);
max-width: min(32rem, 90%);
color: var(--theme-content-color);
background-color: var(--theme-popup-color);
border: 1px solid var(--theme-divider-color);
border-radius: var(--small-focus-BorderRadius);
box-shadow: var(--theme-popup-shadow);
text-align: center;
}
.icon {
font-size: 2rem;
}
.title {
margin: 0;
font-weight: 500;
font-size: 1rem;
color: var(--theme-content-color);
}
.filters-info {
font-size: 0.875rem;
color: var(--theme-dark-color);
}
.actions {
display: flex;
gap: var(--spacing-2);
margin-top: var(--spacing-2);
}
</style>
@@ -20,10 +20,23 @@
export let modeSelectorProps: IModeSelector | undefined = undefined export let modeSelectorProps: IModeSelector | undefined = undefined
export let adaptive: HeaderAdaptive = 'doubleRow' export let adaptive: HeaderAdaptive = 'doubleRow'
export let resultQuery: DocumentQuery<Doc> = {} export let resultQuery: DocumentQuery<Doc> = {}
/**
* When true the consumer's `search` slot replaces the built-in
* SearchInput + FilterButton. Used by consumers that render their own
* search input (e.g. Tracker's SearchInputAdvanced) while keeping the
* search state owned here. Default false so other viewlets keep the
* standard search behaviour even when they forward an (otherwise empty)
* `search` slot upward.
*/
export let overrideSearch: boolean = false
let scroller: HTMLElement let scroller: HTMLElement
$: viewletActions = viewlet != null ? getViewletSpecialActions(getClient(), viewlet) : [] $: viewletActions = viewlet != null ? getViewletSpecialActions(getClient(), viewlet) : []
function setSearchProp (v: string): void {
search = v
}
</script> </script>
<Header <Header
@@ -49,8 +62,17 @@
{/if} {/if}
<svelte:fragment slot="search"> <svelte:fragment slot="search">
<SearchInput bind:value={search} collapsed /> {#if overrideSearch}
<FilterButton {_class} {space} /> <!-- Consumer-driven override. Used by consumers that render their own
search input (e.g. Tracker's SearchInputAdvanced) alongside a
Filter button. The slot props expose the current search value + a
setter so the consumer can render its own SearchInput while keeping
the search state owned here. -->
<slot name="search" {search} setSearch={setSearchProp} />
{:else}
<SearchInput bind:value={search} collapsed />
<FilterButton {_class} {space} />
{/if}
</svelte:fragment> </svelte:fragment>
<svelte:fragment slot="actions"> <svelte:fragment slot="actions">
{#each viewletActions as action (action._id)} {#each viewletActions as action (action._id)}
@@ -136,8 +136,13 @@
{#each visibleOthers as model} {#each visibleOthers as model}
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions --> <!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- Generic view-option items (sub-issues, colours, search-scope, …).
These are NOT the Order-by control; the `.ordering` test-hook class
must stay unique to the real order-by dropdown above, otherwise a
dropdown-type option here (e.g. the search-scope dropdown added by
the filter-search redesign) makes `.ordering button` ambiguous. -->
<div <div
class="antiCard-menu__item hoverable ordering" class="antiCard-menu__item hoverable viewoption-other"
on:click={() => { on:click={() => {
if (isToggleType(model)) changeToggle(model) if (isToggleType(model)) changeToggle(model)
}} }}
@@ -14,12 +14,12 @@
--> -->
<script lang="ts"> <script lang="ts">
import { AccountRole, Class, Doc, DocumentQuery, Ref, Space, getCurrentAccount } from '@hcengineering/core' import { AccountRole, Class, Doc, DocumentQuery, Ref, Space, getCurrentAccount } from '@hcengineering/core'
import { getResource } from '@hcengineering/platform'
import { getClient, reduceCalls } from '@hcengineering/presentation' import { getClient, reduceCalls } from '@hcengineering/presentation'
import { Button, IconAdd, eventToHTMLElement, getCurrentLocation, showPopup } from '@hcengineering/ui' import { Button, IconAdd, IconClose, eventToHTMLElement, getCurrentLocation, showPopup } from '@hcengineering/ui'
import { Filter, FilteredView, ViewOptions, Viewlet } from '@hcengineering/view' import { Filter, FilterMode, FilteredView, ViewOptions, Viewlet } from '@hcengineering/view'
import { createEventDispatcher } from 'svelte' import { createEventDispatcher } from 'svelte'
import { filterStore, removeFilter, selectedFilterStore, updateFilter } from '../../filter' import { filterStore, removeFilter, selectedFilterStore, setFilters, updateFilter } from '../../filter'
import { makeFilterQuery } from '../../filter/query-builder'
import view from '../../plugin' import view from '../../plugin'
import { activeViewlet, getActiveViewletId, makeViewletKey } from '../../utils' import { activeViewlet, getActiveViewletId, makeViewletKey } from '../../utils'
import { getViewOptions, viewOptionStore } from '../../viewOptions' import { getViewOptions, viewOptionStore } from '../../viewOptions'
@@ -32,6 +32,11 @@
export let query: DocumentQuery<Doc> export let query: DocumentQuery<Doc>
export let viewOptions: ViewOptions | undefined = undefined export let viewOptions: ViewOptions | undefined = undefined
export let hideSaveButtons: boolean = false export let hideSaveButtons: boolean = false
// Tracker IssuesView renders its own inline chip strip via <InlineFilterChips>
// and sets hideChips=true so this component shrinks to just the SaveAs row.
// Every other consumer keeps the legacy chip rendering — the redesign only
// covers Tracker.
export let hideChips: boolean = false
const client = getClient() const client = getClient()
const hierarchy = client.getHierarchy() const hierarchy = client.getHierarchy()
@@ -40,12 +45,11 @@
const account = getCurrentAccount() const account = getCurrentAccount()
const canSaveFilteredView = account.role !== AccountRole.ReadOnlyGuest && account.role !== AccountRole.DocGuest const canSaveFilteredView = account.role !== AccountRole.ReadOnlyGuest && account.role !== AccountRole.DocGuest
function onChange (e: Filter | undefined) { function onChange (e: Filter | undefined): void {
if (e === undefined) return if (e !== undefined) updateFilter(e)
updateFilter(e)
} }
function add (e: MouseEvent) { function add (e: MouseEvent): void {
const target = eventToHTMLElement(e) const target = eventToHTMLElement(e)
showPopup( showPopup(
FilterTypePopup, FilterTypePopup,
@@ -60,11 +64,11 @@
) )
} }
async function saveFilteredView () { async function saveFilteredView (): Promise<void> {
showPopup(FilterSave, { viewOptions, _class }) showPopup(FilterSave, { viewOptions, _class })
} }
async function saveCurrentFilteredView (filter: FilteredView | undefined) { async function saveCurrentFilteredView (filter: FilteredView | undefined): Promise<void> {
if (filter !== undefined) { if (filter !== undefined) {
const filters = JSON.stringify($filterStore) const filters = JSON.stringify($filterStore)
await client.update(filter, { await client.update(filter, {
@@ -81,67 +85,21 @@
} }
} }
const resolveMode = async (id: Ref<FilterMode>): Promise<FilterMode | undefined> =>
await client.findOne(view.class.FilterMode, { _id: id })
const makeQuery = reduceCalls(async (query: DocumentQuery<Doc>, filters: Filter[]): Promise<void> => { const makeQuery = reduceCalls(async (query: DocumentQuery<Doc>, filters: Filter[]): Promise<void> => {
const newQuery = hierarchy.clone(query) // Pass a real refresh callback (6th arg) instead of the no-op default in
for (let i = 0; i < filters.length; i++) { // makeFilterQuery. A filter's async result-fn invokes refresh when its
const filter = filters[i] // underlying data resolves/changes (e.g. lookup filters that load their
const mode = await client.findOne(view.class.FilterMode, { _id: filter.mode }) // value set asynchronously) so the query must be rebuilt and re-dispatched.
if (mode === undefined) continue // This restores develop's behaviour, where the inline makeQuery passed
const result = await getResource(mode.result) // `() => makeQuery(query, filters)` to each filter's result(). reduceCalls
const newValue = await result(filter, () => { // coalesces the re-entrant call, so this cannot spin into an infinite loop.
makeQuery(query, filters) const next = await makeFilterQuery(query, filters, resolveMode, undefined, hierarchy, () => {
}) void makeQuery(query, filters)
})
let filterKey = filter.key.key dispatch('change', next)
const attr = client.getHierarchy().getAttribute(filter.key._class, filter.key.key)
if (client.getHierarchy().isMixin(attr.attributeOf)) {
filterKey = attr.attributeOf + '.' + filter.key.key
}
if (newQuery[filterKey] === null || newQuery[filterKey] === undefined) {
newQuery[filterKey] = newValue
} else {
let merged = false
for (const key in newValue) {
if (newQuery[filterKey][key] === undefined) {
if (key === '$in' && typeof newQuery[filterKey] === 'string') {
newQuery[filterKey] = { $in: newValue[key].filter((p: any) => p === newQuery[filterKey]) }
} else {
newQuery[filterKey][key] = newValue[key]
}
merged = true
continue
}
if (key === '$in') {
newQuery[filterKey][key] = newQuery[filterKey][key].filter((p: any) => newValue[key].includes(p))
merged = true
continue
}
if (key === '$nin') {
newQuery[filterKey][key] = [...newQuery[filterKey][key], ...newValue[key]]
merged = true
continue
}
if (key === '$lt') {
newQuery[filterKey][key] =
newQuery[filterKey][key] < newValue[key] ? newQuery[filterKey][key] : newValue[key]
merged = true
continue
}
if (key === '$gt') {
newQuery[filterKey][key] =
newQuery[filterKey][key] > newValue[key] ? newQuery[filterKey][key] : newValue[key]
merged = true
continue
}
}
if (!merged) {
Object.assign(newQuery[filterKey], newValue)
}
}
}
dispatch('change', newQuery)
}) })
$: makeQuery(query, $filterStore) $: makeQuery(query, $filterStore)
@@ -169,9 +127,18 @@
} }
return false return false
} }
$: hasFilters = $filterStore !== undefined && $filterStore.length > 0
$: showSaveRow =
visible &&
hasFilters &&
!hideSaveButtons &&
canSaveFilteredView &&
(hideChips || selectedFilterChanged($selectedFilterStore, $filterStore, $activeViewlet, $viewOptionStore))
$: showChipRow = visible && hasFilters && !hideChips
</script> </script>
{#if visible && $filterStore && $filterStore.length > 0} {#if showChipRow}
<div class="filterbar-container"> <div class="filterbar-container">
<div class="filters"> <div class="filters">
{#each $filterStore as filter, i} {#each $filterStore as filter, i}
@@ -190,6 +157,22 @@
<div class="add-filter"> <div class="add-filter">
<Button size={'small'} icon={IconAdd} kind={'ghost'} on:click={add} /> <Button size={'small'} icon={IconAdd} kind={'ghost'} on:click={add} />
</div> </div>
<!-- Clear-all affordance for the legacy (non-Tracker) consumers that
render their chips here. The Tracker path sets hideChips=true so
this row never renders — its clear-all lives in <InlineFilterChips>
instead, avoiding a duplicate button. Mirrors the pre-redesign
FilterButton toggle (IconClose + view.string.ClearFilters). -->
<div class="clear-filters">
<Button
size={'small'}
icon={IconClose}
label={view.string.ClearFilters}
kind={'ghost'}
on:click={() => {
setFilters([])
}}
/>
</div>
</div> </div>
{#if !hideSaveButtons && canSaveFilteredView} {#if !hideSaveButtons && canSaveFilteredView}
@@ -215,6 +198,31 @@
</div> </div>
{/if} {/if}
</div> </div>
{:else if showSaveRow}
<!-- Tracker IssuesView path: chips render elsewhere; this row only
shows SaveAs / Save buttons when there is something to save. -->
<div class="filterbar-saveas-container">
<div class="flex gap-1-5">
<Button
icon={view.icon.Views}
label={view.string.SaveAs}
width={'fit-content'}
on:click={async () => {
await saveFilteredView()
}}
/>
{#if selectedFilterChanged($selectedFilterStore, $filterStore, $activeViewlet, $viewOptionStore)}
<Button
icon={view.icon.Views}
label={view.string.Save}
width={'fit-content'}
on:click={async () => {
await saveCurrentFilteredView($selectedFilterStore)
}}
/>
{/if}
</div>
</div>
{/if} {/if}
<style lang="scss"> <style lang="scss">
@@ -223,7 +231,7 @@
grid-template-columns: auto auto; grid-template-columns: auto auto;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: var(--spacing-1) var(--spacing-2) var(--spacing-1) var(--spacing-2); padding: var(--spacing-1) var(--spacing-2);
width: 100%; width: 100%;
min-width: 0; min-width: 0;
background-color: var(--theme-comp-header-color); background-color: var(--theme-comp-header-color);
@@ -241,25 +249,19 @@
.add-filter { .add-filter {
margin-bottom: 0.375rem; margin-bottom: 0.375rem;
} }
.clear-filters {
// .filter-button { margin-bottom: 0.375rem;
// display: flex; margin-left: 0.25rem;
// align-items: baseline; }
// flex-shrink: 0; }
// padding: 0 0.375rem; .filterbar-saveas-container {
// height: 1.5rem; display: flex;
// min-width: 1.5rem; justify-content: flex-end;
// white-space: nowrap; align-items: center;
// line-height: 150%; padding: var(--spacing-1) var(--spacing-2);
// color: var(--accent-color); width: 100%;
// background-color: transparent; min-width: 0;
// border-radius: 0.25rem; background-color: var(--theme-comp-header-color);
// transition-duration: background-color 0.15s ease-in-out; border-bottom: 1px solid var(--theme-divider-color);
// &:hover {
// color: var(--caption-color);
// background-color: var(--noborder-bg-hover);
// }
// }
} }
</style> </style>
@@ -15,12 +15,11 @@
<script lang="ts"> <script lang="ts">
import { Class, Doc, Ref, Space } from '@hcengineering/core' import { Class, Doc, Ref, Space } from '@hcengineering/core'
import { getClient } from '@hcengineering/presentation' import { getClient } from '@hcengineering/presentation'
import { Button, IconFilter, eventToHTMLElement, resolvedLocationStore, showPopup } from '@hcengineering/ui' import { Button, IconAdd, IconFilter, eventToHTMLElement, resolvedLocationStore, showPopup } from '@hcengineering/ui'
import { Filter, ViewOptions } from '@hcengineering/view' import { Filter, ViewOptions } from '@hcengineering/view'
import { filterStore, getFilterKey, selectedFilterStore, setFilters } from '../../filter' import { filterStore, getFilterKey, selectedFilterStore, updateFilter } from '../../filter'
import view from '../../plugin' import view from '../../plugin'
import FilterTypePopup from './FilterTypePopup.svelte' import FilterTypePopup from './FilterTypePopup.svelte'
import IconClose from '../icons/Close.svelte'
import { onDestroy } from 'svelte' import { onDestroy } from 'svelte'
export let _class: Ref<Class<Doc>> | undefined export let _class: Ref<Class<Doc>> | undefined
@@ -54,8 +53,13 @@
save(_class, p) save(_class, p)
}) })
function nextFilterIndex (): number {
const current = $filterStore.map((f) => f.index)
return current.length === 0 ? 1 : Math.max(...current) + 1
}
function onChange (e: Filter | undefined): void { function onChange (e: Filter | undefined): void {
if (e !== undefined) setFilters([e]) if (e !== undefined) updateFilter(e)
} }
onDestroy(() => { onDestroy(() => {
@@ -70,7 +74,7 @@
_class, _class,
space, space,
target, target,
index: 1, index: nextFilterIndex(),
onChange, onChange,
viewOptions viewOptions
}, },
@@ -87,16 +91,16 @@
</script> </script>
{#if visible} {#if visible}
<!-- Always opens the add-filter popup. Clearing all filters is a
separate affordance (see <InlineFilterChips> trailing "Clear all"
button); the old toggle behaviour left users with no way to add a
second filter once one was present. -->
<Button <Button
icon={$filterStore.length === 0 ? IconFilter : IconClose} icon={$filterStore.length === 0 ? IconFilter : IconAdd}
label={adaptive ? undefined : $filterStore.length === 0 ? view.string.Filter : view.string.ClearFilters} label={adaptive ? undefined : $filterStore.length === 0 ? view.string.Filter : view.string.AddFilter}
kind={'regular'} kind={'regular'}
size={'medium'} size={'medium'}
pressed={$filterStore.length > 0} showTooltip={{ label: $filterStore.length === 0 ? view.string.Filter : view.string.AddFilter }}
showTooltip={{ label: $filterStore.length === 0 ? view.string.Filter : view.string.ClearFilters }} on:click={add}
on:click={(ev) => {
if ($filterStore.length === 0) add(ev)
else setFilters([])
}}
/> />
{/if} {/if}
@@ -0,0 +1,312 @@
<!--
// Copyright © 2026 Hardcore Engineering Inc.
// SPDX-License-Identifier: EPL-2.0
-->
<!--
Presentational chip strip for active filters. Renders the active filter
list in a single horizontal row; trailing chips collapse to a "+N" button
+ popover when the row is too narrow. The query/data path is owned by
<FilterBar hideChips=true> mounted by the same consumer — this component
is display-only and never dispatches change events.
Consumers that need both visual chips inline AND the SaveAs row below
mount this together with <FilterBar hideChips=true ...>.
-->
<script lang="ts">
import type { Class, Doc, Ref, Space } from '@hcengineering/core'
import type { PopupResult } from '@hcengineering/ui'
import { Button, IconClose, Label, eventToHTMLElement, resizeObserver, showPopup, tooltip } from '@hcengineering/ui'
import { onDestroy, tick } from 'svelte'
import { filterStore, removeFilter, setFilters } from '../../filter'
import view from '../../plugin'
import FilterSection from './FilterSection.svelte'
import InlineFilterChipsOverflow from './InlineFilterChipsOverflow.svelte'
import { computeOverflow } from './InlineFilterChips.svelte.helpers'
export let _class: Ref<Class<Doc>> | undefined
export let space: Ref<Space> | undefined
// Overflow collapsing + the 22rem width cap exist ONLY to keep a crowded
// constrained toolbar row from being pushed off-screen. In the below-header /
// list placement there is a full-width row available, so collapsing chips
// into the "+N" popover just hides the active filter (and removes
// div.filter-section from the DOM). `constrained` opts INTO the toolbar
// behaviour; the default renders every chip inline like the legacy bar.
export let constrained: boolean = false
let containerWidth = 0
let chipEls: HTMLElement[] = []
let badgeEl: HTMLElement | undefined
let visibleCount = 0
let hiddenCount = 0
const widthByIndex = new Map<number, number>()
// Fallback reserve until the real "+N" badge has been measured once. The
// previous 64px was ~2x the real badge (~32px), so it over-reserved and,
// combined with a content-shrinking measurement base, drained chips behind
// "+N" and never let them re-expand. We measure the live badge below
// and cache it; this constant only seeds the first pass.
const BADGE_FALLBACK_PX = 32
let badgeWidth = BADGE_FALLBACK_PX
// Inter-chip flex gap (`gap: var(--spacing-1)` = 0.25rem = 4px) fed into the
// overflow math so the decision matches the real layout.
const GAP_PX = 4
// Reset chipEls + per-index width cache whenever the filter SET changes
// (additions or deletions). Re-keying by filter.index keeps width
// measurements stable across cycle adds/removes that don't change the
// existing filters' visual width — avoiding the all-chips-then-collapse
// flash on every store mutation. The seed visibleCount = filterStore.length
// makes the next paint render every chip so unmeasured chips can be
// measured by recompute().
let lastIndexes: number[] = []
$: {
const currentIndexes = $filterStore.map((f) => f.index)
const sameSet = currentIndexes.length === lastIndexes.length && currentIndexes.every((v, i) => v === lastIndexes[i])
if (!sameSet) {
visibleCount = $filterStore.length
hiddenCount = 0
chipEls = []
// Drop stale entries for filters that are no longer present.
const live = new Set(currentIndexes)
for (const k of Array.from(widthByIndex.keys())) {
if (!live.has(k)) widthByIndex.delete(k)
}
lastIndexes = currentIndexes
}
}
async function recompute (): Promise<void> {
await tick()
// Unconstrained (below-header/list) placement never collapses: keep every
// chip visible so its div.filter-section stays in the DOM.
if (!constrained) {
if (visibleCount !== $filterStore.length || hiddenCount !== 0) {
visibleCount = $filterStore.length
hiddenCount = 0
}
return
}
if (containerWidth === 0) return
const filters = $filterStore
if (filters.length === 0) return
// Update the per-index width cache for any chip currently rendered.
// Chips that were collapsed previously won't be in chipEls yet — they
// get measured the next time visibleCount = filters.length seeds them.
for (let i = 0; i < Math.min(filters.length, chipEls.length); i++) {
const w = chipEls[i]?.getBoundingClientRect().width ?? 0
if (w > 0) widthByIndex.set(filters[i].index, w)
}
// Measure the real "+N" badge once it is in the DOM so we reserve its
// actual width instead of the 64px over-estimate that fuelled the
// collapse spiral. Falls back to BADGE_FALLBACK_PX until first measured.
const bw = badgeEl?.getBoundingClientRect().width ?? 0
if (bw > 0) badgeWidth = bw
// Fall back to a conservative estimate for any filter we haven't
// measured yet — keeps overflow math monotonic while the missing chip
// gets a chance to render in the next pass.
const widths = filters.map((f) => widthByIndex.get(f.index) ?? 120)
const r = computeOverflow(widths, containerWidth, badgeWidth, GAP_PX)
if (r.visibleCount !== visibleCount || r.hiddenCount !== hiddenCount) {
visibleCount = r.visibleCount
hiddenCount = r.hiddenCount
}
}
// Re-run on filter-store or container-width change. The `void` references
// make the reactive block depend on both even though `recompute` is async.
$: {
void $filterStore
void containerWidth
void recompute()
}
function onContainerResize (el: Element): void {
const w = (el as HTMLElement).clientWidth
if (w !== containerWidth) containerWidth = w
}
// The popover receives `hiddenStartIndex` as a one-shot snapshot of
// visibleCount, but its {#each $filterStore} body is live. If the filter set
// (or visibleCount) changes while it is open — e.g. a chip removed from the
// main strip or from inside the popover — that snapshot points at the wrong
// subset. Close the popover on the first store mutation so the split-point can
// never drift out of sync with the live store.
let overflowPopup: PopupResult | undefined
let unsubOverflow: (() => void) | undefined
function cleanupOverflow (): void {
unsubOverflow?.()
unsubOverflow = undefined
overflowPopup = undefined
}
function openOverflowPopover (e: MouseEvent): void {
overflowPopup = showPopup(
InlineFilterChipsOverflow,
{ hiddenStartIndex: visibleCount, space },
eventToHTMLElement(e),
() => {
// User-initiated close (escape / click-outside): drop the subscription.
cleanupOverflow()
}
)
// subscribe() fires synchronously once on subscription; skip that seed call
// and dismiss on the first real filter-set mutation.
let seeded = false
unsubOverflow = filterStore.subscribe(() => {
if (!seeded) {
seeded = true
return
}
overflowPopup?.close()
cleanupOverflow()
})
}
onDestroy(() => {
overflowPopup?.close()
cleanupOverflow()
})
// Reference _class to keep the prop usable for future affordances (chip-add
// popovers reuse it). The current component does not need it because the
// add affordance lives on FilterButton, but consumers pass it for symmetry
// with <FilterBar />.
$: void _class
</script>
<!-- `empty` collapses the constrained placement away entirely. The fixed
22rem below is a deliberate measurement base (see the note on the rule),
but reserving it while there is not a single filter to show simply ate
352 px of a constrained toolbar row in the default state — the very space the
trailing controls cluster was being pushed out of. With no
filters there is nothing to measure and nothing to render, so the whole
wrap (chips container plus the clear-all button, which is itself gated on
a non-empty store) can go. -->
<div class="inline-filter-chips-wrap" class:constrained class:empty={$filterStore.length === 0}>
<!-- Inner container is the scroll/measurement viewport. The wrap layer
above caps the width so the chip cluster cannot push the rest of
the toolbar off-screen. computeOverflow uses the inner container
width via ResizeObserver; once chips overflow it collapses them
into the +N popover. -->
<div class="inline-filter-chips" use:resizeObserver={onContainerResize}>
{#each $filterStore as filter, i (filter.index)}
{#if i < visibleCount}
<span bind:this={chipEls[i]} class="chip-slot">
<FilterSection
{space}
{filter}
on:remove={() => {
removeFilter(i)
}}
/>
</span>
{/if}
{/each}
{#if hiddenCount > 0}
<!-- Bright accent badge so an active-but-collapsed filter is
immediately obvious instead of looking like a passive helper. -->
<button
bind:this={badgeEl}
class="filter-overflow-badge"
type="button"
aria-haspopup="dialog"
aria-expanded={overflowPopup !== undefined}
aria-label={`+${hiddenCount} hidden filters`}
use:tooltip={{ label: view.string.HiddenFilters }}
on:click={openOverflowPopover}
>
+{hiddenCount}
</button>
{/if}
</div>
{#if $filterStore.length > 0}
<!-- Trailing clear-all action. Lives OUTSIDE the chip container so it
is never measured for overflow and stays visible regardless of
how many chips are present. -->
<Button
kind="ghost"
size="small"
icon={IconClose}
label={view.string.ClearFilters}
on:click={() => {
setFilters([])
}}
/>
{/if}
</div>
<style lang="scss">
.inline-filter-chips-wrap {
display: flex;
align-items: center;
gap: var(--spacing-1);
min-width: 0;
flex: 0 1 auto;
}
/* Fixed cluster width — beyond this, the +N popover takes over. Only
applied in a constrained toolbar placement where chips would otherwise
push the trailing toolbar controls off-screen. The below-header/list
placement has a full-width row and must render every chip.
Use a FIXED width (not max-width) so the overflow measurement base
stays constant across collapse cycles. With max-width the wrap was
content-sized, so once chips collapsed the measured width shrank, which
collapsed more chips — a one-way ratchet that drained everything behind
"+N". A fixed reference width lets computeOverflow re-test the full
budget and re-expand chips when the filter set shrinks. */
.inline-filter-chips-wrap.constrained {
width: 22rem;
}
.inline-filter-chips-wrap.constrained.empty {
display: none;
}
.inline-filter-chips {
display: flex;
align-items: center;
gap: var(--spacing-1);
flex-wrap: wrap;
min-width: 0;
flex: 1 1 auto;
}
.inline-filter-chips-wrap.constrained .inline-filter-chips {
flex-wrap: nowrap;
overflow: hidden;
flex: 1 1 0;
}
.chip-slot {
flex-shrink: 0;
}
.filter-overflow-badge {
display: inline-flex;
align-items: center;
flex-shrink: 0;
height: 1.5rem;
padding: 0 0.5rem;
border: none;
border-radius: 999px;
/* Use the attention/warning accent rather than the destructive-red
state colour — "filter active" is a state cue, not a destructive
affordance. The negative colour stays reserved for delete/abort. */
background: var(--theme-state-attention-color, var(--theme-warning-color));
color: var(--theme-state-attention-color-foreground, white);
font-size: 0.75rem;
font-weight: 600;
line-height: 1;
cursor: pointer;
white-space: nowrap;
&:hover {
filter: brightness(1.1);
}
&:focus-visible {
outline: 2px solid var(--theme-state-attention-color, var(--theme-warning-color));
outline-offset: 1px;
}
}
/* Dims the clear-all ghost button's IconClose so it reads as a secondary
affordance next to the active chips. */
:global(.inline-filter-chips-wrap .button.ghost.small svg) {
opacity: 0.7;
}
</style>
@@ -0,0 +1,42 @@
//
// Copyright © 2026 Hardcore Engineering Inc.
// SPDX-License-Identifier: EPL-2.0
//
/**
* Compute how many filter chips fit into a container before they need to be
* collapsed into a "+N" overflow badge. Pure function invoked from the
* Svelte component's ResizeObserver callback.
*
* `chipWidths`: measured px-widths of each chip in insertion order.
* `containerWidth`: available px width.
* `badgeWidth`: reserved px for the "+N" button when overflow happens.
* `gap`: inter-chip flex gap in px. Counted between adjacent chips
* both in the fit-all check and while greedily filling, so the overflow
* decision matches what the browser actually lays out.
*
* If all chips fit: { visibleCount: chipWidths.length, hiddenCount: 0 }.
* Else: greedily fit chips left-to-right, reserving `badgeWidth` for the
* overflow indicator; remaining chips count toward `hiddenCount`.
*/
export function computeOverflow (
chipWidths: number[],
containerWidth: number,
badgeWidth: number,
gap: number = 0
): { visibleCount: number, hiddenCount: number } {
const n = chipWidths.length
const total = chipWidths.reduce((s, w) => s + w, 0) + Math.max(0, n - 1) * gap
if (total <= containerWidth) {
return { visibleCount: n, hiddenCount: 0 }
}
const allowed = containerWidth - badgeWidth
let used = 0
let visible = 0
for (const w of chipWidths) {
const add = w + (visible > 0 ? gap : 0)
if (used + add > allowed) break
used += add
visible++
}
return { visibleCount: visible, hiddenCount: n - visible }
}
@@ -0,0 +1,62 @@
<!--
// Copyright © 2026 Hardcore Engineering Inc.
// SPDX-License-Identifier: EPL-2.0
-->
<script lang="ts">
import type { Ref, Space } from '@hcengineering/core'
import { Label } from '@hcengineering/ui'
import { filterStore, removeFilter } from '../../filter'
import view from '../../plugin'
import FilterSection from './FilterSection.svelte'
export let hiddenStartIndex: number = 0
export let space: Ref<Space> | undefined = undefined
</script>
<div class="filter-overflow-popover">
<div class="title"><Label label={view.string.HiddenFilters} /></div>
<div class="list">
{#each $filterStore as filter, i (filter.index)}
{#if i >= hiddenStartIndex}
<div class="row">
<FilterSection
{filter}
{space}
on:remove={() => {
removeFilter(i)
}}
/>
</div>
{/if}
{/each}
</div>
</div>
<style lang="scss">
.filter-overflow-popover {
padding: var(--spacing-2);
background: var(--theme-popup-color);
border: 1px solid var(--theme-popup-divider);
border-radius: var(--small-BorderRadius);
box-shadow: var(--theme-popup-shadow);
max-width: 480px;
/* Stack above underlying grid content that shows through when
--theme-popup-color is partially transparent in custom themes. */
backdrop-filter: blur(2px);
}
.title {
font-weight: 500;
font-size: 0.875rem;
margin-bottom: var(--spacing-1_5);
color: var(--theme-caption-color);
}
.list {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
}
.row {
display: flex;
align-items: center;
}
</style>
@@ -27,8 +27,9 @@
import { createQuery, getClient, reduceCalls } from '@hcengineering/presentation' import { createQuery, getClient, reduceCalls } from '@hcengineering/presentation'
import { AnyComponent, AnySvelteComponent } from '@hcengineering/ui' import { AnyComponent, AnySvelteComponent } from '@hcengineering/ui'
import { BuildModelKey, ViewOptionModel, ViewOptions, Viewlet } from '@hcengineering/view' import { BuildModelKey, ViewOptionModel, ViewOptions, Viewlet } from '@hcengineering/view'
import { createEventDispatcher } from 'svelte' import { createEventDispatcher, onDestroy } from 'svelte'
import { SelectionFocusProvider } from '../../selection' import { SelectionFocusProvider } from '../../selection'
import { claimResultCountOwner, releaseResultCountOwner, setResultCount } from '../../stores'
import { buildConfigLookup } from '../../utils' import { buildConfigLookup } from '../../utils'
import { getResultOptions, getResultQuery } from '../../viewOptions' import { getResultOptions, getResultQuery } from '../../viewOptions'
import ListCategories from './ListCategories.svelte' import ListCategories from './ListCategories.svelte'
@@ -55,6 +56,18 @@
export let listProvider: SelectionFocusProvider export let listProvider: SelectionFocusProvider
export let singleCategoryLimit: number | undefined = undefined export let singleCategoryLimit: number | undefined = undefined
export let readonly: boolean = false export let readonly: boolean = false
// Opt-in participation in the shared result-count protocol, evaluated once at
// mount. Defaults to `false` so the only List instance that touches the count
// store is the one that explicitly opts in — the PRIMARY tracker viewlet
// (ListView passes `true`). Every other List mount stays out by default:
// embedded sub-issues / related issues in an issue panel, card-panel children,
// process extensions, and any future embedding. An embedded List that claimed
// the owner token would supersede the primary list's token, then release it on
// close — stranding the primary list with a dead token whose future writes are
// no-ops, so the zero-hit card could never render again. Default-out keeps the
// primary viewlet the sole owner and makes new embeddings safe without having
// to remember to opt out.
export let reportResultCount: boolean = false
const limiter = new RateLimiter(10) const limiter = new RateLimiter(10)
@@ -65,6 +78,23 @@
let fastDocs: Doc[] = [] let fastDocs: Doc[] = []
let slowDocs: Doc[] = [] let slowDocs: Doc[] = []
// The opted-in PRIMARY viewlet writes its result-count into the shared
// result-count store (via the owner-token gate) so IssuesView's SearchEmptyState
// card can render when search has no matches. We claim ownership at init so a
// viewlet torn down after us can no longer clobber our count, and release on
// destroy. Instances that leave `reportResultCount` at its default (`false`)
// get no owner and never touch the gate — see the prop comment above.
// queryReady is RE-armed false on every query change (`$: queryReady = false`
// below) so a stale count from a previous query never lingers as truth —
// without this reset the SearchEmptyState card could remain stuck on `0`
// after a successful search produced new results.
const resultCountOwner = reportResultCount ? claimResultCountOwner() : undefined
let queryReady = false
$: if (queryReady && resultCountOwner !== undefined) setResultCount(resultCountOwner, docs.length)
onDestroy(() => {
if (resultCountOwner !== undefined) releaseResultCountOwner(resultCountOwner)
})
$: orderBy = viewOptions.orderBy $: orderBy = viewOptions.orderBy
const docsQuery = createQuery() const docsQuery = createQuery()
@@ -92,6 +122,22 @@
$: void update(query, viewOptions) $: void update(query, viewOptions)
$: queryNoLookup = noLookup(resultQuery) $: queryNoLookup = noLookup(resultQuery)
// Re-arm queryReady whenever the underlying query mutates so the result
// count stops claiming the previous query's outcome (see comment above).
//
// Re-arm only when the query CONTENT changes, not on every new object
// identity. `queryNoLookup` is rebuilt as a fresh object on each cycle, but
// createQuery() skips the callback for a deep-equal query — so resetting
// queryReady on identity alone would strand it at `false` (callback never
// re-fires) and the count/SearchEmptyState would stay stale forever.
let lastQuerySig: string | undefined
$: {
const sig = JSON.stringify(queryNoLookup)
if (sig !== lastQuerySig) {
lastQuerySig = sig
queryReady = false
}
}
let fastQueryIds = new Set<Ref<Doc>>() let fastQueryIds = new Set<Ref<Doc>>()
@@ -112,6 +158,7 @@
(res) => { (res) => {
fastDocs = res fastDocs = res
fastQueryIds = new Set(res.map((it) => it._id)) fastQueryIds = new Set(res.map((it) => it._id))
queryReady = true
}, },
{ ...categoryQueryOptions, limit: 1000 } { ...categoryQueryOptions, limit: 1000 }
) )
@@ -82,6 +82,7 @@
> >
<List <List
bind:this={list} bind:this={list}
reportResultCount={true}
{_class} {_class}
{space} {space}
{query} {query}
+5 -8
View File
@@ -36,19 +36,16 @@ export function setFilters (filters: Filter[]): void {
export function removeFilter (i: number): void { export function removeFilter (i: number): void {
const old = get(filterStore) const old = get(filterStore)
old[i]?.onRemove?.() old[i]?.onRemove?.()
old.splice(i, 1) // Emit a fresh array rather than splicing in place, so reference-memo
filterStore.set(old) // consumers (and reduceCalls queue coalescing) always see a new identity.
filterStore.set(old.filter((_, idx) => idx !== i))
} }
export function updateFilter (filter: Filter): void { export function updateFilter (filter: Filter): void {
const old = get(filterStore) const old = get(filterStore)
const index = old.findIndex((p) => p.index === filter.index) const index = old.findIndex((p) => p.index === filter.index)
if (index === -1) { // Build a new array instead of mutating `old` in place.
old.push(filter) filterStore.set(index === -1 ? [...old, filter] : old.map((p, idx) => (idx === index ? filter : p)))
} else {
old[index] = filter
}
filterStore.set(old)
} }
export async function arrayAllResult (filter: Filter): Promise<ObjQueryType<any>> { export async function arrayAllResult (filter: Filter): Promise<ObjQueryType<any>> {
@@ -0,0 +1,117 @@
//
// Copyright © 2026 Hardcore Engineering Inc.
// SPDX-License-Identifier: EPL-2.0
//
import type { Doc, DocumentQuery, Hierarchy, Ref } from '@hcengineering/core'
import { getResource } from '@hcengineering/platform'
import type { Resource } from '@hcengineering/platform'
import type { Filter, FilterMode } from '@hcengineering/view'
/**
* Pure (no Svelte) helper extracted from FilterBar.svelte:81-142. Composes
* `filters` into a `DocumentQuery<Doc>` by chaining each filter's mode-result
* onto the existing `base`. Used by InlineFilterChips and the slim FilterBar
* SaveAs container so both render-paths share a single source of truth.
*
* NOTE: `mode.result` is a `Resource<>` (platform registry id), NOT a
* callable function. We resolve it via `getResource(mode.result)` before
* invocation. This mirrors FilterBar.svelte:87 exactly. Calling
* `mode.result(filter)` directly would throw at runtime.
*
* AND-semantics across filters on the same key: when two filters target the
* same attribute, their result-operators are merged (intersection on $in,
* union on $nin, tighter bound on $lt/$gt). Matches FilterBar's reduce loop.
*/
export type FilterModeResolver = (id: Ref<FilterMode>) => Promise<FilterMode | undefined>
export type ResourceResolver = <T>(resource: Resource<T>) => Promise<T>
type ResultFn = (filter: Filter, refresh: () => void) => Promise<unknown>
/**
* Default resource resolver uses the platform's getResource. Exposed as a
* parameter so unit tests can inject a mock without spinning up the full
* plugin runtime.
*/
const defaultResolveResource: ResourceResolver = async <T>(r: Resource<T>) => await getResource(r)
/**
* Deep-clone a query so the helper never mutates the caller's `base`.
* Prefers `hierarchy.clone()` when available (it knows about huly's
* domain model and may preserve special types); falls back to
* `structuredClone` (Node 17+, all modern browsers), and finally to a
* JSON-roundtrip for the simple `DocumentQuery` shape that filters
* actually compose against. This mirrors `FilterBar.svelte:83` which
* always did `hierarchy.clone(query)` before mutating.
*/
function cloneQuery (base: DocumentQuery<Doc>, hierarchy?: Hierarchy): DocumentQuery<Doc> {
if (hierarchy !== undefined) return hierarchy.clone(base)
if (typeof structuredClone === 'function') return structuredClone(base)
return JSON.parse(JSON.stringify(base))
}
export async function makeFilterQuery (
base: DocumentQuery<Doc>,
filters: Filter[],
resolveMode: FilterModeResolver,
resolveResource: ResourceResolver = defaultResolveResource,
hierarchy?: Hierarchy,
refresh: () => void = () => {}
): Promise<DocumentQuery<Doc>> {
const out: DocumentQuery<Doc> = cloneQuery(base, hierarchy)
for (let i = 0; i < filters.length; i++) {
const filter = filters[i]
const mode = await resolveMode(filter.mode)
if (mode === undefined) continue
const resultFn = await resolveResource<ResultFn>(mode.result as unknown as Resource<ResultFn>)
const result: any = await resultFn(filter, refresh)
let filterKey = filter.key.key
if (hierarchy !== undefined) {
const attr = hierarchy.getAttribute(filter.key._class, filter.key.key)
if (hierarchy.isMixin(attr.attributeOf)) {
filterKey = (attr.attributeOf as string) + '.' + filter.key.key
}
}
const existing = (out as any)[filterKey]
if (existing == null) {
;(out as any)[filterKey] = result
continue
}
let merged = false
for (const key in result) {
if (existing[key] === undefined) {
if (key === '$in' && typeof existing === 'string') {
;(out as any)[filterKey] = { $in: (result[key] as any[]).filter((p) => p === existing) }
} else {
existing[key] = result[key]
}
merged = true
continue
}
if (key === '$in') {
existing[key] = (existing[key] as any[]).filter((p) => (result[key] as any[]).includes(p))
merged = true
} else if (key === '$nin') {
existing[key] = [...(existing[key] as any[]), ...(result[key] as any[])]
merged = true
} else if (key === '$lt') {
existing[key] = existing[key] < result[key] ? existing[key] : result[key]
merged = true
} else if (key === '$gt') {
existing[key] = existing[key] > result[key] ? existing[key] : result[key]
merged = true
} else if (key === '$lte') {
// Date filters emit $lte/$gte (before/after/dateToday). Two
// filters on one key must intersect — the tighter upper bound wins.
existing[key] = existing[key] < result[key] ? existing[key] : result[key]
merged = true
} else if (key === '$gte') {
// Tighter lower bound wins.
existing[key] = existing[key] > result[key] ? existing[key] : result[key]
merged = true
}
}
if (!merged) Object.assign(existing, result)
}
return out
}
+14
View File
@@ -182,6 +182,7 @@ export { default as ViewletClassSettings } from './components/ViewletClassSettin
export { default as ViewletSelector } from './components/ViewletSelector.svelte' export { default as ViewletSelector } from './components/ViewletSelector.svelte'
export { default as ViewletsSettingButton } from './components/ViewletsSettingButton.svelte' export { default as ViewletsSettingButton } from './components/ViewletsSettingButton.svelte'
export { default as FilterButton } from './components/filter/FilterButton.svelte' export { default as FilterButton } from './components/filter/FilterButton.svelte'
export { default as InlineFilterChips } from './components/filter/InlineFilterChips.svelte'
export { default as FilterRemovedNotification } from './components/filter/FilterRemovedNotification.svelte' export { default as FilterRemovedNotification } from './components/filter/FilterRemovedNotification.svelte'
export { default as PersonIdFilter } from './components/filter/PersonIdFilter.svelte' export { default as PersonIdFilter } from './components/filter/PersonIdFilter.svelte'
export { default as PersonIdFilterValuePresenter } from './components/filter/PersonIdFilterValuePresenter.svelte' export { default as PersonIdFilterValuePresenter } from './components/filter/PersonIdFilterValuePresenter.svelte'
@@ -194,6 +195,19 @@ export { default as StatusRefPresenter } from './components/status/StatusRefPres
export { canArchiveSpace, canDeleteObject, canDeleteSpace, canEditSpace } from './visibilityTester' export { canArchiveSpace, canDeleteObject, canDeleteSpace, canEditSpace } from './visibilityTester'
export * from './filter' export * from './filter'
export { makeFilterQuery } from './filter/query-builder'
export {
resultIssueCountStore,
rawSearchTextStore,
searchHighlightEnabledStore,
claimResultCountOwner,
setResultCount,
releaseResultCountOwner,
resetResultCount
} from './stores'
export type { ResultCountOwner } from './stores'
export { default as SearchEmptyState } from './components/SearchEmptyState.svelte'
export { shouldShowEmptyState, shouldShowSearchEmptyState } from './searchEmptyState'
export * from './icons' export * from './icons'
export * from './middleware' export * from './middleware'
export * from './objectIterator' export * from './objectIterator'
+4 -1
View File
@@ -56,6 +56,8 @@ export default mergeIds(viewId, view, {
RestoreDefaults: '' as IntlString, RestoreDefaults: '' as IntlString,
Filter: '' as IntlString, Filter: '' as IntlString,
ClearFilters: '' as IntlString, ClearFilters: '' as IntlString,
AddFilter: '' as IntlString,
HiddenFilters: '' as IntlString,
FilterIsNot: '' as IntlString, FilterIsNot: '' as IntlString,
FilterIsEither: '' as IntlString, FilterIsEither: '' as IntlString,
FilterIsEitherPlural: '' as IntlString, FilterIsEitherPlural: '' as IntlString,
@@ -104,7 +106,8 @@ export default mergeIds(viewId, view, {
EmojiCategory: '' as IntlString, EmojiCategory: '' as IntlString,
NumberItems: '' as IntlString, NumberItems: '' as IntlString,
ToViewCommands: '' as IntlString, ToViewCommands: '' as IntlString,
NoRelations: '' as IntlString NoRelations: '' as IntlString,
FilterOverflowBadge: '' as IntlString
}, },
function: { function: {
CreateDocMiddleware: '' as Resource<PresentationMiddlewareCreator>, CreateDocMiddleware: '' as Resource<PresentationMiddlewareCreator>,
@@ -0,0 +1,34 @@
//
// Copyright © 2026 Hardcore Engineering Inc.
// SPDX-License-Identifier: EPL-2.0
//
/**
* Pure predicate: should the SearchEmptyState card render?
*
* True iff the user has typed something AND the viewlet reports zero hits.
* `resultCount === -1` is the "not yet measured" sentinel (set on viewlet
* teardown / before the first store write) and must NOT trigger the
* empty-state otherwise route transitions would flash the card.
*/
export function shouldShowEmptyState (searchText: string, resultCount: number): boolean {
return searchText.trim() !== '' && resultCount === 0
}
/**
* Pure policy: should the SearchEmptyState card be rendered at all?
*
* The card never replaces the viewlet the viewlet stays mounted and laid out
* and the card renders as a non-suppressive sibling below it (see the comment
* in IssuesView.svelte for why unmounting/hiding the viewlet is not an option).
* This predicate only gates the card itself: on a zero-hit search it shows,
* unless the explicit "show empty groups" view option (`shouldShowAll`) is on
* then the empty groups / Kanban columns stay visible and the card is
* suppressed, keeping the user's explicit choice authoritative.
*/
export function shouldShowSearchEmptyState (
searchText: string,
resultCount: number,
shouldShowAll: boolean | undefined
): boolean {
return shouldShowEmptyState(searchText, resultCount) && shouldShowAll !== true
}
+105
View File
@@ -0,0 +1,105 @@
//
// Copyright © 2026 Hardcore Engineering Inc.
// SPDX-License-Identifier: EPL-2.0
//
import { writable, type Readable } from 'svelte/store'
/**
* Latest result-count from whichever viewlet is currently mounted.
* Sentinel `-1` = "no current measurement" so consumers stay in their
* default branch during teardown / route changes.
*
* The writable is module-private; the public export is a `Readable` so the
* owner-token gate below is the ONLY write path. Consumers (IssuesView's
* SearchEmptyState) subscribe to the read-only store directly and unchanged.
*/
const resultIssueCountWritable = writable<number>(-1)
export const resultIssueCountStore: Readable<number> = resultIssueCountWritable
/**
* Owner-token gate for {@link resultIssueCountStore}.
*
* WHY: several viewlets write the shared count (List, KanbanView, and after
* PR-2 GanttView), while the container (IssuesView) resets it on query/filter
* changes and reads it for the zero-hit empty state. When the user switches
* viewlet, the outgoing viewlet's teardown and the incoming viewlet's mount
* both touch the store. Previously correctness relied on a non-local framework
* invariant (Svelte's destroy-before-mount ordering): a late teardown `.set(-1)`
* from the outgoing viewlet could clobber the incoming viewlet's fresh count.
*
* The gate makes writes order-independent: each writer claims a token at
* mount/init, which becomes the current owner; only the current owner's writes
* take effect, and a `release()` from a superseded (non-current) owner is a
* no-op. A viewlet torn down AFTER its successor already claimed can therefore
* no longer reset the count out from under the new viewlet.
*
* ASSUMPTION: at most ONE IssuesView surface is active at a time (Issues / My
* Issues route to the same IssuesView, only one mounted). The count store is a
* process-global singleton, so two simultaneously-visible IssuesView surfaces
* would share it; that is out of scope by design (the proportional fix here is
* the token gate, not per-instance scoping of the generic List component).
*/
export interface ResultCountOwner {
readonly id: symbol
}
let currentResultCountOwner: ResultCountOwner | undefined
/**
* Claim ownership of the result-count store. The returned token becomes the
* current owner immediately, superseding any previous owner (whose subsequent
* writes and releases turn into no-ops). Call at viewlet mount/init.
*/
export function claimResultCountOwner (): ResultCountOwner {
const owner: ResultCountOwner = { id: Symbol('resultCountOwner') }
currentResultCountOwner = owner
return owner
}
/**
* Write a measured count. Ignored unless `owner` is the current owner, so a
* superseded viewlet can never overwrite the active viewlet's count.
*/
export function setResultCount (owner: ResultCountOwner, count: number): void {
if (owner !== currentResultCountOwner) return
resultIssueCountWritable.set(count)
}
/**
* Release ownership at viewlet teardown. Resets the store to the `-1` sentinel
* ONLY when `owner` is still the current owner a release from a superseded
* owner (successor already claimed) is a no-op and leaves the successor's count
* intact.
*/
export function releaseResultCountOwner (owner: ResultCountOwner): void {
if (owner !== currentResultCountOwner) return
currentResultCountOwner = undefined
resultIssueCountWritable.set(-1)
}
/**
* Reset the count to the `-1` "pending" sentinel on a query/filter change,
* WITHOUT surrendering ownership the current owner viewlet stays authoritative
* and its next {@link setResultCount} re-populates the value once the new query
* resolves. Called by the container (IssuesView), which drives query/filter
* state but does not hold the viewlet's token; the reset therefore operates on
* whatever the current owner is.
*/
export function resetResultCount (): void {
resultIssueCountWritable.set(-1)
}
/**
* Raw user-typed search text (NOT the encoded $search wire form).
* HighlightedText reads this; SearchInputAdvanced consumer (IssuesView)
* writes it on every debounced change event.
*/
export const rawSearchTextStore = writable<string>('')
/**
* Customize-View toggle: whether matched substrings should be visually
* highlighted in result rows. Defaults to true. The IssuesView consumer
* mirrors `viewOptions.searchHighlight` into this store so HighlightedText
* can short-circuit to a no-op when the user turned highlighting off.
*/
export const searchHighlightEnabledStore = writable<boolean>(true)
@@ -69,6 +69,11 @@ export class CommonTrackerPage extends CalendarPage {
shouldShowAllToggle = (): Locator => shouldShowAllToggle = (): Locator =>
this.page.locator('.antiCard.menu .antiCard-menu__item:has-text("Show empty groups")') this.page.locator('.antiCard.menu .antiCard-menu__item:has-text("Show empty groups")')
// Zero-hit search card. Rendered as an out-of-flow overlay centred on the
// panel while the viewlet stays mounted and measurable, so it is on screen
// in List and Kanban alike — assert with toBeInViewport().
searchEmptyStateCard = (): Locator => this.page.locator('.search-empty-state')
header = (): Locator => header = (): Locator =>
this.page.locator('button.hulyBreadcrumb-container > span.hulyBreadcrumb-label', { hasText: 'Issues' }) this.page.locator('button.hulyBreadcrumb-container > span.hulyBreadcrumb-label', { hasText: 'Issues' })
@@ -148,7 +148,11 @@ export class IssuesPage extends CommonTrackerPage {
issueName = (name: string): Locator => this.page.locator(`text="${name}"`) issueName = (name: string): Locator => this.page.locator(`text="${name}"`)
issuesButton = (): Locator => this.page.locator('.antiPanel-navigator').locator('text="Issues"') issuesButton = (): Locator => this.page.locator('.antiPanel-navigator').locator('text="Issues"')
viewButton = (): Locator => this.page.locator('button[data-id="btn-viewOptions"]') viewButton = (): Locator => this.page.locator('button[data-id="btn-viewOptions"]')
orderingButton = (): Locator => this.page.locator('.ordering button') // The View-Options popup now renders more than one `.ordering` button (the
// Order-by dropdown plus the new searchScope selector). Target the first,
// which is the Order-by control, to keep the locator unambiguous — same fix
// as tracker.utils.ts.
orderingButton = (): Locator => this.page.locator('.ordering button').first()
modifiedDateMenuItem = (): Locator => this.page.locator('button.menu-item', { hasText: 'Modified date' }) modifiedDateMenuItem = (): Locator => this.page.locator('button.menu-item', { hasText: 'Modified date' })
estimationContainer = (): Locator => this.page.locator('.estimation-container').first() estimationContainer = (): Locator => this.page.locator('.estimation-container').first()
addTimeReportButton = (): Locator => this.page.locator('button:has-text("Add time report")') addTimeReportButton = (): Locator => this.page.locator('button:has-text("Add time report")')
@@ -381,10 +385,14 @@ export class IssuesPage extends CommonTrackerPage {
for (let i = 0; i < tabs.length; i++) { for (let i = 0; i < tabs.length; i++) {
await tabs[i].click() await tabs[i].click()
await this.page.waitForTimeout(3000) await this.page.waitForTimeout(3000)
// Scope to the actual result link, not the whole panel: on a zero-hit
// tab the SearchEmptyState card legitimately echoes the search term
// ("No issues found for <name>"), which a panel-wide text assertion would
// wrongly match.
if (presence === checks[i]) { if (presence === checks[i]) {
await expect(this.issueListPanel()).toContainText(issueName) await expect(this.issueAnchorByName(issueName)).toBeVisible()
} else { } else {
await expect(this.issueListPanel()).not.toContainText(issueName) await expect(this.issueAnchorByName(issueName)).toHaveCount(0)
} }
} }
} }
+30 -1
View File
@@ -1,4 +1,4 @@
import { test } from '@playwright/test' import { expect, test } from '@playwright/test'
import { CommonTrackerPage } from '../model/tracker/common-tracker-page' import { CommonTrackerPage } from '../model/tracker/common-tracker-page'
import { IssuesDetailsPage } from '../model/tracker/issues-details-page' import { IssuesDetailsPage } from '../model/tracker/issues-details-page'
import { IssuesPage } from '../model/tracker/issues-page' import { IssuesPage } from '../model/tracker/issues-page'
@@ -191,6 +191,35 @@ test.describe('Tracker tests', () => {
await issuesPage.verifyCategoryHeadersVisibilityKanban() await issuesPage.verifyCategoryHeadersVisibilityKanban()
await issuesPage.openViewOptionsAndToggleShouldShowAll() await issuesPage.openViewOptionsAndToggleShouldShowAll()
}) })
test('list zero-hit search shows the empty-state card unless shouldShowAll is on', async ({ page }) => {
await (
await page.goto(`${PlatformURI}/workbench/sanity-ws/tracker/tracker%3Aproject%3ADefaultProject/issues`)
)?.finished()
const issuesPage = new IssuesPage(page)
await navigate(page)
await issuesPage.navigateToIssues()
await page.click(ViewletSelectors.Table)
// 1) shouldShowAll OFF (default) — zero hits surface the card.
await issuesPage.searchIssueByName('!!!!')
await expect(issuesPage.searchEmptyStateCard()).toBeInViewport()
await expect(issuesPage.searchEmptyStateCard()).toContainText('!!!!')
// 2) shouldShowAll ON — the empty category headers stay visible and the
// card is suppressed, because the explicit view option wins.
await issuesPage.openViewOptionsAndToggleShouldShowAll()
await expect(issuesPage.searchEmptyStateCard()).toHaveCount(0)
// Done / Cancelled only exist in the All mode, same as the
// 'check shouldShowAll option' test above.
await issuesPage.clickModelSelectorAll()
await issuesPage.verifyCategoryHeadersVisibility()
// 3) Toggling back restores the card — proves the option, not the search
// state, is what suppressed it.
await issuesPage.openViewOptionsAndToggleShouldShowAll()
await expect(issuesPage.searchEmptyStateCard()).toBeInViewport()
})
}) })
async function doSaveViewTest ( async function doSaveViewTest (
panels: string[], panels: string[],
+6 -2
View File
@@ -48,9 +48,13 @@ export async function setViewGroup (page: Page, groupName: string): Promise<void
export async function setViewOrder (page: Page, orderName: string): Promise<void> { export async function setViewOrder (page: Page, orderName: string): Promise<void> {
await page.click('button[data-id="btn-viewOptions"]') await page.click('button[data-id="btn-viewOptions"]')
await page.click('.antiCard >> .ordering >> button') // The View-Options popup now renders more than one `.ordering` row: the
// Order-by dropdown plus any "other" toggles/dropdowns (e.g. the new
// searchScope selector). Target the first `.ordering` button, which is the
// Order-by control, to keep the locator unambiguous.
await page.click('.antiCard >> .ordering >> button >> nth=0')
await page.click(`.menu-item:has-text("${orderName}")`) await page.click(`.menu-item:has-text("${orderName}")`)
await expect(page.locator('.antiCard >> .ordering >> button')).toContainText(orderName) await expect(page.locator('.antiCard >> .ordering >> button >> nth=0')).toContainText(orderName)
await page.keyboard.press('Escape') await page.keyboard.press('Escape')
} }