@handled-ai/design-system 0.18.36 → 0.18.38
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/charts/chart.d.ts +1 -1
- package/dist/components/draft-feedback-inline.d.ts +1 -1
- package/dist/components/draft-feedback-inline.js +10 -10
- package/dist/components/draft-feedback-inline.js.map +1 -1
- package/dist/components/email-composer-row.d.ts +11 -0
- package/dist/components/email-composer-row.js +82 -0
- package/dist/components/email-composer-row.js.map +1 -0
- package/dist/components/email-preview-card.d.ts +17 -0
- package/dist/components/email-preview-card.js +71 -0
- package/dist/components/email-preview-card.js.map +1 -0
- package/dist/components/email-recipient-field.d.ts +26 -0
- package/dist/components/email-recipient-field.js +403 -0
- package/dist/components/email-recipient-field.js.map +1 -0
- package/dist/components/email-send-bar.d.ts +22 -0
- package/dist/components/email-send-bar.js +66 -0
- package/dist/components/email-send-bar.js.map +1 -0
- package/dist/components/entity-panel.d.ts +1 -15
- package/dist/components/entity-panel.js +1 -74
- package/dist/components/entity-panel.js.map +1 -1
- package/dist/components/score-feedback.js +6 -6
- package/dist/components/score-feedback.js.map +1 -1
- package/dist/components/suggested-actions.js +5 -17
- package/dist/components/suggested-actions.js.map +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/__tests__/email-composer-row.test.tsx +51 -0
- package/src/components/__tests__/email-preview-card.test.tsx +62 -0
- package/src/components/__tests__/email-recipient-field.test.tsx +256 -0
- package/src/components/__tests__/email-send-bar.test.tsx +80 -0
- package/src/components/draft-feedback-inline.tsx +13 -13
- package/src/components/email-composer-row.tsx +47 -0
- package/src/components/email-preview-card.tsx +94 -0
- package/src/components/email-recipient-field.tsx +461 -0
- package/src/components/email-send-bar.tsx +95 -0
- package/src/components/entity-panel.tsx +0 -117
- package/src/components/score-feedback.tsx +7 -7
- package/src/components/suggested-actions.tsx +5 -19
- package/src/index.ts +4 -0
- package/src/components/__tests__/draft-feedback-inline.test.tsx +0 -72
- package/src/components/__tests__/suggested-actions-feedback-header.test.tsx +0 -86
package/package.json
CHANGED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from "vitest"
|
|
2
|
+
import React from "react"
|
|
3
|
+
import { render, screen, cleanup } from "@testing-library/react"
|
|
4
|
+
|
|
5
|
+
import { EmailComposerRow } from "../email-composer-row"
|
|
6
|
+
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
cleanup()
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
describe("EmailComposerRow", () => {
|
|
12
|
+
it("renders the label and content", () => {
|
|
13
|
+
render(
|
|
14
|
+
<EmailComposerRow label="To">
|
|
15
|
+
<span>content here</span>
|
|
16
|
+
</EmailComposerRow>,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
expect(screen.getByText("To")).toBeTruthy()
|
|
20
|
+
expect(screen.getByText("content here")).toBeTruthy()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it("applies amber state classes", () => {
|
|
24
|
+
const { container } = render(
|
|
25
|
+
<EmailComposerRow label="To" state="amber">
|
|
26
|
+
<span>body</span>
|
|
27
|
+
</EmailComposerRow>,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
const row = container.firstChild as HTMLElement
|
|
31
|
+
expect(row.className).toContain("bg-amber-50/35")
|
|
32
|
+
expect(row.className).toContain("border-amber-200/80")
|
|
33
|
+
|
|
34
|
+
const label = screen.getByText("To")
|
|
35
|
+
expect(label.className).toContain("text-amber-700")
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it("applies alignStart classes", () => {
|
|
39
|
+
const { container } = render(
|
|
40
|
+
<EmailComposerRow label="To" alignStart>
|
|
41
|
+
<span>body</span>
|
|
42
|
+
</EmailComposerRow>,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
const row = container.firstChild as HTMLElement
|
|
46
|
+
expect(row.className).toContain("items-start")
|
|
47
|
+
|
|
48
|
+
const label = screen.getByText("To")
|
|
49
|
+
expect(label.className).toContain("pt-[7px]")
|
|
50
|
+
})
|
|
51
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from "vitest"
|
|
2
|
+
import React from "react"
|
|
3
|
+
import { render, screen, cleanup } from "@testing-library/react"
|
|
4
|
+
|
|
5
|
+
import { EmailPreviewCard } from "../email-preview-card"
|
|
6
|
+
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
cleanup()
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
const from = { name: "Cory Pitt", email: "cory@withhandled.com" }
|
|
12
|
+
|
|
13
|
+
describe("EmailPreviewCard", () => {
|
|
14
|
+
it("renders the subject", () => {
|
|
15
|
+
render(<EmailPreviewCard from={from} subject="Quarterly update" />)
|
|
16
|
+
expect(screen.getByText("Quarterly update")).toBeTruthy()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it("falls back to (no subject) when subject is missing", () => {
|
|
20
|
+
render(<EmailPreviewCard from={from} />)
|
|
21
|
+
expect(screen.getByText("(no subject)")).toBeTruthy()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it("renders the sender name and email", () => {
|
|
25
|
+
render(<EmailPreviewCard from={from} />)
|
|
26
|
+
expect(screen.getByText("Cory Pitt")).toBeTruthy()
|
|
27
|
+
expect(screen.getByText("<cory@withhandled.com>")).toBeTruthy()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it("renders the recipient", () => {
|
|
31
|
+
render(<EmailPreviewCard from={from} to="jane@acme.com" />)
|
|
32
|
+
expect(screen.getByText("to jane@acme.com")).toBeTruthy()
|
|
33
|
+
expect(
|
|
34
|
+
screen.getByText(/This is how your email lands in jane@acme.com's inbox/),
|
|
35
|
+
).toBeTruthy()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it("renders 'to no recipient yet' when no recipient", () => {
|
|
39
|
+
render(<EmailPreviewCard from={from} />)
|
|
40
|
+
expect(screen.getByText("to no recipient yet")).toBeTruthy()
|
|
41
|
+
expect(
|
|
42
|
+
screen.getByText(/This is how your email lands in the recipient's inbox/),
|
|
43
|
+
).toBeTruthy()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it("renders html body markup", () => {
|
|
47
|
+
const { container } = render(
|
|
48
|
+
<EmailPreviewCard from={from} htmlBody="<strong>Hello world</strong>" />,
|
|
49
|
+
)
|
|
50
|
+
expect(container.querySelector("strong")?.textContent).toBe("Hello world")
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it("renders the signature when provided", () => {
|
|
54
|
+
const { container } = render(
|
|
55
|
+
<EmailPreviewCard
|
|
56
|
+
from={from}
|
|
57
|
+
signatureHtml="<em>Best, Cory</em>"
|
|
58
|
+
/>,
|
|
59
|
+
)
|
|
60
|
+
expect(container.querySelector("em")?.textContent).toBe("Best, Cory")
|
|
61
|
+
})
|
|
62
|
+
})
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from "vitest"
|
|
2
|
+
import React from "react"
|
|
3
|
+
import { render, screen, fireEvent, cleanup } from "@testing-library/react"
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
EmailRecipientField,
|
|
7
|
+
type RecipientChip,
|
|
8
|
+
} from "../email-recipient-field"
|
|
9
|
+
import type { SuggestedContact } from "../suggested-actions"
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
cleanup()
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
const contacts: SuggestedContact[] = [
|
|
16
|
+
{ name: "Alex Admin", role: "Controller", email: "alex@example.com", confirmed: true },
|
|
17
|
+
{ name: "Bea Buyer", role: "Buyer", email: "bea@example.com", confirmed: true },
|
|
18
|
+
{ name: "No Email", role: "Unknown", confirmed: false },
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
function getInput(): HTMLInputElement {
|
|
22
|
+
return screen.getByPlaceholderText(/Add email|Add another/) as HTMLInputElement
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("EmailRecipientField", () => {
|
|
26
|
+
it("adds an unconfirmed chip when pressing Enter on a valid email", () => {
|
|
27
|
+
const onChange = vi.fn()
|
|
28
|
+
render(
|
|
29
|
+
<EmailRecipientField label="To" recipients={[]} onRecipientsChange={onChange} />,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
const input = getInput()
|
|
33
|
+
fireEvent.change(input, { target: { value: "new@example.com" } })
|
|
34
|
+
fireEvent.keyDown(input, { key: "Enter" })
|
|
35
|
+
|
|
36
|
+
expect(onChange).toHaveBeenCalledWith([
|
|
37
|
+
{ id: "new@example.com", email: "new@example.com", name: "", confirmed: false },
|
|
38
|
+
])
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it("adds a chip when pressing comma", () => {
|
|
42
|
+
const onChange = vi.fn()
|
|
43
|
+
render(
|
|
44
|
+
<EmailRecipientField label="To" recipients={[]} onRecipientsChange={onChange} />,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
const input = getInput()
|
|
48
|
+
fireEvent.change(input, { target: { value: "comma@example.com" } })
|
|
49
|
+
fireEvent.keyDown(input, { key: "," })
|
|
50
|
+
|
|
51
|
+
expect(onChange).toHaveBeenCalledWith([
|
|
52
|
+
{ id: "comma@example.com", email: "comma@example.com", name: "", confirmed: false },
|
|
53
|
+
])
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it("does not add an invalid email", () => {
|
|
57
|
+
const onChange = vi.fn()
|
|
58
|
+
render(
|
|
59
|
+
<EmailRecipientField label="To" recipients={[]} onRecipientsChange={onChange} />,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
const input = getInput()
|
|
63
|
+
fireEvent.change(input, { target: { value: "not-an-email" } })
|
|
64
|
+
fireEvent.keyDown(input, { key: "Enter" })
|
|
65
|
+
|
|
66
|
+
expect(onChange).not.toHaveBeenCalled()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it("confirms a chip", () => {
|
|
70
|
+
const onChange = vi.fn()
|
|
71
|
+
const recipients: RecipientChip[] = [
|
|
72
|
+
{ id: "a@b.com", email: "a@b.com", name: "", confirmed: false },
|
|
73
|
+
]
|
|
74
|
+
render(
|
|
75
|
+
<EmailRecipientField label="To" recipients={recipients} onRecipientsChange={onChange} />,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
fireEvent.click(screen.getByRole("button", { name: "Confirm" }))
|
|
79
|
+
expect(onChange).toHaveBeenCalledWith([
|
|
80
|
+
{ id: "a@b.com", email: "a@b.com", name: "", confirmed: true },
|
|
81
|
+
])
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it("removes a chip via the X button", () => {
|
|
85
|
+
const onChange = vi.fn()
|
|
86
|
+
const recipients: RecipientChip[] = [
|
|
87
|
+
{ id: "a@b.com", email: "a@b.com", name: "Al", confirmed: true },
|
|
88
|
+
]
|
|
89
|
+
render(
|
|
90
|
+
<EmailRecipientField label="To" recipients={recipients} onRecipientsChange={onChange} />,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
fireEvent.click(screen.getByRole("button", { name: "Remove Al" }))
|
|
94
|
+
expect(onChange).toHaveBeenCalledWith([])
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it("removes the last chip on Backspace with empty input", () => {
|
|
98
|
+
const onChange = vi.fn()
|
|
99
|
+
const recipients: RecipientChip[] = [
|
|
100
|
+
{ id: "a@b.com", email: "a@b.com", name: "", confirmed: true },
|
|
101
|
+
{ id: "c@d.com", email: "c@d.com", name: "", confirmed: true },
|
|
102
|
+
]
|
|
103
|
+
render(
|
|
104
|
+
<EmailRecipientField label="To" recipients={recipients} onRecipientsChange={onChange} />,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
const input = getInput()
|
|
108
|
+
fireEvent.keyDown(input, { key: "Backspace" })
|
|
109
|
+
expect(onChange).toHaveBeenCalledWith([
|
|
110
|
+
{ id: "a@b.com", email: "a@b.com", name: "", confirmed: true },
|
|
111
|
+
])
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it("silently rejects emails already in addedEmails", () => {
|
|
115
|
+
const onChange = vi.fn()
|
|
116
|
+
render(
|
|
117
|
+
<EmailRecipientField
|
|
118
|
+
label="To"
|
|
119
|
+
recipients={[]}
|
|
120
|
+
onRecipientsChange={onChange}
|
|
121
|
+
addedEmails={new Set(["dup@example.com"])}
|
|
122
|
+
/>,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
const input = getInput()
|
|
126
|
+
fireEvent.change(input, { target: { value: "dup@example.com" } })
|
|
127
|
+
fireEvent.keyDown(input, { key: "Enter" })
|
|
128
|
+
|
|
129
|
+
expect(onChange).not.toHaveBeenCalled()
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it("applies amber tint when amber and an unconfirmed chip exists", () => {
|
|
133
|
+
const { container } = render(
|
|
134
|
+
<EmailRecipientField
|
|
135
|
+
label="To"
|
|
136
|
+
amber
|
|
137
|
+
recipients={[{ id: "a@b.com", email: "a@b.com", name: "", confirmed: false }]}
|
|
138
|
+
onRecipientsChange={vi.fn()}
|
|
139
|
+
/>,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
const row = container.firstChild as HTMLElement
|
|
143
|
+
expect(row.className).toContain("bg-amber-50/35")
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it("conditionally renders the mini-chips", () => {
|
|
147
|
+
const { rerender } = render(
|
|
148
|
+
<EmailRecipientField label="To" recipients={[]} onRecipientsChange={vi.fn()} />,
|
|
149
|
+
)
|
|
150
|
+
expect(screen.queryByRole("button", { name: /Contacts/ })).toBeNull()
|
|
151
|
+
expect(screen.queryByRole("button", { name: /Cc\/Bcc/ })).toBeNull()
|
|
152
|
+
|
|
153
|
+
rerender(
|
|
154
|
+
<EmailRecipientField
|
|
155
|
+
label="To"
|
|
156
|
+
recipients={[]}
|
|
157
|
+
onRecipientsChange={vi.fn()}
|
|
158
|
+
showPicker
|
|
159
|
+
showCcBcc
|
|
160
|
+
/>,
|
|
161
|
+
)
|
|
162
|
+
expect(screen.getByRole("button", { name: /Contacts/ })).toBeTruthy()
|
|
163
|
+
expect(screen.getByRole("button", { name: /Add Cc\/Bcc/ })).toBeTruthy()
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it("toggles Cc/Bcc label and fires the toggle handler", () => {
|
|
167
|
+
const onToggle = vi.fn()
|
|
168
|
+
const { rerender } = render(
|
|
169
|
+
<EmailRecipientField
|
|
170
|
+
label="To"
|
|
171
|
+
recipients={[]}
|
|
172
|
+
onRecipientsChange={vi.fn()}
|
|
173
|
+
showCcBcc
|
|
174
|
+
onCcBccToggle={onToggle}
|
|
175
|
+
/>,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
fireEvent.click(screen.getByRole("button", { name: "Add Cc/Bcc" }))
|
|
179
|
+
expect(onToggle).toHaveBeenCalledTimes(1)
|
|
180
|
+
|
|
181
|
+
rerender(
|
|
182
|
+
<EmailRecipientField
|
|
183
|
+
label="To"
|
|
184
|
+
recipients={[]}
|
|
185
|
+
onRecipientsChange={vi.fn()}
|
|
186
|
+
showCcBcc
|
|
187
|
+
ccBccOpen
|
|
188
|
+
onCcBccToggle={onToggle}
|
|
189
|
+
/>,
|
|
190
|
+
)
|
|
191
|
+
expect(screen.getByRole("button", { name: "Hide Cc/Bcc" })).toBeTruthy()
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it("opens and closes the contact picker", () => {
|
|
195
|
+
render(
|
|
196
|
+
<EmailRecipientField
|
|
197
|
+
label="To"
|
|
198
|
+
recipients={[]}
|
|
199
|
+
onRecipientsChange={vi.fn()}
|
|
200
|
+
showPicker
|
|
201
|
+
contacts={contacts}
|
|
202
|
+
/>,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
|
|
206
|
+
expect(screen.getByPlaceholderText("Search contacts...")).toBeTruthy()
|
|
207
|
+
expect(screen.getByText("Alex Admin")).toBeTruthy()
|
|
208
|
+
|
|
209
|
+
fireEvent.keyDown(document, { key: "Escape" })
|
|
210
|
+
expect(screen.queryByPlaceholderText("Search contacts...")).toBeNull()
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it("adds a confirmed recipient when a contact is selected", () => {
|
|
214
|
+
const onChange = vi.fn()
|
|
215
|
+
render(
|
|
216
|
+
<EmailRecipientField
|
|
217
|
+
label="To"
|
|
218
|
+
recipients={[]}
|
|
219
|
+
onRecipientsChange={onChange}
|
|
220
|
+
showPicker
|
|
221
|
+
contacts={contacts}
|
|
222
|
+
/>,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
|
|
226
|
+
fireEvent.click(screen.getByText("Alex Admin"))
|
|
227
|
+
|
|
228
|
+
expect(onChange).toHaveBeenCalledWith([
|
|
229
|
+
{ id: "alex@example.com", email: "alex@example.com", name: "Alex Admin", confirmed: true },
|
|
230
|
+
])
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it("disables rows for added and no-email contacts", () => {
|
|
234
|
+
render(
|
|
235
|
+
<EmailRecipientField
|
|
236
|
+
label="To"
|
|
237
|
+
recipients={[]}
|
|
238
|
+
onRecipientsChange={vi.fn()}
|
|
239
|
+
showPicker
|
|
240
|
+
contacts={contacts}
|
|
241
|
+
addedEmails={new Set(["alex@example.com"])}
|
|
242
|
+
/>,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
|
|
246
|
+
|
|
247
|
+
const options = screen.getAllByRole("option")
|
|
248
|
+
const alexRow = options.find((o) => o.textContent?.includes("Alex Admin"))!
|
|
249
|
+
const noEmailRow = options.find((o) => o.textContent?.includes("No Email"))!
|
|
250
|
+
|
|
251
|
+
expect(alexRow.className).toContain("pointer-events-none")
|
|
252
|
+
expect(alexRow.textContent).toContain("Added")
|
|
253
|
+
expect(noEmailRow.className).toContain("pointer-events-none")
|
|
254
|
+
expect(noEmailRow.textContent).toContain("No email")
|
|
255
|
+
})
|
|
256
|
+
})
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from "vitest"
|
|
2
|
+
import React from "react"
|
|
3
|
+
import { render, screen, fireEvent, cleanup } from "@testing-library/react"
|
|
4
|
+
|
|
5
|
+
import { EmailSendBar } from "../email-send-bar"
|
|
6
|
+
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
cleanup()
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
describe("EmailSendBar", () => {
|
|
12
|
+
it("renders primary and secondary actions", () => {
|
|
13
|
+
render(
|
|
14
|
+
<EmailSendBar
|
|
15
|
+
primaryActions={[{ key: "attach", label: "Attach" }]}
|
|
16
|
+
secondaryActions={[{ key: "schedule", label: "Schedule" }]}
|
|
17
|
+
/>,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
expect(screen.getByRole("button", { name: "Attach" })).toBeTruthy()
|
|
21
|
+
expect(screen.getByRole("button", { name: "Schedule" })).toBeTruthy()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it("renders a divider only when both groups exist", () => {
|
|
25
|
+
const { container, rerender } = render(
|
|
26
|
+
<EmailSendBar
|
|
27
|
+
primaryActions={[{ key: "attach", label: "Attach" }]}
|
|
28
|
+
secondaryActions={[{ key: "schedule", label: "Schedule" }]}
|
|
29
|
+
/>,
|
|
30
|
+
)
|
|
31
|
+
expect(container.querySelector(".w-px.h-\\[18px\\]")).toBeTruthy()
|
|
32
|
+
|
|
33
|
+
rerender(
|
|
34
|
+
<EmailSendBar primaryActions={[{ key: "attach", label: "Attach" }]} />,
|
|
35
|
+
)
|
|
36
|
+
expect(container.querySelector(".w-px.h-\\[18px\\]")).toBeNull()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it("renders the send button with custom label", () => {
|
|
40
|
+
render(<EmailSendBar sendLabel="Send email" />)
|
|
41
|
+
expect(screen.getByRole("button", { name: "Send email" })).toBeTruthy()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it("disables the send button when sendDisabled is true", () => {
|
|
45
|
+
render(<EmailSendBar sendDisabled />)
|
|
46
|
+
const send = screen.getByRole("button", { name: "Send" }) as HTMLButtonElement
|
|
47
|
+
expect(send.disabled).toBe(true)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it("fires click handlers", () => {
|
|
51
|
+
const onSend = vi.fn()
|
|
52
|
+
const onAttach = vi.fn()
|
|
53
|
+
|
|
54
|
+
render(
|
|
55
|
+
<EmailSendBar
|
|
56
|
+
primaryActions={[{ key: "attach", label: "Attach", onClick: onAttach }]}
|
|
57
|
+
onSend={onSend}
|
|
58
|
+
/>,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
fireEvent.click(screen.getByRole("button", { name: "Attach" }))
|
|
62
|
+
fireEvent.click(screen.getByRole("button", { name: "Send" }))
|
|
63
|
+
|
|
64
|
+
expect(onAttach).toHaveBeenCalledTimes(1)
|
|
65
|
+
expect(onSend).toHaveBeenCalledTimes(1)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it("does not fire click for disabled action", () => {
|
|
69
|
+
const onAttach = vi.fn()
|
|
70
|
+
render(
|
|
71
|
+
<EmailSendBar
|
|
72
|
+
primaryActions={[
|
|
73
|
+
{ key: "attach", label: "Attach", onClick: onAttach, disabled: true },
|
|
74
|
+
]}
|
|
75
|
+
/>,
|
|
76
|
+
)
|
|
77
|
+
const btn = screen.getByRole("button", { name: "Attach" }) as HTMLButtonElement
|
|
78
|
+
expect(btn.disabled).toBe(true)
|
|
79
|
+
})
|
|
80
|
+
})
|
|
@@ -16,7 +16,7 @@ const positivePills = ["Tone", "Personalization", "Length", "CTA", "Other"]
|
|
|
16
16
|
const negativePills = ["Too formal", "Too casual", "Too long", "Missing context", "Wrong angle", "Factual error", "Other"]
|
|
17
17
|
|
|
18
18
|
export interface DraftFeedbackInlineProps {
|
|
19
|
-
initialDirection?:
|
|
19
|
+
initialDirection?: 'up' | 'down' | null
|
|
20
20
|
onRegenerateRequest?: (pills: string[], detail: string) => void
|
|
21
21
|
onSubmitFeedback?: (type: "up" | "down", pills: string[], detail: string) => void
|
|
22
22
|
onDiscardRequest?: (pills: string[], detail: string) => void
|
|
@@ -70,7 +70,7 @@ export function DraftFeedbackInline({
|
|
|
70
70
|
if (noted) {
|
|
71
71
|
return (
|
|
72
72
|
<div className="flex items-center gap-1.5 py-1 animate-in fade-in slide-in-from-top-1 duration-200">
|
|
73
|
-
<Check className="w-3.5 h-3.5 text-
|
|
73
|
+
<Check className="w-3.5 h-3.5 text-emerald-500" />
|
|
74
74
|
<span className="text-xs text-muted-foreground">Feedback recorded</span>
|
|
75
75
|
</div>
|
|
76
76
|
)
|
|
@@ -88,7 +88,7 @@ export function DraftFeedbackInline({
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
return (
|
|
91
|
-
<div className="space-y-
|
|
91
|
+
<div className="space-y-0">
|
|
92
92
|
<div className="flex items-center justify-between">
|
|
93
93
|
<span className="text-sm text-foreground font-medium">How's this draft?</span>
|
|
94
94
|
<div className="flex gap-1">
|
|
@@ -100,11 +100,11 @@ export function DraftFeedbackInline({
|
|
|
100
100
|
}}
|
|
101
101
|
className={`p-1.5 rounded transition-colors ${
|
|
102
102
|
thumbState === "up"
|
|
103
|
-
? "bg-
|
|
103
|
+
? "bg-emerald-100 text-emerald-600 dark:bg-emerald-900/30 dark:text-emerald-400"
|
|
104
104
|
: "hover:bg-muted text-muted-foreground hover:text-foreground"
|
|
105
105
|
}`}
|
|
106
106
|
>
|
|
107
|
-
<ThumbsUp className="w-4 h-4" />
|
|
107
|
+
<ThumbsUp className="w-4 h-4" fill={thumbState === "up" ? "currentColor" : "none"} />
|
|
108
108
|
</button>
|
|
109
109
|
<button
|
|
110
110
|
onClick={() => {
|
|
@@ -114,31 +114,31 @@ export function DraftFeedbackInline({
|
|
|
114
114
|
}}
|
|
115
115
|
className={`p-1.5 rounded transition-colors ${
|
|
116
116
|
thumbState === "down"
|
|
117
|
-
? "bg-
|
|
117
|
+
? "bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400"
|
|
118
118
|
: "hover:bg-muted text-muted-foreground hover:text-foreground"
|
|
119
119
|
}`}
|
|
120
120
|
>
|
|
121
|
-
<ThumbsDown className="w-4 h-4" />
|
|
121
|
+
<ThumbsDown className="w-4 h-4" fill={thumbState === "down" ? "currentColor" : "none"} />
|
|
122
122
|
</button>
|
|
123
123
|
</div>
|
|
124
124
|
</div>
|
|
125
125
|
|
|
126
126
|
{thumbState && (
|
|
127
|
-
<div className="pt-
|
|
127
|
+
<div className="pt-3 space-y-3 animate-in fade-in slide-in-from-top-2 duration-200">
|
|
128
128
|
<div>
|
|
129
129
|
<span className="text-xs text-muted-foreground mb-2 block font-medium">
|
|
130
130
|
{thumbState === "up" ? "What worked well?" : "What needs improvement?"}
|
|
131
131
|
</span>
|
|
132
|
-
<div className="flex flex-wrap gap-
|
|
132
|
+
<div className="flex flex-wrap gap-1.5">
|
|
133
133
|
{(thumbState === "up" ? positivePills : negativePills).map((pill) => (
|
|
134
134
|
<button
|
|
135
135
|
key={pill}
|
|
136
136
|
onClick={() => togglePill(pill)}
|
|
137
|
-
className={`px-
|
|
137
|
+
className={`px-2.5 py-1 rounded-full text-[11px] font-medium border transition-colors ${
|
|
138
138
|
selectedPills.includes(pill)
|
|
139
139
|
? thumbState === "up"
|
|
140
|
-
? "bg-
|
|
141
|
-
: "bg-red-
|
|
140
|
+
? "bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-300 dark:border-emerald-800"
|
|
141
|
+
: "bg-red-100 text-red-700 border-red-200 dark:bg-red-900/30 dark:text-red-300 dark:border-red-800"
|
|
142
142
|
: "bg-background text-muted-foreground border-border hover:bg-muted/50 hover:text-foreground"
|
|
143
143
|
}`}
|
|
144
144
|
>
|
|
@@ -155,7 +155,7 @@ export function DraftFeedbackInline({
|
|
|
155
155
|
className="w-full text-xs bg-background border border-border rounded-md px-2.5 py-2 text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-indigo-500/50 focus:border-indigo-500/50 resize-none min-h-[60px]"
|
|
156
156
|
/>
|
|
157
157
|
|
|
158
|
-
<div className="flex items-center gap-2
|
|
158
|
+
<div className="flex items-center gap-2 pt-1">
|
|
159
159
|
{thumbState === "down" ? (
|
|
160
160
|
<>
|
|
161
161
|
<button
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
|
|
5
|
+
import { cn } from "../lib/utils"
|
|
6
|
+
|
|
7
|
+
export interface EmailComposerRowProps
|
|
8
|
+
extends React.HTMLAttributes<HTMLDivElement> {
|
|
9
|
+
label: React.ReactNode
|
|
10
|
+
state?: "default" | "amber"
|
|
11
|
+
alignStart?: boolean
|
|
12
|
+
children: React.ReactNode
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function EmailComposerRow({
|
|
16
|
+
label,
|
|
17
|
+
state = "default",
|
|
18
|
+
alignStart = false,
|
|
19
|
+
children,
|
|
20
|
+
className,
|
|
21
|
+
...props
|
|
22
|
+
}: EmailComposerRowProps) {
|
|
23
|
+
const amber = state === "amber"
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div
|
|
27
|
+
className={cn(
|
|
28
|
+
"grid grid-cols-[60px_1fr] gap-2 px-[18px] py-[9px] border-b border-border/70 items-center text-sm",
|
|
29
|
+
alignStart && "items-start",
|
|
30
|
+
amber && "bg-amber-50/35 border-amber-200/80",
|
|
31
|
+
className,
|
|
32
|
+
)}
|
|
33
|
+
{...props}
|
|
34
|
+
>
|
|
35
|
+
<div
|
|
36
|
+
className={cn(
|
|
37
|
+
"text-[11px] font-semibold uppercase tracking-wide text-muted-foreground",
|
|
38
|
+
alignStart && "pt-[7px]",
|
|
39
|
+
amber && "text-amber-700",
|
|
40
|
+
)}
|
|
41
|
+
>
|
|
42
|
+
{label}
|
|
43
|
+
</div>
|
|
44
|
+
<div className="min-w-0">{children}</div>
|
|
45
|
+
</div>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { Eye } from "lucide-react"
|
|
5
|
+
|
|
6
|
+
import { cn } from "../lib/utils"
|
|
7
|
+
|
|
8
|
+
export interface EmailPreviewCardProps {
|
|
9
|
+
from: { name: string; email: string }
|
|
10
|
+
to?: string
|
|
11
|
+
subject?: string
|
|
12
|
+
htmlBody?: string
|
|
13
|
+
textBody?: string
|
|
14
|
+
signatureHtml?: string | null
|
|
15
|
+
className?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getInitials(name: string): string {
|
|
19
|
+
return name
|
|
20
|
+
.split(" ")
|
|
21
|
+
.map((part) => part[0])
|
|
22
|
+
.filter(Boolean)
|
|
23
|
+
.slice(0, 2)
|
|
24
|
+
.join("")
|
|
25
|
+
.toUpperCase()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function escapeHtml(text: string): string {
|
|
29
|
+
return text
|
|
30
|
+
.replace(/&/g, "&")
|
|
31
|
+
.replace(/</g, "<")
|
|
32
|
+
.replace(/>/g, ">")
|
|
33
|
+
.replace(/"/g, """)
|
|
34
|
+
.replace(/'/g, "'")
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function EmailPreviewCard({
|
|
38
|
+
from,
|
|
39
|
+
to,
|
|
40
|
+
subject,
|
|
41
|
+
htmlBody,
|
|
42
|
+
textBody,
|
|
43
|
+
signatureHtml,
|
|
44
|
+
className,
|
|
45
|
+
}: EmailPreviewCardProps) {
|
|
46
|
+
const recipientLabel = to ? `${to}'s` : "the recipient's"
|
|
47
|
+
const bodyHtml = htmlBody ?? (textBody ? escapeHtml(textBody) : "")
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div className={cn("p-4 bg-muted/30 min-h-full", className)}>
|
|
51
|
+
<div className="flex items-start gap-2 mb-3 py-2.5 px-3 border rounded-lg bg-background text-[11.5px] text-muted-foreground">
|
|
52
|
+
<Eye className="size-4 shrink-0 mt-0.5" />
|
|
53
|
+
<span>
|
|
54
|
+
This is how your email lands in {recipientLabel} inbox. Nothing has
|
|
55
|
+
been sent yet.
|
|
56
|
+
</span>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<div className="bg-background border rounded-xl shadow-sm overflow-hidden">
|
|
60
|
+
<div className="px-[18px] pt-4 pb-3 text-base font-semibold border-b border-border/50">
|
|
61
|
+
{subject || "(no subject)"}
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<div className="flex items-center gap-3 px-[18px] pt-3">
|
|
65
|
+
<div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-foreground text-background text-xs font-semibold">
|
|
66
|
+
{getInitials(from.name)}
|
|
67
|
+
</div>
|
|
68
|
+
<div className="min-w-0 flex-1">
|
|
69
|
+
<div className="text-sm">
|
|
70
|
+
<span className="font-semibold text-foreground">{from.name}</span>{" "}
|
|
71
|
+
<span className="text-muted-foreground"><{from.email}></span>
|
|
72
|
+
</div>
|
|
73
|
+
<div className="text-xs text-muted-foreground">
|
|
74
|
+
{to ? `to ${to}` : "to no recipient yet"}
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
<div className="text-xs text-muted-foreground shrink-0">just now</div>
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
<div
|
|
81
|
+
className="px-[18px] py-2 ml-[47px] text-[13.5px] leading-relaxed whitespace-pre-wrap"
|
|
82
|
+
dangerouslySetInnerHTML={{ __html: bodyHtml }}
|
|
83
|
+
/>
|
|
84
|
+
|
|
85
|
+
{signatureHtml ? (
|
|
86
|
+
<div
|
|
87
|
+
className="ml-[47px] px-[18px] pt-3 mt-3 border-t border-border/50 pb-4 text-xs leading-relaxed text-muted-foreground"
|
|
88
|
+
dangerouslySetInnerHTML={{ __html: signatureHtml }}
|
|
89
|
+
/>
|
|
90
|
+
) : null}
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
)
|
|
94
|
+
}
|