@handled-ai/design-system 0.20.0 → 0.20.2

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.
Files changed (33) hide show
  1. package/dist/components/conversation-panel.d.ts +1 -1
  2. package/dist/components/conversation-panel.js +282 -15
  3. package/dist/components/conversation-panel.js.map +1 -1
  4. package/dist/components/owner-chips.d.ts +3 -4
  5. package/dist/components/owner-chips.js +77 -41
  6. package/dist/components/owner-chips.js.map +1 -1
  7. package/dist/components/score-why-chips.d.ts +1 -1
  8. package/dist/components/signal-priority-popover.d.ts +1 -1
  9. package/dist/components/timeline-activity.d.ts +4 -2
  10. package/dist/components/timeline-activity.js +366 -154
  11. package/dist/components/timeline-activity.js.map +1 -1
  12. package/dist/index.d.ts +2 -2
  13. package/dist/prototype/index.d.ts +1 -1
  14. package/dist/prototype/prototype-accounts-view.d.ts +1 -1
  15. package/dist/prototype/prototype-admin-view.d.ts +1 -1
  16. package/dist/prototype/prototype-config.d.ts +1 -1
  17. package/dist/prototype/prototype-inbox-view.d.ts +9 -3
  18. package/dist/prototype/prototype-inbox-view.js +94 -47
  19. package/dist/prototype/prototype-inbox-view.js.map +1 -1
  20. package/dist/prototype/prototype-insights-view.d.ts +1 -1
  21. package/dist/prototype/prototype-shell.d.ts +1 -1
  22. package/dist/{signal-priority-popover-Cg9XPJsp.d.ts → signal-priority-popover-BJHd07dU.d.ts} +6 -0
  23. package/package.json +1 -1
  24. package/src/components/__tests__/conversation-panel.test.tsx +276 -0
  25. package/src/components/__tests__/owner-chips.test.tsx +137 -17
  26. package/src/components/__tests__/timeline-activity.test.tsx +92 -1
  27. package/src/components/conversation-panel.tsx +358 -21
  28. package/src/components/owner-chips.tsx +98 -63
  29. package/src/components/timeline-activity.tsx +452 -160
  30. package/src/prototype/__tests__/detail-view-case-panel-v2.test.tsx +181 -0
  31. package/src/prototype/__tests__/detail-view-timeline-system-events.test.tsx +16 -2
  32. package/src/prototype/prototype-config.ts +6 -0
  33. package/src/prototype/prototype-inbox-view.tsx +128 -51
@@ -128,6 +128,258 @@ describe("ConversationPanel", () => {
128
128
  expect(quoted.outerHTML).not.toContain("data:text/html");
129
129
  });
130
130
 
131
+
132
+ it("collapses trailing HTML signature and disclaimer behind a details toggle", () => {
133
+ const htmlThread = thread({
134
+ messages: [
135
+ {
136
+ id: "m1",
137
+ direction: "inbound",
138
+ from: priya,
139
+ to: me,
140
+ date: "Today",
141
+ bodyHtml:
142
+ "<p>Hi Dana,</p><p>The updated invoice is attached.</p><p>Thanks,</p><p>Priya Raman</p><p>VP Sales, Northwind</p><p>Confidentiality Notice: this message is private.</p>",
143
+ },
144
+ ],
145
+ });
146
+
147
+ const { container } = render(<ConversationPanel threads={[htmlThread]} me={me} />);
148
+
149
+ const message = container.querySelector('[data-slot="conv-message"]')!;
150
+ expect(message.querySelector('[data-slot="conv-message-body"]')?.textContent).toContain("The updated invoice is attached.");
151
+ expect(message.querySelector('[data-slot="conv-message-details"]')).toBeNull();
152
+ expect(message.querySelector('[data-slot="conv-message-body"]')?.textContent).not.toContain("Confidentiality Notice");
153
+
154
+ fireEvent.click(screen.getByText("Show signature/details"));
155
+
156
+ expect(screen.getByText("Hide signature/details")).toBeDefined();
157
+ expect(message.querySelector('[data-slot="conv-message-details"]')?.textContent).toContain("Priya Raman");
158
+ expect(message.querySelector('[data-slot="conv-message-details"]')?.textContent).toContain("Confidentiality Notice");
159
+ });
160
+
161
+ it("collapses trailing plain-text signatures while preserving line boundaries", () => {
162
+ const textThread = thread({
163
+ messages: [
164
+ {
165
+ id: "m1",
166
+ direction: "inbound",
167
+ from: priya,
168
+ to: me,
169
+ date: "Today",
170
+ body: "Hi Dana,\n\nI can meet tomorrow at 2pm.\n\nBest,\nPriya Raman\nVP Sales, Northwind\npriya@northwind.io",
171
+ },
172
+ ],
173
+ });
174
+
175
+ const { container } = render(<ConversationPanel threads={[textThread]} me={me} />);
176
+
177
+ const message = container.querySelector('[data-slot="conv-message"]')!;
178
+ expect(message.querySelector('[data-slot="conv-message-body"]')?.textContent).toContain("I can meet tomorrow at 2pm.");
179
+ expect(message.querySelector('[data-slot="conv-message-details"]')).toBeNull();
180
+
181
+ fireEvent.click(screen.getByText("Show signature/details"));
182
+
183
+ const details = message.querySelector('[data-slot="conv-message-details"]')!;
184
+ expect(details.textContent).toContain("Best,");
185
+ expect(details.textContent).toContain("VP Sales, Northwind");
186
+ expect(message.querySelector('[data-slot="conv-message-body"]')?.textContent?.endsWith("2pm.\n\n")).toBe(true);
187
+ expect(details.textContent?.startsWith("Best,")).toBe(true);
188
+ });
189
+
190
+ it("keeps body copy that starts with thanks visible when it is not a footer boundary", () => {
191
+ const thanksThread = thread({
192
+ messages: [
193
+ {
194
+ id: "m1",
195
+ direction: "inbound",
196
+ from: priya,
197
+ to: me,
198
+ date: "Today",
199
+ body: "Thanks for sending this over. I reviewed the doc and the numbers look right.",
200
+ },
201
+ ],
202
+ });
203
+
204
+ const { container } = render(<ConversationPanel threads={[thanksThread]} me={me} />);
205
+ const message = container.querySelector('[data-slot="conv-message"]')!;
206
+
207
+ expect(message.querySelector('[data-slot="conv-message-body"]')?.textContent).toContain("Thanks for sending this over");
208
+ expect(screen.queryByText("Show signature/details")).toBeNull();
209
+ });
210
+
211
+ it("keeps trailing thanks comma sentence body visible", () => {
212
+ const thanksCommaThread = thread({
213
+ messages: [
214
+ {
215
+ id: "m1",
216
+ direction: "inbound",
217
+ from: priya,
218
+ to: me,
219
+ date: "Today",
220
+ body: "I reviewed the options.\n\nThanks, this helps us decide next steps.",
221
+ },
222
+ ],
223
+ });
224
+
225
+ const { container } = render(<ConversationPanel threads={[thanksCommaThread]} me={me} />);
226
+ const message = container.querySelector('[data-slot="conv-message"]')!;
227
+
228
+ expect(message.querySelector('[data-slot="conv-message-body"]')?.textContent).toContain("Thanks, this helps us decide next steps.");
229
+ expect(screen.queryByText("Show signature/details")).toBeNull();
230
+ });
231
+
232
+ it("keeps trailing thank-you comma sentence body visible", () => {
233
+ const thankYouCommaThread = thread({
234
+ messages: [
235
+ {
236
+ id: "m1",
237
+ direction: "inbound",
238
+ from: priya,
239
+ to: me,
240
+ date: "Today",
241
+ bodyHtml: "<p>I reviewed the renewal notes.</p><p>Thank you, that clarifies the renewal plan.</p>",
242
+ },
243
+ ],
244
+ });
245
+
246
+ const { container } = render(<ConversationPanel threads={[thankYouCommaThread]} me={me} />);
247
+ const message = container.querySelector('[data-slot="conv-message"]')!;
248
+
249
+ expect(message.querySelector('[data-slot="conv-message-body"]')?.textContent).toContain("Thank you, that clarifies the renewal plan.");
250
+ expect(screen.queryByText("Show signature/details")).toBeNull();
251
+ });
252
+
253
+ it("keeps command-like double-dash plain-text lines visible", () => {
254
+ const flagsThread = thread({
255
+ messages: [
256
+ {
257
+ id: "m1",
258
+ direction: "inbound",
259
+ from: priya,
260
+ to: me,
261
+ date: "Today",
262
+ body: "Please use these flags:\n-- retry failed imports\n-- skip archived records",
263
+ },
264
+ ],
265
+ });
266
+
267
+ const { container } = render(<ConversationPanel threads={[flagsThread]} me={me} />);
268
+ const message = container.querySelector('[data-slot="conv-message"]')!;
269
+
270
+ expect(message.querySelector('[data-slot="conv-message-body"]')?.textContent).toContain("-- retry failed imports");
271
+ expect(message.querySelector('[data-slot="conv-message-body"]')?.textContent).toContain("-- skip archived records");
272
+ expect(screen.queryByText("Show signature/details")).toBeNull();
273
+ });
274
+
275
+ it("keeps command-like double-dash HTML blocks visible", () => {
276
+ const htmlFlagsThread = thread({
277
+ messages: [
278
+ {
279
+ id: "m1",
280
+ direction: "inbound",
281
+ from: priya,
282
+ to: me,
283
+ date: "Today",
284
+ bodyHtml: "<p>Please use these flags:</p><p>-- retry failed imports</p><p>-- skip archived records</p>",
285
+ },
286
+ ],
287
+ });
288
+
289
+ const { container } = render(<ConversationPanel threads={[htmlFlagsThread]} me={me} />);
290
+ const message = container.querySelector('[data-slot="conv-message"]')!;
291
+
292
+ expect(message.querySelector('[data-slot="conv-message-body"]')?.textContent).toContain("-- retry failed imports");
293
+ expect(message.querySelector('[data-slot="conv-message-body"]')?.textContent).toContain("-- skip archived records");
294
+ expect(screen.queryByText("Show signature/details")).toBeNull();
295
+ });
296
+
297
+ it("collapses standard double-dash signature separator with sender details", () => {
298
+ const separatorThread = thread({
299
+ messages: [
300
+ {
301
+ id: "m1",
302
+ direction: "inbound",
303
+ from: priya,
304
+ to: me,
305
+ date: "Today",
306
+ body: "The import plan looks good.\n\n--\nPriya Raman\nVP Sales, Northwind\npriya@northwind.io",
307
+ },
308
+ ],
309
+ });
310
+
311
+ const { container } = render(<ConversationPanel threads={[separatorThread]} me={me} />);
312
+ const message = container.querySelector('[data-slot="conv-message"]')!;
313
+
314
+ expect(message.querySelector('[data-slot="conv-message-body"]')?.textContent).toContain("The import plan looks good.");
315
+ expect(message.querySelector('[data-slot="conv-message-body"]')?.textContent).not.toContain("Priya Raman");
316
+
317
+ fireEvent.click(screen.getByText("Show signature/details"));
318
+
319
+ expect(message.querySelector('[data-slot="conv-message-details"]')?.textContent).toContain("--");
320
+ expect(message.querySelector('[data-slot="conv-message-details"]')?.textContent).toContain("Priya Raman");
321
+ });
322
+
323
+ it("does not split raw HTML into invalid partial tags when collapsing details", () => {
324
+ const rawHtmlThread = thread({
325
+ messages: [
326
+ {
327
+ id: "m1",
328
+ direction: "inbound",
329
+ from: priya,
330
+ to: me,
331
+ date: "Today",
332
+ bodyHtml:
333
+ '<div><p>Please review <a href="https://example.com/report"><strong>the full report</strong></a> before Friday.</p><p>Regards,</p><p>Priya Raman</p><p>VP Sales, Northwind</p></div>',
334
+ },
335
+ ],
336
+ });
337
+
338
+ const { container } = render(<ConversationPanel threads={[rawHtmlThread]} me={me} />);
339
+
340
+ const link = container.querySelector('a[href="https://example.com/report"]');
341
+ expect(link?.textContent).toBe("the full report");
342
+ expect(container.querySelector('[data-slot="conv-message-details"]')).toBeNull();
343
+
344
+ const message = container.querySelector('[data-slot="conv-message"]')!;
345
+ expect(message.querySelector('[data-slot="conv-message-body"]')?.innerHTML).toContain("<strong>the full report</strong>");
346
+ expect(message.querySelector('[data-slot="conv-message-body"]')?.innerHTML).not.toContain("</a></strong>");
347
+
348
+ fireEvent.click(screen.getByText("Show signature/details"));
349
+ expect(container.querySelector('[data-slot="conv-message-details"]')?.textContent).toContain("Priya Raman");
350
+ expect(container.querySelector('[data-slot="conv-message"]')?.querySelectorAll("p").length).toBeGreaterThanOrEqual(4);
351
+ });
352
+
353
+ it("keeps quoted history hidden behind its own toggle when signature details expand", () => {
354
+ const quotedThread = thread({
355
+ messages: [
356
+ {
357
+ id: "m1",
358
+ direction: "inbound",
359
+ from: priya,
360
+ to: me,
361
+ date: "Today",
362
+ bodyHtml:
363
+ "<p>The revised timeline works for us.</p><p>Thank you,</p><p>Priya Raman</p><p>VP Sales, Northwind</p>",
364
+ quoted: {
365
+ attr: "On Monday, Dana wrote:",
366
+ html: '<blockquote class="gmail_quote"><p>Prior hidden message</p></blockquote>',
367
+ },
368
+ },
369
+ ],
370
+ });
371
+
372
+ render(<ConversationPanel threads={[quotedThread]} me={me} />);
373
+
374
+ fireEvent.click(screen.getByText("Show signature/details"));
375
+
376
+ expect(document.querySelector('[data-slot="conv-message-details"]')?.textContent).toContain("Priya Raman");
377
+ expect(screen.queryByText("Prior hidden message")).toBeNull();
378
+
379
+ fireEvent.click(screen.getByTitle("Show quoted text"));
380
+ expect(screen.getByText("Prior hidden message")).toBeDefined();
381
+ });
382
+
131
383
  it("keeps the reply composer open and shows an error when async send fails", async () => {
132
384
  const onSendReply = vi.fn().mockRejectedValue(new Error("Gmail send failed"));
133
385
  render(<ConversationPanel threads={[thread({ draft: "Sounds good" })]} me={me} onSendReply={onSendReply} />);
@@ -152,6 +404,30 @@ describe("ConversationPanel", () => {
152
404
  await waitFor(() => expect(screen.getByText(/Reply sent/i)).toBeDefined());
153
405
  });
154
406
 
407
+ it("keeps the reply composer open and avoids draft-saved state when async draft creation fails", async () => {
408
+ const onCreateGmailDraft = vi.fn().mockRejectedValue(new Error("Gmail draft failed"));
409
+ render(<ConversationPanel threads={[thread({ draft: "Sounds good" })]} me={me} onCreateGmailDraft={onCreateGmailDraft} />);
410
+
411
+ fireEvent.click(screen.getByText("Reply"));
412
+ fireEvent.click(screen.getByText("Send"));
413
+ fireEvent.click(screen.getByText("Open draft in Gmail"));
414
+
415
+ await waitFor(() => expect(screen.getByRole("alert").textContent).toContain("Gmail draft failed"));
416
+ expect(screen.getByPlaceholderText("Write your reply…")).toBeDefined();
417
+ expect(screen.queryByText(/Draft saved to Gmail/i)).toBeNull();
418
+ });
419
+
420
+ it("shows draft-saved state only after async draft creation succeeds", async () => {
421
+ const onCreateGmailDraft = vi.fn().mockResolvedValue(undefined);
422
+ render(<ConversationPanel threads={[thread({ draft: "Sounds good" })]} me={me} onCreateGmailDraft={onCreateGmailDraft} />);
423
+
424
+ fireEvent.click(screen.getByText("Reply"));
425
+ fireEvent.click(screen.getByText("Send"));
426
+ fireEvent.click(screen.getByText("Open draft in Gmail"));
427
+
428
+ await waitFor(() => expect(screen.getByText(/Draft saved to Gmail/i)).toBeDefined());
429
+ });
430
+
155
431
  it("offers a Reply affordance for participant threads", () => {
156
432
  render(<ConversationPanel threads={[thread()]} me={me} />);
157
433
  expect(screen.getByText("Reply")).toBeDefined();
@@ -1,6 +1,58 @@
1
- import { describe, it, expect } from "vitest";
1
+ import { afterEach, describe, it, expect, vi } from "vitest";
2
2
  import React from "react";
3
- import { render, screen } from "@testing-library/react";
3
+ import { cleanup, render, screen, within } from "@testing-library/react";
4
+ /**
5
+ * Radix DropdownMenu content is portalled and unreliable to open with clicks in
6
+ * happy-dom. Mock the local dropdown wrappers in this file so account-owner
7
+ * tests can assert the read-only menu rows without adding test-only public API
8
+ * to the published component props.
9
+ */
10
+ vi.mock("../dropdown-menu", async () => {
11
+ const ReactMod = await import("react");
12
+
13
+ /* eslint-disable @typescript-eslint/no-explicit-any */
14
+ const DropdownMenu = ({ children }: any) =>
15
+ ReactMod.createElement("div", { "data-slot": "dropdown-menu" }, children);
16
+ const DropdownMenuTrigger = ({ children, asChild, ...props }: any) => {
17
+ if (asChild && ReactMod.isValidElement(children)) {
18
+ return ReactMod.cloneElement(children, props as any);
19
+ }
20
+ return ReactMod.createElement("button", { "data-slot": "dropdown-menu-trigger", ...props }, children);
21
+ };
22
+ const DropdownMenuContent = ({ children, ...props }: any) =>
23
+ ReactMod.createElement("div", { "data-slot": "dropdown-menu-content", ...props }, children);
24
+ const DropdownMenuItem = ({ children, onSelect, asChild, disabled, ...props }: any) => {
25
+ const itemProps = {
26
+ "data-slot": "dropdown-menu-item",
27
+ "aria-disabled": disabled ? "true" : undefined,
28
+ "data-disabled": disabled ? "" : undefined,
29
+ role: "menuitem",
30
+ onClick: onSelect,
31
+ ...props,
32
+ };
33
+
34
+ if (asChild && ReactMod.isValidElement(children)) {
35
+ return ReactMod.cloneElement(children, { ...itemProps, "data-as-child": "true" } as any);
36
+ }
37
+
38
+ return ReactMod.createElement("div", itemProps, children);
39
+ };
40
+ const DropdownMenuLabel = ({ children, ...props }: any) =>
41
+ ReactMod.createElement("div", { "data-slot": "dropdown-menu-label", ...props }, children);
42
+ const DropdownMenuSeparator = (props: any) =>
43
+ ReactMod.createElement("div", { "data-slot": "dropdown-menu-separator", ...props });
44
+ /* eslint-enable @typescript-eslint/no-explicit-any */
45
+
46
+ return {
47
+ DropdownMenu,
48
+ DropdownMenuTrigger,
49
+ DropdownMenuContent,
50
+ DropdownMenuItem,
51
+ DropdownMenuLabel,
52
+ DropdownMenuSeparator,
53
+ };
54
+ });
55
+
4
56
  import {
5
57
  SignalOwnerChip,
6
58
  AccountOwnerChip,
@@ -8,8 +60,20 @@ import {
8
60
  type OwnerPerson,
9
61
  } from "../owner-chips";
10
62
 
11
- const dana: OwnerPerson = { id: "u1", name: "Dana Okafor", role: "Senior RM" };
12
- const marcus: OwnerPerson = { id: "u2", name: "Marcus Lee", role: "Treasury" };
63
+ const dana: OwnerPerson = {
64
+ id: "u1",
65
+ name: "Dana Okafor",
66
+ role: "Senior RM",
67
+ email: "dana.okafor@example.com",
68
+ };
69
+ const marcus: OwnerPerson = {
70
+ id: "u2",
71
+ name: "Marcus Lee",
72
+ role: "Treasury",
73
+ email: "marcus.lee@example.com",
74
+ };
75
+
76
+ afterEach(() => cleanup());
13
77
 
14
78
  describe("SignalOwnerChip", () => {
15
79
  it("renders data-slot and the 'Signal owner' label", () => {
@@ -25,9 +89,9 @@ describe("SignalOwnerChip", () => {
25
89
  expect(screen.getByText("Unassigned")).toBeDefined();
26
90
  });
27
91
 
28
- it("shows the owner's first name when assigned", () => {
92
+ it("shows the owner's full name when assigned", () => {
29
93
  render(<SignalOwnerChip owner={dana} />);
30
- expect(screen.getByText("Dana")).toBeDefined();
94
+ expect(screen.getByText("Dana Okafor")).toBeDefined();
31
95
  });
32
96
 
33
97
  it("renders a static span (no button) when read-only / no handlers", () => {
@@ -54,32 +118,86 @@ describe("AccountOwnerChip", () => {
54
118
  expect(container.querySelector('[data-slot="account-owner-chip"]')).toBeNull();
55
119
  });
56
120
 
57
- it("single owner -> static chip, no dropdown (no data-multi)", () => {
121
+ it("single owner -> button dropdown trigger, no direct anchor and no data-multi", () => {
58
122
  const { container } = render(<AccountOwnerChip owners={[dana]} />);
59
123
  const el = container.querySelector('[data-slot="account-owner-chip"]');
60
124
  expect(el).not.toBeNull();
61
125
  expect(el?.getAttribute("data-multi")).toBeNull();
62
- expect(el?.tagName.toLowerCase()).toBe("span");
63
- expect(screen.getByText("Dana")).toBeDefined();
126
+ expect(el?.tagName.toLowerCase()).toBe("button");
127
+ expect(el?.closest("a")).toBeNull();
128
+ expect(within(el as HTMLElement).getByText("Dana Okafor")).toBeDefined();
64
129
  });
65
130
 
66
- it("single owner with href -> renders an external link", () => {
131
+ it("single owner menu shows role, email, helper text, and Salesforce link only with href", () => {
67
132
  const { container } = render(
68
133
  <AccountOwnerChip owners={[{ ...dana, href: "https://sf.example/u1" }]} />,
69
134
  );
70
- const el = container.querySelector('[data-slot="account-owner-chip"]') as HTMLAnchorElement;
71
- expect(el.tagName.toLowerCase()).toBe("a");
72
- expect(el.getAttribute("href")).toBe("https://sf.example/u1");
73
- expect(el.getAttribute("rel")).toContain("noopener");
135
+
136
+ const trigger = container.querySelector('[data-slot="account-owner-chip"]');
137
+ expect(trigger?.tagName.toLowerCase()).toBe("button");
138
+ expect(trigger?.closest("a")).toBeNull();
139
+
140
+ const row = document.querySelector('[data-owner-row="true"]');
141
+ expect(row).not.toBeNull();
142
+ expect(row?.getAttribute("data-slot")).toBe("dropdown-menu-item");
143
+ expect(row?.getAttribute("aria-disabled")).toBe("true");
144
+ expect(within(row as HTMLElement).getByText("Dana Okafor")).toBeDefined();
145
+ expect(within(row as HTMLElement).getByText("Senior RM")).toBeDefined();
146
+ expect(within(row as HTMLElement).getByText("dana.okafor@example.com")).toBeDefined();
147
+ expect(screen.getByText("Read-only from Salesforce. Manage owners in Salesforce.")).toBeDefined();
148
+
149
+ const links = document.querySelectorAll('[data-account-owner-salesforce-link="true"]');
150
+ expect(links).toHaveLength(1);
151
+ expect(links[0].getAttribute("data-slot")).toBe("dropdown-menu-item");
152
+ expect(links[0].getAttribute("data-as-child")).toBe("true");
153
+ expect(links[0].textContent).toContain("Open in Salesforce");
154
+ expect(links[0].getAttribute("href")).toBe("https://sf.example/u1");
74
155
  });
75
156
 
76
- it("multiple owners -> a button with a ×N badge and data-multi", () => {
157
+ it("single owner menu omits the Salesforce link when href is missing", () => {
158
+ render(<AccountOwnerChip owners={[dana]} />);
159
+ expect(document.querySelector('[data-account-owner-salesforce-link="true"]')).toBeNull();
160
+ expect(screen.queryByText("Open in Salesforce")).toBeNull();
161
+ });
162
+
163
+ it("multiple owners -> one-row button trigger with a ×N badge and data-multi", () => {
77
164
  const { container } = render(<AccountOwnerChip owners={[dana, marcus]} />);
78
165
  const el = container.querySelector('[data-slot="account-owner-chip"]');
79
166
  expect(el?.getAttribute("data-multi")).toBe("true");
80
167
  expect(el?.tagName.toLowerCase()).toBe("button");
168
+ expect(screen.getByText("Account owners")).toBeDefined();
81
169
  expect(screen.getByText("×2")).toBeDefined();
82
170
  });
171
+
172
+ it("multi-owner menu rows show role/email and Salesforce links only for owners with href", () => {
173
+ render(
174
+ <AccountOwnerChip
175
+ owners={[
176
+ { ...dana, href: "https://sf.example/u1" },
177
+ marcus,
178
+ ]}
179
+ />,
180
+ );
181
+
182
+ const rows = document.querySelectorAll('[data-owner-row="true"]');
183
+ expect(rows).toHaveLength(2);
184
+ expect(rows[0].getAttribute("data-slot")).toBe("dropdown-menu-item");
185
+ expect(rows[0].getAttribute("aria-disabled")).toBe("true");
186
+ expect(rows[1].getAttribute("data-slot")).toBe("dropdown-menu-item");
187
+ expect(rows[1].getAttribute("aria-disabled")).toBe("true");
188
+ expect(within(rows[0] as HTMLElement).getByText("Dana Okafor")).toBeDefined();
189
+ expect(within(rows[0] as HTMLElement).getByText("Senior RM")).toBeDefined();
190
+ expect(within(rows[0] as HTMLElement).getByText("dana.okafor@example.com")).toBeDefined();
191
+ expect(within(rows[1] as HTMLElement).getByText("Marcus Lee")).toBeDefined();
192
+ expect(within(rows[1] as HTMLElement).getByText("Treasury")).toBeDefined();
193
+ expect(within(rows[1] as HTMLElement).getByText("marcus.lee@example.com")).toBeDefined();
194
+
195
+ const links = document.querySelectorAll('[data-account-owner-salesforce-link="true"]');
196
+ expect(links).toHaveLength(1);
197
+ expect(links[0].getAttribute("data-slot")).toBe("dropdown-menu-item");
198
+ expect(links[0].getAttribute("data-as-child")).toBe("true");
199
+ expect(links[0].getAttribute("href")).toBe("https://sf.example/u1");
200
+ });
83
201
  });
84
202
 
85
203
 
@@ -88,8 +206,10 @@ describe("OwnerChips", () => {
88
206
  const { container } = render(<OwnerChips owner={dana} accountOwners={[marcus]} />);
89
207
  expect(container.querySelector('[data-slot="signal-owner-chip"]')).not.toBeNull();
90
208
  expect(container.querySelector('[data-slot="account-owner-chip"]')).not.toBeNull();
91
- expect(screen.getByText("Dana")).toBeDefined();
92
- expect(screen.getByText("Marcus")).toBeDefined();
209
+ const signalChip = container.querySelector('[data-slot="signal-owner-chip"]');
210
+ const accountChip = container.querySelector('[data-slot="account-owner-chip"]');
211
+ expect(within(signalChip as HTMLElement).getByText("Dana Okafor")).toBeDefined();
212
+ expect(within(accountChip as HTMLElement).getByText("Marcus Lee")).toBeDefined();
93
213
  });
94
214
 
95
215
  it("allows accountOwners to be omitted", () => {
@@ -1,3 +1,4 @@
1
+ import "@testing-library/jest-dom/vitest"
1
2
  import { describe, it, expect } from "vitest"
2
3
  import React from "react"
3
4
  import { render, screen } from "@testing-library/react"
@@ -26,6 +27,80 @@ function minimal(overrides: Partial<TimelineEvent> = {}): TimelineEvent {
26
27
  // ---------------------------------------------------------------------------
27
28
 
28
29
  describe("TimelineActivity", () => {
30
+ it("marks the root with the default variant by default", () => {
31
+ const { container } = render(<TimelineActivity events={[minimal()]} />)
32
+
33
+ expect(container.firstElementChild).toHaveAttribute("data-variant", "default")
34
+ })
35
+
36
+ it("marks the root and interactive cards with the case-panel variant", () => {
37
+ const event = minimal({
38
+ isInteractive: true,
39
+ defaultExpanded: true,
40
+ email: {
41
+ from: "Priya Raman",
42
+ to: "Dana Okafor",
43
+ subject: "Re: hi",
44
+ body: "plain fallback",
45
+ },
46
+ })
47
+
48
+ const { container } = render(<TimelineActivity events={[event]} variant="case-panel" />)
49
+ const root = container.firstElementChild
50
+ const card = root?.querySelector('[data-variant="case-panel"]')
51
+
52
+ expect(root).toHaveAttribute("data-variant", "case-panel")
53
+ expect(card).not.toBeNull()
54
+ expect(card?.querySelector('[data-slot="timeline-card-header"]')).not.toBeNull()
55
+ expect(card?.querySelector('[data-slot="timeline-card-body"]')).not.toBeNull()
56
+ expect(card?.querySelector('[data-slot="timeline-card-footer"]')).not.toBeNull()
57
+ })
58
+
59
+ it("preserves the default expanded email card single-container layout", () => {
60
+ const event = minimal({
61
+ isInteractive: true,
62
+ defaultExpanded: true,
63
+ email: {
64
+ from: "Priya Raman",
65
+ to: "Dana Okafor",
66
+ subject: "Re: hi",
67
+ body: "plain fallback",
68
+ bodyHtml: '<p>Hello <a href="https://example.com">link</a></p>',
69
+ },
70
+ })
71
+
72
+ const { container } = render(<TimelineActivity events={[event]} />)
73
+ const root = container.firstElementChild
74
+ const card = root?.querySelector('[data-variant="default"]')
75
+
76
+ expect(root).toHaveAttribute("data-variant", "default")
77
+ expect(card).not.toBeNull()
78
+ expect(card?.querySelector('[data-slot="timeline-card-header"]')).toBeNull()
79
+ expect(card?.querySelector('[data-slot="timeline-card-body"]')).toBeNull()
80
+ expect(card?.querySelector('[data-slot="timeline-card-footer"]')).toBeNull()
81
+ expect(card?.querySelector('[data-slot="timeline-email-html"]')).not.toBeNull()
82
+ })
83
+
84
+ it("preserves the default expanded content card single-container layout", () => {
85
+ const event = minimal({
86
+ isInteractive: true,
87
+ defaultExpanded: true,
88
+ content: "Salesforce case details",
89
+ source: { label: "Salesforce", url: "https://salesforce.example/case/1" },
90
+ })
91
+
92
+ const { container } = render(<TimelineActivity events={[event]} />)
93
+ const card = container.firstElementChild?.querySelector('[data-variant="default"]')
94
+
95
+ expect(card).not.toBeNull()
96
+ expect(card?.querySelector('[data-slot="timeline-card-body"]')).toBeNull()
97
+ expect(card?.querySelector('[data-slot="timeline-card-footer"]')).toBeNull()
98
+ expect(screen.getByRole("link", { name: /Open in Salesforce/i })).toHaveAttribute(
99
+ "href",
100
+ "https://salesforce.example/case/1",
101
+ )
102
+ })
103
+
29
104
  // --- Tone rendering ---
30
105
 
31
106
  it("renders red dot classes when tone is 'red'", () => {
@@ -165,7 +240,7 @@ describe("TimelineActivity", () => {
165
240
  '<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
241
  },
167
242
  })
168
- const { container } = render(<TimelineActivity events={[event]} />)
243
+ const { container } = render(<TimelineActivity events={[event]} variant="case-panel" />)
169
244
  const html = container.querySelector('[data-slot="timeline-email-html"]')!
170
245
  expect(html.innerHTML).not.toContain("script")
171
246
  expect(html.innerHTML).not.toContain("onclick")
@@ -204,4 +279,20 @@ describe("TimelineActivity", () => {
204
279
  expect(container.querySelector('[data-slot="timeline-email-html"]')).toBeNull()
205
280
  expect(screen.getByText("just text")).toBeDefined()
206
281
  })
282
+
283
+ it("formats source labels as Open in Salesforce", () => {
284
+ const event = minimal({
285
+ isInteractive: true,
286
+ defaultExpanded: true,
287
+ content: "Salesforce case details",
288
+ source: { label: "Salesforce", url: "https://salesforce.example/case/1" },
289
+ })
290
+
291
+ render(<TimelineActivity events={[event]} variant="case-panel" />)
292
+
293
+ expect(screen.getByRole("link", { name: /Open in Salesforce/i })).toHaveAttribute(
294
+ "href",
295
+ "https://salesforce.example/case/1",
296
+ )
297
+ })
207
298
  })