@handled-ai/design-system 0.18.3 → 0.18.5
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.
- package/dist/charts/empty-chart-state.d.ts +11 -0
- package/dist/charts/empty-chart-state.js +70 -0
- package/dist/charts/empty-chart-state.js.map +1 -0
- package/dist/charts/index.d.ts +1 -0
- package/dist/charts/index.js +1 -0
- package/dist/charts/index.js.map +1 -1
- package/dist/charts/pipeline-overview.d.ts +2 -1
- package/dist/charts/pipeline-overview.js +32 -1
- package/dist/charts/pipeline-overview.js.map +1 -1
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/button.d.ts +1 -1
- package/dist/components/days-open-cell.d.ts +16 -0
- package/dist/components/days-open-cell.js +73 -0
- package/dist/components/days-open-cell.js.map +1 -0
- package/dist/components/detail-drawer.d.ts +16 -0
- package/dist/components/detail-drawer.js +45 -0
- package/dist/components/detail-drawer.js.map +1 -0
- package/dist/components/insights-filter-bar.d.ts +2 -1
- package/dist/components/insights-filter-bar.js +13 -5
- package/dist/components/insights-filter-bar.js.map +1 -1
- package/dist/components/linked-entity-cell.d.ts +14 -0
- package/dist/components/linked-entity-cell.js +96 -0
- package/dist/components/linked-entity-cell.js.map +1 -0
- package/dist/components/metric-card.d.ts +14 -1
- package/dist/components/metric-card.js +97 -0
- package/dist/components/metric-card.js.map +1 -1
- package/dist/components/pill.d.ts +26 -0
- package/dist/components/pill.js +77 -0
- package/dist/components/pill.js.map +1 -0
- package/dist/components/quick-segment.d.ts +13 -0
- package/dist/components/quick-segment.js +96 -0
- package/dist/components/quick-segment.js.map +1 -0
- package/dist/components/tabs.d.ts +1 -1
- package/dist/components/timeline-activity.d.ts +1 -16
- package/dist/components/timeline-activity.js +1 -69
- package/dist/components/timeline-activity.js.map +1 -1
- package/dist/index.d.ts +8 -2
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/prototype/prototype-inbox-view.d.ts +1 -11
- package/dist/prototype/prototype-inbox-view.js +33 -101
- package/dist/prototype/prototype-inbox-view.js.map +1 -1
- package/package.json +1 -1
- package/src/charts/__tests__/insights-charts.test.tsx +62 -0
- package/src/charts/empty-chart-state.tsx +44 -0
- package/src/charts/index.ts +1 -0
- package/src/charts/pipeline-overview.tsx +41 -1
- package/src/components/__tests__/insights-primitives.test.tsx +135 -0
- package/src/components/days-open-cell.tsx +50 -0
- package/src/components/detail-drawer.tsx +60 -0
- package/src/components/insights-filter-bar.tsx +13 -4
- package/src/components/linked-entity-cell.tsx +74 -0
- package/src/components/metric-card.tsx +98 -0
- package/src/components/pill.tsx +67 -0
- package/src/components/quick-segment.tsx +68 -0
- package/src/components/timeline-activity.tsx +1 -112
- package/src/index.ts +5 -0
- package/src/prototype/__tests__/detail-view-attention.test.tsx +2 -2
- package/src/prototype/prototype-inbox-view.tsx +30 -131
- package/src/components/__tests__/timeline-activity.test.tsx +0 -137
- package/src/prototype/__tests__/detail-view-timeline-system-events.test.tsx +0 -322
|
@@ -150,16 +150,6 @@ export interface DetailViewProps {
|
|
|
150
150
|
onRequestApproval?: () => Promise<void>
|
|
151
151
|
/** Number of important/attention-worthy events to highlight on the collapsed timeline header. */
|
|
152
152
|
attentionCount?: number
|
|
153
|
-
/** Label for the system-events toggle button (e.g. "Score changes"). */
|
|
154
|
-
timelineSystemEventsToggleLabel?: string
|
|
155
|
-
/** localStorage key for persisting the system-events toggle state. */
|
|
156
|
-
timelineSystemEventsStorageKey?: string
|
|
157
|
-
/** Whether system-noise events are visible by default. @default false */
|
|
158
|
-
timelineSystemEventsDefaultVisible?: boolean
|
|
159
|
-
/** Hint text shown below the timeline when system events are hidden. */
|
|
160
|
-
timelineSystemEventsHiddenHint?: string
|
|
161
|
-
/** Hint text shown below the timeline when system events are visible. Uses {count} as placeholder. */
|
|
162
|
-
timelineSystemEventsVisibleHint?: string
|
|
163
153
|
}
|
|
164
154
|
|
|
165
155
|
export function DetailView({
|
|
@@ -194,47 +184,10 @@ export function DetailView({
|
|
|
194
184
|
opportunityPreview,
|
|
195
185
|
onRequestApproval,
|
|
196
186
|
attentionCount,
|
|
197
|
-
timelineSystemEventsToggleLabel,
|
|
198
|
-
timelineSystemEventsStorageKey,
|
|
199
|
-
timelineSystemEventsDefaultVisible = false,
|
|
200
|
-
timelineSystemEventsHiddenHint,
|
|
201
|
-
timelineSystemEventsVisibleHint,
|
|
202
187
|
}: DetailViewProps) {
|
|
203
188
|
const [showTimeline, setShowTimeline] = React.useState(false)
|
|
204
189
|
const [extraActions, setExtraActions] = React.useState<SuggestedAction[]>([])
|
|
205
190
|
|
|
206
|
-
// ---- System-noise toggle state ----
|
|
207
|
-
const [showSystemEvents, setShowSystemEvents] = React.useState(timelineSystemEventsDefaultVisible)
|
|
208
|
-
const initialReadDoneRef = React.useRef(false)
|
|
209
|
-
|
|
210
|
-
// Read persisted value from localStorage on mount
|
|
211
|
-
React.useEffect(() => {
|
|
212
|
-
if (!timelineSystemEventsStorageKey) {
|
|
213
|
-
initialReadDoneRef.current = true
|
|
214
|
-
return
|
|
215
|
-
}
|
|
216
|
-
try {
|
|
217
|
-
const stored = localStorage.getItem(timelineSystemEventsStorageKey)
|
|
218
|
-
if (stored !== null) {
|
|
219
|
-
setShowSystemEvents(stored === "true")
|
|
220
|
-
}
|
|
221
|
-
} catch {
|
|
222
|
-
// localStorage unavailable — ignore
|
|
223
|
-
}
|
|
224
|
-
initialReadDoneRef.current = true
|
|
225
|
-
}, [timelineSystemEventsStorageKey])
|
|
226
|
-
|
|
227
|
-
// Write to localStorage when the toggle changes (skip initial if matching default)
|
|
228
|
-
React.useEffect(() => {
|
|
229
|
-
if (!timelineSystemEventsStorageKey) return
|
|
230
|
-
if (!initialReadDoneRef.current) return
|
|
231
|
-
try {
|
|
232
|
-
localStorage.setItem(timelineSystemEventsStorageKey, String(showSystemEvents))
|
|
233
|
-
} catch {
|
|
234
|
-
// localStorage unavailable — ignore
|
|
235
|
-
}
|
|
236
|
-
}, [showSystemEvents, timelineSystemEventsStorageKey])
|
|
237
|
-
|
|
238
191
|
React.useEffect(() => {
|
|
239
192
|
setShowTimeline(false)
|
|
240
193
|
setExtraActions([])
|
|
@@ -398,92 +351,38 @@ export function DetailView({
|
|
|
398
351
|
{renderAfterScore?.(item)}
|
|
399
352
|
|
|
400
353
|
{/* Activity Timeline */}
|
|
401
|
-
{sections.timeline && timelineEvents.length > 0 && (
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
className="group/timeline flex w-full items-center justify-between gap-2 py-2 rounded-md transition-colors hover:bg-muted/40 -mx-2 px-2"
|
|
420
|
-
data-testid="timeline-header"
|
|
421
|
-
>
|
|
422
|
-
{/* Left: collapse/expand button */}
|
|
423
|
-
<button
|
|
424
|
-
type="button"
|
|
425
|
-
onClick={() => setShowTimeline((prev) => !prev)}
|
|
426
|
-
className="flex items-center gap-2 cursor-pointer bg-transparent border-0 p-0"
|
|
427
|
-
data-testid="timeline-collapse-btn"
|
|
428
|
-
>
|
|
429
|
-
<h3 className="text-xs font-bold text-muted-foreground uppercase tracking-wider group-hover/timeline:text-foreground transition-colors">Activity timeline</h3>
|
|
430
|
-
{!showTimeline && attentionCount != null && attentionCount > 0 && (
|
|
431
|
-
<span className="inline-flex items-center gap-1 rounded-full bg-destructive/10 px-1.5 py-0.5 text-[10px] font-semibold text-destructive border border-destructive/20">
|
|
432
|
-
{attentionCount} new
|
|
433
|
-
</span>
|
|
434
|
-
)}
|
|
435
|
-
{!showTimeline && firstVisibleTime && (
|
|
436
|
-
<span className="text-[11px] text-muted-foreground/60" data-testid="last-activity-hint">
|
|
437
|
-
· Last activity {firstVisibleTime}
|
|
438
|
-
</span>
|
|
439
|
-
)}
|
|
440
|
-
<div className="flex items-center gap-1.5">
|
|
441
|
-
<span className="text-[11px] font-medium text-muted-foreground" data-testid="event-count">{eventCountLabel}</span>
|
|
442
|
-
<ChevronDown className={`h-3.5 w-3.5 text-muted-foreground transition-transform duration-200 ${showTimeline ? "rotate-180" : ""}`} />
|
|
443
|
-
</div>
|
|
444
|
-
</button>
|
|
445
|
-
|
|
446
|
-
{/* Right: system-events toggle */}
|
|
447
|
-
{showToggle && (
|
|
448
|
-
<button
|
|
449
|
-
type="button"
|
|
450
|
-
onClick={() => setShowSystemEvents((prev) => !prev)}
|
|
451
|
-
className="flex shrink-0 items-center gap-1.5 rounded-full border border-border bg-background px-2.5 py-1 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-muted/40 hover:text-foreground cursor-pointer"
|
|
452
|
-
aria-pressed={showSystemEvents}
|
|
453
|
-
data-testid="system-events-toggle"
|
|
454
|
-
>
|
|
455
|
-
{timelineSystemEventsToggleLabel ?? "System events"}
|
|
456
|
-
<span
|
|
457
|
-
className="inline-flex items-center justify-center rounded-full bg-muted px-1.5 text-[10px] font-semibold min-w-[18px] tabular-nums"
|
|
458
|
-
data-testid="hidden-count-badge"
|
|
459
|
-
>
|
|
460
|
-
{hiddenCount}
|
|
461
|
-
</span>
|
|
462
|
-
</button>
|
|
354
|
+
{sections.timeline && timelineEvents.length > 0 && (
|
|
355
|
+
<div className="mb-8">
|
|
356
|
+
<button
|
|
357
|
+
type="button"
|
|
358
|
+
onClick={() => setShowTimeline((prev) => !prev)}
|
|
359
|
+
className="group/timeline flex w-full items-center justify-between gap-2 py-2 rounded-md transition-colors hover:bg-muted/40 -mx-2 px-2 cursor-pointer"
|
|
360
|
+
>
|
|
361
|
+
<div className="flex items-center gap-2">
|
|
362
|
+
<h3 className="text-xs font-bold text-muted-foreground uppercase tracking-wider group-hover/timeline:text-foreground transition-colors">Activity timeline</h3>
|
|
363
|
+
{!showTimeline && attentionCount != null && attentionCount > 0 && (
|
|
364
|
+
<span className="inline-flex items-center gap-1 rounded-full bg-destructive/10 px-1.5 py-0.5 text-[10px] font-semibold text-destructive border border-destructive/20">
|
|
365
|
+
{attentionCount} new
|
|
366
|
+
</span>
|
|
367
|
+
)}
|
|
368
|
+
{!showTimeline && (lastActivityTime || (timelineEvents.length > 0 && timelineEvents[0].time)) && (
|
|
369
|
+
<span className="text-[11px] text-muted-foreground/60">
|
|
370
|
+
· Last activity {lastActivityTime ?? timelineEvents[0]?.time ?? ''}
|
|
371
|
+
</span>
|
|
463
372
|
)}
|
|
464
373
|
</div>
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
</p>
|
|
478
|
-
)}
|
|
479
|
-
{showTimeline && showSystemEvents && timelineSystemEventsVisibleHint && hasSystemNoise && (
|
|
480
|
-
<p className="mt-2 text-[11px] text-muted-foreground/60 border-t border-dashed border-border pt-2" data-testid="timeline-footer-hint">
|
|
481
|
-
{timelineSystemEventsVisibleHint.replace("{count}", String(hiddenCount))}
|
|
482
|
-
</p>
|
|
483
|
-
)}
|
|
484
|
-
</div>
|
|
485
|
-
)
|
|
486
|
-
})()}
|
|
374
|
+
<div className="flex items-center gap-1.5">
|
|
375
|
+
<span className="text-[11px] font-medium text-muted-foreground">{timelineEvents.length} events</span>
|
|
376
|
+
<ChevronDown className={`h-3.5 w-3.5 text-muted-foreground transition-transform duration-200 ${showTimeline ? "rotate-180" : ""}`} />
|
|
377
|
+
</div>
|
|
378
|
+
</button>
|
|
379
|
+
{showTimeline && (
|
|
380
|
+
<div className="mt-3">
|
|
381
|
+
<TimelineActivity events={timelineEvents} />
|
|
382
|
+
</div>
|
|
383
|
+
)}
|
|
384
|
+
</div>
|
|
385
|
+
)}
|
|
487
386
|
</div>
|
|
488
387
|
|
|
489
388
|
{/* Suggested Actions */}
|
|
@@ -1,137 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest"
|
|
2
|
-
import React from "react"
|
|
3
|
-
import { render, screen } from "@testing-library/react"
|
|
4
|
-
import {
|
|
5
|
-
TimelineActivity,
|
|
6
|
-
TONE_CLASSES,
|
|
7
|
-
type TimelineEvent,
|
|
8
|
-
} from "../timeline-activity"
|
|
9
|
-
|
|
10
|
-
// ---------------------------------------------------------------------------
|
|
11
|
-
// Helpers
|
|
12
|
-
// ---------------------------------------------------------------------------
|
|
13
|
-
|
|
14
|
-
function minimal(overrides: Partial<TimelineEvent> = {}): TimelineEvent {
|
|
15
|
-
return {
|
|
16
|
-
id: "e1",
|
|
17
|
-
icon: React.createElement("span", { "data-testid": "icon" }, "⚡"),
|
|
18
|
-
title: "Test event",
|
|
19
|
-
time: "2h ago",
|
|
20
|
-
...overrides,
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
// ---------------------------------------------------------------------------
|
|
25
|
-
// Tests
|
|
26
|
-
// ---------------------------------------------------------------------------
|
|
27
|
-
|
|
28
|
-
describe("TimelineActivity", () => {
|
|
29
|
-
// --- Tone rendering ---
|
|
30
|
-
|
|
31
|
-
it("renders red dot classes when tone is 'red'", () => {
|
|
32
|
-
const event = minimal({ tone: "red" })
|
|
33
|
-
const { container } = render(<TimelineActivity events={[event]} />)
|
|
34
|
-
const dot = container.querySelector('[data-testid="timeline-dot"]')!
|
|
35
|
-
expect(dot).not.toBeNull()
|
|
36
|
-
const cls = dot.className
|
|
37
|
-
// Should contain all the red tone dot classes
|
|
38
|
-
expect(cls).toContain("bg-red-50")
|
|
39
|
-
expect(cls).toContain("border-red-200")
|
|
40
|
-
// Should contain the red icon classes
|
|
41
|
-
expect(cls).toContain("text-red-600")
|
|
42
|
-
// Should NOT contain neutral classes
|
|
43
|
-
expect(cls).not.toContain("border-border/60")
|
|
44
|
-
expect(cls).not.toContain("text-muted-foreground")
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
it("renders neutral dot classes when tone is absent", () => {
|
|
48
|
-
const event = minimal()
|
|
49
|
-
const { container } = render(<TimelineActivity events={[event]} />)
|
|
50
|
-
const dot = container.querySelector('[data-testid="timeline-dot"]')!
|
|
51
|
-
expect(dot).not.toBeNull()
|
|
52
|
-
const cls = dot.className
|
|
53
|
-
expect(cls).toContain("border-border/60")
|
|
54
|
-
expect(cls).toContain("bg-background")
|
|
55
|
-
expect(cls).toContain("text-muted-foreground")
|
|
56
|
-
})
|
|
57
|
-
|
|
58
|
-
// --- Actor byline ---
|
|
59
|
-
|
|
60
|
-
it("renders actor byline with name when actor.kind is 'user'", () => {
|
|
61
|
-
const event = minimal({
|
|
62
|
-
actor: { kind: "user", name: "Alice" },
|
|
63
|
-
})
|
|
64
|
-
render(<TimelineActivity events={[event]} />)
|
|
65
|
-
const byline = screen.getByTestId("actor-byline")
|
|
66
|
-
expect(byline).not.toBeNull()
|
|
67
|
-
expect(byline.textContent).toContain("Alice")
|
|
68
|
-
expect(byline.textContent).toContain("performed this action")
|
|
69
|
-
})
|
|
70
|
-
|
|
71
|
-
it("renders no byline when actor.kind is 'system'", () => {
|
|
72
|
-
const event = minimal({
|
|
73
|
-
actor: { kind: "system" },
|
|
74
|
-
})
|
|
75
|
-
const { container } = render(<TimelineActivity events={[event]} />)
|
|
76
|
-
const byline = container.querySelector('[data-testid="actor-byline"]')
|
|
77
|
-
expect(byline).toBeNull()
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
it("renders 'Integration' text when actor.kind is 'integration'", () => {
|
|
81
|
-
const event = minimal({
|
|
82
|
-
actor: { kind: "integration" },
|
|
83
|
-
})
|
|
84
|
-
render(<TimelineActivity events={[event]} />)
|
|
85
|
-
const byline = screen.getByTestId("actor-byline")
|
|
86
|
-
expect(byline).not.toBeNull()
|
|
87
|
-
expect(byline.textContent).toContain("Integration")
|
|
88
|
-
})
|
|
89
|
-
|
|
90
|
-
it("renders custom verb for user actor", () => {
|
|
91
|
-
const event = minimal({
|
|
92
|
-
actor: { kind: "user", name: "Bob", verb: "approved this case" },
|
|
93
|
-
})
|
|
94
|
-
render(<TimelineActivity events={[event]} />)
|
|
95
|
-
const byline = screen.getByTestId("actor-byline")
|
|
96
|
-
expect(byline.textContent).toContain("approved this case")
|
|
97
|
-
})
|
|
98
|
-
|
|
99
|
-
it("renders no byline when actor is absent", () => {
|
|
100
|
-
const event = minimal()
|
|
101
|
-
const { container } = render(<TimelineActivity events={[event]} />)
|
|
102
|
-
const byline = container.querySelector('[data-testid="actor-byline"]')
|
|
103
|
-
expect(byline).toBeNull()
|
|
104
|
-
})
|
|
105
|
-
|
|
106
|
-
// --- Backwards compatibility ---
|
|
107
|
-
|
|
108
|
-
it("renders correctly with minimal TimelineEvent (only id, icon, title, time)", () => {
|
|
109
|
-
const event: TimelineEvent = {
|
|
110
|
-
id: "min-1",
|
|
111
|
-
icon: React.createElement("span", null, "📌"),
|
|
112
|
-
title: "Minimal event",
|
|
113
|
-
time: "5m ago",
|
|
114
|
-
}
|
|
115
|
-
const { container } = render(<TimelineActivity events={[event]} />)
|
|
116
|
-
// Should render without errors
|
|
117
|
-
expect(container.textContent).toContain("Minimal event")
|
|
118
|
-
expect(container.textContent).toContain("5m ago")
|
|
119
|
-
// Dot should have neutral classes
|
|
120
|
-
const dot = container.querySelector('[data-testid="timeline-dot"]')!
|
|
121
|
-
expect(dot.className).toContain("border-border/60")
|
|
122
|
-
// No byline
|
|
123
|
-
const byline = container.querySelector('[data-testid="actor-byline"]')
|
|
124
|
-
expect(byline).toBeNull()
|
|
125
|
-
})
|
|
126
|
-
|
|
127
|
-
// --- TONE_CLASSES export ---
|
|
128
|
-
|
|
129
|
-
it("exports TONE_CLASSES with all expected tones", () => {
|
|
130
|
-
const tones = ["red", "amber", "emerald", "violet", "blue", "slate", "salesforce", "gmail"] as const
|
|
131
|
-
for (const tone of tones) {
|
|
132
|
-
expect(TONE_CLASSES[tone]).toBeDefined()
|
|
133
|
-
expect(TONE_CLASSES[tone].dot).toBeTruthy()
|
|
134
|
-
expect(TONE_CLASSES[tone].icon).toBeTruthy()
|
|
135
|
-
}
|
|
136
|
-
})
|
|
137
|
-
})
|
|
@@ -1,322 +0,0 @@
|
|
|
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
|
-
timelineSystemEventsToggleLabel: "Score changes",
|
|
80
|
-
timelineSystemEventsStorageKey: "test-show-score-changes",
|
|
81
|
-
timelineSystemEventsHiddenHint: "Score changes are hidden.",
|
|
82
|
-
timelineSystemEventsVisibleHint: "Showing {count} score changes.",
|
|
83
|
-
...overrides,
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// Mock localStorage
|
|
88
|
-
let store: Record<string, string> = {}
|
|
89
|
-
|
|
90
|
-
const localStorageMock = {
|
|
91
|
-
getItem: vi.fn((key: string) => store[key] ?? null),
|
|
92
|
-
setItem: vi.fn((key: string, value: string) => {
|
|
93
|
-
store[key] = value
|
|
94
|
-
}),
|
|
95
|
-
removeItem: vi.fn((key: string) => {
|
|
96
|
-
delete store[key]
|
|
97
|
-
}),
|
|
98
|
-
clear: vi.fn(() => {
|
|
99
|
-
store = {}
|
|
100
|
-
}),
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
beforeEach(() => {
|
|
104
|
-
store = {}
|
|
105
|
-
localStorageMock.getItem.mockClear()
|
|
106
|
-
localStorageMock.setItem.mockClear()
|
|
107
|
-
localStorageMock.removeItem.mockClear()
|
|
108
|
-
localStorageMock.clear.mockClear()
|
|
109
|
-
// Reset getItem to default implementation
|
|
110
|
-
localStorageMock.getItem.mockImplementation((key: string) => store[key] ?? null)
|
|
111
|
-
Object.defineProperty(window, "localStorage", { value: localStorageMock, writable: true })
|
|
112
|
-
})
|
|
113
|
-
|
|
114
|
-
// ---------------------------------------------------------------------------
|
|
115
|
-
// Helper to expand the timeline
|
|
116
|
-
// ---------------------------------------------------------------------------
|
|
117
|
-
|
|
118
|
-
function expandTimeline(container: HTMLElement) {
|
|
119
|
-
const collapseBtn = container.querySelector(
|
|
120
|
-
'[data-testid="timeline-collapse-btn"]',
|
|
121
|
-
) as HTMLElement
|
|
122
|
-
if (collapseBtn) fireEvent.click(collapseBtn)
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// ---------------------------------------------------------------------------
|
|
126
|
-
// Tests
|
|
127
|
-
// ---------------------------------------------------------------------------
|
|
128
|
-
|
|
129
|
-
describe("DetailView timeline system-events toggle", () => {
|
|
130
|
-
it("hides events with isSystemNoise: true by default", () => {
|
|
131
|
-
const { container } = render(<DetailView {...baseProps()} />)
|
|
132
|
-
// Expand the timeline
|
|
133
|
-
expandTimeline(container)
|
|
134
|
-
// Should only show 2 normal events, not the 2 noise events
|
|
135
|
-
const eventCount = container.querySelector('[data-testid="event-count"]')
|
|
136
|
-
expect(eventCount?.textContent).toBe("2 events")
|
|
137
|
-
// Noise event titles should not appear
|
|
138
|
-
expect(container.textContent).not.toContain("Score updated +3")
|
|
139
|
-
expect(container.textContent).not.toContain("Score updated -1")
|
|
140
|
-
})
|
|
141
|
-
|
|
142
|
-
it("reveals system-noise events when toggle is clicked", () => {
|
|
143
|
-
const { container } = render(<DetailView {...baseProps()} />)
|
|
144
|
-
expandTimeline(container)
|
|
145
|
-
// Click the toggle
|
|
146
|
-
const toggle = container.querySelector(
|
|
147
|
-
'[data-testid="system-events-toggle"]',
|
|
148
|
-
) as HTMLElement
|
|
149
|
-
expect(toggle).not.toBeNull()
|
|
150
|
-
fireEvent.click(toggle)
|
|
151
|
-
// Now all 4 events should be visible
|
|
152
|
-
const eventCount = container.querySelector('[data-testid="event-count"]')
|
|
153
|
-
expect(eventCount?.textContent).toBe("4 events")
|
|
154
|
-
expect(container.textContent).toContain("Score updated +3")
|
|
155
|
-
})
|
|
156
|
-
|
|
157
|
-
it("clicking the toggle does NOT collapse/expand the timeline", () => {
|
|
158
|
-
const { container } = render(<DetailView {...baseProps()} />)
|
|
159
|
-
expandTimeline(container)
|
|
160
|
-
// Timeline should be expanded — normal events visible
|
|
161
|
-
expect(container.textContent).toContain("Email sent")
|
|
162
|
-
|
|
163
|
-
// Click the system-events toggle
|
|
164
|
-
const toggle = container.querySelector(
|
|
165
|
-
'[data-testid="system-events-toggle"]',
|
|
166
|
-
) as HTMLElement
|
|
167
|
-
fireEvent.click(toggle)
|
|
168
|
-
|
|
169
|
-
// Timeline should still be expanded
|
|
170
|
-
expect(container.textContent).toContain("Email sent")
|
|
171
|
-
})
|
|
172
|
-
|
|
173
|
-
it("clicking the collapse button does NOT toggle system-event visibility", () => {
|
|
174
|
-
const { container } = render(<DetailView {...baseProps()} />)
|
|
175
|
-
expandTimeline(container)
|
|
176
|
-
|
|
177
|
-
// Toggle system events on
|
|
178
|
-
const toggle = container.querySelector(
|
|
179
|
-
'[data-testid="system-events-toggle"]',
|
|
180
|
-
) as HTMLElement
|
|
181
|
-
fireEvent.click(toggle)
|
|
182
|
-
expect(container.textContent).toContain("Score updated +3")
|
|
183
|
-
|
|
184
|
-
// Collapse the timeline
|
|
185
|
-
expandTimeline(container)
|
|
186
|
-
// Re-expand
|
|
187
|
-
expandTimeline(container)
|
|
188
|
-
|
|
189
|
-
// System events should still be visible (toggle didn't change)
|
|
190
|
-
expect(container.textContent).toContain("Score updated +3")
|
|
191
|
-
})
|
|
192
|
-
|
|
193
|
-
it("header event count reflects visible events, not total events", () => {
|
|
194
|
-
const { container } = render(<DetailView {...baseProps()} />)
|
|
195
|
-
const eventCount = container.querySelector('[data-testid="event-count"]')
|
|
196
|
-
expect(eventCount?.textContent).toBe("2 events")
|
|
197
|
-
})
|
|
198
|
-
|
|
199
|
-
it("hidden count badge shows the correct number", () => {
|
|
200
|
-
const { container } = render(<DetailView {...baseProps()} />)
|
|
201
|
-
const badge = container.querySelector('[data-testid="hidden-count-badge"]')
|
|
202
|
-
expect(badge).not.toBeNull()
|
|
203
|
-
expect(badge?.textContent).toBe("2")
|
|
204
|
-
})
|
|
205
|
-
|
|
206
|
-
it("calls localStorage.setItem when toggle changes", () => {
|
|
207
|
-
const { container } = render(<DetailView {...baseProps()} />)
|
|
208
|
-
expandTimeline(container)
|
|
209
|
-
const toggle = container.querySelector(
|
|
210
|
-
'[data-testid="system-events-toggle"]',
|
|
211
|
-
) as HTMLElement
|
|
212
|
-
fireEvent.click(toggle)
|
|
213
|
-
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
|
214
|
-
"test-show-score-changes",
|
|
215
|
-
"true",
|
|
216
|
-
)
|
|
217
|
-
})
|
|
218
|
-
|
|
219
|
-
it("reads localStorage.getItem on mount and honors stored value", () => {
|
|
220
|
-
store["test-show-score-changes"] = "true"
|
|
221
|
-
const { container } = render(<DetailView {...baseProps()} />)
|
|
222
|
-
expandTimeline(container)
|
|
223
|
-
// With stored value "true", system events should be visible
|
|
224
|
-
const eventCount = container.querySelector('[data-testid="event-count"]')
|
|
225
|
-
expect(eventCount?.textContent).toBe("4 events")
|
|
226
|
-
expect(localStorageMock.getItem).toHaveBeenCalledWith(
|
|
227
|
-
"test-show-score-changes",
|
|
228
|
-
)
|
|
229
|
-
})
|
|
230
|
-
|
|
231
|
-
it("renders header and toggle when all events are system noise and toggle is off", () => {
|
|
232
|
-
const allNoiseProps = baseProps({
|
|
233
|
-
getTimelineEvents: () => noiseEvents,
|
|
234
|
-
})
|
|
235
|
-
const { container } = render(<DetailView {...allNoiseProps} />)
|
|
236
|
-
// Header should still be rendered
|
|
237
|
-
const header = container.querySelector('[data-testid="timeline-header"]')
|
|
238
|
-
expect(header).not.toBeNull()
|
|
239
|
-
// Toggle should be present
|
|
240
|
-
const toggle = container.querySelector(
|
|
241
|
-
'[data-testid="system-events-toggle"]',
|
|
242
|
-
)
|
|
243
|
-
expect(toggle).not.toBeNull()
|
|
244
|
-
// Event count should show 0 events
|
|
245
|
-
const eventCount = container.querySelector('[data-testid="event-count"]')
|
|
246
|
-
expect(eventCount?.textContent).toBe("0 events")
|
|
247
|
-
})
|
|
248
|
-
|
|
249
|
-
it("'Last activity' uses the first visible event's time, not a hidden system-noise event", () => {
|
|
250
|
-
// Arrange: first event is system noise (time "30m ago"),
|
|
251
|
-
// second event is normal (time "1h ago")
|
|
252
|
-
const orderedEvents: TimelineEvent[] = [
|
|
253
|
-
{
|
|
254
|
-
id: "noise-first",
|
|
255
|
-
icon: React.createElement("span", null, "📊"),
|
|
256
|
-
title: "Score change",
|
|
257
|
-
time: "30m ago",
|
|
258
|
-
isSystemNoise: true,
|
|
259
|
-
},
|
|
260
|
-
{
|
|
261
|
-
id: "normal-second",
|
|
262
|
-
icon: React.createElement("span", null, "📧"),
|
|
263
|
-
title: "Email sent",
|
|
264
|
-
time: "1h ago",
|
|
265
|
-
},
|
|
266
|
-
]
|
|
267
|
-
const props = baseProps({ getTimelineEvents: () => orderedEvents })
|
|
268
|
-
const { container } = render(<DetailView {...props} />)
|
|
269
|
-
const hint = container.querySelector('[data-testid="last-activity-hint"]')
|
|
270
|
-
expect(hint).not.toBeNull()
|
|
271
|
-
// Should show "1h ago" (the first visible event), not "30m ago" (the hidden noise event)
|
|
272
|
-
expect(hint?.textContent).toContain("1h ago")
|
|
273
|
-
expect(hint?.textContent).not.toContain("30m ago")
|
|
274
|
-
})
|
|
275
|
-
|
|
276
|
-
it("uses singular grammar for 1 event and plural for multiple", () => {
|
|
277
|
-
const singleEvent: TimelineEvent[] = [
|
|
278
|
-
{
|
|
279
|
-
id: "s1",
|
|
280
|
-
icon: React.createElement("span", null, "📧"),
|
|
281
|
-
title: "Email sent",
|
|
282
|
-
time: "1h ago",
|
|
283
|
-
},
|
|
284
|
-
]
|
|
285
|
-
const props = baseProps({ getTimelineEvents: () => singleEvent })
|
|
286
|
-
const { container } = render(<DetailView {...props} />)
|
|
287
|
-
const eventCount = container.querySelector('[data-testid="event-count"]')
|
|
288
|
-
expect(eventCount?.textContent).toBe("1 event")
|
|
289
|
-
})
|
|
290
|
-
|
|
291
|
-
it("does not render toggle when there are no system-noise events", () => {
|
|
292
|
-
const props = baseProps({
|
|
293
|
-
getTimelineEvents: () => normalEvents,
|
|
294
|
-
})
|
|
295
|
-
const { container } = render(<DetailView {...props} />)
|
|
296
|
-
const toggle = container.querySelector(
|
|
297
|
-
'[data-testid="system-events-toggle"]',
|
|
298
|
-
)
|
|
299
|
-
expect(toggle).toBeNull()
|
|
300
|
-
})
|
|
301
|
-
|
|
302
|
-
it("shows footer hint when timeline is expanded and system events are hidden", () => {
|
|
303
|
-
const { container } = render(<DetailView {...baseProps()} />)
|
|
304
|
-
expandTimeline(container)
|
|
305
|
-
const hint = container.querySelector('[data-testid="timeline-footer-hint"]')
|
|
306
|
-
expect(hint).not.toBeNull()
|
|
307
|
-
expect(hint?.textContent).toBe("Score changes are hidden.")
|
|
308
|
-
})
|
|
309
|
-
|
|
310
|
-
it("shows visible footer hint with count when system events are shown", () => {
|
|
311
|
-
const { container } = render(<DetailView {...baseProps()} />)
|
|
312
|
-
expandTimeline(container)
|
|
313
|
-
// Toggle on
|
|
314
|
-
const toggle = container.querySelector(
|
|
315
|
-
'[data-testid="system-events-toggle"]',
|
|
316
|
-
) as HTMLElement
|
|
317
|
-
fireEvent.click(toggle)
|
|
318
|
-
const hint = container.querySelector('[data-testid="timeline-footer-hint"]')
|
|
319
|
-
expect(hint).not.toBeNull()
|
|
320
|
-
expect(hint?.textContent).toBe("Showing 2 score changes.")
|
|
321
|
-
})
|
|
322
|
-
})
|