@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,194 @@
|
|
|
1
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
2
|
+
import { Sidebar, SidebarLayout } from '../sidebar'
|
|
3
|
+
|
|
4
|
+
// next/link and next/navigation are mocked via jest.config.js moduleNameMapper
|
|
5
|
+
|
|
6
|
+
const sections = [
|
|
7
|
+
{
|
|
8
|
+
title: 'Main',
|
|
9
|
+
links: [
|
|
10
|
+
{ href: '/', label: 'Dashboard', icon: <span data-testid="icon-dashboard" /> },
|
|
11
|
+
{ href: '/contacts', label: 'Contacts' },
|
|
12
|
+
],
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
title: 'Admin',
|
|
16
|
+
links: [
|
|
17
|
+
{ href: '/settings', label: 'Account Settings' },
|
|
18
|
+
],
|
|
19
|
+
},
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
describe('Sidebar', () => {
|
|
23
|
+
it('renders app name', () => {
|
|
24
|
+
render(<Sidebar appName="MarketSimpli" sections={sections} />)
|
|
25
|
+
expect(screen.getByText('MarketSimpli')).toBeInTheDocument()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('renders section titles', () => {
|
|
29
|
+
render(<Sidebar appName="MarketSimpli" sections={sections} />)
|
|
30
|
+
expect(screen.getByText('Main')).toBeInTheDocument()
|
|
31
|
+
expect(screen.getByText('Admin')).toBeInTheDocument()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('renders all nav links', () => {
|
|
35
|
+
render(<Sidebar appName="MarketSimpli" sections={sections} />)
|
|
36
|
+
expect(screen.getByText('Dashboard')).toBeInTheDocument()
|
|
37
|
+
expect(screen.getByText('Contacts')).toBeInTheDocument()
|
|
38
|
+
expect(screen.getByText('Account Settings')).toBeInTheDocument()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('renders icons for links that have icons', () => {
|
|
42
|
+
render(<Sidebar appName="MarketSimpli" sections={sections} />)
|
|
43
|
+
expect(screen.getByTestId('icon-dashboard')).toBeInTheDocument()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('renders collapse toggle button when collapsible is true (default)', () => {
|
|
47
|
+
render(<Sidebar appName="MarketSimpli" sections={sections} />)
|
|
48
|
+
expect(screen.getByText('Collapse')).toBeInTheDocument()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('does not render collapse toggle when collapsible is false', () => {
|
|
52
|
+
render(<Sidebar appName="MarketSimpli" sections={sections} collapsible={false} />)
|
|
53
|
+
expect(screen.queryByText('Collapse')).not.toBeInTheDocument()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('collapses when toggle button is clicked', () => {
|
|
57
|
+
const { container } = render(
|
|
58
|
+
<Sidebar appName="MarketSimpli" sections={sections} />
|
|
59
|
+
)
|
|
60
|
+
fireEvent.click(screen.getByText('Collapse'))
|
|
61
|
+
// After collapse, the sidebar should have w-20 class
|
|
62
|
+
expect(container.firstChild).toHaveClass('w-20')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('expands back when toggle is clicked again', () => {
|
|
66
|
+
const { container } = render(
|
|
67
|
+
<Sidebar appName="MarketSimpli" sections={sections} />
|
|
68
|
+
)
|
|
69
|
+
// Collapse
|
|
70
|
+
fireEvent.click(screen.getByText('Collapse'))
|
|
71
|
+
// Expand — the chevron button is still present, click it
|
|
72
|
+
const expandBtn = screen.getByRole('button')
|
|
73
|
+
fireEvent.click(expandBtn)
|
|
74
|
+
expect(container.firstChild).toHaveClass('w-64')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('hides link labels when collapsed', () => {
|
|
78
|
+
render(
|
|
79
|
+
<Sidebar appName="MarketSimpli" sections={sections} defaultCollapsed />
|
|
80
|
+
)
|
|
81
|
+
// Labels are wrapped in a <span> that only renders when not collapsed
|
|
82
|
+
expect(screen.queryByText('Dashboard')).not.toBeInTheDocument()
|
|
83
|
+
expect(screen.queryByText('Contacts')).not.toBeInTheDocument()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('hides section titles when collapsed', () => {
|
|
87
|
+
render(
|
|
88
|
+
<Sidebar appName="MarketSimpli" sections={sections} defaultCollapsed />
|
|
89
|
+
)
|
|
90
|
+
expect(screen.queryByText('Main')).not.toBeInTheDocument()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('shows abbreviated app name when collapsed', () => {
|
|
94
|
+
render(
|
|
95
|
+
<Sidebar appName="MarketSimpli" sections={sections} defaultCollapsed />
|
|
96
|
+
)
|
|
97
|
+
expect(screen.getByText('MA')).toBeInTheDocument()
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('renders user dropdown when provided', () => {
|
|
101
|
+
render(
|
|
102
|
+
<Sidebar
|
|
103
|
+
appName="App"
|
|
104
|
+
sections={sections}
|
|
105
|
+
userDropdown={<div data-testid="user-menu">User Menu</div>}
|
|
106
|
+
/>
|
|
107
|
+
)
|
|
108
|
+
expect(screen.getByTestId('user-menu')).toBeInTheDocument()
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('applies custom className', () => {
|
|
112
|
+
const { container } = render(
|
|
113
|
+
<Sidebar appName="App" sections={sections} className="my-sidebar" />
|
|
114
|
+
)
|
|
115
|
+
expect(container.firstChild).toHaveClass('my-sidebar')
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('active link uses primary background (pathname matches)', () => {
|
|
119
|
+
// The mock usePathname returns '/test-path'
|
|
120
|
+
// We add a link with that href
|
|
121
|
+
const customSections = [
|
|
122
|
+
{
|
|
123
|
+
links: [
|
|
124
|
+
{ href: '/test-path', label: 'Active Link' },
|
|
125
|
+
{ href: '/other', label: 'Inactive Link' },
|
|
126
|
+
],
|
|
127
|
+
},
|
|
128
|
+
]
|
|
129
|
+
render(<Sidebar appName="App" sections={customSections} />)
|
|
130
|
+
const activeLink = screen.getByText('Active Link').closest('a')
|
|
131
|
+
expect(activeLink).toHaveClass('bg-primary-600')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('inactive link uses gray text', () => {
|
|
135
|
+
const customSections = [
|
|
136
|
+
{
|
|
137
|
+
links: [
|
|
138
|
+
{ href: '/test-path', label: 'Active Link' },
|
|
139
|
+
{ href: '/other', label: 'Inactive Link' },
|
|
140
|
+
],
|
|
141
|
+
},
|
|
142
|
+
]
|
|
143
|
+
render(<Sidebar appName="App" sections={customSections} />)
|
|
144
|
+
const inactiveLink = screen.getByText('Inactive Link').closest('a')
|
|
145
|
+
expect(inactiveLink).toHaveClass('text-gray-300')
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('root href "/" is active only for exact match', () => {
|
|
149
|
+
// usePathname returns '/test-path', so '/' should not be active
|
|
150
|
+
const customSections = [
|
|
151
|
+
{
|
|
152
|
+
links: [{ href: '/', label: 'Home' }],
|
|
153
|
+
},
|
|
154
|
+
]
|
|
155
|
+
render(<Sidebar appName="App" sections={customSections} />)
|
|
156
|
+
const homeLink = screen.getByText('Home').closest('a')
|
|
157
|
+
expect(homeLink).not.toHaveClass('bg-primary-600')
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// SidebarLayout
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
describe('SidebarLayout', () => {
|
|
166
|
+
it('renders children', () => {
|
|
167
|
+
render(
|
|
168
|
+
<SidebarLayout>
|
|
169
|
+
<p>Page content</p>
|
|
170
|
+
</SidebarLayout>
|
|
171
|
+
)
|
|
172
|
+
expect(screen.getByText('Page content')).toBeInTheDocument()
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('applies expanded width spacer by default', () => {
|
|
176
|
+
const { container } = render(
|
|
177
|
+
<SidebarLayout>
|
|
178
|
+
<p>Content</p>
|
|
179
|
+
</SidebarLayout>
|
|
180
|
+
)
|
|
181
|
+
const spacer = container.querySelector('.w-64')
|
|
182
|
+
expect(spacer).toBeInTheDocument()
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('applies collapsed width spacer when collapsed is true', () => {
|
|
186
|
+
const { container } = render(
|
|
187
|
+
<SidebarLayout collapsed>
|
|
188
|
+
<p>Content</p>
|
|
189
|
+
</SidebarLayout>
|
|
190
|
+
)
|
|
191
|
+
const spacer = container.querySelector('.w-20')
|
|
192
|
+
expect(spacer).toBeInTheDocument()
|
|
193
|
+
})
|
|
194
|
+
})
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { cn } from '../../lib/utils'
|
|
5
|
+
|
|
6
|
+
export interface StageTransitionModalProps {
|
|
7
|
+
open: boolean
|
|
8
|
+
onClose: () => void
|
|
9
|
+
onConfirm: (targetStage: string, notes?: string) => void
|
|
10
|
+
currentStage: string
|
|
11
|
+
stages: Array<{ id: string; label: string }>
|
|
12
|
+
entityName?: string
|
|
13
|
+
isSubmitting?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function StageTransitionModal({
|
|
17
|
+
open,
|
|
18
|
+
onClose,
|
|
19
|
+
onConfirm,
|
|
20
|
+
currentStage,
|
|
21
|
+
stages,
|
|
22
|
+
entityName,
|
|
23
|
+
isSubmitting = false,
|
|
24
|
+
}: StageTransitionModalProps) {
|
|
25
|
+
const [targetStage, setTargetStage] = React.useState('')
|
|
26
|
+
const [notes, setNotes] = React.useState('')
|
|
27
|
+
|
|
28
|
+
React.useEffect(() => {
|
|
29
|
+
if (open) {
|
|
30
|
+
// Default to the first stage that isn't the current one
|
|
31
|
+
const firstOther = stages.find((s) => s.id !== currentStage)
|
|
32
|
+
setTargetStage(firstOther?.id ?? '')
|
|
33
|
+
setNotes('')
|
|
34
|
+
}
|
|
35
|
+
}, [open, currentStage, stages])
|
|
36
|
+
|
|
37
|
+
const handleConfirm = () => {
|
|
38
|
+
if (!targetStage) return
|
|
39
|
+
onConfirm(targetStage, notes.trim() || undefined)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const currentLabel = stages.find((s) => s.id === currentStage)?.label ?? currentStage
|
|
43
|
+
|
|
44
|
+
if (!open) return null
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
48
|
+
{/* Backdrop */}
|
|
49
|
+
<div
|
|
50
|
+
className="fixed inset-0 bg-black/50"
|
|
51
|
+
onClick={isSubmitting ? undefined : onClose}
|
|
52
|
+
aria-hidden="true"
|
|
53
|
+
/>
|
|
54
|
+
|
|
55
|
+
{/* Dialog */}
|
|
56
|
+
<div className="relative z-50 w-full max-w-md rounded-lg border bg-background p-6 shadow-lg">
|
|
57
|
+
<h2 className="text-lg font-semibold text-foreground">
|
|
58
|
+
Move {entityName ? `"${entityName}"` : 'Item'} to Stage
|
|
59
|
+
</h2>
|
|
60
|
+
|
|
61
|
+
<div className="mt-4 space-y-4">
|
|
62
|
+
{/* Current stage indicator */}
|
|
63
|
+
<div className="space-y-1.5">
|
|
64
|
+
<p className="text-sm font-medium text-foreground">Current Stage</p>
|
|
65
|
+
<p className="text-sm text-muted-foreground">{currentLabel}</p>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
{/* Target stage select */}
|
|
69
|
+
<div className="space-y-1.5">
|
|
70
|
+
<label htmlFor="target-stage" className="text-sm font-medium text-foreground">
|
|
71
|
+
New Stage
|
|
72
|
+
</label>
|
|
73
|
+
<select
|
|
74
|
+
id="target-stage"
|
|
75
|
+
value={targetStage}
|
|
76
|
+
onChange={(e) => setTargetStage(e.target.value)}
|
|
77
|
+
disabled={isSubmitting}
|
|
78
|
+
className={cn(
|
|
79
|
+
'w-full rounded-md border bg-background px-3 py-2 text-sm',
|
|
80
|
+
'focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-1',
|
|
81
|
+
'disabled:opacity-50 disabled:cursor-not-allowed',
|
|
82
|
+
)}
|
|
83
|
+
>
|
|
84
|
+
{stages
|
|
85
|
+
.filter((s) => s.id !== currentStage)
|
|
86
|
+
.map((stage) => (
|
|
87
|
+
<option key={stage.id} value={stage.id}>
|
|
88
|
+
{stage.label}
|
|
89
|
+
</option>
|
|
90
|
+
))}
|
|
91
|
+
</select>
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
{/* Notes */}
|
|
95
|
+
<div className="space-y-1.5">
|
|
96
|
+
<label htmlFor="transition-notes" className="text-sm font-medium text-foreground">
|
|
97
|
+
Notes <span className="text-muted-foreground">(optional)</span>
|
|
98
|
+
</label>
|
|
99
|
+
<textarea
|
|
100
|
+
id="transition-notes"
|
|
101
|
+
value={notes}
|
|
102
|
+
onChange={(e) => setNotes(e.target.value)}
|
|
103
|
+
placeholder="Add notes about this transition..."
|
|
104
|
+
rows={3}
|
|
105
|
+
disabled={isSubmitting}
|
|
106
|
+
className={cn(
|
|
107
|
+
'w-full rounded-md border bg-background px-3 py-2 text-sm resize-none',
|
|
108
|
+
'placeholder:text-muted-foreground',
|
|
109
|
+
'focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-1',
|
|
110
|
+
'disabled:opacity-50 disabled:cursor-not-allowed',
|
|
111
|
+
)}
|
|
112
|
+
/>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
{/* Actions */}
|
|
116
|
+
<div className="flex justify-end gap-2 pt-2">
|
|
117
|
+
<button
|
|
118
|
+
type="button"
|
|
119
|
+
onClick={onClose}
|
|
120
|
+
disabled={isSubmitting}
|
|
121
|
+
className={cn(
|
|
122
|
+
'rounded-md border px-4 py-2 text-sm font-medium',
|
|
123
|
+
'hover:bg-muted transition-colors',
|
|
124
|
+
'disabled:opacity-50 disabled:cursor-not-allowed',
|
|
125
|
+
)}
|
|
126
|
+
>
|
|
127
|
+
Cancel
|
|
128
|
+
</button>
|
|
129
|
+
<button
|
|
130
|
+
type="button"
|
|
131
|
+
onClick={handleConfirm}
|
|
132
|
+
disabled={isSubmitting || !targetStage}
|
|
133
|
+
className={cn(
|
|
134
|
+
'rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
|
|
135
|
+
'hover:bg-primary/90 transition-colors',
|
|
136
|
+
'disabled:opacity-50 disabled:cursor-not-allowed',
|
|
137
|
+
)}
|
|
138
|
+
>
|
|
139
|
+
{isSubmitting ? 'Moving...' : 'Confirm'}
|
|
140
|
+
</button>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
)
|
|
146
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
|
2
|
+
import { StageTransitionModal } from '../StageTransitionModal'
|
|
3
|
+
|
|
4
|
+
const stages = [
|
|
5
|
+
{ id: 'prospect', label: 'Prospect' },
|
|
6
|
+
{ id: 'qualified', label: 'Qualified' },
|
|
7
|
+
{ id: 'proposal', label: 'Proposal' },
|
|
8
|
+
{ id: 'closed', label: 'Closed Won' },
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
const baseProps = {
|
|
12
|
+
open: true,
|
|
13
|
+
onClose: jest.fn(),
|
|
14
|
+
onConfirm: jest.fn(),
|
|
15
|
+
currentStage: 'prospect',
|
|
16
|
+
stages,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('StageTransitionModal', () => {
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
jest.clearAllMocks()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('renders nothing when open is false', () => {
|
|
25
|
+
render(<StageTransitionModal {...baseProps} open={false} />)
|
|
26
|
+
expect(screen.queryByText(/move/i)).not.toBeInTheDocument()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('renders dialog when open is true', () => {
|
|
30
|
+
render(<StageTransitionModal {...baseProps} />)
|
|
31
|
+
expect(screen.getByText('Move Item to Stage')).toBeInTheDocument()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('includes entityName in dialog title when provided', () => {
|
|
35
|
+
render(<StageTransitionModal {...baseProps} entityName="ACME Deal" />)
|
|
36
|
+
expect(screen.getByText('Move "ACME Deal" to Stage')).toBeInTheDocument()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('shows the current stage label', () => {
|
|
40
|
+
render(<StageTransitionModal {...baseProps} />)
|
|
41
|
+
expect(screen.getByText('Prospect')).toBeInTheDocument()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('excludes the current stage from the new stage dropdown', () => {
|
|
45
|
+
render(<StageTransitionModal {...baseProps} />)
|
|
46
|
+
const options = screen.getAllByRole('option')
|
|
47
|
+
const optionValues = options.map((o) => (o as HTMLOptionElement).value)
|
|
48
|
+
expect(optionValues).not.toContain('prospect')
|
|
49
|
+
expect(optionValues).toContain('qualified')
|
|
50
|
+
expect(optionValues).toContain('proposal')
|
|
51
|
+
expect(optionValues).toContain('closed')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('defaults to the first non-current stage', () => {
|
|
55
|
+
render(<StageTransitionModal {...baseProps} />)
|
|
56
|
+
const select = screen.getByLabelText('New Stage') as HTMLSelectElement
|
|
57
|
+
expect(select.value).toBe('qualified')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('calls onConfirm with selected stage when Confirm is clicked', () => {
|
|
61
|
+
const onConfirm = jest.fn()
|
|
62
|
+
render(<StageTransitionModal {...baseProps} onConfirm={onConfirm} />)
|
|
63
|
+
fireEvent.click(screen.getByRole('button', { name: /confirm/i }))
|
|
64
|
+
expect(onConfirm).toHaveBeenCalledWith('qualified', undefined)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('passes notes to onConfirm when notes are entered', () => {
|
|
68
|
+
const onConfirm = jest.fn()
|
|
69
|
+
render(<StageTransitionModal {...baseProps} onConfirm={onConfirm} />)
|
|
70
|
+
|
|
71
|
+
fireEvent.change(screen.getByLabelText(/notes/i), {
|
|
72
|
+
target: { value: 'Moving due to demo completed' },
|
|
73
|
+
})
|
|
74
|
+
fireEvent.click(screen.getByRole('button', { name: /confirm/i }))
|
|
75
|
+
|
|
76
|
+
expect(onConfirm).toHaveBeenCalledWith('qualified', 'Moving due to demo completed')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('passes undefined for whitespace-only notes', () => {
|
|
80
|
+
const onConfirm = jest.fn()
|
|
81
|
+
render(<StageTransitionModal {...baseProps} onConfirm={onConfirm} />)
|
|
82
|
+
|
|
83
|
+
fireEvent.change(screen.getByLabelText(/notes/i), { target: { value: ' ' } })
|
|
84
|
+
fireEvent.click(screen.getByRole('button', { name: /confirm/i }))
|
|
85
|
+
|
|
86
|
+
expect(onConfirm).toHaveBeenCalledWith('qualified', undefined)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('calls onClose when Cancel is clicked', () => {
|
|
90
|
+
const onClose = jest.fn()
|
|
91
|
+
render(<StageTransitionModal {...baseProps} onClose={onClose} />)
|
|
92
|
+
fireEvent.click(screen.getByRole('button', { name: /cancel/i }))
|
|
93
|
+
expect(onClose).toHaveBeenCalledTimes(1)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('calls onClose when backdrop is clicked', () => {
|
|
97
|
+
const onClose = jest.fn()
|
|
98
|
+
const { container } = render(
|
|
99
|
+
<StageTransitionModal {...baseProps} onClose={onClose} />
|
|
100
|
+
)
|
|
101
|
+
const backdrop = container.querySelector('[aria-hidden="true"]')
|
|
102
|
+
fireEvent.click(backdrop!)
|
|
103
|
+
expect(onClose).toHaveBeenCalledTimes(1)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('does not call onClose when backdrop clicked while submitting', () => {
|
|
107
|
+
const onClose = jest.fn()
|
|
108
|
+
const { container } = render(
|
|
109
|
+
<StageTransitionModal {...baseProps} onClose={onClose} isSubmitting />
|
|
110
|
+
)
|
|
111
|
+
const backdrop = container.querySelector('[aria-hidden="true"]')
|
|
112
|
+
fireEvent.click(backdrop!)
|
|
113
|
+
expect(onClose).not.toHaveBeenCalled()
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('shows Moving... when isSubmitting', () => {
|
|
117
|
+
render(<StageTransitionModal {...baseProps} isSubmitting />)
|
|
118
|
+
expect(screen.getByText('Moving...')).toBeInTheDocument()
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('disables select and textarea when isSubmitting', () => {
|
|
122
|
+
render(<StageTransitionModal {...baseProps} isSubmitting />)
|
|
123
|
+
expect(screen.getByLabelText('New Stage')).toBeDisabled()
|
|
124
|
+
expect(screen.getByLabelText(/notes/i)).toBeDisabled()
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('disables Cancel when isSubmitting', () => {
|
|
128
|
+
render(<StageTransitionModal {...baseProps} isSubmitting />)
|
|
129
|
+
expect(screen.getByRole('button', { name: /cancel/i })).toBeDisabled()
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('Confirm button is disabled while submitting', () => {
|
|
133
|
+
render(<StageTransitionModal {...baseProps} isSubmitting />)
|
|
134
|
+
expect(screen.getByText('Moving...').closest('button')).toBeDisabled()
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('resets notes when dialog reopens', () => {
|
|
138
|
+
const { rerender } = render(
|
|
139
|
+
<StageTransitionModal {...baseProps} open={false} />
|
|
140
|
+
)
|
|
141
|
+
rerender(<StageTransitionModal {...baseProps} open />)
|
|
142
|
+
expect(screen.getByLabelText(/notes/i)).toHaveValue('')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('allows changing the target stage via the select', () => {
|
|
146
|
+
render(<StageTransitionModal {...baseProps} />)
|
|
147
|
+
const select = screen.getByLabelText('New Stage') as HTMLSelectElement
|
|
148
|
+
fireEvent.change(select, { target: { value: 'closed' } })
|
|
149
|
+
expect(select.value).toBe('closed')
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('calls onConfirm with the newly selected stage', () => {
|
|
153
|
+
const onConfirm = jest.fn()
|
|
154
|
+
render(<StageTransitionModal {...baseProps} onConfirm={onConfirm} />)
|
|
155
|
+
fireEvent.change(screen.getByLabelText('New Stage'), { target: { value: 'closed' } })
|
|
156
|
+
fireEvent.click(screen.getByRole('button', { name: /confirm/i }))
|
|
157
|
+
expect(onConfirm).toHaveBeenCalledWith('closed', undefined)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('renders Current Stage label', () => {
|
|
161
|
+
render(<StageTransitionModal {...baseProps} />)
|
|
162
|
+
expect(screen.getByText('Current Stage')).toBeInTheDocument()
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('renders New Stage label', () => {
|
|
166
|
+
render(<StageTransitionModal {...baseProps} />)
|
|
167
|
+
expect(screen.getByText('New Stage')).toBeInTheDocument()
|
|
168
|
+
})
|
|
169
|
+
})
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
|
|
3
|
+
export interface SettingsCardProps {
|
|
4
|
+
title: string
|
|
5
|
+
description?: string
|
|
6
|
+
icon?: React.ElementType
|
|
7
|
+
action?: React.ReactNode
|
|
8
|
+
children?: React.ReactNode
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function SettingsCard({ title, description, icon: Icon, action, children }: SettingsCardProps) {
|
|
12
|
+
return (
|
|
13
|
+
<div className="rounded-xl border bg-white text-gray-900 shadow-sm">
|
|
14
|
+
<div className="flex items-start justify-between p-6">
|
|
15
|
+
<div className="flex items-start gap-4">
|
|
16
|
+
{Icon && (
|
|
17
|
+
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100">
|
|
18
|
+
<Icon className="h-5 w-5 text-gray-600" />
|
|
19
|
+
</div>
|
|
20
|
+
)}
|
|
21
|
+
<div>
|
|
22
|
+
<h3 className="font-semibold leading-none tracking-tight">{title}</h3>
|
|
23
|
+
{description && (
|
|
24
|
+
<p className="text-sm text-gray-500 mt-1.5">{description}</p>
|
|
25
|
+
)}
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
{action && <div className="flex-shrink-0 ml-4">{action}</div>}
|
|
29
|
+
</div>
|
|
30
|
+
{children && <div className="px-6 pb-6 pt-0">{children}</div>}
|
|
31
|
+
</div>
|
|
32
|
+
)
|
|
33
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
|
|
3
|
+
export interface SettingsNavItem {
|
|
4
|
+
id: string
|
|
5
|
+
label: string
|
|
6
|
+
href: string
|
|
7
|
+
icon?: React.ElementType
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface SettingsLayoutProps {
|
|
11
|
+
children: React.ReactNode
|
|
12
|
+
title?: string
|
|
13
|
+
description?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function SettingsLayout({ children, title, description }: SettingsLayoutProps) {
|
|
17
|
+
return (
|
|
18
|
+
<div className="space-y-6 max-w-4xl">
|
|
19
|
+
{title && (
|
|
20
|
+
<div>
|
|
21
|
+
<h1 className="text-3xl font-bold text-gray-900">{title}</h1>
|
|
22
|
+
{description && <p className="text-gray-600 mt-1">{description}</p>}
|
|
23
|
+
</div>
|
|
24
|
+
)}
|
|
25
|
+
{children}
|
|
26
|
+
</div>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
|
|
5
|
+
export interface SettingsNavItem {
|
|
6
|
+
id: string
|
|
7
|
+
label: string
|
|
8
|
+
href: string
|
|
9
|
+
icon?: React.ElementType
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface SettingsNavProps {
|
|
13
|
+
items: SettingsNavItem[]
|
|
14
|
+
activeId?: string
|
|
15
|
+
onNavigate: (href: string) => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function SettingsNav({ items, activeId, onNavigate }: SettingsNavProps) {
|
|
19
|
+
return (
|
|
20
|
+
<nav className="flex flex-col gap-1">
|
|
21
|
+
{items.map((item) => {
|
|
22
|
+
const isActive = item.id === activeId
|
|
23
|
+
const Icon = item.icon
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<button
|
|
27
|
+
key={item.id}
|
|
28
|
+
onClick={() => onNavigate(item.href)}
|
|
29
|
+
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors text-left ${
|
|
30
|
+
isActive
|
|
31
|
+
? 'bg-gray-100 text-gray-900'
|
|
32
|
+
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
|
|
33
|
+
}`}
|
|
34
|
+
>
|
|
35
|
+
{Icon && <Icon className="w-4 h-4 flex-shrink-0" />}
|
|
36
|
+
{item.label}
|
|
37
|
+
</button>
|
|
38
|
+
)
|
|
39
|
+
})}
|
|
40
|
+
</nav>
|
|
41
|
+
)
|
|
42
|
+
}
|