@startsimpli/ui 0.4.7 → 0.4.9

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 (62) hide show
  1. package/package.json +21 -23
  2. package/src/__mocks__/next/link.js +11 -0
  3. package/src/components/account/__tests__/account.test.tsx +315 -0
  4. package/src/components/command-palette/CommandGroup.tsx +23 -0
  5. package/src/components/command-palette/CommandPalette.tsx +183 -200
  6. package/src/components/command-palette/CommandResultItem.tsx +59 -0
  7. package/src/components/command-palette/__tests__/CommandGroup.test.tsx +81 -0
  8. package/src/components/command-palette/__tests__/CommandResultItem.test.tsx +166 -0
  9. package/src/components/command-palette/__tests__/command-palette-context.test.tsx +166 -0
  10. package/src/components/command-palette/__tests__/useCommandPaletteSearch.test.ts +271 -0
  11. package/src/components/command-palette/index.ts +6 -0
  12. package/src/components/command-palette/useCommandPaletteSearch.ts +114 -0
  13. package/src/components/compose/__tests__/compose.test.tsx +656 -0
  14. package/src/components/dashboard/PipelineFunnel.tsx +126 -0
  15. package/src/components/dashboard/TopCampaigns.tsx +132 -0
  16. package/src/components/dashboard/__tests__/dashboard.test.tsx +785 -0
  17. package/src/components/dashboard/index.ts +6 -0
  18. package/src/components/dialog/ConfirmDialog.tsx +72 -0
  19. package/src/components/dialog/__tests__/ConfirmDialog.test.tsx +126 -0
  20. package/src/components/dialog/index.ts +3 -0
  21. package/src/components/email-dialogs/__tests__/email-dialogs.test.tsx +982 -0
  22. package/src/components/email-editor/BlockRenderer.tsx +120 -0
  23. package/src/components/email-editor/__tests__/BlockRenderer.test.tsx +332 -0
  24. package/src/components/email-editor/__tests__/block-renderers.test.ts +624 -0
  25. package/src/components/email-editor/__tests__/email-html-renderer.test.ts +376 -0
  26. package/src/components/email-editor/blocks/__tests__/blocks.test.tsx +818 -0
  27. package/src/components/email-editor/editor-sidebar.tsx +6 -731
  28. package/src/components/email-editor/email-editor.tsx +78 -467
  29. package/src/components/email-editor/hooks/__tests__/useDragDrop.test.ts +355 -0
  30. package/src/components/email-editor/hooks/__tests__/useEmailEditorState.test.ts +551 -0
  31. package/src/components/email-editor/hooks/useDragDrop.ts +181 -0
  32. package/src/components/email-editor/hooks/useEmailEditorState.ts +426 -0
  33. package/src/components/email-editor/index.ts +1 -0
  34. package/src/components/email-editor/panels/BlockPropertyPanel.tsx +637 -0
  35. package/src/components/email-editor/panels/GlobalStylesPanel.tsx +108 -0
  36. package/src/components/email-editor/panels/SectionSettingsPanel.tsx +80 -0
  37. package/src/components/email-editor/panels/__tests__/BlockPropertyPanel.test.tsx +707 -0
  38. package/src/components/email-editor/panels/__tests__/GlobalStylesPanel.test.tsx +226 -0
  39. package/src/components/email-editor/panels/index.ts +3 -0
  40. package/src/components/enrichment/__tests__/enrichment.test.tsx +184 -0
  41. package/src/components/gantt/GanttBoardView.tsx +71 -0
  42. package/src/components/gantt/GanttChart.tsx +134 -881
  43. package/src/components/gantt/GanttFilterBar.tsx +100 -0
  44. package/src/components/gantt/GanttListView.tsx +63 -0
  45. package/src/components/gantt/GanttTimelineView.tsx +215 -0
  46. package/src/components/gantt/__tests__/GanttBoardView.test.tsx +305 -0
  47. package/src/components/gantt/__tests__/GanttFilterBar.test.tsx +544 -0
  48. package/src/components/gantt/__tests__/GanttListView.test.tsx +337 -0
  49. package/src/components/gantt/__tests__/GanttTimelineView.test.tsx +375 -0
  50. package/src/components/gantt/__tests__/gantt-utils.test.ts +341 -0
  51. package/src/components/gantt/__tests__/useGanttState.test.ts +535 -0
  52. package/src/components/gantt/hooks/useGanttState.ts +644 -0
  53. package/src/components/gantt/index.ts +10 -0
  54. package/src/components/integrations/__tests__/integrations.test.tsx +191 -0
  55. package/src/components/kanban/__tests__/kanban.test.tsx +157 -0
  56. package/src/components/lists/__tests__/lists.test.tsx +263 -0
  57. package/src/components/loading/__tests__/loading.test.tsx +114 -0
  58. package/src/components/navigation/__tests__/navigation.test.tsx +194 -0
  59. package/src/components/pipeline/__tests__/pipeline.test.tsx +169 -0
  60. package/src/components/safe-html.tsx +9 -8
  61. package/src/components/settings/__tests__/settings.test.tsx +181 -0
  62. package/src/components/wizard/__tests__/wizard.test.tsx +97 -0
@@ -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
+ })