@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,375 @@
1
+ import { render, screen, fireEvent } from '@testing-library/react'
2
+ import { GanttTimelineView } from '../GanttTimelineView'
3
+ import type { GanttTimelineViewProps } from '../GanttTimelineView'
4
+ import type { GanttTask, TimelineItem } from '../types'
5
+ import { ZOOM_LEVELS, ROW_HEIGHT } from '../hooks/useGanttState'
6
+ import React from 'react'
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Helpers
10
+ // ---------------------------------------------------------------------------
11
+
12
+ function makeItem(overrides: Partial<TimelineItem> & { id: string; title: string; status: string }): TimelineItem {
13
+ return { startDate: '2025-01-01', endDate: '2025-03-31', ...overrides }
14
+ }
15
+
16
+ function makeTask(item: TimelineItem, overrides: Partial<GanttTask> = {}): GanttTask {
17
+ const start = new Date('2025-01-01')
18
+ const end = new Date('2025-03-31')
19
+ return {
20
+ item,
21
+ start,
22
+ end,
23
+ progress: 50,
24
+ timeProgress: 30,
25
+ depth: 0,
26
+ hasChildren: false,
27
+ parentId: null,
28
+ healthStatus: 'on_track',
29
+ ...overrides,
30
+ }
31
+ }
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Minimal default props factory
35
+ // ---------------------------------------------------------------------------
36
+
37
+ function defaultProps(overrides: Partial<GanttTimelineViewProps> = {}): GanttTimelineViewProps {
38
+ const ZOOM_INDEX = 3
39
+ const DAY_WIDTH = ZOOM_LEVELS[ZOOM_INDEX]
40
+ const startDate = new Date('2025-01-01')
41
+ const days: Date[] = []
42
+ for (let i = 0; i < 90; i++) {
43
+ const d = new Date(startDate)
44
+ d.setDate(d.getDate() + i)
45
+ days.push(d)
46
+ }
47
+
48
+ const months = [
49
+ { month: new Date('2025-01-01'), days: 31, label: 'Jan 2025' },
50
+ { month: new Date('2025-02-01'), days: 28, label: 'Feb 2025' },
51
+ { month: new Date('2025-03-01'), days: 31, label: 'Mar 2025' },
52
+ ]
53
+
54
+ const timelineWidth = days.length * DAY_WIDTH
55
+
56
+ return {
57
+ infoColumnWidth: 320,
58
+ infoColumnLabel: 'Item',
59
+ showCategory: true,
60
+ showStatus: true,
61
+ showFullscreen: false,
62
+ zoomIndex: ZOOM_INDEX,
63
+ setZoomIndex: jest.fn(),
64
+ collapsed: new Set<string>(),
65
+ mounted: true,
66
+ dragState: null,
67
+ focusedRowIndex: -1,
68
+ dayWidth: DAY_WIDTH,
69
+ tasks: [],
70
+ startDate,
71
+ days,
72
+ months,
73
+ timeHeaderUnits: {
74
+ mode: 'days',
75
+ units: days.map((day) => ({
76
+ date: day,
77
+ width: DAY_WIDTH,
78
+ label: String(day.getDate()),
79
+ isWeekend: day.getDay() === 0 || day.getDay() === 6,
80
+ isToday: false,
81
+ })),
82
+ },
83
+ dependencyPaths: [],
84
+ timelineWidth,
85
+ bodyHeight: 0,
86
+ isFullscreen: false,
87
+ categoryColors: { product: '#3b82f6', team: '#a855f7', other: '#6b7280' },
88
+ onDateChange: undefined,
89
+ bodyScrollRef: { current: null },
90
+ headerTimelineRef: { current: null },
91
+ infoColumnRef: { current: null },
92
+ toggleFullscreen: jest.fn(),
93
+ handleItemClick: jest.fn(),
94
+ toggleCollapse: jest.fn(),
95
+ handleBodyScroll: jest.fn(),
96
+ getBarPosition: jest.fn().mockReturnValue({ left: 0, width: 100, top: 0, height: 20 }),
97
+ handleDragStart: jest.fn(),
98
+ ...overrides,
99
+ }
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Header rendering
104
+ // ---------------------------------------------------------------------------
105
+
106
+ describe('GanttTimelineView — header', () => {
107
+ it('renders the info column header with the infoColumnLabel', () => {
108
+ render(<GanttTimelineView {...defaultProps({ infoColumnLabel: 'Goal' })} />)
109
+ expect(screen.getByText('Goal')).toBeInTheDocument()
110
+ })
111
+
112
+ it('renders month labels in the timeline header', () => {
113
+ render(<GanttTimelineView {...defaultProps()} />)
114
+ expect(screen.getByText('Jan 2025')).toBeInTheDocument()
115
+ expect(screen.getByText('Feb 2025')).toBeInTheDocument()
116
+ expect(screen.getByText('Mar 2025')).toBeInTheDocument()
117
+ })
118
+
119
+ it('renders day unit labels in days mode', () => {
120
+ render(<GanttTimelineView {...defaultProps()} />)
121
+ // Day 1 label should appear at start
122
+ const dayOnes = screen.getAllByText('1')
123
+ expect(dayOnes.length).toBeGreaterThan(0)
124
+ })
125
+
126
+ it('renders week unit labels in weeks mode', () => {
127
+ const weekUnits = [
128
+ { startDate: new Date('2025-01-06'), endDate: new Date('2025-01-12'), days: 7, label: 'Jan 6', hasToday: false, width: 56 },
129
+ { startDate: new Date('2025-01-13'), endDate: new Date('2025-01-19'), days: 7, label: 'Jan 13', hasToday: false, width: 56 },
130
+ ]
131
+ render(
132
+ <GanttTimelineView
133
+ {...defaultProps({
134
+ timeHeaderUnits: { mode: 'weeks', units: weekUnits },
135
+ })}
136
+ />
137
+ )
138
+ expect(screen.getByText('Jan 6')).toBeInTheDocument()
139
+ expect(screen.getByText('Jan 13')).toBeInTheDocument()
140
+ })
141
+ })
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // Zoom controls
145
+ // ---------------------------------------------------------------------------
146
+
147
+ describe('GanttTimelineView — zoom controls', () => {
148
+ it('renders zoom out button', () => {
149
+ render(<GanttTimelineView {...defaultProps()} />)
150
+ expect(screen.getByRole('button', { name: 'Zoom out' })).toBeInTheDocument()
151
+ })
152
+
153
+ it('renders zoom in button', () => {
154
+ render(<GanttTimelineView {...defaultProps()} />)
155
+ expect(screen.getByRole('button', { name: 'Zoom in' })) .toBeInTheDocument()
156
+ })
157
+
158
+ it('disables zoom out button at minimum zoom', () => {
159
+ render(<GanttTimelineView {...defaultProps({ zoomIndex: 0 })} />)
160
+ expect(screen.getByRole('button', { name: 'Zoom out' })).toBeDisabled()
161
+ })
162
+
163
+ it('disables zoom in button at maximum zoom', () => {
164
+ render(<GanttTimelineView {...defaultProps({ zoomIndex: ZOOM_LEVELS.length - 1 })} />)
165
+ expect(screen.getByRole('button', { name: 'Zoom in' })).toBeDisabled()
166
+ })
167
+
168
+ it('calls setZoomIndex when zoom out is clicked', () => {
169
+ const setZoomIndex = jest.fn()
170
+ render(<GanttTimelineView {...defaultProps({ setZoomIndex, zoomIndex: 3 })} />)
171
+ fireEvent.click(screen.getByRole('button', { name: 'Zoom out' }))
172
+ expect(setZoomIndex).toHaveBeenCalledTimes(1)
173
+ })
174
+
175
+ it('calls setZoomIndex when zoom in is clicked', () => {
176
+ const setZoomIndex = jest.fn()
177
+ render(<GanttTimelineView {...defaultProps({ setZoomIndex, zoomIndex: 3 })} />)
178
+ fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }))
179
+ expect(setZoomIndex).toHaveBeenCalledTimes(1)
180
+ })
181
+
182
+ it('does not render fullscreen button when showFullscreen is false', () => {
183
+ render(<GanttTimelineView {...defaultProps({ showFullscreen: false })} />)
184
+ expect(screen.queryByRole('button', { name: /fullscreen/i })).not.toBeInTheDocument()
185
+ })
186
+
187
+ it('renders fullscreen button when showFullscreen is true', () => {
188
+ render(<GanttTimelineView {...defaultProps({ showFullscreen: true })} />)
189
+ expect(screen.getByRole('button', { name: /fullscreen/i })).toBeInTheDocument()
190
+ })
191
+
192
+ it('calls toggleFullscreen when the fullscreen button is clicked', () => {
193
+ const toggleFullscreen = jest.fn()
194
+ render(<GanttTimelineView {...defaultProps({ showFullscreen: true, toggleFullscreen })} />)
195
+ fireEvent.click(screen.getByRole('button', { name: /fullscreen/i }))
196
+ expect(toggleFullscreen).toHaveBeenCalledTimes(1)
197
+ })
198
+ })
199
+
200
+ // ---------------------------------------------------------------------------
201
+ // Empty state
202
+ // ---------------------------------------------------------------------------
203
+
204
+ describe('GanttTimelineView — empty state', () => {
205
+ it('renders no info rows when tasks array is empty', () => {
206
+ render(<GanttTimelineView {...defaultProps({ tasks: [] })} />)
207
+ // No gantt-info-row elements means no task title links
208
+ expect(screen.queryAllByRole('button', { name: /expand|collapse/i })).toHaveLength(0)
209
+ })
210
+ })
211
+
212
+ // ---------------------------------------------------------------------------
213
+ // Task bars and info rows
214
+ // ---------------------------------------------------------------------------
215
+
216
+ describe('GanttTimelineView — task rows', () => {
217
+ it('renders a row title for each task', () => {
218
+ const tasks = [
219
+ makeTask(makeItem({ id: 'a', title: 'Alpha Task', status: 'in_progress' })),
220
+ makeTask(makeItem({ id: 'b', title: 'Beta Task', status: 'completed' })),
221
+ ]
222
+ // Use width <= 80 so bar label is empty (avoids duplicate title text nodes)
223
+ const getBarPosition = jest.fn().mockReturnValue({ left: 0, width: 80, top: 0, height: 20 })
224
+ render(<GanttTimelineView {...defaultProps({ tasks, getBarPosition })} />)
225
+ // Titles appear in the info column
226
+ expect(screen.getAllByText('Alpha Task').length).toBeGreaterThanOrEqual(1)
227
+ expect(screen.getAllByText('Beta Task').length).toBeGreaterThanOrEqual(1)
228
+ })
229
+
230
+ it('calls handleItemClick when a row title is clicked', () => {
231
+ const handleItemClick = jest.fn()
232
+ const item = makeItem({ id: 'a', title: 'Clickable Task', status: 'not_started' })
233
+ const tasks = [makeTask(item)]
234
+ // Use width <= 80 so the bar label renders empty and title only appears in the info row
235
+ const getBarPosition = jest.fn().mockReturnValue({ left: 0, width: 80, top: 0, height: 20 })
236
+ const { container } = render(<GanttTimelineView {...defaultProps({ tasks, handleItemClick, getBarPosition })} />)
237
+ // Target the info-column title span specifically to avoid the bar label span
238
+ const titleSpan = container.querySelector('.gantt-row-title') as HTMLElement
239
+ expect(titleSpan).toBeInTheDocument()
240
+ fireEvent.click(titleSpan)
241
+ expect(handleItemClick).toHaveBeenCalledWith(item)
242
+ })
243
+
244
+ it('renders a collapse button for tasks that have children', () => {
245
+ const tasks = [
246
+ makeTask(makeItem({ id: 'p', title: 'Parent', status: 'in_progress' }), { hasChildren: true }),
247
+ ]
248
+ const getBarPosition = jest.fn().mockReturnValue({ left: 0, width: 200, top: 0, height: 20 })
249
+ render(<GanttTimelineView {...defaultProps({ tasks, getBarPosition })} />)
250
+ expect(screen.getByRole('button', { name: /collapse/i })).toBeInTheDocument()
251
+ })
252
+
253
+ it('shows expand button for a collapsed parent task', () => {
254
+ const tasks = [
255
+ makeTask(makeItem({ id: 'p', title: 'Parent', status: 'in_progress' }), { hasChildren: true }),
256
+ ]
257
+ const getBarPosition = jest.fn().mockReturnValue({ left: 0, width: 200, top: 0, height: 20 })
258
+ render(
259
+ <GanttTimelineView
260
+ {...defaultProps({ tasks, collapsed: new Set(['p']), getBarPosition })}
261
+ />
262
+ )
263
+ expect(screen.getByRole('button', { name: 'Expand' })).toBeInTheDocument()
264
+ })
265
+
266
+ it('calls toggleCollapse when the collapse button is clicked', () => {
267
+ const toggleCollapse = jest.fn()
268
+ const tasks = [
269
+ makeTask(makeItem({ id: 'p', title: 'Parent', status: 'in_progress' }), { hasChildren: true }),
270
+ ]
271
+ const getBarPosition = jest.fn().mockReturnValue({ left: 0, width: 200, top: 0, height: 20 })
272
+ render(<GanttTimelineView {...defaultProps({ tasks, toggleCollapse, getBarPosition })} />)
273
+ fireEvent.click(screen.getByRole('button', { name: 'Collapse' }))
274
+ expect(toggleCollapse).toHaveBeenCalledWith('p')
275
+ })
276
+
277
+ it('renders category badge when showCategory is true and item has a category', () => {
278
+ const tasks = [
279
+ makeTask(makeItem({ id: 'a', title: 'Task A', status: 'in_progress', category: 'product' })),
280
+ ]
281
+ const getBarPosition = jest.fn().mockReturnValue({ left: 0, width: 200, top: 0, height: 20 })
282
+ render(<GanttTimelineView {...defaultProps({ tasks, showCategory: true, getBarPosition })} />)
283
+ expect(screen.getByText('product')).toBeInTheDocument()
284
+ })
285
+
286
+ it('does not render category badge when showCategory is false', () => {
287
+ const tasks = [
288
+ makeTask(makeItem({ id: 'a', title: 'Task A', status: 'in_progress', category: 'product' })),
289
+ ]
290
+ const getBarPosition = jest.fn().mockReturnValue({ left: 0, width: 200, top: 0, height: 20 })
291
+ render(<GanttTimelineView {...defaultProps({ tasks, showCategory: false, getBarPosition })} />)
292
+ expect(screen.queryByText('product')).not.toBeInTheDocument()
293
+ })
294
+
295
+ it('renders status badge when showStatus is true', () => {
296
+ const tasks = [
297
+ makeTask(makeItem({ id: 'a', title: 'Task A', status: 'in_progress' })),
298
+ ]
299
+ const getBarPosition = jest.fn().mockReturnValue({ left: 0, width: 200, top: 0, height: 20 })
300
+ render(<GanttTimelineView {...defaultProps({ tasks, showStatus: true, getBarPosition })} />)
301
+ expect(screen.getByText('in progress')).toBeInTheDocument()
302
+ })
303
+
304
+ it('does not render status badge when showStatus is false', () => {
305
+ const tasks = [
306
+ makeTask(makeItem({ id: 'a', title: 'Task A', status: 'in_progress' })),
307
+ ]
308
+ const getBarPosition = jest.fn().mockReturnValue({ left: 0, width: 200, top: 0, height: 20 })
309
+ render(<GanttTimelineView {...defaultProps({ tasks, showStatus: false, getBarPosition })} />)
310
+ expect(screen.queryByText('in progress')).not.toBeInTheDocument()
311
+ })
312
+
313
+ it('applies gantt-row-focused class to the focused row', () => {
314
+ const tasks = [
315
+ makeTask(makeItem({ id: 'a', title: 'Alpha', status: 'not_started' })),
316
+ makeTask(makeItem({ id: 'b', title: 'Beta', status: 'not_started' })),
317
+ ]
318
+ const getBarPosition = jest.fn().mockReturnValue({ left: 0, width: 200, top: 0, height: 20 })
319
+ const { container } = render(
320
+ <GanttTimelineView {...defaultProps({ tasks, focusedRowIndex: 0, getBarPosition })} />
321
+ )
322
+ const focusedRows = container.querySelectorAll('.gantt-row-focused')
323
+ expect(focusedRows).toHaveLength(1)
324
+ })
325
+ })
326
+
327
+ // ---------------------------------------------------------------------------
328
+ // Legend
329
+ // ---------------------------------------------------------------------------
330
+
331
+ describe('GanttTimelineView — legend', () => {
332
+ it('renders the On Track legend entry', () => {
333
+ render(<GanttTimelineView {...defaultProps()} />)
334
+ expect(screen.getByText('On Track')).toBeInTheDocument()
335
+ })
336
+
337
+ it('renders the At Risk legend entry', () => {
338
+ render(<GanttTimelineView {...defaultProps()} />)
339
+ expect(screen.getByText('At Risk')).toBeInTheDocument()
340
+ })
341
+
342
+ it('renders the Blocked legend entry', () => {
343
+ render(<GanttTimelineView {...defaultProps()} />)
344
+ expect(screen.getByText('Blocked')).toBeInTheDocument()
345
+ })
346
+
347
+ it('renders the Not Started legend entry', () => {
348
+ render(<GanttTimelineView {...defaultProps()} />)
349
+ expect(screen.getByText('Not Started')).toBeInTheDocument()
350
+ })
351
+ })
352
+
353
+ // ---------------------------------------------------------------------------
354
+ // Dependency paths
355
+ // ---------------------------------------------------------------------------
356
+
357
+ describe('GanttTimelineView — dependency paths', () => {
358
+ it('does not render an SVG overlay when dependencyPaths is empty', () => {
359
+ const { container } = render(<GanttTimelineView {...defaultProps({ dependencyPaths: [] })} />)
360
+ expect(container.querySelector('.gantt-deps-svg')).not.toBeInTheDocument()
361
+ })
362
+
363
+ it('renders an SVG overlay when dependencyPaths has entries', () => {
364
+ const tasks = [
365
+ makeTask(makeItem({ id: 'a', title: 'A', status: 'in_progress' })),
366
+ makeTask(makeItem({ id: 'b', title: 'B', status: 'not_started' })),
367
+ ]
368
+ const getBarPosition = jest.fn().mockReturnValue({ left: 0, width: 100, top: 0, height: 20 })
369
+ const paths = [{ path: 'M 100 10 C 150 10, 150 46, 200 46', fromId: 'a', toId: 'b' }]
370
+ const { container } = render(
371
+ <GanttTimelineView {...defaultProps({ tasks, dependencyPaths: paths, getBarPosition })} />
372
+ )
373
+ expect(container.querySelector('.gantt-deps-svg')).toBeInTheDocument()
374
+ })
375
+ })