@handled-ai/design-system 0.20.5 → 0.20.7
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/conversation-panel.d.ts +19 -0
- package/dist/components/conversation-panel.js +116 -292
- package/dist/components/conversation-panel.js.map +1 -1
- package/dist/components/email-body.d.ts +15 -0
- package/dist/components/email-body.js +101 -0
- package/dist/components/email-body.js.map +1 -0
- package/dist/components/email-display-helpers.d.ts +34 -0
- package/dist/components/email-display-helpers.js +436 -0
- package/dist/components/email-display-helpers.js.map +1 -0
- package/dist/components/email-preview-card.d.ts +7 -4
- package/dist/components/email-preview-card.js +48 -25
- package/dist/components/email-preview-card.js.map +1 -1
- package/dist/components/timeline-activity.d.ts +1 -0
- package/dist/components/timeline-activity.js +116 -65
- package/dist/components/timeline-activity.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/internal/safe-html.d.ts +1 -1
- package/dist/internal/safe-html.js +64 -3
- package/dist/internal/safe-html.js.map +1 -1
- package/package.json +1 -1
- package/src/components/__tests__/conversation-panel.test.tsx +182 -22
- package/src/components/__tests__/email-body.test.tsx +83 -0
- package/src/components/__tests__/email-display-helpers.test.ts +91 -0
- package/src/components/__tests__/email-preview-card.test.tsx +36 -2
- package/src/components/__tests__/timeline-activity.test.tsx +87 -1
- package/src/components/conversation-panel.tsx +136 -350
- package/src/components/email-body.tsx +126 -0
- package/src/components/email-display-helpers.ts +557 -0
- package/src/components/email-preview-card.tsx +54 -29
- package/src/components/timeline-activity.tsx +105 -63
- package/src/index.ts +2 -0
- package/src/internal/__tests__/safe-html.test.ts +34 -2
- package/src/internal/safe-html.ts +79 -4
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
decodeEmailDisplayText,
|
|
5
|
+
emailBodySnippet,
|
|
6
|
+
formatAddressList,
|
|
7
|
+
formatEmailTimestamp,
|
|
8
|
+
normalizeEmailSender,
|
|
9
|
+
splitEmailHtmlForDisplay,
|
|
10
|
+
splitEmailTextForDisplay,
|
|
11
|
+
} from "../email-display-helpers"
|
|
12
|
+
|
|
13
|
+
function expectNoVisibleEscapeArtifacts(value: string) {
|
|
14
|
+
expect(value).not.toMatch(/&#(?:x[0-9a-f]+|\d+);?/i)
|
|
15
|
+
expect(value).not.toContain("&")
|
|
16
|
+
expect(value).not.toContain("<")
|
|
17
|
+
expect(value).not.toContain(">")
|
|
18
|
+
expect(value).not.toContain(""")
|
|
19
|
+
expect(value).not.toContain("\\n")
|
|
20
|
+
expect(value).not.toContain('\\"')
|
|
21
|
+
expect(value).not.toContain("\\'")
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("email display helpers", () => {
|
|
25
|
+
it("decodes display text without visible escape artifacts", () => {
|
|
26
|
+
const decoded = decodeEmailDisplayText('"Hi Cory & Jane's team\\nSay \\\"hello\\\""')
|
|
27
|
+
|
|
28
|
+
expect(decoded).toBe('Hi Cory & Jane\'s team\nSay "hello"')
|
|
29
|
+
expectNoVisibleEscapeArtifacts(decoded)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it("dedupes combined sender fields into clean name and bare email", () => {
|
|
33
|
+
expect(normalizeEmailSender({ name: "Jane Doe <jane@example.com>", email: "Jane Doe <jane@example.com>" })).toEqual({
|
|
34
|
+
name: "Jane Doe",
|
|
35
|
+
email: "jane@example.com",
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
expect(normalizeEmailSender({ name: "jane@example.com", email: "jane@example.com", fallbackName: "Fallback" })).toEqual({
|
|
39
|
+
name: "jane@example.com",
|
|
40
|
+
email: "jane@example.com",
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it("formats address lists without inventing names", () => {
|
|
45
|
+
expect(formatAddressList('"Jane Doe" <jane@example.com>, alex@example.com, Support & Success <support@example.com>')).toBe(
|
|
46
|
+
"Jane Doe <jane@example.com>, alex@example.com, Support & Success <support@example.com>",
|
|
47
|
+
)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it("formats timestamps deterministically and omits invalid dates", () => {
|
|
51
|
+
expect(formatEmailTimestamp("2026-06-08T20:45:00.000Z")).toBe("Jun 8, 2026, 8:45 PM")
|
|
52
|
+
expect(formatEmailTimestamp("not-a-date")).toBeNull()
|
|
53
|
+
expect(formatEmailTimestamp(null)).toBeNull()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it("splits HTML signatures, Gmail quotes, and disclaimers while preserving formatting", () => {
|
|
57
|
+
const disclaimerSplit = splitEmailHtmlForDisplay("<p>Please review.</p><p>Confidentiality Notice: this message is private.</p>")
|
|
58
|
+
expect(disclaimerSplit.bodyHtml).toContain("Please review")
|
|
59
|
+
expect(disclaimerSplit.detailsHtml).toContain("Confidentiality Notice")
|
|
60
|
+
|
|
61
|
+
const split = splitEmailHtmlForDisplay(
|
|
62
|
+
'<p>Hi Dana & team,</p><p>The update looks good.</p><div class="gmail_signature"><table><tbody><tr><td><sup>TM</sup> Jane</td></tr></tbody></table></div><blockquote class="gmail_quote"><div>On Monday, Dana wrote:</div><p>Old note</p></blockquote>',
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
expect(split.bodyHtml).toContain("The update looks good")
|
|
66
|
+
expect(split.bodyHtml).not.toContain("gmail_signature")
|
|
67
|
+
expect(split.detailsHtml).toContain('class="gmail_signature"')
|
|
68
|
+
expect(split.detailsHtml).toContain("<sup>TM</sup>")
|
|
69
|
+
expect(split.detailsHtml).toContain('class="gmail_quote"')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it("splits plain text signatures while preserving line boundaries", () => {
|
|
73
|
+
const split = splitEmailTextForDisplay("Hi Dana,\n\nLooks good.\n\n-- \nJane Doe\nVP Sales\njane@example.com")
|
|
74
|
+
|
|
75
|
+
expect(split.bodyText).toBe("Hi Dana,\n\nLooks good.\n\n")
|
|
76
|
+
expect(split.detailsText).toBe("-- \nJane Doe\nVP Sales\njane@example.com")
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it("builds decoded snippets from visible body only", () => {
|
|
80
|
+
const snippet = emailBodySnippet(
|
|
81
|
+
{
|
|
82
|
+
bodyHtml:
|
|
83
|
+
'<p>Hello & welcome 'Cory'</p><p>Thanks,</p><p>Jane Doe</p><p>VP Sales</p><p>jane@example.com</p>',
|
|
84
|
+
},
|
|
85
|
+
80,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
expect(snippet).toBe("Hello & welcome 'Cory'")
|
|
89
|
+
expectNoVisibleEscapeArtifacts(snippet)
|
|
90
|
+
})
|
|
91
|
+
})
|
|
@@ -10,6 +10,17 @@ afterEach(() => {
|
|
|
10
10
|
|
|
11
11
|
const from = { name: "Cory Pitt", email: "cory@withhandled.com" }
|
|
12
12
|
|
|
13
|
+
function expectNoVisibleEscapeArtifacts(value: string) {
|
|
14
|
+
expect(value).not.toMatch(/&#(?:x[0-9a-f]+|\d+);?/i)
|
|
15
|
+
expect(value).not.toContain("&")
|
|
16
|
+
expect(value).not.toContain("<")
|
|
17
|
+
expect(value).not.toContain(">")
|
|
18
|
+
expect(value).not.toContain(""")
|
|
19
|
+
expect(value).not.toContain('\\"')
|
|
20
|
+
expect(value).not.toContain("\\'")
|
|
21
|
+
expect(value).not.toContain("\\n")
|
|
22
|
+
}
|
|
23
|
+
|
|
13
24
|
describe("EmailPreviewCard", () => {
|
|
14
25
|
it("renders the subject", () => {
|
|
15
26
|
render(<EmailPreviewCard from={from} subject="Quarterly update" />)
|
|
@@ -29,7 +40,7 @@ describe("EmailPreviewCard", () => {
|
|
|
29
40
|
|
|
30
41
|
it("renders the recipient", () => {
|
|
31
42
|
render(<EmailPreviewCard from={from} to="jane@acme.com" />)
|
|
32
|
-
expect(screen.getByText("
|
|
43
|
+
expect(screen.getByText("jane@acme.com")).toBeTruthy()
|
|
33
44
|
expect(
|
|
34
45
|
screen.getByText(/This is how your email lands in jane@acme.com's inbox/),
|
|
35
46
|
).toBeTruthy()
|
|
@@ -37,7 +48,7 @@ describe("EmailPreviewCard", () => {
|
|
|
37
48
|
|
|
38
49
|
it("renders 'to no recipient yet' when no recipient", () => {
|
|
39
50
|
render(<EmailPreviewCard from={from} />)
|
|
40
|
-
expect(screen.getByText("
|
|
51
|
+
expect(screen.getByText("no recipient yet")).toBeTruthy()
|
|
41
52
|
expect(
|
|
42
53
|
screen.getByText(/This is how your email lands in the recipient's inbox/),
|
|
43
54
|
).toBeTruthy()
|
|
@@ -59,4 +70,27 @@ describe("EmailPreviewCard", () => {
|
|
|
59
70
|
)
|
|
60
71
|
expect(container.querySelector("em")?.textContent).toBe("Best, Cory")
|
|
61
72
|
})
|
|
73
|
+
|
|
74
|
+
it("decodes preview copy, displays To/Cc/Bcc, and renders superscript signatures", () => {
|
|
75
|
+
const { container } = render(
|
|
76
|
+
<EmailPreviewCard
|
|
77
|
+
from={{ name: "Cory & Co", email: "Cory Pitt <cory@withhandled.com>" }}
|
|
78
|
+
to={'"Jane & Team" <jane@example.com>'}
|
|
79
|
+
cc={["Ops <ops@example.com>"]}
|
|
80
|
+
bcc="Audit <audit@example.com>"
|
|
81
|
+
subject="Quarterly & renewal"
|
|
82
|
+
htmlBody="<p>Hello Jane & team — it's ready.</p>"
|
|
83
|
+
signatureHtml="<p>Handled<sup>AI</sup></p>"
|
|
84
|
+
/>,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
expect(screen.getByText("Cory & Co")).toBeTruthy()
|
|
88
|
+
expect(screen.getByText("Quarterly & renewal")).toBeTruthy()
|
|
89
|
+
expect(screen.getByText("Jane & Team <jane@example.com>")).toBeTruthy()
|
|
90
|
+
expect(screen.getByText("Ops <ops@example.com>")).toBeTruthy()
|
|
91
|
+
expect(screen.getByText("Audit <audit@example.com>")).toBeTruthy()
|
|
92
|
+
expect(screen.getByText("Hello Jane & team — it's ready.")).toBeTruthy()
|
|
93
|
+
expect(container.querySelector("sup")?.textContent).toBe("AI")
|
|
94
|
+
expectNoVisibleEscapeArtifacts(container.textContent ?? "")
|
|
95
|
+
})
|
|
62
96
|
})
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import "@testing-library/jest-dom/vitest"
|
|
2
2
|
import { describe, it, expect } from "vitest"
|
|
3
3
|
import React from "react"
|
|
4
|
-
import { render, screen } from "@testing-library/react"
|
|
4
|
+
import { fireEvent, render, screen } from "@testing-library/react"
|
|
5
5
|
import {
|
|
6
6
|
TimelineActivity,
|
|
7
7
|
TONE_CLASSES,
|
|
@@ -12,6 +12,17 @@ import {
|
|
|
12
12
|
// Helpers
|
|
13
13
|
// ---------------------------------------------------------------------------
|
|
14
14
|
|
|
15
|
+
function expectNoVisibleEscapeArtifacts(value: string) {
|
|
16
|
+
expect(value).not.toMatch(/&#(?:x[0-9a-f]+|\d+);?/i)
|
|
17
|
+
expect(value).not.toContain("&")
|
|
18
|
+
expect(value).not.toContain("<")
|
|
19
|
+
expect(value).not.toContain(">")
|
|
20
|
+
expect(value).not.toContain(""")
|
|
21
|
+
expect(value).not.toContain('\\"')
|
|
22
|
+
expect(value).not.toContain("\\'")
|
|
23
|
+
expect(value).not.toContain("\\n")
|
|
24
|
+
}
|
|
25
|
+
|
|
15
26
|
function minimal(overrides: Partial<TimelineEvent> = {}): TimelineEvent {
|
|
16
27
|
return {
|
|
17
28
|
id: "e1",
|
|
@@ -328,4 +339,79 @@ describe("TimelineActivity", () => {
|
|
|
328
339
|
"https://salesforce.example/case/1",
|
|
329
340
|
)
|
|
330
341
|
})
|
|
342
|
+
|
|
343
|
+
it.each(["default", "case-panel"] as const)(
|
|
344
|
+
"renders email source actionLabel before Show less for the %s variant",
|
|
345
|
+
(variant) => {
|
|
346
|
+
const threadUrl = "https://mail.google.com/mail/u/0/#inbox/thread-1"
|
|
347
|
+
const event = minimal({
|
|
348
|
+
isInteractive: true,
|
|
349
|
+
defaultExpanded: true,
|
|
350
|
+
email: {
|
|
351
|
+
from: "Priya Raman",
|
|
352
|
+
to: "Dana Okafor",
|
|
353
|
+
subject: "Re: hi",
|
|
354
|
+
body: "plain fallback",
|
|
355
|
+
},
|
|
356
|
+
source: { label: "Gmail", actionLabel: "Open thread", url: threadUrl },
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
const { container } = render(<TimelineActivity events={[event]} variant={variant} />)
|
|
360
|
+
|
|
361
|
+
const sourceAction = screen.getByRole("link", { name: /Open thread/i })
|
|
362
|
+
expect(sourceAction).toHaveAttribute("href", threadUrl)
|
|
363
|
+
expect(screen.queryByRole("link", { name: /Open in Gmail/i })).toBeNull()
|
|
364
|
+
|
|
365
|
+
const footer = variant === "case-panel"
|
|
366
|
+
? container.querySelector('[data-slot="timeline-card-footer"]')
|
|
367
|
+
: sourceAction.closest("div")
|
|
368
|
+
const footerText = footer?.textContent ?? ""
|
|
369
|
+
const sourceActionIndex = footerText.indexOf("Open thread")
|
|
370
|
+
const showLessIndex = footerText.indexOf("Show less")
|
|
371
|
+
|
|
372
|
+
expect(sourceActionIndex).toBeGreaterThanOrEqual(0)
|
|
373
|
+
expect(showLessIndex).toBeGreaterThan(sourceActionIndex)
|
|
374
|
+
},
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
it("uses shared helpers for decoded timeline sender, timestamp, snippets, and collapsed details", () => {
|
|
378
|
+
const event = minimal({
|
|
379
|
+
isInteractive: true,
|
|
380
|
+
defaultExpanded: false,
|
|
381
|
+
email: {
|
|
382
|
+
from: "Cory & Co",
|
|
383
|
+
fromEmail: "cory@example.com",
|
|
384
|
+
to: "Jane & Team <jane@example.com>",
|
|
385
|
+
cc: "Ops <ops@example.com>",
|
|
386
|
+
date: "2026-06-08T15:30:00.000Z",
|
|
387
|
+
subject: "Update & next steps",
|
|
388
|
+
body: "plain fallback",
|
|
389
|
+
bodyHtml:
|
|
390
|
+
"<p>Hello Jane & team — it's ready.</p><p>Thanks,</p><p>Cory Pitt</p><p>Handled<sup>AI</sup></p>",
|
|
391
|
+
},
|
|
392
|
+
preview: "Hello Jane & team — it's ready.",
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
const { container } = render(<TimelineActivity events={[event]} />)
|
|
396
|
+
const collapsedText = container.textContent ?? ""
|
|
397
|
+
|
|
398
|
+
expect(collapsedText).toContain("Cory & Co")
|
|
399
|
+
expect(collapsedText).toContain("Update & next steps")
|
|
400
|
+
expect(collapsedText).toContain("Hello Jane & team — it's ready.")
|
|
401
|
+
expectNoVisibleEscapeArtifacts(collapsedText)
|
|
402
|
+
|
|
403
|
+
fireEvent.click(screen.getByRole("button", { name: /Expand/i }))
|
|
404
|
+
|
|
405
|
+
expect(screen.getByText("Jun 8, 2026, 3:30 PM")).toBeTruthy()
|
|
406
|
+
expect(screen.getByText(/Jane & Team <jane@example.com>/)).toBeTruthy()
|
|
407
|
+
expect(container.querySelector('[data-slot="email-body-details"]')).toBeNull()
|
|
408
|
+
expect(screen.getByRole("button", { name: "•••" })).toBeTruthy()
|
|
409
|
+
expect(container.textContent).not.toContain("HandledAI")
|
|
410
|
+
|
|
411
|
+
fireEvent.click(screen.getByRole("button", { name: "•••" }))
|
|
412
|
+
expect(container.querySelector("sup")?.textContent).toBe("AI")
|
|
413
|
+
expect(container.textContent).toContain("HandledAI")
|
|
414
|
+
expectNoVisibleEscapeArtifacts(container.textContent ?? "")
|
|
415
|
+
})
|
|
416
|
+
|
|
331
417
|
})
|