@startsimpli/ui 0.4.7 → 0.4.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/package.json +1 -1
  2. package/src/__mocks__/next/link.js +11 -0
  3. package/src/components/account/__tests__/account.test.tsx +315 -0
  4. package/src/components/command-palette/CommandGroup.tsx +23 -0
  5. package/src/components/command-palette/CommandPalette.tsx +183 -200
  6. package/src/components/command-palette/CommandResultItem.tsx +59 -0
  7. package/src/components/command-palette/__tests__/CommandGroup.test.tsx +81 -0
  8. package/src/components/command-palette/__tests__/CommandResultItem.test.tsx +166 -0
  9. package/src/components/command-palette/__tests__/command-palette-context.test.tsx +166 -0
  10. package/src/components/command-palette/__tests__/useCommandPaletteSearch.test.ts +271 -0
  11. package/src/components/command-palette/index.ts +6 -0
  12. package/src/components/command-palette/useCommandPaletteSearch.ts +114 -0
  13. package/src/components/compose/__tests__/compose.test.tsx +656 -0
  14. package/src/components/dashboard/PipelineFunnel.tsx +126 -0
  15. package/src/components/dashboard/TopCampaigns.tsx +132 -0
  16. package/src/components/dashboard/__tests__/dashboard.test.tsx +785 -0
  17. package/src/components/dashboard/index.ts +6 -0
  18. package/src/components/dialog/ConfirmDialog.tsx +72 -0
  19. package/src/components/dialog/__tests__/ConfirmDialog.test.tsx +126 -0
  20. package/src/components/dialog/index.ts +3 -0
  21. package/src/components/email-dialogs/__tests__/email-dialogs.test.tsx +982 -0
  22. package/src/components/email-editor/BlockRenderer.tsx +120 -0
  23. package/src/components/email-editor/__tests__/BlockRenderer.test.tsx +332 -0
  24. package/src/components/email-editor/__tests__/block-renderers.test.ts +624 -0
  25. package/src/components/email-editor/__tests__/email-html-renderer.test.ts +376 -0
  26. package/src/components/email-editor/blocks/__tests__/blocks.test.tsx +818 -0
  27. package/src/components/email-editor/editor-sidebar.tsx +6 -731
  28. package/src/components/email-editor/email-editor.tsx +78 -467
  29. package/src/components/email-editor/hooks/__tests__/useDragDrop.test.ts +355 -0
  30. package/src/components/email-editor/hooks/__tests__/useEmailEditorState.test.ts +551 -0
  31. package/src/components/email-editor/hooks/useDragDrop.ts +181 -0
  32. package/src/components/email-editor/hooks/useEmailEditorState.ts +426 -0
  33. package/src/components/email-editor/index.ts +1 -0
  34. package/src/components/email-editor/panels/BlockPropertyPanel.tsx +637 -0
  35. package/src/components/email-editor/panels/GlobalStylesPanel.tsx +108 -0
  36. package/src/components/email-editor/panels/SectionSettingsPanel.tsx +80 -0
  37. package/src/components/email-editor/panels/__tests__/BlockPropertyPanel.test.tsx +707 -0
  38. package/src/components/email-editor/panels/__tests__/GlobalStylesPanel.test.tsx +226 -0
  39. package/src/components/email-editor/panels/index.ts +3 -0
  40. package/src/components/enrichment/__tests__/enrichment.test.tsx +184 -0
  41. package/src/components/gantt/GanttBoardView.tsx +71 -0
  42. package/src/components/gantt/GanttChart.tsx +134 -881
  43. package/src/components/gantt/GanttFilterBar.tsx +100 -0
  44. package/src/components/gantt/GanttListView.tsx +63 -0
  45. package/src/components/gantt/GanttTimelineView.tsx +215 -0
  46. package/src/components/gantt/__tests__/GanttBoardView.test.tsx +305 -0
  47. package/src/components/gantt/__tests__/GanttFilterBar.test.tsx +544 -0
  48. package/src/components/gantt/__tests__/GanttListView.test.tsx +337 -0
  49. package/src/components/gantt/__tests__/GanttTimelineView.test.tsx +375 -0
  50. package/src/components/gantt/__tests__/gantt-utils.test.ts +341 -0
  51. package/src/components/gantt/__tests__/useGanttState.test.ts +535 -0
  52. package/src/components/gantt/hooks/useGanttState.ts +644 -0
  53. package/src/components/gantt/index.ts +10 -0
  54. package/src/components/integrations/__tests__/integrations.test.tsx +191 -0
  55. package/src/components/kanban/__tests__/kanban.test.tsx +157 -0
  56. package/src/components/lists/__tests__/lists.test.tsx +263 -0
  57. package/src/components/loading/__tests__/loading.test.tsx +114 -0
  58. package/src/components/navigation/__tests__/navigation.test.tsx +194 -0
  59. package/src/components/pipeline/__tests__/pipeline.test.tsx +169 -0
  60. package/src/components/settings/__tests__/settings.test.tsx +181 -0
  61. package/src/components/wizard/__tests__/wizard.test.tsx +97 -0
@@ -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
+ })
@@ -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,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,181 @@
1
+ import { render, screen, fireEvent } from '@testing-library/react'
2
+ import { SettingsLayout } from '../SettingsLayout'
3
+ import { SettingsNav } from '../SettingsNav'
4
+ import { SettingsCard } from '../SettingsCard'
5
+
6
+ const TestIcon = () => <svg data-testid="settings-icon" />
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // SettingsLayout
10
+ // ---------------------------------------------------------------------------
11
+
12
+ describe('SettingsLayout', () => {
13
+ it('renders children', () => {
14
+ render(
15
+ <SettingsLayout>
16
+ <div>Child content</div>
17
+ </SettingsLayout>
18
+ )
19
+ expect(screen.getByText('Child content')).toBeInTheDocument()
20
+ })
21
+
22
+ it('renders title when provided', () => {
23
+ render(
24
+ <SettingsLayout title="Settings">
25
+ <div />
26
+ </SettingsLayout>
27
+ )
28
+ expect(screen.getByText('Settings')).toBeInTheDocument()
29
+ })
30
+
31
+ it('does not render title element when title is not provided', () => {
32
+ render(
33
+ <SettingsLayout>
34
+ <div />
35
+ </SettingsLayout>
36
+ )
37
+ expect(screen.queryByRole('heading')).not.toBeInTheDocument()
38
+ })
39
+
40
+ it('renders description when both title and description are provided', () => {
41
+ render(
42
+ <SettingsLayout title="Settings" description="Manage your preferences">
43
+ <div />
44
+ </SettingsLayout>
45
+ )
46
+ expect(screen.getByText('Manage your preferences')).toBeInTheDocument()
47
+ })
48
+
49
+ it('does not render description when description is not provided', () => {
50
+ render(
51
+ <SettingsLayout title="Settings">
52
+ <div />
53
+ </SettingsLayout>
54
+ )
55
+ expect(screen.queryByText(/manage/i)).not.toBeInTheDocument()
56
+ })
57
+ })
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // SettingsNav
61
+ // ---------------------------------------------------------------------------
62
+
63
+ const navItems = [
64
+ { id: 'account', label: 'Account', href: '/settings/account' },
65
+ { id: 'billing', label: 'Billing', href: '/settings/billing', icon: TestIcon },
66
+ { id: 'integrations', label: 'Integrations', href: '/settings/integrations' },
67
+ ]
68
+
69
+ describe('SettingsNav', () => {
70
+ it('renders all nav items', () => {
71
+ render(
72
+ <SettingsNav items={navItems} onNavigate={jest.fn()} />
73
+ )
74
+ expect(screen.getByText('Account')).toBeInTheDocument()
75
+ expect(screen.getByText('Billing')).toBeInTheDocument()
76
+ expect(screen.getByText('Integrations')).toBeInTheDocument()
77
+ })
78
+
79
+ it('renders icons for items that have icons', () => {
80
+ render(
81
+ <SettingsNav items={navItems} onNavigate={jest.fn()} />
82
+ )
83
+ expect(screen.getByTestId('settings-icon')).toBeInTheDocument()
84
+ })
85
+
86
+ it('marks active item with active styles', () => {
87
+ render(
88
+ <SettingsNav items={navItems} activeId="account" onNavigate={jest.fn()} />
89
+ )
90
+ const accountBtn = screen.getByRole('button', { name: 'Account' })
91
+ expect(accountBtn).toHaveClass('bg-gray-100', 'text-gray-900')
92
+ })
93
+
94
+ it('inactive items do not have active styles', () => {
95
+ render(
96
+ <SettingsNav items={navItems} activeId="account" onNavigate={jest.fn()} />
97
+ )
98
+ const billingBtn = screen.getByRole('button', { name: 'Billing' })
99
+ expect(billingBtn).not.toHaveClass('bg-gray-100')
100
+ expect(billingBtn).toHaveClass('text-gray-600')
101
+ })
102
+
103
+ it('calls onNavigate with the correct href on click', () => {
104
+ const onNavigate = jest.fn()
105
+ render(
106
+ <SettingsNav items={navItems} onNavigate={onNavigate} />
107
+ )
108
+ fireEvent.click(screen.getByRole('button', { name: 'Billing' }))
109
+ expect(onNavigate).toHaveBeenCalledWith('/settings/billing')
110
+ })
111
+
112
+ it('renders all items as buttons', () => {
113
+ render(
114
+ <SettingsNav items={navItems} onNavigate={jest.fn()} />
115
+ )
116
+ expect(screen.getAllByRole('button')).toHaveLength(navItems.length)
117
+ })
118
+
119
+ it('renders inside a nav element', () => {
120
+ render(
121
+ <SettingsNav items={navItems} onNavigate={jest.fn()} />
122
+ )
123
+ expect(screen.getByRole('navigation')).toBeInTheDocument()
124
+ })
125
+ })
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // SettingsCard
129
+ // ---------------------------------------------------------------------------
130
+
131
+ describe('SettingsCard', () => {
132
+ it('renders the title', () => {
133
+ render(<SettingsCard title="API Keys" />)
134
+ expect(screen.getByText('API Keys')).toBeInTheDocument()
135
+ })
136
+
137
+ it('renders description when provided', () => {
138
+ render(<SettingsCard title="API Keys" description="Manage your API tokens." />)
139
+ expect(screen.getByText('Manage your API tokens.')).toBeInTheDocument()
140
+ })
141
+
142
+ it('does not render description when not provided', () => {
143
+ render(<SettingsCard title="API Keys" />)
144
+ expect(screen.queryByText(/manage/i)).not.toBeInTheDocument()
145
+ })
146
+
147
+ it('renders icon when provided', () => {
148
+ render(<SettingsCard title="API Keys" icon={TestIcon} />)
149
+ expect(screen.getByTestId('settings-icon')).toBeInTheDocument()
150
+ })
151
+
152
+ it('does not render icon when not provided', () => {
153
+ render(<SettingsCard title="API Keys" />)
154
+ expect(screen.queryByTestId('settings-icon')).not.toBeInTheDocument()
155
+ })
156
+
157
+ it('renders action node when provided', () => {
158
+ render(
159
+ <SettingsCard
160
+ title="API Keys"
161
+ action={<button>Add Key</button>}
162
+ />
163
+ )
164
+ expect(screen.getByRole('button', { name: 'Add Key' })).toBeInTheDocument()
165
+ })
166
+
167
+ it('renders children content', () => {
168
+ render(
169
+ <SettingsCard title="API Keys">
170
+ <p>Card body content</p>
171
+ </SettingsCard>
172
+ )
173
+ expect(screen.getByText('Card body content')).toBeInTheDocument()
174
+ })
175
+
176
+ it('does not render children section when children is not provided', () => {
177
+ const { container } = render(<SettingsCard title="API Keys" />)
178
+ // The children container (px-6 pb-6 pt-0) should not be present
179
+ expect(container.querySelector('.px-6.pb-6.pt-0')).not.toBeInTheDocument()
180
+ })
181
+ })