@handled-ai/design-system 0.17.0 → 0.17.2

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 (51) hide show
  1. package/dist/components/badge.d.ts +1 -1
  2. package/dist/components/button.d.ts +1 -1
  3. package/dist/components/feedback-primitives.d.ts +66 -0
  4. package/dist/components/feedback-primitives.js +295 -0
  5. package/dist/components/feedback-primitives.js.map +1 -0
  6. package/dist/components/score-why-chips.d.ts +8 -17
  7. package/dist/components/score-why-chips.js +266 -180
  8. package/dist/components/score-why-chips.js.map +1 -1
  9. package/dist/components/signal-priority-popover.d.ts +17 -0
  10. package/dist/components/signal-priority-popover.js +247 -0
  11. package/dist/components/signal-priority-popover.js.map +1 -0
  12. package/dist/components/tabs.d.ts +1 -1
  13. package/dist/components/user-display.d.ts +22 -0
  14. package/dist/components/user-display.js +138 -0
  15. package/dist/components/user-display.js.map +1 -0
  16. package/dist/components/user-pill.d.ts +3 -0
  17. package/dist/components/user-pill.js +5 -0
  18. package/dist/components/user-pill.js.map +1 -0
  19. package/dist/index.d.ts +6 -3
  20. package/dist/index.js +12 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/lib/user-display.d.ts +31 -0
  23. package/dist/lib/user-display.js +57 -0
  24. package/dist/lib/user-display.js.map +1 -0
  25. package/dist/prototype/index.d.ts +2 -1
  26. package/dist/prototype/prototype-accounts-view.d.ts +2 -1
  27. package/dist/prototype/prototype-admin-view.d.ts +2 -1
  28. package/dist/prototype/prototype-config.d.ts +15 -328
  29. package/dist/prototype/prototype-inbox-view.d.ts +8 -3
  30. package/dist/prototype/prototype-inbox-view.js +24 -13
  31. package/dist/prototype/prototype-inbox-view.js.map +1 -1
  32. package/dist/prototype/prototype-insights-view.d.ts +2 -1
  33. package/dist/prototype/prototype-shell.d.ts +2 -1
  34. package/dist/signal-priority-popover-DQ_VuHac.d.ts +390 -0
  35. package/package.json +1 -1
  36. package/src/components/__tests__/contextual-quick-action-launcher.test.tsx +99 -188
  37. package/src/components/__tests__/feedback-primitives.test.tsx +509 -0
  38. package/src/components/__tests__/score-why-chips.test.tsx +540 -0
  39. package/src/components/__tests__/signal-priority-popover.test.tsx +312 -0
  40. package/src/components/feedback-primitives.tsx +424 -0
  41. package/src/components/score-why-chips.tsx +413 -203
  42. package/src/components/signal-priority-popover.tsx +359 -0
  43. package/src/components/user-display.tsx +96 -0
  44. package/src/components/user-pill.tsx +1 -0
  45. package/src/index.ts +6 -0
  46. package/src/lib/__tests__/user-display.test.ts +43 -0
  47. package/src/lib/user-display.ts +88 -0
  48. package/src/prototype/__tests__/detail-view-score-why.test.tsx +33 -29
  49. package/src/prototype/__tests__/detail-view-title-slots.test.tsx +65 -0
  50. package/src/prototype/prototype-config.ts +28 -0
  51. package/src/prototype/prototype-inbox-view.tsx +25 -11
@@ -0,0 +1,540 @@
1
+ /**
2
+ * Tests for ScoreWhyChips, WhyPill, WhyCard, and signal row components.
3
+ *
4
+ * Covers:
5
+ * - ScoreWhyChips renders bucket pills with structured signal rows
6
+ * - Slot grammar grid renders primary value, qualifier, counterparty, time
7
+ * - Missing data renders empty (no em-dashes)
8
+ * - Long list truncation at 8 rows, "Show N more" expands
9
+ * - Combined signal row renders component mini-chips
10
+ * - Bucket feedback footer renders and fires callback with FeedbackSubmitData
11
+ * - WhyPill shows icon, count badge, chevron, and X close button
12
+ * - Accordion behavior: only one pill expanded at a time
13
+ */
14
+
15
+ import { describe, it, expect, vi } from "vitest"
16
+ import React from "react"
17
+ import { render, screen, fireEvent } from "@testing-library/react"
18
+ import { ScoreWhyChips } from "../score-why-chips"
19
+ import type {
20
+ QueueItem,
21
+ SignalScoreData,
22
+ SignalScoreExplanationBucket,
23
+ SignalScoreExplanationSignal,
24
+ } from "../../prototype/prototype-config"
25
+
26
+ // ─── Mock data ───────────────────────────────────────────────────────────────
27
+
28
+ const mockItem: QueueItem = {
29
+ id: "case-1",
30
+ title: "Test Case",
31
+ details: "Some details",
32
+ statusColor: "red",
33
+ time: "2h ago",
34
+ company: "TestCorp",
35
+ tag1: "urgent",
36
+ }
37
+
38
+ function makeSignal(overrides: Partial<SignalScoreExplanationSignal> = {}): SignalScoreExplanationSignal {
39
+ return {
40
+ id: "sig-1",
41
+ label: "Treasury Liquidation",
42
+ primaryValue: "-$1,724,310.11",
43
+ qualifier: "100% of balance",
44
+ counterparty: "-> JPMorgan Chase --6042",
45
+ time: "7h ago",
46
+ ...overrides,
47
+ }
48
+ }
49
+
50
+ function makeBucket(overrides: Partial<SignalScoreExplanationBucket> = {}): SignalScoreExplanationBucket {
51
+ return {
52
+ key: "treasury_liquidation",
53
+ label: "Treasury Liquidation",
54
+ kind: "signal",
55
+ signalCount: 2,
56
+ icon: "trending-down",
57
+ tone: "alert",
58
+ signals: [
59
+ makeSignal({ id: "sig-1" }),
60
+ makeSignal({ id: "sig-2", primaryValue: "-$500,000.00", qualifier: "45% of balance" }),
61
+ ],
62
+ ...overrides,
63
+ }
64
+ }
65
+
66
+ function makeSignalData(overrides: Partial<SignalScoreData> = {}): SignalScoreData {
67
+ return {
68
+ score: 85,
69
+ factors: [],
70
+ whyNow: "Large treasury movement",
71
+ evidence: [],
72
+ confidence: 90,
73
+ explanationBuckets: [makeBucket()],
74
+ ...overrides,
75
+ }
76
+ }
77
+
78
+ // ─── ScoreWhyChips basic rendering ──────────────────────────────────────────
79
+
80
+ describe("ScoreWhyChips", () => {
81
+ it("renders nothing when there are no buckets", () => {
82
+ const { container } = render(
83
+ <ScoreWhyChips
84
+ item={mockItem}
85
+ signalData={makeSignalData({ explanationBuckets: [] })}
86
+ />,
87
+ )
88
+ expect(container.innerHTML).toBe("")
89
+ })
90
+
91
+ it("renders the Why label and bucket pills", () => {
92
+ render(
93
+ <ScoreWhyChips
94
+ item={mockItem}
95
+ signalData={makeSignalData()}
96
+ />,
97
+ )
98
+ expect(screen.getByText("Why")).toBeDefined()
99
+ expect(screen.getByText("Treasury Liquidation")).toBeDefined()
100
+ })
101
+
102
+ it("renders count badge on pill when signalCount > 1", () => {
103
+ render(
104
+ <ScoreWhyChips
105
+ item={mockItem}
106
+ signalData={makeSignalData()}
107
+ />,
108
+ )
109
+ expect(screen.getByText("x2")).toBeDefined()
110
+ })
111
+
112
+ it("does not render count badge when signalCount is 1", () => {
113
+ render(
114
+ <ScoreWhyChips
115
+ item={mockItem}
116
+ signalData={makeSignalData({
117
+ explanationBuckets: [makeBucket({ signalCount: 1, signals: [makeSignal()] })],
118
+ })}
119
+ />,
120
+ )
121
+ expect(screen.queryByText("x1")).toBeNull()
122
+ })
123
+ })
124
+
125
+ // ─── Accordion behavior ────────────────────────────────────────────────────
126
+
127
+ describe("ScoreWhyChips accordion", () => {
128
+ it("expands a bucket when pill is clicked", () => {
129
+ render(
130
+ <ScoreWhyChips
131
+ item={mockItem}
132
+ signalData={makeSignalData()}
133
+ />,
134
+ )
135
+
136
+ // Click the pill
137
+ fireEvent.click(screen.getByText("Treasury Liquidation"))
138
+
139
+ // Should show the card with signal count header
140
+ expect(screen.getByText(/2 signals/)).toBeDefined()
141
+ })
142
+
143
+ it("collapses when the same pill is clicked again", () => {
144
+ render(
145
+ <ScoreWhyChips
146
+ item={mockItem}
147
+ signalData={makeSignalData()}
148
+ />,
149
+ )
150
+
151
+ const pill = screen.getByText("Treasury Liquidation")
152
+ fireEvent.click(pill)
153
+ expect(screen.getByText(/2 signals/)).toBeDefined()
154
+
155
+ // Click again to collapse
156
+ fireEvent.click(pill)
157
+ expect(screen.queryByText(/2 signals/)).toBeNull()
158
+ })
159
+
160
+ it("only one pill is expanded at a time (accordion)", () => {
161
+ const bucket2 = makeBucket({
162
+ key: "test_transaction",
163
+ label: "Test Transaction",
164
+ icon: "radar",
165
+ tone: "info",
166
+ signalCount: 1,
167
+ signals: [makeSignal({ id: "sig-3", label: "Test Tx" })],
168
+ })
169
+
170
+ render(
171
+ <ScoreWhyChips
172
+ item={mockItem}
173
+ signalData={makeSignalData({
174
+ explanationBuckets: [makeBucket(), bucket2],
175
+ })}
176
+ />,
177
+ )
178
+
179
+ // Expand first bucket
180
+ fireEvent.click(screen.getByText("Treasury Liquidation"))
181
+ expect(screen.getByText(/2 signals/)).toBeDefined()
182
+
183
+ // Expand second bucket - should collapse first
184
+ fireEvent.click(screen.getByText("Test Transaction"))
185
+ expect(screen.getByText(/1 signal/)).toBeDefined()
186
+ // The first bucket's card should be gone
187
+ expect(screen.queryByText(/2 signals – Treasury Liquidation/)).toBeNull()
188
+ })
189
+
190
+ it("X close button collapses the expanded bucket", () => {
191
+ render(
192
+ <ScoreWhyChips
193
+ item={mockItem}
194
+ signalData={makeSignalData()}
195
+ />,
196
+ )
197
+
198
+ // Expand
199
+ fireEvent.click(screen.getByText("Treasury Liquidation"))
200
+ expect(screen.getByText(/2 signals/)).toBeDefined()
201
+
202
+ // Click the X close button
203
+ const closeButton = screen.getByLabelText("Close Treasury Liquidation")
204
+ fireEvent.click(closeButton)
205
+ expect(screen.queryByText(/2 signals/)).toBeNull()
206
+ })
207
+ })
208
+
209
+ // ─── Structured signal row (slot grammar) ──────────────────────────────────
210
+
211
+ describe("Structured signal row", () => {
212
+ it("renders primary value, qualifier, counterparty, and time", () => {
213
+ render(
214
+ <ScoreWhyChips
215
+ item={mockItem}
216
+ signalData={makeSignalData()}
217
+ />,
218
+ )
219
+
220
+ // Expand bucket
221
+ fireEvent.click(screen.getByText("Treasury Liquidation"))
222
+
223
+ // Check structured data renders
224
+ expect(screen.getAllByText("-$1,724,310.11").length).toBeGreaterThanOrEqual(1)
225
+ expect(screen.getAllByText("100% of balance").length).toBeGreaterThanOrEqual(1)
226
+ expect(screen.getAllByText("-> JPMorgan Chase --6042").length).toBeGreaterThanOrEqual(1)
227
+ expect(screen.getAllByText("7h ago").length).toBeGreaterThanOrEqual(1)
228
+ })
229
+
230
+ it("renders empty string for null/undefined slots (no em-dashes)", () => {
231
+ // Mix of present and missing data to trigger structured row rendering.
232
+ // One signal has a primaryValue (triggering structured mode), the other
233
+ // has missing fields that should render as empty.
234
+ render(
235
+ <ScoreWhyChips
236
+ item={mockItem}
237
+ signalData={makeSignalData({
238
+ explanationBuckets: [
239
+ makeBucket({
240
+ signals: [
241
+ makeSignal({
242
+ id: "sig-partial",
243
+ primaryValue: "-$100.00",
244
+ qualifier: undefined,
245
+ counterparty: undefined,
246
+ time: undefined,
247
+ }),
248
+ ],
249
+ signalCount: 1,
250
+ }),
251
+ ],
252
+ })}
253
+ />,
254
+ )
255
+
256
+ // Expand bucket
257
+ fireEvent.click(screen.getByText("Treasury Liquidation"))
258
+
259
+ // The primary value should render, but missing slots should be empty (no em-dashes)
260
+ expect(screen.getByText("-$100.00")).toBeTruthy()
261
+ // Verify no em-dashes are rendered
262
+ expect(screen.queryByText("\u2014")).toBeNull()
263
+ })
264
+
265
+ it("calls onOpenSignalBucket when a signal row is clicked", () => {
266
+ const onOpen = vi.fn()
267
+ render(
268
+ <ScoreWhyChips
269
+ item={mockItem}
270
+ signalData={makeSignalData()}
271
+ onOpenSignalBucket={onOpen}
272
+ />,
273
+ )
274
+
275
+ // Expand bucket
276
+ fireEvent.click(screen.getByText("Treasury Liquidation"))
277
+
278
+ // Find signal row buttons and click the first one
279
+ const signalButtons = screen.getAllByRole("button").filter(
280
+ (btn) => btn.className.includes("grid"),
281
+ )
282
+ expect(signalButtons.length).toBeGreaterThan(0)
283
+ fireEvent.click(signalButtons[0])
284
+
285
+ expect(onOpen).toHaveBeenCalledWith({
286
+ item: mockItem,
287
+ bucketKey: "treasury_liquidation",
288
+ signalId: "sig-1",
289
+ })
290
+ })
291
+ })
292
+
293
+ // ─── Long list truncation ──────────────────────────────────────────────────
294
+
295
+ describe("Long list truncation", () => {
296
+ it("shows first 8 rows and 'Show N more' button when there are more than 8 signals", () => {
297
+ const signals = Array.from({ length: 12 }, (_, i) =>
298
+ makeSignal({ id: `sig-${i}`, primaryValue: `$${i * 100}` }),
299
+ )
300
+ render(
301
+ <ScoreWhyChips
302
+ item={mockItem}
303
+ signalData={makeSignalData({
304
+ explanationBuckets: [
305
+ makeBucket({ signals, signalCount: 12 }),
306
+ ],
307
+ })}
308
+ />,
309
+ )
310
+
311
+ // Expand
312
+ fireEvent.click(screen.getByText("Treasury Liquidation"))
313
+
314
+ // Should show "Show 4 more" button
315
+ expect(screen.getByText("Show 4 more")).toBeDefined()
316
+
317
+ // Only 8 rows visible initially (check list items)
318
+ const list = screen.getByRole("list", { name: "Matching signals" })
319
+ expect(list.children.length).toBe(8)
320
+ })
321
+
322
+ it("clicking 'Show N more' reveals all rows", () => {
323
+ const signals = Array.from({ length: 12 }, (_, i) =>
324
+ makeSignal({ id: `sig-${i}`, primaryValue: `$${i * 100}` }),
325
+ )
326
+ render(
327
+ <ScoreWhyChips
328
+ item={mockItem}
329
+ signalData={makeSignalData({
330
+ explanationBuckets: [
331
+ makeBucket({ signals, signalCount: 12 }),
332
+ ],
333
+ })}
334
+ />,
335
+ )
336
+
337
+ fireEvent.click(screen.getByText("Treasury Liquidation"))
338
+ fireEvent.click(screen.getByText("Show 4 more"))
339
+
340
+ // All 12 rows visible now
341
+ const list = screen.getByRole("list", { name: "Matching signals" })
342
+ expect(list.children.length).toBe(12)
343
+
344
+ // Button should be gone
345
+ expect(screen.queryByText("Show 4 more")).toBeNull()
346
+ })
347
+
348
+ it("does not show 'Show more' button when signals <= 8", () => {
349
+ render(
350
+ <ScoreWhyChips
351
+ item={mockItem}
352
+ signalData={makeSignalData()}
353
+ />,
354
+ )
355
+
356
+ fireEvent.click(screen.getByText("Treasury Liquidation"))
357
+ expect(screen.queryByText(/Show \d+ more/)).toBeNull()
358
+ })
359
+ })
360
+
361
+ // ─── Combined signal row ───────────────────────────────────────────────────
362
+
363
+ describe("Combined signal row", () => {
364
+ it("renders component mini-chips for combined_signal type", () => {
365
+ const combinedSignal: SignalScoreExplanationSignal = {
366
+ id: "sig-combined",
367
+ label: "Combined signal",
368
+ signalTypeName: "combined_signal",
369
+ components: [
370
+ { type: "treasury_liquidation", count: 3 },
371
+ { type: "test_transaction", count: 2 },
372
+ ],
373
+ time: "1h ago",
374
+ }
375
+
376
+ render(
377
+ <ScoreWhyChips
378
+ item={mockItem}
379
+ signalData={makeSignalData({
380
+ explanationBuckets: [
381
+ makeBucket({
382
+ key: "combined_signal",
383
+ label: "Combined",
384
+ tone: "alert",
385
+ signals: [combinedSignal],
386
+ signalCount: 1,
387
+ }),
388
+ ],
389
+ })}
390
+ />,
391
+ )
392
+
393
+ // Expand
394
+ fireEvent.click(screen.getByText("Combined"))
395
+
396
+ // Should render component mini-chips
397
+ expect(screen.getByText(/treasury liquidation x3/)).toBeDefined()
398
+ expect(screen.getByText(/test transaction x2/)).toBeDefined()
399
+ // Should render the '+' separator
400
+ expect(screen.getByText("+")).toBeDefined()
401
+ })
402
+ })
403
+
404
+ // ─── Bucket feedback footer ────────────────────────────────────────────────
405
+
406
+ describe("Bucket feedback footer", () => {
407
+ it("renders feedback footer when onBucketFeedback is provided", () => {
408
+ const onBucketFeedback = vi.fn()
409
+ render(
410
+ <ScoreWhyChips
411
+ item={mockItem}
412
+ signalData={makeSignalData({ onBucketFeedback })}
413
+ />,
414
+ )
415
+
416
+ // Expand bucket
417
+ fireEvent.click(screen.getByText("Treasury Liquidation"))
418
+
419
+ // Should show thumbs buttons
420
+ expect(screen.getByText("Helpful")).toBeDefined()
421
+ expect(screen.getByText("Not helpful")).toBeDefined()
422
+ })
423
+
424
+ it("does not render feedback footer when onBucketFeedback is not provided", () => {
425
+ render(
426
+ <ScoreWhyChips
427
+ item={mockItem}
428
+ signalData={makeSignalData()}
429
+ />,
430
+ )
431
+
432
+ // Expand bucket
433
+ fireEvent.click(screen.getByText("Treasury Liquidation"))
434
+
435
+ // Should NOT show thumbs buttons
436
+ expect(screen.queryByText("Helpful")).toBeNull()
437
+ expect(screen.queryByText("Not helpful")).toBeNull()
438
+ })
439
+
440
+ it("fires onBucketFeedback with correct bucket key and FeedbackSubmitData", () => {
441
+ const onBucketFeedback = vi.fn()
442
+ render(
443
+ <ScoreWhyChips
444
+ item={mockItem}
445
+ signalData={makeSignalData({ onBucketFeedback })}
446
+ />,
447
+ )
448
+
449
+ // Expand bucket
450
+ fireEvent.click(screen.getByText("Treasury Liquidation"))
451
+
452
+ // Click "Not helpful"
453
+ fireEvent.click(screen.getByText("Not helpful"))
454
+
455
+ // Select a chip
456
+ fireEvent.click(screen.getByText("Bad timing"))
457
+
458
+ // Submit
459
+ fireEvent.click(screen.getByText("Submit"))
460
+
461
+ expect(onBucketFeedback).toHaveBeenCalledTimes(1)
462
+ expect(onBucketFeedback).toHaveBeenCalledWith("treasury_liquidation", {
463
+ sentiment: "negative",
464
+ reasonTop: "Bad timing",
465
+ reasonSub: undefined,
466
+ pills: [],
467
+ detail: "",
468
+ })
469
+ })
470
+ })
471
+
472
+ // ─── WhyPill visual states ─────────────────────────────────────────────────
473
+
474
+ describe("WhyPill visual states", () => {
475
+ it("collapsed pill has rounded-lg and bg-background classes", () => {
476
+ render(
477
+ <ScoreWhyChips
478
+ item={mockItem}
479
+ signalData={makeSignalData()}
480
+ />,
481
+ )
482
+
483
+ const pill = screen.getByRole("button", { name: /Treasury Liquidation/i })
484
+ expect(pill.className).toContain("rounded-lg")
485
+ expect(pill.className).toContain("bg-background")
486
+ expect(pill.className).not.toContain("rounded-b-none")
487
+ })
488
+
489
+ it("expanded pill has rounded-b-none and bg-muted classes", () => {
490
+ render(
491
+ <ScoreWhyChips
492
+ item={mockItem}
493
+ signalData={makeSignalData()}
494
+ />,
495
+ )
496
+
497
+ // Expand
498
+ fireEvent.click(screen.getByText("Treasury Liquidation"))
499
+
500
+ const pill = screen.getByRole("button", { expanded: true })
501
+ expect(pill.className).toContain("rounded-b-none")
502
+ expect(pill.className).toContain("bg-muted")
503
+ })
504
+ })
505
+
506
+ // ─── Legacy signal row fallback ────────────────────────────────────────────
507
+
508
+ describe("Legacy signal row fallback", () => {
509
+ it("renders legacy rows when signals lack structured data", () => {
510
+ const legacySignal: SignalScoreExplanationSignal = {
511
+ id: "sig-legacy",
512
+ label: "Old format signal",
513
+ description: "Some legacy description",
514
+ source: "Bank API",
515
+ metric: "$500K",
516
+ time: "3h ago",
517
+ }
518
+
519
+ render(
520
+ <ScoreWhyChips
521
+ item={mockItem}
522
+ signalData={makeSignalData({
523
+ explanationBuckets: [
524
+ makeBucket({
525
+ signals: [legacySignal],
526
+ signalCount: 1,
527
+ }),
528
+ ],
529
+ })}
530
+ />,
531
+ )
532
+
533
+ fireEvent.click(screen.getByText("Treasury Liquidation"))
534
+
535
+ expect(screen.getByText("Old format signal")).toBeDefined()
536
+ expect(screen.getByText("Some legacy description")).toBeDefined()
537
+ expect(screen.getByText("Bank API")).toBeDefined()
538
+ expect(screen.getByText("$500K")).toBeDefined()
539
+ })
540
+ })