@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,188 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import {
|
|
5
|
+
Dialog,
|
|
6
|
+
DialogContent,
|
|
7
|
+
DialogDescription,
|
|
8
|
+
DialogFooter,
|
|
9
|
+
DialogHeader,
|
|
10
|
+
DialogTitle,
|
|
11
|
+
} from '../ui/dialog'
|
|
12
|
+
import { Button } from '../ui/button'
|
|
13
|
+
import { Input } from '../ui/input'
|
|
14
|
+
import { Label } from '../ui/label'
|
|
15
|
+
import { Checkbox } from '../ui/checkbox'
|
|
16
|
+
import {
|
|
17
|
+
Select,
|
|
18
|
+
SelectContent,
|
|
19
|
+
SelectItem,
|
|
20
|
+
SelectTrigger,
|
|
21
|
+
SelectValue,
|
|
22
|
+
} from '../ui/select'
|
|
23
|
+
import { Loader2, Send, AlertCircle } from 'lucide-react'
|
|
24
|
+
import { useToast } from '../toast'
|
|
25
|
+
|
|
26
|
+
interface TestSendRecipient {
|
|
27
|
+
id: string
|
|
28
|
+
name: string
|
|
29
|
+
email?: string | null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface TestSendPayload {
|
|
33
|
+
testEmail: string
|
|
34
|
+
selectedRecipientId: string | undefined
|
|
35
|
+
includeTracking: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface TestSendDialogProps {
|
|
39
|
+
open: boolean
|
|
40
|
+
onOpenChange: (open: boolean) => void
|
|
41
|
+
subject: string
|
|
42
|
+
/** Sample recipients whose data can be used for merge field substitution */
|
|
43
|
+
recipients?: TestSendRecipient[]
|
|
44
|
+
/** Default sample option label shown when no specific recipient is chosen */
|
|
45
|
+
defaultSampleLabel?: string
|
|
46
|
+
/** Callback to send the test email. Receives the test send configuration. */
|
|
47
|
+
onSendTest: (payload: TestSendPayload) => Promise<void>
|
|
48
|
+
/** Merge field help text */
|
|
49
|
+
mergeFieldHint?: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function TestSendDialog({
|
|
53
|
+
open,
|
|
54
|
+
onOpenChange,
|
|
55
|
+
subject,
|
|
56
|
+
recipients = [],
|
|
57
|
+
defaultSampleLabel = 'Sample Data (John Smith)',
|
|
58
|
+
onSendTest,
|
|
59
|
+
mergeFieldHint = 'Merge fields like {{recipient.firstName}} will use this recipient\'s data',
|
|
60
|
+
}: TestSendDialogProps) {
|
|
61
|
+
const { toast } = useToast()
|
|
62
|
+
const [sending, setSending] = useState(false)
|
|
63
|
+
const [testEmail, setTestEmail] = useState('')
|
|
64
|
+
const [selectedRecipientId, setSelectedRecipientId] = useState<string>('sample')
|
|
65
|
+
const [includeTracking, setIncludeTracking] = useState(false)
|
|
66
|
+
|
|
67
|
+
const handleSendTest = async () => {
|
|
68
|
+
if (!testEmail) {
|
|
69
|
+
toast({ description: 'Please enter a test email address', variant: 'destructive' })
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
setSending(true)
|
|
74
|
+
try {
|
|
75
|
+
await onSendTest({
|
|
76
|
+
testEmail,
|
|
77
|
+
selectedRecipientId: selectedRecipientId !== 'sample' ? selectedRecipientId : undefined,
|
|
78
|
+
includeTracking,
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
toast({
|
|
82
|
+
description: `Test email sent to ${testEmail}`,
|
|
83
|
+
})
|
|
84
|
+
onOpenChange(false)
|
|
85
|
+
} catch (error) {
|
|
86
|
+
console.error('Error sending test:', error)
|
|
87
|
+
toast({
|
|
88
|
+
description: error instanceof Error ? error.message : 'Failed to send test email',
|
|
89
|
+
variant: 'destructive'
|
|
90
|
+
})
|
|
91
|
+
} finally {
|
|
92
|
+
setSending(false)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
98
|
+
<DialogContent>
|
|
99
|
+
<DialogHeader>
|
|
100
|
+
<DialogTitle>Send Test Email</DialogTitle>
|
|
101
|
+
<DialogDescription>
|
|
102
|
+
Send a test version to yourself to preview how it will look in your inbox.
|
|
103
|
+
</DialogDescription>
|
|
104
|
+
</DialogHeader>
|
|
105
|
+
|
|
106
|
+
<div className="space-y-4 py-4">
|
|
107
|
+
{/* Subject preview */}
|
|
108
|
+
<div className="p-3 bg-muted rounded-lg">
|
|
109
|
+
<Label className="text-xs text-muted-foreground">Subject</Label>
|
|
110
|
+
<p className="text-sm font-medium mt-1">[TEST] {subject || '(no subject)'}</p>
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
{/* Test email input */}
|
|
114
|
+
<div className="space-y-2">
|
|
115
|
+
<Label htmlFor="test-email">Send to</Label>
|
|
116
|
+
<Input
|
|
117
|
+
id="test-email"
|
|
118
|
+
type="email"
|
|
119
|
+
value={testEmail}
|
|
120
|
+
onChange={(e) => setTestEmail(e.target.value)}
|
|
121
|
+
placeholder="your-email@example.com"
|
|
122
|
+
/>
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
{/* Sample recipient data for merge fields */}
|
|
126
|
+
<div className="space-y-2">
|
|
127
|
+
<Label>Use data from</Label>
|
|
128
|
+
<Select value={selectedRecipientId} onValueChange={setSelectedRecipientId}>
|
|
129
|
+
<SelectTrigger>
|
|
130
|
+
<SelectValue placeholder="Select recipient for merge fields" />
|
|
131
|
+
</SelectTrigger>
|
|
132
|
+
<SelectContent>
|
|
133
|
+
<SelectItem value="sample">{defaultSampleLabel}</SelectItem>
|
|
134
|
+
{recipients.map(r => (
|
|
135
|
+
<SelectItem key={r.id} value={r.id}>
|
|
136
|
+
{r.name} {r.email ? `(${r.email})` : ''}
|
|
137
|
+
</SelectItem>
|
|
138
|
+
))}
|
|
139
|
+
</SelectContent>
|
|
140
|
+
</Select>
|
|
141
|
+
<p className="text-xs text-muted-foreground">
|
|
142
|
+
{mergeFieldHint}
|
|
143
|
+
</p>
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
{/* Tracking option */}
|
|
147
|
+
<div className="flex items-center space-x-2">
|
|
148
|
+
<Checkbox
|
|
149
|
+
id="include-tracking"
|
|
150
|
+
checked={includeTracking}
|
|
151
|
+
onCheckedChange={(checked) => setIncludeTracking(checked as boolean)}
|
|
152
|
+
/>
|
|
153
|
+
<Label htmlFor="include-tracking" className="text-sm font-normal">
|
|
154
|
+
Include tracking pixel and link tracking
|
|
155
|
+
</Label>
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
{/* Warning */}
|
|
159
|
+
<div className="flex items-start gap-2 p-3 bg-amber-50 border border-amber-200 rounded-lg text-amber-800">
|
|
160
|
+
<AlertCircle className="h-4 w-4 mt-0.5 flex-shrink-0" />
|
|
161
|
+
<p className="text-sm">
|
|
162
|
+
Test emails are marked with [TEST] in the subject and include a banner indicating it's a test.
|
|
163
|
+
</p>
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
|
|
167
|
+
<DialogFooter>
|
|
168
|
+
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={sending}>
|
|
169
|
+
Cancel
|
|
170
|
+
</Button>
|
|
171
|
+
<Button onClick={handleSendTest} disabled={sending || !testEmail}>
|
|
172
|
+
{sending ? (
|
|
173
|
+
<>
|
|
174
|
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
175
|
+
Sending...
|
|
176
|
+
</>
|
|
177
|
+
) : (
|
|
178
|
+
<>
|
|
179
|
+
<Send className="mr-2 h-4 w-4" />
|
|
180
|
+
Send Test
|
|
181
|
+
</>
|
|
182
|
+
)}
|
|
183
|
+
</Button>
|
|
184
|
+
</DialogFooter>
|
|
185
|
+
</DialogContent>
|
|
186
|
+
</Dialog>
|
|
187
|
+
)
|
|
188
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { Block, MergeFieldDefinition } from './types'
|
|
5
|
+
import {
|
|
6
|
+
TextBlockEditor,
|
|
7
|
+
MetricsBlockEditor,
|
|
8
|
+
DividerBlockEditor,
|
|
9
|
+
ButtonBlockEditor,
|
|
10
|
+
ImageBlockEditor,
|
|
11
|
+
SpacerBlockEditor,
|
|
12
|
+
SocialBlockEditor,
|
|
13
|
+
HeaderBlockEditor,
|
|
14
|
+
FooterBlockEditor,
|
|
15
|
+
} from './blocks'
|
|
16
|
+
|
|
17
|
+
interface BlockRendererProps {
|
|
18
|
+
block: Block
|
|
19
|
+
editing: boolean
|
|
20
|
+
onChange: (updates: Partial<Block>) => void
|
|
21
|
+
mergeFields: MergeFieldDefinition[]
|
|
22
|
+
/** Optional custom rich text editor for the text block. */
|
|
23
|
+
renderTextEditor?: (props: {
|
|
24
|
+
content: string
|
|
25
|
+
onChange: (html: string) => void
|
|
26
|
+
placeholder?: string
|
|
27
|
+
className?: string
|
|
28
|
+
}) => React.ReactNode
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function BlockRenderer({
|
|
32
|
+
block,
|
|
33
|
+
editing,
|
|
34
|
+
onChange,
|
|
35
|
+
mergeFields: _mergeFields,
|
|
36
|
+
renderTextEditor,
|
|
37
|
+
}: BlockRendererProps) {
|
|
38
|
+
const commonProps = {
|
|
39
|
+
isEditing: editing,
|
|
40
|
+
onChange: onChange as (block: Block) => void,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
switch (block.type) {
|
|
44
|
+
case 'text':
|
|
45
|
+
return (
|
|
46
|
+
<TextBlockEditor
|
|
47
|
+
block={block}
|
|
48
|
+
{...commonProps}
|
|
49
|
+
onChange={(b) => onChange(b)}
|
|
50
|
+
renderEditor={renderTextEditor}
|
|
51
|
+
/>
|
|
52
|
+
)
|
|
53
|
+
case 'metrics':
|
|
54
|
+
return (
|
|
55
|
+
<MetricsBlockEditor
|
|
56
|
+
block={block}
|
|
57
|
+
{...commonProps}
|
|
58
|
+
onChange={(b) => onChange(b)}
|
|
59
|
+
/>
|
|
60
|
+
)
|
|
61
|
+
case 'divider':
|
|
62
|
+
return (
|
|
63
|
+
<DividerBlockEditor
|
|
64
|
+
block={block}
|
|
65
|
+
{...commonProps}
|
|
66
|
+
onChange={(b) => onChange(b)}
|
|
67
|
+
/>
|
|
68
|
+
)
|
|
69
|
+
case 'cta':
|
|
70
|
+
return (
|
|
71
|
+
<ButtonBlockEditor
|
|
72
|
+
block={block}
|
|
73
|
+
{...commonProps}
|
|
74
|
+
onChange={(b) => onChange(b)}
|
|
75
|
+
/>
|
|
76
|
+
)
|
|
77
|
+
case 'image':
|
|
78
|
+
return (
|
|
79
|
+
<ImageBlockEditor
|
|
80
|
+
block={block}
|
|
81
|
+
{...commonProps}
|
|
82
|
+
onChange={(b) => onChange(b)}
|
|
83
|
+
/>
|
|
84
|
+
)
|
|
85
|
+
case 'spacer':
|
|
86
|
+
return (
|
|
87
|
+
<SpacerBlockEditor
|
|
88
|
+
block={block}
|
|
89
|
+
{...commonProps}
|
|
90
|
+
onChange={(b) => onChange(b)}
|
|
91
|
+
/>
|
|
92
|
+
)
|
|
93
|
+
case 'social':
|
|
94
|
+
return (
|
|
95
|
+
<SocialBlockEditor
|
|
96
|
+
block={block}
|
|
97
|
+
{...commonProps}
|
|
98
|
+
onChange={(b) => onChange(b)}
|
|
99
|
+
/>
|
|
100
|
+
)
|
|
101
|
+
case 'header':
|
|
102
|
+
return (
|
|
103
|
+
<HeaderBlockEditor
|
|
104
|
+
block={block}
|
|
105
|
+
{...commonProps}
|
|
106
|
+
onChange={(b) => onChange(b)}
|
|
107
|
+
/>
|
|
108
|
+
)
|
|
109
|
+
case 'footer':
|
|
110
|
+
return (
|
|
111
|
+
<FooterBlockEditor
|
|
112
|
+
block={block}
|
|
113
|
+
{...commonProps}
|
|
114
|
+
onChange={(b) => onChange(b)}
|
|
115
|
+
/>
|
|
116
|
+
)
|
|
117
|
+
default:
|
|
118
|
+
return null
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
2
|
+
import { BlockRenderer } from '../BlockRenderer'
|
|
3
|
+
import type {
|
|
4
|
+
TextBlock,
|
|
5
|
+
MetricsBlock,
|
|
6
|
+
DividerBlock,
|
|
7
|
+
CTABlock,
|
|
8
|
+
ImageBlock,
|
|
9
|
+
SpacerBlock,
|
|
10
|
+
SocialBlock,
|
|
11
|
+
HeaderBlock,
|
|
12
|
+
FooterBlock,
|
|
13
|
+
Block,
|
|
14
|
+
} from '../types'
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Fixtures
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
const textBlock: TextBlock = {
|
|
21
|
+
id: 'text-1',
|
|
22
|
+
type: 'text',
|
|
23
|
+
content: '<p>Hello dispatch</p>',
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const metricsBlock: MetricsBlock = {
|
|
27
|
+
id: 'metrics-1',
|
|
28
|
+
type: 'metrics',
|
|
29
|
+
title: 'Dispatch Metrics',
|
|
30
|
+
metrics: [
|
|
31
|
+
{ id: 'm1', label: 'ARR', value: '$5k', changeType: 'positive' },
|
|
32
|
+
],
|
|
33
|
+
columns: 2,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const dividerBlock: DividerBlock = {
|
|
37
|
+
id: 'divider-1',
|
|
38
|
+
type: 'divider',
|
|
39
|
+
dividerStyle: 'solid',
|
|
40
|
+
color: '#d1d5db',
|
|
41
|
+
thickness: 1,
|
|
42
|
+
width: 100,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const ctaBlock: CTABlock = {
|
|
46
|
+
id: 'cta-1',
|
|
47
|
+
type: 'cta',
|
|
48
|
+
text: 'Dispatch CTA',
|
|
49
|
+
url: 'https://example.com',
|
|
50
|
+
buttonColor: '#2563eb',
|
|
51
|
+
textColor: '#ffffff',
|
|
52
|
+
borderRadius: 6,
|
|
53
|
+
paddingH: 24,
|
|
54
|
+
paddingV: 12,
|
|
55
|
+
alignment: 'center',
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const imageBlock: ImageBlock = {
|
|
59
|
+
id: 'image-1',
|
|
60
|
+
type: 'image',
|
|
61
|
+
url: 'https://example.com/img.png',
|
|
62
|
+
alt: 'dispatch img',
|
|
63
|
+
alignment: 'center',
|
|
64
|
+
width: 100,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const spacerBlock: SpacerBlock = {
|
|
68
|
+
id: 'spacer-1',
|
|
69
|
+
type: 'spacer',
|
|
70
|
+
height: 40,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const socialBlock: SocialBlock = {
|
|
74
|
+
id: 'social-1',
|
|
75
|
+
type: 'social',
|
|
76
|
+
links: [{ id: 'sl1', platform: 'linkedin', url: 'https://linkedin.com/test' }],
|
|
77
|
+
iconSize: 24,
|
|
78
|
+
alignment: 'center',
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const headerBlock: HeaderBlock = {
|
|
82
|
+
id: 'header-1',
|
|
83
|
+
type: 'header',
|
|
84
|
+
companyName: 'DispatchCo',
|
|
85
|
+
alignment: 'center',
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const footerBlock: FooterBlock = {
|
|
89
|
+
id: 'footer-1',
|
|
90
|
+
type: 'footer',
|
|
91
|
+
companyName: 'DispatchCo',
|
|
92
|
+
showUnsubscribe: false,
|
|
93
|
+
alignment: 'center',
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Helper
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
function renderBlock(block: Block, editing = false) {
|
|
101
|
+
return render(
|
|
102
|
+
<BlockRenderer
|
|
103
|
+
block={block}
|
|
104
|
+
editing={editing}
|
|
105
|
+
onChange={jest.fn()}
|
|
106
|
+
mergeFields={[]}
|
|
107
|
+
/>
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// Dispatches to correct editor
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
describe('BlockRenderer — dispatches to correct block editor', () => {
|
|
116
|
+
it('renders TextBlockEditor for text block (view mode shows .prose)', () => {
|
|
117
|
+
const { container } = renderBlock(textBlock, false)
|
|
118
|
+
expect(container.querySelector('.prose')).toBeInTheDocument()
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('renders TextBlockEditor for text block (editing mode shows contentEditable)', () => {
|
|
122
|
+
const { container } = renderBlock(textBlock, true)
|
|
123
|
+
expect(container.querySelector('[contenteditable="true"]')).toBeInTheDocument()
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('renders MetricsBlockEditor for metrics block — shows title', () => {
|
|
127
|
+
renderBlock(metricsBlock, false)
|
|
128
|
+
expect(screen.getByText('Dispatch Metrics')).toBeInTheDocument()
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('renders MetricsBlockEditor for metrics block — shows metric value', () => {
|
|
132
|
+
renderBlock(metricsBlock, false)
|
|
133
|
+
expect(screen.getByText('$5k')).toBeInTheDocument()
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('renders DividerBlockEditor for divider block — shows hr element', () => {
|
|
137
|
+
const { container } = renderBlock(dividerBlock, false)
|
|
138
|
+
expect(container.querySelector('hr')).toBeInTheDocument()
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('renders ButtonBlockEditor for cta block — shows button text', () => {
|
|
142
|
+
renderBlock(ctaBlock, false)
|
|
143
|
+
expect(screen.getByText('Dispatch CTA')).toBeInTheDocument()
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('renders ImageBlockEditor for image block — shows img element', () => {
|
|
147
|
+
const { container } = renderBlock(imageBlock, false)
|
|
148
|
+
expect(container.querySelector('img')).toBeInTheDocument()
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('renders SpacerBlockEditor for spacer block — shows correct height in editing mode', () => {
|
|
152
|
+
renderBlock(spacerBlock, true)
|
|
153
|
+
expect(screen.getByText('40px')).toBeInTheDocument()
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('renders SocialBlockEditor for social block — shows social icon wrapper', () => {
|
|
157
|
+
const { container } = renderBlock(socialBlock, false)
|
|
158
|
+
expect(container.querySelector('div')).toBeInTheDocument()
|
|
159
|
+
// LinkedIn link present since url is set
|
|
160
|
+
expect(container.querySelector('a')).toBeInTheDocument()
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('renders HeaderBlockEditor for header block — shows company name', () => {
|
|
164
|
+
renderBlock(headerBlock, false)
|
|
165
|
+
expect(screen.getByText('DispatchCo')).toBeInTheDocument()
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('renders FooterBlockEditor for footer block — shows company name', () => {
|
|
169
|
+
renderBlock(footerBlock, false)
|
|
170
|
+
// Footer renders company name
|
|
171
|
+
expect(screen.getByText('DispatchCo')).toBeInTheDocument()
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// editing prop is forwarded correctly
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
describe('BlockRenderer — editing prop forwarding', () => {
|
|
180
|
+
it('text block: editing=false renders view mode (.prose)', () => {
|
|
181
|
+
const { container } = renderBlock(textBlock, false)
|
|
182
|
+
expect(container.querySelector('.prose')).toBeInTheDocument()
|
|
183
|
+
expect(container.querySelector('[contenteditable="true"]')).not.toBeInTheDocument()
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('text block: editing=true renders edit mode (contentEditable)', () => {
|
|
187
|
+
const { container } = renderBlock(textBlock, true)
|
|
188
|
+
expect(container.querySelector('[contenteditable="true"]')).toBeInTheDocument()
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('spacer block: editing=false renders plain div without label text', () => {
|
|
192
|
+
const { container } = renderBlock(spacerBlock, false)
|
|
193
|
+
// view mode renders a plain height div, no "px" label
|
|
194
|
+
expect(screen.queryByText('40px')).not.toBeInTheDocument()
|
|
195
|
+
const spacer = container.querySelector('div') as HTMLElement
|
|
196
|
+
expect(spacer.style.height).toBe('40px')
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('spacer block: editing=true shows height label', () => {
|
|
200
|
+
renderBlock(spacerBlock, true)
|
|
201
|
+
expect(screen.getByText('40px')).toBeInTheDocument()
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('image block: editing=true shows placeholder when url empty', () => {
|
|
205
|
+
const emptyImage: ImageBlock = { ...imageBlock, url: '' }
|
|
206
|
+
render(
|
|
207
|
+
<BlockRenderer
|
|
208
|
+
block={emptyImage}
|
|
209
|
+
editing={true}
|
|
210
|
+
onChange={jest.fn()}
|
|
211
|
+
mergeFields={[]}
|
|
212
|
+
/>
|
|
213
|
+
)
|
|
214
|
+
expect(screen.getByText('Click to set image URL')).toBeInTheDocument()
|
|
215
|
+
})
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
// onChange is forwarded through to child editors
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
describe('BlockRenderer — onChange forwarding', () => {
|
|
223
|
+
it('text block: onChange is called when contentEditable blurs', () => {
|
|
224
|
+
const onChange = jest.fn()
|
|
225
|
+
const { container } = render(
|
|
226
|
+
<BlockRenderer
|
|
227
|
+
block={textBlock}
|
|
228
|
+
editing={true}
|
|
229
|
+
onChange={onChange}
|
|
230
|
+
mergeFields={[]}
|
|
231
|
+
/>
|
|
232
|
+
)
|
|
233
|
+
const editable = container.querySelector('[contenteditable="true"]') as HTMLElement
|
|
234
|
+
fireEvent.blur(editable)
|
|
235
|
+
expect(onChange).toHaveBeenCalledTimes(1)
|
|
236
|
+
expect(onChange).toHaveBeenCalledWith(
|
|
237
|
+
expect.objectContaining({ type: 'text', id: 'text-1' })
|
|
238
|
+
)
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('metrics block: onChange is called when title input changes', () => {
|
|
242
|
+
const onChange = jest.fn()
|
|
243
|
+
render(
|
|
244
|
+
<BlockRenderer
|
|
245
|
+
block={metricsBlock}
|
|
246
|
+
editing={true}
|
|
247
|
+
onChange={onChange}
|
|
248
|
+
mergeFields={[]}
|
|
249
|
+
/>
|
|
250
|
+
)
|
|
251
|
+
const titleInput = screen.getByPlaceholderText('Key Metrics')
|
|
252
|
+
fireEvent.change(titleInput, { target: { value: 'New Title' } })
|
|
253
|
+
expect(onChange).toHaveBeenCalledTimes(1)
|
|
254
|
+
expect(onChange).toHaveBeenCalledWith(
|
|
255
|
+
expect.objectContaining({ type: 'metrics', title: 'New Title' })
|
|
256
|
+
)
|
|
257
|
+
})
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
// renderTextEditor prop is forwarded to TextBlockEditor
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
describe('BlockRenderer — renderTextEditor prop', () => {
|
|
265
|
+
it('renders custom editor when renderTextEditor is provided for text block', () => {
|
|
266
|
+
const renderTextEditor = jest.fn(() => <div data-testid="custom-rich-editor" />)
|
|
267
|
+
render(
|
|
268
|
+
<BlockRenderer
|
|
269
|
+
block={textBlock}
|
|
270
|
+
editing={true}
|
|
271
|
+
onChange={jest.fn()}
|
|
272
|
+
mergeFields={[]}
|
|
273
|
+
renderTextEditor={renderTextEditor}
|
|
274
|
+
/>
|
|
275
|
+
)
|
|
276
|
+
expect(screen.getByTestId('custom-rich-editor')).toBeInTheDocument()
|
|
277
|
+
expect(renderTextEditor).toHaveBeenCalledWith(
|
|
278
|
+
expect.objectContaining({
|
|
279
|
+
content: textBlock.content,
|
|
280
|
+
})
|
|
281
|
+
)
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
it('renderTextEditor is ignored for non-text block types', () => {
|
|
285
|
+
const renderTextEditor = jest.fn(() => <div data-testid="custom-rich-editor" />)
|
|
286
|
+
render(
|
|
287
|
+
<BlockRenderer
|
|
288
|
+
block={dividerBlock}
|
|
289
|
+
editing={true}
|
|
290
|
+
onChange={jest.fn()}
|
|
291
|
+
mergeFields={[]}
|
|
292
|
+
renderTextEditor={renderTextEditor}
|
|
293
|
+
/>
|
|
294
|
+
)
|
|
295
|
+
// DividerBlockEditor should render instead
|
|
296
|
+
expect(screen.queryByTestId('custom-rich-editor')).not.toBeInTheDocument()
|
|
297
|
+
const { container } = render(
|
|
298
|
+
<BlockRenderer
|
|
299
|
+
block={dividerBlock}
|
|
300
|
+
editing={true}
|
|
301
|
+
onChange={jest.fn()}
|
|
302
|
+
mergeFields={[]}
|
|
303
|
+
renderTextEditor={renderTextEditor}
|
|
304
|
+
/>
|
|
305
|
+
)
|
|
306
|
+
expect(container.querySelector('hr')).toBeInTheDocument()
|
|
307
|
+
expect(renderTextEditor).not.toHaveBeenCalled()
|
|
308
|
+
})
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
// ---------------------------------------------------------------------------
|
|
312
|
+
// mergeFields prop does not cause errors
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
describe('BlockRenderer — mergeFields prop', () => {
|
|
316
|
+
it('renders without error when mergeFields is empty', () => {
|
|
317
|
+
expect(() => renderBlock(textBlock, false)).not.toThrow()
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
it('renders without error when mergeFields has items', () => {
|
|
321
|
+
expect(() =>
|
|
322
|
+
render(
|
|
323
|
+
<BlockRenderer
|
|
324
|
+
block={textBlock}
|
|
325
|
+
editing={false}
|
|
326
|
+
onChange={jest.fn()}
|
|
327
|
+
mergeFields={[{ key: '{{name}}', label: 'Name', example: 'John', category: 'contact' }]}
|
|
328
|
+
/>
|
|
329
|
+
)
|
|
330
|
+
).not.toThrow()
|
|
331
|
+
})
|
|
332
|
+
})
|