@handled-ai/design-system 0.18.58 → 0.19.0-rc.1
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/case-panel-activity-timeline.d.ts +2 -0
- package/dist/components/case-panel-activity-timeline.js +22 -1
- package/dist/components/case-panel-activity-timeline.js.map +1 -1
- package/dist/components/comment-composer.d.ts +29 -0
- package/dist/components/comment-composer.js +102 -0
- package/dist/components/comment-composer.js.map +1 -0
- package/dist/components/conversation-panel.d.ts +95 -0
- package/dist/components/conversation-panel.js +636 -0
- package/dist/components/conversation-panel.js.map +1 -0
- package/dist/components/detail-view.js +1 -1
- package/dist/components/detail-view.js.map +1 -1
- package/dist/components/owner-chips.d.ts +59 -0
- package/dist/components/owner-chips.js +256 -0
- package/dist/components/owner-chips.js.map +1 -0
- package/dist/components/pill.d.ts +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/signal-priority-popover.js +16 -7
- package/dist/components/signal-priority-popover.js.map +1 -1
- package/dist/components/tabs.d.ts +1 -1
- package/dist/components/timeline-activity.d.ts +7 -0
- package/dist/components/timeline-activity.js +22 -1
- package/dist/components/timeline-activity.js.map +1 -1
- package/dist/components/virtualized-data-table.js +4 -4
- package/dist/components/virtualized-data-table.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/internal/safe-html.d.ts +11 -0
- package/dist/internal/safe-html.js +222 -0
- package/dist/internal/safe-html.js.map +1 -0
- 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 +1 -1
- package/dist/prototype/prototype-inbox-view.js +2 -0
- 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-QJngMAj7.d.ts → signal-priority-popover-CZitE9xq.d.ts} +11 -2
- package/package.json +1 -1
- package/src/components/__tests__/comment-composer.test.tsx +57 -0
- package/src/components/__tests__/conversation-panel.test.tsx +157 -0
- package/src/components/__tests__/owner-chips.test.tsx +100 -0
- package/src/components/__tests__/signal-priority-popover.test.tsx +41 -4
- package/src/components/__tests__/timeline-activity.test.tsx +55 -0
- package/src/components/__tests__/virtualized-data-table-resize.test.tsx +18 -0
- package/src/components/case-panel-activity-timeline.tsx +20 -0
- package/src/components/comment-composer.tsx +119 -0
- package/src/components/conversation-panel.tsx +790 -0
- package/src/components/detail-view.tsx +3 -1
- package/src/components/owner-chips.tsx +335 -0
- package/src/components/signal-priority-popover.tsx +19 -6
- package/src/components/timeline-activity.tsx +37 -3
- package/src/components/virtualized-data-table.tsx +4 -4
- package/src/index.ts +4 -1
- package/src/internal/__tests__/safe-html.test.ts +53 -0
- package/src/internal/safe-html.ts +284 -0
- package/src/prototype/__tests__/detail-view-score-why.test.tsx +34 -0
- package/src/prototype/prototype-config.ts +5 -1
- package/src/prototype/prototype-inbox-view.tsx +2 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
|
4
|
+
import {
|
|
5
|
+
ConversationPanel,
|
|
6
|
+
type ConversationThread,
|
|
7
|
+
type ConvParticipant,
|
|
8
|
+
} from "../conversation-panel";
|
|
9
|
+
|
|
10
|
+
const me: ConvParticipant = { name: "Dana Okafor", email: "dana@handled.ai" };
|
|
11
|
+
const priya: ConvParticipant = { name: "Priya Raman", email: "priya@northwind.io" };
|
|
12
|
+
|
|
13
|
+
function thread(overrides: Partial<ConversationThread> = {}): ConversationThread {
|
|
14
|
+
return {
|
|
15
|
+
threadId: "t1",
|
|
16
|
+
subject: "Want to connect?",
|
|
17
|
+
status: "responded",
|
|
18
|
+
lastWhen: "2h ago",
|
|
19
|
+
contact: priya,
|
|
20
|
+
messages: [
|
|
21
|
+
{
|
|
22
|
+
id: "m1",
|
|
23
|
+
direction: "outbound",
|
|
24
|
+
from: me,
|
|
25
|
+
to: priya,
|
|
26
|
+
date: "Jun 1",
|
|
27
|
+
body: "Hi Priya, can we talk?",
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: "m2",
|
|
31
|
+
direction: "inbound",
|
|
32
|
+
from: priya,
|
|
33
|
+
to: me,
|
|
34
|
+
date: "Today",
|
|
35
|
+
bodyHtml: '<p>Sure, see the <a href="https://ex.io/x">doc</a>.</p>',
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
...overrides,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("ConversationPanel", () => {
|
|
43
|
+
it("renders nothing with no threads", () => {
|
|
44
|
+
const { container } = render(<ConversationPanel threads={[]} />);
|
|
45
|
+
expect(container.querySelector('[data-slot="conversation-panel"]')).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("shows the 'Email response detected' badge when a thread has responded", () => {
|
|
49
|
+
render(<ConversationPanel threads={[thread()]} me={me} />);
|
|
50
|
+
expect(screen.getByText("Email response detected")).toBeDefined();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("shows the 'Awaiting response' badge when nothing has responded", () => {
|
|
54
|
+
render(<ConversationPanel threads={[thread({ status: "awaiting" })]} me={me} />);
|
|
55
|
+
expect(screen.getByText("Awaiting response")).toBeDefined();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("auto-opens the first responded thread and renders its newest message as HTML", () => {
|
|
59
|
+
const { container } = render(<ConversationPanel threads={[thread()]} me={me} />);
|
|
60
|
+
// newest (inbound) message expanded -> its anchor is in the DOM
|
|
61
|
+
const link = container.querySelector('a[href="https://ex.io/x"]');
|
|
62
|
+
expect(link).not.toBeNull();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
it("sanitizes HTML message bodies and quoted history before rendering", () => {
|
|
67
|
+
const unsafe = thread({
|
|
68
|
+
messages: [
|
|
69
|
+
{
|
|
70
|
+
id: "m1",
|
|
71
|
+
direction: "inbound",
|
|
72
|
+
from: priya,
|
|
73
|
+
to: me,
|
|
74
|
+
date: "Today",
|
|
75
|
+
bodyHtml:
|
|
76
|
+
'<p onclick="alert(1)">Hello <a href="javascript:alert(1)">bad</a><script>alert(1)</script><img src="https://example.com/x.png" onerror="alert(1)"></p>',
|
|
77
|
+
quoted: {
|
|
78
|
+
attr: '<span onclick="alert(1)">On Tuesday</span>',
|
|
79
|
+
html: '<blockquote class="gmail_quote" style="color:red"><a href="data:text/html,boom">quoted</a></blockquote>',
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const { container } = render(<ConversationPanel threads={[unsafe]} me={me} />);
|
|
86
|
+
const message = container.querySelector('[data-slot="conv-message"]')!;
|
|
87
|
+
expect(message.innerHTML).not.toContain("script");
|
|
88
|
+
expect(message.innerHTML).not.toContain("onclick");
|
|
89
|
+
expect(message.innerHTML).not.toContain("onerror");
|
|
90
|
+
expect(message.innerHTML).not.toContain("javascript:");
|
|
91
|
+
expect(message.querySelector("img")?.getAttribute("src")).toBe("https://example.com/x.png");
|
|
92
|
+
|
|
93
|
+
fireEvent.click(screen.getByTitle("Show quoted text"));
|
|
94
|
+
const quoted = container.querySelector("blockquote.gmail_quote")!;
|
|
95
|
+
expect(quoted).not.toBeNull();
|
|
96
|
+
expect(quoted.outerHTML).not.toContain("style=");
|
|
97
|
+
expect(quoted.outerHTML).not.toContain("data:text/html");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("keeps the reply composer open and shows an error when async send fails", async () => {
|
|
101
|
+
const onSendReply = vi.fn().mockRejectedValue(new Error("Gmail send failed"));
|
|
102
|
+
render(<ConversationPanel threads={[thread({ draft: "Sounds good" })]} me={me} onSendReply={onSendReply} />);
|
|
103
|
+
|
|
104
|
+
fireEvent.click(screen.getByText("Reply"));
|
|
105
|
+
fireEvent.click(screen.getByText("Send"));
|
|
106
|
+
fireEvent.click(screen.getByText("Send now"));
|
|
107
|
+
|
|
108
|
+
await waitFor(() => expect(screen.getByRole("alert").textContent).toContain("Gmail send failed"));
|
|
109
|
+
expect(screen.getByPlaceholderText("Write your reply…")).toBeDefined();
|
|
110
|
+
expect(screen.queryByText(/Reply sent/i)).toBeNull();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("shows sent state only after async send succeeds", async () => {
|
|
114
|
+
const onSendReply = vi.fn().mockResolvedValue(undefined);
|
|
115
|
+
render(<ConversationPanel threads={[thread({ draft: "Sounds good" })]} me={me} onSendReply={onSendReply} />);
|
|
116
|
+
|
|
117
|
+
fireEvent.click(screen.getByText("Reply"));
|
|
118
|
+
fireEvent.click(screen.getByText("Send"));
|
|
119
|
+
fireEvent.click(screen.getByText("Send now"));
|
|
120
|
+
|
|
121
|
+
await waitFor(() => expect(screen.getByText(/Reply sent/i)).toBeDefined());
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("offers a Reply affordance for participant threads", () => {
|
|
125
|
+
render(<ConversationPanel threads={[thread()]} me={me} />);
|
|
126
|
+
expect(screen.getByText("Reply")).toBeDefined();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("renders the read-only notice and no Reply when canReply is false", () => {
|
|
130
|
+
render(
|
|
131
|
+
<ConversationPanel
|
|
132
|
+
threads={[thread({ canReply: false, status: "viewing", defaultOpenThreadId: "t1" } as Partial<ConversationThread>)]}
|
|
133
|
+
me={me}
|
|
134
|
+
defaultOpenThreadId="t1"
|
|
135
|
+
/>,
|
|
136
|
+
);
|
|
137
|
+
expect(screen.getByText(/Viewing only/i)).toBeDefined();
|
|
138
|
+
expect(screen.queryByText("Reply")).toBeNull();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("shows the paused-playbook banner when a thread is paused", () => {
|
|
142
|
+
render(
|
|
143
|
+
<ConversationPanel
|
|
144
|
+
threads={[thread({ paused: { playbook: "Retention Outreach" } })]}
|
|
145
|
+
me={me}
|
|
146
|
+
tenantName="Mercury OS"
|
|
147
|
+
/>,
|
|
148
|
+
);
|
|
149
|
+
expect(screen.getByText(/Follow-up actions stopped/i)).toBeDefined();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("opens the reply composer when Reply is clicked", () => {
|
|
153
|
+
render(<ConversationPanel threads={[thread()]} me={me} />);
|
|
154
|
+
fireEvent.click(screen.getByText("Reply"));
|
|
155
|
+
expect(screen.getByPlaceholderText("Write your reply…")).toBeDefined();
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { render, screen } from "@testing-library/react";
|
|
4
|
+
import {
|
|
5
|
+
SignalOwnerChip,
|
|
6
|
+
AccountOwnerChip,
|
|
7
|
+
OwnerChips,
|
|
8
|
+
type OwnerPerson,
|
|
9
|
+
} from "../owner-chips";
|
|
10
|
+
|
|
11
|
+
const dana: OwnerPerson = { id: "u1", name: "Dana Okafor", role: "Senior RM" };
|
|
12
|
+
const marcus: OwnerPerson = { id: "u2", name: "Marcus Lee", role: "Treasury" };
|
|
13
|
+
|
|
14
|
+
describe("SignalOwnerChip", () => {
|
|
15
|
+
it("renders data-slot and the 'Signal owner' label", () => {
|
|
16
|
+
const { container } = render(<SignalOwnerChip owner={null} />);
|
|
17
|
+
expect(container.querySelector('[data-slot="signal-owner-chip"]')).not.toBeNull();
|
|
18
|
+
expect(screen.getByText("Signal owner")).toBeDefined();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("shows 'Unassigned' (data-empty) with no owner", () => {
|
|
22
|
+
const { container } = render(<SignalOwnerChip owner={null} />);
|
|
23
|
+
const el = container.querySelector('[data-slot="signal-owner-chip"]');
|
|
24
|
+
expect(el?.getAttribute("data-empty")).toBe("true");
|
|
25
|
+
expect(screen.getByText("Unassigned")).toBeDefined();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("shows the owner's first name when assigned", () => {
|
|
29
|
+
render(<SignalOwnerChip owner={dana} />);
|
|
30
|
+
expect(screen.getByText("Dana")).toBeDefined();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("renders a static span (no button) when read-only / no handlers", () => {
|
|
34
|
+
const { container } = render(<SignalOwnerChip owner={dana} />);
|
|
35
|
+
const el = container.querySelector('[data-slot="signal-owner-chip"]');
|
|
36
|
+
expect(el?.tagName.toLowerCase()).toBe("span");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("renders a button trigger when assignment handlers are provided", () => {
|
|
40
|
+
const { container } = render(
|
|
41
|
+
<SignalOwnerChip owner={null} assignableOwners={[dana]} onAssign={() => {}} />,
|
|
42
|
+
);
|
|
43
|
+
const el = container.querySelector('[data-slot="signal-owner-chip"]');
|
|
44
|
+
expect(el?.tagName.toLowerCase()).toBe("button");
|
|
45
|
+
});
|
|
46
|
+
// NOTE: the assignment menu opens via Radix (pointer events + a body portal),
|
|
47
|
+
// which doesn't drive cleanly under happy-dom; the open/select interaction is
|
|
48
|
+
// covered by Radix's own tests. Here we assert the trigger affordance only.
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("AccountOwnerChip", () => {
|
|
52
|
+
it("renders nothing when there are no owners", () => {
|
|
53
|
+
const { container } = render(<AccountOwnerChip owners={[]} />);
|
|
54
|
+
expect(container.querySelector('[data-slot="account-owner-chip"]')).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("single owner -> static chip, no dropdown (no data-multi)", () => {
|
|
58
|
+
const { container } = render(<AccountOwnerChip owners={[dana]} />);
|
|
59
|
+
const el = container.querySelector('[data-slot="account-owner-chip"]');
|
|
60
|
+
expect(el).not.toBeNull();
|
|
61
|
+
expect(el?.getAttribute("data-multi")).toBeNull();
|
|
62
|
+
expect(el?.tagName.toLowerCase()).toBe("span");
|
|
63
|
+
expect(screen.getByText("Dana")).toBeDefined();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("single owner with href -> renders an external link", () => {
|
|
67
|
+
const { container } = render(
|
|
68
|
+
<AccountOwnerChip owners={[{ ...dana, href: "https://sf.example/u1" }]} />,
|
|
69
|
+
);
|
|
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");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("multiple owners -> a button with a ×N badge and data-multi", () => {
|
|
77
|
+
const { container } = render(<AccountOwnerChip owners={[dana, marcus]} />);
|
|
78
|
+
const el = container.querySelector('[data-slot="account-owner-chip"]');
|
|
79
|
+
expect(el?.getAttribute("data-multi")).toBe("true");
|
|
80
|
+
expect(el?.tagName.toLowerCase()).toBe("button");
|
|
81
|
+
expect(screen.getByText("×2")).toBeDefined();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
describe("OwnerChips", () => {
|
|
87
|
+
it("renders the composite with accountOwners and without the legacy owners prop", () => {
|
|
88
|
+
const { container } = render(<OwnerChips owner={dana} accountOwners={[marcus]} />);
|
|
89
|
+
expect(container.querySelector('[data-slot="signal-owner-chip"]')).not.toBeNull();
|
|
90
|
+
expect(container.querySelector('[data-slot="account-owner-chip"]')).not.toBeNull();
|
|
91
|
+
expect(screen.getByText("Dana")).toBeDefined();
|
|
92
|
+
expect(screen.getByText("Marcus")).toBeDefined();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("allows accountOwners to be omitted", () => {
|
|
96
|
+
const { container } = render(<OwnerChips owner={dana} />);
|
|
97
|
+
expect(container.querySelector('[data-slot="signal-owner-chip"]')).not.toBeNull();
|
|
98
|
+
expect(container.querySelector('[data-slot="account-owner-chip"]')).toBeNull();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -119,8 +119,8 @@ describe("SignalPriorityPopover", () => {
|
|
|
119
119
|
|
|
120
120
|
// Check head section
|
|
121
121
|
expect(content.textContent).toContain("Why this is high priority")
|
|
122
|
-
expect(
|
|
123
|
-
expect(
|
|
122
|
+
expect(screen.getByTestId("priority-overall-score").textContent).toContain("79")
|
|
123
|
+
expect(screen.getByTestId("priority-overall-score").textContent).toContain("/100")
|
|
124
124
|
expect(content.textContent).toContain("High range")
|
|
125
125
|
expect(content.textContent).toContain("60-79")
|
|
126
126
|
})
|
|
@@ -188,13 +188,50 @@ describe("SignalPriorityPopover", () => {
|
|
|
188
188
|
expect(row.textContent).not.toContain("Raises0/100")
|
|
189
189
|
})
|
|
190
190
|
|
|
191
|
-
it("renders Contributing factors section label", () => {
|
|
191
|
+
it("renders Contributing factors section label and shared-consumer-safe default formula label", () => {
|
|
192
192
|
render(<SignalPriorityPopover {...defaultProps} />)
|
|
193
193
|
fireEvent.click(screen.getByTestId("priority-popover-trigger"))
|
|
194
194
|
|
|
195
195
|
const content = screen.getByTestId("priority-popover-content")
|
|
196
196
|
expect(content.textContent).toContain("Contributing factors")
|
|
197
|
-
expect(content.textContent).toContain("
|
|
197
|
+
expect(content.textContent).toContain("Priority factors")
|
|
198
|
+
expect(content.textContent).not.toContain("Score = weighted sum")
|
|
199
|
+
expect(content.textContent).not.toContain("Priority = weighted signals + calibration")
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it("renders a custom formula label when provided", () => {
|
|
203
|
+
render(
|
|
204
|
+
<SignalPriorityPopover
|
|
205
|
+
{...defaultProps}
|
|
206
|
+
formulaLabel="Priority = weighted signals + calibration"
|
|
207
|
+
/>,
|
|
208
|
+
)
|
|
209
|
+
fireEvent.click(screen.getByTestId("priority-popover-trigger"))
|
|
210
|
+
|
|
211
|
+
const content = screen.getByTestId("priority-popover-content")
|
|
212
|
+
expect(content.textContent).toContain("Priority = weighted signals + calibration")
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
it("renders the overall score number by default while preserving factor row scores", () => {
|
|
217
|
+
render(<SignalPriorityPopover {...defaultProps} />)
|
|
218
|
+
fireEvent.click(screen.getByTestId("priority-popover-trigger"))
|
|
219
|
+
|
|
220
|
+
expect(screen.getByTestId("priority-overall-score").textContent).toBe("79/100")
|
|
221
|
+
expect(screen.getByTestId("factor-row-test_severity").textContent).toContain("85/100")
|
|
222
|
+
expect(screen.getByTestId("factor-row-account_depth").textContent).toContain("30/100")
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it("hides only the overall header score in label display mode", () => {
|
|
226
|
+
render(<SignalPriorityPopover {...defaultProps} scoreDisplay="label" />)
|
|
227
|
+
fireEvent.click(screen.getByTestId("priority-popover-trigger"))
|
|
228
|
+
|
|
229
|
+
const header = screen.getByTestId("priority-popover-header")
|
|
230
|
+
expect(screen.queryByTestId("priority-overall-score")).toBeNull()
|
|
231
|
+
expect(header.textContent).toContain("Why this is high priority")
|
|
232
|
+
expect(header.textContent).not.toContain("79/100")
|
|
233
|
+
expect(screen.getByTestId("factor-row-test_severity").textContent).toContain("85/100")
|
|
234
|
+
expect(screen.getByTestId("factor-row-account_depth").textContent).toContain("30/100")
|
|
198
235
|
})
|
|
199
236
|
|
|
200
237
|
it("renders score track bars with correct width percentage", () => {
|
|
@@ -149,4 +149,59 @@ describe("TimelineActivity", () => {
|
|
|
149
149
|
expect(TONE_CLASSES[tone].icon).toBeTruthy()
|
|
150
150
|
}
|
|
151
151
|
})
|
|
152
|
+
|
|
153
|
+
// --- Email body: opt-in HTML render mode ---
|
|
154
|
+
|
|
155
|
+
it("sanitizes formatted HTML when email.bodyHtml is provided", () => {
|
|
156
|
+
const event = minimal({
|
|
157
|
+
isInteractive: true,
|
|
158
|
+
defaultExpanded: true,
|
|
159
|
+
email: {
|
|
160
|
+
from: "Priya Raman",
|
|
161
|
+
to: "Dana Okafor",
|
|
162
|
+
subject: "Re: hi",
|
|
163
|
+
body: "plain fallback",
|
|
164
|
+
bodyHtml:
|
|
165
|
+
'<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
|
+
},
|
|
167
|
+
})
|
|
168
|
+
const { container } = render(<TimelineActivity events={[event]} />)
|
|
169
|
+
const html = container.querySelector('[data-slot="timeline-email-html"]')!
|
|
170
|
+
expect(html.innerHTML).not.toContain("script")
|
|
171
|
+
expect(html.innerHTML).not.toContain("onclick")
|
|
172
|
+
expect(html.innerHTML).not.toContain("onerror")
|
|
173
|
+
expect(html.innerHTML).not.toContain("javascript:")
|
|
174
|
+
expect(html.querySelector("img")?.getAttribute("src")).toBe("https://example.com/pixel.png")
|
|
175
|
+
expect(html.querySelector("a")?.getAttribute("href")).toBeNull()
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it("renders formatted HTML when email.bodyHtml is provided", () => {
|
|
179
|
+
const event = minimal({
|
|
180
|
+
isInteractive: true,
|
|
181
|
+
defaultExpanded: true,
|
|
182
|
+
email: {
|
|
183
|
+
from: "Priya Raman",
|
|
184
|
+
to: "Dana Okafor",
|
|
185
|
+
subject: "Re: hi",
|
|
186
|
+
body: "plain fallback",
|
|
187
|
+
bodyHtml: '<p>Hello <a href="https://example.com">link</a></p>',
|
|
188
|
+
},
|
|
189
|
+
})
|
|
190
|
+
const { container } = render(<TimelineActivity events={[event]} />)
|
|
191
|
+
const html = container.querySelector('[data-slot="timeline-email-html"]')
|
|
192
|
+
expect(html).not.toBeNull()
|
|
193
|
+
// Renders the actual anchor element (not escaped text)
|
|
194
|
+
expect(html!.querySelector("a")?.getAttribute("href")).toBe("https://example.com")
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it("falls back to the plain-text body when bodyHtml is absent", () => {
|
|
198
|
+
const event = minimal({
|
|
199
|
+
isInteractive: true,
|
|
200
|
+
defaultExpanded: true,
|
|
201
|
+
email: { from: "Priya", to: "Dana", subject: "Re: hi", body: "just text" },
|
|
202
|
+
})
|
|
203
|
+
const { container } = render(<TimelineActivity events={[event]} />)
|
|
204
|
+
expect(container.querySelector('[data-slot="timeline-email-html"]')).toBeNull()
|
|
205
|
+
expect(screen.getByText("just text")).toBeDefined()
|
|
206
|
+
})
|
|
152
207
|
})
|
|
@@ -84,6 +84,24 @@ describe("VirtualizedDataTable — resize handles render when enabled", () => {
|
|
|
84
84
|
});
|
|
85
85
|
});
|
|
86
86
|
|
|
87
|
+
it("each handle has a wider grab target and visible divider", () => {
|
|
88
|
+
const { container } = render(
|
|
89
|
+
<VirtualizedDataTable
|
|
90
|
+
columns={testColumns}
|
|
91
|
+
data={testData}
|
|
92
|
+
height={300}
|
|
93
|
+
enableColumnResizing
|
|
94
|
+
/>,
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
container.querySelectorAll('[role="separator"]').forEach((sep) => {
|
|
98
|
+
expect(sep.classList.contains("w-4")).toBe(true);
|
|
99
|
+
expect(sep.classList.contains("-mr-2")).toBe(true);
|
|
100
|
+
expect(sep.className).toContain("after:bg-border/70");
|
|
101
|
+
expect(sep.className).toContain("hover:after:bg-primary/60");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
87
105
|
it("header cells have 'relative' class when resizing is enabled", () => {
|
|
88
106
|
const { container } = render(
|
|
89
107
|
<VirtualizedDataTable
|
|
@@ -62,6 +62,8 @@ export type CasePanelActivityPayload =
|
|
|
62
62
|
dueLabel: string
|
|
63
63
|
status?: "upcoming" | "due" | "overdue" | "met"
|
|
64
64
|
description?: string
|
|
65
|
+
/** 0..1 elapsed toward the deadline; renders a thin progress bar. */
|
|
66
|
+
progress?: number
|
|
65
67
|
}
|
|
66
68
|
| {
|
|
67
69
|
kind: "operatorNote"
|
|
@@ -373,6 +375,24 @@ function renderPayloadContent(
|
|
|
373
375
|
) : null}
|
|
374
376
|
</div>
|
|
375
377
|
{payload.description ? <p className="text-xs leading-relaxed text-muted-foreground">{payload.description}</p> : null}
|
|
378
|
+
{typeof payload.progress === "number" ? (
|
|
379
|
+
<div
|
|
380
|
+
data-slot="deadline-progress"
|
|
381
|
+
role="progressbar"
|
|
382
|
+
aria-valuenow={Math.round(Math.min(1, Math.max(0, payload.progress)) * 100)}
|
|
383
|
+
aria-valuemin={0}
|
|
384
|
+
aria-valuemax={100}
|
|
385
|
+
className="bg-muted h-1.5 w-full overflow-hidden rounded-full"
|
|
386
|
+
>
|
|
387
|
+
<div
|
|
388
|
+
className={cn(
|
|
389
|
+
"h-full rounded-full",
|
|
390
|
+
payload.status === "overdue" ? "bg-red-500" : payload.status === "due" ? "bg-amber-500" : "bg-foreground/70"
|
|
391
|
+
)}
|
|
392
|
+
style={{ width: `${Math.min(1, Math.max(0, payload.progress)) * 100}%` }}
|
|
393
|
+
/>
|
|
394
|
+
</div>
|
|
395
|
+
) : null}
|
|
376
396
|
</div>
|
|
377
397
|
)
|
|
378
398
|
case "operatorNote":
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* comment-composer.tsx — an internal-note composer for the case activity
|
|
5
|
+
* timeline. Posting a comment prepends an `operatorNote` event to the log
|
|
6
|
+
* (wired by the consumer). Collapses to a single line; expands on focus or
|
|
7
|
+
* when it has text. ⌘↵ / Ctrl↵ posts.
|
|
8
|
+
*
|
|
9
|
+
* Presentational: `onPost` does the work (the consumer persists the note and
|
|
10
|
+
* adds it to the timeline). Reuses Avatar / Button / Textarea primitives.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as React from "react"
|
|
14
|
+
import { Lock } from "lucide-react"
|
|
15
|
+
|
|
16
|
+
import { cn } from "../lib/utils"
|
|
17
|
+
import { getInitials } from "../lib/user-display"
|
|
18
|
+
import { Avatar, AvatarFallback, AvatarImage } from "./avatar"
|
|
19
|
+
import { Button } from "./button"
|
|
20
|
+
import { Textarea } from "./textarea"
|
|
21
|
+
|
|
22
|
+
export interface CommentComposerProps {
|
|
23
|
+
/** Called with the trimmed note text when the operator posts. */
|
|
24
|
+
onPost: (text: string) => void
|
|
25
|
+
/** Current operator (for the avatar). */
|
|
26
|
+
author?: { name?: string; email?: string; avatarUrl?: string | null }
|
|
27
|
+
placeholder?: string
|
|
28
|
+
/** Hint shown in the footer; defaults to the internal-note reassurance. */
|
|
29
|
+
hint?: string
|
|
30
|
+
className?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function CommentComposer({
|
|
34
|
+
onPost,
|
|
35
|
+
author,
|
|
36
|
+
placeholder = "Add a comment or internal note…",
|
|
37
|
+
hint = "Internal note: only your team sees this",
|
|
38
|
+
className,
|
|
39
|
+
}: CommentComposerProps) {
|
|
40
|
+
const [text, setText] = React.useState("")
|
|
41
|
+
const [focused, setFocused] = React.useState(false)
|
|
42
|
+
const open = focused || text.length > 0
|
|
43
|
+
const canPost = text.trim().length > 0
|
|
44
|
+
|
|
45
|
+
const post = () => {
|
|
46
|
+
const value = text.trim()
|
|
47
|
+
if (!value) return
|
|
48
|
+
onPost(value)
|
|
49
|
+
setText("")
|
|
50
|
+
setFocused(false)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div
|
|
55
|
+
data-slot="comment-composer"
|
|
56
|
+
data-open={open ? "true" : undefined}
|
|
57
|
+
className={cn(
|
|
58
|
+
"border-border bg-background flex items-start gap-2 rounded-lg border p-2 transition-colors",
|
|
59
|
+
open && "ring-ring/30 ring-2",
|
|
60
|
+
className
|
|
61
|
+
)}
|
|
62
|
+
>
|
|
63
|
+
<Avatar size="sm" className="mt-0.5">
|
|
64
|
+
{author?.avatarUrl ? <AvatarImage src={author.avatarUrl} alt={author.name ?? "You"} /> : null}
|
|
65
|
+
<AvatarFallback className="bg-muted text-muted-foreground text-[10px] font-medium uppercase">
|
|
66
|
+
{getInitials({ name: author?.name, email: author?.email })}
|
|
67
|
+
</AvatarFallback>
|
|
68
|
+
</Avatar>
|
|
69
|
+
|
|
70
|
+
<div className="min-w-0 flex-1">
|
|
71
|
+
<Textarea
|
|
72
|
+
data-slot="comment-composer-input"
|
|
73
|
+
value={text}
|
|
74
|
+
onChange={(e) => setText(e.target.value)}
|
|
75
|
+
onFocus={() => setFocused(true)}
|
|
76
|
+
placeholder={placeholder}
|
|
77
|
+
rows={open ? 3 : 1}
|
|
78
|
+
className={cn(
|
|
79
|
+
"resize-none border-0 bg-transparent p-1 text-sm shadow-none focus-visible:ring-0",
|
|
80
|
+
!open && "min-h-0"
|
|
81
|
+
)}
|
|
82
|
+
onKeyDown={(e) => {
|
|
83
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
|
84
|
+
e.preventDefault()
|
|
85
|
+
post()
|
|
86
|
+
}
|
|
87
|
+
}}
|
|
88
|
+
/>
|
|
89
|
+
|
|
90
|
+
{open ? (
|
|
91
|
+
<div className="mt-1 flex items-center justify-between gap-2">
|
|
92
|
+
<span className="text-muted-foreground inline-flex items-center gap-1 text-[11px]">
|
|
93
|
+
<Lock size={12} /> {hint}
|
|
94
|
+
</span>
|
|
95
|
+
<span className="flex items-center gap-2">
|
|
96
|
+
<Button
|
|
97
|
+
type="button"
|
|
98
|
+
variant="ghost"
|
|
99
|
+
size="sm"
|
|
100
|
+
onClick={() => {
|
|
101
|
+
setText("")
|
|
102
|
+
setFocused(false)
|
|
103
|
+
}}
|
|
104
|
+
>
|
|
105
|
+
Cancel
|
|
106
|
+
</Button>
|
|
107
|
+
<Button type="button" size="sm" disabled={!canPost} onClick={post}>
|
|
108
|
+
Comment
|
|
109
|
+
<kbd className="bg-primary-foreground/15 ml-1 rounded px-1 text-[10px]">⌘↵</kbd>
|
|
110
|
+
</Button>
|
|
111
|
+
</span>
|
|
112
|
+
</div>
|
|
113
|
+
) : null}
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export { CommentComposer }
|