@handled-ai/design-system 0.18.53 → 0.19.0-rc.0
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/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/data-table-filter.d.ts +18 -1
- package/dist/components/data-table-filter.js +20 -6
- package/dist/components/data-table-filter.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/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/index.d.ts +3 -0
- 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/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__/data-table-filter.test.tsx +72 -0
- package/src/components/__tests__/owner-chips.test.tsx +100 -0
- package/src/components/__tests__/timeline-activity.test.tsx +55 -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/data-table-filter.tsx +53 -10
- package/src/components/owner-chips.tsx +335 -0
- package/src/components/timeline-activity.tsx +37 -3
- package/src/index.ts +3 -0
- package/src/internal/__tests__/safe-html.test.ts +53 -0
- package/src/internal/safe-html.ts +284 -0
|
@@ -44,6 +44,14 @@ export interface DataTableOptionFilterCategory extends DataTableFilterCategoryBa
|
|
|
44
44
|
options: (string | FilterOption)[]
|
|
45
45
|
/** Filter behavior. Defaults to "multi" (checkbox multi-select). */
|
|
46
46
|
type?: "multi" | "single" | "boolean"
|
|
47
|
+
/**
|
|
48
|
+
* When true, the submenu search box is parent-driven: typing fires
|
|
49
|
+
* `onOptionSearch(categoryId, query)` and the parent is responsible for
|
|
50
|
+
* supplying the matching `options` (e.g. a server-backed lookup over a large
|
|
51
|
+
* set). The built-in client-side option filtering is skipped, the search box
|
|
52
|
+
* is always shown, and `optionSearchLoading[categoryId]` drives a loading row.
|
|
53
|
+
*/
|
|
54
|
+
remoteSearch?: boolean
|
|
47
55
|
}
|
|
48
56
|
|
|
49
57
|
export interface DataTableTextFilterCategory extends DataTableFilterCategoryBase {
|
|
@@ -196,6 +204,15 @@ export interface DataTableFilterProps {
|
|
|
196
204
|
textFilters?: Record<string, string>
|
|
197
205
|
/** Callback when a free-text filter value is applied or cleared. */
|
|
198
206
|
onTextFilterChange?: (categoryId: string, value: string) => void
|
|
207
|
+
/**
|
|
208
|
+
* Fired when the submenu search input changes for a category with
|
|
209
|
+
* `remoteSearch: true`. The parent should debounce, fetch matching options,
|
|
210
|
+
* and feed them back via that category's `options`. The empty string is sent
|
|
211
|
+
* when the box is cleared (or the submenu re-opens) so the parent can reset.
|
|
212
|
+
*/
|
|
213
|
+
onOptionSearch?: (categoryId: string, query: string) => void
|
|
214
|
+
/** Per-category loading state for remote option search, keyed by category id. */
|
|
215
|
+
optionSearchLoading?: Record<string, boolean>
|
|
199
216
|
}
|
|
200
217
|
|
|
201
218
|
export function DataTableFilter({
|
|
@@ -213,6 +230,8 @@ export function DataTableFilter({
|
|
|
213
230
|
conditionBuilderLabel = "Add filter",
|
|
214
231
|
textFilters = {},
|
|
215
232
|
onTextFilterChange,
|
|
233
|
+
onOptionSearch,
|
|
234
|
+
optionSearchLoading = {},
|
|
216
235
|
}: DataTableFilterProps) {
|
|
217
236
|
const [query, setQuery] = React.useState("")
|
|
218
237
|
const [subQueries, setSubQueries] = React.useState<Record<string, string>>({})
|
|
@@ -360,17 +379,21 @@ export function DataTableFilter({
|
|
|
360
379
|
}
|
|
361
380
|
|
|
362
381
|
/* ── Sub-menu (single / multi) ──────────────────────── */
|
|
382
|
+
const isRemote = category.remoteSearch === true
|
|
363
383
|
const subQuery = (subQueries[category.id] ?? "").trim().toLowerCase()
|
|
364
|
-
|
|
365
|
-
|
|
384
|
+
// Remote-search categories are filtered by the parent (server-side);
|
|
385
|
+
// never client-filter their options.
|
|
386
|
+
const filteredOptions = isRemote || !subQuery
|
|
387
|
+
? category.options
|
|
388
|
+
: category.options.filter((opt) =>
|
|
366
389
|
getOptionLabel(opt).toLowerCase().includes(subQuery)
|
|
367
390
|
)
|
|
368
|
-
|
|
369
|
-
const shouldShowSubmenuSearch = shouldShowOptionSearch(
|
|
391
|
+
const shouldShowSubmenuSearch = isRemote || shouldShowOptionSearch(
|
|
370
392
|
category.searchable,
|
|
371
393
|
category.options.length,
|
|
372
394
|
optionSearchThreshold,
|
|
373
395
|
)
|
|
396
|
+
const isSearching = optionSearchLoading[category.id] === true
|
|
374
397
|
|
|
375
398
|
return (
|
|
376
399
|
<DropdownMenuSub
|
|
@@ -382,6 +405,8 @@ export function DataTableFilter({
|
|
|
382
405
|
delete next[category.id]
|
|
383
406
|
return next
|
|
384
407
|
})
|
|
408
|
+
// Reset the parent's remote results so re-opening starts clean.
|
|
409
|
+
if (isRemote) onOptionSearch?.(category.id, "")
|
|
385
410
|
}
|
|
386
411
|
}}
|
|
387
412
|
>
|
|
@@ -399,9 +424,11 @@ export function DataTableFilter({
|
|
|
399
424
|
className="h-7 w-full rounded-md bg-muted/50 py-1 pr-2 pl-7 text-xs outline-none transition-colors placeholder:text-muted-foreground/70 focus:bg-muted"
|
|
400
425
|
placeholder="Search..."
|
|
401
426
|
value={subQueries[category.id] ?? ""}
|
|
402
|
-
onChange={(e) =>
|
|
403
|
-
|
|
404
|
-
|
|
427
|
+
onChange={(e) => {
|
|
428
|
+
const next = e.target.value
|
|
429
|
+
setSubQueries((prev) => ({ ...prev, [category.id]: next }))
|
|
430
|
+
if (isRemote) onOptionSearch?.(category.id, next)
|
|
431
|
+
}}
|
|
405
432
|
onClick={(e) => e.stopPropagation()}
|
|
406
433
|
onKeyDown={(e) => {
|
|
407
434
|
// Allow navigation keys to propagate to Radix menu handling
|
|
@@ -447,11 +474,27 @@ export function DataTableFilter({
|
|
|
447
474
|
</DropdownMenuItem>
|
|
448
475
|
)
|
|
449
476
|
})}
|
|
450
|
-
{
|
|
477
|
+
{isSearching ? (
|
|
451
478
|
<div className="p-2 text-center text-xs text-muted-foreground">
|
|
452
|
-
|
|
479
|
+
Searching…
|
|
453
480
|
</div>
|
|
454
|
-
)
|
|
481
|
+
) : filteredOptions.length === 0 ? (
|
|
482
|
+
(() => {
|
|
483
|
+
const typed = (subQueries[category.id] ?? "").trim().length > 0
|
|
484
|
+
const message = isRemote
|
|
485
|
+
? typed
|
|
486
|
+
? "No matches"
|
|
487
|
+
: "Type to search"
|
|
488
|
+
: category.options.length > 0
|
|
489
|
+
? "No matches"
|
|
490
|
+
: null
|
|
491
|
+
return message ? (
|
|
492
|
+
<div className="p-2 text-center text-xs text-muted-foreground">
|
|
493
|
+
{message}
|
|
494
|
+
</div>
|
|
495
|
+
) : null
|
|
496
|
+
})()
|
|
497
|
+
) : null}
|
|
455
498
|
</DropdownMenuSubContent>
|
|
456
499
|
</DropdownMenuSub>
|
|
457
500
|
)
|
|
@@ -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 }
|
|
@@ -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) => {
|
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"
|
|
@@ -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
|
+
})
|