@open-mercato/ui 0.6.1-develop.3276.1.1fcd145078 → 0.6.1-develop.3287.1.450f4ffb56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +23 -14
  3. package/dist/backend/AppShell.js +8 -32
  4. package/dist/backend/AppShell.js.map +2 -2
  5. package/dist/backend/CrudForm.js +18 -0
  6. package/dist/backend/CrudForm.js.map +2 -2
  7. package/dist/backend/DataTable.js +149 -53
  8. package/dist/backend/DataTable.js.map +2 -2
  9. package/dist/backend/FilterBar.js +11 -14
  10. package/dist/backend/FilterBar.js.map +2 -2
  11. package/dist/backend/columns/ColumnChooserPanel.js +4 -6
  12. package/dist/backend/columns/ColumnChooserPanel.js.map +2 -2
  13. package/dist/backend/filters/ActiveFilterChips.js +142 -0
  14. package/dist/backend/filters/ActiveFilterChips.js.map +7 -0
  15. package/dist/backend/filters/AdvancedFilterBuilder.js +689 -163
  16. package/dist/backend/filters/AdvancedFilterBuilder.js.map +2 -2
  17. package/dist/backend/filters/AdvancedFilterPanel.js +354 -0
  18. package/dist/backend/filters/AdvancedFilterPanel.js.map +7 -0
  19. package/dist/backend/filters/FilterEmptyState.js +41 -0
  20. package/dist/backend/filters/FilterEmptyState.js.map +7 -0
  21. package/dist/backend/filters/FilterFieldPicker.js +148 -0
  22. package/dist/backend/filters/FilterFieldPicker.js.map +7 -0
  23. package/dist/backend/filters/FilteredEmptyResults.js +26 -0
  24. package/dist/backend/filters/FilteredEmptyResults.js.map +7 -0
  25. package/dist/backend/filters/QuickFilters.js +41 -0
  26. package/dist/backend/filters/QuickFilters.js.map +7 -0
  27. package/dist/backend/filters/filterValidation.js +47 -0
  28. package/dist/backend/filters/filterValidation.js.map +7 -0
  29. package/dist/backend/filters/treeReducer.js +159 -0
  30. package/dist/backend/filters/treeReducer.js.map +7 -0
  31. package/dist/backend/hooks/useAdvancedFilter.js +82 -1
  32. package/dist/backend/hooks/useAdvancedFilter.js.map +2 -2
  33. package/dist/backend/inputs/ComboboxInput.js +11 -7
  34. package/dist/backend/inputs/ComboboxInput.js.map +2 -2
  35. package/dist/backend/inputs/EventPatternInput.js +5 -2
  36. package/dist/backend/inputs/EventPatternInput.js.map +2 -2
  37. package/dist/backend/inputs/EventSelect.js +10 -4
  38. package/dist/backend/inputs/EventSelect.js.map +2 -2
  39. package/dist/backend/inputs/LookupSelect.js +51 -47
  40. package/dist/backend/inputs/LookupSelect.js.map +2 -2
  41. package/dist/backend/inputs/PhoneNumberField.js +196 -45
  42. package/dist/backend/inputs/PhoneNumberField.js.map +2 -2
  43. package/dist/backend/inputs/SwitchableMarkdownInput.js.map +2 -2
  44. package/dist/backend/messages/message-compose-form-groups.js +2 -2
  45. package/dist/backend/messages/message-compose-form-groups.js.map +2 -2
  46. package/dist/backend/utils/api.js +18 -1
  47. package/dist/backend/utils/api.js.map +2 -2
  48. package/dist/backend/utils/serverErrors.js +6 -2
  49. package/dist/backend/utils/serverErrors.js.map +2 -2
  50. package/dist/backend/utils/useAutoDiscoveredFields.js +1 -0
  51. package/dist/backend/utils/useAutoDiscoveredFields.js.map +2 -2
  52. package/dist/backend/utils/useCurrentUserId.js +31 -0
  53. package/dist/backend/utils/useCurrentUserId.js.map +7 -0
  54. package/dist/portal/utils/nav.js +17 -0
  55. package/dist/portal/utils/nav.js.map +2 -2
  56. package/dist/primitives/amount-input.js +128 -0
  57. package/dist/primitives/amount-input.js.map +7 -0
  58. package/dist/primitives/button-input.js +47 -0
  59. package/dist/primitives/button-input.js.map +7 -0
  60. package/dist/primitives/card-input.js +133 -0
  61. package/dist/primitives/card-input.js.map +7 -0
  62. package/dist/primitives/dialog.js +7 -5
  63. package/dist/primitives/dialog.js.map +2 -2
  64. package/dist/primitives/email-input.js +29 -0
  65. package/dist/primitives/email-input.js.map +7 -0
  66. package/dist/primitives/password-input.js +64 -0
  67. package/dist/primitives/password-input.js.map +7 -0
  68. package/dist/primitives/search-input.js +57 -0
  69. package/dist/primitives/search-input.js.map +7 -0
  70. package/dist/primitives/select.js +43 -5
  71. package/dist/primitives/select.js.map +2 -2
  72. package/dist/primitives/tag.js +4 -2
  73. package/dist/primitives/tag.js.map +2 -2
  74. package/dist/primitives/website-input.js +56 -0
  75. package/dist/primitives/website-input.js.map +7 -0
  76. package/package.json +3 -3
  77. package/src/backend/AppShell.tsx +7 -30
  78. package/src/backend/CrudForm.tsx +17 -0
  79. package/src/backend/DataTable.tsx +307 -63
  80. package/src/backend/FilterBar.tsx +5 -6
  81. package/src/backend/__tests__/DataTable.extensions.test.tsx +170 -0
  82. package/src/backend/__tests__/serverErrors.test.ts +79 -1
  83. package/src/backend/columns/ColumnChooserPanel.tsx +4 -5
  84. package/src/backend/filters/ActiveFilterChips.tsx +179 -0
  85. package/src/backend/filters/AdvancedFilterBuilder.tsx +840 -237
  86. package/src/backend/filters/AdvancedFilterPanel.tsx +456 -0
  87. package/src/backend/filters/FilterEmptyState.tsx +51 -0
  88. package/src/backend/filters/FilterFieldPicker.tsx +139 -0
  89. package/src/backend/filters/FilteredEmptyResults.tsx +40 -0
  90. package/src/backend/filters/QuickFilters.tsx +62 -0
  91. package/src/backend/filters/__tests__/ActiveFilterChips.test.tsx +75 -0
  92. package/src/backend/filters/__tests__/AdvancedFilterBuilder.test.tsx +210 -0
  93. package/src/backend/filters/__tests__/AdvancedFilterPanel.test.tsx +212 -0
  94. package/src/backend/filters/__tests__/FilterEmptyState.test.tsx +31 -0
  95. package/src/backend/filters/__tests__/FilterFieldPicker.test.tsx +61 -0
  96. package/src/backend/filters/__tests__/FilteredEmptyResults.test.tsx +35 -0
  97. package/src/backend/filters/__tests__/QuickFilters.test.tsx +34 -0
  98. package/src/backend/filters/__tests__/filterValidation.test.ts +69 -0
  99. package/src/backend/filters/__tests__/treeReducer.test.ts +189 -0
  100. package/src/backend/filters/filterValidation.ts +61 -0
  101. package/src/backend/filters/treeReducer.ts +201 -0
  102. package/src/backend/hooks/__tests__/useAdvancedFilter.test.tsx +213 -0
  103. package/src/backend/hooks/useAdvancedFilter.ts +130 -0
  104. package/src/backend/inputs/ComboboxInput.tsx +32 -26
  105. package/src/backend/inputs/EventPatternInput.tsx +11 -2
  106. package/src/backend/inputs/EventSelect.tsx +13 -4
  107. package/src/backend/inputs/LookupSelect.tsx +75 -53
  108. package/src/backend/inputs/PhoneNumberField.tsx +237 -47
  109. package/src/backend/inputs/SwitchableMarkdownInput.tsx +30 -0
  110. package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +4 -0
  111. package/src/backend/inputs/__tests__/PhoneNumberField.test.tsx +4 -0
  112. package/src/backend/messages/message-compose-form-groups.tsx +2 -2
  113. package/src/backend/utils/api.ts +27 -2
  114. package/src/backend/utils/serverErrors.ts +15 -8
  115. package/src/backend/utils/useAutoDiscoveredFields.ts +2 -0
  116. package/src/backend/utils/useCurrentUserId.ts +40 -0
  117. package/src/portal/utils/__tests__/nav.test.ts +80 -0
  118. package/src/portal/utils/nav.ts +23 -1
  119. package/src/primitives/__tests__/password-input.test.tsx +93 -0
  120. package/src/primitives/__tests__/search-input.test.tsx +95 -0
  121. package/src/primitives/__tests__/zindex-overlay.test.tsx +26 -0
  122. package/src/primitives/amount-input.tsx +184 -0
  123. package/src/primitives/button-input.tsx +68 -0
  124. package/src/primitives/card-input.tsx +193 -0
  125. package/src/primitives/dialog.tsx +18 -7
  126. package/src/primitives/email-input.tsx +36 -0
  127. package/src/primitives/password-input.tsx +100 -0
  128. package/src/primitives/search-input.tsx +87 -0
  129. package/src/primitives/select.tsx +98 -11
  130. package/src/primitives/tag.tsx +3 -1
  131. package/src/primitives/website-input.tsx +72 -0
@@ -389,4 +389,174 @@ describe('DataTable extensions', () => {
389
389
  rendered.cleanupQueryClient()
390
390
  }
391
391
  })
392
+
393
+ it('does not expose saved filters through the perspectives action row', async () => {
394
+ const originalFetch = globalThis.fetch
395
+ const fetchMock = jest.fn(async (input: RequestInfo | URL) => {
396
+ const url = String(input)
397
+ if (url.includes('/api/auth/feature-check')) {
398
+ return new Response(JSON.stringify({ granted: ['perspectives.use'] }), {
399
+ status: 200,
400
+ headers: { 'content-type': 'application/json' },
401
+ })
402
+ }
403
+ if (url.includes('/api/perspectives/customers.people.list')) {
404
+ return new Response(JSON.stringify({
405
+ tableId: 'customers.people.list',
406
+ perspectives: [],
407
+ rolePerspectives: [],
408
+ roles: [],
409
+ defaultPerspectiveId: null,
410
+ canApplyToRoles: false,
411
+ }), {
412
+ status: 200,
413
+ headers: { 'content-type': 'application/json' },
414
+ })
415
+ }
416
+ return new Response(JSON.stringify({}), {
417
+ status: 404,
418
+ headers: { 'content-type': 'application/json' },
419
+ })
420
+ })
421
+ ;(globalThis as typeof globalThis & { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch
422
+
423
+ const rendered = renderTable({
424
+ columns: [{ accessorKey: 'name', header: 'Name' }],
425
+ data: [{ id: 'r1', name: 'Alice' }],
426
+ searchValue: '',
427
+ onSearchChange: () => {},
428
+ perspective: { tableId: 'customers.people.list' },
429
+ advancedFilter: {
430
+ value: {
431
+ root: {
432
+ id: 'root',
433
+ type: 'group',
434
+ combinator: 'and',
435
+ children: [
436
+ { id: 'rule-1', type: 'rule', field: 'name', operator: 'contains', value: 'Alice' },
437
+ ],
438
+ },
439
+ },
440
+ fields: [{ key: 'name', label: 'Name', type: 'text' }],
441
+ onChange: () => {},
442
+ onApply: () => {},
443
+ onClear: () => {},
444
+ externalPopover: true,
445
+ },
446
+ })
447
+
448
+ try {
449
+ await screen.findByRole('button', { name: /All views/ })
450
+ expect(screen.queryByTestId('advanced-filter-save-trigger')).not.toBeInTheDocument()
451
+ expect(screen.queryByRole('button', { name: 'Save filter' })).not.toBeInTheDocument()
452
+ expect(fetchMock.mock.calls.some(([input, init]) =>
453
+ String(input).includes('/api/perspectives/customers.people.list') && init?.method === 'POST'
454
+ )).toBe(false)
455
+ } finally {
456
+ if (originalFetch) {
457
+ globalThis.fetch = originalFetch
458
+ } else {
459
+ delete (globalThis as typeof globalThis & { fetch?: typeof fetch }).fetch
460
+ }
461
+ rendered.cleanupQueryClient()
462
+ }
463
+ })
464
+
465
+ it('clears the advanced filter tree when No view is selected', async () => {
466
+ const originalFetch = globalThis.fetch
467
+ const savedTree = {
468
+ v: 2,
469
+ root: {
470
+ id: 'saved-root',
471
+ type: 'group',
472
+ combinator: 'and',
473
+ children: [
474
+ { id: 'saved-rule', type: 'rule', field: 'name', operator: 'contains', value: 'Alice' },
475
+ ],
476
+ },
477
+ }
478
+ const perspectiveResponse = {
479
+ tableId: 'customers.people.list.clear-test',
480
+ perspectives: [
481
+ {
482
+ id: 'saved-filter-1',
483
+ name: 'Owned leads',
484
+ tableId: 'customers.people.list.clear-test',
485
+ isDefault: false,
486
+ settings: { filters: savedTree },
487
+ createdAt: new Date().toISOString(),
488
+ updatedAt: new Date().toISOString(),
489
+ },
490
+ ],
491
+ rolePerspectives: [],
492
+ roles: [],
493
+ defaultPerspectiveId: null,
494
+ canApplyToRoles: false,
495
+ }
496
+ const fetchMock = jest.fn(async (input: RequestInfo | URL) => {
497
+ const url = String(input)
498
+ if (url.includes('/api/auth/feature-check')) {
499
+ return new Response(JSON.stringify({ granted: ['perspectives.use'] }), {
500
+ status: 200,
501
+ headers: { 'content-type': 'application/json' },
502
+ })
503
+ }
504
+ if (url.includes('/api/perspectives/customers.people.list.clear-test')) {
505
+ return new Response(JSON.stringify(perspectiveResponse), {
506
+ status: 200,
507
+ headers: { 'content-type': 'application/json' },
508
+ })
509
+ }
510
+ return new Response(JSON.stringify({}), {
511
+ status: 404,
512
+ headers: { 'content-type': 'application/json' },
513
+ })
514
+ })
515
+ ;(globalThis as typeof globalThis & { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch
516
+ window.localStorage.clear()
517
+
518
+ const onApplyTree = jest.fn()
519
+ const rendered = renderTable({
520
+ columns: [{ accessorKey: 'name', header: 'Name' }],
521
+ data: [{ id: 'r1', name: 'Alice' }],
522
+ searchValue: '',
523
+ onSearchChange: () => {},
524
+ perspective: {
525
+ tableId: 'customers.people.list.clear-test',
526
+ initialState: { response: perspectiveResponse },
527
+ },
528
+ advancedFilter: {
529
+ value: savedTree,
530
+ fields: [{ key: 'name', label: 'Name', type: 'text' }],
531
+ onChange: () => {},
532
+ onApply: () => {},
533
+ onClear: () => {},
534
+ externalPopover: true,
535
+ onApplyTree,
536
+ },
537
+ })
538
+
539
+ try {
540
+ await waitFor(() => expect(onApplyTree).toHaveBeenCalled())
541
+ const activeViewButton = await screen.findByRole('button', { name: /Owned leads/ })
542
+ fireEvent.click(activeViewButton)
543
+ fireEvent.click(screen.getByRole('button', { name: /No view/ }))
544
+
545
+ await waitFor(() => {
546
+ const calls = onApplyTree.mock.calls
547
+ const lastTree = calls[calls.length - 1]?.[0]
548
+ expect(lastTree?.root.children).toHaveLength(0)
549
+ })
550
+ await waitFor(() => expect(screen.getByRole('button', { name: /All views/ })).toBeInTheDocument())
551
+ const calls = onApplyTree.mock.calls
552
+ expect(calls[calls.length - 1]?.[0]?.root.children).toHaveLength(0)
553
+ } finally {
554
+ if (originalFetch) {
555
+ globalThis.fetch = originalFetch
556
+ } else {
557
+ delete (globalThis as typeof globalThis & { fetch?: typeof fetch }).fetch
558
+ }
559
+ rendered.cleanupQueryClient()
560
+ }
561
+ })
392
562
  })
@@ -1,4 +1,9 @@
1
- import { mapCrudServerErrorToFormErrors, raiseCrudError, readJsonSafe } from '../utils/serverErrors'
1
+ import {
2
+ mapCrudServerErrorToFormErrors,
3
+ normalizeCrudServerError,
4
+ raiseCrudError,
5
+ readJsonSafe,
6
+ } from '../utils/serverErrors'
2
7
 
3
8
  describe('serverErrors helpers', () => {
4
9
  it('maps details array into field errors and message', () => {
@@ -59,6 +64,79 @@ describe('serverErrors helpers', () => {
59
64
  }
60
65
  })
61
66
 
67
+ it('raiseCrudError extracts message from structured { error: { code, message } } envelope', async () => {
68
+ expect.assertions(4)
69
+ const response = new Response(
70
+ JSON.stringify({
71
+ ok: false,
72
+ error: {
73
+ code: 'invite_cooldown_active',
74
+ message: 'A recent invite for this email is still pending — please wait before re-inviting.',
75
+ details: { retryAfterSeconds: 60 },
76
+ },
77
+ }),
78
+ { status: 429, headers: { 'content-type': 'application/json' } },
79
+ )
80
+
81
+ try {
82
+ await raiseCrudError(response, 'Fallback message')
83
+ } catch (err) {
84
+ expect(err).toMatchObject({
85
+ status: 429,
86
+ message:
87
+ 'A recent invite for this email is still pending — please wait before re-inviting.',
88
+ })
89
+ const errorField = (err as { error?: unknown }).error as
90
+ | { code?: unknown; details?: unknown }
91
+ | undefined
92
+ expect(errorField?.code).toBe('invite_cooldown_active')
93
+ expect(errorField?.details).toEqual({ retryAfterSeconds: 60 })
94
+ expect((err as { ok?: unknown }).ok).toBe(false)
95
+ }
96
+ })
97
+
98
+ it('normalizeCrudServerError reads message from nested { error: { message } } envelope', () => {
99
+ const normalized = normalizeCrudServerError({
100
+ ok: false,
101
+ error: {
102
+ code: 'invite_cooldown_active',
103
+ message: 'A recent invite for this email is still pending — please wait before re-inviting.',
104
+ },
105
+ })
106
+ expect(normalized.message).toBe(
107
+ 'A recent invite for this email is still pending — please wait before re-inviting.',
108
+ )
109
+ })
110
+
111
+ it('raiseCrudError prefers top-level string error over nested error.message', async () => {
112
+ expect.assertions(1)
113
+ const response = new Response(
114
+ JSON.stringify({
115
+ error: 'Top-level message',
116
+ message: 'Top-level message',
117
+ }),
118
+ { status: 400, headers: { 'content-type': 'application/json' } },
119
+ )
120
+
121
+ await expect(raiseCrudError(response, 'Fallback message')).rejects.toMatchObject({
122
+ status: 400,
123
+ message: 'Top-level message',
124
+ })
125
+ })
126
+
127
+ it('raiseCrudError falls back when structured envelope has empty nested message', async () => {
128
+ expect.assertions(1)
129
+ const response = new Response(
130
+ JSON.stringify({ ok: false, error: { code: 'oops', message: ' ' } }),
131
+ { status: 500, headers: { 'content-type': 'application/json' } },
132
+ )
133
+
134
+ await expect(raiseCrudError(response, 'Fallback message')).rejects.toMatchObject({
135
+ status: 500,
136
+ message: 'Fallback message',
137
+ })
138
+ })
139
+
62
140
  it('raiseCrudError falls back to message when body is plain text', async () => {
63
141
  expect.assertions(1)
64
142
  const response = new Response('Something went wrong', { status: 500 })
@@ -1,10 +1,11 @@
1
1
  "use client"
2
2
  import * as React from 'react'
3
- import { Search, GripVertical, X, ChevronRight } from 'lucide-react'
3
+ import { GripVertical, X, ChevronRight } from 'lucide-react'
4
4
  import { Button } from '../../primitives/button'
5
5
  import { IconButton } from '../../primitives/icon-button'
6
6
  import { Switch } from '../../primitives/switch'
7
7
  import { Input } from '../../primitives/input'
8
+ import { SearchInput } from '../../primitives/search-input'
8
9
  import { useT } from '@open-mercato/shared/lib/i18n/context'
9
10
  import {
10
11
  DndContext,
@@ -168,13 +169,11 @@ export function ColumnChooserSection({
168
169
  return (
169
170
  <div className="flex flex-col">
170
171
  <div className="px-4 py-3 border-t">
171
- <Input
172
- type="text"
172
+ <SearchInput
173
173
  size="sm"
174
- leftIcon={<Search />}
175
174
  placeholder={t('ui.columnChooser.search', 'Search columns...')}
176
175
  value={searchQuery}
177
- onChange={(e) => setSearchQuery(e.target.value)}
176
+ onChange={setSearchQuery}
178
177
  />
179
178
  </div>
180
179
 
@@ -0,0 +1,179 @@
1
+ 'use client'
2
+ import * as React from 'react'
3
+ import { X } from 'lucide-react'
4
+ import { Tag, type TagVariant } from '../../primitives/tag'
5
+ import { IconButton } from '../../primitives/icon-button'
6
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
7
+ import type { AdvancedFilterTree, FilterRule, FilterGroup } from '@open-mercato/shared/lib/query/advanced-filter-tree'
8
+ import type { FilterFieldDef, FilterOption } from '@open-mercato/shared/lib/query/advanced-filter'
9
+ import { isValuelessOperator } from '@open-mercato/shared/lib/query/advanced-filter'
10
+ import { validateTreeForApply } from './filterValidation'
11
+
12
+ export type ActiveFilterChipsProps = {
13
+ tree: AdvancedFilterTree
14
+ fields: FilterFieldDef[]
15
+ popoverOpen: boolean
16
+ onRemoveNode: (id: string) => void
17
+ onOpen: (focusNodeId?: string) => void
18
+ }
19
+
20
+ const MAX_LABEL = 24
21
+
22
+ function truncate(s: string): string {
23
+ if (s.length <= MAX_LABEL) return s
24
+ return s.slice(0, MAX_LABEL) + '…'
25
+ }
26
+
27
+ function getOption(field: FilterFieldDef | undefined, value: string): FilterOption | undefined {
28
+ if (!field?.options) return undefined
29
+ return field.options.find((o) => o.value === value)
30
+ }
31
+
32
+ function valueText(field: FilterFieldDef | undefined, value: unknown): { text: string; tone?: TagVariant } {
33
+ if (Array.isArray(value)) {
34
+ if (value.length === 0) return { text: '' }
35
+ const first = String(value[0])
36
+ const opt = getOption(field, first)
37
+ const display = opt?.label ?? first
38
+ const more = value.length - 1
39
+ return {
40
+ text: more > 0 ? `${truncate(display)} +${more}` : truncate(display),
41
+ tone: opt?.tone,
42
+ }
43
+ }
44
+ if (value == null || value === '') return { text: '' }
45
+ const str = String(value)
46
+ const opt = getOption(field, str)
47
+ return { text: truncate(opt?.label ?? str), tone: opt?.tone }
48
+ }
49
+
50
+ // Operator labels in chips read as natural English: "Email is empty" beats
51
+ // "Email: " (trailing colon, blank value). Keep the map narrow — only valueless
52
+ // operators need a phrase form here; value-bearing operators show the value.
53
+ const VALUELESS_OPERATOR_LABEL: Record<string, string> = {
54
+ is_empty: 'is empty',
55
+ is_not_empty: 'is not empty',
56
+ is_true: 'is true',
57
+ is_false: 'is false',
58
+ }
59
+
60
+ type Translator = ReturnType<typeof useT>
61
+
62
+ function ruleChip(rule: FilterRule, fields: FilterFieldDef[], t: Translator): { label: string; tone?: TagVariant } {
63
+ const field = fields.find((f) => f.key === rule.field)
64
+ const fieldLabel = field?.label ?? rule.field
65
+ if (isValuelessOperator(rule.operator)) {
66
+ const fallback = VALUELESS_OPERATOR_LABEL[rule.operator] ?? rule.operator
67
+ const opLabel = t(`ui.advancedFilter.operator.${rule.operator}`, fallback)
68
+ return { label: `${fieldLabel} ${opLabel}` }
69
+ }
70
+ // `between` stores `[start, end]` — render it as a range, not as the
71
+ // multi-select "first +N" summary that `valueText` would produce.
72
+ if (rule.operator === 'between' && Array.isArray(rule.value)) {
73
+ const [start, end] = rule.value
74
+ const startStr = start == null ? '' : String(start)
75
+ const endStr = end == null ? '' : String(end)
76
+ return { label: `${fieldLabel}: ${truncate(startStr)} – ${truncate(endStr)}` }
77
+ }
78
+ const v = valueText(field, rule.value)
79
+ return { label: `${fieldLabel}: ${v.text}`, tone: v.tone }
80
+ }
81
+
82
+ function groupChip(group: FilterGroup, fields: FilterFieldDef[], invalidRuleIds: Set<string>, t: Translator): { label: string; tone?: TagVariant; remaining: number } {
83
+ const ruleChildren: FilterRule[] = []
84
+ for (const c of group.children) if (c.type === 'rule' && !invalidRuleIds.has(c.id)) ruleChildren.push(c)
85
+ if (!ruleChildren.length) return { label: '', remaining: 0 }
86
+ const firstChip = ruleChip(ruleChildren[0], fields, t)
87
+ const remaining = ruleChildren.length - 1
88
+ return {
89
+ label: remaining > 0 ? `${firstChip.label} +${remaining}` : firstChip.label,
90
+ tone: firstChip.tone,
91
+ remaining,
92
+ }
93
+ }
94
+
95
+ function isNodeFullyInvalid(node: FilterRule | FilterGroup, invalidRuleIds: Set<string>): boolean {
96
+ if (node.type === 'rule') return invalidRuleIds.has(node.id)
97
+ if (!node.children.length) return true
98
+ return node.children.every((c) => isNodeFullyInvalid(c, invalidRuleIds))
99
+ }
100
+
101
+ export function ActiveFilterChips({ tree, fields, popoverOpen, onRemoveNode, onOpen }: ActiveFilterChipsProps) {
102
+ const t = useT()
103
+ // Suppress chips for rules/groups whose values won't actually filter (matches
104
+ // the same gate the auto-apply hook uses). Stops the "chip is shown but data
105
+ // appears unfiltered" mismatch when a substring text rule has < 2 characters
106
+ // or a multi-value rule has no picks yet.
107
+ const invalidRuleIds = React.useMemo(() => {
108
+ const result = validateTreeForApply(tree, fields)
109
+ if (result.ok) return new Set<string>()
110
+ return new Set(result.errors.map((e) => e.ruleId))
111
+ }, [tree, fields])
112
+ const visibleChildren = React.useMemo(
113
+ () => tree.root.children.filter((child) => !isNodeFullyInvalid(child, invalidRuleIds)),
114
+ [tree.root.children, invalidRuleIds],
115
+ )
116
+ if (popoverOpen) return null
117
+ if (!visibleChildren.length) return null
118
+ return (
119
+ <div
120
+ className="flex items-center gap-2 px-4 py-2 overflow-x-auto bg-background border-b border-border"
121
+ data-testid="active-filter-chips"
122
+ >
123
+ {visibleChildren.map((child) => {
124
+ if (child.type === 'rule') {
125
+ const c = ruleChip(child, fields, t)
126
+ return (
127
+ <ChipWithRemove
128
+ key={child.id}
129
+ label={c.label}
130
+ tone={c.tone}
131
+ onClick={() => onOpen(child.id)}
132
+ onRemove={() => onRemoveNode(child.id)}
133
+ removeLabel={t('ui.advancedFilter.chips.remove', 'Remove filter')}
134
+ />
135
+ )
136
+ }
137
+ const c = groupChip(child, fields, invalidRuleIds, t)
138
+ return (
139
+ <ChipWithRemove
140
+ key={child.id}
141
+ label={c.label}
142
+ tone={c.tone}
143
+ onClick={() => onOpen(child.id)}
144
+ onRemove={() => onRemoveNode(child.id)}
145
+ removeLabel={t('ui.advancedFilter.chips.removeGroup', 'Remove filter group')}
146
+ />
147
+ )
148
+ })}
149
+ </div>
150
+ )
151
+ }
152
+
153
+ type ChipWithRemoveProps = {
154
+ label: string
155
+ tone?: TagVariant
156
+ onClick: () => void
157
+ onRemove: () => void
158
+ removeLabel: string
159
+ }
160
+
161
+ function ChipWithRemove({ label, tone, onClick, onRemove, removeLabel }: ChipWithRemoveProps) {
162
+ return (
163
+ <div className="inline-flex items-center gap-1" data-testid="active-filter-chip" aria-label={label}>
164
+ {/* Raw <button> required: <Tag> is a presentational pill primitive (not a Button), so wrapping
165
+ it in <Button> would inject Button's own styling around it. The native button keeps Tag's
166
+ visual contract intact while adding click + keyboard semantics; DS focus-visible ring is applied. */}
167
+ <button
168
+ type="button"
169
+ onClick={onClick}
170
+ className="inline-flex rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
171
+ >
172
+ <Tag variant={tone ?? 'default'} dot={tone != null}>{label}</Tag>
173
+ </button>
174
+ <IconButton type="button" variant="ghost" size="xs" aria-label={removeLabel} onClick={onRemove}>
175
+ <X className="size-3" />
176
+ </IconButton>
177
+ </div>
178
+ )
179
+ }