@handled-ai/design-system 0.18.1 → 0.18.3
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/index.d.ts +0 -1
- package/dist/charts/index.js +0 -1
- package/dist/charts/index.js.map +1 -1
- package/dist/charts/pipeline-overview.d.ts +1 -2
- package/dist/charts/pipeline-overview.js +1 -29
- package/dist/charts/pipeline-overview.js.map +1 -1
- package/dist/components/insights-filter-bar.d.ts +1 -2
- package/dist/components/insights-filter-bar.js +5 -13
- package/dist/components/insights-filter-bar.js.map +1 -1
- package/dist/components/metric-card.d.ts +1 -14
- package/dist/components/metric-card.js +0 -86
- package/dist/components/metric-card.js.map +1 -1
- package/dist/components/timeline-activity.d.ts +16 -1
- package/dist/components/timeline-activity.js +69 -1
- package/dist/components/timeline-activity.js.map +1 -1
- package/dist/index.d.ts +2 -8
- package/dist/index.js +0 -5
- package/dist/index.js.map +1 -1
- package/dist/prototype/prototype-inbox-view.d.ts +11 -1
- package/dist/prototype/prototype-inbox-view.js +101 -33
- package/dist/prototype/prototype-inbox-view.js.map +1 -1
- package/package.json +1 -1
- package/src/charts/index.ts +0 -1
- package/src/charts/pipeline-overview.tsx +1 -38
- package/src/components/__tests__/timeline-activity.test.tsx +137 -0
- package/src/components/insights-filter-bar.tsx +4 -13
- package/src/components/metric-card.tsx +0 -82
- package/src/components/timeline-activity.tsx +112 -1
- package/src/index.ts +0 -5
- package/src/prototype/__tests__/detail-view-attention.test.tsx +2 -2
- package/src/prototype/__tests__/detail-view-timeline-system-events.test.tsx +322 -0
- package/src/prototype/prototype-inbox-view.tsx +131 -30
- package/dist/charts/empty-chart-state.d.ts +0 -11
- package/dist/charts/empty-chart-state.js +0 -70
- package/dist/charts/empty-chart-state.js.map +0 -1
- package/dist/components/days-open-cell.d.ts +0 -16
- package/dist/components/days-open-cell.js +0 -73
- package/dist/components/days-open-cell.js.map +0 -1
- package/dist/components/detail-drawer.d.ts +0 -16
- package/dist/components/detail-drawer.js +0 -45
- package/dist/components/detail-drawer.js.map +0 -1
- package/dist/components/linked-entity-cell.d.ts +0 -14
- package/dist/components/linked-entity-cell.js +0 -96
- package/dist/components/linked-entity-cell.js.map +0 -1
- package/dist/components/pill.d.ts +0 -26
- package/dist/components/pill.js +0 -77
- package/dist/components/pill.js.map +0 -1
- package/dist/components/quick-segment.d.ts +0 -13
- package/dist/components/quick-segment.js +0 -96
- package/dist/components/quick-segment.js.map +0 -1
- package/src/charts/__tests__/insights-charts.test.tsx +0 -62
- package/src/charts/empty-chart-state.tsx +0 -44
- package/src/components/__tests__/insights-primitives.test.tsx +0 -117
- package/src/components/days-open-cell.tsx +0 -50
- package/src/components/detail-drawer.tsx +0 -60
- package/src/components/linked-entity-cell.tsx +0 -74
- package/src/components/pill.tsx +0 -67
- package/src/components/quick-segment.tsx +0 -68
|
@@ -24,88 +24,6 @@ export interface MetricCardProps {
|
|
|
24
24
|
showInfo?: boolean
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
export interface KpiStripItem {
|
|
28
|
-
id?: string
|
|
29
|
-
label: React.ReactNode
|
|
30
|
-
value: React.ReactNode
|
|
31
|
-
unit?: React.ReactNode
|
|
32
|
-
subtitle?: React.ReactNode
|
|
33
|
-
change?: MetricCardProps["change"]
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export interface KpiStripProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
37
|
-
items: KpiStripItem[]
|
|
38
|
-
columns?: 2 | 3 | 4
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export function KpiStrip({ items, columns = 4, className, ...props }: KpiStripProps) {
|
|
42
|
-
return (
|
|
43
|
-
<div
|
|
44
|
-
data-slot="kpi-strip"
|
|
45
|
-
className={cn(
|
|
46
|
-
"grid gap-3 rounded-xl border border-border bg-card p-3 shadow-sm",
|
|
47
|
-
columns === 2 && "sm:grid-cols-2",
|
|
48
|
-
columns === 3 && "sm:grid-cols-3",
|
|
49
|
-
columns === 4 && "sm:grid-cols-2 lg:grid-cols-4",
|
|
50
|
-
className
|
|
51
|
-
)}
|
|
52
|
-
{...props}
|
|
53
|
-
>
|
|
54
|
-
{items.map((item, index) => {
|
|
55
|
-
const isGoodDirection = item.change
|
|
56
|
-
? item.change.isGood !== undefined
|
|
57
|
-
? item.change.isGood
|
|
58
|
-
: item.change.direction === "up"
|
|
59
|
-
: false
|
|
60
|
-
const ChangeIcon = item.change?.direction === "down" ? ArrowDown : ArrowUp
|
|
61
|
-
|
|
62
|
-
return (
|
|
63
|
-
<div
|
|
64
|
-
key={item.id ?? index}
|
|
65
|
-
data-slot="kpi-strip-item"
|
|
66
|
-
className="min-w-0 rounded-lg bg-muted/40 px-3 py-2"
|
|
67
|
-
>
|
|
68
|
-
<div data-slot="kpi-strip-label" className="truncate text-xs font-medium text-muted-foreground">
|
|
69
|
-
{item.label}
|
|
70
|
-
</div>
|
|
71
|
-
<div className="mt-1 flex items-baseline gap-1">
|
|
72
|
-
<span data-slot="kpi-strip-value" className="truncate text-2xl font-bold tracking-tight text-foreground">
|
|
73
|
-
{item.value}
|
|
74
|
-
</span>
|
|
75
|
-
{item.unit ? (
|
|
76
|
-
<span data-slot="kpi-strip-unit" className="text-sm font-semibold text-muted-foreground">
|
|
77
|
-
{item.unit}
|
|
78
|
-
</span>
|
|
79
|
-
) : null}
|
|
80
|
-
</div>
|
|
81
|
-
{item.subtitle || item.change ? (
|
|
82
|
-
<div className="mt-1 flex items-center gap-2 text-xs">
|
|
83
|
-
{item.change ? (
|
|
84
|
-
<span
|
|
85
|
-
data-slot="kpi-strip-change"
|
|
86
|
-
className={cn(
|
|
87
|
-
"inline-flex items-center gap-0.5 font-semibold",
|
|
88
|
-
isGoodDirection ? "text-emerald-600" : "text-red-600"
|
|
89
|
-
)}
|
|
90
|
-
>
|
|
91
|
-
<ChangeIcon className="h-3 w-3 stroke-[3]" />
|
|
92
|
-
{item.change.value}
|
|
93
|
-
</span>
|
|
94
|
-
) : null}
|
|
95
|
-
{item.subtitle ? (
|
|
96
|
-
<span data-slot="kpi-strip-subtitle" className="truncate text-muted-foreground">
|
|
97
|
-
{item.subtitle}
|
|
98
|
-
</span>
|
|
99
|
-
) : null}
|
|
100
|
-
</div>
|
|
101
|
-
) : null}
|
|
102
|
-
</div>
|
|
103
|
-
)
|
|
104
|
-
})}
|
|
105
|
-
</div>
|
|
106
|
-
)
|
|
107
|
-
}
|
|
108
|
-
|
|
109
27
|
export function MetricCard({
|
|
110
28
|
title,
|
|
111
29
|
value,
|
|
@@ -4,6 +4,24 @@ import * as React from "react"
|
|
|
4
4
|
import { cn } from "../lib/utils"
|
|
5
5
|
import { ChevronDown, ChevronUp, ExternalLink } from "lucide-react"
|
|
6
6
|
|
|
7
|
+
export type TimelineEventTone =
|
|
8
|
+
| "red"
|
|
9
|
+
| "amber"
|
|
10
|
+
| "emerald"
|
|
11
|
+
| "violet"
|
|
12
|
+
| "blue"
|
|
13
|
+
| "slate"
|
|
14
|
+
| "salesforce"
|
|
15
|
+
| "gmail"
|
|
16
|
+
|
|
17
|
+
export interface TimelineEventActor {
|
|
18
|
+
kind: "user" | "integration" | "system"
|
|
19
|
+
name?: string
|
|
20
|
+
initials?: string
|
|
21
|
+
avatarUrl?: string
|
|
22
|
+
verb?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
7
25
|
export interface TimelineEvent {
|
|
8
26
|
id: string
|
|
9
27
|
icon: React.ReactNode
|
|
@@ -28,8 +46,57 @@ export interface TimelineEvent {
|
|
|
28
46
|
defaultExpanded?: boolean
|
|
29
47
|
isInteractive?: boolean
|
|
30
48
|
onSourceClick?: () => void
|
|
49
|
+
tone?: TimelineEventTone
|
|
50
|
+
actor?: TimelineEventActor
|
|
51
|
+
isSystemNoise?: boolean
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Tone class map — every class is a complete static string literal so
|
|
56
|
+
// Tailwind's JIT scanner can detect them. NO interpolation.
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
export const TONE_CLASSES: Record<
|
|
60
|
+
TimelineEventTone,
|
|
61
|
+
{ dot: string; icon: string }
|
|
62
|
+
> = {
|
|
63
|
+
red: {
|
|
64
|
+
dot: "bg-red-50 border-red-200 dark:bg-red-950/30 dark:border-red-900/40",
|
|
65
|
+
icon: "text-red-600 dark:text-red-300",
|
|
66
|
+
},
|
|
67
|
+
amber: {
|
|
68
|
+
dot: "bg-amber-50 border-amber-200 dark:bg-amber-950/30 dark:border-amber-900/40",
|
|
69
|
+
icon: "text-amber-600 dark:text-amber-300",
|
|
70
|
+
},
|
|
71
|
+
emerald: {
|
|
72
|
+
dot: "bg-emerald-50 border-emerald-200 dark:bg-emerald-950/30 dark:border-emerald-900/40",
|
|
73
|
+
icon: "text-emerald-600 dark:text-emerald-300",
|
|
74
|
+
},
|
|
75
|
+
violet: {
|
|
76
|
+
dot: "bg-violet-50 border-violet-200 dark:bg-violet-950/30 dark:border-violet-900/40",
|
|
77
|
+
icon: "text-violet-600 dark:text-violet-300",
|
|
78
|
+
},
|
|
79
|
+
blue: {
|
|
80
|
+
dot: "bg-blue-50 border-blue-200 dark:bg-blue-950/30 dark:border-blue-900/40",
|
|
81
|
+
icon: "text-blue-600 dark:text-blue-300",
|
|
82
|
+
},
|
|
83
|
+
slate: {
|
|
84
|
+
dot: "bg-slate-100 border-slate-200 dark:bg-slate-800/50 dark:border-slate-700",
|
|
85
|
+
icon: "text-slate-500 dark:text-slate-300",
|
|
86
|
+
},
|
|
87
|
+
salesforce: {
|
|
88
|
+
dot: "bg-white border-[#00A1E0]/25 dark:bg-background dark:border-[#00A1E0]/25",
|
|
89
|
+
icon: "text-[#00A1E0]",
|
|
90
|
+
},
|
|
91
|
+
gmail: {
|
|
92
|
+
dot: "bg-white border-red-200 dark:bg-background dark:border-red-900/40",
|
|
93
|
+
icon: "text-red-500 dark:text-red-300",
|
|
94
|
+
},
|
|
31
95
|
}
|
|
32
96
|
|
|
97
|
+
const NEUTRAL_DOT_CLASSES = "border-border/60 bg-background"
|
|
98
|
+
const NEUTRAL_ICON_CLASSES = "text-muted-foreground"
|
|
99
|
+
|
|
33
100
|
export interface TimelineActivityProps {
|
|
34
101
|
events: TimelineEvent[]
|
|
35
102
|
className?: string
|
|
@@ -49,12 +116,54 @@ export function TimelineActivity({ events, className }: TimelineActivityProps) {
|
|
|
49
116
|
)
|
|
50
117
|
}
|
|
51
118
|
|
|
119
|
+
function ActorByline({ actor, time }: { actor: TimelineEventActor; time: string }) {
|
|
120
|
+
if (actor.kind === "system") return null
|
|
121
|
+
|
|
122
|
+
if (actor.kind === "integration") {
|
|
123
|
+
return (
|
|
124
|
+
<div className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground" data-testid="actor-byline">
|
|
125
|
+
<span>Integration</span>
|
|
126
|
+
<span className="text-muted-foreground/40">·</span>
|
|
127
|
+
<span>{time}</span>
|
|
128
|
+
</div>
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// actor.kind === "user"
|
|
133
|
+
const verb = actor.verb ?? "performed this action"
|
|
134
|
+
const displayInitials = actor.initials ?? (actor.name ? actor.name.charAt(0).toUpperCase() : "?")
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<div className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground" data-testid="actor-byline">
|
|
138
|
+
{actor.avatarUrl ? (
|
|
139
|
+
<img
|
|
140
|
+
src={actor.avatarUrl}
|
|
141
|
+
alt={actor.name ?? "User"}
|
|
142
|
+
className="h-4 w-4 rounded-full object-cover"
|
|
143
|
+
/>
|
|
144
|
+
) : (
|
|
145
|
+
<span className="flex h-4 w-4 items-center justify-center rounded-full bg-muted-foreground/10 text-[8px] font-semibold text-muted-foreground">
|
|
146
|
+
{displayInitials}
|
|
147
|
+
</span>
|
|
148
|
+
)}
|
|
149
|
+
<span className="text-foreground font-medium">{actor.name}</span>
|
|
150
|
+
<span>{verb}</span>
|
|
151
|
+
<span className="text-muted-foreground/40">·</span>
|
|
152
|
+
<span>{time}</span>
|
|
153
|
+
</div>
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
52
157
|
function TimelineItem({ event, isLast }: { event: TimelineEvent; isLast: boolean }) {
|
|
53
158
|
const [expanded, setExpanded] = React.useState(event.defaultExpanded ?? false)
|
|
54
159
|
const [showAllRecipients, setShowAllRecipients] = React.useState(false)
|
|
55
160
|
const hasContent = !!event.content
|
|
56
161
|
const hasEmail = !!event.email
|
|
57
162
|
|
|
163
|
+
const toneStyle = event.tone ? TONE_CLASSES[event.tone] : null
|
|
164
|
+
const dotClasses = toneStyle ? toneStyle.dot : NEUTRAL_DOT_CLASSES
|
|
165
|
+
const iconClasses = toneStyle ? toneStyle.icon : NEUTRAL_ICON_CLASSES
|
|
166
|
+
|
|
58
167
|
return (
|
|
59
168
|
<div className="group relative flex gap-3.5">
|
|
60
169
|
{!isLast && (
|
|
@@ -62,7 +171,7 @@ function TimelineItem({ event, isLast }: { event: TimelineEvent; isLast: boolean
|
|
|
62
171
|
)}
|
|
63
172
|
|
|
64
173
|
<div className="relative z-10 mt-1 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-background">
|
|
65
|
-
<div className="flex h-4.5 w-4.5 items-center justify-center rounded-full border
|
|
174
|
+
<div className={cn("flex h-4.5 w-4.5 items-center justify-center rounded-full border ring-4 ring-background", dotClasses, iconClasses)} data-testid="timeline-dot">
|
|
66
175
|
{event.icon}
|
|
67
176
|
</div>
|
|
68
177
|
</div>
|
|
@@ -77,6 +186,8 @@ function TimelineItem({ event, isLast }: { event: TimelineEvent; isLast: boolean
|
|
|
77
186
|
</span>
|
|
78
187
|
</div>
|
|
79
188
|
|
|
189
|
+
{event.actor && <ActorByline actor={event.actor} time={event.time} />}
|
|
190
|
+
|
|
80
191
|
{(hasContent || hasEmail) && (
|
|
81
192
|
<div className="mt-2">
|
|
82
193
|
{event.isInteractive ? (
|
package/src/index.ts
CHANGED
|
@@ -33,7 +33,6 @@ export * from "./components/data-table-filter"
|
|
|
33
33
|
export * from "./components/data-table-quick-views"
|
|
34
34
|
export * from "./components/data-table-toolbar"
|
|
35
35
|
export * from "./components/detail-view"
|
|
36
|
-
export * from "./components/detail-drawer"
|
|
37
36
|
export * from "./components/dialog"
|
|
38
37
|
export * from "./components/dropdown-menu"
|
|
39
38
|
export * from "./components/empty-state"
|
|
@@ -48,8 +47,6 @@ export * from "./components/inbox-toolbar"
|
|
|
48
47
|
export * from "./components/inline-banner"
|
|
49
48
|
export * from "./components/input"
|
|
50
49
|
export * from "./components/insights-filter-bar"
|
|
51
|
-
export * from "./components/days-open-cell"
|
|
52
|
-
export * from "./components/linked-entity-cell"
|
|
53
50
|
export * from "./components/item-list"
|
|
54
51
|
export * from "./components/item-list-display"
|
|
55
52
|
export * from "./components/item-list-filter"
|
|
@@ -59,11 +56,9 @@ export * from "./components/label"
|
|
|
59
56
|
export * from "./components/message"
|
|
60
57
|
export * from "./components/metric-card"
|
|
61
58
|
export * from "./components/performance-metrics-table"
|
|
62
|
-
export * from "./components/pill"
|
|
63
59
|
export * from "./components/preview-list"
|
|
64
60
|
export * from "./components/progress"
|
|
65
61
|
export * from "./components/quick-action-chat-area"
|
|
66
|
-
export * from "./components/quick-segment"
|
|
67
62
|
export {
|
|
68
63
|
QuickActionModal,
|
|
69
64
|
type QuickActionPriority,
|
|
@@ -89,8 +89,8 @@ describe("DetailView attentionCount", () => {
|
|
|
89
89
|
expect(pill).not.toBeNull();
|
|
90
90
|
expect(pill!.textContent).toContain("5");
|
|
91
91
|
|
|
92
|
-
// Click the timeline
|
|
93
|
-
const timelineButton = container.querySelector("
|
|
92
|
+
// Click the timeline collapse button to expand
|
|
93
|
+
const timelineButton = container.querySelector('[data-testid="timeline-collapse-btn"]') as HTMLElement;
|
|
94
94
|
expect(timelineButton).not.toBeNull();
|
|
95
95
|
fireEvent.click(timelineButton);
|
|
96
96
|
|
|
@@ -0,0 +1,322 @@
|
|
|
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
|
+
})
|