@startsimpli/ui 0.4.6 → 0.4.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/src/__mocks__/next/link.js +11 -0
- package/src/components/ActivityTimeline.tsx +173 -0
- package/src/components/LogActivityDialog.tsx +303 -0
- package/src/components/QuickLogButtons.tsx +32 -0
- package/src/components/account/__tests__/account.test.tsx +315 -0
- package/src/components/badge/StageBadge.tsx +31 -0
- package/src/components/badge/index.ts +3 -0
- package/src/components/command-palette/CommandGroup.tsx +23 -0
- package/src/components/command-palette/CommandPalette.tsx +327 -0
- package/src/components/command-palette/CommandResultItem.tsx +59 -0
- package/src/components/command-palette/__tests__/CommandGroup.test.tsx +81 -0
- package/src/components/command-palette/__tests__/CommandResultItem.test.tsx +166 -0
- package/src/components/command-palette/__tests__/command-palette-context.test.tsx +166 -0
- package/src/components/command-palette/__tests__/useCommandPaletteSearch.test.ts +271 -0
- package/src/components/command-palette/command-palette-context.tsx +51 -0
- package/src/components/command-palette/index.ts +9 -0
- package/src/components/command-palette/useCommandPaletteSearch.ts +114 -0
- package/src/components/compose/__tests__/compose.test.tsx +656 -0
- package/src/components/compose/compose-header.tsx +72 -0
- package/src/components/compose/compose-loading.tsx +13 -0
- package/src/components/compose/index.ts +6 -0
- package/src/components/compose/save-status-indicator.tsx +57 -0
- package/src/components/compose/send-confirmation-dialog.tsx +87 -0
- package/src/components/compose/subject-input.tsx +25 -0
- package/src/components/compose/useAutoSave.ts +93 -0
- package/src/components/dashboard/DashboardGrid.tsx +32 -0
- package/src/components/dashboard/DashboardSection.tsx +32 -0
- package/src/components/dashboard/MetricCard.tsx +129 -0
- package/src/components/dashboard/PeriodSelector.tsx +55 -0
- package/src/components/dashboard/PipelineFunnel.tsx +126 -0
- package/src/components/dashboard/SparklineTrend.tsx +102 -0
- package/src/components/dashboard/TopCampaigns.tsx +132 -0
- package/src/components/dashboard/__tests__/dashboard.test.tsx +785 -0
- package/src/components/dashboard/index.ts +20 -0
- package/src/components/dialog/ConfirmDialog.tsx +72 -0
- package/src/components/dialog/__tests__/ConfirmDialog.test.tsx +126 -0
- package/src/components/dialog/index.ts +3 -0
- package/src/components/email-dialogs/__tests__/email-dialogs.test.tsx +982 -0
- package/src/components/email-dialogs/index.ts +14 -0
- package/src/components/email-dialogs/merge-fields.tsx +196 -0
- package/src/components/email-dialogs/preview-dialog.tsx +194 -0
- package/src/components/email-dialogs/schedule-dialog.tsx +297 -0
- package/src/components/email-dialogs/template-picker.tsx +225 -0
- package/src/components/email-dialogs/test-send-dialog.tsx +188 -0
- package/src/components/email-editor/BlockRenderer.tsx +120 -0
- package/src/components/email-editor/__tests__/BlockRenderer.test.tsx +332 -0
- package/src/components/email-editor/__tests__/block-renderers.test.ts +624 -0
- package/src/components/email-editor/__tests__/email-html-renderer.test.ts +376 -0
- package/src/components/email-editor/add-block-menu.tsx +151 -0
- package/src/components/email-editor/block-toolbar.tsx +73 -0
- package/src/components/email-editor/blocks/__tests__/blocks.test.tsx +818 -0
- package/src/components/email-editor/blocks/button-block.tsx +44 -0
- package/src/components/email-editor/blocks/divider-block.tsx +43 -0
- package/src/components/email-editor/blocks/footer-block.tsx +39 -0
- package/src/components/email-editor/blocks/header-block.tsx +39 -0
- package/src/components/email-editor/blocks/image-block.tsx +61 -0
- package/src/components/email-editor/blocks/index.ts +9 -0
- package/src/components/email-editor/blocks/metrics-block.tsx +198 -0
- package/src/components/email-editor/blocks/social-block.tsx +75 -0
- package/src/components/email-editor/blocks/spacer-block.tsx +26 -0
- package/src/components/email-editor/blocks/text-block.tsx +75 -0
- package/src/components/email-editor/editor-sidebar.tsx +66 -0
- package/src/components/email-editor/email-editor.tsx +497 -0
- package/src/components/email-editor/hooks/__tests__/useDragDrop.test.ts +355 -0
- package/src/components/email-editor/hooks/__tests__/useEmailEditorState.test.ts +551 -0
- package/src/components/email-editor/hooks/useDragDrop.ts +181 -0
- package/src/components/email-editor/hooks/useEmailEditorState.ts +426 -0
- package/src/components/email-editor/index.ts +51 -0
- package/src/components/email-editor/panels/BlockPropertyPanel.tsx +637 -0
- package/src/components/email-editor/panels/GlobalStylesPanel.tsx +108 -0
- package/src/components/email-editor/panels/SectionSettingsPanel.tsx +80 -0
- package/src/components/email-editor/panels/__tests__/BlockPropertyPanel.test.tsx +707 -0
- package/src/components/email-editor/panels/__tests__/GlobalStylesPanel.test.tsx +226 -0
- package/src/components/email-editor/panels/index.ts +3 -0
- package/src/components/email-editor/renderer/block-renderers.ts +209 -0
- package/src/components/email-editor/renderer/email-html-renderer.ts +128 -0
- package/src/components/email-editor/types.ts +413 -0
- package/src/components/email-editor/utils/defaults.ts +116 -0
- package/src/components/email-editor/utils/undo-redo.ts +59 -0
- package/src/components/enrichment/EnrichButton.tsx +33 -0
- package/src/components/enrichment/EnrichmentProgress.tsx +66 -0
- package/src/components/enrichment/QualityBadge.tsx +43 -0
- package/src/components/enrichment/__tests__/enrichment.test.tsx +184 -0
- package/src/components/enrichment/index.ts +8 -0
- package/src/components/gantt/GanttBoardView.tsx +71 -0
- package/src/components/gantt/GanttChart.tsx +140 -887
- package/src/components/gantt/GanttFilterBar.tsx +100 -0
- package/src/components/gantt/GanttListView.tsx +63 -0
- package/src/components/gantt/GanttTimelineView.tsx +215 -0
- package/src/components/gantt/__tests__/GanttBoardView.test.tsx +305 -0
- package/src/components/gantt/__tests__/GanttFilterBar.test.tsx +544 -0
- package/src/components/gantt/__tests__/GanttListView.test.tsx +337 -0
- package/src/components/gantt/__tests__/GanttTimelineView.test.tsx +375 -0
- package/src/components/gantt/__tests__/gantt-utils.test.ts +341 -0
- package/src/components/gantt/__tests__/useGanttState.test.ts +535 -0
- package/src/components/gantt/hooks/useGanttState.ts +644 -0
- package/src/components/gantt/index.ts +10 -0
- package/src/components/gantt/types.ts +5 -5
- package/src/components/index.ts +46 -0
- package/src/components/integrations/ConnectionStatus.tsx +77 -0
- package/src/components/integrations/IntegrationCard.tsx +92 -0
- package/src/components/integrations/__tests__/integrations.test.tsx +191 -0
- package/src/components/integrations/index.ts +5 -0
- package/src/components/kanban/KanbanBoard.tsx +103 -0
- package/src/components/kanban/__tests__/kanban.test.tsx +157 -0
- package/src/components/kanban/index.ts +2 -0
- package/src/components/lists/CreateListDialog.tsx +158 -0
- package/src/components/lists/ListCard.tsx +77 -0
- package/src/components/lists/__tests__/lists.test.tsx +263 -0
- package/src/components/lists/index.ts +5 -0
- package/src/components/loading/__tests__/loading.test.tsx +114 -0
- package/src/components/navigation/__tests__/navigation.test.tsx +194 -0
- package/src/components/pipeline/StageTransitionModal.tsx +146 -0
- package/src/components/pipeline/__tests__/pipeline.test.tsx +169 -0
- package/src/components/pipeline/index.ts +2 -0
- package/src/components/settings/SettingsCard.tsx +33 -0
- package/src/components/settings/SettingsLayout.tsx +28 -0
- package/src/components/settings/SettingsNav.tsx +42 -0
- package/src/components/settings/__tests__/settings.test.tsx +181 -0
- package/src/components/settings/index.ts +6 -0
- package/src/components/wizard/__tests__/wizard.test.tsx +97 -0
package/src/components/index.ts
CHANGED
|
@@ -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,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
|
+
})
|