@handled-ai/design-system 0.18.51 → 0.18.52

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 (34) hide show
  1. package/dist/components/data-table-filter.d.ts +21 -6
  2. package/dist/components/data-table-filter.js +134 -9
  3. package/dist/components/data-table-filter.js.map +1 -1
  4. package/dist/components/score-why-chips.d.ts +1 -1
  5. package/dist/components/signal-feedback-inline.d.ts +28 -12
  6. package/dist/components/signal-feedback-inline.js +146 -10
  7. package/dist/components/signal-feedback-inline.js.map +1 -1
  8. package/dist/components/signal-priority-popover.d.ts +1 -1
  9. package/dist/components/signal-priority-popover.js +7 -16
  10. package/dist/components/signal-priority-popover.js.map +1 -1
  11. package/dist/index.d.ts +3 -3
  12. package/dist/index.js.map +1 -1
  13. package/dist/prototype/index.d.ts +1 -1
  14. package/dist/prototype/prototype-accounts-view.d.ts +1 -1
  15. package/dist/prototype/prototype-admin-view.d.ts +1 -1
  16. package/dist/prototype/prototype-config.d.ts +1 -1
  17. package/dist/prototype/prototype-inbox-view.d.ts +3 -3
  18. package/dist/prototype/prototype-inbox-view.js +1 -3
  19. package/dist/prototype/prototype-inbox-view.js.map +1 -1
  20. package/dist/prototype/prototype-insights-view.d.ts +1 -1
  21. package/dist/prototype/prototype-shell.d.ts +1 -1
  22. package/dist/{signal-priority-popover-DTedstRL.d.ts → signal-priority-popover-QJngMAj7.d.ts} +4 -13
  23. package/package.json +1 -1
  24. package/src/components/__tests__/case-panel-why.test.tsx +126 -0
  25. package/src/components/__tests__/data-table-filter.test.tsx +130 -0
  26. package/src/components/__tests__/signal-priority-popover.test.tsx +4 -41
  27. package/src/components/data-table-filter.tsx +160 -9
  28. package/src/components/signal-feedback-inline.tsx +181 -20
  29. package/src/components/signal-priority-popover.tsx +6 -19
  30. package/src/index.ts +1 -1
  31. package/src/prototype/__tests__/detail-view-opportunity-preview.test.tsx +90 -0
  32. package/src/prototype/__tests__/detail-view-score-why.test.tsx +0 -34
  33. package/src/prototype/prototype-config.ts +3 -7
  34. package/src/prototype/prototype-inbox-view.tsx +3 -5
@@ -264,6 +264,136 @@ describe("DataTableFilter", () => {
264
264
  expect(openItem!.querySelector("span.rounded-full")).toBeNull();
265
265
  });
266
266
 
267
+
268
+ it("renders a text category as a submenu with a text input", () => {
269
+ const textCategory: DataTableFilterCategory = {
270
+ id: "callsign",
271
+ label: "Callsign",
272
+ icon: ListFilter,
273
+ type: "text",
274
+ valuePlaceholder: "Enter callsign",
275
+ };
276
+
277
+ render(
278
+ <DataTableFilter
279
+ categories={[textCategory]}
280
+ selectedFilters={{}}
281
+ onToggleFilter={() => {}}
282
+ />
283
+ );
284
+
285
+ const subTrigger = document.querySelector('[data-slot="dropdown-menu-sub-trigger"]');
286
+ expect(subTrigger).not.toBeNull();
287
+ expect(subTrigger!.textContent).toContain("Callsign");
288
+ expect(screen.getByLabelText("Callsign")).toBeDefined();
289
+ expect(screen.getByPlaceholderText("Enter callsign")).toBeDefined();
290
+ expect(screen.getByRole("button", { name: "Apply" })).toBeDefined();
291
+ expect(screen.queryByText("No matches")).toBeNull();
292
+ });
293
+
294
+ it("applies a trimmed text filter value", () => {
295
+ const onTextFilterChange = vi.fn();
296
+ const textCategory: DataTableFilterCategory = {
297
+ id: "callsign",
298
+ label: "Callsign",
299
+ icon: ListFilter,
300
+ type: "text",
301
+ valuePlaceholder: "Enter callsign",
302
+ };
303
+
304
+ render(
305
+ <DataTableFilter
306
+ categories={[textCategory]}
307
+ selectedFilters={{}}
308
+ onToggleFilter={() => {}}
309
+ textFilters={{}}
310
+ onTextFilterChange={onTextFilterChange}
311
+ />
312
+ );
313
+
314
+ fireEvent.change(screen.getByLabelText("Callsign"), {
315
+ target: { value: " M42 " },
316
+ });
317
+ fireEvent.click(screen.getByRole("button", { name: "Apply" }));
318
+
319
+ expect(onTextFilterChange).toHaveBeenCalledWith("callsign", "M42");
320
+ });
321
+
322
+ it("clears an active text filter value", () => {
323
+ const onTextFilterChange = vi.fn();
324
+ const textCategory: DataTableFilterCategory = {
325
+ id: "organizationId",
326
+ label: "Organization ID",
327
+ icon: ListFilter,
328
+ type: "text",
329
+ };
330
+
331
+ render(
332
+ <DataTableFilter
333
+ categories={[textCategory]}
334
+ selectedFilters={{}}
335
+ onToggleFilter={() => {}}
336
+ textFilters={{ organizationId: "ORG-123" }}
337
+ onTextFilterChange={onTextFilterChange}
338
+ />
339
+ );
340
+
341
+ expect(screen.getByLabelText("Organization ID")).toHaveProperty("value", "ORG-123");
342
+ fireEvent.click(screen.getByRole("button", { name: "Clear" }));
343
+
344
+ expect(onTextFilterChange).toHaveBeenCalledWith("organizationId", "");
345
+ });
346
+
347
+ it("includes active text filters in the filter count", () => {
348
+ const textCategory: DataTableFilterCategory = {
349
+ id: "callsign",
350
+ label: "Callsign",
351
+ icon: ListFilter,
352
+ type: "text",
353
+ };
354
+ const optionCategory: DataTableFilterCategory = {
355
+ id: "status",
356
+ label: "Status",
357
+ icon: ListFilter,
358
+ options: ["Open", "Closed"],
359
+ };
360
+
361
+ render(
362
+ <DataTableFilter
363
+ categories={[textCategory, optionCategory]}
364
+ selectedFilters={{ status: ["Open"] }}
365
+ onToggleFilter={() => {}}
366
+ textFilters={{ callsign: " M42 " }}
367
+ />
368
+ );
369
+
370
+ const badge = screen.getByText("2");
371
+ expect(badge.tagName).toBe("SPAN");
372
+ expect(badge.className).toContain("bg-muted");
373
+ });
374
+
375
+ it("does not count blank text filter values as active", () => {
376
+ const textCategory: DataTableFilterCategory = {
377
+ id: "callsign",
378
+ label: "Callsign",
379
+ icon: ListFilter,
380
+ type: "text",
381
+ };
382
+
383
+ render(
384
+ <DataTableFilter
385
+ categories={[textCategory]}
386
+ selectedFilters={{}}
387
+ onToggleFilter={() => {}}
388
+ textFilters={{ callsign: " " }}
389
+ />
390
+ );
391
+
392
+ const triggerButton = document.querySelector('[data-slot="dropdown-menu-trigger"]');
393
+ expect(triggerButton).not.toBeNull();
394
+ expect(triggerButton!.querySelector("span.bg-muted")).toBeNull();
395
+ });
396
+
267
397
  it("does not expose the condition builder entry point without condition fields", () => {
268
398
  render(<DataTableFilter {...defaultProps} />);
269
399
 
@@ -119,8 +119,8 @@ describe("SignalPriorityPopover", () => {
119
119
 
120
120
  // Check head section
121
121
  expect(content.textContent).toContain("Why this is high priority")
122
- expect(screen.getByTestId("priority-overall-score").textContent).toContain("79")
123
- expect(screen.getByTestId("priority-overall-score").textContent).toContain("/100")
122
+ expect(content.textContent).toContain("79")
123
+ expect(content.textContent).toContain("/100")
124
124
  expect(content.textContent).toContain("High range")
125
125
  expect(content.textContent).toContain("60-79")
126
126
  })
@@ -188,50 +188,13 @@ describe("SignalPriorityPopover", () => {
188
188
  expect(row.textContent).not.toContain("Raises0/100")
189
189
  })
190
190
 
191
- it("renders Contributing factors section label and shared-consumer-safe default formula label", () => {
191
+ it("renders Contributing factors section label", () => {
192
192
  render(<SignalPriorityPopover {...defaultProps} />)
193
193
  fireEvent.click(screen.getByTestId("priority-popover-trigger"))
194
194
 
195
195
  const content = screen.getByTestId("priority-popover-content")
196
196
  expect(content.textContent).toContain("Contributing factors")
197
- expect(content.textContent).toContain("Priority factors")
198
- expect(content.textContent).not.toContain("Score = weighted sum")
199
- expect(content.textContent).not.toContain("Priority = weighted signals + calibration")
200
- })
201
-
202
- it("renders a custom formula label when provided", () => {
203
- render(
204
- <SignalPriorityPopover
205
- {...defaultProps}
206
- formulaLabel="Priority = weighted signals + calibration"
207
- />,
208
- )
209
- fireEvent.click(screen.getByTestId("priority-popover-trigger"))
210
-
211
- const content = screen.getByTestId("priority-popover-content")
212
- expect(content.textContent).toContain("Priority = weighted signals + calibration")
213
- })
214
-
215
-
216
- it("renders the overall score number by default while preserving factor row scores", () => {
217
- render(<SignalPriorityPopover {...defaultProps} />)
218
- fireEvent.click(screen.getByTestId("priority-popover-trigger"))
219
-
220
- expect(screen.getByTestId("priority-overall-score").textContent).toBe("79/100")
221
- expect(screen.getByTestId("factor-row-test_severity").textContent).toContain("85/100")
222
- expect(screen.getByTestId("factor-row-account_depth").textContent).toContain("30/100")
223
- })
224
-
225
- it("hides only the overall header score in label display mode", () => {
226
- render(<SignalPriorityPopover {...defaultProps} scoreDisplay="label" />)
227
- fireEvent.click(screen.getByTestId("priority-popover-trigger"))
228
-
229
- const header = screen.getByTestId("priority-popover-header")
230
- expect(screen.queryByTestId("priority-overall-score")).toBeNull()
231
- expect(header.textContent).toContain("Why this is high priority")
232
- expect(header.textContent).not.toContain("79/100")
233
- expect(screen.getByTestId("factor-row-test_severity").textContent).toContain("85/100")
234
- expect(screen.getByTestId("factor-row-account_depth").textContent).toContain("30/100")
197
+ expect(content.textContent).toContain("Score = weighted sum")
235
198
  })
236
199
 
237
200
  it("renders score track bars with correct width percentage", () => {
@@ -28,13 +28,10 @@ export interface FilterOption {
28
28
  value: string
29
29
  }
30
30
 
31
- export interface DataTableFilterCategory {
31
+ interface DataTableFilterCategoryBase {
32
32
  id: string
33
33
  label: string
34
34
  icon: React.ComponentType<{ className?: string }>
35
- options: (string | FilterOption)[]
36
- /** Filter behavior. Defaults to "multi" (checkbox multi-select). */
37
- type?: "multi" | "single" | "boolean"
38
35
  /**
39
36
  * Submenu search behavior. Defaults to the DataTableFilter
40
37
  * optionSearchThreshold prop. Use true to always show search or false to
@@ -43,6 +40,25 @@ export interface DataTableFilterCategory {
43
40
  searchable?: boolean | { threshold?: number }
44
41
  }
45
42
 
43
+ export interface DataTableOptionFilterCategory extends DataTableFilterCategoryBase {
44
+ options: (string | FilterOption)[]
45
+ /** Filter behavior. Defaults to "multi" (checkbox multi-select). */
46
+ type?: "multi" | "single" | "boolean"
47
+ }
48
+
49
+ export interface DataTableTextFilterCategory extends DataTableFilterCategoryBase {
50
+ /** Free-text filter behavior. Renders a top-level submenu with a text input. */
51
+ type: "text"
52
+ /** Placeholder shown in the text filter input. */
53
+ valuePlaceholder?: string
54
+ /** Not used for text filters; optional for backwards-compatible category shapes. */
55
+ options?: (string | FilterOption)[]
56
+ }
57
+
58
+ export type DataTableFilterCategory =
59
+ | DataTableOptionFilterCategory
60
+ | DataTableTextFilterCategory
61
+
46
62
  function getOptionValue(option: string | FilterOption): string {
47
63
  return typeof option === "string" ? option : option.value
48
64
  }
@@ -50,6 +66,111 @@ function getOptionLabel(option: string | FilterOption): string {
50
66
  return typeof option === "string" ? option : option.label
51
67
  }
52
68
 
69
+ function isTextFilterCategory(
70
+ category: DataTableFilterCategory
71
+ ): category is DataTableTextFilterCategory {
72
+ return category.type === "text"
73
+ }
74
+
75
+ function TextFilterSubmenu({
76
+ category,
77
+ value,
78
+ onValueChange,
79
+ }: {
80
+ category: DataTableTextFilterCategory
81
+ value: string
82
+ onValueChange?: (categoryId: string, value: string) => void
83
+ }) {
84
+ const [draftValue, setDraftValue] = React.useState(value)
85
+
86
+ React.useEffect(() => {
87
+ setDraftValue(value)
88
+ }, [value])
89
+
90
+ const active = value.trim().length > 0
91
+ const applyValue = React.useCallback(() => {
92
+ onValueChange?.(category.id, draftValue.trim())
93
+ }, [category.id, draftValue, onValueChange])
94
+
95
+ return (
96
+ <DropdownMenuSub
97
+ onOpenChange={(open) => {
98
+ if (!open) {
99
+ setDraftValue(value)
100
+ }
101
+ }}
102
+ >
103
+ <DropdownMenuSubTrigger
104
+ className={cn(
105
+ "cursor-pointer py-1.5 text-xs",
106
+ active && "text-brand-purple"
107
+ )}
108
+ >
109
+ <category.icon
110
+ className={cn(
111
+ "mr-2 h-3.5 w-3.5 text-muted-foreground",
112
+ active && "text-brand-purple"
113
+ )}
114
+ />
115
+ {category.label}
116
+ {active ? <Check className="ml-auto h-4 w-4" /> : null}
117
+ </DropdownMenuSubTrigger>
118
+ <DropdownMenuSubContent className="w-64 p-2">
119
+ <div className="space-y-2">
120
+ <input
121
+ aria-label={category.label}
122
+ className="h-8 w-full rounded-md bg-muted/50 px-2 py-1 text-xs outline-none transition-colors placeholder:text-muted-foreground/70 focus:bg-muted"
123
+ placeholder={
124
+ category.valuePlaceholder ??
125
+ `Enter ${category.label.toLowerCase()}...`
126
+ }
127
+ value={draftValue}
128
+ onChange={(event) => setDraftValue(event.target.value)}
129
+ onClick={(event) => event.stopPropagation()}
130
+ onKeyDown={(event) => {
131
+ event.stopPropagation()
132
+ if (event.key === "Enter") {
133
+ event.preventDefault()
134
+ applyValue()
135
+ }
136
+ }}
137
+ />
138
+ <div className="flex items-center justify-end gap-2">
139
+ {active ? (
140
+ <Button
141
+ type="button"
142
+ variant="ghost"
143
+ size="sm"
144
+ className="h-7 px-2 text-xs"
145
+ onClick={(event) => {
146
+ event.preventDefault()
147
+ event.stopPropagation()
148
+ setDraftValue("")
149
+ onValueChange?.(category.id, "")
150
+ }}
151
+ >
152
+ Clear
153
+ </Button>
154
+ ) : null}
155
+ <Button
156
+ type="button"
157
+ size="sm"
158
+ className="h-7 px-2 text-xs"
159
+ onClick={(event) => {
160
+ event.preventDefault()
161
+ event.stopPropagation()
162
+ applyValue()
163
+ }}
164
+ >
165
+ Apply
166
+ </Button>
167
+ </div>
168
+ </div>
169
+ </DropdownMenuSubContent>
170
+ </DropdownMenuSub>
171
+ )
172
+ }
173
+
53
174
  export interface DataTableFilterProps {
54
175
  categories: DataTableFilterCategory[]
55
176
  selectedFilters: Record<string, string[]>
@@ -71,6 +192,10 @@ export interface DataTableFilterProps {
71
192
  onConditionFiltersChange?: (conditions: ConditionFilterValue[]) => void
72
193
  /** Dropdown entry label for the condition-builder panel. Default: "Add filter". */
73
194
  conditionBuilderLabel?: string
195
+ /** Active free-text filters keyed by category id. */
196
+ textFilters?: Record<string, string>
197
+ /** Callback when a free-text filter value is applied or cleared. */
198
+ onTextFilterChange?: (categoryId: string, value: string) => void
74
199
  }
75
200
 
76
201
  export function DataTableFilter({
@@ -86,6 +211,8 @@ export function DataTableFilter({
86
211
  conditionFilters = [],
87
212
  onConditionFiltersChange,
88
213
  conditionBuilderLabel = "Add filter",
214
+ textFilters = {},
215
+ onTextFilterChange,
89
216
  }: DataTableFilterProps) {
90
217
  const [query, setQuery] = React.useState("")
91
218
  const [subQueries, setSubQueries] = React.useState<Record<string, string>>({})
@@ -103,6 +230,10 @@ export function DataTableFilter({
103
230
  return true
104
231
  }
105
232
 
233
+ if (isTextFilterCategory(category)) {
234
+ return false
235
+ }
236
+
106
237
  return category.options.some((option) =>
107
238
  getOptionLabel(option).toLowerCase().includes(normalized)
108
239
  )
@@ -123,8 +254,16 @@ export function DataTableFilter({
123
254
  0
124
255
  )
125
256
 
126
- return userCount + conditionFilters.length
127
- }, [selectedFilters, conditionFilters.length])
257
+ const textCount = categories.reduce((count, category) => {
258
+ if (!isTextFilterCategory(category)) {
259
+ return count
260
+ }
261
+
262
+ return textFilters[category.id]?.trim() ? count + 1 : count
263
+ }, 0)
264
+
265
+ return userCount + conditionFilters.length + textCount
266
+ }, [categories, selectedFilters, conditionFilters.length, textFilters])
128
267
 
129
268
  /** Collect all preset chips to render */
130
269
  const presetChips = React.useMemo(() => {
@@ -135,9 +274,9 @@ export function DataTableFilter({
135
274
  for (const [categoryId, values] of Object.entries(presetFilters)) {
136
275
  const category = categories.find((c) => c.id === categoryId)
137
276
  for (const value of values) {
138
- const option = category?.options.find(
139
- (opt) => getOptionValue(opt) === value
140
- )
277
+ const option = category && !isTextFilterCategory(category)
278
+ ? category.options.find((opt) => getOptionValue(opt) === value)
279
+ : undefined
141
280
  const label = option ? getOptionLabel(option) : value
142
281
  const active = selectedFilters[categoryId]?.includes(value) ?? false
143
282
  chips.push({ categoryId, value, label, active })
@@ -208,6 +347,18 @@ export function DataTableFilter({
208
347
  )
209
348
  }
210
349
 
350
+ /* ── Free-text submenu ───────────────────────────────── */
351
+ if (isTextFilterCategory(category)) {
352
+ return (
353
+ <TextFilterSubmenu
354
+ key={category.id}
355
+ category={category}
356
+ value={textFilters[category.id] ?? ""}
357
+ onValueChange={onTextFilterChange}
358
+ />
359
+ )
360
+ }
361
+
211
362
  /* ── Sub-menu (single / multi) ──────────────────────── */
212
363
  const subQuery = (subQueries[category.id] ?? "").trim().toLowerCase()
213
364
  const filteredOptions = subQuery