@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,818 @@
1
+ import { render, screen, fireEvent } from '@testing-library/react'
2
+ import { TextBlockEditor } from '../text-block'
3
+ import { MetricsBlockEditor } from '../metrics-block'
4
+ import { DividerBlockEditor } from '../divider-block'
5
+ import { ButtonBlockEditor } from '../button-block'
6
+ import { ImageBlockEditor } from '../image-block'
7
+ import { SpacerBlockEditor } from '../spacer-block'
8
+ import { SocialBlockEditor } from '../social-block'
9
+ import { HeaderBlockEditor } from '../header-block'
10
+ import { FooterBlockEditor } from '../footer-block'
11
+ import type {
12
+ TextBlock,
13
+ MetricsBlock,
14
+ DividerBlock,
15
+ CTABlock,
16
+ ImageBlock,
17
+ SpacerBlock,
18
+ SocialBlock,
19
+ HeaderBlock,
20
+ FooterBlock,
21
+ } from '../../types'
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Shared test fixtures
25
+ // ---------------------------------------------------------------------------
26
+
27
+ const baseTextBlock: TextBlock = {
28
+ id: 'text-1',
29
+ type: 'text',
30
+ content: '<p>Hello world</p>',
31
+ }
32
+
33
+ const baseMetricsBlock: MetricsBlock = {
34
+ id: 'metrics-1',
35
+ type: 'metrics',
36
+ title: 'Key Metrics',
37
+ metrics: [
38
+ { id: 'metric-a', label: 'Revenue', value: '$1,200', change: '+10%', changeType: 'positive' },
39
+ { id: 'metric-b', label: 'Users', value: '340', change: '-5%', changeType: 'negative' },
40
+ ],
41
+ columns: 2,
42
+ }
43
+
44
+ const baseDividerBlock: DividerBlock = {
45
+ id: 'divider-1',
46
+ type: 'divider',
47
+ dividerStyle: 'solid',
48
+ color: '#d1d5db',
49
+ thickness: 1,
50
+ width: 100,
51
+ }
52
+
53
+ const baseButtonBlock: CTABlock = {
54
+ id: 'cta-1',
55
+ type: 'cta',
56
+ text: 'Click Me',
57
+ url: 'https://example.com',
58
+ buttonColor: '#2563eb',
59
+ textColor: '#ffffff',
60
+ borderRadius: 6,
61
+ paddingH: 24,
62
+ paddingV: 12,
63
+ alignment: 'center',
64
+ }
65
+
66
+ const baseImageBlock: ImageBlock = {
67
+ id: 'image-1',
68
+ type: 'image',
69
+ url: 'https://example.com/image.png',
70
+ alt: 'Test image',
71
+ alignment: 'center',
72
+ width: 100,
73
+ }
74
+
75
+ const baseSpacerBlock: SpacerBlock = {
76
+ id: 'spacer-1',
77
+ type: 'spacer',
78
+ height: 48,
79
+ }
80
+
81
+ const baseSocialBlock: SocialBlock = {
82
+ id: 'social-1',
83
+ type: 'social',
84
+ links: [
85
+ { id: 'link-linkedin', platform: 'linkedin', url: 'https://linkedin.com/in/test' },
86
+ { id: 'link-twitter', platform: 'twitter', url: 'https://twitter.com/test' },
87
+ { id: 'link-website', platform: 'website', url: '' },
88
+ ],
89
+ iconSize: 24,
90
+ alignment: 'center',
91
+ }
92
+
93
+ const baseHeaderBlock: HeaderBlock = {
94
+ id: 'header-1',
95
+ type: 'header',
96
+ companyName: 'Acme Corp',
97
+ alignment: 'center',
98
+ }
99
+
100
+ const baseFooterBlock: FooterBlock = {
101
+ id: 'footer-1',
102
+ type: 'footer',
103
+ companyName: 'Acme Corp',
104
+ address: '123 Main St, Springfield',
105
+ showUnsubscribe: true,
106
+ unsubscribeUrl: 'https://example.com/unsubscribe',
107
+ alignment: 'center',
108
+ }
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // TextBlockEditor
112
+ // ---------------------------------------------------------------------------
113
+
114
+ describe('TextBlockEditor', () => {
115
+ it('renders content in view mode via dangerouslySetInnerHTML', () => {
116
+ const { container } = render(
117
+ <TextBlockEditor block={baseTextBlock} onChange={jest.fn()} isEditing={false} />
118
+ )
119
+ expect(container.querySelector('.prose')).toBeInTheDocument()
120
+ })
121
+
122
+ it('renders contentEditable div in editing mode without renderEditor', () => {
123
+ const { container } = render(
124
+ <TextBlockEditor block={baseTextBlock} onChange={jest.fn()} isEditing={true} />
125
+ )
126
+ expect(container.querySelector('[contenteditable="true"]')).toBeInTheDocument()
127
+ })
128
+
129
+ it('applies fontSize style from block data', () => {
130
+ const block: TextBlock = { ...baseTextBlock, fontSize: 18 }
131
+ const { container } = render(
132
+ <TextBlockEditor block={block} onChange={jest.fn()} isEditing={false} />
133
+ )
134
+ const el = container.querySelector('.prose') as HTMLElement
135
+ expect(el.style.fontSize).toBe('18px')
136
+ })
137
+
138
+ it('applies fontFamily style from block data', () => {
139
+ const block: TextBlock = { ...baseTextBlock, fontFamily: 'Georgia, serif' }
140
+ const { container } = render(
141
+ <TextBlockEditor block={block} onChange={jest.fn()} isEditing={false} />
142
+ )
143
+ const el = container.querySelector('.prose') as HTMLElement
144
+ expect(el.style.fontFamily).toBe('Georgia, serif')
145
+ })
146
+
147
+ it('applies textColor style from block data', () => {
148
+ const block: TextBlock = { ...baseTextBlock, textColor: '#ff0000' }
149
+ const { container } = render(
150
+ <TextBlockEditor block={block} onChange={jest.fn()} isEditing={false} />
151
+ )
152
+ const el = container.querySelector('.prose') as HTMLElement
153
+ expect(el.style.color).toBe('rgb(255, 0, 0)')
154
+ })
155
+
156
+ it('calls onChange with updated content on blur in default editing mode', () => {
157
+ const onChange = jest.fn()
158
+ const { container } = render(
159
+ <TextBlockEditor block={baseTextBlock} onChange={onChange} isEditing={true} />
160
+ )
161
+ const editable = container.querySelector('[contenteditable="true"]') as HTMLElement
162
+ fireEvent.blur(editable)
163
+ expect(onChange).toHaveBeenCalledTimes(1)
164
+ expect(onChange).toHaveBeenCalledWith(
165
+ expect.objectContaining({ type: 'text', id: 'text-1' })
166
+ )
167
+ })
168
+
169
+ it('uses renderEditor when provided in editing mode', () => {
170
+ const renderEditor = jest.fn(() => <div data-testid="custom-editor" />)
171
+ render(
172
+ <TextBlockEditor
173
+ block={baseTextBlock}
174
+ onChange={jest.fn()}
175
+ isEditing={true}
176
+ renderEditor={renderEditor}
177
+ />
178
+ )
179
+ expect(screen.getByTestId('custom-editor')).toBeInTheDocument()
180
+ expect(renderEditor).toHaveBeenCalledWith(
181
+ expect.objectContaining({
182
+ content: baseTextBlock.content,
183
+ placeholder: 'Write your content...',
184
+ })
185
+ )
186
+ })
187
+
188
+ it('renderEditor onChange callback updates block content', () => {
189
+ const onChange = jest.fn()
190
+ let capturedOnChange: (html: string) => void = () => {}
191
+ const renderEditor = jest.fn(({ onChange: cb }: { onChange: (html: string) => void }) => {
192
+ capturedOnChange = cb
193
+ return <div data-testid="custom-editor" />
194
+ })
195
+ render(
196
+ <TextBlockEditor
197
+ block={baseTextBlock}
198
+ onChange={onChange}
199
+ isEditing={true}
200
+ renderEditor={renderEditor}
201
+ />
202
+ )
203
+ capturedOnChange('<p>Updated</p>')
204
+ expect(onChange).toHaveBeenCalledWith(
205
+ expect.objectContaining({ content: '<p>Updated</p>' })
206
+ )
207
+ })
208
+
209
+ it('falls back to empty string for content in editing mode when content is empty', () => {
210
+ const block: TextBlock = { ...baseTextBlock, content: '' }
211
+ const { container } = render(
212
+ <TextBlockEditor block={block} onChange={jest.fn()} isEditing={true} />
213
+ )
214
+ expect(container.querySelector('[contenteditable="true"]')).toBeInTheDocument()
215
+ })
216
+
217
+ it('defaults to isEditing=true when prop is omitted', () => {
218
+ const { container } = render(
219
+ <TextBlockEditor block={baseTextBlock} onChange={jest.fn()} />
220
+ )
221
+ expect(container.querySelector('[contenteditable="true"]')).toBeInTheDocument()
222
+ })
223
+ })
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // MetricsBlockEditor
227
+ // ---------------------------------------------------------------------------
228
+
229
+ describe('MetricsBlockEditor', () => {
230
+ it('renders metric values and labels in view mode', () => {
231
+ render(<MetricsBlockEditor block={baseMetricsBlock} onChange={jest.fn()} isEditing={false} />)
232
+ expect(screen.getByText('$1,200')).toBeInTheDocument()
233
+ expect(screen.getByText('Revenue')).toBeInTheDocument()
234
+ expect(screen.getByText('340')).toBeInTheDocument()
235
+ expect(screen.getByText('Users')).toBeInTheDocument()
236
+ })
237
+
238
+ it('renders section title in view mode when provided', () => {
239
+ render(<MetricsBlockEditor block={baseMetricsBlock} onChange={jest.fn()} isEditing={false} />)
240
+ expect(screen.getByText('Key Metrics')).toBeInTheDocument()
241
+ })
242
+
243
+ it('does not render title in view mode when omitted', () => {
244
+ const block: MetricsBlock = { ...baseMetricsBlock, title: undefined }
245
+ render(<MetricsBlockEditor block={block} onChange={jest.fn()} isEditing={false} />)
246
+ expect(screen.queryByRole('heading')).not.toBeInTheDocument()
247
+ })
248
+
249
+ it('renders change indicator for positive metric in view mode', () => {
250
+ render(<MetricsBlockEditor block={baseMetricsBlock} onChange={jest.fn()} isEditing={false} />)
251
+ expect(screen.getByText('+10%')).toBeInTheDocument()
252
+ })
253
+
254
+ it('renders change indicator for negative metric in view mode', () => {
255
+ render(<MetricsBlockEditor block={baseMetricsBlock} onChange={jest.fn()} isEditing={false} />)
256
+ expect(screen.getByText('-5%')).toBeInTheDocument()
257
+ })
258
+
259
+ it('does not render change when metric has no change field', () => {
260
+ const block: MetricsBlock = {
261
+ ...baseMetricsBlock,
262
+ metrics: [{ id: 'metric-c', label: 'Sessions', value: '100' }],
263
+ }
264
+ render(<MetricsBlockEditor block={block} onChange={jest.fn()} isEditing={false} />)
265
+ expect(screen.getByText('Sessions')).toBeInTheDocument()
266
+ expect(screen.getByText('100')).toBeInTheDocument()
267
+ })
268
+
269
+ it('renders section title input and columns select in editing mode', () => {
270
+ render(<MetricsBlockEditor block={baseMetricsBlock} onChange={jest.fn()} isEditing={true} />)
271
+ expect(screen.getByPlaceholderText('Key Metrics')).toBeInTheDocument()
272
+ expect(screen.getByText('Add Metric')).toBeInTheDocument()
273
+ })
274
+
275
+ it('calls onChange when title input changes in editing mode', () => {
276
+ const onChange = jest.fn()
277
+ render(<MetricsBlockEditor block={baseMetricsBlock} onChange={onChange} isEditing={true} />)
278
+ const titleInput = screen.getByPlaceholderText('Key Metrics')
279
+ fireEvent.change(titleInput, { target: { value: 'Updated Title' } })
280
+ expect(onChange).toHaveBeenCalledWith(
281
+ expect.objectContaining({ title: 'Updated Title' })
282
+ )
283
+ })
284
+
285
+ it('calls onChange with new metric when Add Metric is clicked', () => {
286
+ const onChange = jest.fn()
287
+ render(<MetricsBlockEditor block={baseMetricsBlock} onChange={onChange} isEditing={true} />)
288
+ fireEvent.click(screen.getByText('Add Metric'))
289
+ expect(onChange).toHaveBeenCalledTimes(1)
290
+ const updated: MetricsBlock = onChange.mock.calls[0][0]
291
+ expect(updated.metrics).toHaveLength(3)
292
+ expect(updated.metrics[2].label).toBe('New Metric')
293
+ })
294
+
295
+ it('calls onChange with metric removed when remove button is clicked', () => {
296
+ const onChange = jest.fn()
297
+ render(<MetricsBlockEditor block={baseMetricsBlock} onChange={onChange} isEditing={true} />)
298
+ // Two metrics — two remove buttons; first button should be enabled
299
+ const removeButtons = screen.getAllByRole('button').filter((btn) =>
300
+ btn.querySelector('svg') !== null && !btn.textContent?.includes('Add')
301
+ )
302
+ fireEvent.click(removeButtons[0])
303
+ expect(onChange).toHaveBeenCalledTimes(1)
304
+ const updated: MetricsBlock = onChange.mock.calls[0][0]
305
+ expect(updated.metrics).toHaveLength(1)
306
+ })
307
+
308
+ it('disables remove button when only one metric remains', () => {
309
+ const block: MetricsBlock = {
310
+ ...baseMetricsBlock,
311
+ metrics: [{ id: 'only', label: 'Solo', value: '1' }],
312
+ }
313
+ render(<MetricsBlockEditor block={block} onChange={jest.fn()} isEditing={true} />)
314
+ const removeButtons = screen.getAllByRole('button', { hidden: true }).filter(
315
+ (btn) => btn.hasAttribute('disabled')
316
+ )
317
+ expect(removeButtons.length).toBeGreaterThan(0)
318
+ })
319
+
320
+ it('calls onChange when a metric label input changes', () => {
321
+ const onChange = jest.fn()
322
+ render(<MetricsBlockEditor block={baseMetricsBlock} onChange={onChange} isEditing={true} />)
323
+ const labelInputs = screen.getAllByPlaceholderText('Label')
324
+ fireEvent.change(labelInputs[0], { target: { value: 'ARR' } })
325
+ expect(onChange).toHaveBeenCalledTimes(1)
326
+ const updated: MetricsBlock = onChange.mock.calls[0][0]
327
+ expect(updated.metrics[0].label).toBe('ARR')
328
+ })
329
+
330
+ it('calls onChange when a metric value input changes', () => {
331
+ const onChange = jest.fn()
332
+ render(<MetricsBlockEditor block={baseMetricsBlock} onChange={onChange} isEditing={true} />)
333
+ const valueInputs = screen.getAllByPlaceholderText('Value')
334
+ fireEvent.change(valueInputs[0], { target: { value: '$9,999' } })
335
+ expect(onChange).toHaveBeenCalledTimes(1)
336
+ const updated: MetricsBlock = onChange.mock.calls[0][0]
337
+ expect(updated.metrics[0].value).toBe('$9,999')
338
+ })
339
+
340
+ it('defaults to isEditing=true when prop is omitted', () => {
341
+ render(<MetricsBlockEditor block={baseMetricsBlock} onChange={jest.fn()} />)
342
+ expect(screen.getByText('Add Metric')).toBeInTheDocument()
343
+ })
344
+ })
345
+
346
+ // ---------------------------------------------------------------------------
347
+ // DividerBlockEditor
348
+ // ---------------------------------------------------------------------------
349
+
350
+ describe('DividerBlockEditor', () => {
351
+ it('renders an hr element in view mode for solid style', () => {
352
+ const { container } = render(
353
+ <DividerBlockEditor block={baseDividerBlock} onChange={jest.fn()} isEditing={false} />
354
+ )
355
+ expect(container.querySelector('hr')).toBeInTheDocument()
356
+ })
357
+
358
+ it('applies correct border-top style to hr in view mode', () => {
359
+ const { container } = render(
360
+ <DividerBlockEditor block={baseDividerBlock} onChange={jest.fn()} isEditing={false} />
361
+ )
362
+ const hr = container.querySelector('hr') as HTMLElement
363
+ expect(hr.style.borderTop).toContain('solid')
364
+ })
365
+
366
+ it('renders a spacer div (no hr) when dividerStyle is "space"', () => {
367
+ const block: DividerBlock = { ...baseDividerBlock, dividerStyle: 'space' }
368
+ const { container } = render(
369
+ <DividerBlockEditor block={block} onChange={jest.fn()} isEditing={false} />
370
+ )
371
+ expect(container.querySelector('hr')).not.toBeInTheDocument()
372
+ const spacer = container.querySelector('div') as HTMLElement
373
+ expect(spacer.style.height).toBe('32px')
374
+ })
375
+
376
+ it('renders with dashed style', () => {
377
+ const block: DividerBlock = { ...baseDividerBlock, dividerStyle: 'dashed' }
378
+ const { container } = render(
379
+ <DividerBlockEditor block={block} onChange={jest.fn()} isEditing={false} />
380
+ )
381
+ const hr = container.querySelector('hr') as HTMLElement
382
+ expect(hr.style.borderTop).toContain('dashed')
383
+ })
384
+
385
+ it('renders with dotted style', () => {
386
+ const block: DividerBlock = { ...baseDividerBlock, dividerStyle: 'dotted' }
387
+ const { container } = render(
388
+ <DividerBlockEditor block={block} onChange={jest.fn()} isEditing={false} />
389
+ )
390
+ const hr = container.querySelector('hr') as HTMLElement
391
+ expect(hr.style.borderTop).toContain('dotted')
392
+ })
393
+
394
+ it('applies width percentage to hr', () => {
395
+ const block: DividerBlock = { ...baseDividerBlock, width: 80 }
396
+ const { container } = render(
397
+ <DividerBlockEditor block={block} onChange={jest.fn()} isEditing={false} />
398
+ )
399
+ const hr = container.querySelector('hr') as HTMLElement
400
+ expect(hr.style.width).toBe('80%')
401
+ })
402
+
403
+ it('renders a preview hr in editing mode', () => {
404
+ const { container } = render(
405
+ <DividerBlockEditor block={baseDividerBlock} onChange={jest.fn()} isEditing={true} />
406
+ )
407
+ // Editing mode wraps the view-mode render, so hr should still appear
408
+ expect(container.querySelector('hr')).toBeInTheDocument()
409
+ })
410
+
411
+ it('uses default color when color is not provided', () => {
412
+ const block: DividerBlock = { ...baseDividerBlock, color: undefined }
413
+ const { container } = render(
414
+ <DividerBlockEditor block={block} onChange={jest.fn()} isEditing={false} />
415
+ )
416
+ const hr = container.querySelector('hr') as HTMLElement
417
+ // jsdom normalizes hex colors to rgb in computed styles
418
+ expect(hr.style.borderTop).toMatch(/rgb\(209,\s*213,\s*219\)|#d1d5db/)
419
+ })
420
+ })
421
+
422
+ // ---------------------------------------------------------------------------
423
+ // ButtonBlockEditor
424
+ // ---------------------------------------------------------------------------
425
+
426
+ describe('ButtonBlockEditor', () => {
427
+ it('renders the button text from block data', () => {
428
+ render(<ButtonBlockEditor block={baseButtonBlock} onChange={jest.fn()} />)
429
+ expect(screen.getByText('Click Me')).toBeInTheDocument()
430
+ })
431
+
432
+ it('falls back to "Button" text when block.text is empty', () => {
433
+ const block: CTABlock = { ...baseButtonBlock, text: '' }
434
+ render(<ButtonBlockEditor block={block} onChange={jest.fn()} />)
435
+ expect(screen.getByText('Button')).toBeInTheDocument()
436
+ })
437
+
438
+ it('applies button background color from block data', () => {
439
+ const { container } = render(<ButtonBlockEditor block={baseButtonBlock} onChange={jest.fn()} />)
440
+ const span = container.querySelector('span[style]') as HTMLElement
441
+ expect(span.style.backgroundColor).toBe('rgb(37, 99, 235)')
442
+ })
443
+
444
+ it('applies text color from block data', () => {
445
+ const { container } = render(<ButtonBlockEditor block={baseButtonBlock} onChange={jest.fn()} />)
446
+ const span = container.querySelector('span[style]') as HTMLElement
447
+ expect(span.style.color).toBe('rgb(255, 255, 255)')
448
+ })
449
+
450
+ it('applies border radius from block data', () => {
451
+ const { container } = render(<ButtonBlockEditor block={baseButtonBlock} onChange={jest.fn()} />)
452
+ const span = container.querySelector('span[style]') as HTMLElement
453
+ expect(span.style.borderRadius).toBe('6px')
454
+ })
455
+
456
+ it('uses center alignment class by default', () => {
457
+ const { container } = render(<ButtonBlockEditor block={baseButtonBlock} onChange={jest.fn()} />)
458
+ const wrapper = container.querySelector('div[class]') as HTMLElement
459
+ expect(wrapper.className).toContain('text-center')
460
+ })
461
+
462
+ it('applies left alignment class', () => {
463
+ const block: CTABlock = { ...baseButtonBlock, alignment: 'left' }
464
+ const { container } = render(<ButtonBlockEditor block={block} onChange={jest.fn()} />)
465
+ const wrapper = container.querySelector('div[class]') as HTMLElement
466
+ expect(wrapper.className).toContain('text-left')
467
+ })
468
+
469
+ it('applies right alignment class', () => {
470
+ const block: CTABlock = { ...baseButtonBlock, alignment: 'right' }
471
+ const { container } = render(<ButtonBlockEditor block={block} onChange={jest.fn()} />)
472
+ const wrapper = container.querySelector('div[class]') as HTMLElement
473
+ expect(wrapper.className).toContain('text-right')
474
+ })
475
+
476
+ it('uses default buttonColor when not provided', () => {
477
+ const block: CTABlock = { ...baseButtonBlock, buttonColor: undefined }
478
+ const { container } = render(<ButtonBlockEditor block={block} onChange={jest.fn()} />)
479
+ const span = container.querySelector('span[style]') as HTMLElement
480
+ expect(span.style.backgroundColor).toBe('rgb(37, 99, 235)')
481
+ })
482
+ })
483
+
484
+ // ---------------------------------------------------------------------------
485
+ // ImageBlockEditor
486
+ // ---------------------------------------------------------------------------
487
+
488
+ describe('ImageBlockEditor', () => {
489
+ it('renders img element when url is provided', () => {
490
+ const { container } = render(
491
+ <ImageBlockEditor block={baseImageBlock} onChange={jest.fn()} />
492
+ )
493
+ const img = container.querySelector('img') as HTMLImageElement
494
+ expect(img).toBeInTheDocument()
495
+ expect(img.src).toBe('https://example.com/image.png')
496
+ expect(img.alt).toBe('Test image')
497
+ })
498
+
499
+ it('renders placeholder when url is empty', () => {
500
+ const block: ImageBlock = { ...baseImageBlock, url: '' }
501
+ render(<ImageBlockEditor block={block} onChange={jest.fn()} isEditing={true} />)
502
+ expect(screen.getByText('Click to set image URL')).toBeInTheDocument()
503
+ })
504
+
505
+ it('renders "No image set" placeholder text in view mode when url is empty', () => {
506
+ const block: ImageBlock = { ...baseImageBlock, url: '' }
507
+ render(<ImageBlockEditor block={block} onChange={jest.fn()} isEditing={false} />)
508
+ expect(screen.getByText('No image set')).toBeInTheDocument()
509
+ })
510
+
511
+ it('renders caption when provided', () => {
512
+ const block: ImageBlock = { ...baseImageBlock, caption: 'A beautiful photo' }
513
+ render(<ImageBlockEditor block={block} onChange={jest.fn()} />)
514
+ expect(screen.getByText('A beautiful photo')).toBeInTheDocument()
515
+ })
516
+
517
+ it('does not render caption element when caption is absent', () => {
518
+ render(<ImageBlockEditor block={baseImageBlock} onChange={jest.fn()} />)
519
+ expect(screen.queryByRole('paragraph')).not.toBeInTheDocument()
520
+ })
521
+
522
+ it('wraps image in an anchor when linkUrl is set in view mode', () => {
523
+ const block: ImageBlock = { ...baseImageBlock, linkUrl: 'https://linked.com' }
524
+ const { container } = render(
525
+ <ImageBlockEditor block={block} onChange={jest.fn()} isEditing={false} />
526
+ )
527
+ const anchor = container.querySelector('a') as HTMLAnchorElement
528
+ expect(anchor).toBeInTheDocument()
529
+ expect(anchor.href).toBe('https://linked.com/')
530
+ })
531
+
532
+ it('does not wrap image in anchor in editing mode even when linkUrl is set', () => {
533
+ const block: ImageBlock = { ...baseImageBlock, linkUrl: 'https://linked.com' }
534
+ const { container } = render(
535
+ <ImageBlockEditor block={block} onChange={jest.fn()} isEditing={true} />
536
+ )
537
+ expect(container.querySelector('a')).not.toBeInTheDocument()
538
+ })
539
+
540
+ it('applies center alignment class by default', () => {
541
+ const { container } = render(
542
+ <ImageBlockEditor block={baseImageBlock} onChange={jest.fn()} />
543
+ )
544
+ const wrapper = container.querySelector('div[class]') as HTMLElement
545
+ expect(wrapper.className).toContain('text-center')
546
+ })
547
+
548
+ it('applies left alignment class', () => {
549
+ const block: ImageBlock = { ...baseImageBlock, alignment: 'left' }
550
+ const { container } = render(
551
+ <ImageBlockEditor block={block} onChange={jest.fn()} />
552
+ )
553
+ const wrapper = container.querySelector('div[class]') as HTMLElement
554
+ expect(wrapper.className).toContain('text-left')
555
+ })
556
+
557
+ it('applies width percentage to img element', () => {
558
+ const block: ImageBlock = { ...baseImageBlock, width: 50 }
559
+ const { container } = render(
560
+ <ImageBlockEditor block={block} onChange={jest.fn()} />
561
+ )
562
+ const img = container.querySelector('img') as HTMLElement
563
+ expect(img.style.width).toBe('50%')
564
+ })
565
+ })
566
+
567
+ // ---------------------------------------------------------------------------
568
+ // SpacerBlockEditor
569
+ // ---------------------------------------------------------------------------
570
+
571
+ describe('SpacerBlockEditor', () => {
572
+ it('renders a plain div with correct height in view mode', () => {
573
+ const { container } = render(
574
+ <SpacerBlockEditor block={baseSpacerBlock} onChange={jest.fn()} isEditing={false} />
575
+ )
576
+ const spacer = container.querySelector('div') as HTMLElement
577
+ expect(spacer.style.height).toBe('48px')
578
+ })
579
+
580
+ it('renders editing mode wrapper with height label', () => {
581
+ render(
582
+ <SpacerBlockEditor block={baseSpacerBlock} onChange={jest.fn()} isEditing={true} />
583
+ )
584
+ expect(screen.getByText('48px')).toBeInTheDocument()
585
+ })
586
+
587
+ it('defaults to 32px height when height is 0', () => {
588
+ const block: SpacerBlock = { ...baseSpacerBlock, height: 0 }
589
+ const { container } = render(
590
+ <SpacerBlockEditor block={block} onChange={jest.fn()} isEditing={false} />
591
+ )
592
+ const spacer = container.querySelector('div') as HTMLElement
593
+ // height of 0 is falsy so falls back to 32
594
+ expect(spacer.style.height).toBe('32px')
595
+ })
596
+
597
+ it('renders dashed border container in editing mode', () => {
598
+ const { container } = render(
599
+ <SpacerBlockEditor block={baseSpacerBlock} onChange={jest.fn()} isEditing={true} />
600
+ )
601
+ const wrapper = container.querySelector('div[class]') as HTMLElement
602
+ expect(wrapper.className).toContain('border-dashed')
603
+ })
604
+
605
+ it('defaults to isEditing=true when prop is omitted', () => {
606
+ render(<SpacerBlockEditor block={baseSpacerBlock} onChange={jest.fn()} />)
607
+ expect(screen.getByText('48px')).toBeInTheDocument()
608
+ })
609
+ })
610
+
611
+ // ---------------------------------------------------------------------------
612
+ // SocialBlockEditor
613
+ // ---------------------------------------------------------------------------
614
+
615
+ describe('SocialBlockEditor', () => {
616
+ it('renders icons for each link in the block', () => {
617
+ const { container } = render(
618
+ <SocialBlockEditor block={baseSocialBlock} onChange={jest.fn()} />
619
+ )
620
+ // 3 links → 3 icon wrapper elements (span or a)
621
+ const iconWrappers = container.querySelectorAll('[class*="inline-flex"]')
622
+ expect(iconWrappers.length).toBe(3)
623
+ })
624
+
625
+ it('renders spans (not anchors) for links in editing mode', () => {
626
+ const { container } = render(
627
+ <SocialBlockEditor block={baseSocialBlock} onChange={jest.fn()} isEditing={true} />
628
+ )
629
+ expect(container.querySelectorAll('a')).toHaveLength(0)
630
+ expect(container.querySelectorAll('span.inline-flex').length).toBeGreaterThan(0)
631
+ })
632
+
633
+ it('renders spans for links with empty url even in view mode', () => {
634
+ // link-website has url: '' so should render span not anchor
635
+ const { container } = render(
636
+ <SocialBlockEditor block={baseSocialBlock} onChange={jest.fn()} isEditing={false} />
637
+ )
638
+ // linkedin and twitter have URLs → 2 anchors; website has no url → 1 span
639
+ const anchors = container.querySelectorAll('a')
640
+ expect(anchors).toHaveLength(2)
641
+ })
642
+
643
+ it('renders anchors with correct hrefs for links with URLs in view mode', () => {
644
+ const { container } = render(
645
+ <SocialBlockEditor block={baseSocialBlock} onChange={jest.fn()} isEditing={false} />
646
+ )
647
+ const anchors = container.querySelectorAll('a') as NodeListOf<HTMLAnchorElement>
648
+ const hrefs = Array.from(anchors).map((a) => a.href)
649
+ expect(hrefs).toContain('https://linkedin.com/in/test')
650
+ expect(hrefs).toContain('https://twitter.com/test')
651
+ })
652
+
653
+ it('applies justify-center class for center alignment', () => {
654
+ const { container } = render(
655
+ <SocialBlockEditor block={baseSocialBlock} onChange={jest.fn()} />
656
+ )
657
+ const wrapper = container.querySelector('div[class]') as HTMLElement
658
+ expect(wrapper.className).toContain('justify-center')
659
+ })
660
+
661
+ it('applies justify-start class for left alignment', () => {
662
+ const block: SocialBlock = { ...baseSocialBlock, alignment: 'left' }
663
+ const { container } = render(<SocialBlockEditor block={block} onChange={jest.fn()} />)
664
+ const wrapper = container.querySelector('div[class]') as HTMLElement
665
+ expect(wrapper.className).toContain('justify-start')
666
+ })
667
+
668
+ it('applies justify-end class for right alignment', () => {
669
+ const block: SocialBlock = { ...baseSocialBlock, alignment: 'right' }
670
+ const { container } = render(<SocialBlockEditor block={block} onChange={jest.fn()} />)
671
+ const wrapper = container.querySelector('div[class]') as HTMLElement
672
+ expect(wrapper.className).toContain('justify-end')
673
+ })
674
+
675
+ it('renders empty links array without crashing', () => {
676
+ const block: SocialBlock = { ...baseSocialBlock, links: [] }
677
+ const { container } = render(<SocialBlockEditor block={block} onChange={jest.fn()} />)
678
+ expect(container.querySelector('div')).toBeInTheDocument()
679
+ })
680
+ })
681
+
682
+ // ---------------------------------------------------------------------------
683
+ // HeaderBlockEditor
684
+ // ---------------------------------------------------------------------------
685
+
686
+ describe('HeaderBlockEditor', () => {
687
+ it('renders company name', () => {
688
+ render(<HeaderBlockEditor block={baseHeaderBlock} onChange={jest.fn()} />)
689
+ expect(screen.getByText('Acme Corp')).toBeInTheDocument()
690
+ })
691
+
692
+ it('renders logo image when logoUrl is provided', () => {
693
+ const block: HeaderBlock = { ...baseHeaderBlock, logoUrl: 'https://example.com/logo.png' }
694
+ const { container } = render(<HeaderBlockEditor block={block} onChange={jest.fn()} />)
695
+ const img = container.querySelector('img') as HTMLImageElement
696
+ expect(img).toBeInTheDocument()
697
+ expect(img.src).toBe('https://example.com/logo.png')
698
+ expect(img.alt).toBe('Acme Corp')
699
+ })
700
+
701
+ it('renders placeholder icon when logoUrl is absent', () => {
702
+ const { container } = render(<HeaderBlockEditor block={baseHeaderBlock} onChange={jest.fn()} />)
703
+ expect(container.querySelector('img')).not.toBeInTheDocument()
704
+ // The placeholder wrapper div should be present
705
+ expect(container.querySelector('.rounded.bg-gray-100')).toBeInTheDocument()
706
+ })
707
+
708
+ it('applies center alignment class', () => {
709
+ const { container } = render(<HeaderBlockEditor block={baseHeaderBlock} onChange={jest.fn()} />)
710
+ const wrapper = container.querySelector('div[class]') as HTMLElement
711
+ expect(wrapper.className).toContain('text-center')
712
+ })
713
+
714
+ it('applies left alignment class', () => {
715
+ const block: HeaderBlock = { ...baseHeaderBlock, alignment: 'left' }
716
+ const { container } = render(<HeaderBlockEditor block={block} onChange={jest.fn()} />)
717
+ const wrapper = container.querySelector('div[class]') as HTMLElement
718
+ expect(wrapper.className).toContain('text-left')
719
+ })
720
+
721
+ it('applies right alignment class', () => {
722
+ const block: HeaderBlock = { ...baseHeaderBlock, alignment: 'right' }
723
+ const { container } = render(<HeaderBlockEditor block={block} onChange={jest.fn()} />)
724
+ const wrapper = container.querySelector('div[class]') as HTMLElement
725
+ expect(wrapper.className).toContain('text-right')
726
+ })
727
+
728
+ it('renders without logoUrl field (undefined)', () => {
729
+ const block: HeaderBlock = { ...baseHeaderBlock, logoUrl: undefined }
730
+ render(<HeaderBlockEditor block={block} onChange={jest.fn()} />)
731
+ expect(screen.getByText('Acme Corp')).toBeInTheDocument()
732
+ })
733
+ })
734
+
735
+ // ---------------------------------------------------------------------------
736
+ // FooterBlockEditor
737
+ // ---------------------------------------------------------------------------
738
+
739
+ describe('FooterBlockEditor', () => {
740
+ it('renders company name', () => {
741
+ render(<FooterBlockEditor block={baseFooterBlock} onChange={jest.fn()} />)
742
+ expect(screen.getByText('Acme Corp')).toBeInTheDocument()
743
+ })
744
+
745
+ it('renders address when provided', () => {
746
+ render(<FooterBlockEditor block={baseFooterBlock} onChange={jest.fn()} />)
747
+ expect(screen.getByText('123 Main St, Springfield')).toBeInTheDocument()
748
+ })
749
+
750
+ it('does not render address element when address is absent', () => {
751
+ const block: FooterBlock = { ...baseFooterBlock, address: undefined }
752
+ render(<FooterBlockEditor block={block} onChange={jest.fn()} />)
753
+ expect(screen.queryByText('123 Main St, Springfield')).not.toBeInTheDocument()
754
+ })
755
+
756
+ it('renders unsubscribe link when showUnsubscribe is true', () => {
757
+ render(<FooterBlockEditor block={baseFooterBlock} onChange={jest.fn()} />)
758
+ expect(screen.getByText('Unsubscribe')).toBeInTheDocument()
759
+ })
760
+
761
+ it('does not render unsubscribe link when showUnsubscribe is false', () => {
762
+ const block: FooterBlock = { ...baseFooterBlock, showUnsubscribe: false }
763
+ render(<FooterBlockEditor block={block} onChange={jest.fn()} />)
764
+ expect(screen.queryByText('Unsubscribe')).not.toBeInTheDocument()
765
+ })
766
+
767
+ it('unsubscribe link href uses unsubscribeUrl when provided in view mode', () => {
768
+ const { container } = render(
769
+ <FooterBlockEditor block={baseFooterBlock} onChange={jest.fn()} isEditing={false} />
770
+ )
771
+ const anchor = container.querySelector('a.underline') as HTMLAnchorElement
772
+ expect(anchor.href).toBe('https://example.com/unsubscribe')
773
+ })
774
+
775
+ it('unsubscribe link href falls back to "#" when unsubscribeUrl is absent', () => {
776
+ const block: FooterBlock = { ...baseFooterBlock, unsubscribeUrl: undefined }
777
+ const { container } = render(
778
+ <FooterBlockEditor block={block} onChange={jest.fn()} isEditing={false} />
779
+ )
780
+ const anchor = container.querySelector('a.underline') as HTMLAnchorElement
781
+ expect(anchor.getAttribute('href')).toBe('#')
782
+ })
783
+
784
+ it('prevents default click on unsubscribe link in editing mode', () => {
785
+ render(<FooterBlockEditor block={baseFooterBlock} onChange={jest.fn()} isEditing={true} />)
786
+ const anchor = screen.getByText('Unsubscribe')
787
+ const event = createClickEvent()
788
+ anchor.dispatchEvent(event)
789
+ expect(event.defaultPrevented).toBe(true)
790
+ })
791
+
792
+ it('applies center alignment class', () => {
793
+ const { container } = render(<FooterBlockEditor block={baseFooterBlock} onChange={jest.fn()} />)
794
+ const wrapper = container.querySelector('div[class]') as HTMLElement
795
+ expect(wrapper.className).toContain('text-center')
796
+ })
797
+
798
+ it('applies left alignment class', () => {
799
+ const block: FooterBlock = { ...baseFooterBlock, alignment: 'left' }
800
+ const { container } = render(<FooterBlockEditor block={block} onChange={jest.fn()} />)
801
+ const wrapper = container.querySelector('div[class]') as HTMLElement
802
+ expect(wrapper.className).toContain('text-left')
803
+ })
804
+
805
+ it('applies right alignment class', () => {
806
+ const block: FooterBlock = { ...baseFooterBlock, alignment: 'right' }
807
+ const { container } = render(<FooterBlockEditor block={block} onChange={jest.fn()} />)
808
+ const wrapper = container.querySelector('div[class]') as HTMLElement
809
+ expect(wrapper.className).toContain('text-right')
810
+ })
811
+ })
812
+
813
+ // ---------------------------------------------------------------------------
814
+ // Helper: create a real MouseEvent with bubbles so dispatchEvent registers handler
815
+ // ---------------------------------------------------------------------------
816
+ function createClickEvent(): MouseEvent {
817
+ return new MouseEvent('click', { bubbles: true, cancelable: true })
818
+ }