@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
|
@@ -149,4 +149,59 @@ describe("TimelineActivity", () => {
|
|
|
149
149
|
expect(TONE_CLASSES[tone].icon).toBeTruthy()
|
|
150
150
|
}
|
|
151
151
|
})
|
|
152
|
+
|
|
153
|
+
// --- Email body: opt-in HTML render mode ---
|
|
154
|
+
|
|
155
|
+
it("sanitizes formatted HTML when email.bodyHtml is provided", () => {
|
|
156
|
+
const event = minimal({
|
|
157
|
+
isInteractive: true,
|
|
158
|
+
defaultExpanded: true,
|
|
159
|
+
email: {
|
|
160
|
+
from: "Priya Raman",
|
|
161
|
+
to: "Dana Okafor",
|
|
162
|
+
subject: "Re: hi",
|
|
163
|
+
body: "plain fallback",
|
|
164
|
+
bodyHtml:
|
|
165
|
+
'<p onclick="alert(1)">Hello <a href="javascript:alert(1)">bad</a><script>alert(1)</script><img src="https://example.com/pixel.png" onerror="alert(1)"></p>',
|
|
166
|
+
},
|
|
167
|
+
})
|
|
168
|
+
const { container } = render(<TimelineActivity events={[event]} />)
|
|
169
|
+
const html = container.querySelector('[data-slot="timeline-email-html"]')!
|
|
170
|
+
expect(html.innerHTML).not.toContain("script")
|
|
171
|
+
expect(html.innerHTML).not.toContain("onclick")
|
|
172
|
+
expect(html.innerHTML).not.toContain("onerror")
|
|
173
|
+
expect(html.innerHTML).not.toContain("javascript:")
|
|
174
|
+
expect(html.querySelector("img")?.getAttribute("src")).toBe("https://example.com/pixel.png")
|
|
175
|
+
expect(html.querySelector("a")?.getAttribute("href")).toBeNull()
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it("renders formatted HTML when email.bodyHtml is provided", () => {
|
|
179
|
+
const event = minimal({
|
|
180
|
+
isInteractive: true,
|
|
181
|
+
defaultExpanded: true,
|
|
182
|
+
email: {
|
|
183
|
+
from: "Priya Raman",
|
|
184
|
+
to: "Dana Okafor",
|
|
185
|
+
subject: "Re: hi",
|
|
186
|
+
body: "plain fallback",
|
|
187
|
+
bodyHtml: '<p>Hello <a href="https://example.com">link</a></p>',
|
|
188
|
+
},
|
|
189
|
+
})
|
|
190
|
+
const { container } = render(<TimelineActivity events={[event]} />)
|
|
191
|
+
const html = container.querySelector('[data-slot="timeline-email-html"]')
|
|
192
|
+
expect(html).not.toBeNull()
|
|
193
|
+
// Renders the actual anchor element (not escaped text)
|
|
194
|
+
expect(html!.querySelector("a")?.getAttribute("href")).toBe("https://example.com")
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it("falls back to the plain-text body when bodyHtml is absent", () => {
|
|
198
|
+
const event = minimal({
|
|
199
|
+
isInteractive: true,
|
|
200
|
+
defaultExpanded: true,
|
|
201
|
+
email: { from: "Priya", to: "Dana", subject: "Re: hi", body: "just text" },
|
|
202
|
+
})
|
|
203
|
+
const { container } = render(<TimelineActivity events={[event]} />)
|
|
204
|
+
expect(container.querySelector('[data-slot="timeline-email-html"]')).toBeNull()
|
|
205
|
+
expect(screen.getByText("just text")).toBeDefined()
|
|
206
|
+
})
|
|
152
207
|
})
|
|
@@ -62,6 +62,8 @@ export type CasePanelActivityPayload =
|
|
|
62
62
|
dueLabel: string
|
|
63
63
|
status?: "upcoming" | "due" | "overdue" | "met"
|
|
64
64
|
description?: string
|
|
65
|
+
/** 0..1 elapsed toward the deadline; renders a thin progress bar. */
|
|
66
|
+
progress?: number
|
|
65
67
|
}
|
|
66
68
|
| {
|
|
67
69
|
kind: "operatorNote"
|
|
@@ -373,6 +375,24 @@ function renderPayloadContent(
|
|
|
373
375
|
) : null}
|
|
374
376
|
</div>
|
|
375
377
|
{payload.description ? <p className="text-xs leading-relaxed text-muted-foreground">{payload.description}</p> : null}
|
|
378
|
+
{typeof payload.progress === "number" ? (
|
|
379
|
+
<div
|
|
380
|
+
data-slot="deadline-progress"
|
|
381
|
+
role="progressbar"
|
|
382
|
+
aria-valuenow={Math.round(Math.min(1, Math.max(0, payload.progress)) * 100)}
|
|
383
|
+
aria-valuemin={0}
|
|
384
|
+
aria-valuemax={100}
|
|
385
|
+
className="bg-muted h-1.5 w-full overflow-hidden rounded-full"
|
|
386
|
+
>
|
|
387
|
+
<div
|
|
388
|
+
className={cn(
|
|
389
|
+
"h-full rounded-full",
|
|
390
|
+
payload.status === "overdue" ? "bg-red-500" : payload.status === "due" ? "bg-amber-500" : "bg-foreground/70"
|
|
391
|
+
)}
|
|
392
|
+
style={{ width: `${Math.min(1, Math.max(0, payload.progress)) * 100}%` }}
|
|
393
|
+
/>
|
|
394
|
+
</div>
|
|
395
|
+
) : null}
|
|
376
396
|
</div>
|
|
377
397
|
)
|
|
378
398
|
case "operatorNote":
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* comment-composer.tsx — an internal-note composer for the case activity
|
|
5
|
+
* timeline. Posting a comment prepends an `operatorNote` event to the log
|
|
6
|
+
* (wired by the consumer). Collapses to a single line; expands on focus or
|
|
7
|
+
* when it has text. ⌘↵ / Ctrl↵ posts.
|
|
8
|
+
*
|
|
9
|
+
* Presentational: `onPost` does the work (the consumer persists the note and
|
|
10
|
+
* adds it to the timeline). Reuses Avatar / Button / Textarea primitives.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as React from "react"
|
|
14
|
+
import { Lock } from "lucide-react"
|
|
15
|
+
|
|
16
|
+
import { cn } from "../lib/utils"
|
|
17
|
+
import { getInitials } from "../lib/user-display"
|
|
18
|
+
import { Avatar, AvatarFallback, AvatarImage } from "./avatar"
|
|
19
|
+
import { Button } from "./button"
|
|
20
|
+
import { Textarea } from "./textarea"
|
|
21
|
+
|
|
22
|
+
export interface CommentComposerProps {
|
|
23
|
+
/** Called with the trimmed note text when the operator posts. */
|
|
24
|
+
onPost: (text: string) => void
|
|
25
|
+
/** Current operator (for the avatar). */
|
|
26
|
+
author?: { name?: string; email?: string; avatarUrl?: string | null }
|
|
27
|
+
placeholder?: string
|
|
28
|
+
/** Hint shown in the footer; defaults to the internal-note reassurance. */
|
|
29
|
+
hint?: string
|
|
30
|
+
className?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function CommentComposer({
|
|
34
|
+
onPost,
|
|
35
|
+
author,
|
|
36
|
+
placeholder = "Add a comment or internal note…",
|
|
37
|
+
hint = "Internal note: only your team sees this",
|
|
38
|
+
className,
|
|
39
|
+
}: CommentComposerProps) {
|
|
40
|
+
const [text, setText] = React.useState("")
|
|
41
|
+
const [focused, setFocused] = React.useState(false)
|
|
42
|
+
const open = focused || text.length > 0
|
|
43
|
+
const canPost = text.trim().length > 0
|
|
44
|
+
|
|
45
|
+
const post = () => {
|
|
46
|
+
const value = text.trim()
|
|
47
|
+
if (!value) return
|
|
48
|
+
onPost(value)
|
|
49
|
+
setText("")
|
|
50
|
+
setFocused(false)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div
|
|
55
|
+
data-slot="comment-composer"
|
|
56
|
+
data-open={open ? "true" : undefined}
|
|
57
|
+
className={cn(
|
|
58
|
+
"border-border bg-background flex items-start gap-2 rounded-lg border p-2 transition-colors",
|
|
59
|
+
open && "ring-ring/30 ring-2",
|
|
60
|
+
className
|
|
61
|
+
)}
|
|
62
|
+
>
|
|
63
|
+
<Avatar size="sm" className="mt-0.5">
|
|
64
|
+
{author?.avatarUrl ? <AvatarImage src={author.avatarUrl} alt={author.name ?? "You"} /> : null}
|
|
65
|
+
<AvatarFallback className="bg-muted text-muted-foreground text-[10px] font-medium uppercase">
|
|
66
|
+
{getInitials({ name: author?.name, email: author?.email })}
|
|
67
|
+
</AvatarFallback>
|
|
68
|
+
</Avatar>
|
|
69
|
+
|
|
70
|
+
<div className="min-w-0 flex-1">
|
|
71
|
+
<Textarea
|
|
72
|
+
data-slot="comment-composer-input"
|
|
73
|
+
value={text}
|
|
74
|
+
onChange={(e) => setText(e.target.value)}
|
|
75
|
+
onFocus={() => setFocused(true)}
|
|
76
|
+
placeholder={placeholder}
|
|
77
|
+
rows={open ? 3 : 1}
|
|
78
|
+
className={cn(
|
|
79
|
+
"resize-none border-0 bg-transparent p-1 text-sm shadow-none focus-visible:ring-0",
|
|
80
|
+
!open && "min-h-0"
|
|
81
|
+
)}
|
|
82
|
+
onKeyDown={(e) => {
|
|
83
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
|
84
|
+
e.preventDefault()
|
|
85
|
+
post()
|
|
86
|
+
}
|
|
87
|
+
}}
|
|
88
|
+
/>
|
|
89
|
+
|
|
90
|
+
{open ? (
|
|
91
|
+
<div className="mt-1 flex items-center justify-between gap-2">
|
|
92
|
+
<span className="text-muted-foreground inline-flex items-center gap-1 text-[11px]">
|
|
93
|
+
<Lock size={12} /> {hint}
|
|
94
|
+
</span>
|
|
95
|
+
<span className="flex items-center gap-2">
|
|
96
|
+
<Button
|
|
97
|
+
type="button"
|
|
98
|
+
variant="ghost"
|
|
99
|
+
size="sm"
|
|
100
|
+
onClick={() => {
|
|
101
|
+
setText("")
|
|
102
|
+
setFocused(false)
|
|
103
|
+
}}
|
|
104
|
+
>
|
|
105
|
+
Cancel
|
|
106
|
+
</Button>
|
|
107
|
+
<Button type="button" size="sm" disabled={!canPost} onClick={post}>
|
|
108
|
+
Comment
|
|
109
|
+
<kbd className="bg-primary-foreground/15 ml-1 rounded px-1 text-[10px]">⌘↵</kbd>
|
|
110
|
+
</Button>
|
|
111
|
+
</span>
|
|
112
|
+
</div>
|
|
113
|
+
) : null}
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export { CommentComposer }
|