@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,785 @@
1
+ import { render, screen, fireEvent } from '@testing-library/react'
2
+ import { MetricCard } from '../MetricCard'
3
+ import { PipelineFunnel } from '../PipelineFunnel'
4
+ import { TopCampaigns } from '../TopCampaigns'
5
+ import { SparklineTrend } from '../SparklineTrend'
6
+ import { PeriodSelector } from '../PeriodSelector'
7
+ import { DashboardGrid } from '../DashboardGrid'
8
+ import { DashboardSection } from '../DashboardSection'
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Shared test helpers
12
+ // ---------------------------------------------------------------------------
13
+
14
+ function MockIcon({ className }: { className?: string }) {
15
+ return <svg data-testid="mock-icon" className={className} />
16
+ }
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // MetricCard
20
+ // ---------------------------------------------------------------------------
21
+
22
+ describe('MetricCard', () => {
23
+ const baseProps = {
24
+ label: 'Total Leads',
25
+ value: 1234,
26
+ icon: MockIcon,
27
+ }
28
+
29
+ it('renders the label and value', () => {
30
+ render(<MetricCard {...baseProps} />)
31
+ expect(screen.getByText('Total Leads')).toBeInTheDocument()
32
+ expect(screen.getByText('1234')).toBeInTheDocument()
33
+ })
34
+
35
+ it('renders string values', () => {
36
+ render(<MetricCard {...baseProps} value="$9,999" />)
37
+ expect(screen.getByText('$9,999')).toBeInTheDocument()
38
+ })
39
+
40
+ it('renders the icon', () => {
41
+ render(<MetricCard {...baseProps} />)
42
+ expect(screen.getByTestId('mock-icon')).toBeInTheDocument()
43
+ })
44
+
45
+ it('renders "—" while loading', () => {
46
+ render(<MetricCard {...baseProps} isLoading />)
47
+ expect(screen.getByText('—')).toBeInTheDocument()
48
+ expect(screen.queryByText('1234')).not.toBeInTheDocument()
49
+ })
50
+
51
+ it('renders "Error" in error state', () => {
52
+ render(<MetricCard {...baseProps} error />)
53
+ expect(screen.getByText('Error')).toBeInTheDocument()
54
+ })
55
+
56
+ it('applies animate-pulse class when loading', () => {
57
+ const { container } = render(<MetricCard {...baseProps} isLoading />)
58
+ expect(container.firstChild).toHaveClass('animate-pulse')
59
+ })
60
+
61
+ it('applies error border classes when error is true', () => {
62
+ const { container } = render(<MetricCard {...baseProps} error />)
63
+ // The inner content div gets the error classes
64
+ const cardDiv = container.querySelector('.border-red-200')
65
+ expect(cardDiv).toBeInTheDocument()
66
+ })
67
+
68
+ describe('trend indicator', () => {
69
+ it('renders positive trend value with + prefix', () => {
70
+ render(<MetricCard {...baseProps} trend={{ value: 12, isPositive: true }} />)
71
+ expect(screen.getByText('+12%')).toBeInTheDocument()
72
+ })
73
+
74
+ it('renders negative trend value without + prefix', () => {
75
+ render(<MetricCard {...baseProps} trend={{ value: -5, isPositive: false }} />)
76
+ expect(screen.getByText('-5%')).toBeInTheDocument()
77
+ })
78
+
79
+ it('renders neutral trend when isPositive is undefined', () => {
80
+ render(<MetricCard {...baseProps} trend={{ value: 0 }} />)
81
+ expect(screen.getByText('0%')).toBeInTheDocument()
82
+ })
83
+
84
+ it('renders positive trend in green', () => {
85
+ render(<MetricCard {...baseProps} trend={{ value: 8, isPositive: true }} />)
86
+ const trendEl = screen.getByText('+8%').closest('div')
87
+ expect(trendEl).toHaveClass('text-green-600')
88
+ })
89
+
90
+ it('renders negative trend in red', () => {
91
+ render(<MetricCard {...baseProps} trend={{ value: -3, isPositive: false }} />)
92
+ const trendEl = screen.getByText('-3%').closest('div')
93
+ expect(trendEl).toHaveClass('text-red-600')
94
+ })
95
+
96
+ it('renders neutral trend in gray', () => {
97
+ render(<MetricCard {...baseProps} trend={{ value: 0 }} />)
98
+ const trendEl = screen.getByText('0%').closest('div')
99
+ expect(trendEl).toHaveClass('text-gray-500')
100
+ })
101
+
102
+ it('renders the trend label', () => {
103
+ render(
104
+ <MetricCard
105
+ {...baseProps}
106
+ trend={{ value: 5, isPositive: true, label: 'vs last month' }}
107
+ />
108
+ )
109
+ expect(screen.getByText('vs last month')).toBeInTheDocument()
110
+ })
111
+
112
+ it('hides trend while loading', () => {
113
+ render(
114
+ <MetricCard {...baseProps} trend={{ value: 5, isPositive: true }} isLoading />
115
+ )
116
+ expect(screen.queryByText('+5%')).not.toBeInTheDocument()
117
+ })
118
+
119
+ it('hides trend in error state', () => {
120
+ render(
121
+ <MetricCard {...baseProps} trend={{ value: 5, isPositive: true }} error />
122
+ )
123
+ expect(screen.queryByText('+5%')).not.toBeInTheDocument()
124
+ })
125
+ })
126
+
127
+ describe('sparkline', () => {
128
+ it('renders sparkline when sparklineData has 2+ points', () => {
129
+ const { container } = render(
130
+ <MetricCard {...baseProps} sparklineData={[10, 20, 15, 30]} />
131
+ )
132
+ expect(container.querySelector('svg')).toBeInTheDocument()
133
+ })
134
+
135
+ it('does not render sparkline for a single data point', () => {
136
+ const { container } = render(
137
+ <MetricCard {...baseProps} sparklineData={[10]} />
138
+ )
139
+ // The only svg present might be the icon; sparkline itself returns null for < 2 points
140
+ const svgs = container.querySelectorAll('svg')
141
+ // icon is an svg; sparkline should not add a second one
142
+ expect(svgs.length).toBe(1)
143
+ })
144
+
145
+ it('does not render sparkline while loading', () => {
146
+ const { container } = render(
147
+ <MetricCard {...baseProps} sparklineData={[10, 20, 30]} isLoading />
148
+ )
149
+ // Only the mock icon svg should exist (no sparkline)
150
+ const svgs = container.querySelectorAll('svg')
151
+ expect(svgs.length).toBe(1)
152
+ })
153
+ })
154
+
155
+ describe('href link', () => {
156
+ it('wraps content in an anchor tag when href is provided', () => {
157
+ render(<MetricCard {...baseProps} href="/leads" />)
158
+ const link = screen.getByRole('link')
159
+ expect(link).toHaveAttribute('href', '/leads')
160
+ })
161
+
162
+ it('sets accessible label on the anchor', () => {
163
+ render(<MetricCard {...baseProps} href="/leads" label="Total Leads" />)
164
+ expect(screen.getByRole('link', { name: 'View Total Leads details' })).toBeInTheDocument()
165
+ })
166
+
167
+ it('does not render anchor when href is omitted', () => {
168
+ render(<MetricCard {...baseProps} />)
169
+ expect(screen.queryByRole('link')).not.toBeInTheDocument()
170
+ })
171
+ })
172
+
173
+ it('applies custom className', () => {
174
+ const { container } = render(<MetricCard {...baseProps} className="custom-class" />)
175
+ const cardDiv = container.querySelector('.custom-class')
176
+ expect(cardDiv).toBeInTheDocument()
177
+ })
178
+ })
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // SparklineTrend
182
+ // ---------------------------------------------------------------------------
183
+
184
+ describe('SparklineTrend', () => {
185
+ it('renders an svg with the correct role and aria-label for increasing trend', () => {
186
+ render(<SparklineTrend data={[10, 20, 30]} />)
187
+ const svg = screen.getByRole('img')
188
+ expect(svg).toHaveAttribute('aria-label', 'Trend chart showing increasing pattern')
189
+ })
190
+
191
+ it('reports decreasing trend in aria-label', () => {
192
+ render(<SparklineTrend data={[30, 20, 10]} />)
193
+ expect(screen.getByRole('img')).toHaveAttribute(
194
+ 'aria-label',
195
+ 'Trend chart showing decreasing pattern'
196
+ )
197
+ })
198
+
199
+ it('reports stable trend in aria-label', () => {
200
+ render(<SparklineTrend data={[15, 15, 15]} />)
201
+ expect(screen.getByRole('img')).toHaveAttribute(
202
+ 'aria-label',
203
+ 'Trend chart showing stable pattern'
204
+ )
205
+ })
206
+
207
+ it('returns null for a single data point', () => {
208
+ const { container } = render(<SparklineTrend data={[42]} />)
209
+ expect(container.firstChild).toBeNull()
210
+ })
211
+
212
+ it('returns null for an empty array', () => {
213
+ const { container } = render(<SparklineTrend data={[]} />)
214
+ expect(container.firstChild).toBeNull()
215
+ })
216
+
217
+ it('renders both the line path and the area fill path', () => {
218
+ const { container } = render(<SparklineTrend data={[5, 10, 7, 14]} />)
219
+ const paths = container.querySelectorAll('path')
220
+ // area fill + line stroke = 2 paths
221
+ expect(paths.length).toBe(2)
222
+ })
223
+
224
+ it('accepts a hex color directly', () => {
225
+ const { container } = render(<SparklineTrend data={[1, 2, 3]} color="#FF0000" />)
226
+ const linePath = container.querySelectorAll('path')[1]
227
+ expect(linePath).toHaveAttribute('stroke', '#FF0000')
228
+ })
229
+
230
+ it('resolves a Tailwind color class to hex', () => {
231
+ const { container } = render(<SparklineTrend data={[1, 2, 3]} color="text-green-500" />)
232
+ const linePath = container.querySelectorAll('path')[1]
233
+ expect(linePath).toHaveAttribute('stroke', '#10B981')
234
+ })
235
+
236
+ it('falls back to blue-500 for unknown color keys', () => {
237
+ const { container } = render(<SparklineTrend data={[1, 2, 3]} color="text-unknown-999" />)
238
+ const linePath = container.querySelectorAll('path')[1]
239
+ expect(linePath).toHaveAttribute('stroke', '#3B82F6')
240
+ })
241
+
242
+ it('applies custom height', () => {
243
+ const { container } = render(<SparklineTrend data={[1, 2, 3]} height={60} />)
244
+ const svg = container.querySelector('svg')
245
+ expect(svg).toHaveAttribute('height', '60')
246
+ })
247
+
248
+ it('applies custom className', () => {
249
+ const { container } = render(
250
+ <SparklineTrend data={[1, 2, 3]} className="my-sparkline" />
251
+ )
252
+ expect(container.querySelector('svg')).toHaveClass('my-sparkline')
253
+ })
254
+ })
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // PeriodSelector
258
+ // ---------------------------------------------------------------------------
259
+
260
+ describe('PeriodSelector', () => {
261
+ const onChange = jest.fn()
262
+
263
+ beforeEach(() => jest.clearAllMocks())
264
+
265
+ it('renders default period buttons', () => {
266
+ render(<PeriodSelector selected="month" onChange={onChange} />)
267
+ expect(screen.getByText('Week')).toBeInTheDocument()
268
+ expect(screen.getByText('Month')).toBeInTheDocument()
269
+ expect(screen.getByText('Quarter')).toBeInTheDocument()
270
+ expect(screen.getByText('Year')).toBeInTheDocument()
271
+ })
272
+
273
+ it('marks the selected period button as pressed', () => {
274
+ render(<PeriodSelector selected="quarter" onChange={onChange} />)
275
+ expect(screen.getByText('Quarter')).toHaveAttribute('aria-pressed', 'true')
276
+ expect(screen.getByText('Week')).toHaveAttribute('aria-pressed', 'false')
277
+ expect(screen.getByText('Month')).toHaveAttribute('aria-pressed', 'false')
278
+ expect(screen.getByText('Year')).toHaveAttribute('aria-pressed', 'false')
279
+ })
280
+
281
+ it('calls onChange with the correct value on button click', () => {
282
+ render(<PeriodSelector selected="month" onChange={onChange} />)
283
+ fireEvent.click(screen.getByText('Year'))
284
+ expect(onChange).toHaveBeenCalledWith('year')
285
+ expect(onChange).toHaveBeenCalledTimes(1)
286
+ })
287
+
288
+ it('calls onChange when the already-selected period is clicked', () => {
289
+ render(<PeriodSelector selected="month" onChange={onChange} />)
290
+ fireEvent.click(screen.getByText('Month'))
291
+ expect(onChange).toHaveBeenCalledWith('month')
292
+ })
293
+
294
+ it('renders custom periods', () => {
295
+ const custom = [
296
+ { value: 'day', label: 'Day' },
297
+ { value: 'week', label: 'Week' },
298
+ ]
299
+ render(<PeriodSelector periods={custom} selected="day" onChange={onChange} />)
300
+ expect(screen.getByText('Day')).toBeInTheDocument()
301
+ expect(screen.getByText('Week')).toBeInTheDocument()
302
+ expect(screen.queryByText('Month')).not.toBeInTheDocument()
303
+ })
304
+
305
+ it('has role="group" with accessible label', () => {
306
+ const { container } = render(<PeriodSelector selected="month" onChange={onChange} />)
307
+ const group = container.querySelector('[role="group"]')
308
+ expect(group).toBeInTheDocument()
309
+ expect(group).toHaveAttribute('aria-label', 'Time period')
310
+ })
311
+
312
+ it('applies custom className to the wrapper', () => {
313
+ const { container } = render(
314
+ <PeriodSelector selected="week" onChange={onChange} className="my-selector" />
315
+ )
316
+ expect(container.firstChild).toHaveClass('my-selector')
317
+ })
318
+
319
+ it('applies active style class only to selected button', () => {
320
+ render(<PeriodSelector selected="week" onChange={onChange} />)
321
+ const weekBtn = screen.getByText('Week')
322
+ const monthBtn = screen.getByText('Month')
323
+ expect(weekBtn).toHaveClass('bg-primary-100')
324
+ expect(monthBtn).not.toHaveClass('bg-primary-100')
325
+ })
326
+ })
327
+
328
+ // ---------------------------------------------------------------------------
329
+ // PipelineFunnel
330
+ // ---------------------------------------------------------------------------
331
+
332
+ describe('PipelineFunnel', () => {
333
+ const stages = [
334
+ { stage: 'Lead', count: 200 },
335
+ { stage: 'Qualified', count: 100 },
336
+ { stage: 'Proposal', count: 50 },
337
+ { stage: 'Closed', count: 20 },
338
+ ]
339
+
340
+ it('renders the default title', () => {
341
+ render(<PipelineFunnel stages={stages} />)
342
+ expect(screen.getByText('Pipeline Funnel')).toBeInTheDocument()
343
+ })
344
+
345
+ it('renders a custom title', () => {
346
+ render(<PipelineFunnel stages={stages} title="Sales Funnel" />)
347
+ expect(screen.getByText('Sales Funnel')).toBeInTheDocument()
348
+ })
349
+
350
+ it('renders all stage names', () => {
351
+ render(<PipelineFunnel stages={stages} />)
352
+ expect(screen.getByText('Lead')).toBeInTheDocument()
353
+ expect(screen.getByText('Qualified')).toBeInTheDocument()
354
+ expect(screen.getByText('Proposal')).toBeInTheDocument()
355
+ expect(screen.getByText('Closed')).toBeInTheDocument()
356
+ })
357
+
358
+ it('renders stage counts with locale formatting', () => {
359
+ const bigStages = [
360
+ { stage: 'Top', count: 1000 },
361
+ { stage: 'Mid', count: 500 },
362
+ ]
363
+ render(<PipelineFunnel stages={bigStages} />)
364
+ expect(screen.getByText('1,000')).toBeInTheDocument()
365
+ expect(screen.getByText('500')).toBeInTheDocument()
366
+ })
367
+
368
+ it('shows "% of total" relative to the first stage', () => {
369
+ render(<PipelineFunnel stages={stages} />)
370
+ // First stage = 200, second = 100 → 50%, third = 50 → 25%, last = 20 → 10%
371
+ expect(screen.getByText('100% of total')).toBeInTheDocument()
372
+ expect(screen.getByText('50% of total')).toBeInTheDocument()
373
+ expect(screen.getByText('25% of total')).toBeInTheDocument()
374
+ expect(screen.getByText('10% of total')).toBeInTheDocument()
375
+ })
376
+
377
+ it('renders the overall conversion footer when there are 2+ stages', () => {
378
+ render(<PipelineFunnel stages={stages} />)
379
+ expect(screen.getByText('Overall Conversion')).toBeInTheDocument()
380
+ // The footer span contains "10%" directly followed by the route span "(Lead → Closed)"
381
+ // Use getAllByText and check one matches the bold footer element specifically
382
+ const tenPctMatches = screen.getAllByText(/10%/)
383
+ // At least one match must be the bold footer span (not the "% of total" span)
384
+ const footerMatch = tenPctMatches.find(
385
+ el => !el.textContent?.includes('of total')
386
+ )
387
+ expect(footerMatch).toBeTruthy()
388
+ expect(screen.getByText(/Lead → Closed/)).toBeInTheDocument()
389
+ })
390
+
391
+ it('does not render overall conversion footer with a single stage', () => {
392
+ render(<PipelineFunnel stages={[{ stage: 'Only', count: 50 }]} />)
393
+ expect(screen.queryByText('Overall Conversion')).not.toBeInTheDocument()
394
+ })
395
+
396
+ it('renders empty state when stages is an empty array', () => {
397
+ render(<PipelineFunnel stages={[]} />)
398
+ expect(screen.getByText('No pipeline data available')).toBeInTheDocument()
399
+ })
400
+
401
+ it('renders empty state title even in empty state', () => {
402
+ render(<PipelineFunnel stages={[]} title="My Funnel" />)
403
+ expect(screen.getByText('My Funnel')).toBeInTheDocument()
404
+ })
405
+
406
+ it('renders stage list with role="list"', () => {
407
+ render(<PipelineFunnel stages={stages} />)
408
+ expect(screen.getByRole('list', { name: 'Pipeline stages' })).toBeInTheDocument()
409
+ })
410
+
411
+ it('renders each stage as a listitem', () => {
412
+ render(<PipelineFunnel stages={stages} />)
413
+ expect(screen.getAllByRole('listitem')).toHaveLength(stages.length)
414
+ })
415
+
416
+ it('renders bar buttons with accessible labels', () => {
417
+ render(<PipelineFunnel stages={stages} />)
418
+ const leadBtn = screen.getByRole('button', {
419
+ name: 'Lead: 200 prospects, 100% of total',
420
+ })
421
+ expect(leadBtn).toBeInTheDocument()
422
+ })
423
+
424
+ it('calls onStageClick with stage name when a bar is clicked', () => {
425
+ const onStageClick = jest.fn()
426
+ render(<PipelineFunnel stages={stages} onStageClick={onStageClick} />)
427
+ // Click the listitem div wrapping the 'Qualified' stage
428
+ const listitems = screen.getAllByRole('listitem')
429
+ fireEvent.click(listitems[1])
430
+ expect(onStageClick).toHaveBeenCalledWith('Qualified')
431
+ expect(onStageClick).toHaveBeenCalledTimes(1)
432
+ })
433
+
434
+ it('disables bar buttons when onStageClick is not provided', () => {
435
+ render(<PipelineFunnel stages={stages} />)
436
+ const buttons = screen.getAllByRole('button')
437
+ buttons.forEach(btn => expect(btn).toBeDisabled())
438
+ })
439
+
440
+ it('enables bar buttons when onStageClick is provided', () => {
441
+ render(<PipelineFunnel stages={stages} onStageClick={jest.fn()} />)
442
+ const buttons = screen.getAllByRole('button')
443
+ buttons.forEach(btn => expect(btn).not.toBeDisabled())
444
+ })
445
+
446
+ it('handles stages with zero counts without crashing', () => {
447
+ const zeroStages = [
448
+ { stage: 'Lead', count: 0 },
449
+ { stage: 'Closed', count: 0 },
450
+ ]
451
+ render(<PipelineFunnel stages={zeroStages} />)
452
+ expect(screen.getByText('Lead')).toBeInTheDocument()
453
+ expect(screen.getByText('Overall Conversion')).toBeInTheDocument()
454
+ })
455
+ })
456
+
457
+ // ---------------------------------------------------------------------------
458
+ // TopCampaigns
459
+ // ---------------------------------------------------------------------------
460
+
461
+ describe('TopCampaigns', () => {
462
+ const campaigns = [
463
+ { id: '1', name: 'Spring Launch', engagementRate: 80, status: 'active' as const },
464
+ { id: '2', name: 'Summer Sale', engagementRate: 55, status: 'paused' as const },
465
+ { id: '3', name: 'Fall Promo', engagementRate: 30, status: 'completed' as const },
466
+ { id: '4', name: 'Winter Deal', engagementRate: 15 },
467
+ { id: '5', name: 'Holiday Push', engagementRate: 70, status: 'active' as const },
468
+ { id: '6', name: 'Extra Campaign', engagementRate: 40, status: 'active' as const },
469
+ ]
470
+
471
+ it('renders the default title', () => {
472
+ render(<TopCampaigns campaigns={campaigns} />)
473
+ expect(screen.getByText('Top Performing Campaigns')).toBeInTheDocument()
474
+ })
475
+
476
+ it('renders a custom title', () => {
477
+ render(<TopCampaigns campaigns={campaigns} title="Best Campaigns" />)
478
+ expect(screen.getByText('Best Campaigns')).toBeInTheDocument()
479
+ })
480
+
481
+ it('shows empty state when campaigns array is empty', () => {
482
+ render(<TopCampaigns campaigns={[]} />)
483
+ expect(screen.getByText('No campaigns yet')).toBeInTheDocument()
484
+ expect(
485
+ screen.getByText('Launch your first campaign to see performance metrics')
486
+ ).toBeInTheDocument()
487
+ })
488
+
489
+ it('renders empty state title in empty state', () => {
490
+ render(<TopCampaigns campaigns={[]} title="My Campaigns" />)
491
+ expect(screen.getByText('My Campaigns')).toBeInTheDocument()
492
+ })
493
+
494
+ it('limits display to maxItems (default 5)', () => {
495
+ render(<TopCampaigns campaigns={campaigns} />)
496
+ // 6 campaigns but only 5 should appear
497
+ expect(screen.queryByText('Extra Campaign')).not.toBeInTheDocument()
498
+ expect(screen.getByText('Spring Launch')).toBeInTheDocument()
499
+ expect(screen.getByText('Holiday Push')).toBeInTheDocument()
500
+ })
501
+
502
+ it('respects a custom maxItems value', () => {
503
+ render(<TopCampaigns campaigns={campaigns} maxItems={2} />)
504
+ expect(screen.getByText('Spring Launch')).toBeInTheDocument()
505
+ expect(screen.getByText('Summer Sale')).toBeInTheDocument()
506
+ expect(screen.queryByText('Fall Promo')).not.toBeInTheDocument()
507
+ })
508
+
509
+ it('renders rank numbers starting at 1', () => {
510
+ render(<TopCampaigns campaigns={campaigns} />)
511
+ expect(screen.getByText('1')).toBeInTheDocument()
512
+ expect(screen.getByText('2')).toBeInTheDocument()
513
+ expect(screen.getByText('3')).toBeInTheDocument()
514
+ })
515
+
516
+ it('renders engagement rates as percentages', () => {
517
+ render(<TopCampaigns campaigns={campaigns} />)
518
+ expect(screen.getByText('80%')).toBeInTheDocument()
519
+ expect(screen.getByText('55%')).toBeInTheDocument()
520
+ })
521
+
522
+ it('renders progress bars with correct aria attributes', () => {
523
+ render(<TopCampaigns campaigns={campaigns} />)
524
+ const progressBars = screen.getAllByRole('progressbar')
525
+ expect(progressBars[0]).toHaveAttribute('aria-valuenow', '80')
526
+ expect(progressBars[0]).toHaveAttribute('aria-valuemin', '0')
527
+ expect(progressBars[0]).toHaveAttribute('aria-valuemax', '100')
528
+ expect(progressBars[0]).toHaveAttribute(
529
+ 'aria-label',
530
+ 'Spring Launch engagement rate: 80%'
531
+ )
532
+ })
533
+
534
+ it('renders campaign status badges', () => {
535
+ render(<TopCampaigns campaigns={campaigns} />)
536
+ // Two campaigns have 'active' status within the first 5 (Spring Launch + Holiday Push)
537
+ const activebadges = screen.getAllByText('active')
538
+ expect(activebadges.length).toBeGreaterThanOrEqual(1)
539
+ expect(screen.getByText('paused')).toBeInTheDocument()
540
+ expect(screen.getByText('completed')).toBeInTheDocument()
541
+ })
542
+
543
+ it('does not render a status badge when status is omitted', () => {
544
+ const noBadge = [{ id: '1', name: 'No Status', engagementRate: 50 }]
545
+ render(<TopCampaigns campaigns={noBadge} />)
546
+ // Only one listitem, no status span expected
547
+ expect(screen.queryByText('active')).not.toBeInTheDocument()
548
+ expect(screen.queryByText('paused')).not.toBeInTheDocument()
549
+ })
550
+
551
+ it('renders the list with role and aria-label', () => {
552
+ render(<TopCampaigns campaigns={campaigns} />)
553
+ expect(
554
+ screen.getByRole('list', { name: 'Top campaigns by engagement' })
555
+ ).toBeInTheDocument()
556
+ })
557
+
558
+ it('renders each campaign as a listitem', () => {
559
+ render(<TopCampaigns campaigns={campaigns} />)
560
+ // maxItems defaults to 5
561
+ expect(screen.getAllByRole('listitem')).toHaveLength(5)
562
+ })
563
+
564
+ it('shows average engagement footer', () => {
565
+ const simple = [
566
+ { id: '1', name: 'A', engagementRate: 40 },
567
+ { id: '2', name: 'B', engagementRate: 60 },
568
+ ]
569
+ render(<TopCampaigns campaigns={simple} />)
570
+ expect(screen.getByText('Average Engagement')).toBeInTheDocument()
571
+ expect(screen.getByText('50%')).toBeInTheDocument()
572
+ })
573
+
574
+ describe('View all button', () => {
575
+ it('shows "View all" when onViewAll is provided AND campaigns exceed maxItems', () => {
576
+ render(
577
+ <TopCampaigns campaigns={campaigns} maxItems={5} onViewAll={jest.fn()} />
578
+ )
579
+ expect(screen.getByText('View all')).toBeInTheDocument()
580
+ })
581
+
582
+ it('does not show "View all" when campaigns do not exceed maxItems', () => {
583
+ render(
584
+ <TopCampaigns campaigns={campaigns.slice(0, 3)} maxItems={5} onViewAll={jest.fn()} />
585
+ )
586
+ expect(screen.queryByText('View all')).not.toBeInTheDocument()
587
+ })
588
+
589
+ it('does not show "View all" when onViewAll is not provided', () => {
590
+ render(<TopCampaigns campaigns={campaigns} maxItems={5} />)
591
+ expect(screen.queryByText('View all')).not.toBeInTheDocument()
592
+ })
593
+
594
+ it('calls onViewAll when "View all" button is clicked', () => {
595
+ const onViewAll = jest.fn()
596
+ render(<TopCampaigns campaigns={campaigns} maxItems={5} onViewAll={onViewAll} />)
597
+ fireEvent.click(screen.getByText('View all'))
598
+ expect(onViewAll).toHaveBeenCalledTimes(1)
599
+ })
600
+ })
601
+
602
+ describe('progress bar color thresholds', () => {
603
+ it('applies success color for engagement >= 75', () => {
604
+ const { container } = render(
605
+ <TopCampaigns campaigns={[{ id: '1', name: 'High', engagementRate: 80 }]} />
606
+ )
607
+ const bar = container.querySelector('[role="progressbar"]')
608
+ expect(bar).toHaveClass('bg-success-500')
609
+ })
610
+
611
+ it('applies primary color for engagement >= 50 and < 75', () => {
612
+ const { container } = render(
613
+ <TopCampaigns campaigns={[{ id: '1', name: 'Mid', engagementRate: 60 }]} />
614
+ )
615
+ const bar = container.querySelector('[role="progressbar"]')
616
+ expect(bar).toHaveClass('bg-primary-500')
617
+ })
618
+
619
+ it('applies warning color for engagement >= 25 and < 50', () => {
620
+ const { container } = render(
621
+ <TopCampaigns campaigns={[{ id: '1', name: 'Low', engagementRate: 30 }]} />
622
+ )
623
+ const bar = container.querySelector('[role="progressbar"]')
624
+ expect(bar).toHaveClass('bg-warning-500')
625
+ })
626
+
627
+ it('applies gray color for engagement < 25', () => {
628
+ const { container } = render(
629
+ <TopCampaigns campaigns={[{ id: '1', name: 'VeryLow', engagementRate: 10 }]} />
630
+ )
631
+ const bar = container.querySelector('[role="progressbar"]')
632
+ expect(bar).toHaveClass('bg-gray-400')
633
+ })
634
+ })
635
+ })
636
+
637
+ // ---------------------------------------------------------------------------
638
+ // DashboardGrid
639
+ // ---------------------------------------------------------------------------
640
+
641
+ describe('DashboardGrid', () => {
642
+ it('renders children', () => {
643
+ render(
644
+ <DashboardGrid>
645
+ <div data-testid="child-a">A</div>
646
+ <div data-testid="child-b">B</div>
647
+ </DashboardGrid>
648
+ )
649
+ expect(screen.getByTestId('child-a')).toBeInTheDocument()
650
+ expect(screen.getByTestId('child-b')).toBeInTheDocument()
651
+ })
652
+
653
+ it('defaults to 2 columns and md gap', () => {
654
+ const { container } = render(<DashboardGrid><div /></DashboardGrid>)
655
+ const grid = container.firstChild as HTMLElement
656
+ expect(grid).toHaveClass('lg:grid-cols-2')
657
+ expect(grid).toHaveClass('gap-6')
658
+ })
659
+
660
+ it('applies lg:grid-cols-1 when columns=1', () => {
661
+ const { container } = render(<DashboardGrid columns={1}><div /></DashboardGrid>)
662
+ expect(container.firstChild).toHaveClass('lg:grid-cols-1')
663
+ })
664
+
665
+ it('applies lg:grid-cols-3 when columns=3', () => {
666
+ const { container } = render(<DashboardGrid columns={3}><div /></DashboardGrid>)
667
+ expect(container.firstChild).toHaveClass('lg:grid-cols-3')
668
+ })
669
+
670
+ it('applies sm:grid-cols-2 and lg:grid-cols-4 when columns=4', () => {
671
+ const { container } = render(<DashboardGrid columns={4}><div /></DashboardGrid>)
672
+ const grid = container.firstChild as HTMLElement
673
+ expect(grid).toHaveClass('sm:grid-cols-2')
674
+ expect(grid).toHaveClass('lg:grid-cols-4')
675
+ })
676
+
677
+ it('applies gap-4 for gap="sm"', () => {
678
+ const { container } = render(<DashboardGrid gap="sm"><div /></DashboardGrid>)
679
+ expect(container.firstChild).toHaveClass('gap-4')
680
+ })
681
+
682
+ it('applies gap-8 for gap="lg"', () => {
683
+ const { container } = render(<DashboardGrid gap="lg"><div /></DashboardGrid>)
684
+ expect(container.firstChild).toHaveClass('gap-8')
685
+ })
686
+
687
+ it('always has grid-cols-1 as the base class', () => {
688
+ const { container } = render(<DashboardGrid><div /></DashboardGrid>)
689
+ expect(container.firstChild).toHaveClass('grid-cols-1')
690
+ })
691
+
692
+ it('applies a custom className', () => {
693
+ const { container } = render(
694
+ <DashboardGrid className="my-grid"><div /></DashboardGrid>
695
+ )
696
+ expect(container.firstChild).toHaveClass('my-grid')
697
+ })
698
+ })
699
+
700
+ // ---------------------------------------------------------------------------
701
+ // DashboardSection
702
+ // ---------------------------------------------------------------------------
703
+
704
+ describe('DashboardSection', () => {
705
+ it('renders the title', () => {
706
+ render(<DashboardSection title="Overview"><div /></DashboardSection>)
707
+ expect(screen.getByText('Overview')).toBeInTheDocument()
708
+ })
709
+
710
+ it('renders children', () => {
711
+ render(
712
+ <DashboardSection title="Overview">
713
+ <p data-testid="content">Content here</p>
714
+ </DashboardSection>
715
+ )
716
+ expect(screen.getByTestId('content')).toBeInTheDocument()
717
+ })
718
+
719
+ it('renders description when provided', () => {
720
+ render(
721
+ <DashboardSection title="Overview" description="A summary of key metrics">
722
+ <div />
723
+ </DashboardSection>
724
+ )
725
+ expect(screen.getByText('A summary of key metrics')).toBeInTheDocument()
726
+ })
727
+
728
+ it('does not render description when omitted', () => {
729
+ const { container } = render(
730
+ <DashboardSection title="Overview"><div /></DashboardSection>
731
+ )
732
+ expect(container.querySelector('p')).not.toBeInTheDocument()
733
+ })
734
+
735
+ it('renders action node when provided', () => {
736
+ render(
737
+ <DashboardSection
738
+ title="Overview"
739
+ action={<button data-testid="action-btn">Export</button>}
740
+ >
741
+ <div />
742
+ </DashboardSection>
743
+ )
744
+ expect(screen.getByTestId('action-btn')).toBeInTheDocument()
745
+ })
746
+
747
+ it('does not render the action wrapper when action is omitted', () => {
748
+ const { container } = render(
749
+ <DashboardSection title="Overview"><div /></DashboardSection>
750
+ )
751
+ // The action wrapper div has class flex-shrink-0
752
+ expect(container.querySelector('.flex-shrink-0')).not.toBeInTheDocument()
753
+ })
754
+
755
+ it('renders the title as an h2 element', () => {
756
+ render(<DashboardSection title="My Section"><div /></DashboardSection>)
757
+ expect(screen.getByRole('heading', { level: 2, name: 'My Section' })).toBeInTheDocument()
758
+ })
759
+
760
+ it('applies custom className', () => {
761
+ const { container } = render(
762
+ <DashboardSection title="Overview" className="my-section">
763
+ <div />
764
+ </DashboardSection>
765
+ )
766
+ expect(container.firstChild).toHaveClass('my-section')
767
+ })
768
+
769
+ it('still renders correctly with all props provided', () => {
770
+ render(
771
+ <DashboardSection
772
+ title="Full Section"
773
+ description="Detailed description"
774
+ action={<button>Action</button>}
775
+ className="full-class"
776
+ >
777
+ <span data-testid="slot">Slot content</span>
778
+ </DashboardSection>
779
+ )
780
+ expect(screen.getByText('Full Section')).toBeInTheDocument()
781
+ expect(screen.getByText('Detailed description')).toBeInTheDocument()
782
+ expect(screen.getByText('Action')).toBeInTheDocument()
783
+ expect(screen.getByTestId('slot')).toBeInTheDocument()
784
+ })
785
+ })