@handled-ai/design-system 0.20.29 → 0.20.30
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/account-contacts-popover.d.ts +1 -1
- package/dist/components/account-contacts-popover.js +20 -25
- package/dist/components/account-contacts-popover.js.map +1 -1
- package/dist/components/email-recipient-field.d.ts +1 -9
- package/dist/components/email-recipient-field.js +4 -30
- package/dist/components/email-recipient-field.js.map +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/suggested-actions.d.ts +1 -2
- package/dist/components/suggested-actions.js.map +1 -1
- package/dist/components/timeline-activity.js +41 -51
- package/dist/components/timeline-activity.js.map +1 -1
- package/dist/index.d.ts +1 -1
- 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 +10 -3
- package/dist/prototype/prototype-inbox-view.js +10 -4
- 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-BJHd07dU.d.ts → signal-priority-popover-DIUVhipw.d.ts} +17 -1
- package/package.json +1 -1
- package/src/components/__tests__/account-contacts-popover.test.tsx +0 -71
- package/src/components/__tests__/email-recipient-field.test.tsx +0 -123
- package/src/components/__tests__/timeline-activity.test.tsx +0 -16
- package/src/components/account-contacts-popover.tsx +19 -33
- package/src/components/email-recipient-field.tsx +0 -45
- package/src/components/suggested-actions.tsx +3 -4
- package/src/components/timeline-activity.tsx +1 -7
- package/src/prototype/__tests__/detail-view-timeline-system-events.test.tsx +81 -0
- package/src/prototype/prototype-config.ts +17 -0
- package/src/prototype/prototype-inbox-view.tsx +16 -1
|
@@ -513,127 +513,4 @@ describe("EmailRecipientField", () => {
|
|
|
513
513
|
// trigger another search.
|
|
514
514
|
expect(secondOnSearch).not.toHaveBeenCalled()
|
|
515
515
|
})
|
|
516
|
-
|
|
517
|
-
// --- Last activity (WIT-1007) ---
|
|
518
|
-
|
|
519
|
-
const activityContact: SuggestedContact = {
|
|
520
|
-
name: "Cara Customer",
|
|
521
|
-
role: "VP Ops",
|
|
522
|
-
email: "cara@example.com",
|
|
523
|
-
confirmed: true,
|
|
524
|
-
lastActivity: { date: "Jun 8, 2026", type: "email", timelineEventId: "evt-1" },
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
it("renders last activity as non-clickable text when no onOpenRecentActivity callback is provided", () => {
|
|
528
|
-
render(
|
|
529
|
-
<EmailRecipientField
|
|
530
|
-
label="To"
|
|
531
|
-
recipients={[]}
|
|
532
|
-
onRecipientsChange={vi.fn()}
|
|
533
|
-
showPicker
|
|
534
|
-
contacts={[activityContact]}
|
|
535
|
-
/>,
|
|
536
|
-
)
|
|
537
|
-
|
|
538
|
-
fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
|
|
539
|
-
expect(screen.getByText("Jun 8, 2026")).toBeTruthy()
|
|
540
|
-
// No clickable activity button exists.
|
|
541
|
-
expect(screen.queryByRole("button", { name: /Last activity/i })).toBeNull()
|
|
542
|
-
})
|
|
543
|
-
|
|
544
|
-
it("renders last activity as non-clickable text when timelineEventId is missing even with a callback", () => {
|
|
545
|
-
const onOpenRecentActivity = vi.fn()
|
|
546
|
-
render(
|
|
547
|
-
<EmailRecipientField
|
|
548
|
-
label="To"
|
|
549
|
-
recipients={[]}
|
|
550
|
-
onRecipientsChange={vi.fn()}
|
|
551
|
-
showPicker
|
|
552
|
-
contacts={[
|
|
553
|
-
{
|
|
554
|
-
...activityContact,
|
|
555
|
-
lastActivity: { date: "Jun 8, 2026", type: "email" },
|
|
556
|
-
},
|
|
557
|
-
]}
|
|
558
|
-
onOpenRecentActivity={onOpenRecentActivity}
|
|
559
|
-
/>,
|
|
560
|
-
)
|
|
561
|
-
|
|
562
|
-
fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
|
|
563
|
-
expect(screen.getByText("Jun 8, 2026")).toBeTruthy()
|
|
564
|
-
expect(screen.queryByRole("button", { name: /Last activity/i })).toBeNull()
|
|
565
|
-
})
|
|
566
|
-
|
|
567
|
-
it("renders last activity as a clickable button only when callback and timelineEventId both exist", () => {
|
|
568
|
-
const onOpenRecentActivity = vi.fn()
|
|
569
|
-
render(
|
|
570
|
-
<EmailRecipientField
|
|
571
|
-
label="To"
|
|
572
|
-
recipients={[]}
|
|
573
|
-
onRecipientsChange={vi.fn()}
|
|
574
|
-
showPicker
|
|
575
|
-
contacts={[activityContact]}
|
|
576
|
-
onOpenRecentActivity={onOpenRecentActivity}
|
|
577
|
-
/>,
|
|
578
|
-
)
|
|
579
|
-
|
|
580
|
-
fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
|
|
581
|
-
const activityButton = screen.getByRole("button", { name: /Last activity/i })
|
|
582
|
-
expect(activityButton.tagName).toBe("BUTTON")
|
|
583
|
-
})
|
|
584
|
-
|
|
585
|
-
it("invokes onOpenRecentActivity with the contact and does NOT add a recipient on activity click", () => {
|
|
586
|
-
const onOpenRecentActivity = vi.fn()
|
|
587
|
-
const onChange = vi.fn()
|
|
588
|
-
render(
|
|
589
|
-
<EmailRecipientField
|
|
590
|
-
label="To"
|
|
591
|
-
recipients={[]}
|
|
592
|
-
onRecipientsChange={onChange}
|
|
593
|
-
showPicker
|
|
594
|
-
contacts={[activityContact]}
|
|
595
|
-
onOpenRecentActivity={onOpenRecentActivity}
|
|
596
|
-
/>,
|
|
597
|
-
)
|
|
598
|
-
|
|
599
|
-
fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
|
|
600
|
-
fireEvent.click(screen.getByRole("button", { name: /Last activity/i }))
|
|
601
|
-
|
|
602
|
-
expect(onOpenRecentActivity).toHaveBeenCalledTimes(1)
|
|
603
|
-
expect(onOpenRecentActivity).toHaveBeenCalledWith(activityContact)
|
|
604
|
-
// Clicking the activity must not select/add the recipient.
|
|
605
|
-
expect(onChange).not.toHaveBeenCalled()
|
|
606
|
-
})
|
|
607
|
-
|
|
608
|
-
it("keeps the last-activity button clickable for already-added contacts without adding the recipient", () => {
|
|
609
|
-
const onOpenRecentActivity = vi.fn()
|
|
610
|
-
const onChange = vi.fn()
|
|
611
|
-
render(
|
|
612
|
-
<EmailRecipientField
|
|
613
|
-
label="To"
|
|
614
|
-
recipients={[]}
|
|
615
|
-
onRecipientsChange={onChange}
|
|
616
|
-
showPicker
|
|
617
|
-
contacts={[activityContact]}
|
|
618
|
-
addedEmails={new Set([activityContact.email!.toLowerCase()])}
|
|
619
|
-
onOpenRecentActivity={onOpenRecentActivity}
|
|
620
|
-
/>,
|
|
621
|
-
)
|
|
622
|
-
|
|
623
|
-
fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
|
|
624
|
-
|
|
625
|
-
// The row is disabled (already added) but its activity button stays clickable.
|
|
626
|
-
const option = screen
|
|
627
|
-
.getAllByRole("option")
|
|
628
|
-
.find((o) => o.textContent?.includes("Cara Customer"))!
|
|
629
|
-
expect(option.className).toContain("pointer-events-none")
|
|
630
|
-
const activityButton = within(option).getByRole("button", { name: /Last activity/i })
|
|
631
|
-
expect(activityButton.className).toContain("pointer-events-auto")
|
|
632
|
-
|
|
633
|
-
fireEvent.click(activityButton)
|
|
634
|
-
|
|
635
|
-
expect(onOpenRecentActivity).toHaveBeenCalledTimes(1)
|
|
636
|
-
expect(onOpenRecentActivity).toHaveBeenCalledWith(activityContact)
|
|
637
|
-
expect(onChange).not.toHaveBeenCalled()
|
|
638
|
-
})
|
|
639
516
|
})
|
|
@@ -44,22 +44,6 @@ describe("TimelineActivity", () => {
|
|
|
44
44
|
expect(container.firstElementChild).toHaveAttribute("data-variant", "default")
|
|
45
45
|
})
|
|
46
46
|
|
|
47
|
-
it("renders each timeline item wrapper with scroll/focus anchors and test ids", () => {
|
|
48
|
-
const events = [minimal({ id: "evt-a" }), minimal({ id: "evt-b" })]
|
|
49
|
-
const { container } = render(<TimelineActivity events={events} />)
|
|
50
|
-
|
|
51
|
-
for (const id of ["evt-a", "evt-b"]) {
|
|
52
|
-
const wrapper = container.querySelector(`#timeline-event-${id}`)
|
|
53
|
-
expect(wrapper).not.toBeNull()
|
|
54
|
-
expect(wrapper).toHaveAttribute("data-testid", `timeline-event-${id}`)
|
|
55
|
-
expect(wrapper).toHaveAttribute("data-activity-id", id)
|
|
56
|
-
expect(wrapper).toHaveAttribute("tabindex", "-1")
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// The test id is also reachable via screen for click-through targeting.
|
|
60
|
-
expect(screen.getByTestId("timeline-event-evt-a")).toBeTruthy()
|
|
61
|
-
})
|
|
62
|
-
|
|
63
47
|
it("marks the root and interactive cards with the case-panel variant", () => {
|
|
64
48
|
const event = minimal({
|
|
65
49
|
isInteractive: true,
|
|
@@ -5,7 +5,6 @@ import {
|
|
|
5
5
|
Clock,
|
|
6
6
|
ExternalLink,
|
|
7
7
|
} from "lucide-react"
|
|
8
|
-
import { cn } from "../lib/utils"
|
|
9
8
|
import type { SuggestedContact, SuggestedActionsIconMap } from "./suggested-actions"
|
|
10
9
|
|
|
11
10
|
// ---------------------------------------------------------------------------
|
|
@@ -38,7 +37,7 @@ export interface AccountContactsPopoverProps {
|
|
|
38
37
|
/** Optional replacement-selection callback. When provided, row clicks call this instead of additive onSelect/onSelectTo. */
|
|
39
38
|
onSelectSwitch?: (contact: SuggestedContact) => void
|
|
40
39
|
onViewAll?: () => void
|
|
41
|
-
onOpenRecentActivity?: (
|
|
40
|
+
onOpenRecentActivity?: () => void
|
|
42
41
|
trigger: React.ReactNode
|
|
43
42
|
iconMap?: SuggestedActionsIconMap
|
|
44
43
|
}
|
|
@@ -118,37 +117,24 @@ export function AccountContactsPopover({
|
|
|
118
117
|
<div className="truncate text-xs text-muted-foreground leading-tight">
|
|
119
118
|
{c.role} · {c.email ?? c.emails?.[0] ?? c.phone ?? c.phones?.[0] ?? ""}
|
|
120
119
|
</div>
|
|
121
|
-
{c.lastActivity && (
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
"
|
|
134
|
-
|
|
135
|
-
<
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
onOpenRecentActivity(c)
|
|
140
|
-
setOpen(false)
|
|
141
|
-
}}
|
|
142
|
-
className={cn(chipBaseClass, "hover:text-foreground hover:bg-muted/50 transition-colors")}
|
|
143
|
-
>
|
|
144
|
-
{activityContent}
|
|
145
|
-
</button>
|
|
146
|
-
) : (
|
|
147
|
-
<div className={chipBaseClass}>
|
|
148
|
-
{activityContent}
|
|
149
|
-
</div>
|
|
150
|
-
)
|
|
151
|
-
})()}
|
|
120
|
+
{c.lastActivity && (
|
|
121
|
+
<button
|
|
122
|
+
type="button"
|
|
123
|
+
onClick={(e) => {
|
|
124
|
+
e.stopPropagation()
|
|
125
|
+
onOpenRecentActivity?.()
|
|
126
|
+
setOpen(false)
|
|
127
|
+
}}
|
|
128
|
+
className="mt-1.5 flex max-w-full items-center gap-1.5 overflow-hidden rounded-md border border-border/70 bg-muted/30 px-2 py-1 text-[11px] text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
|
|
129
|
+
>
|
|
130
|
+
<Clock className="w-3 h-3 shrink-0" />
|
|
131
|
+
<span className="shrink-0 font-medium">Last activity</span>
|
|
132
|
+
<span className="shrink-0 text-muted-foreground/60">·</span>
|
|
133
|
+
<span className="shrink-0">{c.lastActivity.date}</span>
|
|
134
|
+
<span className="shrink-0 text-muted-foreground/60">·</span>
|
|
135
|
+
<span className="truncate capitalize">{c.lastActivity.type}</span>
|
|
136
|
+
</button>
|
|
137
|
+
)}
|
|
152
138
|
</div>
|
|
153
139
|
<div className="ml-2 flex items-center gap-1.5 shrink-0">
|
|
154
140
|
{resolvedDefaultSelectLabel && (
|
|
@@ -4,7 +4,6 @@ import * as React from "react"
|
|
|
4
4
|
import { Popover as PopoverPrimitive } from "radix-ui"
|
|
5
5
|
import {
|
|
6
6
|
ChevronDown,
|
|
7
|
-
Clock,
|
|
8
7
|
CornerDownLeft,
|
|
9
8
|
Plus,
|
|
10
9
|
Search,
|
|
@@ -64,14 +63,6 @@ export interface EmailRecipientFieldProps {
|
|
|
64
63
|
onSearch?: (query: string) => void
|
|
65
64
|
/** Shows a "Searching contacts..." indicator while async results load. */
|
|
66
65
|
searchLoading?: boolean
|
|
67
|
-
/**
|
|
68
|
-
* Opens the recent activity for a contact in the Account Panel timeline.
|
|
69
|
-
* When provided AND the contact's `lastActivity.timelineEventId` exists, the
|
|
70
|
-
* last-activity line renders as a button that calls this callback (and stops
|
|
71
|
-
* propagation so it never adds/selects the recipient). Otherwise the line
|
|
72
|
-
* renders as non-clickable text.
|
|
73
|
-
*/
|
|
74
|
-
onOpenRecentActivity?: (contact: SuggestedContact) => void
|
|
75
66
|
}
|
|
76
67
|
|
|
77
68
|
function RecipientChipPill({
|
|
@@ -155,7 +146,6 @@ function ContactPickerContents({
|
|
|
155
146
|
onAddEmail,
|
|
156
147
|
onSearch,
|
|
157
148
|
searchLoading = false,
|
|
158
|
-
onOpenRecentActivity,
|
|
159
149
|
}: {
|
|
160
150
|
contacts: SuggestedContact[]
|
|
161
151
|
addedEmails: Set<string>
|
|
@@ -163,7 +153,6 @@ function ContactPickerContents({
|
|
|
163
153
|
onAddEmail: (email: string) => void
|
|
164
154
|
onSearch?: (query: string) => void
|
|
165
155
|
searchLoading?: boolean
|
|
166
|
-
onOpenRecentActivity?: (contact: SuggestedContact) => void
|
|
167
156
|
}) {
|
|
168
157
|
const [query, setQuery] = React.useState("")
|
|
169
158
|
|
|
@@ -290,38 +279,6 @@ function ContactPickerContents({
|
|
|
290
279
|
{email}
|
|
291
280
|
</div>
|
|
292
281
|
) : null}
|
|
293
|
-
{contact.lastActivity ? (() => {
|
|
294
|
-
const activityContent = (
|
|
295
|
-
<>
|
|
296
|
-
<Clock className="size-3 shrink-0" />
|
|
297
|
-
<span className="shrink-0">Last activity</span>
|
|
298
|
-
<span className="shrink-0 text-muted-foreground/60">·</span>
|
|
299
|
-
<span className="shrink-0">{contact.lastActivity.date}</span>
|
|
300
|
-
<span className="shrink-0 text-muted-foreground/60">·</span>
|
|
301
|
-
<span className="truncate capitalize">{contact.lastActivity.type}</span>
|
|
302
|
-
</>
|
|
303
|
-
)
|
|
304
|
-
return onOpenRecentActivity && contact.lastActivity.timelineEventId ? (
|
|
305
|
-
<button
|
|
306
|
-
type="button"
|
|
307
|
-
onClick={(event) => {
|
|
308
|
-
event.stopPropagation()
|
|
309
|
-
onOpenRecentActivity(contact)
|
|
310
|
-
}}
|
|
311
|
-
// `pointer-events-auto` keeps the activity button clickable even
|
|
312
|
-
// when the parent row is disabled (no email / already added) and
|
|
313
|
-
// gets `pointer-events-none` — the timeline activity is still
|
|
314
|
-
// openable for those contacts.
|
|
315
|
-
className="mt-1 flex max-w-full items-center gap-1 overflow-hidden text-[11px] text-muted-foreground hover:text-foreground transition-colors pointer-events-auto"
|
|
316
|
-
>
|
|
317
|
-
{activityContent}
|
|
318
|
-
</button>
|
|
319
|
-
) : (
|
|
320
|
-
<div className="mt-1 flex max-w-full items-center gap-1 overflow-hidden text-[11px] text-muted-foreground">
|
|
321
|
-
{activityContent}
|
|
322
|
-
</div>
|
|
323
|
-
)
|
|
324
|
-
})() : null}
|
|
325
282
|
</div>
|
|
326
283
|
{alreadyAdded ? (
|
|
327
284
|
<span className="shrink-0 text-[10.5px] font-medium text-muted-foreground">
|
|
@@ -361,7 +318,6 @@ export function EmailRecipientField({
|
|
|
361
318
|
contactToRecipient,
|
|
362
319
|
onSearch,
|
|
363
320
|
searchLoading,
|
|
364
|
-
onOpenRecentActivity,
|
|
365
321
|
}: EmailRecipientFieldProps) {
|
|
366
322
|
const [value, setValue] = React.useState("")
|
|
367
323
|
const [pickerOpen, setPickerOpen] = React.useState(false)
|
|
@@ -509,7 +465,6 @@ export function EmailRecipientField({
|
|
|
509
465
|
}}
|
|
510
466
|
onSearch={onSearch}
|
|
511
467
|
searchLoading={searchLoading}
|
|
512
|
-
onOpenRecentActivity={onOpenRecentActivity}
|
|
513
468
|
/>
|
|
514
469
|
</PopoverPrimitive.Content>
|
|
515
470
|
</PopoverPrimitive.Portal>
|
|
@@ -84,7 +84,6 @@ export interface SuggestedContact {
|
|
|
84
84
|
lastActivity?: {
|
|
85
85
|
date: string
|
|
86
86
|
type: string
|
|
87
|
-
timelineEventId?: string
|
|
88
87
|
}
|
|
89
88
|
}
|
|
90
89
|
|
|
@@ -548,7 +547,7 @@ function EmailHeader({
|
|
|
548
547
|
onBccAdd?: (contact: SuggestedContact) => void
|
|
549
548
|
onBccRemove?: (index: number) => void
|
|
550
549
|
onOpenAccountDetails?: () => void
|
|
551
|
-
onOpenRecentActivity?: (
|
|
550
|
+
onOpenRecentActivity?: () => void
|
|
552
551
|
iconMap?: SuggestedActionsIconMap
|
|
553
552
|
showSubject?: boolean
|
|
554
553
|
accountDetailsLabel?: string
|
|
@@ -911,7 +910,7 @@ function SuggestedActionCard({
|
|
|
911
910
|
signature?: string | React.ReactNode
|
|
912
911
|
onDuplicate?: (id: number | string) => void
|
|
913
912
|
onOpenAccountDetails?: () => void
|
|
914
|
-
onOpenRecentActivity?: (
|
|
913
|
+
onOpenRecentActivity?: () => void
|
|
915
914
|
onMarkComplete?: (id: number | string) => void
|
|
916
915
|
onDispatchAgent?: (id: number | string, editedContent?: string, settings?: { aiDisclosureEnabled?: boolean; maxDurationMinutes?: string; callRecordingEnabled?: boolean; recordingNoticeEnabled?: boolean }) => void
|
|
917
916
|
onFeedback?: (type: "up" | "down", pills: string[], detail: string) => void
|
|
@@ -1572,7 +1571,7 @@ export interface SuggestedActionsProps {
|
|
|
1572
1571
|
signature?: string | React.ReactNode
|
|
1573
1572
|
onDuplicate?: (id: number | string) => void
|
|
1574
1573
|
onOpenAccountDetails?: () => void
|
|
1575
|
-
onOpenRecentActivity?: (
|
|
1574
|
+
onOpenRecentActivity?: () => void
|
|
1576
1575
|
onMarkComplete?: (id: number | string) => void
|
|
1577
1576
|
onDispatchAgent?: (id: number | string, editedContent?: string, settings?: { aiDisclosureEnabled?: boolean; maxDurationMinutes?: string; callRecordingEnabled?: boolean; recordingNoticeEnabled?: boolean }) => void
|
|
1578
1577
|
iconMap?: SuggestedActionsIconMap
|
|
@@ -298,13 +298,7 @@ function TimelineItem({
|
|
|
298
298
|
const iconClasses = toneStyle ? toneStyle.icon : NEUTRAL_ICON_CLASSES
|
|
299
299
|
|
|
300
300
|
return (
|
|
301
|
-
<div
|
|
302
|
-
id={`timeline-event-${event.id}`}
|
|
303
|
-
data-testid={`timeline-event-${event.id}`}
|
|
304
|
-
data-activity-id={event.id}
|
|
305
|
-
tabIndex={-1}
|
|
306
|
-
className={classes.outerRowGap}
|
|
307
|
-
>
|
|
301
|
+
<div className={classes.outerRowGap}>
|
|
308
302
|
{!isLast && (
|
|
309
303
|
<div className={classes.connector} />
|
|
310
304
|
)}
|
|
@@ -475,4 +475,85 @@ describe("DetailView timeline system-events toggle", () => {
|
|
|
475
475
|
expect(hint).toBeNull()
|
|
476
476
|
expect(toggle).toHaveAttribute("title", "Legacy hidden hint.")
|
|
477
477
|
})
|
|
478
|
+
|
|
479
|
+
// --- renderTimelineHeaderControls slot (WIT-1008 Task 1) ---
|
|
480
|
+
|
|
481
|
+
it("renders custom timeline header controls without interfering with system-event ownership (case-panel)", () => {
|
|
482
|
+
const props = baseProps({
|
|
483
|
+
sectionLayout: "case-panel-v2",
|
|
484
|
+
renderTimelineHeaderControls: ({ visibleCount, hiddenCount }) => (
|
|
485
|
+
<button type="button" data-testid="custom-filter">
|
|
486
|
+
Custom Filter ({visibleCount}/{hiddenCount})
|
|
487
|
+
</button>
|
|
488
|
+
),
|
|
489
|
+
})
|
|
490
|
+
const { container, getByTestId } = render(<DetailView {...props} />)
|
|
491
|
+
|
|
492
|
+
// Custom node renders in the timeline header.
|
|
493
|
+
const header = container.querySelector('[data-testid="timeline-header"]') as HTMLElement
|
|
494
|
+
const custom = getByTestId("custom-filter")
|
|
495
|
+
expect(custom).not.toBeNull()
|
|
496
|
+
expect(header.contains(custom)).toBe(true)
|
|
497
|
+
expect(custom.textContent).toContain("Custom Filter")
|
|
498
|
+
|
|
499
|
+
// System-noise events remain hidden by default.
|
|
500
|
+
expandTimeline(container)
|
|
501
|
+
const eventCount = container.querySelector('[data-testid="event-count"]')
|
|
502
|
+
expect(eventCount?.textContent).toBe("2 events")
|
|
503
|
+
expect(container.textContent).not.toContain("Score updated +3")
|
|
504
|
+
expect(container.textContent).not.toContain("Score updated -1")
|
|
505
|
+
|
|
506
|
+
// They only appear after clicking the existing system-events toggle.
|
|
507
|
+
const toggle = container.querySelector('[data-testid="system-events-toggle"]') as HTMLElement
|
|
508
|
+
fireEvent.click(toggle)
|
|
509
|
+
expect(container.textContent).toContain("Score updated +3")
|
|
510
|
+
expect(container.querySelector('[data-testid="event-count"]')?.textContent).toBe("4 events")
|
|
511
|
+
})
|
|
512
|
+
|
|
513
|
+
it("renders custom timeline header controls in the default layout, before the system-events toggle, without owning system-event state", () => {
|
|
514
|
+
// Capture the args the render prop receives across renders.
|
|
515
|
+
const seenArgs: Array<{ visibleCount: number; hiddenCount: number; showSystemEvents: boolean }> = []
|
|
516
|
+
const props = baseProps({
|
|
517
|
+
// default layout (no sectionLayout="case-panel-v2")
|
|
518
|
+
renderTimelineHeaderControls: (args) => {
|
|
519
|
+
seenArgs.push(args)
|
|
520
|
+
return (
|
|
521
|
+
<button type="button" data-testid="custom-filter">
|
|
522
|
+
Custom Filter
|
|
523
|
+
</button>
|
|
524
|
+
)
|
|
525
|
+
},
|
|
526
|
+
})
|
|
527
|
+
const { container, getByTestId } = render(<DetailView {...props} />)
|
|
528
|
+
|
|
529
|
+
const header = container.querySelector('[data-testid="timeline-header"]') as HTMLElement
|
|
530
|
+
const custom = getByTestId("custom-filter")
|
|
531
|
+
const toggle = container.querySelector('[data-testid="system-events-toggle"]') as HTMLElement
|
|
532
|
+
|
|
533
|
+
// Custom node renders inside the timeline header.
|
|
534
|
+
expect(header.contains(custom)).toBe(true)
|
|
535
|
+
|
|
536
|
+
// Custom control appears BEFORE the system-events toggle in DOM order.
|
|
537
|
+
const order = custom.compareDocumentPosition(toggle)
|
|
538
|
+
expect(order & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
|
539
|
+
|
|
540
|
+
// Expand the timeline; system-noise events are hidden by default.
|
|
541
|
+
expandTimeline(container)
|
|
542
|
+
expect(container.textContent).toContain("Email sent")
|
|
543
|
+
expect(container.textContent).not.toContain("Score updated +3")
|
|
544
|
+
|
|
545
|
+
// Clicking the custom control does NOT collapse/expand the timeline and
|
|
546
|
+
// does NOT change system-event visibility.
|
|
547
|
+
fireEvent.click(custom)
|
|
548
|
+
expect(container.textContent).toContain("Email sent")
|
|
549
|
+
expect(toggle).toHaveAttribute("aria-pressed", "false")
|
|
550
|
+
expect(container.textContent).not.toContain("Score updated +3")
|
|
551
|
+
|
|
552
|
+
// The showSystemEvents arg flips to true after the built-in toggle is clicked.
|
|
553
|
+
expect(seenArgs.at(-1)?.showSystemEvents).toBe(false)
|
|
554
|
+
fireEvent.click(toggle)
|
|
555
|
+
expect(toggle).toHaveAttribute("aria-pressed", "true")
|
|
556
|
+
expect(container.textContent).toContain("Score updated +3")
|
|
557
|
+
expect(seenArgs.at(-1)?.showSystemEvents).toBe(true)
|
|
558
|
+
})
|
|
478
559
|
})
|
|
@@ -161,6 +161,18 @@ export interface InboxSortOption {
|
|
|
161
161
|
/** Controls the visual prominence of the signal brief section. */
|
|
162
162
|
export type BriefStyleVariant = "default" | "prominent"
|
|
163
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Render consumer-owned controls (e.g. activity-type filters) in the right
|
|
166
|
+
* side of the activity timeline header, before the built-in system-events
|
|
167
|
+
* toggle. Receives the current visible/hidden counts and system-events state
|
|
168
|
+
* so the consumer can label its own controls.
|
|
169
|
+
*/
|
|
170
|
+
export type RenderTimelineHeaderControls = (args: {
|
|
171
|
+
visibleCount: number
|
|
172
|
+
hiddenCount: number
|
|
173
|
+
showSystemEvents: boolean
|
|
174
|
+
}) => React.ReactNode
|
|
175
|
+
|
|
164
176
|
export interface InboxDetailSections {
|
|
165
177
|
signalBrief?: boolean
|
|
166
178
|
suggestedActions?: boolean
|
|
@@ -242,6 +254,11 @@ export interface InboxViewConfig {
|
|
|
242
254
|
timelineSystemEventsConfig?: TimelineSystemEventsConfig
|
|
243
255
|
/** Number of important/attention-worthy events to highlight on the collapsed timeline header. */
|
|
244
256
|
attentionCount?: number
|
|
257
|
+
/**
|
|
258
|
+
* Render consumer-owned controls (e.g. activity-type filters) in the right
|
|
259
|
+
* side of the activity timeline header, before the built-in system-events toggle.
|
|
260
|
+
*/
|
|
261
|
+
renderTimelineHeaderControls?: RenderTimelineHeaderControls
|
|
245
262
|
/** Render extra content inline with the detail title. */
|
|
246
263
|
renderTitleExtra?: (item: QueueItem) => React.ReactNode
|
|
247
264
|
/** Render a full-width action row below the detail title row. */
|
|
@@ -56,6 +56,7 @@ import type {
|
|
|
56
56
|
SignalScoreData,
|
|
57
57
|
BriefStyleVariant,
|
|
58
58
|
TimelineSystemEventsConfig,
|
|
59
|
+
RenderTimelineHeaderControls,
|
|
59
60
|
} from "./prototype-config"
|
|
60
61
|
|
|
61
62
|
// ---------------------------------------------------------------------------
|
|
@@ -195,6 +196,13 @@ export interface DetailViewProps {
|
|
|
195
196
|
attentionCount?: number
|
|
196
197
|
/** Configuration for the system-noise events toggle (score changes, etc.). */
|
|
197
198
|
timelineSystemEventsConfig?: TimelineSystemEventsConfig
|
|
199
|
+
/**
|
|
200
|
+
* Render consumer-owned controls (e.g. activity-type filters) in the right
|
|
201
|
+
* side of the activity timeline header, before the built-in system-events
|
|
202
|
+
* toggle. Receives the current visible/hidden counts and system-events state
|
|
203
|
+
* so the consumer can label its own controls. Behavior is unchanged when omitted.
|
|
204
|
+
*/
|
|
205
|
+
renderTimelineHeaderControls?: RenderTimelineHeaderControls
|
|
198
206
|
|
|
199
207
|
// ── Deprecated individual props (use timelineSystemEventsConfig instead) ──
|
|
200
208
|
/** @deprecated Use `timelineSystemEventsConfig.toggleLabel`. */
|
|
@@ -223,6 +231,7 @@ function TimelineSection({
|
|
|
223
231
|
sysEvtConfig,
|
|
224
232
|
lastActivityTime,
|
|
225
233
|
isCasePanel = false,
|
|
234
|
+
renderTimelineHeaderControls,
|
|
226
235
|
}: {
|
|
227
236
|
timelineEvents: TimelineEvent[]
|
|
228
237
|
showTimeline: boolean
|
|
@@ -233,6 +242,7 @@ function TimelineSection({
|
|
|
233
242
|
sysEvtConfig?: TimelineSystemEventsConfig
|
|
234
243
|
lastActivityTime?: string
|
|
235
244
|
isCasePanel?: boolean
|
|
245
|
+
renderTimelineHeaderControls?: RenderTimelineHeaderControls
|
|
236
246
|
}) {
|
|
237
247
|
// Single-pass partition: compute visibleEvents and hiddenCount together
|
|
238
248
|
const visibleEvents: TimelineEvent[] = []
|
|
@@ -302,8 +312,9 @@ function TimelineSection({
|
|
|
302
312
|
)}
|
|
303
313
|
</button>
|
|
304
314
|
|
|
305
|
-
{/* Right: system-events toggle, event count, and collapse affordance */}
|
|
315
|
+
{/* Right: consumer controls, system-events toggle, event count, and collapse affordance */}
|
|
306
316
|
<div className="flex shrink-0 items-center gap-4">
|
|
317
|
+
{renderTimelineHeaderControls?.({ visibleCount, hiddenCount, showSystemEvents })}
|
|
307
318
|
{hasSystemNoise && (
|
|
308
319
|
<button
|
|
309
320
|
type="button"
|
|
@@ -420,6 +431,7 @@ export function DetailView({
|
|
|
420
431
|
onRequestApproval,
|
|
421
432
|
attentionCount,
|
|
422
433
|
timelineSystemEventsConfig: configProp,
|
|
434
|
+
renderTimelineHeaderControls,
|
|
423
435
|
timelineSystemEventsToggleLabel,
|
|
424
436
|
timelineSystemEventsStorageKey,
|
|
425
437
|
timelineSystemEventsDefaultVisible,
|
|
@@ -628,6 +640,7 @@ export function DetailView({
|
|
|
628
640
|
sysEvtConfig={sysEvtConfig}
|
|
629
641
|
lastActivityTime={lastActivityTime}
|
|
630
642
|
isCasePanel={isCasePanelV2}
|
|
643
|
+
renderTimelineHeaderControls={renderTimelineHeaderControls}
|
|
631
644
|
/>
|
|
632
645
|
) : null
|
|
633
646
|
|
|
@@ -862,6 +875,7 @@ export function PrototypeInboxView({
|
|
|
862
875
|
sortOptions,
|
|
863
876
|
activeSortId,
|
|
864
877
|
onSortChange,
|
|
878
|
+
renderTimelineHeaderControls,
|
|
865
879
|
}: PrototypeInboxViewProps) {
|
|
866
880
|
const [inboxViewMode, setInboxViewMode] = React.useState<"inbox" | "list" | "detail">(
|
|
867
881
|
defaultViewMode === "list" ? "list" : defaultViewMode === "split" ? "inbox" : "inbox"
|
|
@@ -1108,6 +1122,7 @@ export function PrototypeInboxView({
|
|
|
1108
1122
|
accountDetailsButtonLabel,
|
|
1109
1123
|
getAccountDetailsButtonAriaLabel,
|
|
1110
1124
|
onOpenSignalBucket,
|
|
1125
|
+
renderTimelineHeaderControls,
|
|
1111
1126
|
}
|
|
1112
1127
|
|
|
1113
1128
|
return (
|