@handled-ai/design-system 0.20.21 → 0.20.23
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/conversation-panel.d.ts +9 -0
- package/dist/components/conversation-panel.js +2 -1
- package/dist/components/conversation-panel.js.map +1 -1
- package/dist/components/pill.d.ts +1 -1
- package/dist/components/tabs.d.ts +1 -1
- package/dist/components/timeline-activity.d.ts +45 -0
- package/dist/components/timeline-activity.js +401 -52
- package/dist/components/timeline-activity.js.map +1 -1
- package/dist/internal/safe-html.js +32 -0
- package/dist/internal/safe-html.js.map +1 -1
- package/package.json +1 -1
- package/src/components/__tests__/conversation-panel.test.tsx +40 -1
- package/src/components/__tests__/timeline-activity.test.tsx +329 -0
- package/src/components/conversation-panel.tsx +10 -0
- package/src/components/timeline-activity.tsx +516 -39
- package/src/internal/__tests__/safe-html.test.ts +24 -5
- package/src/internal/safe-html.ts +39 -0
|
@@ -416,4 +416,333 @@ describe("TimelineActivity", () => {
|
|
|
416
416
|
expectNoVisibleEscapeArtifacts(container.textContent ?? "")
|
|
417
417
|
})
|
|
418
418
|
|
|
419
|
+
// --- Empty-recipient suppression (history emails) ---
|
|
420
|
+
|
|
421
|
+
it.each(["default", "case-panel"] as const)(
|
|
422
|
+
"suppresses the To-segment in the expanded %s card when there is no recipient",
|
|
423
|
+
(variant) => {
|
|
424
|
+
const event = minimal({
|
|
425
|
+
isInteractive: true,
|
|
426
|
+
defaultExpanded: true,
|
|
427
|
+
email: {
|
|
428
|
+
from: "Priya Raman",
|
|
429
|
+
to: "",
|
|
430
|
+
subject: "Synced email",
|
|
431
|
+
body: "historical body",
|
|
432
|
+
},
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
const { container } = render(<TimelineActivity events={[event]} variant={variant} />)
|
|
436
|
+
|
|
437
|
+
expect(container.textContent).not.toContain("no recipient yet")
|
|
438
|
+
// No "To " label renders at all when recipients are empty.
|
|
439
|
+
expect(container.textContent).not.toMatch(/\bTo\s/)
|
|
440
|
+
},
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
it("suppresses the To-segment in the collapsed preview when there is no recipient", () => {
|
|
444
|
+
const event = minimal({
|
|
445
|
+
isInteractive: true,
|
|
446
|
+
defaultExpanded: false,
|
|
447
|
+
email: {
|
|
448
|
+
from: "Priya Raman",
|
|
449
|
+
to: "",
|
|
450
|
+
subject: "Synced email",
|
|
451
|
+
body: "historical body",
|
|
452
|
+
},
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
const { container } = render(<TimelineActivity events={[event]} />)
|
|
456
|
+
|
|
457
|
+
expect(container.textContent).not.toContain("no recipient yet")
|
|
458
|
+
// Collapsed preview shows sender + subject + snippet, never a To-segment.
|
|
459
|
+
expect(screen.getByRole("button", { name: /Expand/i })).toBeTruthy()
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
it("still renders the recipient on the expanded card when present, stacked below the sender row", () => {
|
|
463
|
+
const event = minimal({
|
|
464
|
+
isInteractive: true,
|
|
465
|
+
defaultExpanded: true,
|
|
466
|
+
email: {
|
|
467
|
+
from: "Priya Raman",
|
|
468
|
+
fromEmail: "priya@example.com",
|
|
469
|
+
to: "Recipient Name <recipient@example.com>",
|
|
470
|
+
date: "2026-06-08T15:30:00.000Z",
|
|
471
|
+
subject: "Re: hi",
|
|
472
|
+
body: "body",
|
|
473
|
+
},
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
const { container } = render(<TimelineActivity events={[event]} variant="case-panel" />)
|
|
477
|
+
const header = container.querySelector('[data-slot="timeline-card-header"]')!
|
|
478
|
+
|
|
479
|
+
expect(header.textContent).toContain("Priya Raman")
|
|
480
|
+
expect(header.textContent).toContain("To Recipient Name <recipient@example.com>")
|
|
481
|
+
// Date and the To-line are in separate block rows (no jammed concatenation).
|
|
482
|
+
expect(header.textContent).not.toMatch(/\d(?:AM|PM)To /)
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
// --- Top collapse affordance ---
|
|
486
|
+
|
|
487
|
+
it.each(["default", "case-panel"] as const)(
|
|
488
|
+
"collapses the expanded %s email card from the top affordance",
|
|
489
|
+
(variant) => {
|
|
490
|
+
const event = minimal({
|
|
491
|
+
isInteractive: true,
|
|
492
|
+
defaultExpanded: true,
|
|
493
|
+
email: {
|
|
494
|
+
from: "Priya Raman",
|
|
495
|
+
to: "Dana Okafor",
|
|
496
|
+
subject: "Re: hi",
|
|
497
|
+
body: "expanded body content",
|
|
498
|
+
},
|
|
499
|
+
})
|
|
500
|
+
|
|
501
|
+
const { container } = render(<TimelineActivity events={[event]} variant={variant} />)
|
|
502
|
+
|
|
503
|
+
// The top collapse control is present while expanded.
|
|
504
|
+
const topCollapse = container.querySelector('[data-slot="timeline-collapse-top"]')!
|
|
505
|
+
expect(topCollapse).not.toBeNull()
|
|
506
|
+
// While expanded both the top and bottom "Show less" affordances exist.
|
|
507
|
+
expect(screen.getAllByRole("button", { name: /Show less/i }).length).toBe(2)
|
|
508
|
+
|
|
509
|
+
fireEvent.click(topCollapse)
|
|
510
|
+
|
|
511
|
+
// After collapsing, the expanded affordances are gone and the collapsed
|
|
512
|
+
// preview (Expand button) returns.
|
|
513
|
+
expect(container.querySelector('[data-slot="timeline-collapse-top"]')).toBeNull()
|
|
514
|
+
expect(screen.queryAllByRole("button", { name: /Show less/i }).length).toBe(0)
|
|
515
|
+
expect(screen.getByRole("button", { name: /Expand/i })).toBeTruthy()
|
|
516
|
+
},
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
// --- Signature / quoted-content suppression ---
|
|
520
|
+
|
|
521
|
+
it("hides signatureHtml behind a 'Show signature' toggle (collapsed by default)", () => {
|
|
522
|
+
const event = minimal({
|
|
523
|
+
isInteractive: true,
|
|
524
|
+
defaultExpanded: true,
|
|
525
|
+
email: {
|
|
526
|
+
from: "Priya Raman",
|
|
527
|
+
to: "Dana Okafor",
|
|
528
|
+
subject: "Re: hi",
|
|
529
|
+
body: "main body",
|
|
530
|
+
bodyHtml: "<p>main body</p>",
|
|
531
|
+
signatureHtml: "<p>Priya Raman | VP of Sales | 555-1234</p>",
|
|
532
|
+
},
|
|
533
|
+
})
|
|
534
|
+
|
|
535
|
+
const { container } = render(<TimelineActivity events={[event]} />)
|
|
536
|
+
|
|
537
|
+
// Signature content is not visible by default.
|
|
538
|
+
expect(container.textContent).not.toContain("VP of Sales")
|
|
539
|
+
const toggle = screen.getByRole("button", { name: /Show signature/i })
|
|
540
|
+
expect(toggle).toBeTruthy()
|
|
541
|
+
|
|
542
|
+
fireEvent.click(toggle)
|
|
543
|
+
expect(container.querySelector('[data-slot="timeline-email-signature-content"]')).not.toBeNull()
|
|
544
|
+
expect(container.textContent).toContain("VP of Sales")
|
|
545
|
+
expect(screen.getByRole("button", { name: /Hide signature/i })).toBeTruthy()
|
|
546
|
+
})
|
|
547
|
+
|
|
548
|
+
it("hides quotedHtml behind a 'Show quoted text' toggle (collapsed by default)", () => {
|
|
549
|
+
const event = minimal({
|
|
550
|
+
isInteractive: true,
|
|
551
|
+
defaultExpanded: true,
|
|
552
|
+
email: {
|
|
553
|
+
from: "Priya Raman",
|
|
554
|
+
to: "Dana Okafor",
|
|
555
|
+
subject: "Re: hi",
|
|
556
|
+
body: "main body",
|
|
557
|
+
bodyHtml: "<p>main body</p>",
|
|
558
|
+
quotedHtml: "<blockquote>On Mon, earlier message text</blockquote>",
|
|
559
|
+
},
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
const { container } = render(<TimelineActivity events={[event]} variant="case-panel" />)
|
|
563
|
+
|
|
564
|
+
expect(container.textContent).not.toContain("earlier message text")
|
|
565
|
+
const toggle = screen.getByRole("button", { name: /Show quoted text/i })
|
|
566
|
+
expect(toggle).toBeTruthy()
|
|
567
|
+
|
|
568
|
+
fireEvent.click(toggle)
|
|
569
|
+
expect(container.querySelector('[data-slot="timeline-email-quoted-content"]')).not.toBeNull()
|
|
570
|
+
expect(container.textContent).toContain("earlier message text")
|
|
571
|
+
})
|
|
572
|
+
|
|
573
|
+
it("renders the plain email path unchanged when signatureHtml/quotedHtml are absent", () => {
|
|
574
|
+
const event = minimal({
|
|
575
|
+
isInteractive: true,
|
|
576
|
+
defaultExpanded: true,
|
|
577
|
+
email: {
|
|
578
|
+
from: "Priya Raman",
|
|
579
|
+
to: "Dana Okafor",
|
|
580
|
+
subject: "Re: hi",
|
|
581
|
+
body: "just the body",
|
|
582
|
+
bodyHtml: "<p>just the body</p>",
|
|
583
|
+
},
|
|
584
|
+
})
|
|
585
|
+
|
|
586
|
+
const { container } = render(<TimelineActivity events={[event]} />)
|
|
587
|
+
|
|
588
|
+
expect(container.querySelector('[data-slot="timeline-email-signature"]')).toBeNull()
|
|
589
|
+
expect(container.querySelector('[data-slot="timeline-email-quoted"]')).toBeNull()
|
|
590
|
+
expect(screen.queryByRole("button", { name: /Show signature/i })).toBeNull()
|
|
591
|
+
expect(screen.queryByRole("button", { name: /Show quoted text/i })).toBeNull()
|
|
592
|
+
// The HTML body still renders via the existing slot.
|
|
593
|
+
expect(container.querySelector('[data-slot="timeline-email-html"]')).not.toBeNull()
|
|
594
|
+
expect(container.textContent).toContain("just the body")
|
|
595
|
+
})
|
|
596
|
+
|
|
597
|
+
// --- Gong call card ---
|
|
598
|
+
|
|
599
|
+
function gongEvent(overrides: Partial<NonNullable<TimelineEvent["gongCall"]>> = {}): TimelineEvent {
|
|
600
|
+
return minimal({
|
|
601
|
+
isInteractive: true,
|
|
602
|
+
defaultExpanded: true,
|
|
603
|
+
gongCall: {
|
|
604
|
+
title: "Discovery call with Acme",
|
|
605
|
+
startTime: "2026-06-08T15:30:00.000Z",
|
|
606
|
+
durationSeconds: 2280, // 38 min
|
|
607
|
+
direction: "Outbound",
|
|
608
|
+
outcome: "Connected",
|
|
609
|
+
brief: "Discussed Q3 expansion plans.\nBudget approved by finance.",
|
|
610
|
+
keyPoints: "- Wants SSO\n- 200 seats",
|
|
611
|
+
nextSteps: "Send proposal by Friday",
|
|
612
|
+
url: "https://app.gong.io/call?id=abc123",
|
|
613
|
+
...overrides,
|
|
614
|
+
},
|
|
615
|
+
})
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
it.each(["default", "case-panel"] as const)(
|
|
619
|
+
"renders the Gong call title, duration, and brief in the expanded %s card",
|
|
620
|
+
(variant) => {
|
|
621
|
+
const { container } = render(<TimelineActivity events={[gongEvent()]} variant={variant} />)
|
|
622
|
+
|
|
623
|
+
const card = container.querySelector('[data-slot="timeline-gong-card"]')
|
|
624
|
+
expect(card).not.toBeNull()
|
|
625
|
+
expect(container.textContent).toContain("Discovery call with Acme")
|
|
626
|
+
expect(container.querySelector('[data-slot="timeline-gong-duration"]')?.textContent).toContain("38 min")
|
|
627
|
+
expect(container.textContent).toContain("Discussed Q3 expansion plans.")
|
|
628
|
+
expect(container.textContent).toContain("Outbound")
|
|
629
|
+
expect(container.textContent).toContain("Connected")
|
|
630
|
+
},
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
it("formats long durations as hours and minutes", () => {
|
|
634
|
+
const { container } = render(
|
|
635
|
+
<TimelineActivity events={[gongEvent({ durationSeconds: 4320 })]} />, // 72 min
|
|
636
|
+
)
|
|
637
|
+
expect(container.querySelector('[data-slot="timeline-gong-duration"]')?.textContent).toContain("1 h 12 min")
|
|
638
|
+
})
|
|
639
|
+
|
|
640
|
+
it("renders Key points and Next steps sections only when present", () => {
|
|
641
|
+
const { container } = render(<TimelineActivity events={[gongEvent()]} />)
|
|
642
|
+
expect(container.querySelector('[data-slot="timeline-gong-key-points"]')).not.toBeNull()
|
|
643
|
+
expect(container.querySelector('[data-slot="timeline-gong-next-steps"]')).not.toBeNull()
|
|
644
|
+
expect(container.textContent).toContain("Key points")
|
|
645
|
+
expect(container.textContent).toContain("Wants SSO")
|
|
646
|
+
expect(container.textContent).toContain("Next steps")
|
|
647
|
+
expect(container.textContent).toContain("Send proposal by Friday")
|
|
648
|
+
})
|
|
649
|
+
|
|
650
|
+
it("omits Key points and Next steps sections when those fields are absent", () => {
|
|
651
|
+
const { container } = render(
|
|
652
|
+
<TimelineActivity events={[gongEvent({ keyPoints: null, nextSteps: undefined })]} />,
|
|
653
|
+
)
|
|
654
|
+
expect(container.querySelector('[data-slot="timeline-gong-key-points"]')).toBeNull()
|
|
655
|
+
expect(container.querySelector('[data-slot="timeline-gong-next-steps"]')).toBeNull()
|
|
656
|
+
expect(container.textContent).not.toContain("Key points")
|
|
657
|
+
expect(container.textContent).not.toContain("Next steps")
|
|
658
|
+
})
|
|
659
|
+
|
|
660
|
+
it("renders a 'View in Gong' link with href + new-tab attributes when url is present", () => {
|
|
661
|
+
render(<TimelineActivity events={[gongEvent()]} />)
|
|
662
|
+
const link = screen.getByRole("link", { name: /View in Gong/i })
|
|
663
|
+
expect(link).toHaveAttribute("href", "https://app.gong.io/call?id=abc123")
|
|
664
|
+
expect(link).toHaveAttribute("target", "_blank")
|
|
665
|
+
expect(link).toHaveAttribute("rel", "noopener noreferrer")
|
|
666
|
+
})
|
|
667
|
+
|
|
668
|
+
it("omits the 'View in Gong' link when url is absent", () => {
|
|
669
|
+
render(<TimelineActivity events={[gongEvent({ url: null })]} />)
|
|
670
|
+
expect(screen.queryByRole("link", { name: /View in Gong/i })).toBeNull()
|
|
671
|
+
})
|
|
672
|
+
|
|
673
|
+
it("keeps absent Gong fields hygienic (no null/undefined/Invalid Date, no empty labels)", () => {
|
|
674
|
+
const { container } = render(
|
|
675
|
+
<TimelineActivity
|
|
676
|
+
events={[
|
|
677
|
+
gongEvent({
|
|
678
|
+
startTime: null,
|
|
679
|
+
durationSeconds: null,
|
|
680
|
+
direction: null,
|
|
681
|
+
outcome: null,
|
|
682
|
+
brief: null,
|
|
683
|
+
keyPoints: null,
|
|
684
|
+
nextSteps: null,
|
|
685
|
+
url: null,
|
|
686
|
+
}),
|
|
687
|
+
]}
|
|
688
|
+
/>,
|
|
689
|
+
)
|
|
690
|
+
const text = container.textContent ?? ""
|
|
691
|
+
expect(text).toContain("Discovery call with Acme")
|
|
692
|
+
expect(text).not.toContain("null")
|
|
693
|
+
expect(text).not.toContain("undefined")
|
|
694
|
+
expect(text).not.toContain("Invalid Date")
|
|
695
|
+
expect(text).not.toContain("NaN")
|
|
696
|
+
expect(text).not.toContain("Key points")
|
|
697
|
+
expect(text).not.toContain("Next steps")
|
|
698
|
+
expect(container.querySelector('[data-slot="timeline-gong-duration"]')).toBeNull()
|
|
699
|
+
})
|
|
700
|
+
|
|
701
|
+
it("collapses to a duration + snippet preview and expands the Gong card", () => {
|
|
702
|
+
const event = minimal({
|
|
703
|
+
isInteractive: true,
|
|
704
|
+
defaultExpanded: false,
|
|
705
|
+
gongCall: {
|
|
706
|
+
title: "Pricing sync",
|
|
707
|
+
durationSeconds: 2280,
|
|
708
|
+
brief: "Walked through the enterprise tier.",
|
|
709
|
+
},
|
|
710
|
+
})
|
|
711
|
+
const { container } = render(<TimelineActivity events={[event]} />)
|
|
712
|
+
|
|
713
|
+
// Collapsed: shows duration + snippet, no expanded body sections.
|
|
714
|
+
expect(container.textContent).toContain("38 min")
|
|
715
|
+
expect(container.textContent).toContain("Walked through the enterprise tier.")
|
|
716
|
+
expect(screen.getByRole("button", { name: /Expand/i })).toBeTruthy()
|
|
717
|
+
|
|
718
|
+
fireEvent.click(screen.getByRole("button", { name: /Expand/i }))
|
|
719
|
+
expect(container.querySelector('[data-slot="timeline-gong-body"]')).not.toBeNull()
|
|
720
|
+
})
|
|
721
|
+
|
|
722
|
+
it.each(["default", "case-panel"] as const)(
|
|
723
|
+
"collapses the expanded Gong %s card from the top affordance",
|
|
724
|
+
(variant) => {
|
|
725
|
+
const { container } = render(<TimelineActivity events={[gongEvent()]} variant={variant} />)
|
|
726
|
+
|
|
727
|
+
const topCollapse = container.querySelector('[data-slot="timeline-collapse-top"]')!
|
|
728
|
+
expect(topCollapse).not.toBeNull()
|
|
729
|
+
|
|
730
|
+
fireEvent.click(topCollapse)
|
|
731
|
+
|
|
732
|
+
expect(container.querySelector('[data-slot="timeline-collapse-top"]')).toBeNull()
|
|
733
|
+
expect(screen.getByRole("button", { name: /Expand/i })).toBeTruthy()
|
|
734
|
+
},
|
|
735
|
+
)
|
|
736
|
+
|
|
737
|
+
it("leaves email-only events unaffected by the Gong call path", () => {
|
|
738
|
+
const event = minimal({
|
|
739
|
+
isInteractive: true,
|
|
740
|
+
defaultExpanded: true,
|
|
741
|
+
email: { from: "Priya Raman", to: "Dana Okafor", subject: "Re: hi", body: "plain body" },
|
|
742
|
+
})
|
|
743
|
+
const { container } = render(<TimelineActivity events={[event]} />)
|
|
744
|
+
expect(container.querySelector('[data-slot="timeline-gong-card"]')).toBeNull()
|
|
745
|
+
expect(container.textContent).toContain("plain body")
|
|
746
|
+
})
|
|
747
|
+
|
|
419
748
|
})
|
|
@@ -99,6 +99,15 @@ export interface ConvMessage {
|
|
|
99
99
|
receipt?: { kind: "new" | "read" | "opened" | "sent" | "draft"; label: string }
|
|
100
100
|
/** HTML body (preferred). Sanitized by the component before rendering. */
|
|
101
101
|
bodyHtml?: string
|
|
102
|
+
/**
|
|
103
|
+
* Pre-split signature HTML for this message, sanitized server-side by the
|
|
104
|
+
* sender's signature pipeline (which preserves the authored Gmail look —
|
|
105
|
+
* fonts, colors, logos). When present it renders inside the collapsed
|
|
106
|
+
* details section ("•••" toggle) instead of relying on the heuristic footer
|
|
107
|
+
* split of `bodyHtml`, so long signatures never expand the thread by
|
|
108
|
+
* default.
|
|
109
|
+
*/
|
|
110
|
+
signatureHtml?: string | null
|
|
102
111
|
/** Plain-text fallback when `bodyHtml` is absent. */
|
|
103
112
|
body?: string
|
|
104
113
|
/** Quoted prior message, collapsed behind a toggle. Sanitized before rendering. */
|
|
@@ -470,6 +479,7 @@ function MessageView({
|
|
|
470
479
|
<EmailBody
|
|
471
480
|
html={message.bodyHtml}
|
|
472
481
|
text={message.body}
|
|
482
|
+
detailsHtml={message.signatureHtml ?? undefined}
|
|
473
483
|
variant="history"
|
|
474
484
|
collapseDetails={true}
|
|
475
485
|
className="text-sm"
|