@handled-ai/design-system 0.18.58 → 0.19.0-rc.1
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/components/badge.d.ts +1 -1
- package/dist/components/button.d.ts +1 -1
- package/dist/components/case-panel-activity-timeline.d.ts +2 -0
- package/dist/components/case-panel-activity-timeline.js +22 -1
- package/dist/components/case-panel-activity-timeline.js.map +1 -1
- package/dist/components/comment-composer.d.ts +29 -0
- package/dist/components/comment-composer.js +102 -0
- package/dist/components/comment-composer.js.map +1 -0
- package/dist/components/conversation-panel.d.ts +95 -0
- package/dist/components/conversation-panel.js +636 -0
- package/dist/components/conversation-panel.js.map +1 -0
- package/dist/components/detail-view.js +1 -1
- package/dist/components/detail-view.js.map +1 -1
- package/dist/components/owner-chips.d.ts +59 -0
- package/dist/components/owner-chips.js +256 -0
- package/dist/components/owner-chips.js.map +1 -0
- package/dist/components/pill.d.ts +1 -1
- package/dist/components/score-why-chips.d.ts +1 -1
- package/dist/components/signal-priority-popover.d.ts +1 -1
- package/dist/components/signal-priority-popover.js +16 -7
- package/dist/components/signal-priority-popover.js.map +1 -1
- package/dist/components/tabs.d.ts +1 -1
- package/dist/components/timeline-activity.d.ts +7 -0
- package/dist/components/timeline-activity.js +22 -1
- package/dist/components/timeline-activity.js.map +1 -1
- package/dist/components/virtualized-data-table.js +4 -4
- package/dist/components/virtualized-data-table.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/internal/safe-html.d.ts +11 -0
- package/dist/internal/safe-html.js +222 -0
- package/dist/internal/safe-html.js.map +1 -0
- package/dist/prototype/index.d.ts +1 -1
- package/dist/prototype/prototype-accounts-view.d.ts +1 -1
- package/dist/prototype/prototype-admin-view.d.ts +1 -1
- package/dist/prototype/prototype-config.d.ts +1 -1
- package/dist/prototype/prototype-inbox-view.d.ts +1 -1
- package/dist/prototype/prototype-inbox-view.js +2 -0
- package/dist/prototype/prototype-inbox-view.js.map +1 -1
- package/dist/prototype/prototype-insights-view.d.ts +1 -1
- package/dist/prototype/prototype-shell.d.ts +1 -1
- package/dist/{signal-priority-popover-QJngMAj7.d.ts → signal-priority-popover-CZitE9xq.d.ts} +11 -2
- package/package.json +1 -1
- package/src/components/__tests__/comment-composer.test.tsx +57 -0
- package/src/components/__tests__/conversation-panel.test.tsx +157 -0
- package/src/components/__tests__/owner-chips.test.tsx +100 -0
- package/src/components/__tests__/signal-priority-popover.test.tsx +41 -4
- package/src/components/__tests__/timeline-activity.test.tsx +55 -0
- package/src/components/__tests__/virtualized-data-table-resize.test.tsx +18 -0
- package/src/components/case-panel-activity-timeline.tsx +20 -0
- package/src/components/comment-composer.tsx +119 -0
- package/src/components/conversation-panel.tsx +790 -0
- package/src/components/detail-view.tsx +3 -1
- package/src/components/owner-chips.tsx +335 -0
- package/src/components/signal-priority-popover.tsx +19 -6
- package/src/components/timeline-activity.tsx +37 -3
- package/src/components/virtualized-data-table.tsx +4 -4
- package/src/index.ts +4 -1
- package/src/internal/__tests__/safe-html.test.ts +53 -0
- package/src/internal/safe-html.ts +284 -0
- package/src/prototype/__tests__/detail-view-score-why.test.tsx +34 -0
- package/src/prototype/prototype-config.ts +5 -1
- package/src/prototype/prototype-inbox-view.tsx +2 -0
|
@@ -168,7 +168,9 @@ export function DetailViewThread({
|
|
|
168
168
|
<span className="text-sm text-muted-foreground">{actionCount} actions</span>
|
|
169
169
|
)}
|
|
170
170
|
</div>
|
|
171
|
-
|
|
171
|
+
{/* Minimal case-panel shadow (WIT-854): kept subtle but present, ~45%
|
|
172
|
+
lighter than shadow-sm. */}
|
|
173
|
+
<div className="rounded-xl border border-border bg-card overflow-hidden shadow-[0_1px_2px_rgba(0,0,0,0.03)]">
|
|
172
174
|
{children}
|
|
173
175
|
</div>
|
|
174
176
|
</div>
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* owner-chips.tsx — disambiguates the two ownership concepts operators kept
|
|
5
|
+
* confusing on the case panel:
|
|
6
|
+
*
|
|
7
|
+
* 1. SIGNAL OWNER — Handled's OWN assignment. Who owns working this
|
|
8
|
+
* signal/action inside Handled. Editable here (assign / reassign /
|
|
9
|
+
* unassign). Replaces the ambiguous bare "Unassigned" chip.
|
|
10
|
+
*
|
|
11
|
+
* 2. ACCOUNT OWNER(S) — read-through from Salesforce. An account can carry
|
|
12
|
+
* more than one (AE + RM). Informational + links out to Salesforce; never
|
|
13
|
+
* assigned from inside Handled. Leads with the Salesforce mark.
|
|
14
|
+
*
|
|
15
|
+
* Single account owner -> a static, non-interactive chip (no dropdown).
|
|
16
|
+
* Multiple account owners -> stacked avatars + a ×N badge and a menu that
|
|
17
|
+
* lists each owner with a link out. This keeps a single owner cheap and quiet
|
|
18
|
+
* while still surfacing the full set when there is more than one.
|
|
19
|
+
*
|
|
20
|
+
* Presentational only: data + handlers come from the consumer (the app).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import * as React from "react"
|
|
24
|
+
import {
|
|
25
|
+
ChevronDown,
|
|
26
|
+
ChevronUp,
|
|
27
|
+
Check,
|
|
28
|
+
UserPlus,
|
|
29
|
+
UserMinus,
|
|
30
|
+
Info,
|
|
31
|
+
ArrowUpRight,
|
|
32
|
+
} from "lucide-react"
|
|
33
|
+
|
|
34
|
+
import { cn } from "../lib/utils"
|
|
35
|
+
import { getInitials } from "../lib/user-display"
|
|
36
|
+
import { BRAND_ICONS } from "../lib/icons"
|
|
37
|
+
import { Avatar, AvatarFallback, AvatarImage } from "./avatar"
|
|
38
|
+
import {
|
|
39
|
+
DropdownMenu,
|
|
40
|
+
DropdownMenuTrigger,
|
|
41
|
+
DropdownMenuContent,
|
|
42
|
+
DropdownMenuItem,
|
|
43
|
+
DropdownMenuLabel,
|
|
44
|
+
DropdownMenuSeparator,
|
|
45
|
+
} from "./dropdown-menu"
|
|
46
|
+
|
|
47
|
+
export interface OwnerPerson {
|
|
48
|
+
/** Stable id (profile id for signal owner; SF user id for account owner). */
|
|
49
|
+
id?: string
|
|
50
|
+
name: string
|
|
51
|
+
email?: string
|
|
52
|
+
/** e.g. "Relationship Manager", "Account Executive". */
|
|
53
|
+
role?: string
|
|
54
|
+
/** Avatar image; falls back to initials when absent. */
|
|
55
|
+
avatarUrl?: string | null
|
|
56
|
+
/** External link (Salesforce) for an account owner. */
|
|
57
|
+
href?: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/* ── shared bits ─────────────────────────────────────────────────────────── */
|
|
61
|
+
|
|
62
|
+
function SalesforceMark({ size = 14 }: { size?: number }) {
|
|
63
|
+
return (
|
|
64
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
65
|
+
<img
|
|
66
|
+
src={BRAND_ICONS.salesforce}
|
|
67
|
+
alt="Salesforce"
|
|
68
|
+
width={size}
|
|
69
|
+
height={size}
|
|
70
|
+
style={{ width: size, height: size, objectFit: "contain", display: "block" }}
|
|
71
|
+
/>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function OwnerAvatar({ person, size = "sm" }: { person: OwnerPerson; size?: "sm" | "default" }) {
|
|
76
|
+
return (
|
|
77
|
+
<Avatar size={size} className="ring-background ring-2">
|
|
78
|
+
{person.avatarUrl ? <AvatarImage src={person.avatarUrl} alt={person.name} /> : null}
|
|
79
|
+
<AvatarFallback className="bg-muted text-muted-foreground text-[10px] font-medium uppercase">
|
|
80
|
+
{getInitials({ name: person.name, email: person.email })}
|
|
81
|
+
</AvatarFallback>
|
|
82
|
+
</Avatar>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const chipBase =
|
|
87
|
+
"inline-flex h-8 items-center gap-1.5 rounded-lg border border-border bg-background px-2.5 text-[13px] " +
|
|
88
|
+
"shadow-[0_1px_1px_rgba(0,0,0,0.03)]"
|
|
89
|
+
|
|
90
|
+
/* ── Signal owner (Handled assignment, editable) ─────────────────────────── */
|
|
91
|
+
|
|
92
|
+
export interface SignalOwnerChipProps {
|
|
93
|
+
/** Current Handled assignee, or null when unassigned. */
|
|
94
|
+
owner: OwnerPerson | null
|
|
95
|
+
/** Operators the case can be assigned to (preloaded — no fetch on open). */
|
|
96
|
+
assignableOwners?: OwnerPerson[]
|
|
97
|
+
onAssign?: (owner: OwnerPerson) => void
|
|
98
|
+
onUnassign?: () => void
|
|
99
|
+
/** Read-only: render a static chip without the assignment menu. */
|
|
100
|
+
disabled?: boolean
|
|
101
|
+
className?: string
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function SignalOwnerChip({
|
|
105
|
+
owner,
|
|
106
|
+
assignableOwners = [],
|
|
107
|
+
onAssign,
|
|
108
|
+
onUnassign,
|
|
109
|
+
disabled,
|
|
110
|
+
className,
|
|
111
|
+
}: SignalOwnerChipProps) {
|
|
112
|
+
const [open, setOpen] = React.useState(false)
|
|
113
|
+
|
|
114
|
+
const value = (
|
|
115
|
+
<>
|
|
116
|
+
{owner ? (
|
|
117
|
+
<OwnerAvatar person={owner} />
|
|
118
|
+
) : (
|
|
119
|
+
<span className="text-muted-foreground inline-flex size-[18px] items-center justify-center">
|
|
120
|
+
<UserPlus size={13} />
|
|
121
|
+
</span>
|
|
122
|
+
)}
|
|
123
|
+
<span className="text-muted-foreground text-[11px] font-medium tracking-wide uppercase">
|
|
124
|
+
Signal owner
|
|
125
|
+
</span>
|
|
126
|
+
<span className="bg-border/70 mx-0.5 h-3.5 w-px" aria-hidden />
|
|
127
|
+
<span className={cn("font-medium", owner ? "text-foreground" : "text-muted-foreground")}>
|
|
128
|
+
{owner ? owner.name.split(" ")[0] : "Unassigned"}
|
|
129
|
+
</span>
|
|
130
|
+
</>
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
// Read-only or nothing to assign to: static chip.
|
|
134
|
+
if (disabled || (!onAssign && !onUnassign)) {
|
|
135
|
+
return (
|
|
136
|
+
<span
|
|
137
|
+
data-slot="signal-owner-chip"
|
|
138
|
+
data-empty={owner ? undefined : "true"}
|
|
139
|
+
className={cn(chipBase, className)}
|
|
140
|
+
title="Who owns this signal inside Handled"
|
|
141
|
+
>
|
|
142
|
+
{value}
|
|
143
|
+
</span>
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<DropdownMenu open={open} onOpenChange={setOpen}>
|
|
149
|
+
<DropdownMenuTrigger asChild>
|
|
150
|
+
<button
|
|
151
|
+
type="button"
|
|
152
|
+
data-slot="signal-owner-chip"
|
|
153
|
+
data-empty={owner ? undefined : "true"}
|
|
154
|
+
className={cn(chipBase, "hover:bg-muted cursor-pointer transition-colors", className)}
|
|
155
|
+
title="Who owns this signal inside Handled"
|
|
156
|
+
>
|
|
157
|
+
{value}
|
|
158
|
+
<span className="text-muted-foreground ml-0.5">
|
|
159
|
+
{open ? <ChevronUp size={13} /> : <ChevronDown size={13} />}
|
|
160
|
+
</span>
|
|
161
|
+
</button>
|
|
162
|
+
</DropdownMenuTrigger>
|
|
163
|
+
<DropdownMenuContent align="start" className="w-64">
|
|
164
|
+
<DropdownMenuLabel className="flex flex-col gap-0.5">
|
|
165
|
+
<span className="text-[13px] font-semibold">Assign signal owner</span>
|
|
166
|
+
<span className="text-muted-foreground text-[11px] font-normal">
|
|
167
|
+
Who works this inside Handled, separate from the Salesforce account owner.
|
|
168
|
+
</span>
|
|
169
|
+
</DropdownMenuLabel>
|
|
170
|
+
<DropdownMenuSeparator />
|
|
171
|
+
{assignableOwners.map((o) => {
|
|
172
|
+
const active = !!owner && (owner.id ? owner.id === o.id : owner.name === o.name)
|
|
173
|
+
return (
|
|
174
|
+
<DropdownMenuItem
|
|
175
|
+
key={o.id ?? o.name}
|
|
176
|
+
onSelect={() => onAssign?.(o)}
|
|
177
|
+
className="gap-2"
|
|
178
|
+
>
|
|
179
|
+
<OwnerAvatar person={o} size="default" />
|
|
180
|
+
<span className="flex min-w-0 flex-col">
|
|
181
|
+
<span className="truncate text-[13px] font-medium">{o.name}</span>
|
|
182
|
+
{o.role ? (
|
|
183
|
+
<span className="text-muted-foreground truncate text-[11px]">{o.role}</span>
|
|
184
|
+
) : null}
|
|
185
|
+
</span>
|
|
186
|
+
{active ? <Check size={14} className="text-foreground ml-auto" /> : null}
|
|
187
|
+
</DropdownMenuItem>
|
|
188
|
+
)
|
|
189
|
+
})}
|
|
190
|
+
{owner && onUnassign ? (
|
|
191
|
+
<>
|
|
192
|
+
<DropdownMenuSeparator />
|
|
193
|
+
<DropdownMenuItem onSelect={() => onUnassign()} className="text-muted-foreground gap-2">
|
|
194
|
+
<UserMinus size={13} /> Unassign
|
|
195
|
+
</DropdownMenuItem>
|
|
196
|
+
</>
|
|
197
|
+
) : null}
|
|
198
|
+
</DropdownMenuContent>
|
|
199
|
+
</DropdownMenu>
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/* ── Account owner(s) (Salesforce, read-through) ─────────────────────────── */
|
|
204
|
+
|
|
205
|
+
export interface AccountOwnerChipProps {
|
|
206
|
+
/** Salesforce account owners (RM, AE, …). Empty -> renders nothing. */
|
|
207
|
+
owners: OwnerPerson[]
|
|
208
|
+
className?: string
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function AccountOwnerChip({ owners, className }: AccountOwnerChipProps) {
|
|
212
|
+
const [open, setOpen] = React.useState(false)
|
|
213
|
+
if (!owners.length) return null
|
|
214
|
+
const multi = owners.length > 1
|
|
215
|
+
|
|
216
|
+
// Single owner: a quiet, static chip — no dropdown (per product intent).
|
|
217
|
+
if (!multi) {
|
|
218
|
+
const only = owners[0]
|
|
219
|
+
const body = (
|
|
220
|
+
<>
|
|
221
|
+
<span className="inline-flex shrink-0 items-center">
|
|
222
|
+
<SalesforceMark />
|
|
223
|
+
</span>
|
|
224
|
+
<OwnerAvatar person={only} />
|
|
225
|
+
<span className="text-foreground font-medium">{only.name.split(" ")[0]}</span>
|
|
226
|
+
</>
|
|
227
|
+
)
|
|
228
|
+
return only.href ? (
|
|
229
|
+
<a
|
|
230
|
+
href={only.href}
|
|
231
|
+
target="_blank"
|
|
232
|
+
rel="noopener noreferrer"
|
|
233
|
+
data-slot="account-owner-chip"
|
|
234
|
+
className={cn(chipBase, "hover:bg-muted transition-colors", className)}
|
|
235
|
+
title={`Account owner in Salesforce — ${only.name}`}
|
|
236
|
+
>
|
|
237
|
+
{body}
|
|
238
|
+
<ArrowUpRight size={12} className="text-muted-foreground ml-0.5" />
|
|
239
|
+
</a>
|
|
240
|
+
) : (
|
|
241
|
+
<span
|
|
242
|
+
data-slot="account-owner-chip"
|
|
243
|
+
className={cn(chipBase, className)}
|
|
244
|
+
title={`Account owner in Salesforce — ${only.name}`}
|
|
245
|
+
>
|
|
246
|
+
{body}
|
|
247
|
+
</span>
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Multiple owners: stacked avatars + ×N + a menu listing each.
|
|
252
|
+
return (
|
|
253
|
+
<DropdownMenu open={open} onOpenChange={setOpen}>
|
|
254
|
+
<DropdownMenuTrigger asChild>
|
|
255
|
+
<button
|
|
256
|
+
type="button"
|
|
257
|
+
data-slot="account-owner-chip"
|
|
258
|
+
data-multi="true"
|
|
259
|
+
className={cn(chipBase, "hover:bg-muted cursor-pointer transition-colors", className)}
|
|
260
|
+
title="Account owners in Salesforce"
|
|
261
|
+
>
|
|
262
|
+
<span className="inline-flex shrink-0 items-center">
|
|
263
|
+
<SalesforceMark />
|
|
264
|
+
</span>
|
|
265
|
+
<span className="flex -space-x-2">
|
|
266
|
+
{owners.map((o) => (
|
|
267
|
+
<OwnerAvatar key={o.id ?? o.name} person={o} />
|
|
268
|
+
))}
|
|
269
|
+
</span>
|
|
270
|
+
<span className="text-foreground font-medium">Account owners</span>
|
|
271
|
+
<span className="bg-muted text-muted-foreground rounded px-1 text-[11px] font-semibold tabular-nums">
|
|
272
|
+
×{owners.length}
|
|
273
|
+
</span>
|
|
274
|
+
<span className="text-muted-foreground ml-0.5">
|
|
275
|
+
{open ? <ChevronUp size={13} /> : <ChevronDown size={13} />}
|
|
276
|
+
</span>
|
|
277
|
+
</button>
|
|
278
|
+
</DropdownMenuTrigger>
|
|
279
|
+
<DropdownMenuContent align="start" className="w-72">
|
|
280
|
+
<DropdownMenuLabel className="flex items-center gap-1.5 text-[13px]">
|
|
281
|
+
<SalesforceMark />
|
|
282
|
+
{owners.length} account owners
|
|
283
|
+
</DropdownMenuLabel>
|
|
284
|
+
<DropdownMenuSeparator />
|
|
285
|
+
{owners.map((o) => {
|
|
286
|
+
const row = (
|
|
287
|
+
<>
|
|
288
|
+
<OwnerAvatar person={o} size="default" />
|
|
289
|
+
<span className="flex min-w-0 flex-col">
|
|
290
|
+
<span className="truncate text-[13px] font-medium">{o.name}</span>
|
|
291
|
+
{o.role ? (
|
|
292
|
+
<span className="text-muted-foreground truncate text-[11px]">{o.role}</span>
|
|
293
|
+
) : null}
|
|
294
|
+
</span>
|
|
295
|
+
{o.href ? <ArrowUpRight size={13} className="text-muted-foreground ml-auto" /> : null}
|
|
296
|
+
</>
|
|
297
|
+
)
|
|
298
|
+
return (
|
|
299
|
+
<DropdownMenuItem key={o.id ?? o.name} asChild={!!o.href} className="gap-2">
|
|
300
|
+
{o.href ? (
|
|
301
|
+
<a href={o.href} target="_blank" rel="noopener noreferrer" title={`Open ${o.name} in Salesforce`}>
|
|
302
|
+
{row}
|
|
303
|
+
</a>
|
|
304
|
+
) : (
|
|
305
|
+
<span>{row}</span>
|
|
306
|
+
)}
|
|
307
|
+
</DropdownMenuItem>
|
|
308
|
+
)
|
|
309
|
+
})}
|
|
310
|
+
<DropdownMenuSeparator />
|
|
311
|
+
<div className="text-muted-foreground flex items-center gap-1.5 px-2 py-1.5 text-[11px]">
|
|
312
|
+
<Info size={12} /> Synced from Salesforce. Manage owners there.
|
|
313
|
+
</div>
|
|
314
|
+
</DropdownMenuContent>
|
|
315
|
+
</DropdownMenu>
|
|
316
|
+
)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/* ── Convenience composite ──────────────────────────────────────────────── */
|
|
320
|
+
|
|
321
|
+
export interface OwnerChipsProps extends SignalOwnerChipProps {
|
|
322
|
+
/** Salesforce account owners (read-through). */
|
|
323
|
+
accountOwners?: OwnerPerson[]
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function OwnerChips({ accountOwners = [], className, ...signal }: OwnerChipsProps) {
|
|
327
|
+
return (
|
|
328
|
+
<>
|
|
329
|
+
<SignalOwnerChip {...signal} className={className} />
|
|
330
|
+
<AccountOwnerChip owners={accountOwners} className={className} />
|
|
331
|
+
</>
|
|
332
|
+
)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export { SignalOwnerChip, AccountOwnerChip, OwnerChips }
|
|
@@ -50,8 +50,14 @@ export interface PriorityFactor {
|
|
|
50
50
|
rationale: string
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
export type SignalPriorityScoreDisplay = "label" | "number"
|
|
54
|
+
|
|
53
55
|
export interface SignalPriorityPopoverProps {
|
|
54
56
|
score: number
|
|
57
|
+
/** Controls whether the overall score number is shown in the popover header. @default "number" */
|
|
58
|
+
scoreDisplay?: SignalPriorityScoreDisplay
|
|
59
|
+
/** Short formula/context label shown beside the contributing factors heading. @default "Priority factors" */
|
|
60
|
+
formulaLabel?: string
|
|
55
61
|
urgencyLabel?: SignalScoreUrgencyLabel
|
|
56
62
|
/** Synthesis sentence displayed in the popover head. */
|
|
57
63
|
urgencyExplanation?: string
|
|
@@ -282,6 +288,8 @@ export function SignalPriorityPopover({
|
|
|
282
288
|
initialFactorFeedback,
|
|
283
289
|
onFactorFeedback,
|
|
284
290
|
initialPriorityFeedback,
|
|
291
|
+
scoreDisplay = "number",
|
|
292
|
+
formulaLabel = "Priority factors",
|
|
285
293
|
}: SignalPriorityPopoverProps) {
|
|
286
294
|
const urgencyLabel = providedLabel ?? getUrgencyLevel(score)
|
|
287
295
|
const scoreRange = getUrgencyRange(urgencyLabel)
|
|
@@ -330,15 +338,20 @@ export function SignalPriorityPopover({
|
|
|
330
338
|
data-testid="priority-popover-content"
|
|
331
339
|
>
|
|
332
340
|
{/* Head section */}
|
|
333
|
-
<div className="p-4 pb-3">
|
|
341
|
+
<div className="p-4 pb-3" data-testid="priority-popover-header">
|
|
334
342
|
<div className="flex items-start justify-between gap-3">
|
|
335
343
|
<p className="text-sm font-semibold text-foreground">
|
|
336
344
|
Why this is {urgencyLabel.toLowerCase()} priority
|
|
337
345
|
</p>
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
346
|
+
{scoreDisplay === "number" && (
|
|
347
|
+
<span
|
|
348
|
+
className="text-2xl font-bold tabular-nums text-foreground"
|
|
349
|
+
data-testid="priority-overall-score"
|
|
350
|
+
>
|
|
351
|
+
{score}
|
|
352
|
+
<span className="text-sm font-normal text-muted-foreground">/100</span>
|
|
353
|
+
</span>
|
|
354
|
+
)}
|
|
342
355
|
</div>
|
|
343
356
|
|
|
344
357
|
{/* Band indicator */}
|
|
@@ -369,7 +382,7 @@ export function SignalPriorityPopover({
|
|
|
369
382
|
</span>
|
|
370
383
|
<span className="flex items-center gap-1 text-[10px] text-muted-foreground">
|
|
371
384
|
<Info className="h-3 w-3" />
|
|
372
|
-
|
|
385
|
+
{formulaLabel}
|
|
373
386
|
</span>
|
|
374
387
|
</div>
|
|
375
388
|
|
|
@@ -2,8 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
import * as React from "react"
|
|
4
4
|
import { cn } from "../lib/utils"
|
|
5
|
+
import { sanitizeHtml } from "../internal/safe-html"
|
|
5
6
|
import { ChevronDown, ChevronUp, ExternalLink } from "lucide-react"
|
|
6
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Gmail-like reading-pane typography for rendered, sanitized email HTML.
|
|
10
|
+
* Self-contained Tailwind child-variant utilities — no global CSS. Links use
|
|
11
|
+
* Gmail blue; quoted history (`blockquote.gmail_quote`) is de-emphasized with a
|
|
12
|
+
* left rule and muted, slightly smaller text.
|
|
13
|
+
*/
|
|
14
|
+
const EMAIL_HTML_CLASS = cn(
|
|
15
|
+
"text-sm leading-[1.62] text-foreground/90 break-words",
|
|
16
|
+
"[&_p]:my-2 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0",
|
|
17
|
+
"[&_a]:text-[#1a73e8] [&_a]:underline-offset-2 hover:[&_a]:underline",
|
|
18
|
+
"[&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5",
|
|
19
|
+
"[&_img]:max-w-full [&_img]:h-auto",
|
|
20
|
+
"[&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground [&_blockquote]:text-[13px]",
|
|
21
|
+
"[&_.gmail_quote]:border-l-2 [&_.gmail_quote]:border-border [&_.gmail_quote]:pl-3 [&_.gmail_quote]:text-muted-foreground [&_.gmail_quote]:text-[13px]"
|
|
22
|
+
)
|
|
23
|
+
|
|
7
24
|
export type TimelineEventTone =
|
|
8
25
|
| "red"
|
|
9
26
|
| "amber"
|
|
@@ -37,6 +54,13 @@ export interface TimelineEvent {
|
|
|
37
54
|
date?: string
|
|
38
55
|
subject?: string
|
|
39
56
|
body: React.ReactNode
|
|
57
|
+
/**
|
|
58
|
+
* HTML body. When provided, the card renders formatted, Gmail-like HTML
|
|
59
|
+
* instead of the plain-text `body`. The component sanitizes it before
|
|
60
|
+
* rendering. Opt-in: when absent, the plain-text `body` path is used and
|
|
61
|
+
* existing consumers are unaffected.
|
|
62
|
+
*/
|
|
63
|
+
bodyHtml?: string
|
|
40
64
|
}
|
|
41
65
|
content?: React.ReactNode
|
|
42
66
|
source?: {
|
|
@@ -242,9 +266,19 @@ function TimelineItem({ event, isLast }: { event: TimelineEvent; isLast: boolean
|
|
|
242
266
|
</div>
|
|
243
267
|
</div>
|
|
244
268
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
269
|
+
{event.email.bodyHtml ? (
|
|
270
|
+
// Gmail reading-pane typography; quoted history
|
|
271
|
+
// (blockquote.gmail_quote) is de-emphasized with a left rule.
|
|
272
|
+
<div
|
|
273
|
+
data-slot="timeline-email-html"
|
|
274
|
+
className={EMAIL_HTML_CLASS}
|
|
275
|
+
dangerouslySetInnerHTML={{ __html: sanitizeHtml(event.email.bodyHtml) }}
|
|
276
|
+
/>
|
|
277
|
+
) : (
|
|
278
|
+
<div className="whitespace-pre-line text-sm leading-relaxed text-foreground/90">
|
|
279
|
+
{event.email.body}
|
|
280
|
+
</div>
|
|
281
|
+
)}
|
|
248
282
|
|
|
249
283
|
<button
|
|
250
284
|
onClick={(e) => {
|
|
@@ -375,10 +375,10 @@ export function VirtualizedDataTable<TData>({
|
|
|
375
375
|
onMouseDown={header.getResizeHandler()}
|
|
376
376
|
onTouchStart={header.getResizeHandler()}
|
|
377
377
|
className={cn(
|
|
378
|
-
"absolute right-0 top-0 h-full w-
|
|
379
|
-
"after:absolute after:right-
|
|
380
|
-
"after:bg-
|
|
381
|
-
header.column.getIsResizing() && "after:bg-primary/
|
|
378
|
+
"absolute right-0 top-0 z-20 h-full w-4 -mr-2 cursor-col-resize select-none touch-none",
|
|
379
|
+
"after:absolute after:right-2 after:top-1 after:h-[calc(100%-0.5rem)] after:w-px after:rounded-full",
|
|
380
|
+
"after:bg-border/70 after:transition-colors hover:after:bg-primary/60",
|
|
381
|
+
header.column.getIsResizing() && "after:bg-primary/70",
|
|
382
382
|
)}
|
|
383
383
|
role="separator"
|
|
384
384
|
aria-orientation="vertical"
|
package/src/index.ts
CHANGED
|
@@ -29,10 +29,12 @@ export * from "./components/case-panel-activity-timeline"
|
|
|
29
29
|
export * from "./components/case-panel-detail"
|
|
30
30
|
export * from "./components/case-panel-why"
|
|
31
31
|
export { CollapsibleSection, type CollapsibleSectionProps } from "./components/collapsible-section"
|
|
32
|
+
export * from "./components/comment-composer"
|
|
32
33
|
export * from "./components/compliance-badge"
|
|
33
34
|
export * from "./components/contact-chip"
|
|
34
35
|
export * from "./components/contact-list"
|
|
35
36
|
export * from "./components/contextual-quick-action-launcher"
|
|
37
|
+
export * from "./components/conversation-panel"
|
|
36
38
|
export * from "./components/dashboard-cards"
|
|
37
39
|
export * from "./components/data-table"
|
|
38
40
|
export * from "./components/data-table-condition-filter"
|
|
@@ -50,7 +52,7 @@ export * from "./components/related-record-action-card"
|
|
|
50
52
|
export { FeedbackFooter, FeedbackChipGroup, FeedbackInput, FeedbackActions, InlineFeedbackControl } from "./components/feedback-primitives"
|
|
51
53
|
export type { FeedbackFooterProps, FeedbackChipTree, FeedbackChipGroupProps, FeedbackInputProps, FeedbackActionsProps, FeedbackSubmitData, PersistedFeedbackData, InlineFeedbackControlProps } from "./components/feedback-primitives"
|
|
52
54
|
export { SignalPriorityPopover } from "./components/signal-priority-popover"
|
|
53
|
-
export type { SignalPriorityPopoverProps, PriorityFactor } from "./components/signal-priority-popover"
|
|
55
|
+
export type { SignalPriorityPopoverProps, SignalPriorityScoreDisplay, PriorityFactor } from "./components/signal-priority-popover"
|
|
54
56
|
export * from "./components/filter-chip"
|
|
55
57
|
export * from "./components/inbox-row"
|
|
56
58
|
export * from "./components/inbox-toolbar"
|
|
@@ -67,6 +69,7 @@ export * from "./components/kbd-hint"
|
|
|
67
69
|
export * from "./components/label"
|
|
68
70
|
export * from "./components/message"
|
|
69
71
|
export * from "./components/metric-card"
|
|
72
|
+
export * from "./components/owner-chips"
|
|
70
73
|
export * from "./components/performance-metrics-table"
|
|
71
74
|
export * from "./components/pill"
|
|
72
75
|
export * from "./components/preview-list"
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { htmlToTextSnippet, sanitizeHtml } from "../safe-html"
|
|
3
|
+
|
|
4
|
+
describe("sanitizeHtml", () => {
|
|
5
|
+
it("removes executable tags, event handlers, styles, and unsafe urls", () => {
|
|
6
|
+
const html = sanitizeHtml(
|
|
7
|
+
'<p style="color:red" onclick="alert(1)">Hi<script>alert(1)</script><iframe src="https://evil.test"></iframe><a href="java:script:alert(1)">bad</a><img src="data:text/html,boom" onerror="alert(1)"></p>',
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
expect(html).toBe('<p>Hi<a>bad</a><img></p>')
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it("rejects entity-obfuscated unsafe protocols", () => {
|
|
14
|
+
const html = sanitizeHtml('<a href="javascript:alert(1)">bad</a><a href="java&#x73;cript:alert(1)">also bad</a>')
|
|
15
|
+
|
|
16
|
+
expect(html).toBe('<a>bad</a><a>also bad</a>')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it("drops url attributes with out-of-range numeric entities without throwing", () => {
|
|
20
|
+
expect(() => sanitizeHtml('<a href="�">link</a><img src="�">')).not.toThrow()
|
|
21
|
+
expect(sanitizeHtml('<a href="�">link</a><img src="�">')).toBe('<a>link</a><img>')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it("drops protocol-relative urls instead of treating them as local paths", () => {
|
|
25
|
+
const html = sanitizeHtml('<a href="//evil.example/path">link</a><img src="//evil.example/pixel.png">')
|
|
26
|
+
|
|
27
|
+
expect(html).toBe('<a>link</a><img>')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it("handles less-than characters inside quoted attributes without preserving unsafe attributes", () => {
|
|
31
|
+
const html = sanitizeHtml('<img src=x onerror="alert(1)" alt="<"><p title="<" onclick="alert(1)">click me</p>')
|
|
32
|
+
|
|
33
|
+
expect(html).toBe('<img src="x" alt="<"><p title="<">click me</p>')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it("keeps common email formatting, safe links, and gmail_quote classes", () => {
|
|
37
|
+
const html = sanitizeHtml(
|
|
38
|
+
'<blockquote class="gmail_quote weird$class" style="color:red"><a href="https://example.com" target="_self">Quoted</a><br><ul><li>One</li></ul></blockquote>',
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
expect(html).toContain('class="gmail_quote"')
|
|
42
|
+
expect(html).toContain('href="https://example.com"')
|
|
43
|
+
expect(html).toContain('rel="noopener noreferrer"')
|
|
44
|
+
expect(html).toContain("<ul><li>One</li></ul>")
|
|
45
|
+
expect(html).not.toContain("style=")
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
describe("htmlToTextSnippet", () => {
|
|
50
|
+
it("builds snippets from sanitized text content", () => {
|
|
51
|
+
expect(htmlToTextSnippet('<p>Hello <script>alert(1)</script><b>world</b></p>', 11)).toBe("Hello world")
|
|
52
|
+
})
|
|
53
|
+
})
|