@handled-ai/design-system 0.18.6 → 0.18.8

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 (32) hide show
  1. package/dist/components/badge.d.ts +1 -1
  2. package/dist/components/button.d.ts +1 -1
  3. package/dist/components/data-table-filter.d.ts +8 -0
  4. package/dist/components/data-table-filter.js +8 -1
  5. package/dist/components/data-table-filter.js.map +1 -1
  6. package/dist/components/pill.d.ts +1 -1
  7. package/dist/components/score-why-chips.d.ts +1 -1
  8. package/dist/components/signal-priority-popover.d.ts +1 -1
  9. package/dist/components/tabs.d.ts +1 -1
  10. package/dist/components/timeline-activity.d.ts +16 -1
  11. package/dist/components/timeline-activity.js +69 -1
  12. package/dist/components/timeline-activity.js.map +1 -1
  13. package/dist/index.d.ts +2 -2
  14. package/dist/prototype/index.d.ts +1 -1
  15. package/dist/prototype/prototype-accounts-view.d.ts +1 -1
  16. package/dist/prototype/prototype-admin-view.d.ts +1 -1
  17. package/dist/prototype/prototype-config.d.ts +1 -1
  18. package/dist/prototype/prototype-inbox-view.d.ts +15 -3
  19. package/dist/prototype/prototype-inbox-view.js +156 -36
  20. package/dist/prototype/prototype-inbox-view.js.map +1 -1
  21. package/dist/prototype/prototype-insights-view.d.ts +1 -1
  22. package/dist/prototype/prototype-shell.d.ts +1 -1
  23. package/dist/{signal-priority-popover-DWaAMhPI.d.ts → signal-priority-popover-BT6CPYNs.d.ts} +17 -1
  24. package/package.json +2 -1
  25. package/src/components/__tests__/data-table-filter.test.tsx +41 -0
  26. package/src/components/__tests__/timeline-activity.test.tsx +152 -0
  27. package/src/components/data-table-filter.tsx +17 -2
  28. package/src/components/timeline-activity.tsx +112 -1
  29. package/src/prototype/__tests__/detail-view-attention.test.tsx +2 -2
  30. package/src/prototype/__tests__/detail-view-timeline-system-events.test.tsx +409 -0
  31. package/src/prototype/prototype-config.ts +21 -0
  32. package/src/prototype/prototype-inbox-view.tsx +227 -30
@@ -0,0 +1,409 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest"
2
+ import React from "react"
3
+ import { render, fireEvent } from "@testing-library/react"
4
+ import { DetailView, type DetailViewProps } from "../prototype-inbox-view"
5
+ import type { QueueItem, SignalScoreData } from "../prototype-config"
6
+ import type { TimelineEvent } from "../../components/timeline-activity"
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Helpers
10
+ // ---------------------------------------------------------------------------
11
+
12
+ const baseItem: QueueItem = {
13
+ id: "1",
14
+ title: "Test Signal",
15
+ details: "Some details",
16
+ statusColor: "green",
17
+ time: "2h ago",
18
+ company: "Acme Inc",
19
+ tag1: "renewal",
20
+ }
21
+
22
+ function makeSignalScore(): SignalScoreData {
23
+ return {
24
+ score: 75,
25
+ factors: [
26
+ { key: "trigger", label: "Trigger strength", score: 70, why: "Strong signal" },
27
+ ],
28
+ whyNow: "Strong signals detected.",
29
+ evidence: ["Evidence line 1"],
30
+ confidence: 80,
31
+ }
32
+ }
33
+
34
+ const normalEvents: TimelineEvent[] = [
35
+ {
36
+ id: "t1",
37
+ icon: React.createElement("span", null, "📧"),
38
+ title: "Email sent",
39
+ time: "1h ago",
40
+ },
41
+ {
42
+ id: "t2",
43
+ icon: React.createElement("span", null, "📞"),
44
+ title: "Call logged",
45
+ time: "3h ago",
46
+ },
47
+ ]
48
+
49
+ const noiseEvents: TimelineEvent[] = [
50
+ {
51
+ id: "t3",
52
+ icon: React.createElement("span", null, "📊"),
53
+ title: "Score updated +3",
54
+ time: "2h ago",
55
+ isSystemNoise: true,
56
+ },
57
+ {
58
+ id: "t4",
59
+ icon: React.createElement("span", null, "📊"),
60
+ title: "Score updated -1",
61
+ time: "4h ago",
62
+ isSystemNoise: true,
63
+ },
64
+ ]
65
+
66
+ const mixedEvents: TimelineEvent[] = [...normalEvents, ...noiseEvents]
67
+
68
+ function baseProps(overrides: Partial<DetailViewProps> = {}): DetailViewProps {
69
+ return {
70
+ item: baseItem,
71
+ sections: { signalBrief: true, suggestedActions: false, timeline: true },
72
+ getSignalScore: () => makeSignalScore(),
73
+ buildSuggestedActions: () => [],
74
+ buildSourceItems: () => [],
75
+ getTimelineEvents: () => mixedEvents,
76
+ accountContacts: [],
77
+ emailSignature: "",
78
+ iconMap: {},
79
+ timelineSystemEventsConfig: {
80
+ toggleLabel: "Score changes",
81
+ storageKey: "test-show-score-changes",
82
+ hiddenHint: "Score changes are hidden.",
83
+ visibleHint: "Showing {count} score changes.",
84
+ },
85
+ ...overrides,
86
+ }
87
+ }
88
+
89
+ // Mock localStorage
90
+ let store: Record<string, string> = {}
91
+
92
+ const localStorageMock = {
93
+ getItem: vi.fn((key: string) => store[key] ?? null),
94
+ setItem: vi.fn((key: string, value: string) => {
95
+ store[key] = value
96
+ }),
97
+ removeItem: vi.fn((key: string) => {
98
+ delete store[key]
99
+ }),
100
+ clear: vi.fn(() => {
101
+ store = {}
102
+ }),
103
+ }
104
+
105
+ beforeEach(() => {
106
+ store = {}
107
+ localStorageMock.getItem.mockClear()
108
+ localStorageMock.setItem.mockClear()
109
+ localStorageMock.removeItem.mockClear()
110
+ localStorageMock.clear.mockClear()
111
+ // Reset getItem to default implementation
112
+ localStorageMock.getItem.mockImplementation((key: string) => store[key] ?? null)
113
+ Object.defineProperty(window, "localStorage", { value: localStorageMock, writable: true })
114
+ })
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Helper to expand the timeline
118
+ // ---------------------------------------------------------------------------
119
+
120
+ function expandTimeline(container: HTMLElement) {
121
+ const collapseBtn = container.querySelector(
122
+ '[data-testid="timeline-collapse-btn"]',
123
+ ) as HTMLElement
124
+ if (collapseBtn) fireEvent.click(collapseBtn)
125
+ }
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // Tests
129
+ // ---------------------------------------------------------------------------
130
+
131
+ describe("DetailView timeline system-events toggle", () => {
132
+ it("hides events with isSystemNoise: true by default", () => {
133
+ const { container } = render(<DetailView {...baseProps()} />)
134
+ // Expand the timeline
135
+ expandTimeline(container)
136
+ // Should only show 2 normal events, not the 2 noise events
137
+ const eventCount = container.querySelector('[data-testid="event-count"]')
138
+ expect(eventCount?.textContent).toBe("2 events")
139
+ // Noise event titles should not appear
140
+ expect(container.textContent).not.toContain("Score updated +3")
141
+ expect(container.textContent).not.toContain("Score updated -1")
142
+ })
143
+
144
+ it("reveals system-noise events when toggle is clicked", () => {
145
+ const { container } = render(<DetailView {...baseProps()} />)
146
+ expandTimeline(container)
147
+ // Click the toggle
148
+ const toggle = container.querySelector(
149
+ '[data-testid="system-events-toggle"]',
150
+ ) as HTMLElement
151
+ expect(toggle).not.toBeNull()
152
+ fireEvent.click(toggle)
153
+ // Now all 4 events should be visible
154
+ const eventCount = container.querySelector('[data-testid="event-count"]')
155
+ expect(eventCount?.textContent).toBe("4 events")
156
+ expect(container.textContent).toContain("Score updated +3")
157
+ })
158
+
159
+ it("clicking the toggle does NOT collapse/expand the timeline", () => {
160
+ const { container } = render(<DetailView {...baseProps()} />)
161
+ expandTimeline(container)
162
+ // Timeline should be expanded — normal events visible
163
+ expect(container.textContent).toContain("Email sent")
164
+
165
+ // Click the system-events toggle
166
+ const toggle = container.querySelector(
167
+ '[data-testid="system-events-toggle"]',
168
+ ) as HTMLElement
169
+ fireEvent.click(toggle)
170
+
171
+ // Timeline should still be expanded
172
+ expect(container.textContent).toContain("Email sent")
173
+ })
174
+
175
+ it("clicking the collapse button does NOT toggle system-event visibility", () => {
176
+ const { container } = render(<DetailView {...baseProps()} />)
177
+ expandTimeline(container)
178
+
179
+ // Toggle system events on
180
+ const toggle = container.querySelector(
181
+ '[data-testid="system-events-toggle"]',
182
+ ) as HTMLElement
183
+ fireEvent.click(toggle)
184
+ expect(container.textContent).toContain("Score updated +3")
185
+
186
+ // Collapse the timeline
187
+ expandTimeline(container)
188
+ // Re-expand
189
+ expandTimeline(container)
190
+
191
+ // System events should still be visible (toggle didn't change)
192
+ expect(container.textContent).toContain("Score updated +3")
193
+ })
194
+
195
+ it("header event count reflects visible events, not total events", () => {
196
+ const { container } = render(<DetailView {...baseProps()} />)
197
+ const eventCount = container.querySelector('[data-testid="event-count"]')
198
+ expect(eventCount?.textContent).toBe("2 events")
199
+ })
200
+
201
+ it("hidden count badge shows the correct number", () => {
202
+ const { container } = render(<DetailView {...baseProps()} />)
203
+ const badge = container.querySelector('[data-testid="hidden-count-badge"]')
204
+ expect(badge).not.toBeNull()
205
+ expect(badge?.textContent).toBe("2")
206
+ })
207
+
208
+ it("calls localStorage.setItem when toggle changes", () => {
209
+ const { container } = render(<DetailView {...baseProps()} />)
210
+ expandTimeline(container)
211
+ const toggle = container.querySelector(
212
+ '[data-testid="system-events-toggle"]',
213
+ ) as HTMLElement
214
+ fireEvent.click(toggle)
215
+ expect(localStorageMock.setItem).toHaveBeenCalledWith(
216
+ "test-show-score-changes",
217
+ "true",
218
+ )
219
+ })
220
+
221
+ it("reads localStorage.getItem on mount and honors stored value", () => {
222
+ store["test-show-score-changes"] = "true"
223
+ const { container } = render(<DetailView {...baseProps()} />)
224
+ expandTimeline(container)
225
+ // With stored value "true", system events should be visible
226
+ const eventCount = container.querySelector('[data-testid="event-count"]')
227
+ expect(eventCount?.textContent).toBe("4 events")
228
+ expect(localStorageMock.getItem).toHaveBeenCalledWith(
229
+ "test-show-score-changes",
230
+ )
231
+ })
232
+
233
+ it("renders header and toggle when all events are system noise and toggle is off", () => {
234
+ const allNoiseProps = baseProps({
235
+ getTimelineEvents: () => noiseEvents,
236
+ })
237
+ const { container } = render(<DetailView {...allNoiseProps} />)
238
+ // Header should still be rendered
239
+ const header = container.querySelector('[data-testid="timeline-header"]')
240
+ expect(header).not.toBeNull()
241
+ // Toggle should be present
242
+ const toggle = container.querySelector(
243
+ '[data-testid="system-events-toggle"]',
244
+ )
245
+ expect(toggle).not.toBeNull()
246
+ // Event count should show 0 events
247
+ const eventCount = container.querySelector('[data-testid="event-count"]')
248
+ expect(eventCount?.textContent).toBe("0 events")
249
+ })
250
+
251
+ it("'Last activity' uses the first visible event's time, not a hidden system-noise event", () => {
252
+ // Arrange: first event is system noise (time "30m ago"),
253
+ // second event is normal (time "1h ago")
254
+ const orderedEvents: TimelineEvent[] = [
255
+ {
256
+ id: "noise-first",
257
+ icon: React.createElement("span", null, "📊"),
258
+ title: "Score change",
259
+ time: "30m ago",
260
+ isSystemNoise: true,
261
+ },
262
+ {
263
+ id: "normal-second",
264
+ icon: React.createElement("span", null, "📧"),
265
+ title: "Email sent",
266
+ time: "1h ago",
267
+ },
268
+ ]
269
+ const props = baseProps({ getTimelineEvents: () => orderedEvents })
270
+ const { container } = render(<DetailView {...props} />)
271
+ const hint = container.querySelector('[data-testid="last-activity-hint"]')
272
+ expect(hint).not.toBeNull()
273
+ // Should show "1h ago" (the first visible event), not "30m ago" (the hidden noise event)
274
+ expect(hint?.textContent).toContain("1h ago")
275
+ expect(hint?.textContent).not.toContain("30m ago")
276
+ })
277
+
278
+ it("uses singular grammar for 1 event and plural for multiple", () => {
279
+ const singleEvent: TimelineEvent[] = [
280
+ {
281
+ id: "s1",
282
+ icon: React.createElement("span", null, "📧"),
283
+ title: "Email sent",
284
+ time: "1h ago",
285
+ },
286
+ ]
287
+ const props = baseProps({ getTimelineEvents: () => singleEvent })
288
+ const { container } = render(<DetailView {...props} />)
289
+ const eventCount = container.querySelector('[data-testid="event-count"]')
290
+ expect(eventCount?.textContent).toBe("1 event")
291
+ })
292
+
293
+ it("does not render toggle when there are no system-noise events", () => {
294
+ const props = baseProps({
295
+ getTimelineEvents: () => normalEvents,
296
+ })
297
+ const { container } = render(<DetailView {...props} />)
298
+ const toggle = container.querySelector(
299
+ '[data-testid="system-events-toggle"]',
300
+ )
301
+ expect(toggle).toBeNull()
302
+ })
303
+
304
+ it("shows footer hint when timeline is expanded and system events are hidden", () => {
305
+ const { container } = render(<DetailView {...baseProps()} />)
306
+ expandTimeline(container)
307
+ const hint = container.querySelector('[data-testid="timeline-footer-hint"]')
308
+ expect(hint).not.toBeNull()
309
+ expect(hint?.textContent).toBe("Score changes are hidden.")
310
+ })
311
+
312
+ it("shows visible footer hint with count when system events are shown", () => {
313
+ const { container } = render(<DetailView {...baseProps()} />)
314
+ expandTimeline(container)
315
+ // Toggle on
316
+ const toggle = container.querySelector(
317
+ '[data-testid="system-events-toggle"]',
318
+ ) as HTMLElement
319
+ fireEvent.click(toggle)
320
+ const hint = container.querySelector('[data-testid="timeline-footer-hint"]')
321
+ expect(hint).not.toBeNull()
322
+ expect(hint?.textContent).toBe("Showing 2 score changes.")
323
+ })
324
+
325
+ // --- Toggle always renders when system-noise events exist (review fix #1) ---
326
+
327
+ it("renders toggle even when no config is provided, using default 'System events' label", () => {
328
+ const props = baseProps({
329
+ timelineSystemEventsConfig: undefined,
330
+ getTimelineEvents: () => mixedEvents,
331
+ })
332
+ const { container } = render(<DetailView {...props} />)
333
+ const toggle = container.querySelector(
334
+ '[data-testid="system-events-toggle"]',
335
+ )
336
+ expect(toggle).not.toBeNull()
337
+ expect(toggle?.textContent).toContain("System events")
338
+ })
339
+
340
+ // --- lastActivityTime derives from filtered visible events (review fix #2) ---
341
+
342
+ it("ignores lastActivityTime when system-noise events are hidden and uses first visible event time", () => {
343
+ // lastActivityTime says "5m ago" but that comes from a hidden score event
344
+ const orderedEvents: TimelineEvent[] = [
345
+ {
346
+ id: "noise-first",
347
+ icon: React.createElement("span", null, "📊"),
348
+ title: "Score change",
349
+ time: "5m ago",
350
+ isSystemNoise: true,
351
+ },
352
+ {
353
+ id: "normal-second",
354
+ icon: React.createElement("span", null, "📧"),
355
+ title: "Email sent",
356
+ time: "2h ago",
357
+ },
358
+ ]
359
+ const props = baseProps({
360
+ getTimelineEvents: () => orderedEvents,
361
+ lastActivityTime: "5m ago",
362
+ })
363
+ const { container } = render(<DetailView {...props} />)
364
+ const hint = container.querySelector('[data-testid="last-activity-hint"]')
365
+ expect(hint).not.toBeNull()
366
+ // Should show "2h ago" (first visible event), not "5m ago" (from lastActivityTime)
367
+ expect(hint?.textContent).toContain("2h ago")
368
+ expect(hint?.textContent).not.toContain("5m ago")
369
+ })
370
+
371
+ it("uses lastActivityTime when all events are visible (showSystemEvents is true or no noise)", () => {
372
+ const props = baseProps({
373
+ getTimelineEvents: () => normalEvents,
374
+ lastActivityTime: "5m ago",
375
+ })
376
+ const { container } = render(<DetailView {...props} />)
377
+ const hint = container.querySelector('[data-testid="last-activity-hint"]')
378
+ expect(hint).not.toBeNull()
379
+ expect(hint?.textContent).toContain("5m ago")
380
+ })
381
+
382
+ // --- Deprecated individual props backward compatibility ---
383
+
384
+ it("accepts deprecated individual props and renders toggle correctly", () => {
385
+ const props: DetailViewProps = {
386
+ item: baseItem,
387
+ sections: { signalBrief: true, suggestedActions: false, timeline: true },
388
+ getSignalScore: () => makeSignalScore(),
389
+ buildSuggestedActions: () => [],
390
+ buildSourceItems: () => [],
391
+ getTimelineEvents: () => mixedEvents,
392
+ accountContacts: [],
393
+ emailSignature: "",
394
+ iconMap: {},
395
+ timelineSystemEventsToggleLabel: "Legacy label",
396
+ timelineSystemEventsStorageKey: "legacy-key",
397
+ timelineSystemEventsHiddenHint: "Legacy hidden hint.",
398
+ }
399
+ const { container } = render(<DetailView {...props} />)
400
+ const toggle = container.querySelector('[data-testid="system-events-toggle"]')
401
+ expect(toggle).not.toBeNull()
402
+ expect(toggle?.textContent).toContain("Legacy label")
403
+ // Footer hint should work too
404
+ expandTimeline(container)
405
+ const hint = container.querySelector('[data-testid="timeline-footer-hint"]')
406
+ expect(hint).not.toBeNull()
407
+ expect(hint?.textContent).toBe("Legacy hidden hint.")
408
+ })
409
+ })
@@ -18,6 +18,23 @@ import type { LucideIcon } from "lucide-react"
18
18
  import type { PriorityFactor } from "../components/signal-priority-popover"
19
19
  import type { FeedbackChipTree, FeedbackSubmitData, PersistedFeedbackData } from "../components/feedback-primitives"
20
20
 
21
+ // ---------------------------------------------------------------------------
22
+ // Timeline system-events toggle config
23
+ // ---------------------------------------------------------------------------
24
+
25
+ export interface TimelineSystemEventsConfig {
26
+ /** Label for the toggle button (e.g. "Score changes"). Falls back to "System events". */
27
+ toggleLabel?: string
28
+ /** localStorage key for persisting the toggle state. */
29
+ storageKey?: string
30
+ /** Whether system-noise events are visible by default. @default false */
31
+ defaultVisible?: boolean
32
+ /** Hint text shown below the timeline when system events are hidden. */
33
+ hiddenHint?: string
34
+ /** Hint text shown below the timeline when system events are visible. Uses {count} as placeholder. */
35
+ visibleHint?: string
36
+ }
37
+
21
38
  // ---------------------------------------------------------------------------
22
39
  // Shared
23
40
  // ---------------------------------------------------------------------------
@@ -206,6 +223,10 @@ export interface InboxViewConfig {
206
223
  renderAfterScore?: (item: QueueItem) => React.ReactNode
207
224
  /** Formatted string for "Last activity X ago" in the collapsed timeline header. If omitted, falls back to the first event's time. */
208
225
  lastActivityTime?: string
226
+ /** Configuration for the system-noise events toggle (score changes, etc.). */
227
+ timelineSystemEventsConfig?: TimelineSystemEventsConfig
228
+ /** Number of important/attention-worthy events to highlight on the collapsed timeline header. */
229
+ attentionCount?: number
209
230
  /** Render extra content inline with the detail title. */
210
231
  renderTitleExtra?: (item: QueueItem) => React.ReactNode
211
232
  /** Render supporting content below the detail title. */