@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,656 @@
1
+ import { render, screen, fireEvent, act, waitFor } from '@testing-library/react'
2
+ import { renderHook } from '@testing-library/react'
3
+ import { useAutoSave } from '../useAutoSave'
4
+ import { SendConfirmationDialog } from '../send-confirmation-dialog'
5
+ import { ComposeHeader } from '../compose-header'
6
+ import { SaveStatusIndicator } from '../save-status-indicator'
7
+ import { SubjectInput } from '../subject-input'
8
+ import { ComposeLoading } from '../compose-loading'
9
+
10
+ // Mock Radix portal to render inline for all dialog tests
11
+ jest.mock('@radix-ui/react-dialog', () => {
12
+ const actual = jest.requireActual('@radix-ui/react-dialog')
13
+ return {
14
+ ...actual,
15
+ Portal: ({ children }: { children: React.ReactNode }) => <>{children}</>,
16
+ }
17
+ })
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // useAutoSave
21
+ // ---------------------------------------------------------------------------
22
+
23
+ describe('useAutoSave', () => {
24
+ beforeEach(() => {
25
+ jest.useFakeTimers()
26
+ })
27
+
28
+ afterEach(() => {
29
+ jest.runOnlyPendingTimers()
30
+ jest.useRealTimers()
31
+ })
32
+
33
+ it('initializes with no unsaved changes when content matches snapshot', () => {
34
+ const onSave = jest.fn().mockResolvedValue(undefined)
35
+ const { result } = renderHook(() =>
36
+ useAutoSave({ serializedContent: '', onSave, delay: 1000 })
37
+ )
38
+ // lastSavedContentRef starts as '' and serializedContent is '' — no changes
39
+ expect(result.current.hasUnsavedChanges).toBe(false)
40
+ })
41
+
42
+ it('detects unsaved changes when serializedContent differs from snapshot', () => {
43
+ const onSave = jest.fn().mockResolvedValue(undefined)
44
+ const { result } = renderHook(() =>
45
+ useAutoSave({ serializedContent: 'hello world', onSave, delay: 1000 })
46
+ )
47
+ // lastSavedContentRef.current is '' but serializedContent is 'hello world'
48
+ expect(result.current.hasUnsavedChanges).toBe(true)
49
+ })
50
+
51
+ it('fires onSave after the debounce delay when content has changed', async () => {
52
+ const onSave = jest.fn().mockResolvedValue(undefined)
53
+ renderHook(() =>
54
+ useAutoSave({ serializedContent: 'changed', onSave, delay: 1000 })
55
+ )
56
+ expect(onSave).not.toHaveBeenCalled()
57
+ act(() => {
58
+ jest.advanceTimersByTime(1000)
59
+ })
60
+ expect(onSave).toHaveBeenCalledTimes(1)
61
+ expect(onSave).toHaveBeenCalledWith(false)
62
+ })
63
+
64
+ it('does not fire onSave when content matches the snapshot', () => {
65
+ const onSave = jest.fn().mockResolvedValue(undefined)
66
+ const { result } = renderHook(() =>
67
+ useAutoSave({ serializedContent: '', onSave, delay: 1000 })
68
+ )
69
+ // Take a snapshot of the initial content so they match
70
+ act(() => {
71
+ result.current.snapshotContent('')
72
+ })
73
+ act(() => {
74
+ jest.advanceTimersByTime(2000)
75
+ })
76
+ expect(onSave).not.toHaveBeenCalled()
77
+ })
78
+
79
+ it('tracks that snapshotContent updates the saved-content reference', () => {
80
+ // snapshotContent stores the "known saved" baseline in a ref.
81
+ // The hasUnsavedChanges flag is derived by comparing serializedContent to
82
+ // that ref inside a useEffect. This test verifies the snapshot mechanism:
83
+ // start with no changes, take a snapshot of new content, then verify the
84
+ // hook re-evaluates when content changes relative to that snapshot.
85
+ const onSave = jest.fn().mockResolvedValue(undefined)
86
+ const { result, rerender } = renderHook(
87
+ ({ content }: { content: string }) =>
88
+ useAutoSave({ serializedContent: content, onSave, delay: 1000 }),
89
+ { initialProps: { content: '' } }
90
+ )
91
+ // Empty string matches initial ref — no unsaved changes
92
+ expect(result.current.hasUnsavedChanges).toBe(false)
93
+
94
+ // Snapshot '' as the saved state, then change content to 'edited'
95
+ act(() => {
96
+ result.current.snapshotContent('')
97
+ rerender({ content: 'edited' })
98
+ })
99
+ // 'edited' !== '' (snapshotted) — changes should now be detected
100
+ expect(result.current.hasUnsavedChanges).toBe(true)
101
+ })
102
+
103
+ it('debounces: only fires once when content changes rapidly', () => {
104
+ const onSave = jest.fn().mockResolvedValue(undefined)
105
+ const { rerender } = renderHook(
106
+ ({ content }: { content: string }) =>
107
+ useAutoSave({ serializedContent: content, onSave, delay: 1000 }),
108
+ { initialProps: { content: 'v1' } }
109
+ )
110
+ rerender({ content: 'v2' })
111
+ rerender({ content: 'v3' })
112
+ act(() => {
113
+ jest.advanceTimersByTime(1000)
114
+ })
115
+ expect(onSave).toHaveBeenCalledTimes(1)
116
+ })
117
+
118
+ it('uses default delay of 30000ms when no delay is provided', () => {
119
+ const onSave = jest.fn().mockResolvedValue(undefined)
120
+ renderHook(() =>
121
+ useAutoSave({ serializedContent: 'some content', onSave })
122
+ )
123
+ act(() => {
124
+ jest.advanceTimersByTime(29999)
125
+ })
126
+ expect(onSave).not.toHaveBeenCalled()
127
+ act(() => {
128
+ jest.advanceTimersByTime(1)
129
+ })
130
+ expect(onSave).toHaveBeenCalledTimes(1)
131
+ })
132
+
133
+ it('clears the timer on unmount to prevent calling save after cleanup', () => {
134
+ const onSave = jest.fn().mockResolvedValue(undefined)
135
+ const { unmount } = renderHook(() =>
136
+ useAutoSave({ serializedContent: 'will unmount', onSave, delay: 1000 })
137
+ )
138
+ unmount()
139
+ act(() => {
140
+ jest.advanceTimersByTime(2000)
141
+ })
142
+ expect(onSave).not.toHaveBeenCalled()
143
+ })
144
+
145
+ describe('setSaving / setLastSaved', () => {
146
+ it('exposes setSaving to update saving state', () => {
147
+ const onSave = jest.fn().mockResolvedValue(undefined)
148
+ const { result } = renderHook(() =>
149
+ useAutoSave({ serializedContent: '', onSave, delay: 1000 })
150
+ )
151
+ expect(result.current.saving).toBe(false)
152
+ act(() => {
153
+ result.current.setSaving(true)
154
+ })
155
+ expect(result.current.saving).toBe(true)
156
+ })
157
+
158
+ it('exposes setLastSaved to update lastSaved date', () => {
159
+ const onSave = jest.fn().mockResolvedValue(undefined)
160
+ const { result } = renderHook(() =>
161
+ useAutoSave({ serializedContent: '', onSave, delay: 1000 })
162
+ )
163
+ expect(result.current.lastSaved).toBeNull()
164
+ const now = new Date()
165
+ act(() => {
166
+ result.current.setLastSaved(now)
167
+ })
168
+ expect(result.current.lastSaved).toBe(now)
169
+ })
170
+ })
171
+
172
+ describe('formatLastSaved', () => {
173
+ it('returns null when lastSaved is null', () => {
174
+ const onSave = jest.fn().mockResolvedValue(undefined)
175
+ const { result } = renderHook(() =>
176
+ useAutoSave({ serializedContent: '', onSave })
177
+ )
178
+ expect(result.current.formatLastSaved()).toBeNull()
179
+ })
180
+
181
+ it('returns "just now" when saved less than 60 seconds ago', () => {
182
+ const onSave = jest.fn().mockResolvedValue(undefined)
183
+ const { result } = renderHook(() =>
184
+ useAutoSave({ serializedContent: '', onSave })
185
+ )
186
+ act(() => {
187
+ result.current.setLastSaved(new Date(Date.now() - 30000))
188
+ })
189
+ expect(result.current.formatLastSaved()).toBe('just now')
190
+ })
191
+
192
+ it('returns minutes ago when saved between 1 and 60 minutes ago', () => {
193
+ const onSave = jest.fn().mockResolvedValue(undefined)
194
+ const { result } = renderHook(() =>
195
+ useAutoSave({ serializedContent: '', onSave })
196
+ )
197
+ act(() => {
198
+ result.current.setLastSaved(new Date(Date.now() - 5 * 60000))
199
+ })
200
+ expect(result.current.formatLastSaved()).toBe('5m ago')
201
+ })
202
+
203
+ it('returns locale time string when saved more than 60 minutes ago', () => {
204
+ const onSave = jest.fn().mockResolvedValue(undefined)
205
+ const { result } = renderHook(() =>
206
+ useAutoSave({ serializedContent: '', onSave })
207
+ )
208
+ const savedDate = new Date(Date.now() - 2 * 3600000)
209
+ act(() => {
210
+ result.current.setLastSaved(savedDate)
211
+ })
212
+ expect(result.current.formatLastSaved()).toBe(savedDate.toLocaleTimeString())
213
+ })
214
+
215
+ it('returns null for an invalid Date', () => {
216
+ const onSave = jest.fn().mockResolvedValue(undefined)
217
+ const { result } = renderHook(() =>
218
+ useAutoSave({ serializedContent: '', onSave })
219
+ )
220
+ act(() => {
221
+ result.current.setLastSaved(new Date('invalid'))
222
+ })
223
+ expect(result.current.formatLastSaved()).toBeNull()
224
+ })
225
+ })
226
+
227
+ describe('beforeunload listener', () => {
228
+ it('adds a beforeunload listener when there are unsaved changes', () => {
229
+ const addSpy = jest.spyOn(window, 'addEventListener')
230
+ const onSave = jest.fn().mockResolvedValue(undefined)
231
+ renderHook(() =>
232
+ useAutoSave({ serializedContent: 'unsaved', onSave, delay: 1000 })
233
+ )
234
+ const calls = addSpy.mock.calls.filter(([event]) => event === 'beforeunload')
235
+ expect(calls.length).toBeGreaterThan(0)
236
+ addSpy.mockRestore()
237
+ })
238
+
239
+ it('prevents navigation when there are unsaved changes', () => {
240
+ const onSave = jest.fn().mockResolvedValue(undefined)
241
+ renderHook(() =>
242
+ useAutoSave({ serializedContent: 'unsaved', onSave, delay: 1000 })
243
+ )
244
+ const event = new Event('beforeunload') as BeforeUnloadEvent
245
+ Object.defineProperty(event, 'returnValue', { writable: true, value: '' })
246
+ const preventDefaultSpy = jest.spyOn(event, 'preventDefault')
247
+ window.dispatchEvent(event)
248
+ expect(preventDefaultSpy).toHaveBeenCalled()
249
+ })
250
+
251
+ it('removes the beforeunload listener on unmount', () => {
252
+ const removeSpy = jest.spyOn(window, 'removeEventListener')
253
+ const onSave = jest.fn().mockResolvedValue(undefined)
254
+ const { unmount } = renderHook(() =>
255
+ useAutoSave({ serializedContent: 'unsaved', onSave, delay: 1000 })
256
+ )
257
+ unmount()
258
+ const calls = removeSpy.mock.calls.filter(([event]) => event === 'beforeunload')
259
+ expect(calls.length).toBeGreaterThan(0)
260
+ removeSpy.mockRestore()
261
+ })
262
+ })
263
+ })
264
+
265
+ // ---------------------------------------------------------------------------
266
+ // SendConfirmationDialog
267
+ // ---------------------------------------------------------------------------
268
+
269
+ describe('SendConfirmationDialog', () => {
270
+ const defaultProps = {
271
+ open: true,
272
+ onOpenChange: jest.fn(),
273
+ onConfirm: jest.fn(),
274
+ sending: false,
275
+ summaryItems: [
276
+ { label: 'Recipients', value: '42 contacts' },
277
+ { label: 'Subject', value: 'Q1 Update' },
278
+ ],
279
+ }
280
+
281
+ beforeEach(() => {
282
+ jest.clearAllMocks()
283
+ })
284
+
285
+ it('renders the default title', () => {
286
+ render(<SendConfirmationDialog {...defaultProps} />)
287
+ expect(screen.getByText('Send Update')).toBeInTheDocument()
288
+ })
289
+
290
+ it('renders a custom title', () => {
291
+ render(<SendConfirmationDialog {...defaultProps} title="Send Campaign" />)
292
+ expect(screen.getByText('Send Campaign')).toBeInTheDocument()
293
+ })
294
+
295
+ it('renders the default description', () => {
296
+ render(<SendConfirmationDialog {...defaultProps} />)
297
+ expect(screen.getByText('Are you ready to send this?')).toBeInTheDocument()
298
+ })
299
+
300
+ it('renders a custom description', () => {
301
+ render(
302
+ <SendConfirmationDialog {...defaultProps} description="Please confirm the send." />
303
+ )
304
+ expect(screen.getByText('Please confirm the send.')).toBeInTheDocument()
305
+ })
306
+
307
+ it('renders all summary item labels and values', () => {
308
+ render(<SendConfirmationDialog {...defaultProps} />)
309
+ expect(screen.getByText('Recipients:')).toBeInTheDocument()
310
+ expect(screen.getByText('42 contacts')).toBeInTheDocument()
311
+ expect(screen.getByText('Subject:')).toBeInTheDocument()
312
+ expect(screen.getByText('Q1 Update')).toBeInTheDocument()
313
+ })
314
+
315
+ it('renders the default warning message', () => {
316
+ render(<SendConfirmationDialog {...defaultProps} />)
317
+ expect(
318
+ screen.getByText(/Emails will be sent immediately/)
319
+ ).toBeInTheDocument()
320
+ })
321
+
322
+ it('renders a custom warning message', () => {
323
+ render(
324
+ <SendConfirmationDialog {...defaultProps} warningMessage="This action cannot be undone." />
325
+ )
326
+ expect(screen.getByText('This action cannot be undone.')).toBeInTheDocument()
327
+ })
328
+
329
+ it('does not render a warning block when warningMessage is empty string', () => {
330
+ const { container } = render(
331
+ <SendConfirmationDialog {...defaultProps} warningMessage="" />
332
+ )
333
+ expect(container.querySelector('.bg-amber-50')).not.toBeInTheDocument()
334
+ })
335
+
336
+ it('calls onConfirm when "Send Now" is clicked', () => {
337
+ render(<SendConfirmationDialog {...defaultProps} />)
338
+ fireEvent.click(screen.getByText('Send Now'))
339
+ expect(defaultProps.onConfirm).toHaveBeenCalledTimes(1)
340
+ })
341
+
342
+ it('calls onOpenChange(false) when Cancel is clicked', () => {
343
+ render(<SendConfirmationDialog {...defaultProps} />)
344
+ fireEvent.click(screen.getByText('Cancel'))
345
+ expect(defaultProps.onOpenChange).toHaveBeenCalledWith(false)
346
+ })
347
+
348
+ it('disables both buttons while sending', () => {
349
+ render(<SendConfirmationDialog {...defaultProps} sending />)
350
+ const cancelBtn = screen.getByText('Cancel').closest('button')
351
+ const sendingBtn = screen.getByText('Sending...').closest('button')
352
+ expect(cancelBtn).toBeDisabled()
353
+ expect(sendingBtn).toBeDisabled()
354
+ })
355
+
356
+ it('shows "Sending..." with a spinner while sending', () => {
357
+ const { container } = render(<SendConfirmationDialog {...defaultProps} sending />)
358
+ expect(screen.getByText('Sending...')).toBeInTheDocument()
359
+ expect(container.querySelector('.animate-spin')).toBeInTheDocument()
360
+ })
361
+
362
+ it('shows "Send Now" when not sending', () => {
363
+ render(<SendConfirmationDialog {...defaultProps} />)
364
+ expect(screen.getByText('Send Now')).toBeInTheDocument()
365
+ expect(screen.queryByText('Sending...')).not.toBeInTheDocument()
366
+ })
367
+
368
+ it('renders multiple summary items correctly', () => {
369
+ const items = [
370
+ { label: 'To', value: '100 contacts' },
371
+ { label: 'From', value: 'hello@example.com' },
372
+ { label: 'Schedule', value: 'Immediately' },
373
+ ]
374
+ render(<SendConfirmationDialog {...defaultProps} summaryItems={items} />)
375
+ expect(screen.getByText('To:')).toBeInTheDocument()
376
+ expect(screen.getByText('100 contacts')).toBeInTheDocument()
377
+ expect(screen.getByText('From:')).toBeInTheDocument()
378
+ expect(screen.getByText('hello@example.com')).toBeInTheDocument()
379
+ expect(screen.getByText('Schedule:')).toBeInTheDocument()
380
+ expect(screen.getByText('Immediately')).toBeInTheDocument()
381
+ })
382
+
383
+ it('does not render when open is false', () => {
384
+ render(<SendConfirmationDialog {...defaultProps} open={false} />)
385
+ expect(screen.queryByText('Send Update')).not.toBeInTheDocument()
386
+ })
387
+ })
388
+
389
+ // ---------------------------------------------------------------------------
390
+ // ComposeHeader
391
+ // ---------------------------------------------------------------------------
392
+
393
+ describe('ComposeHeader', () => {
394
+ const defaultProps = {
395
+ title: 'Monthly Newsletter',
396
+ saving: false,
397
+ lastSavedLabel: null,
398
+ hasUnsavedChanges: false,
399
+ canSend: true,
400
+ onBack: jest.fn(),
401
+ onSave: jest.fn(),
402
+ onPreview: jest.fn(),
403
+ onSend: jest.fn(),
404
+ }
405
+
406
+ beforeEach(() => {
407
+ jest.clearAllMocks()
408
+ })
409
+
410
+ it('renders the title', () => {
411
+ render(<ComposeHeader {...defaultProps} />)
412
+ expect(screen.getByText('Monthly Newsletter')).toBeInTheDocument()
413
+ })
414
+
415
+ it('calls onBack when the back button is clicked', () => {
416
+ render(<ComposeHeader {...defaultProps} />)
417
+ // ArrowLeft icon button — find by its container role since it has no label text
418
+ const buttons = screen.getAllByRole('button')
419
+ // First button is the back button (ghost icon button)
420
+ fireEvent.click(buttons[0])
421
+ expect(defaultProps.onBack).toHaveBeenCalledTimes(1)
422
+ })
423
+
424
+ it('calls onSave when Save is clicked', () => {
425
+ render(<ComposeHeader {...defaultProps} />)
426
+ fireEvent.click(screen.getByText('Save'))
427
+ expect(defaultProps.onSave).toHaveBeenCalledTimes(1)
428
+ })
429
+
430
+ it('calls onPreview when Preview is clicked', () => {
431
+ render(<ComposeHeader {...defaultProps} />)
432
+ fireEvent.click(screen.getByText('Preview'))
433
+ expect(defaultProps.onPreview).toHaveBeenCalledTimes(1)
434
+ })
435
+
436
+ it('calls onSend when Send is clicked', () => {
437
+ render(<ComposeHeader {...defaultProps} />)
438
+ fireEvent.click(screen.getByText('Send'))
439
+ expect(defaultProps.onSend).toHaveBeenCalledTimes(1)
440
+ })
441
+
442
+ it('disables Save button while saving', () => {
443
+ render(<ComposeHeader {...defaultProps} saving />)
444
+ const saveBtn = screen.getByText('Save').closest('button')
445
+ expect(saveBtn).toBeDisabled()
446
+ })
447
+
448
+ it('disables Send button when canSend is false', () => {
449
+ render(<ComposeHeader {...defaultProps} canSend={false} />)
450
+ const sendBtn = screen.getByText('Send').closest('button')
451
+ expect(sendBtn).toBeDisabled()
452
+ })
453
+
454
+ it('enables Send button when canSend is true', () => {
455
+ render(<ComposeHeader {...defaultProps} canSend />)
456
+ const sendBtn = screen.getByText('Send').closest('button')
457
+ expect(sendBtn).not.toBeDisabled()
458
+ })
459
+
460
+ it('renders extraActions when provided', () => {
461
+ render(
462
+ <ComposeHeader
463
+ {...defaultProps}
464
+ extraActions={<button data-testid="extra-action">Schedule</button>}
465
+ />
466
+ )
467
+ expect(screen.getByTestId('extra-action')).toBeInTheDocument()
468
+ })
469
+
470
+ it('renders SaveStatusIndicator with the correct props (saving state)', () => {
471
+ render(<ComposeHeader {...defaultProps} saving />)
472
+ expect(screen.getByText('Saving...')).toBeInTheDocument()
473
+ })
474
+
475
+ it('renders SaveStatusIndicator with lastSavedLabel', () => {
476
+ render(<ComposeHeader {...defaultProps} lastSavedLabel="just now" />)
477
+ expect(screen.getByText('Saved just now')).toBeInTheDocument()
478
+ })
479
+
480
+ it('renders SaveStatusIndicator showing unsaved changes', () => {
481
+ render(<ComposeHeader {...defaultProps} hasUnsavedChanges />)
482
+ expect(screen.getByText('Unsaved changes')).toBeInTheDocument()
483
+ })
484
+ })
485
+
486
+ // ---------------------------------------------------------------------------
487
+ // SaveStatusIndicator
488
+ // ---------------------------------------------------------------------------
489
+
490
+ describe('SaveStatusIndicator', () => {
491
+ it('shows "Saving..." when saving is true', () => {
492
+ render(
493
+ <SaveStatusIndicator saving lastSavedLabel={null} hasUnsavedChanges={false} />
494
+ )
495
+ expect(screen.getByText('Saving...')).toBeInTheDocument()
496
+ })
497
+
498
+ it('shows a spinning loader icon when saving', () => {
499
+ const { container } = render(
500
+ <SaveStatusIndicator saving lastSavedLabel={null} hasUnsavedChanges={false} />
501
+ )
502
+ expect(container.querySelector('.animate-spin')).toBeInTheDocument()
503
+ })
504
+
505
+ it('shows "Saved <label>" when lastSavedLabel is provided and not saving', () => {
506
+ render(
507
+ <SaveStatusIndicator saving={false} lastSavedLabel="just now" hasUnsavedChanges={false} />
508
+ )
509
+ expect(screen.getByText('Saved just now')).toBeInTheDocument()
510
+ })
511
+
512
+ it('shows "Unsaved changes" when hasUnsavedChanges is true and no lastSavedLabel', () => {
513
+ render(
514
+ <SaveStatusIndicator saving={false} lastSavedLabel={null} hasUnsavedChanges />
515
+ )
516
+ expect(screen.getByText('Unsaved changes')).toBeInTheDocument()
517
+ })
518
+
519
+ it('shows "New draft" when not saving, no lastSavedLabel, no unsaved changes', () => {
520
+ render(
521
+ <SaveStatusIndicator saving={false} lastSavedLabel={null} hasUnsavedChanges={false} />
522
+ )
523
+ expect(screen.getByText('New draft')).toBeInTheDocument()
524
+ })
525
+
526
+ it('prioritizes "Saving..." over other states when saving=true', () => {
527
+ render(
528
+ <SaveStatusIndicator saving lastSavedLabel="just now" hasUnsavedChanges />
529
+ )
530
+ expect(screen.getByText('Saving...')).toBeInTheDocument()
531
+ expect(screen.queryByText('Saved just now')).not.toBeInTheDocument()
532
+ expect(screen.queryByText('Unsaved changes')).not.toBeInTheDocument()
533
+ })
534
+
535
+ it('prioritizes lastSavedLabel over unsaved changes when not saving', () => {
536
+ render(
537
+ <SaveStatusIndicator saving={false} lastSavedLabel="5m ago" hasUnsavedChanges />
538
+ )
539
+ expect(screen.getByText('Saved 5m ago')).toBeInTheDocument()
540
+ expect(screen.queryByText('Unsaved changes')).not.toBeInTheDocument()
541
+ })
542
+
543
+ it('shows amber dot for unsaved changes', () => {
544
+ const { container } = render(
545
+ <SaveStatusIndicator saving={false} lastSavedLabel={null} hasUnsavedChanges />
546
+ )
547
+ expect(container.querySelector('.bg-amber-400')).toBeInTheDocument()
548
+ })
549
+
550
+ it('shows green dot when lastSavedLabel is set', () => {
551
+ const { container } = render(
552
+ <SaveStatusIndicator saving={false} lastSavedLabel="just now" hasUnsavedChanges={false} />
553
+ )
554
+ expect(container.querySelector('.bg-green-500')).toBeInTheDocument()
555
+ })
556
+
557
+ it('shows a bordered dot (new draft state) when no changes and no lastSavedLabel', () => {
558
+ const { container } = render(
559
+ <SaveStatusIndicator saving={false} lastSavedLabel={null} hasUnsavedChanges={false} />
560
+ )
561
+ const dot = container.querySelector('.border.border-muted-foreground')
562
+ expect(dot).toBeInTheDocument()
563
+ })
564
+ })
565
+
566
+ // ---------------------------------------------------------------------------
567
+ // SubjectInput
568
+ // ---------------------------------------------------------------------------
569
+
570
+ describe('SubjectInput', () => {
571
+ it('renders an input with the given value', () => {
572
+ render(<SubjectInput value="Hello World" onChange={jest.fn()} />)
573
+ const input = screen.getByRole('textbox')
574
+ expect(input).toHaveValue('Hello World')
575
+ })
576
+
577
+ it('renders an empty input when value is empty string', () => {
578
+ render(<SubjectInput value="" onChange={jest.fn()} />)
579
+ expect(screen.getByRole('textbox')).toHaveValue('')
580
+ })
581
+
582
+ it('calls onChange with the new value when user types', () => {
583
+ const onChange = jest.fn()
584
+ render(<SubjectInput value="" onChange={onChange} />)
585
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: 'New subject' } })
586
+ expect(onChange).toHaveBeenCalledWith('New subject')
587
+ expect(onChange).toHaveBeenCalledTimes(1)
588
+ })
589
+
590
+ it('renders with the default placeholder', () => {
591
+ render(<SubjectInput value="" onChange={jest.fn()} />)
592
+ expect(screen.getByPlaceholderText('Subject line...')).toBeInTheDocument()
593
+ })
594
+
595
+ it('renders with a custom placeholder', () => {
596
+ render(
597
+ <SubjectInput value="" onChange={jest.fn()} placeholder="Enter subject..." />
598
+ )
599
+ expect(screen.getByPlaceholderText('Enter subject...')).toBeInTheDocument()
600
+ })
601
+
602
+ it('has id="subject" for label association', () => {
603
+ render(<SubjectInput value="Test" onChange={jest.fn()} />)
604
+ expect(screen.getByRole('textbox')).toHaveAttribute('id', 'subject')
605
+ })
606
+
607
+ it('does not call onChange when value prop changes without user interaction', () => {
608
+ const onChange = jest.fn()
609
+ const { rerender } = render(<SubjectInput value="v1" onChange={onChange} />)
610
+ rerender(<SubjectInput value="v2" onChange={onChange} />)
611
+ expect(onChange).not.toHaveBeenCalled()
612
+ })
613
+ })
614
+
615
+ // ---------------------------------------------------------------------------
616
+ // ComposeLoading
617
+ // ---------------------------------------------------------------------------
618
+
619
+ describe('ComposeLoading', () => {
620
+ it('renders without crashing', () => {
621
+ const { container } = render(<ComposeLoading />)
622
+ expect(container.firstChild).toBeInTheDocument()
623
+ })
624
+
625
+ it('renders within a padded container', () => {
626
+ const { container } = render(<ComposeLoading />)
627
+ expect(container.querySelector('.p-6')).toBeInTheDocument()
628
+ })
629
+
630
+ it('renders an animate-pulse wrapper for the skeleton', () => {
631
+ const { container } = render(<ComposeLoading />)
632
+ expect(container.querySelector('.animate-pulse')).toBeInTheDocument()
633
+ })
634
+
635
+ it('renders three distinct skeleton blocks', () => {
636
+ const { container } = render(<ComposeLoading />)
637
+ // header skeleton, subject/toolbar skeleton, body skeleton
638
+ const skeletonBlocks = container.querySelectorAll('.animate-pulse > div')
639
+ expect(skeletonBlocks.length).toBe(3)
640
+ })
641
+
642
+ it('renders the header skeleton with a narrower width', () => {
643
+ const { container } = render(<ComposeLoading />)
644
+ const headerBlock = container.querySelector('.w-1\\/4')
645
+ expect(headerBlock).toBeInTheDocument()
646
+ })
647
+
648
+ it('renders full-width skeleton blocks for content areas', () => {
649
+ const { container } = render(<ComposeLoading />)
650
+ const animatePulse = container.querySelector('.animate-pulse')
651
+ const divs = animatePulse?.querySelectorAll(':scope > div')
652
+ // Blocks 2 and 3 should not have a width constraint (full width)
653
+ expect(divs?.[1]).not.toHaveClass('w-1/4')
654
+ expect(divs?.[2]).not.toHaveClass('w-1/4')
655
+ })
656
+ })