@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.
- package/package.json +2 -1
- package/src/__mocks__/next/link.js +11 -0
- package/src/components/ActivityTimeline.tsx +173 -0
- package/src/components/LogActivityDialog.tsx +303 -0
- package/src/components/QuickLogButtons.tsx +32 -0
- package/src/components/account/__tests__/account.test.tsx +315 -0
- package/src/components/badge/StageBadge.tsx +31 -0
- package/src/components/badge/index.ts +3 -0
- package/src/components/command-palette/CommandGroup.tsx +23 -0
- package/src/components/command-palette/CommandPalette.tsx +327 -0
- package/src/components/command-palette/CommandResultItem.tsx +59 -0
- package/src/components/command-palette/__tests__/CommandGroup.test.tsx +81 -0
- package/src/components/command-palette/__tests__/CommandResultItem.test.tsx +166 -0
- package/src/components/command-palette/__tests__/command-palette-context.test.tsx +166 -0
- package/src/components/command-palette/__tests__/useCommandPaletteSearch.test.ts +271 -0
- package/src/components/command-palette/command-palette-context.tsx +51 -0
- package/src/components/command-palette/index.ts +9 -0
- package/src/components/command-palette/useCommandPaletteSearch.ts +114 -0
- package/src/components/compose/__tests__/compose.test.tsx +656 -0
- package/src/components/compose/compose-header.tsx +72 -0
- package/src/components/compose/compose-loading.tsx +13 -0
- package/src/components/compose/index.ts +6 -0
- package/src/components/compose/save-status-indicator.tsx +57 -0
- package/src/components/compose/send-confirmation-dialog.tsx +87 -0
- package/src/components/compose/subject-input.tsx +25 -0
- package/src/components/compose/useAutoSave.ts +93 -0
- package/src/components/dashboard/DashboardGrid.tsx +32 -0
- package/src/components/dashboard/DashboardSection.tsx +32 -0
- package/src/components/dashboard/MetricCard.tsx +129 -0
- package/src/components/dashboard/PeriodSelector.tsx +55 -0
- package/src/components/dashboard/PipelineFunnel.tsx +126 -0
- package/src/components/dashboard/SparklineTrend.tsx +102 -0
- package/src/components/dashboard/TopCampaigns.tsx +132 -0
- package/src/components/dashboard/__tests__/dashboard.test.tsx +785 -0
- package/src/components/dashboard/index.ts +20 -0
- package/src/components/dialog/ConfirmDialog.tsx +72 -0
- package/src/components/dialog/__tests__/ConfirmDialog.test.tsx +126 -0
- package/src/components/dialog/index.ts +3 -0
- package/src/components/email-dialogs/__tests__/email-dialogs.test.tsx +982 -0
- package/src/components/email-dialogs/index.ts +14 -0
- package/src/components/email-dialogs/merge-fields.tsx +196 -0
- package/src/components/email-dialogs/preview-dialog.tsx +194 -0
- package/src/components/email-dialogs/schedule-dialog.tsx +297 -0
- package/src/components/email-dialogs/template-picker.tsx +225 -0
- package/src/components/email-dialogs/test-send-dialog.tsx +188 -0
- package/src/components/email-editor/BlockRenderer.tsx +120 -0
- package/src/components/email-editor/__tests__/BlockRenderer.test.tsx +332 -0
- package/src/components/email-editor/__tests__/block-renderers.test.ts +624 -0
- package/src/components/email-editor/__tests__/email-html-renderer.test.ts +376 -0
- package/src/components/email-editor/add-block-menu.tsx +151 -0
- package/src/components/email-editor/block-toolbar.tsx +73 -0
- package/src/components/email-editor/blocks/__tests__/blocks.test.tsx +818 -0
- package/src/components/email-editor/blocks/button-block.tsx +44 -0
- package/src/components/email-editor/blocks/divider-block.tsx +43 -0
- package/src/components/email-editor/blocks/footer-block.tsx +39 -0
- package/src/components/email-editor/blocks/header-block.tsx +39 -0
- package/src/components/email-editor/blocks/image-block.tsx +61 -0
- package/src/components/email-editor/blocks/index.ts +9 -0
- package/src/components/email-editor/blocks/metrics-block.tsx +198 -0
- package/src/components/email-editor/blocks/social-block.tsx +75 -0
- package/src/components/email-editor/blocks/spacer-block.tsx +26 -0
- package/src/components/email-editor/blocks/text-block.tsx +75 -0
- package/src/components/email-editor/editor-sidebar.tsx +66 -0
- package/src/components/email-editor/email-editor.tsx +497 -0
- package/src/components/email-editor/hooks/__tests__/useDragDrop.test.ts +355 -0
- package/src/components/email-editor/hooks/__tests__/useEmailEditorState.test.ts +551 -0
- package/src/components/email-editor/hooks/useDragDrop.ts +181 -0
- package/src/components/email-editor/hooks/useEmailEditorState.ts +426 -0
- package/src/components/email-editor/index.ts +51 -0
- package/src/components/email-editor/panels/BlockPropertyPanel.tsx +637 -0
- package/src/components/email-editor/panels/GlobalStylesPanel.tsx +108 -0
- package/src/components/email-editor/panels/SectionSettingsPanel.tsx +80 -0
- package/src/components/email-editor/panels/__tests__/BlockPropertyPanel.test.tsx +707 -0
- package/src/components/email-editor/panels/__tests__/GlobalStylesPanel.test.tsx +226 -0
- package/src/components/email-editor/panels/index.ts +3 -0
- package/src/components/email-editor/renderer/block-renderers.ts +209 -0
- package/src/components/email-editor/renderer/email-html-renderer.ts +128 -0
- package/src/components/email-editor/types.ts +413 -0
- package/src/components/email-editor/utils/defaults.ts +116 -0
- package/src/components/email-editor/utils/undo-redo.ts +59 -0
- package/src/components/enrichment/EnrichButton.tsx +33 -0
- package/src/components/enrichment/EnrichmentProgress.tsx +66 -0
- package/src/components/enrichment/QualityBadge.tsx +43 -0
- package/src/components/enrichment/__tests__/enrichment.test.tsx +184 -0
- package/src/components/enrichment/index.ts +8 -0
- package/src/components/gantt/GanttBoardView.tsx +71 -0
- package/src/components/gantt/GanttChart.tsx +140 -887
- package/src/components/gantt/GanttFilterBar.tsx +100 -0
- package/src/components/gantt/GanttListView.tsx +63 -0
- package/src/components/gantt/GanttTimelineView.tsx +215 -0
- package/src/components/gantt/__tests__/GanttBoardView.test.tsx +305 -0
- package/src/components/gantt/__tests__/GanttFilterBar.test.tsx +544 -0
- package/src/components/gantt/__tests__/GanttListView.test.tsx +337 -0
- package/src/components/gantt/__tests__/GanttTimelineView.test.tsx +375 -0
- package/src/components/gantt/__tests__/gantt-utils.test.ts +341 -0
- package/src/components/gantt/__tests__/useGanttState.test.ts +535 -0
- package/src/components/gantt/hooks/useGanttState.ts +644 -0
- package/src/components/gantt/index.ts +10 -0
- package/src/components/gantt/types.ts +5 -5
- package/src/components/index.ts +46 -0
- package/src/components/integrations/ConnectionStatus.tsx +77 -0
- package/src/components/integrations/IntegrationCard.tsx +92 -0
- package/src/components/integrations/__tests__/integrations.test.tsx +191 -0
- package/src/components/integrations/index.ts +5 -0
- package/src/components/kanban/KanbanBoard.tsx +103 -0
- package/src/components/kanban/__tests__/kanban.test.tsx +157 -0
- package/src/components/kanban/index.ts +2 -0
- package/src/components/lists/CreateListDialog.tsx +158 -0
- package/src/components/lists/ListCard.tsx +77 -0
- package/src/components/lists/__tests__/lists.test.tsx +263 -0
- package/src/components/lists/index.ts +5 -0
- package/src/components/loading/__tests__/loading.test.tsx +114 -0
- package/src/components/navigation/__tests__/navigation.test.tsx +194 -0
- package/src/components/pipeline/StageTransitionModal.tsx +146 -0
- package/src/components/pipeline/__tests__/pipeline.test.tsx +169 -0
- package/src/components/pipeline/index.ts +2 -0
- package/src/components/settings/SettingsCard.tsx +33 -0
- package/src/components/settings/SettingsLayout.tsx +28 -0
- package/src/components/settings/SettingsNav.tsx +42 -0
- package/src/components/settings/__tests__/settings.test.tsx +181 -0
- package/src/components/settings/index.ts +6 -0
- 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
|
+
}
|