@proteos/ui 0.31.0

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 (226) hide show
  1. package/LICENSE +105 -0
  2. package/dist/chunk-IKAX76MB.js +113 -0
  3. package/dist/chunk-IKAX76MB.js.map +1 -0
  4. package/dist/chunk-MWAXK66H.cjs +119 -0
  5. package/dist/chunk-MWAXK66H.cjs.map +1 -0
  6. package/dist/index.cjs +6246 -0
  7. package/dist/index.cjs.map +1 -0
  8. package/dist/index.d.cts +2126 -0
  9. package/dist/index.d.ts +2126 -0
  10. package/dist/index.js +6069 -0
  11. package/dist/index.js.map +1 -0
  12. package/dist/pdf-view-3ZMMS6ML.cjs +95 -0
  13. package/dist/pdf-view-3ZMMS6ML.cjs.map +1 -0
  14. package/dist/pdf-view-BV5ZD6OC.js +93 -0
  15. package/dist/pdf-view-BV5ZD6OC.js.map +1 -0
  16. package/package.json +96 -0
  17. package/src/components/attachment-chip/attachment-chip.tsx +129 -0
  18. package/src/components/attachment-chip/index.ts +2 -0
  19. package/src/components/attribute-type-icon/attribute-type-icon.stories.tsx +70 -0
  20. package/src/components/attribute-type-icon/attribute-type-icon.tsx +125 -0
  21. package/src/components/attribute-type-icon/index.ts +8 -0
  22. package/src/components/avatar/avatar.stories.tsx +42 -0
  23. package/src/components/avatar/avatar.tsx +48 -0
  24. package/src/components/avatar/index.ts +2 -0
  25. package/src/components/avatar-group/avatar-group.test.tsx +38 -0
  26. package/src/components/avatar-group/avatar-group.tsx +60 -0
  27. package/src/components/avatar-group/index.ts +2 -0
  28. package/src/components/badge/badge.stories.tsx +62 -0
  29. package/src/components/badge/badge.test.tsx +36 -0
  30. package/src/components/badge/badge.tsx +54 -0
  31. package/src/components/badge/index.ts +2 -0
  32. package/src/components/badge-multi-select/badge-multi-select.stories.tsx +177 -0
  33. package/src/components/badge-multi-select/badge-multi-select.test.tsx +155 -0
  34. package/src/components/badge-multi-select/badge-multi-select.tsx +272 -0
  35. package/src/components/badge-multi-select/index.ts +2 -0
  36. package/src/components/breadcrumbs/breadcrumbs.stories.tsx +43 -0
  37. package/src/components/breadcrumbs/breadcrumbs.tsx +86 -0
  38. package/src/components/breadcrumbs/index.ts +2 -0
  39. package/src/components/button/button.stories.tsx +290 -0
  40. package/src/components/button/button.test.tsx +59 -0
  41. package/src/components/button/button.tsx +86 -0
  42. package/src/components/button/index.ts +2 -0
  43. package/src/components/card/card.stories.tsx +351 -0
  44. package/src/components/card/card.test.tsx +141 -0
  45. package/src/components/card/card.tsx +173 -0
  46. package/src/components/card/index.ts +13 -0
  47. package/src/components/checkbox/checkbox-group.tsx +52 -0
  48. package/src/components/checkbox/checkbox.stories.tsx +48 -0
  49. package/src/components/checkbox/checkbox.test.tsx +50 -0
  50. package/src/components/checkbox/checkbox.tsx +71 -0
  51. package/src/components/checkbox/index.ts +4 -0
  52. package/src/components/combobox/combobox.stories.tsx +97 -0
  53. package/src/components/combobox/combobox.test.tsx +123 -0
  54. package/src/components/combobox/combobox.tsx +239 -0
  55. package/src/components/combobox/index.ts +2 -0
  56. package/src/components/command/command-hint.tsx +46 -0
  57. package/src/components/command/command.stories.tsx +88 -0
  58. package/src/components/command/command.test.tsx +102 -0
  59. package/src/components/command/command.tsx +76 -0
  60. package/src/components/command/index.ts +16 -0
  61. package/src/components/currency-input/currency-input.tsx +192 -0
  62. package/src/components/currency-input/index.ts +6 -0
  63. package/src/components/data-table/cells.stories.tsx +120 -0
  64. package/src/components/data-table/cells.tsx +165 -0
  65. package/src/components/data-table/data-table.stories.tsx +371 -0
  66. package/src/components/data-table/data-table.tsx +556 -0
  67. package/src/components/data-table/index.ts +22 -0
  68. package/src/components/data-table/make-actions-column.ts +30 -0
  69. package/src/components/data-table/states/data-table-empty.tsx +40 -0
  70. package/src/components/data-table/states/data-table-error.tsx +30 -0
  71. package/src/components/data-table/states/data-table-skeleton.tsx +42 -0
  72. package/src/components/data-table/use-infinite-scroll-sentinel.ts +51 -0
  73. package/src/components/date-picker/calendar-internals.tsx +138 -0
  74. package/src/components/date-picker/date-picker.stories.tsx +78 -0
  75. package/src/components/date-picker/date-picker.test.tsx +71 -0
  76. package/src/components/date-picker/date-picker.tsx +140 -0
  77. package/src/components/date-picker/index.ts +2 -0
  78. package/src/components/date-time-picker/date-time-picker.stories.tsx +96 -0
  79. package/src/components/date-time-picker/date-time-picker.test.tsx +87 -0
  80. package/src/components/date-time-picker/date-time-picker.tsx +203 -0
  81. package/src/components/date-time-picker/index.ts +2 -0
  82. package/src/components/empty-state/empty-state.tsx +63 -0
  83. package/src/components/empty-state/index.ts +2 -0
  84. package/src/components/error-animation/error-animation.stories.tsx +50 -0
  85. package/src/components/error-animation/error-animation.test.tsx +22 -0
  86. package/src/components/error-animation/error-animation.tsx +26 -0
  87. package/src/components/error-animation/index.ts +2 -0
  88. package/src/components/field/field.tsx +142 -0
  89. package/src/components/field/index.ts +8 -0
  90. package/src/components/file-field/file-field.tsx +285 -0
  91. package/src/components/file-field/index.ts +2 -0
  92. package/src/components/file-viewer/fallback-card.tsx +68 -0
  93. package/src/components/file-viewer/file-viewer.stories.tsx +67 -0
  94. package/src/components/file-viewer/file-viewer.test.tsx +81 -0
  95. package/src/components/file-viewer/file-viewer.tsx +116 -0
  96. package/src/components/file-viewer/image-view.tsx +38 -0
  97. package/src/components/file-viewer/index.ts +7 -0
  98. package/src/components/file-viewer/pdf-view.tsx +122 -0
  99. package/src/components/file-viewer/video-view.tsx +16 -0
  100. package/src/components/image-attachment/image-attachment.stories.tsx +39 -0
  101. package/src/components/image-attachment/image-attachment.test.tsx +54 -0
  102. package/src/components/image-attachment/image-attachment.tsx +77 -0
  103. package/src/components/image-attachment/index.ts +3 -0
  104. package/src/components/image-attachment/is-previewable-image-name.ts +14 -0
  105. package/src/components/image-lightbox/image-lightbox.stories.tsx +38 -0
  106. package/src/components/image-lightbox/image-lightbox.test.tsx +72 -0
  107. package/src/components/image-lightbox/image-lightbox.tsx +92 -0
  108. package/src/components/image-lightbox/index.ts +2 -0
  109. package/src/components/input/index.ts +2 -0
  110. package/src/components/input/input.stories.tsx +71 -0
  111. package/src/components/input/input.test.tsx +36 -0
  112. package/src/components/input/input.tsx +76 -0
  113. package/src/components/kbd/index.ts +2 -0
  114. package/src/components/kbd/kbd.stories.tsx +34 -0
  115. package/src/components/kbd/kbd.tsx +27 -0
  116. package/src/components/menu/index.ts +2 -0
  117. package/src/components/menu/menu.tsx +121 -0
  118. package/src/components/modal/index.ts +10 -0
  119. package/src/components/modal/modal.stories.tsx +153 -0
  120. package/src/components/modal/modal.test.tsx +93 -0
  121. package/src/components/modal/modal.tsx +149 -0
  122. package/src/components/multi-select/index.ts +2 -0
  123. package/src/components/multi-select/multi-select.stories.tsx +211 -0
  124. package/src/components/multi-select/multi-select.test.tsx +151 -0
  125. package/src/components/multi-select/multi-select.tsx +339 -0
  126. package/src/components/nav/index.ts +7 -0
  127. package/src/components/nav/nav.stories.tsx +139 -0
  128. package/src/components/nav/nav.tsx +246 -0
  129. package/src/components/popover/index.ts +8 -0
  130. package/src/components/popover/popover.stories.tsx +92 -0
  131. package/src/components/popover/popover.tsx +135 -0
  132. package/src/components/progress-bar/index.ts +2 -0
  133. package/src/components/progress-bar/progress-bar.stories.tsx +75 -0
  134. package/src/components/progress-bar/progress-bar.test.tsx +29 -0
  135. package/src/components/progress-bar/progress-bar.tsx +57 -0
  136. package/src/components/radio-group/index.ts +2 -0
  137. package/src/components/radio-group/radio-group.stories.tsx +113 -0
  138. package/src/components/radio-group/radio-group.test.tsx +87 -0
  139. package/src/components/radio-group/radio-group.tsx +104 -0
  140. package/src/components/record-filter/attribute-picker.tsx +74 -0
  141. package/src/components/record-filter/chip-value.tsx +171 -0
  142. package/src/components/record-filter/filter-row.tsx +262 -0
  143. package/src/components/record-filter/filter-value-input.tsx +351 -0
  144. package/src/components/record-filter/group-add-controls.tsx +77 -0
  145. package/src/components/record-filter/group-body.tsx +173 -0
  146. package/src/components/record-filter/group-mutations.test.ts +104 -0
  147. package/src/components/record-filter/group-mutations.ts +116 -0
  148. package/src/components/record-filter/index.ts +58 -0
  149. package/src/components/record-filter/nested-group.tsx +138 -0
  150. package/src/components/record-filter/operator-picker.tsx +62 -0
  151. package/src/components/record-filter/operators.test.ts +105 -0
  152. package/src/components/record-filter/operators.ts +97 -0
  153. package/src/components/record-filter/record-filter-builder-panel.tsx +236 -0
  154. package/src/components/record-filter/record-filter-button.tsx +126 -0
  155. package/src/components/record-filter/record-filter-chips.tsx +527 -0
  156. package/src/components/record-filter/record-filter-editor.tsx +91 -0
  157. package/src/components/record-filter/record-filter.stories.tsx +196 -0
  158. package/src/components/record-filter/record-filter.test.tsx +437 -0
  159. package/src/components/record-filter/show-details-store.ts +72 -0
  160. package/src/components/record-filter/types.ts +150 -0
  161. package/src/components/segmented-control/index.ts +6 -0
  162. package/src/components/segmented-control/segmented-control.stories.tsx +67 -0
  163. package/src/components/segmented-control/segmented-control.test.tsx +47 -0
  164. package/src/components/segmented-control/segmented-control.tsx +105 -0
  165. package/src/components/select/index.ts +2 -0
  166. package/src/components/select/select.stories.tsx +76 -0
  167. package/src/components/select/select.test.tsx +56 -0
  168. package/src/components/select/select.tsx +159 -0
  169. package/src/components/separator/index.ts +2 -0
  170. package/src/components/separator/separator.stories.tsx +73 -0
  171. package/src/components/separator/separator.test.tsx +29 -0
  172. package/src/components/separator/separator.tsx +51 -0
  173. package/src/components/skeleton/index.ts +2 -0
  174. package/src/components/skeleton/skeleton.stories.tsx +68 -0
  175. package/src/components/skeleton/skeleton.test.tsx +38 -0
  176. package/src/components/skeleton/skeleton.tsx +43 -0
  177. package/src/components/success-animation/index.ts +2 -0
  178. package/src/components/success-animation/success-animation.stories.tsx +50 -0
  179. package/src/components/success-animation/success-animation.test.tsx +22 -0
  180. package/src/components/success-animation/success-animation.tsx +26 -0
  181. package/src/components/switch/index.ts +2 -0
  182. package/src/components/switch/switch.stories.tsx +60 -0
  183. package/src/components/switch/switch.test.tsx +50 -0
  184. package/src/components/switch/switch.tsx +49 -0
  185. package/src/components/table/index.ts +25 -0
  186. package/src/components/table/table.stories.tsx +306 -0
  187. package/src/components/table/table.test.tsx +115 -0
  188. package/src/components/table/table.tsx +543 -0
  189. package/src/components/tabs/index.ts +8 -0
  190. package/src/components/tabs/tabs.tsx +141 -0
  191. package/src/components/tag-input/index.ts +2 -0
  192. package/src/components/tag-input/tag-input.stories.tsx +73 -0
  193. package/src/components/tag-input/tag-input.test.tsx +80 -0
  194. package/src/components/tag-input/tag-input.tsx +193 -0
  195. package/src/components/textarea/index.ts +2 -0
  196. package/src/components/textarea/textarea.stories.tsx +75 -0
  197. package/src/components/textarea/textarea.test.tsx +41 -0
  198. package/src/components/textarea/textarea.tsx +86 -0
  199. package/src/components/time-field/index.ts +2 -0
  200. package/src/components/time-field/time-field.stories.tsx +76 -0
  201. package/src/components/time-field/time-field.test.tsx +51 -0
  202. package/src/components/time-field/time-field.tsx +98 -0
  203. package/src/components/tooltip/index.ts +2 -0
  204. package/src/components/tooltip/tooltip.stories.tsx +176 -0
  205. package/src/components/tooltip/tooltip.test.tsx +52 -0
  206. package/src/components/tooltip/tooltip.tsx +56 -0
  207. package/src/index.ts +47 -0
  208. package/src/lib/cn.ts +6 -0
  209. package/src/lib/commands/discovery-store.test.tsx +76 -0
  210. package/src/lib/commands/discovery-store.ts +110 -0
  211. package/src/lib/commands/index.ts +9 -0
  212. package/src/lib/commands/match-event.test.ts +99 -0
  213. package/src/lib/commands/match-event.ts +31 -0
  214. package/src/lib/commands/parse-combo.test.ts +65 -0
  215. package/src/lib/commands/parse-combo.ts +48 -0
  216. package/src/lib/commands/platform.ts +27 -0
  217. package/src/lib/commands/protected-combos.test.ts +18 -0
  218. package/src/lib/commands/protected-combos.ts +38 -0
  219. package/src/lib/commands/registry.test.ts +97 -0
  220. package/src/lib/commands/registry.ts +59 -0
  221. package/src/lib/commands/use-command.test.tsx +91 -0
  222. package/src/lib/commands/use-command.ts +32 -0
  223. package/src/lib/commands/use-escape-key.test.tsx +97 -0
  224. package/src/lib/commands/use-escape-key.ts +62 -0
  225. package/src/styles.css +321 -0
  226. package/src/test/setup.ts +9 -0
@@ -0,0 +1,196 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { useState } from 'react'
3
+
4
+ import { RecordFilterButton } from './record-filter-button'
5
+ import { RecordFilterChips } from './record-filter-chips'
6
+ import type { FilterField, FilterGroup } from './types'
7
+
8
+ /* =========================================================================
9
+ Composite story for the RecordFilter family. Mirrors the design's
10
+ Customer entity so the visual matches the prototype.
11
+ ========================================================================= */
12
+
13
+ const customerFields: FilterField[] = [
14
+ { path: 'name', shortLabel: 'Name', label: 'Name', type: 'string' },
15
+ {
16
+ path: 'email',
17
+ shortLabel: 'Email',
18
+ label: 'Email',
19
+ type: 'string',
20
+ inputType: 'email',
21
+ },
22
+ {
23
+ path: 'tier',
24
+ shortLabel: 'Tier',
25
+ label: 'Tier',
26
+ type: 'enum',
27
+ options: [
28
+ { value: 'free', label: 'Free' },
29
+ { value: 'starter', label: 'Starter', variant: 'neutral' },
30
+ { value: 'growth', label: 'Growth', variant: 'active' },
31
+ { value: 'enterprise', label: 'Enterprise', variant: 'success' },
32
+ ],
33
+ },
34
+ {
35
+ path: 'status',
36
+ shortLabel: 'Status',
37
+ label: 'Status',
38
+ type: 'enum',
39
+ options: [
40
+ { value: 'active', label: 'Active', variant: 'success' },
41
+ { value: 'trialing', label: 'Trialing', variant: 'active' },
42
+ { value: 'churned', label: 'Churned', variant: 'danger' },
43
+ { value: 'paused', label: 'Paused', variant: 'warning' },
44
+ ],
45
+ },
46
+ { path: 'mrr', shortLabel: 'MRR', label: 'MRR', type: 'number' },
47
+ { path: 'seats', shortLabel: 'Seats', label: 'Seats', type: 'integer' },
48
+ { path: 'isVerified', shortLabel: 'Verified', label: 'Verified', type: 'boolean' },
49
+ { path: 'signedUpAt', shortLabel: 'Signed up', label: 'Signed up', type: 'datetime' },
50
+ { path: 'tags', shortLabel: 'Tags', label: 'Tags', type: 'array' },
51
+ {
52
+ path: 'owner.name',
53
+ shortLabel: 'Name',
54
+ parentLabel: 'Owner',
55
+ label: 'Owner / Name',
56
+ type: 'string',
57
+ },
58
+ {
59
+ path: 'owner.email',
60
+ shortLabel: 'Email',
61
+ parentLabel: 'Owner',
62
+ label: 'Owner / Email',
63
+ type: 'string',
64
+ inputType: 'email',
65
+ },
66
+ {
67
+ path: 'address.country',
68
+ shortLabel: 'Country',
69
+ parentLabel: 'Address',
70
+ label: 'Address / Country',
71
+ type: 'enum',
72
+ options: [
73
+ { value: 'us', label: 'United States' },
74
+ { value: 'ca', label: 'Canada' },
75
+ { value: 'uk', label: 'United Kingdom' },
76
+ { value: 'de', label: 'Germany' },
77
+ { value: 'fr', label: 'France' },
78
+ { value: 'jp', label: 'Japan' },
79
+ ],
80
+ },
81
+ ]
82
+
83
+ const meta: Meta = {
84
+ title: 'Components/RecordFilter',
85
+ decorators: [
86
+ (Story) => (
87
+ <div className="p-12 min-h-[60vh]">
88
+ <Story />
89
+ </div>
90
+ ),
91
+ ],
92
+ }
93
+
94
+ export default meta
95
+ type Story = StoryObj
96
+
97
+ export const ToolbarComposition: Story = {
98
+ render: () => {
99
+ const [filter, setFilter] = useState<FilterGroup>({
100
+ logicalOperator: 'and',
101
+ elements: [
102
+ { field: 'status', operator: 'in', value: ['active', 'trialing'] },
103
+ { field: 'mrr', operator: 'gt', value: '1000' },
104
+ ],
105
+ })
106
+ return (
107
+ <div className="flex flex-col gap-4 max-w-[1100px]">
108
+ <div className="flex items-end justify-between gap-4">
109
+ <div className="flex-1 min-w-0">
110
+ <RecordFilterChips fields={customerFields} filter={filter} onChange={setFilter} />
111
+ </div>
112
+ <div className="flex items-center gap-2 shrink-0">
113
+ <RecordFilterButton
114
+ entityLabel="Customer"
115
+ fields={customerFields}
116
+ filter={filter}
117
+ onChange={setFilter}
118
+ />
119
+ <button
120
+ type="button"
121
+ className="inline-flex items-center gap-1.5 px-3 py-2 rounded-md border border-rule bg-surface font-mono text-[12px] text-ink-2 hover:border-ink-4"
122
+ >
123
+ + New customer
124
+ </button>
125
+ </div>
126
+ </div>
127
+ <div className="rounded-lg border border-rule bg-surface p-4 text-[12px] font-mono text-ink-3">
128
+ (table renders here — wire up `filter` to your records query)
129
+ </div>
130
+ <pre className="rounded-md border border-rule bg-surface-2 p-3 text-[11px] font-mono text-ink-3 overflow-auto">
131
+ {JSON.stringify(filter, null, 2)}
132
+ </pre>
133
+ </div>
134
+ )
135
+ },
136
+ }
137
+
138
+ export const Empty: Story = {
139
+ render: () => {
140
+ const [filter, setFilter] = useState<FilterGroup>({ logicalOperator: 'and', elements: [] })
141
+ return (
142
+ <div className="flex items-center gap-2">
143
+ <RecordFilterButton
144
+ entityLabel="Customer"
145
+ fields={customerFields}
146
+ filter={filter}
147
+ onChange={setFilter}
148
+ />
149
+ </div>
150
+ )
151
+ },
152
+ }
153
+
154
+ /* tier ∈ {growth, enterprise} AND status = active AND (mrr > 1000 OR owner.team = ae)
155
+ — direct port of the example the design's chat transcript ends on. */
156
+ export const WithNestedGroups: Story = {
157
+ render: () => {
158
+ const [filter, setFilter] = useState<FilterGroup>({
159
+ logicalOperator: 'and',
160
+ elements: [
161
+ { field: 'tier', operator: 'in', value: ['growth', 'enterprise'] },
162
+ { field: 'status', operator: 'eq', value: 'active' },
163
+ ],
164
+ groups: [
165
+ {
166
+ logicalOperator: 'or',
167
+ elements: [
168
+ { field: 'mrr', operator: 'gt', value: '1000' },
169
+ { field: 'owner.email', operator: 'contains', value: 'ae@' },
170
+ ],
171
+ },
172
+ ],
173
+ })
174
+ return (
175
+ <div className="flex flex-col gap-4 max-w-[1100px]">
176
+ <div className="flex items-end justify-between gap-4">
177
+ <div className="flex-1 min-w-0">
178
+ <RecordFilterChips fields={customerFields} filter={filter} onChange={setFilter} />
179
+ </div>
180
+ <div className="flex items-center gap-2 shrink-0">
181
+ <RecordFilterButton
182
+ entityLabel="Customer"
183
+ fields={customerFields}
184
+ filter={filter}
185
+ onChange={setFilter}
186
+ isComplexEnabled
187
+ />
188
+ </div>
189
+ </div>
190
+ <pre className="rounded-md border border-rule bg-surface-2 p-3 text-[11px] font-mono text-ink-3 overflow-auto">
191
+ {JSON.stringify(filter, null, 2)}
192
+ </pre>
193
+ </div>
194
+ )
195
+ },
196
+ }
@@ -0,0 +1,437 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import userEvent from '@testing-library/user-event'
3
+ import { useState } from 'react'
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5
+
6
+ import { RecordFilterBuilderPanel } from './record-filter-builder-panel'
7
+ import { RecordFilterButton } from './record-filter-button'
8
+ import { RecordFilterChips } from './record-filter-chips'
9
+ import { __resetShowDetailsStores } from './show-details-store'
10
+ import type { FilterField, FilterGroup } from './types'
11
+
12
+ const fields: FilterField[] = [
13
+ { path: 'name', shortLabel: 'Name', label: 'Name', type: 'string' },
14
+ { path: 'mrr', shortLabel: 'MRR', label: 'MRR', type: 'number' },
15
+ {
16
+ path: 'status',
17
+ shortLabel: 'Status',
18
+ label: 'Status',
19
+ type: 'enum',
20
+ options: [
21
+ { value: 'active', label: 'Active', variant: 'success' },
22
+ { value: 'churned', label: 'Churned', variant: 'danger' },
23
+ ],
24
+ },
25
+ ]
26
+
27
+ function Harness({ initial, storageKey }: { initial?: FilterGroup; storageKey?: string }) {
28
+ const [filter, setFilter] = useState<FilterGroup>(
29
+ initial ?? { logicalOperator: 'and', elements: [] },
30
+ )
31
+ return (
32
+ <div>
33
+ <RecordFilterChips
34
+ fields={fields}
35
+ filter={filter}
36
+ onChange={setFilter}
37
+ {...(storageKey ? { storageKey } : {})}
38
+ />
39
+ <RecordFilterButton
40
+ entityLabel="Customer"
41
+ fields={fields}
42
+ filter={filter}
43
+ onChange={setFilter}
44
+ {...(storageKey ? { storageKey } : {})}
45
+ />
46
+ </div>
47
+ )
48
+ }
49
+
50
+ beforeEach(() => {
51
+ __resetShowDetailsStores()
52
+ })
53
+
54
+ afterEach(() => {
55
+ __resetShowDetailsStores()
56
+ })
57
+
58
+ describe('RecordFilter', () => {
59
+ it('renders the Filter button only when collapsed', () => {
60
+ // Default collapsed state: the Filter button owns UI; no chip row.
61
+ const { unmount } = render(<Harness storageKey="rf.test.empty" />)
62
+ expect(screen.getByRole('button', { name: /^Filter$/ })).toBeInTheDocument()
63
+ unmount()
64
+
65
+ // Pre-expand via localStorage → chips take over, Filter button hides.
66
+ window.localStorage.setItem('rf.test.empty.expanded', '1')
67
+ render(<Harness storageKey="rf.test.empty.expanded" />)
68
+ expect(screen.queryByRole('button', { name: /^Filter$/ })).not.toBeInTheDocument()
69
+ // The +Add inline pill is the entry point in expanded-empty state.
70
+ expect(screen.getByRole('button', { name: /Add filter/i })).toBeInTheDocument()
71
+ })
72
+
73
+ it('renders chips for active filters and hides the Filter button when expanded', () => {
74
+ // Expanded is no longer the default — opt in explicitly.
75
+ window.localStorage.setItem('rf.test.expanded', '1')
76
+ render(
77
+ <Harness
78
+ storageKey="rf.test.expanded"
79
+ initial={{
80
+ logicalOperator: 'and',
81
+ elements: [
82
+ { field: 'status', operator: 'eq', value: 'active' },
83
+ { field: 'mrr', operator: 'gt', value: '500' },
84
+ ],
85
+ }}
86
+ />,
87
+ )
88
+ // Two chips visible.
89
+ expect(screen.getByLabelText(/Edit filter 1: Status/)).toBeInTheDocument()
90
+ expect(screen.getByLabelText(/Edit filter 2: MRR/)).toBeInTheDocument()
91
+ // Filter button hidden because expanded with active filters.
92
+ expect(screen.queryByRole('button', { name: /^Filter$/i })).not.toBeInTheDocument()
93
+ // The collapse toggle is visible.
94
+ expect(screen.getByRole('button', { name: /Collapse/i })).toBeInTheDocument()
95
+ })
96
+
97
+ it('toggles between collapsed and expanded', async () => {
98
+ const user = userEvent.setup()
99
+ // Start expanded so the Collapse toggle is present to click.
100
+ window.localStorage.setItem('rf.test.toggle', '1')
101
+ render(
102
+ <Harness
103
+ storageKey="rf.test.toggle"
104
+ initial={{
105
+ logicalOperator: 'and',
106
+ elements: [{ field: 'status', operator: 'eq', value: 'active' }],
107
+ }}
108
+ />,
109
+ )
110
+
111
+ await user.click(screen.getByRole('button', { name: /Collapse/i }))
112
+
113
+ // Now collapsed — Filter button visible (with count badge), chips hidden.
114
+ expect(screen.queryByLabelText(/Edit filter/)).not.toBeInTheDocument()
115
+ expect(screen.getByRole('button', { name: /Filter/i })).toBeInTheDocument()
116
+ // Count badge "1" somewhere inside the button.
117
+ expect(screen.getByText('1')).toBeInTheDocument()
118
+ })
119
+
120
+ it('removes a filter via the chip × button', async () => {
121
+ const user = userEvent.setup()
122
+ window.localStorage.setItem('rf.test.remove', '1')
123
+ render(
124
+ <Harness
125
+ storageKey="rf.test.remove"
126
+ initial={{
127
+ logicalOperator: 'and',
128
+ elements: [
129
+ { field: 'status', operator: 'eq', value: 'active' },
130
+ { field: 'mrr', operator: 'gt', value: '500' },
131
+ ],
132
+ }}
133
+ />,
134
+ )
135
+
136
+ const removes = screen.getAllByRole('button', { name: 'Remove filter' })
137
+ expect(removes).toHaveLength(2)
138
+ if (!removes[0]) throw new Error('expected first remove button')
139
+ await user.click(removes[0])
140
+
141
+ expect(screen.queryByLabelText(/Edit filter 1: Status/)).not.toBeInTheDocument()
142
+ expect(screen.getByLabelText(/Edit filter 1: MRR/)).toBeInTheDocument()
143
+ })
144
+
145
+ it('toggles AND <-> OR via the joiner pill between chips', async () => {
146
+ const user = userEvent.setup()
147
+ window.localStorage.setItem('rf.test.joiner', '1')
148
+ render(
149
+ <Harness
150
+ storageKey="rf.test.joiner"
151
+ initial={{
152
+ logicalOperator: 'and',
153
+ elements: [
154
+ { field: 'status', operator: 'eq', value: 'active' },
155
+ { field: 'mrr', operator: 'gt', value: '500' },
156
+ ],
157
+ }}
158
+ />,
159
+ )
160
+
161
+ const andPill = screen.getByRole('button', { name: 'AND' })
162
+ await user.click(andPill)
163
+ expect(screen.getByRole('button', { name: 'OR' })).toBeInTheDocument()
164
+ })
165
+ })
166
+
167
+ /* =========================================================================
168
+ Keyboard-first creation — RecordFilterBuilderPanel.
169
+ ========================================================================= */
170
+
171
+ function PanelHarness({
172
+ initial,
173
+ onClose = () => {},
174
+ }: {
175
+ initial?: FilterGroup
176
+ onClose?: () => void
177
+ }) {
178
+ const [filter, setFilter] = useState<FilterGroup>(
179
+ initial ?? { logicalOperator: 'and', elements: [] },
180
+ )
181
+ return (
182
+ <RecordFilterBuilderPanel
183
+ fields={fields}
184
+ filter={filter}
185
+ onChange={setFilter}
186
+ onClose={onClose}
187
+ />
188
+ )
189
+ }
190
+
191
+ describe('RecordFilterBuilderPanel — keyboard creation', () => {
192
+ it('auto-adds a pending row on mount when elements is empty', () => {
193
+ const onChange = vi.fn()
194
+ render(
195
+ <RecordFilterBuilderPanel
196
+ fields={fields}
197
+ filter={{ logicalOperator: 'and', elements: [] }}
198
+ onChange={onChange}
199
+ onClose={() => {}}
200
+ />,
201
+ )
202
+ // First and only call appends a single pending element with field=''.
203
+ expect(onChange).toHaveBeenCalledTimes(1)
204
+ expect(onChange).toHaveBeenCalledWith({
205
+ logicalOperator: 'and',
206
+ elements: [{ field: '', operator: 'eq', value: '' }],
207
+ })
208
+ })
209
+
210
+ it('does not auto-add a pending row when elements is non-empty', () => {
211
+ const onChange = vi.fn()
212
+ render(
213
+ <RecordFilterBuilderPanel
214
+ fields={fields}
215
+ filter={{
216
+ logicalOperator: 'and',
217
+ elements: [{ field: 'name', operator: 'eq', value: 'x' }],
218
+ }}
219
+ onChange={onChange}
220
+ onClose={() => {}}
221
+ />,
222
+ )
223
+ expect(onChange).not.toHaveBeenCalled()
224
+ })
225
+
226
+ it('Enter inside a plain value Input adds a new pending row', async () => {
227
+ const user = userEvent.setup()
228
+ render(
229
+ <PanelHarness
230
+ initial={{
231
+ logicalOperator: 'and',
232
+ elements: [{ field: 'name', operator: 'eq', value: 'tonio' }],
233
+ }}
234
+ />,
235
+ )
236
+
237
+ // The string row's value cell is a plain <input>. Find it and focus it.
238
+ const valueInput = screen.getByLabelText('Name')
239
+ await user.click(valueInput)
240
+ await user.keyboard('{Enter}')
241
+
242
+ // After commit + add new pending row, two rows should exist: the
243
+ // original plus a fresh pending one. Probe for the second row's
244
+ // AttributePicker placeholder ("Find attribute…") — there's at least
245
+ // one in the DOM either way (the original row also has one), but with
246
+ // a pending row added there should be one *empty* attribute combobox.
247
+ const placeholders = screen.getAllByPlaceholderText('Find attribute…')
248
+ // Original row has one (showing the picked field as input value); the
249
+ // pending row adds another. Total = 2.
250
+ expect(placeholders).toHaveLength(2)
251
+ })
252
+
253
+ it('Cmd+Enter on the panel calls onClose', async () => {
254
+ const user = userEvent.setup()
255
+ const onClose = vi.fn()
256
+ render(
257
+ <PanelHarness
258
+ onClose={onClose}
259
+ initial={{
260
+ logicalOperator: 'and',
261
+ elements: [{ field: 'name', operator: 'eq', value: 'tonio' }],
262
+ }}
263
+ />,
264
+ )
265
+
266
+ // Focus any control inside the panel and fire Cmd+Enter.
267
+ const valueInput = screen.getByLabelText('Name')
268
+ await user.click(valueInput)
269
+ await user.keyboard('{Meta>}{Enter}{/Meta}')
270
+
271
+ expect(onClose).toHaveBeenCalledTimes(1)
272
+ })
273
+ })
274
+
275
+ /* =========================================================================
276
+ Nested groups — opt-in via `isComplexEnabled`.
277
+ ========================================================================= */
278
+
279
+ function NestedHarness({
280
+ initial,
281
+ isComplexEnabled,
282
+ }: {
283
+ initial?: FilterGroup
284
+ isComplexEnabled: boolean
285
+ }) {
286
+ const [filter, setFilter] = useState<FilterGroup>(
287
+ initial ?? { logicalOperator: 'and', elements: [] },
288
+ )
289
+ return (
290
+ <RecordFilterBuilderPanel
291
+ fields={fields}
292
+ filter={filter}
293
+ onChange={setFilter}
294
+ onClose={() => {}}
295
+ isComplexEnabled={isComplexEnabled}
296
+ />
297
+ )
298
+ }
299
+
300
+ describe('RecordFilterBuilderPanel — nested groups', () => {
301
+ it('hides the Add group button when isComplexEnabled is false', () => {
302
+ render(
303
+ <RecordFilterBuilderPanel
304
+ fields={fields}
305
+ filter={{
306
+ logicalOperator: 'and',
307
+ elements: [{ field: 'name', operator: 'eq', value: 'x' }],
308
+ }}
309
+ onChange={() => {}}
310
+ onClose={() => {}}
311
+ />,
312
+ )
313
+ expect(screen.queryByRole('button', { name: /Add group/i })).not.toBeInTheDocument()
314
+ expect(screen.getByRole('button', { name: /Add filter/i })).toBeInTheDocument()
315
+ })
316
+
317
+ it('shows Add group, "Wrap in group" hover icon, and the GROUP eyebrow when enabled', async () => {
318
+ const user = userEvent.setup()
319
+ render(
320
+ <NestedHarness
321
+ isComplexEnabled
322
+ initial={{
323
+ logicalOperator: 'and',
324
+ elements: [{ field: 'name', operator: 'eq', value: 'x' }],
325
+ }}
326
+ />,
327
+ )
328
+
329
+ expect(screen.getByRole('button', { name: /Wrap in group/i })).toBeInTheDocument()
330
+ const addGroup = screen.getByRole('button', { name: /Add group/i })
331
+ await user.click(addGroup)
332
+
333
+ // After clicking Add group: a Group eyebrow appears + the new sub-group's
334
+ // "Empty group" prompt appears in the body.
335
+ expect(screen.getByText(/^Group$/)).toBeInTheDocument()
336
+ expect(screen.getByText(/Empty group/i)).toBeInTheDocument()
337
+ })
338
+
339
+ it('Wrap in group moves the leaf into a new sub-group with the opposite joiner', async () => {
340
+ const user = userEvent.setup()
341
+ const onFilter = vi.fn<(f: FilterGroup) => void>()
342
+ render(
343
+ <ProbePanel
344
+ onFilter={onFilter}
345
+ initial={{
346
+ logicalOperator: 'and',
347
+ elements: [{ field: 'name', operator: 'eq', value: 'x' }],
348
+ }}
349
+ />,
350
+ )
351
+
352
+ await user.click(screen.getByRole('button', { name: /Wrap in group/i }))
353
+
354
+ expect(onFilter).toHaveBeenCalled()
355
+ const next = onFilter.mock.calls.at(-1)?.[0]
356
+ expect(next?.elements).toHaveLength(0)
357
+ expect(next?.groups).toHaveLength(1)
358
+ expect(next?.groups?.[0]?.logicalOperator).toBe('or')
359
+ expect(next?.groups?.[0]?.elements?.[0]?.field).toBe('name')
360
+ })
361
+
362
+ it('Removing a nested group via the group × leaves elements untouched', async () => {
363
+ const user = userEvent.setup()
364
+ const onFilter = vi.fn<(f: FilterGroup) => void>()
365
+ render(
366
+ <ProbePanel
367
+ onFilter={onFilter}
368
+ initial={{
369
+ logicalOperator: 'and',
370
+ elements: [{ field: 'name', operator: 'eq', value: 'x' }],
371
+ groups: [
372
+ {
373
+ logicalOperator: 'or',
374
+ elements: [{ field: 'mrr', operator: 'gt', value: '1000' }],
375
+ },
376
+ ],
377
+ }}
378
+ />,
379
+ )
380
+
381
+ await user.click(screen.getByRole('button', { name: /Remove group/i }))
382
+
383
+ const next = onFilter.mock.calls.at(-1)?.[0]
384
+ expect(next?.groups ?? []).toHaveLength(0)
385
+ expect(next?.elements).toHaveLength(1)
386
+ })
387
+ })
388
+
389
+ function ProbePanel({
390
+ initial,
391
+ onFilter,
392
+ }: {
393
+ initial: FilterGroup
394
+ onFilter: (f: FilterGroup) => void
395
+ }) {
396
+ const [filter, setFilter] = useState<FilterGroup>(initial)
397
+ return (
398
+ <RecordFilterBuilderPanel
399
+ fields={fields}
400
+ filter={filter}
401
+ onChange={(next) => {
402
+ setFilter(next)
403
+ onFilter(next)
404
+ }}
405
+ onClose={() => {}}
406
+ isComplexEnabled
407
+ />
408
+ )
409
+ }
410
+
411
+ describe('RecordFilterChips — nested groups', () => {
412
+ it('renders a read-only group pill for each sub-group', () => {
413
+ // Chips only render when expanded; the store now defaults to collapsed.
414
+ window.localStorage.setItem('rf.showDetails', '1')
415
+ render(
416
+ <RecordFilterChips
417
+ fields={fields}
418
+ filter={{
419
+ logicalOperator: 'and',
420
+ elements: [{ field: 'name', operator: 'eq', value: 'x' }],
421
+ groups: [
422
+ {
423
+ logicalOperator: 'or',
424
+ elements: [
425
+ { field: 'mrr', operator: 'gt', value: '1000' },
426
+ { field: 'status', operator: 'eq', value: 'active' },
427
+ ],
428
+ },
429
+ ],
430
+ }}
431
+ onChange={() => {}}
432
+ />,
433
+ )
434
+ expect(screen.getByText(/or group/i)).toBeInTheDocument()
435
+ expect(screen.getByText(/· 2/)).toBeInTheDocument()
436
+ })
437
+ })
@@ -0,0 +1,72 @@
1
+ import { useEffect, useState } from 'react'
2
+
3
+ /* =========================================================================
4
+ showDetails persistence — a tiny pub/sub on top of localStorage so the
5
+ FilterButton (collapsed) and the FilterChips (expanded) stay in sync
6
+ regardless of where they're mounted in the page. Keyed by a configurable
7
+ storage key so multiple filter UIs can coexist.
8
+ ========================================================================= */
9
+
10
+ type Listener = (next: boolean) => void
11
+
12
+ const stores = new Map<string, Set<Listener>>()
13
+
14
+ function listeners(key: string): Set<Listener> {
15
+ let set = stores.get(key)
16
+ if (!set) {
17
+ set = new Set()
18
+ stores.set(key, set)
19
+ }
20
+ return set
21
+ }
22
+
23
+ function read(key: string): boolean {
24
+ if (typeof window === 'undefined') return false
25
+ try {
26
+ // Default collapsed: only an explicit '1' expands. Unset / '0' → collapsed.
27
+ return window.localStorage.getItem(key) === '1'
28
+ } catch {
29
+ return false
30
+ }
31
+ }
32
+
33
+ function write(key: string, next: boolean) {
34
+ if (typeof window !== 'undefined') {
35
+ try {
36
+ window.localStorage.setItem(key, next ? '1' : '0')
37
+ } catch {
38
+ /* ignore quota / privacy-mode errors */
39
+ }
40
+ }
41
+ for (const fn of listeners(key)) fn(next)
42
+ }
43
+
44
+ export function useShowDetails(storageKey = 'rf.showDetails') {
45
+ const [value, setValue] = useState<boolean>(() => read(storageKey))
46
+
47
+ useEffect(() => {
48
+ const fn: Listener = (next) => setValue(next)
49
+ listeners(storageKey).add(fn)
50
+ setValue(read(storageKey))
51
+ return () => {
52
+ listeners(storageKey).delete(fn)
53
+ }
54
+ }, [storageKey])
55
+
56
+ const toggle = () => write(storageKey, !read(storageKey))
57
+ const set = (next: boolean) => write(storageKey, next)
58
+
59
+ return [value, toggle, set] as const
60
+ }
61
+
62
+ /** Test-only escape hatch — clears all listeners and storage values. */
63
+ export function __resetShowDetailsStores() {
64
+ stores.clear()
65
+ if (typeof window !== 'undefined') {
66
+ try {
67
+ window.localStorage.clear()
68
+ } catch {
69
+ /* ignore */
70
+ }
71
+ }
72
+ }