@startsimpli/ui 0.4.6 → 0.4.8

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 (122) hide show
  1. package/package.json +2 -1
  2. package/src/__mocks__/next/link.js +11 -0
  3. package/src/components/ActivityTimeline.tsx +173 -0
  4. package/src/components/LogActivityDialog.tsx +303 -0
  5. package/src/components/QuickLogButtons.tsx +32 -0
  6. package/src/components/account/__tests__/account.test.tsx +315 -0
  7. package/src/components/badge/StageBadge.tsx +31 -0
  8. package/src/components/badge/index.ts +3 -0
  9. package/src/components/command-palette/CommandGroup.tsx +23 -0
  10. package/src/components/command-palette/CommandPalette.tsx +327 -0
  11. package/src/components/command-palette/CommandResultItem.tsx +59 -0
  12. package/src/components/command-palette/__tests__/CommandGroup.test.tsx +81 -0
  13. package/src/components/command-palette/__tests__/CommandResultItem.test.tsx +166 -0
  14. package/src/components/command-palette/__tests__/command-palette-context.test.tsx +166 -0
  15. package/src/components/command-palette/__tests__/useCommandPaletteSearch.test.ts +271 -0
  16. package/src/components/command-palette/command-palette-context.tsx +51 -0
  17. package/src/components/command-palette/index.ts +9 -0
  18. package/src/components/command-palette/useCommandPaletteSearch.ts +114 -0
  19. package/src/components/compose/__tests__/compose.test.tsx +656 -0
  20. package/src/components/compose/compose-header.tsx +72 -0
  21. package/src/components/compose/compose-loading.tsx +13 -0
  22. package/src/components/compose/index.ts +6 -0
  23. package/src/components/compose/save-status-indicator.tsx +57 -0
  24. package/src/components/compose/send-confirmation-dialog.tsx +87 -0
  25. package/src/components/compose/subject-input.tsx +25 -0
  26. package/src/components/compose/useAutoSave.ts +93 -0
  27. package/src/components/dashboard/DashboardGrid.tsx +32 -0
  28. package/src/components/dashboard/DashboardSection.tsx +32 -0
  29. package/src/components/dashboard/MetricCard.tsx +129 -0
  30. package/src/components/dashboard/PeriodSelector.tsx +55 -0
  31. package/src/components/dashboard/PipelineFunnel.tsx +126 -0
  32. package/src/components/dashboard/SparklineTrend.tsx +102 -0
  33. package/src/components/dashboard/TopCampaigns.tsx +132 -0
  34. package/src/components/dashboard/__tests__/dashboard.test.tsx +785 -0
  35. package/src/components/dashboard/index.ts +20 -0
  36. package/src/components/dialog/ConfirmDialog.tsx +72 -0
  37. package/src/components/dialog/__tests__/ConfirmDialog.test.tsx +126 -0
  38. package/src/components/dialog/index.ts +3 -0
  39. package/src/components/email-dialogs/__tests__/email-dialogs.test.tsx +982 -0
  40. package/src/components/email-dialogs/index.ts +14 -0
  41. package/src/components/email-dialogs/merge-fields.tsx +196 -0
  42. package/src/components/email-dialogs/preview-dialog.tsx +194 -0
  43. package/src/components/email-dialogs/schedule-dialog.tsx +297 -0
  44. package/src/components/email-dialogs/template-picker.tsx +225 -0
  45. package/src/components/email-dialogs/test-send-dialog.tsx +188 -0
  46. package/src/components/email-editor/BlockRenderer.tsx +120 -0
  47. package/src/components/email-editor/__tests__/BlockRenderer.test.tsx +332 -0
  48. package/src/components/email-editor/__tests__/block-renderers.test.ts +624 -0
  49. package/src/components/email-editor/__tests__/email-html-renderer.test.ts +376 -0
  50. package/src/components/email-editor/add-block-menu.tsx +151 -0
  51. package/src/components/email-editor/block-toolbar.tsx +73 -0
  52. package/src/components/email-editor/blocks/__tests__/blocks.test.tsx +818 -0
  53. package/src/components/email-editor/blocks/button-block.tsx +44 -0
  54. package/src/components/email-editor/blocks/divider-block.tsx +43 -0
  55. package/src/components/email-editor/blocks/footer-block.tsx +39 -0
  56. package/src/components/email-editor/blocks/header-block.tsx +39 -0
  57. package/src/components/email-editor/blocks/image-block.tsx +61 -0
  58. package/src/components/email-editor/blocks/index.ts +9 -0
  59. package/src/components/email-editor/blocks/metrics-block.tsx +198 -0
  60. package/src/components/email-editor/blocks/social-block.tsx +75 -0
  61. package/src/components/email-editor/blocks/spacer-block.tsx +26 -0
  62. package/src/components/email-editor/blocks/text-block.tsx +75 -0
  63. package/src/components/email-editor/editor-sidebar.tsx +66 -0
  64. package/src/components/email-editor/email-editor.tsx +497 -0
  65. package/src/components/email-editor/hooks/__tests__/useDragDrop.test.ts +355 -0
  66. package/src/components/email-editor/hooks/__tests__/useEmailEditorState.test.ts +551 -0
  67. package/src/components/email-editor/hooks/useDragDrop.ts +181 -0
  68. package/src/components/email-editor/hooks/useEmailEditorState.ts +426 -0
  69. package/src/components/email-editor/index.ts +51 -0
  70. package/src/components/email-editor/panels/BlockPropertyPanel.tsx +637 -0
  71. package/src/components/email-editor/panels/GlobalStylesPanel.tsx +108 -0
  72. package/src/components/email-editor/panels/SectionSettingsPanel.tsx +80 -0
  73. package/src/components/email-editor/panels/__tests__/BlockPropertyPanel.test.tsx +707 -0
  74. package/src/components/email-editor/panels/__tests__/GlobalStylesPanel.test.tsx +226 -0
  75. package/src/components/email-editor/panels/index.ts +3 -0
  76. package/src/components/email-editor/renderer/block-renderers.ts +209 -0
  77. package/src/components/email-editor/renderer/email-html-renderer.ts +128 -0
  78. package/src/components/email-editor/types.ts +413 -0
  79. package/src/components/email-editor/utils/defaults.ts +116 -0
  80. package/src/components/email-editor/utils/undo-redo.ts +59 -0
  81. package/src/components/enrichment/EnrichButton.tsx +33 -0
  82. package/src/components/enrichment/EnrichmentProgress.tsx +66 -0
  83. package/src/components/enrichment/QualityBadge.tsx +43 -0
  84. package/src/components/enrichment/__tests__/enrichment.test.tsx +184 -0
  85. package/src/components/enrichment/index.ts +8 -0
  86. package/src/components/gantt/GanttBoardView.tsx +71 -0
  87. package/src/components/gantt/GanttChart.tsx +140 -887
  88. package/src/components/gantt/GanttFilterBar.tsx +100 -0
  89. package/src/components/gantt/GanttListView.tsx +63 -0
  90. package/src/components/gantt/GanttTimelineView.tsx +215 -0
  91. package/src/components/gantt/__tests__/GanttBoardView.test.tsx +305 -0
  92. package/src/components/gantt/__tests__/GanttFilterBar.test.tsx +544 -0
  93. package/src/components/gantt/__tests__/GanttListView.test.tsx +337 -0
  94. package/src/components/gantt/__tests__/GanttTimelineView.test.tsx +375 -0
  95. package/src/components/gantt/__tests__/gantt-utils.test.ts +341 -0
  96. package/src/components/gantt/__tests__/useGanttState.test.ts +535 -0
  97. package/src/components/gantt/hooks/useGanttState.ts +644 -0
  98. package/src/components/gantt/index.ts +10 -0
  99. package/src/components/gantt/types.ts +5 -5
  100. package/src/components/index.ts +46 -0
  101. package/src/components/integrations/ConnectionStatus.tsx +77 -0
  102. package/src/components/integrations/IntegrationCard.tsx +92 -0
  103. package/src/components/integrations/__tests__/integrations.test.tsx +191 -0
  104. package/src/components/integrations/index.ts +5 -0
  105. package/src/components/kanban/KanbanBoard.tsx +103 -0
  106. package/src/components/kanban/__tests__/kanban.test.tsx +157 -0
  107. package/src/components/kanban/index.ts +2 -0
  108. package/src/components/lists/CreateListDialog.tsx +158 -0
  109. package/src/components/lists/ListCard.tsx +77 -0
  110. package/src/components/lists/__tests__/lists.test.tsx +263 -0
  111. package/src/components/lists/index.ts +5 -0
  112. package/src/components/loading/__tests__/loading.test.tsx +114 -0
  113. package/src/components/navigation/__tests__/navigation.test.tsx +194 -0
  114. package/src/components/pipeline/StageTransitionModal.tsx +146 -0
  115. package/src/components/pipeline/__tests__/pipeline.test.tsx +169 -0
  116. package/src/components/pipeline/index.ts +2 -0
  117. package/src/components/settings/SettingsCard.tsx +33 -0
  118. package/src/components/settings/SettingsLayout.tsx +28 -0
  119. package/src/components/settings/SettingsNav.tsx +42 -0
  120. package/src/components/settings/__tests__/settings.test.tsx +181 -0
  121. package/src/components/settings/index.ts +6 -0
  122. package/src/components/wizard/__tests__/wizard.test.tsx +97 -0
@@ -0,0 +1,982 @@
1
+ import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'
2
+ import userEvent from '@testing-library/user-event'
3
+ import { ScheduleDialog } from '../schedule-dialog'
4
+ import { TemplatePicker, EmailTemplate } from '../template-picker'
5
+ import {
6
+ MergeFieldsMenu,
7
+ MergeFieldPreview,
8
+ replaceMergeFields,
9
+ } from '../merge-fields'
10
+ import { PreviewDialog } from '../preview-dialog'
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Shared mocks
14
+ // ---------------------------------------------------------------------------
15
+
16
+ // Render Radix portals inline so dialog content is queryable
17
+ jest.mock('@radix-ui/react-dialog', () => {
18
+ const actual = jest.requireActual('@radix-ui/react-dialog')
19
+ return {
20
+ ...actual,
21
+ Portal: ({ children }: { children: React.ReactNode }) => <>{children}</>,
22
+ }
23
+ })
24
+
25
+ // Render Radix dropdown portal inline
26
+ jest.mock('@radix-ui/react-dropdown-menu', () => {
27
+ const actual = jest.requireActual('@radix-ui/react-dropdown-menu')
28
+ return {
29
+ ...actual,
30
+ Portal: ({ children }: { children: React.ReactNode }) => <>{children}</>,
31
+ }
32
+ })
33
+
34
+ // Render Radix select portal inline
35
+ jest.mock('@radix-ui/react-select', () => {
36
+ const actual = jest.requireActual('@radix-ui/react-select')
37
+ return {
38
+ ...actual,
39
+ Portal: ({ children }: { children: React.ReactNode }) => <>{children}</>,
40
+ }
41
+ })
42
+
43
+ // Render Radix popover portal inline
44
+ jest.mock('@radix-ui/react-popover', () => {
45
+ const actual = jest.requireActual('@radix-ui/react-popover')
46
+ return {
47
+ ...actual,
48
+ Portal: ({ children }: { children: React.ReactNode }) => <>{children}</>,
49
+ }
50
+ })
51
+
52
+ // Mock useToast so we can assert toast calls without real toast state
53
+ const mockToast = jest.fn()
54
+ jest.mock('../../toast', () => ({
55
+ useToast: () => ({ toast: mockToast }),
56
+ }))
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // ScheduleDialog
60
+ // ---------------------------------------------------------------------------
61
+
62
+ describe('ScheduleDialog', () => {
63
+ const defaultProps = {
64
+ open: true,
65
+ onOpenChange: jest.fn(),
66
+ onSchedule: jest.fn().mockResolvedValue(undefined),
67
+ summaryItems: [
68
+ { label: 'Subject', value: 'Q1 Update' },
69
+ { label: 'Recipients', value: '150 contacts' },
70
+ ],
71
+ }
72
+
73
+ beforeEach(() => {
74
+ jest.clearAllMocks()
75
+ jest.useFakeTimers()
76
+ jest.setSystemTime(new Date('2026-01-01T08:00:00.000Z'))
77
+ })
78
+
79
+ afterEach(() => {
80
+ jest.runOnlyPendingTimers()
81
+ jest.useRealTimers()
82
+ })
83
+
84
+ it('renders the default title', () => {
85
+ render(<ScheduleDialog {...defaultProps} />)
86
+ expect(screen.getByRole('heading', { name: 'Schedule Send' })).toBeInTheDocument()
87
+ })
88
+
89
+ it('renders the default description', () => {
90
+ render(<ScheduleDialog {...defaultProps} />)
91
+ expect(screen.getByText('Choose when to send this email')).toBeInTheDocument()
92
+ })
93
+
94
+ it('renders a custom title and description', () => {
95
+ render(
96
+ <ScheduleDialog
97
+ {...defaultProps}
98
+ title="Schedule Campaign"
99
+ description="Pick a delivery time"
100
+ />
101
+ )
102
+ expect(screen.getByRole('heading', { name: 'Schedule Campaign' })).toBeInTheDocument()
103
+ expect(screen.getByText('Pick a delivery time')).toBeInTheDocument()
104
+ })
105
+
106
+ it('renders summary item labels and values', () => {
107
+ render(<ScheduleDialog {...defaultProps} />)
108
+ expect(screen.getByText('Subject:')).toBeInTheDocument()
109
+ expect(screen.getByText('Q1 Update')).toBeInTheDocument()
110
+ expect(screen.getByText('Recipients:')).toBeInTheDocument()
111
+ expect(screen.getByText('150 contacts')).toBeInTheDocument()
112
+ })
113
+
114
+ it('renders all four quick schedule option buttons', () => {
115
+ render(<ScheduleDialog {...defaultProps} />)
116
+ expect(screen.getByRole('button', { name: 'Tomorrow 9 AM' })).toBeInTheDocument()
117
+ expect(screen.getByRole('button', { name: 'Tomorrow 2 PM' })).toBeInTheDocument()
118
+ expect(screen.getByRole('button', { name: 'Monday 9 AM' })).toBeInTheDocument()
119
+ expect(screen.getByRole('button', { name: 'Next week' })).toBeInTheDocument()
120
+ })
121
+
122
+ it('renders Date and Time labels', () => {
123
+ render(<ScheduleDialog {...defaultProps} />)
124
+ expect(screen.getByText('Date')).toBeInTheDocument()
125
+ expect(screen.getByText('Time')).toBeInTheDocument()
126
+ })
127
+
128
+ it('renders Cancel and Schedule Send buttons', () => {
129
+ render(<ScheduleDialog {...defaultProps} />)
130
+ expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument()
131
+ expect(screen.getByRole('button', { name: /schedule send/i })).toBeInTheDocument()
132
+ })
133
+
134
+ it('calls onOpenChange(false) when Cancel is clicked', () => {
135
+ render(<ScheduleDialog {...defaultProps} />)
136
+ fireEvent.click(screen.getByRole('button', { name: /cancel/i }))
137
+ expect(defaultProps.onOpenChange).toHaveBeenCalledWith(false)
138
+ })
139
+
140
+ it('Schedule Send button is enabled when default date is in the future', () => {
141
+ // System time = 2026-01-01T08:00:00Z. Default date = addDays(now, 1) at 09:00 — valid.
142
+ render(<ScheduleDialog {...defaultProps} />)
143
+ expect(screen.getByRole('button', { name: /schedule send/i })).not.toBeDisabled()
144
+ })
145
+
146
+ it('calls onSchedule with an ISO date string when Schedule Send is clicked', async () => {
147
+ render(<ScheduleDialog {...defaultProps} />)
148
+ await act(async () => {
149
+ fireEvent.click(screen.getByRole('button', { name: /schedule send/i }))
150
+ })
151
+ await waitFor(() => {
152
+ expect(defaultProps.onSchedule).toHaveBeenCalledTimes(1)
153
+ })
154
+ const calledWith: string = defaultProps.onSchedule.mock.calls[0][0]
155
+ expect(typeof calledWith).toBe('string')
156
+ expect(isNaN(Date.parse(calledWith))).toBe(false)
157
+ })
158
+
159
+ it('calls onSaveDraft before onSchedule when hasUnsavedChanges is true', async () => {
160
+ const onSaveDraft = jest.fn().mockResolvedValue(undefined)
161
+ const onSchedule = jest.fn().mockResolvedValue(undefined)
162
+ render(
163
+ <ScheduleDialog
164
+ {...defaultProps}
165
+ onSchedule={onSchedule}
166
+ onSaveDraft={onSaveDraft}
167
+ hasUnsavedChanges
168
+ />
169
+ )
170
+ await act(async () => {
171
+ fireEvent.click(screen.getByRole('button', { name: /schedule send/i }))
172
+ })
173
+ await waitFor(() => {
174
+ expect(onSaveDraft).toHaveBeenCalled()
175
+ expect(onSchedule).toHaveBeenCalled()
176
+ })
177
+ expect(onSaveDraft.mock.invocationCallOrder[0]).toBeLessThan(
178
+ onSchedule.mock.invocationCallOrder[0]
179
+ )
180
+ })
181
+
182
+ it('does not call onSaveDraft when hasUnsavedChanges is false', async () => {
183
+ const onSaveDraft = jest.fn().mockResolvedValue(undefined)
184
+ const onSchedule = jest.fn().mockResolvedValue(undefined)
185
+ render(
186
+ <ScheduleDialog
187
+ {...defaultProps}
188
+ onSchedule={onSchedule}
189
+ onSaveDraft={onSaveDraft}
190
+ hasUnsavedChanges={false}
191
+ />
192
+ )
193
+ await act(async () => {
194
+ fireEvent.click(screen.getByRole('button', { name: /schedule send/i }))
195
+ })
196
+ await waitFor(() => expect(onSchedule).toHaveBeenCalled())
197
+ expect(onSaveDraft).not.toHaveBeenCalled()
198
+ })
199
+
200
+ it('calls onPrepareRecipients before onSchedule when provided', async () => {
201
+ const onPrepareRecipients = jest.fn().mockResolvedValue(undefined)
202
+ const onSchedule = jest.fn().mockResolvedValue(undefined)
203
+ render(
204
+ <ScheduleDialog
205
+ {...defaultProps}
206
+ onSchedule={onSchedule}
207
+ onPrepareRecipients={onPrepareRecipients}
208
+ />
209
+ )
210
+ await act(async () => {
211
+ fireEvent.click(screen.getByRole('button', { name: /schedule send/i }))
212
+ })
213
+ await waitFor(() => {
214
+ expect(onPrepareRecipients).toHaveBeenCalled()
215
+ expect(onSchedule).toHaveBeenCalled()
216
+ })
217
+ expect(onPrepareRecipients.mock.invocationCallOrder[0]).toBeLessThan(
218
+ onSchedule.mock.invocationCallOrder[0]
219
+ )
220
+ })
221
+
222
+ it('shows a success toast and closes dialog after successful scheduling', async () => {
223
+ const onOpenChange = jest.fn()
224
+ render(<ScheduleDialog {...defaultProps} onOpenChange={onOpenChange} />)
225
+ await act(async () => {
226
+ fireEvent.click(screen.getByRole('button', { name: /schedule send/i }))
227
+ })
228
+ await waitFor(() => {
229
+ expect(mockToast).toHaveBeenCalledWith(
230
+ expect.objectContaining({ description: expect.stringContaining('Scheduled for') })
231
+ )
232
+ expect(onOpenChange).toHaveBeenCalledWith(false)
233
+ })
234
+ })
235
+
236
+ it('shows a destructive toast when onSchedule throws', async () => {
237
+ const onSchedule = jest.fn().mockRejectedValue(new Error('Network error'))
238
+ render(<ScheduleDialog {...defaultProps} onSchedule={onSchedule} />)
239
+ await act(async () => {
240
+ fireEvent.click(screen.getByRole('button', { name: /schedule send/i }))
241
+ })
242
+ await waitFor(() => {
243
+ expect(mockToast).toHaveBeenCalledWith(
244
+ expect.objectContaining({
245
+ description: 'Network error',
246
+ variant: 'destructive',
247
+ })
248
+ )
249
+ })
250
+ })
251
+
252
+ it('shows scheduled preview after clicking a quick option', async () => {
253
+ render(<ScheduleDialog {...defaultProps} />)
254
+ await act(async () => {
255
+ fireEvent.click(screen.getByRole('button', { name: 'Next week' }))
256
+ })
257
+ expect(screen.getByText(/Scheduled for:/)).toBeInTheDocument()
258
+ })
259
+
260
+ it('renders custom scheduledInfoMessage in the preview box', async () => {
261
+ render(
262
+ <ScheduleDialog
263
+ {...defaultProps}
264
+ scheduledInfoMessage="Remember to review before send."
265
+ />
266
+ )
267
+ await act(async () => {
268
+ fireEvent.click(screen.getByRole('button', { name: 'Next week' }))
269
+ })
270
+ expect(screen.getByText('Remember to review before send.')).toBeInTheDocument()
271
+ })
272
+
273
+ it('does not render when open is false', () => {
274
+ render(<ScheduleDialog {...defaultProps} open={false} />)
275
+ expect(screen.queryByRole('heading', { name: 'Schedule Send' })).not.toBeInTheDocument()
276
+ })
277
+ })
278
+
279
+ // ---------------------------------------------------------------------------
280
+ // TemplatePicker
281
+ // ---------------------------------------------------------------------------
282
+
283
+ const SAMPLE_TEMPLATES: EmailTemplate[] = [
284
+ {
285
+ id: '1',
286
+ name: 'Welcome Email',
287
+ description: 'Onboarding welcome message',
288
+ subject: 'Welcome to our platform!',
289
+ blocks: null,
290
+ category: 'onboarding',
291
+ isDefault: true,
292
+ useCount: 10,
293
+ },
294
+ {
295
+ id: '2',
296
+ name: 'Follow Up',
297
+ description: 'Post-demo follow up',
298
+ subject: 'Great talking with you',
299
+ blocks: null,
300
+ category: 'sales',
301
+ isDefault: false,
302
+ useCount: 5,
303
+ },
304
+ {
305
+ id: '3',
306
+ name: 'Newsletter',
307
+ description: null,
308
+ subject: 'Monthly updates',
309
+ blocks: null,
310
+ category: 'marketing',
311
+ isDefault: false,
312
+ useCount: 0,
313
+ },
314
+ ]
315
+
316
+ describe('TemplatePicker', () => {
317
+ const defaultProps = {
318
+ open: true,
319
+ onOpenChange: jest.fn(),
320
+ onSelectTemplate: jest.fn(),
321
+ onLoadTemplates: jest.fn().mockResolvedValue(SAMPLE_TEMPLATES),
322
+ }
323
+
324
+ beforeEach(() => {
325
+ jest.clearAllMocks()
326
+ defaultProps.onLoadTemplates.mockResolvedValue(SAMPLE_TEMPLATES)
327
+ })
328
+
329
+ it('shows a loading state while templates are being fetched', async () => {
330
+ let resolve!: (v: EmailTemplate[]) => void
331
+ const onLoadTemplates = jest.fn(
332
+ () => new Promise<EmailTemplate[]>((res) => { resolve = res })
333
+ )
334
+ render(<TemplatePicker {...defaultProps} onLoadTemplates={onLoadTemplates} />)
335
+ expect(screen.getByRole('status', { name: /loading/i })).toBeInTheDocument()
336
+ await act(async () => { resolve([]) })
337
+ })
338
+
339
+ it('renders all templates after loading', async () => {
340
+ render(<TemplatePicker {...defaultProps} />)
341
+ await waitFor(() => {
342
+ expect(screen.getByText('Welcome Email')).toBeInTheDocument()
343
+ expect(screen.getByText('Follow Up')).toBeInTheDocument()
344
+ expect(screen.getByText('Newsletter')).toBeInTheDocument()
345
+ })
346
+ })
347
+
348
+ it('renders template descriptions when present', async () => {
349
+ render(<TemplatePicker {...defaultProps} />)
350
+ await waitFor(() => {
351
+ expect(screen.getByText('Onboarding welcome message')).toBeInTheDocument()
352
+ expect(screen.getByText('Post-demo follow up')).toBeInTheDocument()
353
+ })
354
+ })
355
+
356
+ it('renders template subjects', async () => {
357
+ render(<TemplatePicker {...defaultProps} />)
358
+ await waitFor(() => {
359
+ expect(screen.getByText(/Welcome to our platform!/)).toBeInTheDocument()
360
+ })
361
+ })
362
+
363
+ it('renders a "Default" badge for default templates', async () => {
364
+ render(<TemplatePicker {...defaultProps} />)
365
+ await waitFor(() => {
366
+ expect(screen.getByText('Default')).toBeInTheDocument()
367
+ })
368
+ })
369
+
370
+ it('shows available template count', async () => {
371
+ render(<TemplatePicker {...defaultProps} />)
372
+ await waitFor(() => {
373
+ expect(screen.getByText('3 templates available')).toBeInTheDocument()
374
+ })
375
+ })
376
+
377
+ it('renders the search input', async () => {
378
+ render(<TemplatePicker {...defaultProps} />)
379
+ await waitFor(() => {
380
+ expect(screen.getByPlaceholderText('Search templates...')).toBeInTheDocument()
381
+ })
382
+ })
383
+
384
+ it('filters templates by name when searching', async () => {
385
+ render(<TemplatePicker {...defaultProps} />)
386
+ await waitFor(() => screen.getByPlaceholderText('Search templates...'))
387
+
388
+ fireEvent.change(screen.getByPlaceholderText('Search templates...'), {
389
+ target: { value: 'follow' },
390
+ })
391
+
392
+ expect(screen.getByText('Follow Up')).toBeInTheDocument()
393
+ expect(screen.queryByText('Welcome Email')).not.toBeInTheDocument()
394
+ expect(screen.queryByText('Newsletter')).not.toBeInTheDocument()
395
+ })
396
+
397
+ it('filters templates by subject when searching', async () => {
398
+ render(<TemplatePicker {...defaultProps} />)
399
+ await waitFor(() => screen.getByPlaceholderText('Search templates...'))
400
+
401
+ fireEvent.change(screen.getByPlaceholderText('Search templates...'), {
402
+ target: { value: 'monthly' },
403
+ })
404
+
405
+ expect(screen.getByText('Newsletter')).toBeInTheDocument()
406
+ expect(screen.queryByText('Welcome Email')).not.toBeInTheDocument()
407
+ })
408
+
409
+ it('filters templates by description when searching', async () => {
410
+ render(<TemplatePicker {...defaultProps} />)
411
+ await waitFor(() => screen.getByPlaceholderText('Search templates...'))
412
+
413
+ fireEvent.change(screen.getByPlaceholderText('Search templates...'), {
414
+ target: { value: 'onboarding' },
415
+ })
416
+
417
+ expect(screen.getByText('Welcome Email')).toBeInTheDocument()
418
+ expect(screen.queryByText('Follow Up')).not.toBeInTheDocument()
419
+ })
420
+
421
+ it('shows "No templates match your search" when no results', async () => {
422
+ render(<TemplatePicker {...defaultProps} />)
423
+ await waitFor(() => screen.getByPlaceholderText('Search templates...'))
424
+
425
+ fireEvent.change(screen.getByPlaceholderText('Search templates...'), {
426
+ target: { value: 'xyzzy-no-match' },
427
+ })
428
+
429
+ expect(screen.getByText('No templates match your search')).toBeInTheDocument()
430
+ })
431
+
432
+ it('updates count as search filters results', async () => {
433
+ render(<TemplatePicker {...defaultProps} />)
434
+ await waitFor(() => screen.getByPlaceholderText('Search templates...'))
435
+
436
+ fireEvent.change(screen.getByPlaceholderText('Search templates...'), {
437
+ target: { value: 'follow' },
438
+ })
439
+
440
+ expect(screen.getByText('1 template available')).toBeInTheDocument()
441
+ })
442
+
443
+ it('calls onSelectTemplate with the clicked template and closes the dialog', async () => {
444
+ const onSelectTemplate = jest.fn()
445
+ const onOpenChange = jest.fn()
446
+ render(
447
+ <TemplatePicker
448
+ {...defaultProps}
449
+ onSelectTemplate={onSelectTemplate}
450
+ onOpenChange={onOpenChange}
451
+ />
452
+ )
453
+ await waitFor(() => screen.getByText('Follow Up'))
454
+
455
+ fireEvent.click(screen.getByRole('option', { name: /select follow up template/i }))
456
+
457
+ expect(onSelectTemplate).toHaveBeenCalledTimes(1)
458
+ expect(onSelectTemplate).toHaveBeenCalledWith(
459
+ expect.objectContaining({ id: '2', name: 'Follow Up' })
460
+ )
461
+ expect(onOpenChange).toHaveBeenCalledWith(false)
462
+ })
463
+
464
+ it('filters by categoryFilter prop', async () => {
465
+ render(<TemplatePicker {...defaultProps} categoryFilter="sales" />)
466
+ await waitFor(() => {
467
+ expect(screen.getByText('Follow Up')).toBeInTheDocument()
468
+ expect(screen.queryByText('Welcome Email')).not.toBeInTheDocument()
469
+ expect(screen.queryByText('Newsletter')).not.toBeInTheDocument()
470
+ })
471
+ })
472
+
473
+ it('shows error state when onLoadTemplates rejects', async () => {
474
+ const onLoadTemplates = jest.fn().mockRejectedValue(new Error('API down'))
475
+ render(<TemplatePicker {...defaultProps} onLoadTemplates={onLoadTemplates} />)
476
+ await waitFor(() => {
477
+ expect(screen.getByText('Failed to load templates')).toBeInTheDocument()
478
+ })
479
+ })
480
+
481
+ it('shows Retry button on error and retries on click', async () => {
482
+ const onLoadTemplates = jest.fn()
483
+ .mockRejectedValueOnce(new Error('API down'))
484
+ .mockResolvedValueOnce(SAMPLE_TEMPLATES)
485
+
486
+ render(<TemplatePicker {...defaultProps} onLoadTemplates={onLoadTemplates} />)
487
+ await waitFor(() => screen.getByText('Retry'))
488
+
489
+ fireEvent.click(screen.getByText('Retry'))
490
+
491
+ await waitFor(() => {
492
+ expect(screen.getByText('Welcome Email')).toBeInTheDocument()
493
+ })
494
+ })
495
+
496
+ it('shows empty state when no templates are returned', async () => {
497
+ const onLoadTemplates = jest.fn().mockResolvedValue([])
498
+ render(<TemplatePicker {...defaultProps} onLoadTemplates={onLoadTemplates} />)
499
+ await waitFor(() => {
500
+ expect(screen.getByText('No templates available')).toBeInTheDocument()
501
+ })
502
+ })
503
+
504
+ it('shows "Create Default Templates" button in empty state when onSeedTemplates is provided', async () => {
505
+ const onLoadTemplates = jest.fn().mockResolvedValue([])
506
+ const onSeedTemplates = jest.fn().mockResolvedValue(undefined)
507
+ render(
508
+ <TemplatePicker
509
+ {...defaultProps}
510
+ onLoadTemplates={onLoadTemplates}
511
+ onSeedTemplates={onSeedTemplates}
512
+ />
513
+ )
514
+ await waitFor(() => {
515
+ expect(screen.getByText('Create Default Templates')).toBeInTheDocument()
516
+ })
517
+ })
518
+
519
+ it('normalizes templates using subjectTemplate when subject is empty', async () => {
520
+ const rawTemplates = [
521
+ {
522
+ id: '99',
523
+ name: 'Alt Template',
524
+ description: null,
525
+ subject: '',
526
+ blocks: null,
527
+ category: null,
528
+ isDefault: false,
529
+ useCount: 0,
530
+ subjectTemplate: 'Fallback Subject',
531
+ },
532
+ ]
533
+ const onLoadTemplates = jest.fn().mockResolvedValue(rawTemplates)
534
+ render(<TemplatePicker {...defaultProps} onLoadTemplates={onLoadTemplates} />)
535
+ await waitFor(() => {
536
+ expect(screen.getByText(/Fallback Subject/)).toBeInTheDocument()
537
+ })
538
+ })
539
+
540
+ it('calls onOpenChange(false) when Cancel is clicked', async () => {
541
+ const onOpenChange = jest.fn()
542
+ render(<TemplatePicker {...defaultProps} onOpenChange={onOpenChange} />)
543
+ await waitFor(() => screen.getByText('Cancel'))
544
+ fireEvent.click(screen.getByText('Cancel'))
545
+ expect(onOpenChange).toHaveBeenCalledWith(false)
546
+ })
547
+
548
+ it('does not render content when open is false', () => {
549
+ render(<TemplatePicker {...defaultProps} open={false} />)
550
+ expect(screen.queryByText('Choose a Template')).not.toBeInTheDocument()
551
+ })
552
+
553
+ it('resets search when dialog is reopened', async () => {
554
+ const { rerender } = render(<TemplatePicker {...defaultProps} />)
555
+ await waitFor(() => screen.getByPlaceholderText('Search templates...'))
556
+
557
+ fireEvent.change(screen.getByPlaceholderText('Search templates...'), {
558
+ target: { value: 'follow' },
559
+ })
560
+ expect(screen.queryByText('Welcome Email')).not.toBeInTheDocument()
561
+
562
+ rerender(<TemplatePicker {...defaultProps} open={false} />)
563
+ rerender(<TemplatePicker {...defaultProps} open={true} />)
564
+
565
+ await waitFor(() => {
566
+ expect(screen.getByText('Welcome Email')).toBeInTheDocument()
567
+ })
568
+ })
569
+ })
570
+
571
+ // ---------------------------------------------------------------------------
572
+ // MergeFieldsMenu
573
+ // ---------------------------------------------------------------------------
574
+
575
+ describe('MergeFieldsMenu', () => {
576
+ beforeEach(() => {
577
+ jest.clearAllMocks()
578
+ })
579
+
580
+ it('renders the "Insert Merge Field" trigger button by default', () => {
581
+ render(<MergeFieldsMenu onInsert={jest.fn()} />)
582
+ expect(screen.getByRole('button', { name: /insert merge field/i })).toBeInTheDocument()
583
+ })
584
+
585
+ it('renders a compact trigger button when variant is "compact"', () => {
586
+ render(<MergeFieldsMenu onInsert={jest.fn()} variant="compact" />)
587
+ expect(screen.getByText('Merge')).toBeInTheDocument()
588
+ })
589
+
590
+ it('opens the dropdown menu when trigger is clicked', async () => {
591
+ const user = userEvent.setup()
592
+ render(<MergeFieldsMenu onInsert={jest.fn()} />)
593
+ await user.click(screen.getByRole('button', { name: /insert merge field/i }))
594
+ await waitFor(() => {
595
+ expect(screen.getByText('Recipient')).toBeInTheDocument()
596
+ })
597
+ })
598
+
599
+ it('renders all default category labels after opening', async () => {
600
+ const user = userEvent.setup()
601
+ render(<MergeFieldsMenu onInsert={jest.fn()} />)
602
+ await user.click(screen.getByRole('button', { name: /insert merge field/i }))
603
+ await waitFor(() => {
604
+ expect(screen.getByText('Recipient')).toBeInTheDocument()
605
+ // 'Organization' appears as both a category label and a field label —
606
+ // assert at least one instance is present
607
+ expect(screen.getAllByText('Organization').length).toBeGreaterThanOrEqual(1)
608
+ expect(screen.getByText('Sender')).toBeInTheDocument()
609
+ expect(screen.getByText('Date')).toBeInTheDocument()
610
+ })
611
+ })
612
+
613
+ it('renders field labels from default fields after opening', async () => {
614
+ const user = userEvent.setup()
615
+ render(<MergeFieldsMenu onInsert={jest.fn()} />)
616
+ await user.click(screen.getByRole('button', { name: /insert merge field/i }))
617
+ await waitFor(() => {
618
+ expect(screen.getByText('First Name')).toBeInTheDocument()
619
+ expect(screen.getByText('Last Name')).toBeInTheDocument()
620
+ })
621
+ })
622
+
623
+ it('renders example values for each field', async () => {
624
+ const user = userEvent.setup()
625
+ render(<MergeFieldsMenu onInsert={jest.fn()} />)
626
+ await user.click(screen.getByRole('button', { name: /insert merge field/i }))
627
+ await waitFor(() => {
628
+ expect(screen.getByText('John')).toBeInTheDocument()
629
+ expect(screen.getByText('Acme Corp')).toBeInTheDocument()
630
+ })
631
+ })
632
+
633
+ it('calls onInsert with the field key when a merge field item is clicked', async () => {
634
+ const user = userEvent.setup()
635
+ const onInsert = jest.fn()
636
+ render(<MergeFieldsMenu onInsert={onInsert} />)
637
+ await user.click(screen.getByRole('button', { name: /insert merge field/i }))
638
+ await waitFor(() => screen.getByText('First Name'))
639
+
640
+ await user.click(screen.getByText('First Name'))
641
+
642
+ expect(onInsert).toHaveBeenCalledTimes(1)
643
+ expect(onInsert).toHaveBeenCalledWith('{{recipient.firstName}}')
644
+ })
645
+
646
+ it('calls onInsert with sender.name key when "Your Company" is clicked', async () => {
647
+ const user = userEvent.setup()
648
+ const onInsert = jest.fn()
649
+ render(<MergeFieldsMenu onInsert={onInsert} />)
650
+ await user.click(screen.getByRole('button', { name: /insert merge field/i }))
651
+ await waitFor(() => screen.getByText('Your Company'))
652
+
653
+ await user.click(screen.getByText('Your Company'))
654
+
655
+ expect(onInsert).toHaveBeenCalledWith('{{sender.name}}')
656
+ })
657
+
658
+ it('renders custom category labels when provided', async () => {
659
+ const user = userEvent.setup()
660
+ const customFields = [
661
+ { key: '{{custom.field}}', label: 'Custom Field', example: 'Value', category: 'custom' },
662
+ ]
663
+ const customCategories = [{ name: 'custom', label: 'Custom' }]
664
+
665
+ render(
666
+ <MergeFieldsMenu
667
+ onInsert={jest.fn()}
668
+ fields={customFields}
669
+ categories={customCategories}
670
+ />
671
+ )
672
+ await user.click(screen.getByRole('button', { name: /insert merge field/i }))
673
+ await waitFor(() => {
674
+ expect(screen.getByText('Custom')).toBeInTheDocument()
675
+ expect(screen.getByText('Custom Field')).toBeInTheDocument()
676
+ })
677
+ })
678
+
679
+ it('calls onInsert with the custom field key', async () => {
680
+ const user = userEvent.setup()
681
+ const onInsert = jest.fn()
682
+ const customFields = [
683
+ { key: '{{custom.field}}', label: 'My Field', example: 'ex', category: 'custom' },
684
+ ]
685
+ const customCategories = [{ name: 'custom', label: 'Custom' }]
686
+
687
+ render(
688
+ <MergeFieldsMenu
689
+ onInsert={onInsert}
690
+ fields={customFields}
691
+ categories={customCategories}
692
+ />
693
+ )
694
+ await user.click(screen.getByRole('button', { name: /insert merge field/i }))
695
+ await waitFor(() => screen.getByText('My Field'))
696
+
697
+ await user.click(screen.getByText('My Field'))
698
+
699
+ expect(onInsert).toHaveBeenCalledWith('{{custom.field}}')
700
+ })
701
+ })
702
+
703
+ // ---------------------------------------------------------------------------
704
+ // MergeFieldPreview (pure function)
705
+ // ---------------------------------------------------------------------------
706
+
707
+ describe('MergeFieldPreview', () => {
708
+ it('replaces known merge field tokens with default sample data', () => {
709
+ const result = MergeFieldPreview({ content: 'Hello {{recipient.firstName}}' })
710
+ expect(result).toBe('Hello John')
711
+ })
712
+
713
+ it('replaces multiple tokens in a single string', () => {
714
+ const result = MergeFieldPreview({
715
+ content: '{{recipient.firstName}} from {{organization.name}}',
716
+ })
717
+ expect(result).toBe('John from Acme Corp')
718
+ })
719
+
720
+ it('overrides default sample data with provided sampleData', () => {
721
+ const result = MergeFieldPreview({
722
+ content: 'Hi {{recipient.firstName}}',
723
+ sampleData: { '{{recipient.firstName}}': 'Alice' },
724
+ })
725
+ expect(result).toBe('Hi Alice')
726
+ })
727
+
728
+ it('returns content unchanged when no tokens match', () => {
729
+ const result = MergeFieldPreview({ content: 'Hello world' })
730
+ expect(result).toBe('Hello world')
731
+ })
732
+
733
+ it('handles empty string content', () => {
734
+ const result = MergeFieldPreview({ content: '' })
735
+ expect(result).toBe('')
736
+ })
737
+
738
+ it('replaces sender name token', () => {
739
+ const result = MergeFieldPreview({
740
+ content: 'From: {{sender.name}}',
741
+ sampleData: { '{{sender.name}}': 'My Biz' },
742
+ })
743
+ expect(result).toBe('From: My Biz')
744
+ })
745
+ })
746
+
747
+ // ---------------------------------------------------------------------------
748
+ // replaceMergeFields (pure utility)
749
+ // ---------------------------------------------------------------------------
750
+
751
+ describe('replaceMergeFields', () => {
752
+ it('replaces recipient firstName token', () => {
753
+ const result = replaceMergeFields('Hi {{recipient.firstName}}', { firstName: 'Alice' })
754
+ expect(result).toBe('Hi Alice')
755
+ })
756
+
757
+ it('replaces recipient fullName using the name field when firstName/lastName absent', () => {
758
+ const result = replaceMergeFields('{{recipient.fullName}}', { name: 'Bob Jones' })
759
+ expect(result).toBe('Bob Jones')
760
+ })
761
+
762
+ it('derives firstName from name when not explicitly provided', () => {
763
+ const result = replaceMergeFields('{{recipient.firstName}}', { name: 'Bob Jones' })
764
+ expect(result).toBe('Bob')
765
+ })
766
+
767
+ it('replaces organization name token', () => {
768
+ const result = replaceMergeFields('{{organization.name}}', {}, { name: 'Acme Corp' })
769
+ expect(result).toBe('Acme Corp')
770
+ })
771
+
772
+ it('replaces sender tokens', () => {
773
+ const result = replaceMergeFields(
774
+ '{{sender.name}} / {{sender.contactName}}',
775
+ {},
776
+ undefined,
777
+ { name: 'My Biz', contactName: 'Jane' }
778
+ )
779
+ expect(result).toBe('My Biz / Jane')
780
+ })
781
+
782
+ it('replaces recipient email token', () => {
783
+ const result = replaceMergeFields('{{recipient.email}}', { email: 'bob@example.com' })
784
+ expect(result).toBe('bob@example.com')
785
+ })
786
+
787
+ it('replaces recipient title token', () => {
788
+ const result = replaceMergeFields('{{recipient.title}}', { title: 'CTO' })
789
+ expect(result).toBe('CTO')
790
+ })
791
+
792
+ it('replaces title with empty string when not provided', () => {
793
+ const result = replaceMergeFields('Title: {{recipient.title}}', {})
794
+ expect(result).toBe('Title: ')
795
+ })
796
+
797
+ it('returns content unchanged when there are no tokens', () => {
798
+ const result = replaceMergeFields('No tokens here', { firstName: 'X' })
799
+ expect(result).toBe('No tokens here')
800
+ })
801
+
802
+ it('replaces all occurrences of the same token', () => {
803
+ const result = replaceMergeFields(
804
+ '{{recipient.firstName}} and {{recipient.firstName}}',
805
+ { firstName: 'Alice' }
806
+ )
807
+ expect(result).toBe('Alice and Alice')
808
+ })
809
+
810
+ it('replaces organization type token', () => {
811
+ const result = replaceMergeFields('{{organization.type}}', {}, { type: 'Enterprise' })
812
+ expect(result).toBe('Enterprise')
813
+ })
814
+ })
815
+
816
+ // ---------------------------------------------------------------------------
817
+ // PreviewDialog
818
+ // ---------------------------------------------------------------------------
819
+
820
+ describe('PreviewDialog', () => {
821
+ const defaultProps = {
822
+ open: true,
823
+ onOpenChange: jest.fn(),
824
+ subject: 'Hello {{recipient.firstName}}',
825
+ bodyHtml: '<p>Welcome to our platform!</p>',
826
+ }
827
+
828
+ beforeEach(() => {
829
+ jest.clearAllMocks()
830
+ })
831
+
832
+ it('renders the "Preview Email" heading', () => {
833
+ render(<PreviewDialog {...defaultProps} />)
834
+ expect(screen.getByRole('heading', { name: 'Preview Email' })).toBeInTheDocument()
835
+ })
836
+
837
+ it('renders the resolved subject line with merge fields substituted', () => {
838
+ render(<PreviewDialog {...defaultProps} />)
839
+ // Default sample recipient has firstName = 'John'
840
+ expect(screen.getByText('Hello John')).toBeInTheDocument()
841
+ })
842
+
843
+ it('renders Desktop, Mobile, and HTML toggle tabs', () => {
844
+ render(<PreviewDialog {...defaultProps} />)
845
+ expect(screen.getByRole('tab', { name: /desktop/i })).toBeInTheDocument()
846
+ expect(screen.getByRole('tab', { name: /mobile/i })).toBeInTheDocument()
847
+ expect(screen.getByRole('tab', { name: /html/i })).toBeInTheDocument()
848
+ })
849
+
850
+ it('defaults to desktop view with an email iframe', () => {
851
+ render(<PreviewDialog {...defaultProps} />)
852
+ expect(screen.getByTitle('Email preview')).toBeInTheDocument()
853
+ })
854
+
855
+ it('switches to mobile view when Mobile tab is clicked', async () => {
856
+ const user = userEvent.setup()
857
+ const { container } = render(<PreviewDialog {...defaultProps} />)
858
+ await user.click(screen.getByRole('tab', { name: /mobile/i }))
859
+ const allDivs = Array.from(container.querySelectorAll('div'))
860
+ const mobileContainer = allDivs.find(d => d.className.includes('max-w-[375px]'))
861
+ expect(mobileContainer).toBeTruthy()
862
+ })
863
+
864
+ it('switches back to desktop view when Desktop tab is clicked after mobile', async () => {
865
+ const user = userEvent.setup()
866
+ const { container } = render(<PreviewDialog {...defaultProps} />)
867
+ await user.click(screen.getByRole('tab', { name: /mobile/i }))
868
+ await user.click(screen.getByRole('tab', { name: /desktop/i }))
869
+ const allDivs = Array.from(container.querySelectorAll('div'))
870
+ const mobileContainer = allDivs.find(d => d.className.includes('max-w-[375px]'))
871
+ expect(mobileContainer).toBeFalsy()
872
+ expect(screen.getByTitle('Email preview')).toBeInTheDocument()
873
+ })
874
+
875
+ it('switches to HTML source view when HTML tab is clicked', async () => {
876
+ const user = userEvent.setup()
877
+ const { container } = render(<PreviewDialog {...defaultProps} />)
878
+ await user.click(screen.getByRole('tab', { name: /html/i }))
879
+ const pre = container.querySelector('pre')
880
+ expect(pre).toBeInTheDocument()
881
+ expect(pre?.textContent).toContain('<!DOCTYPE html>')
882
+ })
883
+
884
+ it('renders the body HTML inside the iframe src document', async () => {
885
+ const user = userEvent.setup()
886
+ const { container } = render(<PreviewDialog {...defaultProps} />)
887
+ await user.click(screen.getByRole('tab', { name: /html/i }))
888
+ const pre = container.querySelector('pre')
889
+ expect(pre?.textContent).toContain('Welcome to our platform!')
890
+ })
891
+
892
+ it('renders the unsubscribe text in HTML source by default', async () => {
893
+ const user = userEvent.setup()
894
+ const { container } = render(<PreviewDialog {...defaultProps} />)
895
+ await user.click(screen.getByRole('tab', { name: /html/i }))
896
+ const pre = container.querySelector('pre')
897
+ expect(pre?.textContent).toContain('Unsubscribe from these updates')
898
+ })
899
+
900
+ it('omits unsubscribe block when unsubscribeText is null', async () => {
901
+ const user = userEvent.setup()
902
+ const { container } = render(<PreviewDialog {...defaultProps} unsubscribeText={null} />)
903
+ await user.click(screen.getByRole('tab', { name: /html/i }))
904
+ const pre = container.querySelector('pre')
905
+ expect(pre?.textContent).not.toContain('Unsubscribe')
906
+ })
907
+
908
+ it('renders custom unsubscribe text in HTML source', async () => {
909
+ const user = userEvent.setup()
910
+ const { container } = render(<PreviewDialog {...defaultProps} unsubscribeText="Opt out" />)
911
+ await user.click(screen.getByRole('tab', { name: /html/i }))
912
+ const pre = container.querySelector('pre')
913
+ expect(pre?.textContent).toContain('Opt out')
914
+ })
915
+
916
+ it('renders the select trigger with the default sample recipient name', () => {
917
+ render(<PreviewDialog {...defaultProps} />)
918
+ // The select trigger shows the currently selected recipient name
919
+ expect(screen.getByText('John Smith')).toBeInTheDocument()
920
+ })
921
+
922
+ it('applies recipient merge data to the subject preview', () => {
923
+ const recipients = [
924
+ {
925
+ id: 'r1',
926
+ name: 'Alice',
927
+ mergeData: { '{{recipient.firstName}}': 'Alice' },
928
+ },
929
+ ]
930
+ render(<PreviewDialog {...defaultProps} recipients={recipients} />)
931
+ expect(screen.getByText('Hello Alice')).toBeInTheDocument()
932
+ })
933
+
934
+ it('uses the first custom recipient label for merge field substitution', () => {
935
+ const recipients = [
936
+ {
937
+ id: 'r1',
938
+ name: 'Alice',
939
+ label: 'Alice (alice@example.com)',
940
+ mergeData: { '{{recipient.firstName}}': 'Alice' },
941
+ },
942
+ {
943
+ id: 'r2',
944
+ name: 'Bob',
945
+ label: 'Bob (bob@example.com)',
946
+ mergeData: { '{{recipient.firstName}}': 'Bob' },
947
+ },
948
+ ]
949
+ render(<PreviewDialog {...defaultProps} recipients={recipients} />)
950
+ // The first recipient is selected by default — subject shows Alice's firstName
951
+ expect(screen.getByText('Hello Alice')).toBeInTheDocument()
952
+ })
953
+
954
+ it('calls onOpenChange(false) when the Close button is clicked', () => {
955
+ const onOpenChange = jest.fn()
956
+ render(<PreviewDialog {...defaultProps} onOpenChange={onOpenChange} />)
957
+ // There are two "Close" text occurrences: the visible button and an sr-only span
958
+ // on the dialog's X button. Click the one that is a visible button element.
959
+ const closeButtons = screen.getAllByText('Close')
960
+ const visibleButton = closeButtons.find(
961
+ (el) => el.tagName === 'BUTTON' && !el.classList.contains('sr-only')
962
+ )
963
+ expect(visibleButton).toBeTruthy()
964
+ fireEvent.click(visibleButton!)
965
+ expect(onOpenChange).toHaveBeenCalledWith(false)
966
+ })
967
+
968
+ it('does not render when open is false', () => {
969
+ render(<PreviewDialog {...defaultProps} open={false} />)
970
+ expect(screen.queryByRole('heading', { name: 'Preview Email' })).not.toBeInTheDocument()
971
+ })
972
+
973
+ it('shows "Subject" label', () => {
974
+ render(<PreviewDialog {...defaultProps} />)
975
+ expect(screen.getByText('Subject')).toBeInTheDocument()
976
+ })
977
+
978
+ it('shows "Preview as" label', () => {
979
+ render(<PreviewDialog {...defaultProps} />)
980
+ expect(screen.getByText('Preview as')).toBeInTheDocument()
981
+ })
982
+ })