@handled-ai/design-system 0.20.1 → 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.
- package/dist/components/conversation-panel.d.ts +1 -1
- package/dist/components/conversation-panel.js +282 -15
- package/dist/components/conversation-panel.js.map +1 -1
- package/dist/components/owner-chips.d.ts +3 -4
- package/dist/components/owner-chips.js +77 -41
- package/dist/components/owner-chips.js.map +1 -1
- package/dist/components/timeline-activity.d.ts +4 -2
- package/dist/components/timeline-activity.js +366 -154
- package/dist/components/timeline-activity.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/prototype/prototype-inbox-view.js +10 -3
- package/dist/prototype/prototype-inbox-view.js.map +1 -1
- package/package.json +1 -1
- package/src/components/__tests__/conversation-panel.test.tsx +276 -0
- package/src/components/__tests__/owner-chips.test.tsx +137 -17
- package/src/components/__tests__/timeline-activity.test.tsx +92 -1
- package/src/components/conversation-panel.tsx +358 -21
- package/src/components/owner-chips.tsx +98 -63
- package/src/components/timeline-activity.tsx +452 -160
- package/src/prototype/__tests__/detail-view-timeline-system-events.test.tsx +16 -2
- package/src/prototype/prototype-inbox-view.tsx +14 -3
|
@@ -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 = {
|
|
12
|
-
|
|
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
|
|
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 ->
|
|
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("
|
|
63
|
-
expect(
|
|
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
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
expect(
|
|
73
|
-
expect(
|
|
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("
|
|
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
|
-
|
|
92
|
-
|
|
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
|
})
|