@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
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { cn } from '../../lib/utils'
|
|
5
|
+
|
|
6
|
+
export interface CreateListDialogProps {
|
|
7
|
+
open: boolean
|
|
8
|
+
onClose: () => void
|
|
9
|
+
onSubmit: (data: { name: string; description: string; sourceType: string }) => void
|
|
10
|
+
isSubmitting?: boolean
|
|
11
|
+
sourceTypes?: Array<{ value: string; label: string }>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const defaultSourceTypes = [
|
|
15
|
+
{ value: 'static', label: 'Static' },
|
|
16
|
+
{ value: 'funnel', label: 'Funnel' },
|
|
17
|
+
{ value: 'query', label: 'Query' },
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
export function CreateListDialog({
|
|
21
|
+
open,
|
|
22
|
+
onClose,
|
|
23
|
+
onSubmit,
|
|
24
|
+
isSubmitting = false,
|
|
25
|
+
sourceTypes = defaultSourceTypes,
|
|
26
|
+
}: CreateListDialogProps) {
|
|
27
|
+
const [name, setName] = React.useState('')
|
|
28
|
+
const [description, setDescription] = React.useState('')
|
|
29
|
+
const [sourceType, setSourceType] = React.useState(sourceTypes[0]?.value ?? 'static')
|
|
30
|
+
|
|
31
|
+
React.useEffect(() => {
|
|
32
|
+
if (open) {
|
|
33
|
+
setName('')
|
|
34
|
+
setDescription('')
|
|
35
|
+
setSourceType(sourceTypes[0]?.value ?? 'static')
|
|
36
|
+
}
|
|
37
|
+
}, [open, sourceTypes])
|
|
38
|
+
|
|
39
|
+
const handleSubmit = (e: React.FormEvent) => {
|
|
40
|
+
e.preventDefault()
|
|
41
|
+
if (!name.trim()) return
|
|
42
|
+
onSubmit({ name: name.trim(), description: description.trim(), sourceType })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!open) return null
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
49
|
+
{/* Backdrop */}
|
|
50
|
+
<div
|
|
51
|
+
className="fixed inset-0 bg-black/50"
|
|
52
|
+
onClick={isSubmitting ? undefined : onClose}
|
|
53
|
+
aria-hidden="true"
|
|
54
|
+
/>
|
|
55
|
+
|
|
56
|
+
{/* Dialog */}
|
|
57
|
+
<div className="relative z-50 w-full max-w-md rounded-lg border bg-background p-6 shadow-lg">
|
|
58
|
+
<h2 className="text-lg font-semibold text-foreground">Create List</h2>
|
|
59
|
+
|
|
60
|
+
<form onSubmit={handleSubmit} className="mt-4 space-y-4">
|
|
61
|
+
{/* Name */}
|
|
62
|
+
<div className="space-y-1.5">
|
|
63
|
+
<label htmlFor="list-name" className="text-sm font-medium text-foreground">
|
|
64
|
+
Name <span className="text-red-500">*</span>
|
|
65
|
+
</label>
|
|
66
|
+
<input
|
|
67
|
+
id="list-name"
|
|
68
|
+
type="text"
|
|
69
|
+
required
|
|
70
|
+
value={name}
|
|
71
|
+
onChange={(e) => setName(e.target.value)}
|
|
72
|
+
placeholder="Enter list name"
|
|
73
|
+
disabled={isSubmitting}
|
|
74
|
+
className={cn(
|
|
75
|
+
'w-full rounded-md border bg-background px-3 py-2 text-sm',
|
|
76
|
+
'placeholder:text-muted-foreground',
|
|
77
|
+
'focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-1',
|
|
78
|
+
'disabled:opacity-50 disabled:cursor-not-allowed',
|
|
79
|
+
)}
|
|
80
|
+
/>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
{/* Description */}
|
|
84
|
+
<div className="space-y-1.5">
|
|
85
|
+
<label htmlFor="list-description" className="text-sm font-medium text-foreground">
|
|
86
|
+
Description
|
|
87
|
+
</label>
|
|
88
|
+
<textarea
|
|
89
|
+
id="list-description"
|
|
90
|
+
value={description}
|
|
91
|
+
onChange={(e) => setDescription(e.target.value)}
|
|
92
|
+
placeholder="Optional description"
|
|
93
|
+
rows={3}
|
|
94
|
+
disabled={isSubmitting}
|
|
95
|
+
className={cn(
|
|
96
|
+
'w-full rounded-md border bg-background px-3 py-2 text-sm resize-none',
|
|
97
|
+
'placeholder:text-muted-foreground',
|
|
98
|
+
'focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-1',
|
|
99
|
+
'disabled:opacity-50 disabled:cursor-not-allowed',
|
|
100
|
+
)}
|
|
101
|
+
/>
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
{/* Source Type */}
|
|
105
|
+
<div className="space-y-1.5">
|
|
106
|
+
<label htmlFor="list-source-type" className="text-sm font-medium text-foreground">
|
|
107
|
+
Source Type
|
|
108
|
+
</label>
|
|
109
|
+
<select
|
|
110
|
+
id="list-source-type"
|
|
111
|
+
value={sourceType}
|
|
112
|
+
onChange={(e) => setSourceType(e.target.value)}
|
|
113
|
+
disabled={isSubmitting}
|
|
114
|
+
className={cn(
|
|
115
|
+
'w-full rounded-md border bg-background px-3 py-2 text-sm',
|
|
116
|
+
'focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-1',
|
|
117
|
+
'disabled:opacity-50 disabled:cursor-not-allowed',
|
|
118
|
+
)}
|
|
119
|
+
>
|
|
120
|
+
{sourceTypes.map((st) => (
|
|
121
|
+
<option key={st.value} value={st.value}>
|
|
122
|
+
{st.label}
|
|
123
|
+
</option>
|
|
124
|
+
))}
|
|
125
|
+
</select>
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
{/* Actions */}
|
|
129
|
+
<div className="flex justify-end gap-2 pt-2">
|
|
130
|
+
<button
|
|
131
|
+
type="button"
|
|
132
|
+
onClick={onClose}
|
|
133
|
+
disabled={isSubmitting}
|
|
134
|
+
className={cn(
|
|
135
|
+
'rounded-md border px-4 py-2 text-sm font-medium',
|
|
136
|
+
'hover:bg-muted transition-colors',
|
|
137
|
+
'disabled:opacity-50 disabled:cursor-not-allowed',
|
|
138
|
+
)}
|
|
139
|
+
>
|
|
140
|
+
Cancel
|
|
141
|
+
</button>
|
|
142
|
+
<button
|
|
143
|
+
type="submit"
|
|
144
|
+
disabled={isSubmitting || !name.trim()}
|
|
145
|
+
className={cn(
|
|
146
|
+
'rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
|
|
147
|
+
'hover:bg-primary/90 transition-colors',
|
|
148
|
+
'disabled:opacity-50 disabled:cursor-not-allowed',
|
|
149
|
+
)}
|
|
150
|
+
>
|
|
151
|
+
{isSubmitting ? 'Creating...' : 'Create'}
|
|
152
|
+
</button>
|
|
153
|
+
</div>
|
|
154
|
+
</form>
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
)
|
|
158
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { cn } from '../../lib/utils'
|
|
5
|
+
|
|
6
|
+
export interface ListCardProps {
|
|
7
|
+
name: string
|
|
8
|
+
sourceType: string
|
|
9
|
+
memberCount: number
|
|
10
|
+
createdAt: string
|
|
11
|
+
onClick?: () => void
|
|
12
|
+
className?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const sourceTypeBadgeClasses: Record<string, string> = {
|
|
16
|
+
static: 'bg-blue-100 text-blue-700 border-blue-200',
|
|
17
|
+
funnel: 'bg-purple-100 text-purple-700 border-purple-200',
|
|
18
|
+
query: 'bg-green-100 text-green-700 border-green-200',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function ListCard({
|
|
22
|
+
name,
|
|
23
|
+
sourceType,
|
|
24
|
+
memberCount,
|
|
25
|
+
createdAt,
|
|
26
|
+
onClick,
|
|
27
|
+
className,
|
|
28
|
+
}: ListCardProps) {
|
|
29
|
+
const badgeClass =
|
|
30
|
+
sourceTypeBadgeClasses[sourceType] ??
|
|
31
|
+
'bg-gray-100 text-gray-700 border-gray-200'
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div
|
|
35
|
+
role={onClick ? 'button' : undefined}
|
|
36
|
+
tabIndex={onClick ? 0 : undefined}
|
|
37
|
+
onClick={onClick}
|
|
38
|
+
onKeyDown={
|
|
39
|
+
onClick
|
|
40
|
+
? (e) => {
|
|
41
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
42
|
+
e.preventDefault()
|
|
43
|
+
onClick()
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
: undefined
|
|
47
|
+
}
|
|
48
|
+
className={cn(
|
|
49
|
+
'rounded-lg border bg-card text-card-foreground p-4 transition-all duration-150',
|
|
50
|
+
onClick && 'cursor-pointer hover:shadow-md hover:border-primary/30',
|
|
51
|
+
className,
|
|
52
|
+
)}
|
|
53
|
+
>
|
|
54
|
+
<div className="flex items-start justify-between gap-3">
|
|
55
|
+
<div className="min-w-0 flex-1">
|
|
56
|
+
<h4 className="text-sm font-semibold text-foreground truncate">
|
|
57
|
+
{name}
|
|
58
|
+
</h4>
|
|
59
|
+
<p className="text-xs text-muted-foreground mt-1">
|
|
60
|
+
{memberCount.toLocaleString()} {memberCount === 1 ? 'member' : 'members'}
|
|
61
|
+
</p>
|
|
62
|
+
</div>
|
|
63
|
+
<span
|
|
64
|
+
className={cn(
|
|
65
|
+
'inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium shrink-0',
|
|
66
|
+
badgeClass,
|
|
67
|
+
)}
|
|
68
|
+
>
|
|
69
|
+
{sourceType}
|
|
70
|
+
</span>
|
|
71
|
+
</div>
|
|
72
|
+
<p className="text-xs text-muted-foreground mt-2">
|
|
73
|
+
Created {createdAt}
|
|
74
|
+
</p>
|
|
75
|
+
</div>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
|
2
|
+
import { ListCard } from '../ListCard'
|
|
3
|
+
import { CreateListDialog } from '../CreateListDialog'
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// ListCard
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
describe('ListCard', () => {
|
|
10
|
+
const baseProps = {
|
|
11
|
+
name: 'My Prospect List',
|
|
12
|
+
sourceType: 'static',
|
|
13
|
+
memberCount: 42,
|
|
14
|
+
createdAt: 'Jan 1, 2025',
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
it('renders the list name', () => {
|
|
18
|
+
render(<ListCard {...baseProps} />)
|
|
19
|
+
expect(screen.getByText('My Prospect List')).toBeInTheDocument()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('renders member count with singular "member"', () => {
|
|
23
|
+
render(<ListCard {...baseProps} memberCount={1} />)
|
|
24
|
+
expect(screen.getByText('1 member')).toBeInTheDocument()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('renders member count with plural "members"', () => {
|
|
28
|
+
render(<ListCard {...baseProps} memberCount={42} />)
|
|
29
|
+
expect(screen.getByText('42 members')).toBeInTheDocument()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('renders zero members correctly', () => {
|
|
33
|
+
render(<ListCard {...baseProps} memberCount={0} />)
|
|
34
|
+
expect(screen.getByText('0 members')).toBeInTheDocument()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('renders large member count with locale formatting', () => {
|
|
38
|
+
render(<ListCard {...baseProps} memberCount={1500} />)
|
|
39
|
+
expect(screen.getByText('1,500 members')).toBeInTheDocument()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('renders the source type badge', () => {
|
|
43
|
+
render(<ListCard {...baseProps} />)
|
|
44
|
+
expect(screen.getByText('static')).toBeInTheDocument()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('renders the createdAt text', () => {
|
|
48
|
+
render(<ListCard {...baseProps} />)
|
|
49
|
+
expect(screen.getByText('Created Jan 1, 2025')).toBeInTheDocument()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('applies blue badge styles for static source type', () => {
|
|
53
|
+
render(<ListCard {...baseProps} sourceType="static" />)
|
|
54
|
+
const badge = screen.getByText('static')
|
|
55
|
+
expect(badge).toHaveClass('bg-blue-100', 'text-blue-700')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('applies purple badge styles for funnel source type', () => {
|
|
59
|
+
render(<ListCard {...baseProps} sourceType="funnel" />)
|
|
60
|
+
const badge = screen.getByText('funnel')
|
|
61
|
+
expect(badge).toHaveClass('bg-purple-100', 'text-purple-700')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('applies green badge styles for query source type', () => {
|
|
65
|
+
render(<ListCard {...baseProps} sourceType="query" />)
|
|
66
|
+
const badge = screen.getByText('query')
|
|
67
|
+
expect(badge).toHaveClass('bg-green-100', 'text-green-700')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('applies gray badge for unknown source type', () => {
|
|
71
|
+
render(<ListCard {...baseProps} sourceType="unknown-type" />)
|
|
72
|
+
const badge = screen.getByText('unknown-type')
|
|
73
|
+
expect(badge).toHaveClass('bg-gray-100', 'text-gray-700')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('has role=button when onClick is provided', () => {
|
|
77
|
+
render(<ListCard {...baseProps} onClick={jest.fn()} />)
|
|
78
|
+
expect(screen.getByRole('button')).toBeInTheDocument()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('does not have role=button when onClick is not provided', () => {
|
|
82
|
+
render(<ListCard {...baseProps} />)
|
|
83
|
+
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('calls onClick when clicked', () => {
|
|
87
|
+
const onClick = jest.fn()
|
|
88
|
+
render(<ListCard {...baseProps} onClick={onClick} />)
|
|
89
|
+
fireEvent.click(screen.getByRole('button'))
|
|
90
|
+
expect(onClick).toHaveBeenCalledTimes(1)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('calls onClick when Enter key is pressed', () => {
|
|
94
|
+
const onClick = jest.fn()
|
|
95
|
+
render(<ListCard {...baseProps} onClick={onClick} />)
|
|
96
|
+
fireEvent.keyDown(screen.getByRole('button'), { key: 'Enter' })
|
|
97
|
+
expect(onClick).toHaveBeenCalledTimes(1)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('calls onClick when Space key is pressed', () => {
|
|
101
|
+
const onClick = jest.fn()
|
|
102
|
+
render(<ListCard {...baseProps} onClick={onClick} />)
|
|
103
|
+
fireEvent.keyDown(screen.getByRole('button'), { key: ' ' })
|
|
104
|
+
expect(onClick).toHaveBeenCalledTimes(1)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('applies custom className', () => {
|
|
108
|
+
const { container } = render(<ListCard {...baseProps} className="custom-card" />)
|
|
109
|
+
expect(container.firstChild).toHaveClass('custom-card')
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// CreateListDialog
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
describe('CreateListDialog', () => {
|
|
118
|
+
const baseProps = {
|
|
119
|
+
open: true,
|
|
120
|
+
onClose: jest.fn(),
|
|
121
|
+
onSubmit: jest.fn(),
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
beforeEach(() => {
|
|
125
|
+
jest.clearAllMocks()
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('renders nothing when open is false', () => {
|
|
129
|
+
render(<CreateListDialog {...baseProps} open={false} />)
|
|
130
|
+
expect(screen.queryByText('Create List')).not.toBeInTheDocument()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('renders dialog when open is true', () => {
|
|
134
|
+
render(<CreateListDialog {...baseProps} />)
|
|
135
|
+
expect(screen.getByText('Create List')).toBeInTheDocument()
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('renders name, description, and source type fields', () => {
|
|
139
|
+
render(<CreateListDialog {...baseProps} />)
|
|
140
|
+
expect(screen.getByLabelText(/name/i)).toBeInTheDocument()
|
|
141
|
+
expect(screen.getByLabelText(/description/i)).toBeInTheDocument()
|
|
142
|
+
expect(screen.getByLabelText(/source type/i)).toBeInTheDocument()
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('renders default source type options', () => {
|
|
146
|
+
render(<CreateListDialog {...baseProps} />)
|
|
147
|
+
expect(screen.getByRole('option', { name: 'Static' })).toBeInTheDocument()
|
|
148
|
+
expect(screen.getByRole('option', { name: 'Funnel' })).toBeInTheDocument()
|
|
149
|
+
expect(screen.getByRole('option', { name: 'Query' })).toBeInTheDocument()
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('renders custom source types when provided', () => {
|
|
153
|
+
render(
|
|
154
|
+
<CreateListDialog
|
|
155
|
+
{...baseProps}
|
|
156
|
+
sourceTypes={[{ value: 'csv', label: 'CSV Upload' }]}
|
|
157
|
+
/>
|
|
158
|
+
)
|
|
159
|
+
expect(screen.getByRole('option', { name: 'CSV Upload' })).toBeInTheDocument()
|
|
160
|
+
expect(screen.queryByRole('option', { name: 'Static' })).not.toBeInTheDocument()
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('Create button is disabled when name is empty', () => {
|
|
164
|
+
render(<CreateListDialog {...baseProps} />)
|
|
165
|
+
expect(screen.getByRole('button', { name: /^create$/i })).toBeDisabled()
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('Create button is enabled after entering a name', () => {
|
|
169
|
+
render(<CreateListDialog {...baseProps} />)
|
|
170
|
+
fireEvent.change(screen.getByLabelText(/name/i), { target: { value: 'New List' } })
|
|
171
|
+
expect(screen.getByRole('button', { name: /^create$/i })).toBeEnabled()
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('calls onSubmit with correct data', () => {
|
|
175
|
+
const onSubmit = jest.fn()
|
|
176
|
+
render(<CreateListDialog {...baseProps} onSubmit={onSubmit} />)
|
|
177
|
+
|
|
178
|
+
fireEvent.change(screen.getByLabelText(/name/i), { target: { value: 'Sales Leads' } })
|
|
179
|
+
fireEvent.change(screen.getByLabelText(/description/i), { target: { value: 'Top prospects' } })
|
|
180
|
+
|
|
181
|
+
fireEvent.click(screen.getByRole('button', { name: /^create$/i }))
|
|
182
|
+
|
|
183
|
+
expect(onSubmit).toHaveBeenCalledWith({
|
|
184
|
+
name: 'Sales Leads',
|
|
185
|
+
description: 'Top prospects',
|
|
186
|
+
sourceType: 'static',
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('trims name and description on submit', () => {
|
|
191
|
+
const onSubmit = jest.fn()
|
|
192
|
+
render(<CreateListDialog {...baseProps} onSubmit={onSubmit} />)
|
|
193
|
+
|
|
194
|
+
fireEvent.change(screen.getByLabelText(/name/i), { target: { value: ' Trimmed ' } })
|
|
195
|
+
fireEvent.click(screen.getByRole('button', { name: /^create$/i }))
|
|
196
|
+
|
|
197
|
+
expect(onSubmit).toHaveBeenCalledWith(
|
|
198
|
+
expect.objectContaining({ name: 'Trimmed' })
|
|
199
|
+
)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('does not call onSubmit when name is only whitespace', () => {
|
|
203
|
+
const onSubmit = jest.fn()
|
|
204
|
+
render(<CreateListDialog {...baseProps} onSubmit={onSubmit} />)
|
|
205
|
+
|
|
206
|
+
fireEvent.change(screen.getByLabelText(/name/i), { target: { value: ' ' } })
|
|
207
|
+
fireEvent.submit(screen.getByLabelText(/name/i).closest('form')!)
|
|
208
|
+
|
|
209
|
+
expect(onSubmit).not.toHaveBeenCalled()
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('calls onClose when Cancel is clicked', () => {
|
|
213
|
+
const onClose = jest.fn()
|
|
214
|
+
render(<CreateListDialog {...baseProps} onClose={onClose} />)
|
|
215
|
+
fireEvent.click(screen.getByRole('button', { name: /cancel/i }))
|
|
216
|
+
expect(onClose).toHaveBeenCalledTimes(1)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('calls onClose when backdrop is clicked', () => {
|
|
220
|
+
const onClose = jest.fn()
|
|
221
|
+
const { container } = render(<CreateListDialog {...baseProps} onClose={onClose} />)
|
|
222
|
+
const backdrop = container.querySelector('[aria-hidden="true"]')
|
|
223
|
+
fireEvent.click(backdrop!)
|
|
224
|
+
expect(onClose).toHaveBeenCalledTimes(1)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('does not call onClose when backdrop clicked during submit', () => {
|
|
228
|
+
const onClose = jest.fn()
|
|
229
|
+
const { container } = render(
|
|
230
|
+
<CreateListDialog {...baseProps} onClose={onClose} isSubmitting />
|
|
231
|
+
)
|
|
232
|
+
const backdrop = container.querySelector('[aria-hidden="true"]')
|
|
233
|
+
fireEvent.click(backdrop!)
|
|
234
|
+
expect(onClose).not.toHaveBeenCalled()
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('shows Creating... and disables fields when isSubmitting', () => {
|
|
238
|
+
render(<CreateListDialog {...baseProps} isSubmitting />)
|
|
239
|
+
expect(screen.getByText('Creating...')).toBeInTheDocument()
|
|
240
|
+
expect(screen.getByLabelText(/name/i)).toBeDisabled()
|
|
241
|
+
expect(screen.getByLabelText(/description/i)).toBeDisabled()
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('resets fields when dialog reopens', () => {
|
|
245
|
+
const { rerender } = render(<CreateListDialog {...baseProps} open={false} />)
|
|
246
|
+
rerender(<CreateListDialog {...baseProps} open />)
|
|
247
|
+
expect(screen.getByLabelText(/name/i)).toHaveValue('')
|
|
248
|
+
expect(screen.getByLabelText(/description/i)).toHaveValue('')
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('submits the selected source type', () => {
|
|
252
|
+
const onSubmit = jest.fn()
|
|
253
|
+
render(<CreateListDialog {...baseProps} onSubmit={onSubmit} />)
|
|
254
|
+
|
|
255
|
+
fireEvent.change(screen.getByLabelText(/name/i), { target: { value: 'My List' } })
|
|
256
|
+
fireEvent.change(screen.getByLabelText(/source type/i), { target: { value: 'funnel' } })
|
|
257
|
+
fireEvent.click(screen.getByRole('button', { name: /^create$/i }))
|
|
258
|
+
|
|
259
|
+
expect(onSubmit).toHaveBeenCalledWith(
|
|
260
|
+
expect.objectContaining({ sourceType: 'funnel' })
|
|
261
|
+
)
|
|
262
|
+
})
|
|
263
|
+
})
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react'
|
|
2
|
+
import { DashboardSkeleton } from '../DashboardSkeleton'
|
|
3
|
+
import { TableSkeleton } from '../TableSkeleton'
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// DashboardSkeleton
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
describe('DashboardSkeleton', () => {
|
|
10
|
+
it('renders 4 skeleton cards by default', () => {
|
|
11
|
+
const { container } = render(<DashboardSkeleton />)
|
|
12
|
+
// Each card is a direct child of the grid container
|
|
13
|
+
const grid = container.firstChild as HTMLElement
|
|
14
|
+
expect(grid.children).toHaveLength(4)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('renders the specified number of cards', () => {
|
|
18
|
+
const { container } = render(<DashboardSkeleton cards={3} />)
|
|
19
|
+
const grid = container.firstChild as HTMLElement
|
|
20
|
+
expect(grid.children).toHaveLength(3)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('renders 0 cards when cards=0', () => {
|
|
24
|
+
const { container } = render(<DashboardSkeleton cards={0} />)
|
|
25
|
+
const grid = container.firstChild as HTMLElement
|
|
26
|
+
expect(grid.children).toHaveLength(0)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('renders animated pulse elements inside each card', () => {
|
|
30
|
+
const { container } = render(<DashboardSkeleton cards={1} />)
|
|
31
|
+
const pulseEls = container.querySelectorAll('.animate-pulse')
|
|
32
|
+
// Each card has 4 pulse elements
|
|
33
|
+
expect(pulseEls.length).toBe(4)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('applies custom className to the grid', () => {
|
|
37
|
+
const { container } = render(<DashboardSkeleton className="my-skeleton" />)
|
|
38
|
+
expect(container.firstChild).toHaveClass('my-skeleton')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('renders with 8 cards', () => {
|
|
42
|
+
const { container } = render(<DashboardSkeleton cards={8} />)
|
|
43
|
+
const grid = container.firstChild as HTMLElement
|
|
44
|
+
expect(grid.children).toHaveLength(8)
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// TableSkeleton
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
describe('TableSkeleton', () => {
|
|
53
|
+
it('renders a table', () => {
|
|
54
|
+
render(<TableSkeleton />)
|
|
55
|
+
expect(screen.getByRole('table')).toBeInTheDocument()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('renders default 5 rows', () => {
|
|
59
|
+
render(<TableSkeleton />)
|
|
60
|
+
const rows = screen.getAllByRole('row')
|
|
61
|
+
// 1 header row + 5 body rows
|
|
62
|
+
expect(rows).toHaveLength(6)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('renders the specified number of body rows', () => {
|
|
66
|
+
render(<TableSkeleton rows={3} />)
|
|
67
|
+
const rows = screen.getAllByRole('row')
|
|
68
|
+
// 1 header row + 3 body rows
|
|
69
|
+
expect(rows).toHaveLength(4)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('renders default 4 columns in the header', () => {
|
|
73
|
+
render(<TableSkeleton />)
|
|
74
|
+
const headerCells = screen.getAllByRole('columnheader')
|
|
75
|
+
expect(headerCells).toHaveLength(4)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('renders the specified number of columns', () => {
|
|
79
|
+
render(<TableSkeleton columns={6} />)
|
|
80
|
+
const headerCells = screen.getAllByRole('columnheader')
|
|
81
|
+
expect(headerCells).toHaveLength(6)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('renders correct number of data cells (rows * columns)', () => {
|
|
85
|
+
render(<TableSkeleton rows={3} columns={2} />)
|
|
86
|
+
const cells = screen.getAllByRole('cell')
|
|
87
|
+
expect(cells).toHaveLength(6)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('renders animated pulse elements in header cells', () => {
|
|
91
|
+
const { container } = render(<TableSkeleton columns={2} rows={0} />)
|
|
92
|
+
const headerPulse = container.querySelectorAll('thead .animate-pulse')
|
|
93
|
+
expect(headerPulse.length).toBe(2)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('applies custom className to the container', () => {
|
|
97
|
+
const { container } = render(<TableSkeleton className="my-table-skeleton" />)
|
|
98
|
+
expect(container.firstChild).toHaveClass('my-table-skeleton')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('renders a header skeleton area above the table', () => {
|
|
102
|
+
const { container } = render(<TableSkeleton />)
|
|
103
|
+
// Header search/filter area has a border-b
|
|
104
|
+
const headerArea = container.querySelector('.p-4.border-b')
|
|
105
|
+
expect(headerArea).toBeInTheDocument()
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('renders a pagination skeleton area below the table', () => {
|
|
109
|
+
const { container } = render(<TableSkeleton />)
|
|
110
|
+
// Pagination area has border-t
|
|
111
|
+
const paginationArea = container.querySelector('.p-4.border-t')
|
|
112
|
+
expect(paginationArea).toBeInTheDocument()
|
|
113
|
+
})
|
|
114
|
+
})
|