@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.
Files changed (122) hide show
  1. package/package.json +2 -1
  2. package/src/__mocks__/next/link.js +11 -0
  3. package/src/components/ActivityTimeline.tsx +173 -0
  4. package/src/components/LogActivityDialog.tsx +303 -0
  5. package/src/components/QuickLogButtons.tsx +32 -0
  6. package/src/components/account/__tests__/account.test.tsx +315 -0
  7. package/src/components/badge/StageBadge.tsx +31 -0
  8. package/src/components/badge/index.ts +3 -0
  9. package/src/components/command-palette/CommandGroup.tsx +23 -0
  10. package/src/components/command-palette/CommandPalette.tsx +327 -0
  11. package/src/components/command-palette/CommandResultItem.tsx +59 -0
  12. package/src/components/command-palette/__tests__/CommandGroup.test.tsx +81 -0
  13. package/src/components/command-palette/__tests__/CommandResultItem.test.tsx +166 -0
  14. package/src/components/command-palette/__tests__/command-palette-context.test.tsx +166 -0
  15. package/src/components/command-palette/__tests__/useCommandPaletteSearch.test.ts +271 -0
  16. package/src/components/command-palette/command-palette-context.tsx +51 -0
  17. package/src/components/command-palette/index.ts +9 -0
  18. package/src/components/command-palette/useCommandPaletteSearch.ts +114 -0
  19. package/src/components/compose/__tests__/compose.test.tsx +656 -0
  20. package/src/components/compose/compose-header.tsx +72 -0
  21. package/src/components/compose/compose-loading.tsx +13 -0
  22. package/src/components/compose/index.ts +6 -0
  23. package/src/components/compose/save-status-indicator.tsx +57 -0
  24. package/src/components/compose/send-confirmation-dialog.tsx +87 -0
  25. package/src/components/compose/subject-input.tsx +25 -0
  26. package/src/components/compose/useAutoSave.ts +93 -0
  27. package/src/components/dashboard/DashboardGrid.tsx +32 -0
  28. package/src/components/dashboard/DashboardSection.tsx +32 -0
  29. package/src/components/dashboard/MetricCard.tsx +129 -0
  30. package/src/components/dashboard/PeriodSelector.tsx +55 -0
  31. package/src/components/dashboard/PipelineFunnel.tsx +126 -0
  32. package/src/components/dashboard/SparklineTrend.tsx +102 -0
  33. package/src/components/dashboard/TopCampaigns.tsx +132 -0
  34. package/src/components/dashboard/__tests__/dashboard.test.tsx +785 -0
  35. package/src/components/dashboard/index.ts +20 -0
  36. package/src/components/dialog/ConfirmDialog.tsx +72 -0
  37. package/src/components/dialog/__tests__/ConfirmDialog.test.tsx +126 -0
  38. package/src/components/dialog/index.ts +3 -0
  39. package/src/components/email-dialogs/__tests__/email-dialogs.test.tsx +982 -0
  40. package/src/components/email-dialogs/index.ts +14 -0
  41. package/src/components/email-dialogs/merge-fields.tsx +196 -0
  42. package/src/components/email-dialogs/preview-dialog.tsx +194 -0
  43. package/src/components/email-dialogs/schedule-dialog.tsx +297 -0
  44. package/src/components/email-dialogs/template-picker.tsx +225 -0
  45. package/src/components/email-dialogs/test-send-dialog.tsx +188 -0
  46. package/src/components/email-editor/BlockRenderer.tsx +120 -0
  47. package/src/components/email-editor/__tests__/BlockRenderer.test.tsx +332 -0
  48. package/src/components/email-editor/__tests__/block-renderers.test.ts +624 -0
  49. package/src/components/email-editor/__tests__/email-html-renderer.test.ts +376 -0
  50. package/src/components/email-editor/add-block-menu.tsx +151 -0
  51. package/src/components/email-editor/block-toolbar.tsx +73 -0
  52. package/src/components/email-editor/blocks/__tests__/blocks.test.tsx +818 -0
  53. package/src/components/email-editor/blocks/button-block.tsx +44 -0
  54. package/src/components/email-editor/blocks/divider-block.tsx +43 -0
  55. package/src/components/email-editor/blocks/footer-block.tsx +39 -0
  56. package/src/components/email-editor/blocks/header-block.tsx +39 -0
  57. package/src/components/email-editor/blocks/image-block.tsx +61 -0
  58. package/src/components/email-editor/blocks/index.ts +9 -0
  59. package/src/components/email-editor/blocks/metrics-block.tsx +198 -0
  60. package/src/components/email-editor/blocks/social-block.tsx +75 -0
  61. package/src/components/email-editor/blocks/spacer-block.tsx +26 -0
  62. package/src/components/email-editor/blocks/text-block.tsx +75 -0
  63. package/src/components/email-editor/editor-sidebar.tsx +66 -0
  64. package/src/components/email-editor/email-editor.tsx +497 -0
  65. package/src/components/email-editor/hooks/__tests__/useDragDrop.test.ts +355 -0
  66. package/src/components/email-editor/hooks/__tests__/useEmailEditorState.test.ts +551 -0
  67. package/src/components/email-editor/hooks/useDragDrop.ts +181 -0
  68. package/src/components/email-editor/hooks/useEmailEditorState.ts +426 -0
  69. package/src/components/email-editor/index.ts +51 -0
  70. package/src/components/email-editor/panels/BlockPropertyPanel.tsx +637 -0
  71. package/src/components/email-editor/panels/GlobalStylesPanel.tsx +108 -0
  72. package/src/components/email-editor/panels/SectionSettingsPanel.tsx +80 -0
  73. package/src/components/email-editor/panels/__tests__/BlockPropertyPanel.test.tsx +707 -0
  74. package/src/components/email-editor/panels/__tests__/GlobalStylesPanel.test.tsx +226 -0
  75. package/src/components/email-editor/panels/index.ts +3 -0
  76. package/src/components/email-editor/renderer/block-renderers.ts +209 -0
  77. package/src/components/email-editor/renderer/email-html-renderer.ts +128 -0
  78. package/src/components/email-editor/types.ts +413 -0
  79. package/src/components/email-editor/utils/defaults.ts +116 -0
  80. package/src/components/email-editor/utils/undo-redo.ts +59 -0
  81. package/src/components/enrichment/EnrichButton.tsx +33 -0
  82. package/src/components/enrichment/EnrichmentProgress.tsx +66 -0
  83. package/src/components/enrichment/QualityBadge.tsx +43 -0
  84. package/src/components/enrichment/__tests__/enrichment.test.tsx +184 -0
  85. package/src/components/enrichment/index.ts +8 -0
  86. package/src/components/gantt/GanttBoardView.tsx +71 -0
  87. package/src/components/gantt/GanttChart.tsx +140 -887
  88. package/src/components/gantt/GanttFilterBar.tsx +100 -0
  89. package/src/components/gantt/GanttListView.tsx +63 -0
  90. package/src/components/gantt/GanttTimelineView.tsx +215 -0
  91. package/src/components/gantt/__tests__/GanttBoardView.test.tsx +305 -0
  92. package/src/components/gantt/__tests__/GanttFilterBar.test.tsx +544 -0
  93. package/src/components/gantt/__tests__/GanttListView.test.tsx +337 -0
  94. package/src/components/gantt/__tests__/GanttTimelineView.test.tsx +375 -0
  95. package/src/components/gantt/__tests__/gantt-utils.test.ts +341 -0
  96. package/src/components/gantt/__tests__/useGanttState.test.ts +535 -0
  97. package/src/components/gantt/hooks/useGanttState.ts +644 -0
  98. package/src/components/gantt/index.ts +10 -0
  99. package/src/components/gantt/types.ts +5 -5
  100. package/src/components/index.ts +46 -0
  101. package/src/components/integrations/ConnectionStatus.tsx +77 -0
  102. package/src/components/integrations/IntegrationCard.tsx +92 -0
  103. package/src/components/integrations/__tests__/integrations.test.tsx +191 -0
  104. package/src/components/integrations/index.ts +5 -0
  105. package/src/components/kanban/KanbanBoard.tsx +103 -0
  106. package/src/components/kanban/__tests__/kanban.test.tsx +157 -0
  107. package/src/components/kanban/index.ts +2 -0
  108. package/src/components/lists/CreateListDialog.tsx +158 -0
  109. package/src/components/lists/ListCard.tsx +77 -0
  110. package/src/components/lists/__tests__/lists.test.tsx +263 -0
  111. package/src/components/lists/index.ts +5 -0
  112. package/src/components/loading/__tests__/loading.test.tsx +114 -0
  113. package/src/components/navigation/__tests__/navigation.test.tsx +194 -0
  114. package/src/components/pipeline/StageTransitionModal.tsx +146 -0
  115. package/src/components/pipeline/__tests__/pipeline.test.tsx +169 -0
  116. package/src/components/pipeline/index.ts +2 -0
  117. package/src/components/settings/SettingsCard.tsx +33 -0
  118. package/src/components/settings/SettingsLayout.tsx +28 -0
  119. package/src/components/settings/SettingsNav.tsx +42 -0
  120. package/src/components/settings/__tests__/settings.test.tsx +181 -0
  121. package/src/components/settings/index.ts +6 -0
  122. package/src/components/wizard/__tests__/wizard.test.tsx +97 -0
@@ -76,3 +76,49 @@ export * from './wizard'
76
76
 
77
77
  // Account (profile + password forms)
78
78
  export * from './account'
79
+
80
+ // Compose flow (auto-save, header, status, confirmation dialog)
81
+ export * from './compose'
82
+
83
+ // Email Editor (block-based email builder with drag-drop, undo/redo, HTML renderer)
84
+ export * from './email-editor'
85
+
86
+ // Email dialogs (schedule, test send, preview, template picker, merge fields)
87
+ export * from './email-dialogs'
88
+
89
+ // Dashboard components (MetricCard, PeriodSelector, SparklineTrend, DashboardGrid, DashboardSection)
90
+ export * from './dashboard'
91
+
92
+ // Enrichment components (QualityBadge, EnrichButton, EnrichmentProgress)
93
+ export * from './enrichment'
94
+
95
+ // Integrations (IntegrationCard, ConnectionStatus)
96
+ export * from './integrations'
97
+
98
+ // Command Palette
99
+ export * from './command-palette'
100
+
101
+ // Settings components (SettingsLayout, SettingsNav, SettingsCard)
102
+ export * from './settings'
103
+
104
+ // Kanban board layout
105
+ export * from './kanban'
106
+
107
+ // List components (ListCard, CreateListDialog)
108
+ export * from './lists'
109
+
110
+ // Pipeline components (StageTransitionModal)
111
+ export * from './pipeline'
112
+
113
+ // Activity components (timeline, quick log, log dialog)
114
+ export { ActivityTimeline } from './ActivityTimeline'
115
+ export type { ActivityTimelineProps, ActivityTimelineItem } from './ActivityTimeline'
116
+ export { QuickLogButtons } from './QuickLogButtons'
117
+ export type { QuickLogButtonsProps, QuickLogAction } from './QuickLogButtons'
118
+ export { LogActivityDialog } from './LogActivityDialog'
119
+ export type {
120
+ LogActivityDialogProps,
121
+ LogActivityFormData,
122
+ ActivityTypeOption,
123
+ OutcomeOption,
124
+ } from './LogActivityDialog'
@@ -0,0 +1,77 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { Loader2 } from 'lucide-react'
5
+ import { cn } from '../../lib/utils'
6
+
7
+ export interface ConnectionStatusProps {
8
+ providerName: string
9
+ providerIcon: React.ElementType
10
+ connected: boolean
11
+ accountLabel?: string
12
+ onConnect?: () => void
13
+ onDisconnect?: () => void
14
+ isLoading?: boolean
15
+ className?: string
16
+ }
17
+
18
+ export function ConnectionStatus({
19
+ providerName,
20
+ providerIcon: Icon,
21
+ connected,
22
+ accountLabel,
23
+ onConnect,
24
+ onDisconnect,
25
+ isLoading = false,
26
+ className,
27
+ }: ConnectionStatusProps) {
28
+ return (
29
+ <div
30
+ className={cn(
31
+ 'flex items-center justify-between rounded-lg border p-4',
32
+ className
33
+ )}
34
+ >
35
+ <div className="flex items-center gap-3">
36
+ <div className="flex-shrink-0 w-9 h-9 rounded-lg bg-gray-100 flex items-center justify-center">
37
+ <Icon className="w-5 h-5 text-gray-600" />
38
+ </div>
39
+ <div>
40
+ <p className="text-sm font-medium">{providerName}</p>
41
+ {connected && accountLabel && (
42
+ <p className="text-xs text-muted-foreground">{accountLabel}</p>
43
+ )}
44
+ {!connected && (
45
+ <p className="text-xs text-muted-foreground">Not connected</p>
46
+ )}
47
+ </div>
48
+ </div>
49
+
50
+ <div>
51
+ {isLoading ? (
52
+ <button
53
+ disabled
54
+ className="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium bg-gray-100 text-gray-400 cursor-not-allowed"
55
+ >
56
+ <Loader2 className="w-3 h-3 animate-spin" />
57
+ Loading...
58
+ </button>
59
+ ) : connected ? (
60
+ <button
61
+ onClick={onDisconnect}
62
+ className="inline-flex items-center rounded-md px-3 py-1.5 text-xs font-medium border border-red-200 text-red-600 hover:bg-red-50 transition-colors"
63
+ >
64
+ Disconnect
65
+ </button>
66
+ ) : (
67
+ <button
68
+ onClick={onConnect}
69
+ className="inline-flex items-center rounded-md px-3 py-1.5 text-xs font-medium bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
70
+ >
71
+ Connect
72
+ </button>
73
+ )}
74
+ </div>
75
+ </div>
76
+ )
77
+ }
@@ -0,0 +1,92 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { ChevronRight } from 'lucide-react'
5
+ import { cn } from '../../lib/utils'
6
+
7
+ export type IntegrationStatus = 'connected' | 'available' | 'coming-soon'
8
+
9
+ export interface IntegrationCardProps {
10
+ name: string
11
+ description: string
12
+ icon: React.ElementType
13
+ status: IntegrationStatus
14
+ onClick?: () => void
15
+ lastSync?: string
16
+ className?: string
17
+ }
18
+
19
+ const STATUS_CONFIG: Record<IntegrationStatus, { label: string; className: string }> = {
20
+ connected: { label: 'Connected', className: 'bg-green-100 text-green-700' },
21
+ available: { label: 'Available', className: 'bg-blue-100 text-blue-700' },
22
+ 'coming-soon': { label: 'Coming Soon', className: 'bg-gray-100 text-gray-500' },
23
+ }
24
+
25
+ export function IntegrationCard({
26
+ name,
27
+ description,
28
+ icon: Icon,
29
+ status,
30
+ onClick,
31
+ lastSync,
32
+ className,
33
+ }: IntegrationCardProps) {
34
+ const isClickable = status !== 'coming-soon'
35
+ const statusEntry = STATUS_CONFIG[status]
36
+
37
+ return (
38
+ <div
39
+ className={cn(
40
+ 'rounded-xl border bg-card text-card-foreground shadow p-5 flex items-start gap-4',
41
+ isClickable && 'cursor-pointer hover:shadow-md hover:border-primary/30 transition-shadow',
42
+ !isClickable && 'opacity-75',
43
+ className
44
+ )}
45
+ onClick={isClickable ? onClick : undefined}
46
+ role={isClickable ? 'button' : undefined}
47
+ tabIndex={isClickable ? 0 : undefined}
48
+ onKeyDown={
49
+ isClickable
50
+ ? (e: React.KeyboardEvent) => {
51
+ if (e.key === 'Enter' || e.key === ' ') {
52
+ e.preventDefault()
53
+ onClick?.()
54
+ }
55
+ }
56
+ : undefined
57
+ }
58
+ >
59
+ <div className="flex-shrink-0 w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center">
60
+ <Icon className="w-5 h-5 text-gray-600" />
61
+ </div>
62
+
63
+ <div className="flex-1 min-w-0">
64
+ <div className="flex items-center gap-2 mb-1">
65
+ <h3 className="font-medium text-sm truncate">{name}</h3>
66
+ <span
67
+ className={cn(
68
+ 'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium',
69
+ statusEntry.className
70
+ )}
71
+ >
72
+ {statusEntry.label}
73
+ </span>
74
+ </div>
75
+
76
+ <p className="text-xs text-muted-foreground line-clamp-2">{description}</p>
77
+
78
+ {lastSync && (
79
+ <p className="text-xs text-muted-foreground mt-1">
80
+ Last synced: {lastSync}
81
+ </p>
82
+ )}
83
+
84
+ {isClickable && (
85
+ <span className="inline-flex items-center text-xs text-primary mt-2 font-medium">
86
+ Configure <ChevronRight className="w-3 h-3 ml-0.5" />
87
+ </span>
88
+ )}
89
+ </div>
90
+ </div>
91
+ )
92
+ }
@@ -0,0 +1,191 @@
1
+ import { render, screen, fireEvent } from '@testing-library/react'
2
+ import { IntegrationCard } from '../IntegrationCard'
3
+ import { ConnectionStatus } from '../ConnectionStatus'
4
+
5
+ // Simple icon stub usable as React.ElementType
6
+ const TestIcon = () => <svg data-testid="test-icon" />
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // IntegrationCard
10
+ // ---------------------------------------------------------------------------
11
+
12
+ describe('IntegrationCard', () => {
13
+ const baseProps = {
14
+ name: 'Salesforce',
15
+ description: 'Sync leads and deals from Salesforce CRM.',
16
+ icon: TestIcon,
17
+ status: 'available' as const,
18
+ }
19
+
20
+ it('renders name and description', () => {
21
+ render(<IntegrationCard {...baseProps} />)
22
+ expect(screen.getByText('Salesforce')).toBeInTheDocument()
23
+ expect(screen.getByText('Sync leads and deals from Salesforce CRM.')).toBeInTheDocument()
24
+ })
25
+
26
+ it('shows "Available" badge for available status', () => {
27
+ render(<IntegrationCard {...baseProps} />)
28
+ expect(screen.getByText('Available')).toBeInTheDocument()
29
+ })
30
+
31
+ it('shows "Connected" badge for connected status', () => {
32
+ render(<IntegrationCard {...baseProps} status="connected" />)
33
+ expect(screen.getByText('Connected')).toBeInTheDocument()
34
+ })
35
+
36
+ it('shows "Coming Soon" badge for coming-soon status', () => {
37
+ render(<IntegrationCard {...baseProps} status="coming-soon" />)
38
+ expect(screen.getByText('Coming Soon')).toBeInTheDocument()
39
+ })
40
+
41
+ it('renders icon', () => {
42
+ render(<IntegrationCard {...baseProps} />)
43
+ expect(screen.getByTestId('test-icon')).toBeInTheDocument()
44
+ })
45
+
46
+ it('shows Configure link for clickable statuses', () => {
47
+ render(<IntegrationCard {...baseProps} onClick={jest.fn()} />)
48
+ expect(screen.getByText('Configure')).toBeInTheDocument()
49
+ })
50
+
51
+ it('does not show Configure link for coming-soon status', () => {
52
+ render(<IntegrationCard {...baseProps} status="coming-soon" />)
53
+ expect(screen.queryByText('Configure')).not.toBeInTheDocument()
54
+ })
55
+
56
+ it('calls onClick when clicked for available status', () => {
57
+ const onClick = jest.fn()
58
+ render(<IntegrationCard {...baseProps} onClick={onClick} />)
59
+ fireEvent.click(screen.getByRole('button'))
60
+ expect(onClick).toHaveBeenCalledTimes(1)
61
+ })
62
+
63
+ it('calls onClick on Enter key press for clickable cards', () => {
64
+ const onClick = jest.fn()
65
+ render(<IntegrationCard {...baseProps} onClick={onClick} />)
66
+ fireEvent.keyDown(screen.getByRole('button'), { key: 'Enter' })
67
+ expect(onClick).toHaveBeenCalledTimes(1)
68
+ })
69
+
70
+ it('calls onClick on Space key press for clickable cards', () => {
71
+ const onClick = jest.fn()
72
+ render(<IntegrationCard {...baseProps} onClick={onClick} />)
73
+ fireEvent.keyDown(screen.getByRole('button'), { key: ' ' })
74
+ expect(onClick).toHaveBeenCalledTimes(1)
75
+ })
76
+
77
+ it('does not have role=button for coming-soon', () => {
78
+ render(<IntegrationCard {...baseProps} status="coming-soon" />)
79
+ expect(screen.queryByRole('button')).not.toBeInTheDocument()
80
+ })
81
+
82
+ it('shows lastSync when provided', () => {
83
+ render(<IntegrationCard {...baseProps} status="connected" lastSync="2 hours ago" />)
84
+ expect(screen.getByText('Last synced: 2 hours ago')).toBeInTheDocument()
85
+ })
86
+
87
+ it('does not show lastSync when not provided', () => {
88
+ render(<IntegrationCard {...baseProps} />)
89
+ expect(screen.queryByText(/last synced/i)).not.toBeInTheDocument()
90
+ })
91
+
92
+ it('applies custom className', () => {
93
+ const { container } = render(
94
+ <IntegrationCard {...baseProps} className="my-class" />
95
+ )
96
+ expect(container.firstChild).toHaveClass('my-class')
97
+ })
98
+
99
+ it('applies opacity for coming-soon status', () => {
100
+ const { container } = render(
101
+ <IntegrationCard {...baseProps} status="coming-soon" />
102
+ )
103
+ expect(container.firstChild).toHaveClass('opacity-75')
104
+ })
105
+ })
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // ConnectionStatus
109
+ // ---------------------------------------------------------------------------
110
+
111
+ describe('ConnectionStatus', () => {
112
+ const baseProps = {
113
+ providerName: 'Gmail',
114
+ providerIcon: TestIcon,
115
+ connected: false,
116
+ }
117
+
118
+ it('renders provider name', () => {
119
+ render(<ConnectionStatus {...baseProps} />)
120
+ expect(screen.getByText('Gmail')).toBeInTheDocument()
121
+ })
122
+
123
+ it('renders provider icon', () => {
124
+ render(<ConnectionStatus {...baseProps} />)
125
+ expect(screen.getByTestId('test-icon')).toBeInTheDocument()
126
+ })
127
+
128
+ it('shows "Not connected" when connected is false', () => {
129
+ render(<ConnectionStatus {...baseProps} />)
130
+ expect(screen.getByText('Not connected')).toBeInTheDocument()
131
+ })
132
+
133
+ it('shows Connect button when not connected', () => {
134
+ render(<ConnectionStatus {...baseProps} onConnect={jest.fn()} />)
135
+ expect(screen.getByRole('button', { name: /connect/i })).toBeInTheDocument()
136
+ })
137
+
138
+ it('shows Disconnect button when connected', () => {
139
+ render(<ConnectionStatus {...baseProps} connected onDisconnect={jest.fn()} />)
140
+ expect(screen.getByRole('button', { name: /disconnect/i })).toBeInTheDocument()
141
+ })
142
+
143
+ it('shows accountLabel when connected', () => {
144
+ render(
145
+ <ConnectionStatus
146
+ {...baseProps}
147
+ connected
148
+ accountLabel="jane@example.com"
149
+ />
150
+ )
151
+ expect(screen.getByText('jane@example.com')).toBeInTheDocument()
152
+ })
153
+
154
+ it('does not show accountLabel when not connected', () => {
155
+ render(
156
+ <ConnectionStatus
157
+ {...baseProps}
158
+ connected={false}
159
+ accountLabel="jane@example.com"
160
+ />
161
+ )
162
+ expect(screen.queryByText('jane@example.com')).not.toBeInTheDocument()
163
+ })
164
+
165
+ it('calls onConnect when Connect is clicked', () => {
166
+ const onConnect = jest.fn()
167
+ render(<ConnectionStatus {...baseProps} onConnect={onConnect} />)
168
+ fireEvent.click(screen.getByRole('button', { name: /connect/i }))
169
+ expect(onConnect).toHaveBeenCalledTimes(1)
170
+ })
171
+
172
+ it('calls onDisconnect when Disconnect is clicked', () => {
173
+ const onDisconnect = jest.fn()
174
+ render(<ConnectionStatus {...baseProps} connected onDisconnect={onDisconnect} />)
175
+ fireEvent.click(screen.getByRole('button', { name: /disconnect/i }))
176
+ expect(onDisconnect).toHaveBeenCalledTimes(1)
177
+ })
178
+
179
+ it('shows loading state when isLoading is true', () => {
180
+ render(<ConnectionStatus {...baseProps} isLoading />)
181
+ expect(screen.getByText('Loading...')).toBeInTheDocument()
182
+ expect(screen.getByText('Loading...').closest('button')).toBeDisabled()
183
+ })
184
+
185
+ it('applies custom className', () => {
186
+ const { container } = render(
187
+ <ConnectionStatus {...baseProps} className="my-class" />
188
+ )
189
+ expect(container.firstChild).toHaveClass('my-class')
190
+ })
191
+ })
@@ -0,0 +1,5 @@
1
+ export { IntegrationCard } from './IntegrationCard'
2
+ export type { IntegrationCardProps, IntegrationStatus } from './IntegrationCard'
3
+
4
+ export { ConnectionStatus } from './ConnectionStatus'
5
+ export type { ConnectionStatusProps } from './ConnectionStatus'
@@ -0,0 +1,103 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { cn } from '../../lib/utils'
5
+
6
+ export interface KanbanColumnConfig {
7
+ id: string
8
+ label: string
9
+ color?: string
10
+ }
11
+
12
+ export interface KanbanBoardProps<T> {
13
+ columns: KanbanColumnConfig[]
14
+ items: Record<string, T[]>
15
+ renderCard: (item: T) => React.ReactNode
16
+ renderColumnHeader?: (column: KanbanColumnConfig, items: T[]) => React.ReactNode
17
+ renderColumnFooter?: (column: KanbanColumnConfig) => React.ReactNode
18
+ emptyColumnMessage?: string
19
+ columnWidth?: number
20
+ columnRef?: (columnId: string) => React.Ref<HTMLDivElement> | undefined
21
+ isColumnOver?: (columnId: string) => boolean
22
+ className?: string
23
+ }
24
+
25
+ export function KanbanBoard<T>({
26
+ columns,
27
+ items,
28
+ renderCard,
29
+ renderColumnHeader,
30
+ renderColumnFooter,
31
+ emptyColumnMessage = 'No items',
32
+ columnWidth = 320,
33
+ columnRef,
34
+ isColumnOver,
35
+ className,
36
+ }: KanbanBoardProps<T>) {
37
+ return (
38
+ <div className={cn('flex gap-4 overflow-x-auto pb-4', className)}>
39
+ {columns.map((column) => {
40
+ const columnItems = items[column.id] ?? []
41
+ const ref = columnRef?.(column.id)
42
+ const isOver = isColumnOver?.(column.id) ?? false
43
+
44
+ return (
45
+ <div
46
+ key={column.id}
47
+ ref={ref}
48
+ style={{ minWidth: columnWidth, maxWidth: columnWidth }}
49
+ className={cn(
50
+ 'flex flex-col rounded-lg border bg-muted/50 transition-colors',
51
+ isOver && 'border-primary ring-2 ring-primary/20',
52
+ )}
53
+ >
54
+ {/* Column Header */}
55
+ {renderColumnHeader ? (
56
+ renderColumnHeader(column, columnItems)
57
+ ) : (
58
+ <div className="flex items-center justify-between px-3 py-2 border-b">
59
+ <div className="flex items-center gap-2">
60
+ {column.color && (
61
+ <span
62
+ className="w-2.5 h-2.5 rounded-full shrink-0"
63
+ style={{ backgroundColor: column.color }}
64
+ aria-hidden="true"
65
+ />
66
+ )}
67
+ <h3 className="text-sm font-semibold text-foreground truncate">
68
+ {column.label}
69
+ </h3>
70
+ </div>
71
+ <span className="inline-flex items-center justify-center rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
72
+ {columnItems.length}
73
+ </span>
74
+ </div>
75
+ )}
76
+
77
+ {/* Column Body */}
78
+ <div className="flex-1 overflow-y-auto p-2 space-y-2">
79
+ {columnItems.length === 0 ? (
80
+ <p className="text-xs text-muted-foreground text-center py-8">
81
+ {emptyColumnMessage}
82
+ </p>
83
+ ) : (
84
+ columnItems.map((item, index) => (
85
+ <React.Fragment key={index}>
86
+ {renderCard(item)}
87
+ </React.Fragment>
88
+ ))
89
+ )}
90
+ </div>
91
+
92
+ {/* Column Footer */}
93
+ {renderColumnFooter && (
94
+ <div className="border-t px-3 py-2">
95
+ {renderColumnFooter(column)}
96
+ </div>
97
+ )}
98
+ </div>
99
+ )
100
+ })}
101
+ </div>
102
+ )
103
+ }
@@ -0,0 +1,157 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import { KanbanBoard } from '../KanbanBoard'
3
+
4
+ interface Card {
5
+ id: string
6
+ title: string
7
+ }
8
+
9
+ const columns = [
10
+ { id: 'todo', label: 'To Do', color: '#6366f1' },
11
+ { id: 'in-progress', label: 'In Progress', color: '#f59e0b' },
12
+ { id: 'done', label: 'Done' },
13
+ ]
14
+
15
+ const items: Record<string, Card[]> = {
16
+ todo: [{ id: '1', title: 'Task One' }, { id: '2', title: 'Task Two' }],
17
+ 'in-progress': [{ id: '3', title: 'Task Three' }],
18
+ done: [],
19
+ }
20
+
21
+ describe('KanbanBoard', () => {
22
+ const renderCard = (item: Card) => (
23
+ <div key={item.id} data-testid={`card-${item.id}`}>
24
+ {item.title}
25
+ </div>
26
+ )
27
+
28
+ it('renders all column labels', () => {
29
+ render(
30
+ <KanbanBoard columns={columns} items={items} renderCard={renderCard} />
31
+ )
32
+ expect(screen.getByText('To Do')).toBeInTheDocument()
33
+ expect(screen.getByText('In Progress')).toBeInTheDocument()
34
+ expect(screen.getByText('Done')).toBeInTheDocument()
35
+ })
36
+
37
+ it('renders all card items', () => {
38
+ render(
39
+ <KanbanBoard columns={columns} items={items} renderCard={renderCard} />
40
+ )
41
+ expect(screen.getByText('Task One')).toBeInTheDocument()
42
+ expect(screen.getByText('Task Two')).toBeInTheDocument()
43
+ expect(screen.getByText('Task Three')).toBeInTheDocument()
44
+ })
45
+
46
+ it('shows item counts per column in the default header', () => {
47
+ render(
48
+ <KanbanBoard columns={columns} items={items} renderCard={renderCard} />
49
+ )
50
+ // todo has 2, in-progress has 1, done has 0
51
+ const counts = screen.getAllByText(/^[0-9]+$/)
52
+ const countValues = counts.map((el) => el.textContent)
53
+ expect(countValues).toContain('2')
54
+ expect(countValues).toContain('1')
55
+ expect(countValues).toContain('0')
56
+ })
57
+
58
+ it('shows empty column message when column has no items', () => {
59
+ render(
60
+ <KanbanBoard columns={columns} items={items} renderCard={renderCard} />
61
+ )
62
+ expect(screen.getByText('No items')).toBeInTheDocument()
63
+ })
64
+
65
+ it('shows custom empty column message', () => {
66
+ render(
67
+ <KanbanBoard
68
+ columns={columns}
69
+ items={items}
70
+ renderCard={renderCard}
71
+ emptyColumnMessage="Nothing here yet"
72
+ />
73
+ )
74
+ expect(screen.getByText('Nothing here yet')).toBeInTheDocument()
75
+ })
76
+
77
+ it('renders color dot for columns that have a color', () => {
78
+ const { container } = render(
79
+ <KanbanBoard columns={columns} items={items} renderCard={renderCard} />
80
+ )
81
+ // Two columns have colors (todo, in-progress), one does not (done)
82
+ const colorDots = container.querySelectorAll('[aria-hidden="true"]')
83
+ expect(colorDots.length).toBe(2)
84
+ })
85
+
86
+ it('uses custom column header when renderColumnHeader is provided', () => {
87
+ const renderColumnHeader = (col: typeof columns[0]) => (
88
+ <div data-testid={`custom-header-${col.id}`}>Custom: {col.label}</div>
89
+ )
90
+ render(
91
+ <KanbanBoard
92
+ columns={columns}
93
+ items={items}
94
+ renderCard={renderCard}
95
+ renderColumnHeader={renderColumnHeader}
96
+ />
97
+ )
98
+ expect(screen.getByTestId('custom-header-todo')).toBeInTheDocument()
99
+ expect(screen.getByText('Custom: To Do')).toBeInTheDocument()
100
+ })
101
+
102
+ it('renders column footer when renderColumnFooter is provided', () => {
103
+ const renderColumnFooter = (col: typeof columns[0]) => (
104
+ <div data-testid={`footer-${col.id}`}>Footer for {col.label}</div>
105
+ )
106
+ render(
107
+ <KanbanBoard
108
+ columns={columns}
109
+ items={items}
110
+ renderCard={renderCard}
111
+ renderColumnFooter={renderColumnFooter}
112
+ />
113
+ )
114
+ expect(screen.getByTestId('footer-todo')).toBeInTheDocument()
115
+ expect(screen.getByText('Footer for To Do')).toBeInTheDocument()
116
+ })
117
+
118
+ it('handles missing items for a column gracefully', () => {
119
+ // Only provide items for one column
120
+ const partialItems: Record<string, Card[]> = {
121
+ todo: [{ id: '1', title: 'Task One' }],
122
+ }
123
+ render(
124
+ <KanbanBoard columns={columns} items={partialItems} renderCard={renderCard} />
125
+ )
126
+ // The other two columns should show the empty message
127
+ const emptyMessages = screen.getAllByText('No items')
128
+ expect(emptyMessages.length).toBe(2)
129
+ })
130
+
131
+ it('applies custom className to container', () => {
132
+ const { container } = render(
133
+ <KanbanBoard
134
+ columns={columns}
135
+ items={items}
136
+ renderCard={renderCard}
137
+ className="my-board"
138
+ />
139
+ )
140
+ expect(container.firstChild).toHaveClass('my-board')
141
+ })
142
+
143
+ it('applies ring styles to column that isColumnOver returns true for', () => {
144
+ const isColumnOver = (id: string) => id === 'todo'
145
+ const { container } = render(
146
+ <KanbanBoard
147
+ columns={columns}
148
+ items={items}
149
+ renderCard={renderCard}
150
+ isColumnOver={isColumnOver}
151
+ />
152
+ )
153
+ // The first column div should have ring classes
154
+ const firstCol = container.querySelector('[style*="320"]')
155
+ expect(firstCol).toHaveClass('ring-2')
156
+ })
157
+ })
@@ -0,0 +1,2 @@
1
+ export { KanbanBoard } from './KanbanBoard'
2
+ export type { KanbanBoardProps, KanbanColumnConfig } from './KanbanBoard'