@dilipod/ui 0.4.2 → 0.4.3
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/dist/components/button.d.ts +4 -0
- package/dist/components/button.d.ts.map +1 -1
- package/dist/components/scenarios-manager.d.ts +4 -1
- package/dist/components/scenarios-manager.d.ts.map +1 -1
- package/dist/index.js +256 -196
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +251 -191
- package/dist/index.mjs.map +1 -1
- package/package.json +12 -2
- package/src/__tests__/button.test.tsx +171 -0
- package/src/__tests__/scenarios-manager.test.tsx +260 -0
- package/src/__tests__/setup.ts +31 -0
- package/src/components/button.tsx +50 -25
- package/src/components/scenarios-manager.tsx +83 -26
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dilipod/ui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
4
4
|
"description": "Dilipod Design System - Shared UI components and styles",
|
|
5
5
|
"author": "Dilipod <hello@dilipod.com>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -46,6 +46,9 @@
|
|
|
46
46
|
"build": "tsup && tsc --emitDeclarationOnly --declaration --declarationMap",
|
|
47
47
|
"dev": "tsup --watch",
|
|
48
48
|
"lint": "eslint src/",
|
|
49
|
+
"test": "vitest",
|
|
50
|
+
"test:run": "vitest run",
|
|
51
|
+
"test:coverage": "vitest run --coverage",
|
|
49
52
|
"storybook": "storybook dev -p 6006",
|
|
50
53
|
"build-storybook": "storybook build",
|
|
51
54
|
"clean": "rm -rf dist",
|
|
@@ -89,9 +92,14 @@
|
|
|
89
92
|
"@storybook/react": "8.6.14",
|
|
90
93
|
"@storybook/react-vite": "8.6.14",
|
|
91
94
|
"@storybook/test": "8.6.14",
|
|
95
|
+
"@testing-library/jest-dom": "^6.0.0",
|
|
96
|
+
"@testing-library/react": "^16.0.0",
|
|
97
|
+
"@testing-library/user-event": "^14.0.0",
|
|
92
98
|
"@types/react": "^19.0.0",
|
|
93
99
|
"@types/react-dom": "^19.0.0",
|
|
100
|
+
"@vitejs/plugin-react": "^4.2.0",
|
|
94
101
|
"autoprefixer": "^10.4.21",
|
|
102
|
+
"jsdom": "^23.0.0",
|
|
95
103
|
"playwright": "^1.57.0",
|
|
96
104
|
"postcss": "^8.5.4",
|
|
97
105
|
"react": "^19.0.0",
|
|
@@ -99,6 +107,8 @@
|
|
|
99
107
|
"storybook": "8.6.14",
|
|
100
108
|
"tailwindcss": "^4.1.8",
|
|
101
109
|
"tsup": "^8.5.0",
|
|
102
|
-
"typescript": "^5.8.3"
|
|
110
|
+
"typescript": "^5.8.3",
|
|
111
|
+
"vite": "^7.3.1",
|
|
112
|
+
"vitest": "^1.2.0"
|
|
103
113
|
}
|
|
104
114
|
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
3
|
+
import { Button, buttonVariants } from '../components/button'
|
|
4
|
+
|
|
5
|
+
describe('Button', () => {
|
|
6
|
+
describe('Rendering', () => {
|
|
7
|
+
it('should render button with text', () => {
|
|
8
|
+
render(<Button>Click me</Button>)
|
|
9
|
+
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument()
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('should support custom HTML attributes', () => {
|
|
13
|
+
render(<Button data-testid="custom-button">Custom</Button>)
|
|
14
|
+
expect(screen.getByTestId('custom-button')).toBeInTheDocument()
|
|
15
|
+
})
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
describe('Variants', () => {
|
|
19
|
+
it('should apply default variant styles', () => {
|
|
20
|
+
render(<Button>Default</Button>)
|
|
21
|
+
const button = screen.getByRole('button')
|
|
22
|
+
expect(button.className).toContain('bg-')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('should apply outline variant styles', () => {
|
|
26
|
+
render(<Button variant="outline">Outline</Button>)
|
|
27
|
+
const button = screen.getByRole('button')
|
|
28
|
+
expect(button.className).toContain('border')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('should apply ghost variant styles', () => {
|
|
32
|
+
render(<Button variant="ghost">Ghost</Button>)
|
|
33
|
+
const button = screen.getByRole('button')
|
|
34
|
+
expect(button.className).toContain('hover:bg-')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('should apply destructive variant styles', () => {
|
|
38
|
+
render(<Button variant="destructive">Delete</Button>)
|
|
39
|
+
const button = screen.getByRole('button')
|
|
40
|
+
// Destructive uses red colors
|
|
41
|
+
expect(button.className).toContain('red')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('should apply link variant styles', () => {
|
|
45
|
+
render(<Button variant="link">Link</Button>)
|
|
46
|
+
const button = screen.getByRole('button')
|
|
47
|
+
expect(button.className).toContain('underline')
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('Sizes', () => {
|
|
52
|
+
it('should apply default size', () => {
|
|
53
|
+
render(<Button>Default Size</Button>)
|
|
54
|
+
const button = screen.getByRole('button')
|
|
55
|
+
expect(button.className).toContain('h-')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('should apply small size', () => {
|
|
59
|
+
render(<Button size="sm">Small</Button>)
|
|
60
|
+
const button = screen.getByRole('button')
|
|
61
|
+
expect(button.className).toContain('h-')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('should apply large size', () => {
|
|
65
|
+
render(<Button size="lg">Large</Button>)
|
|
66
|
+
const button = screen.getByRole('button')
|
|
67
|
+
expect(button.className).toContain('h-')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('should apply icon size', () => {
|
|
71
|
+
render(<Button size="icon">🔍</Button>)
|
|
72
|
+
const button = screen.getByRole('button')
|
|
73
|
+
expect(button.className).toContain('w-')
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('States', () => {
|
|
78
|
+
it('should be disabled when disabled prop is true', () => {
|
|
79
|
+
render(<Button disabled>Disabled</Button>)
|
|
80
|
+
expect(screen.getByRole('button')).toBeDisabled()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('should show loading state', () => {
|
|
84
|
+
render(<Button loading>Loading</Button>)
|
|
85
|
+
const button = screen.getByRole('button')
|
|
86
|
+
expect(button).toBeDisabled()
|
|
87
|
+
// Should have loading spinner
|
|
88
|
+
expect(button.querySelector('.animate-spin')).toBeInTheDocument()
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('should not be clickable when loading', () => {
|
|
92
|
+
const onClick = vi.fn()
|
|
93
|
+
render(<Button loading onClick={onClick}>Loading</Button>)
|
|
94
|
+
|
|
95
|
+
fireEvent.click(screen.getByRole('button'))
|
|
96
|
+
expect(onClick).not.toHaveBeenCalled()
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
describe('Events', () => {
|
|
101
|
+
it('should call onClick when clicked', () => {
|
|
102
|
+
const onClick = vi.fn()
|
|
103
|
+
render(<Button onClick={onClick}>Click</Button>)
|
|
104
|
+
|
|
105
|
+
fireEvent.click(screen.getByRole('button'))
|
|
106
|
+
expect(onClick).toHaveBeenCalledTimes(1)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('should not call onClick when disabled', () => {
|
|
110
|
+
const onClick = vi.fn()
|
|
111
|
+
render(<Button disabled onClick={onClick}>Disabled</Button>)
|
|
112
|
+
|
|
113
|
+
fireEvent.click(screen.getByRole('button'))
|
|
114
|
+
expect(onClick).not.toHaveBeenCalled()
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
describe('Custom className', () => {
|
|
119
|
+
it('should merge custom className with default styles', () => {
|
|
120
|
+
render(<Button className="custom-class">Custom</Button>)
|
|
121
|
+
const button = screen.getByRole('button')
|
|
122
|
+
expect(button.className).toContain('custom-class')
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
describe('Button type', () => {
|
|
127
|
+
it('should support type="submit"', () => {
|
|
128
|
+
render(<Button type="submit">Submit</Button>)
|
|
129
|
+
expect(screen.getByRole('button')).toHaveAttribute('type', 'submit')
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('should support type="button" explicitly', () => {
|
|
133
|
+
render(<Button type="button">Button</Button>)
|
|
134
|
+
expect(screen.getByRole('button')).toHaveAttribute('type', 'button')
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
describe('buttonVariants helper', () => {
|
|
139
|
+
it('should generate correct class names', () => {
|
|
140
|
+
const classes = buttonVariants({ variant: 'default', size: 'default' })
|
|
141
|
+
expect(typeof classes).toBe('string')
|
|
142
|
+
expect(classes.length).toBeGreaterThan(0)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('should handle different combinations', () => {
|
|
146
|
+
const combinations = [
|
|
147
|
+
{ variant: 'default' as const, size: 'sm' as const },
|
|
148
|
+
{ variant: 'outline' as const, size: 'lg' as const },
|
|
149
|
+
{ variant: 'ghost' as const, size: 'icon' as const },
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
combinations.forEach(combo => {
|
|
153
|
+
const classes = buttonVariants(combo)
|
|
154
|
+
expect(typeof classes).toBe('string')
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
describe('Accessibility', () => {
|
|
160
|
+
it('should support aria-label', () => {
|
|
161
|
+
render(<Button aria-label="Close dialog">×</Button>)
|
|
162
|
+
expect(screen.getByRole('button', { name: 'Close dialog' })).toBeInTheDocument()
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('should support aria-disabled when disabled', () => {
|
|
166
|
+
render(<Button disabled>Disabled</Button>)
|
|
167
|
+
const button = screen.getByRole('button')
|
|
168
|
+
expect(button).toHaveAttribute('disabled')
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
})
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
|
3
|
+
import userEvent from '@testing-library/user-event'
|
|
4
|
+
import { ScenariosManager, type Scenario, type ScenarioType } from '../components/scenarios-manager'
|
|
5
|
+
|
|
6
|
+
describe('ScenariosManager', () => {
|
|
7
|
+
const mockOnAdd = vi.fn()
|
|
8
|
+
const mockOnUpdate = vi.fn()
|
|
9
|
+
const mockOnDelete = vi.fn()
|
|
10
|
+
const mockOnComplete = vi.fn()
|
|
11
|
+
|
|
12
|
+
const defaultProps = {
|
|
13
|
+
scenarios: [],
|
|
14
|
+
onAdd: mockOnAdd,
|
|
15
|
+
onUpdate: mockOnUpdate,
|
|
16
|
+
onDelete: mockOnDelete,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const mockScenarios: Scenario[] = [
|
|
20
|
+
{
|
|
21
|
+
id: '1',
|
|
22
|
+
type: 'default_behavior' as ScenarioType,
|
|
23
|
+
situation: 'Invoice amount is over $10,000',
|
|
24
|
+
action: 'Flag for manual review',
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
id: '2',
|
|
28
|
+
type: 'escalation' as ScenarioType,
|
|
29
|
+
situation: 'Email is marked as spam',
|
|
30
|
+
action: 'Ignore and archive',
|
|
31
|
+
},
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
vi.clearAllMocks()
|
|
36
|
+
mockOnAdd.mockResolvedValue(undefined)
|
|
37
|
+
mockOnUpdate.mockResolvedValue(undefined)
|
|
38
|
+
mockOnDelete.mockResolvedValue(undefined)
|
|
39
|
+
mockOnComplete.mockResolvedValue(undefined)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('Empty State', () => {
|
|
43
|
+
it('should show empty state when no scenarios exist', () => {
|
|
44
|
+
render(<ScenariosManager {...defaultProps} />)
|
|
45
|
+
|
|
46
|
+
expect(screen.getByText('No scenarios yet. Add rules for how the worker should handle edge cases.')).toBeInTheDocument()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('should show "Add your first scenario" button in empty state', () => {
|
|
50
|
+
render(<ScenariosManager {...defaultProps} />)
|
|
51
|
+
|
|
52
|
+
expect(screen.getByText('Add your first scenario')).toBeInTheDocument()
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('Displaying Scenarios', () => {
|
|
57
|
+
it('should render list of scenarios', () => {
|
|
58
|
+
render(<ScenariosManager {...defaultProps} scenarios={mockScenarios} />)
|
|
59
|
+
|
|
60
|
+
expect(screen.getByText('Invoice amount is over $10,000')).toBeInTheDocument()
|
|
61
|
+
expect(screen.getByText('Email is marked as spam')).toBeInTheDocument()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('should show scenario type badges', () => {
|
|
65
|
+
render(<ScenariosManager {...defaultProps} scenarios={mockScenarios} />)
|
|
66
|
+
|
|
67
|
+
expect(screen.getByText('Handle it')).toBeInTheDocument()
|
|
68
|
+
expect(screen.getByText('Ask me first')).toBeInTheDocument()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('should display When and Action for each scenario', () => {
|
|
72
|
+
render(<ScenariosManager {...defaultProps} scenarios={mockScenarios} />)
|
|
73
|
+
|
|
74
|
+
const whenLabels = screen.getAllByText(/When:/)
|
|
75
|
+
const actionLabels = screen.getAllByText(/Action:/)
|
|
76
|
+
expect(whenLabels.length).toBe(2)
|
|
77
|
+
expect(actionLabels.length).toBe(2)
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('Adding Scenarios', () => {
|
|
82
|
+
it('should show add scenario modal when clicking add button', async () => {
|
|
83
|
+
render(<ScenariosManager {...defaultProps} />)
|
|
84
|
+
|
|
85
|
+
await userEvent.click(screen.getByText('Add your first scenario'))
|
|
86
|
+
|
|
87
|
+
// Modal should appear with a dialog role
|
|
88
|
+
await waitFor(() => {
|
|
89
|
+
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('should close modal when clicking close', async () => {
|
|
94
|
+
render(<ScenariosManager {...defaultProps} />)
|
|
95
|
+
|
|
96
|
+
await userEvent.click(screen.getByText('Add your first scenario'))
|
|
97
|
+
|
|
98
|
+
await waitFor(() => {
|
|
99
|
+
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
// Cancel should close the modal
|
|
103
|
+
const cancelButton = screen.getByText('Cancel')
|
|
104
|
+
await userEvent.click(cancelButton)
|
|
105
|
+
|
|
106
|
+
await waitFor(() => {
|
|
107
|
+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('should open add modal dialog', async () => {
|
|
112
|
+
render(<ScenariosManager {...defaultProps} />)
|
|
113
|
+
|
|
114
|
+
await userEvent.click(screen.getByText('Add your first scenario'))
|
|
115
|
+
|
|
116
|
+
// Should show modal (dialog role)
|
|
117
|
+
await waitFor(() => {
|
|
118
|
+
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
describe('Deleting Scenarios', () => {
|
|
124
|
+
it('should have delete buttons for each scenario', () => {
|
|
125
|
+
render(<ScenariosManager {...defaultProps} scenarios={mockScenarios} />)
|
|
126
|
+
|
|
127
|
+
// Should render multiple buttons (edit + delete for each scenario)
|
|
128
|
+
const allButtons = screen.getAllByRole('button')
|
|
129
|
+
// At minimum: add button + (edit + delete) for each scenario = 1 + 4 = 5
|
|
130
|
+
expect(allButtons.length).toBeGreaterThanOrEqual(4)
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
describe('Completion Feature', () => {
|
|
135
|
+
it('should not show complete button when below minimum scenarios', () => {
|
|
136
|
+
render(
|
|
137
|
+
<ScenariosManager
|
|
138
|
+
{...defaultProps}
|
|
139
|
+
scenarios={[mockScenarios[0]]}
|
|
140
|
+
onComplete={mockOnComplete}
|
|
141
|
+
minScenariosToComplete={2}
|
|
142
|
+
/>
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
expect(screen.queryByText('Mark complete')).not.toBeInTheDocument()
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('should show complete button when minimum scenarios are added', () => {
|
|
149
|
+
render(
|
|
150
|
+
<ScenariosManager
|
|
151
|
+
{...defaultProps}
|
|
152
|
+
scenarios={mockScenarios}
|
|
153
|
+
onComplete={mockOnComplete}
|
|
154
|
+
minScenariosToComplete={2}
|
|
155
|
+
/>
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
expect(screen.getByText('Mark complete')).toBeInTheDocument()
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('should call onComplete when clicking complete button', async () => {
|
|
162
|
+
render(
|
|
163
|
+
<ScenariosManager
|
|
164
|
+
{...defaultProps}
|
|
165
|
+
scenarios={mockScenarios}
|
|
166
|
+
onComplete={mockOnComplete}
|
|
167
|
+
minScenariosToComplete={1}
|
|
168
|
+
/>
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
await userEvent.click(screen.getByText('Mark complete'))
|
|
172
|
+
|
|
173
|
+
await waitFor(() => {
|
|
174
|
+
expect(mockOnComplete).toHaveBeenCalled()
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('should show completed state when isComplete is true', () => {
|
|
179
|
+
render(
|
|
180
|
+
<ScenariosManager
|
|
181
|
+
{...defaultProps}
|
|
182
|
+
scenarios={mockScenarios}
|
|
183
|
+
isComplete={true}
|
|
184
|
+
/>
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
expect(screen.getByText('Scenarios completed')).toBeInTheDocument()
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('should not show complete button when already complete', () => {
|
|
191
|
+
render(
|
|
192
|
+
<ScenariosManager
|
|
193
|
+
{...defaultProps}
|
|
194
|
+
scenarios={mockScenarios}
|
|
195
|
+
onComplete={mockOnComplete}
|
|
196
|
+
isComplete={true}
|
|
197
|
+
minScenariosToComplete={1}
|
|
198
|
+
/>
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
expect(screen.queryByText('Mark complete')).not.toBeInTheDocument()
|
|
202
|
+
})
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
describe('Suggestions', () => {
|
|
206
|
+
const suggestions = [
|
|
207
|
+
{ situation: 'Invoice is missing PO number', type: 'default_behavior' as ScenarioType, action: 'Request PO' },
|
|
208
|
+
{ situation: 'Duplicate invoice detected', type: 'escalation' as ScenarioType, action: 'Flag for review' },
|
|
209
|
+
]
|
|
210
|
+
|
|
211
|
+
it('should display scenario suggestions when provided', () => {
|
|
212
|
+
render(
|
|
213
|
+
<ScenariosManager
|
|
214
|
+
{...defaultProps}
|
|
215
|
+
suggestions={suggestions}
|
|
216
|
+
/>
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
expect(screen.getByText('Invoice is missing PO number')).toBeInTheDocument()
|
|
220
|
+
expect(screen.getByText('Duplicate invoice detected')).toBeInTheDocument()
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('should render suggestion buttons', () => {
|
|
224
|
+
render(
|
|
225
|
+
<ScenariosManager
|
|
226
|
+
{...defaultProps}
|
|
227
|
+
suggestions={suggestions}
|
|
228
|
+
/>
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
// Suggestions should be clickable buttons
|
|
232
|
+
const suggestionButton = screen.getByText('Invoice is missing PO number').closest('button')
|
|
233
|
+
expect(suggestionButton).toBeInTheDocument()
|
|
234
|
+
})
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
describe('Loading State', () => {
|
|
238
|
+
it('should render with isLoading prop', () => {
|
|
239
|
+
render(
|
|
240
|
+
<ScenariosManager
|
|
241
|
+
{...defaultProps}
|
|
242
|
+
isLoading={true}
|
|
243
|
+
/>
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
// Component should still render with loading state
|
|
247
|
+
expect(screen.getByText('Add your first scenario')).toBeInTheDocument()
|
|
248
|
+
})
|
|
249
|
+
})
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
describe('Scenario Type Configuration', () => {
|
|
253
|
+
it('should have all four scenario types', () => {
|
|
254
|
+
const types: ScenarioType[] = ['escalation', 'default_behavior', 'quality_check', 'edge_case']
|
|
255
|
+
|
|
256
|
+
types.forEach(type => {
|
|
257
|
+
expect(types).toContain(type)
|
|
258
|
+
})
|
|
259
|
+
})
|
|
260
|
+
})
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import '@testing-library/jest-dom'
|
|
2
|
+
import { vi } from 'vitest'
|
|
3
|
+
|
|
4
|
+
// Mock ResizeObserver
|
|
5
|
+
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
|
6
|
+
observe: vi.fn(),
|
|
7
|
+
unobserve: vi.fn(),
|
|
8
|
+
disconnect: vi.fn(),
|
|
9
|
+
}))
|
|
10
|
+
|
|
11
|
+
// Mock IntersectionObserver
|
|
12
|
+
global.IntersectionObserver = vi.fn().mockImplementation(() => ({
|
|
13
|
+
observe: vi.fn(),
|
|
14
|
+
unobserve: vi.fn(),
|
|
15
|
+
disconnect: vi.fn(),
|
|
16
|
+
}))
|
|
17
|
+
|
|
18
|
+
// Mock matchMedia
|
|
19
|
+
Object.defineProperty(window, 'matchMedia', {
|
|
20
|
+
writable: true,
|
|
21
|
+
value: vi.fn().mockImplementation((query) => ({
|
|
22
|
+
matches: false,
|
|
23
|
+
media: query,
|
|
24
|
+
onchange: null,
|
|
25
|
+
addListener: vi.fn(),
|
|
26
|
+
removeListener: vi.fn(),
|
|
27
|
+
addEventListener: vi.fn(),
|
|
28
|
+
removeEventListener: vi.fn(),
|
|
29
|
+
dispatchEvent: vi.fn(),
|
|
30
|
+
})),
|
|
31
|
+
})
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import * as React from 'react'
|
|
4
|
-
import { Slot } from '@radix-ui/react-slot'
|
|
4
|
+
import { Slot, Slottable } from '@radix-ui/react-slot'
|
|
5
5
|
import { cva, type VariantProps } from 'class-variance-authority'
|
|
6
6
|
|
|
7
7
|
import { cn } from '../lib/utils'
|
|
@@ -14,7 +14,7 @@ const buttonVariants = cva(
|
|
|
14
14
|
default:
|
|
15
15
|
'bg-[var(--black)] text-white border-2 border-[var(--black)] hover:bg-gray-800 hover:border-gray-800 active:scale-95',
|
|
16
16
|
primary:
|
|
17
|
-
'bg-[var(--cyan)] text-
|
|
17
|
+
'bg-[var(--cyan)] text-white border-2 border-[var(--cyan)] hover:bg-[var(--cyan-dark)] hover:border-[var(--cyan-dark)] active:scale-95',
|
|
18
18
|
destructive:
|
|
19
19
|
'bg-red-600 text-white border-2 border-red-600 hover:bg-red-700 hover:border-red-700 active:scale-95',
|
|
20
20
|
outline:
|
|
@@ -48,13 +48,57 @@ export interface ButtonProps
|
|
|
48
48
|
loading?: boolean
|
|
49
49
|
/** Loading text (defaults to current children text) */
|
|
50
50
|
loadingText?: string
|
|
51
|
+
/** Icon to show before the text */
|
|
52
|
+
icon?: React.ReactNode
|
|
53
|
+
/** Icon to show after the text */
|
|
54
|
+
iconAfter?: React.ReactNode
|
|
51
55
|
}
|
|
52
56
|
|
|
53
57
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
54
|
-
({ className, variant, size, asChild = false, loading, loadingText, children, disabled, ...props }, ref) => {
|
|
58
|
+
({ className, variant, size, asChild = false, loading, loadingText, icon, iconAfter, children, disabled, ...props }, ref) => {
|
|
55
59
|
const Comp = asChild ? Slot : 'button'
|
|
56
60
|
const isDisabled = disabled || loading
|
|
57
61
|
|
|
62
|
+
// Loading spinner component
|
|
63
|
+
const LoadingSpinner = (
|
|
64
|
+
<svg
|
|
65
|
+
className="animate-spin h-4 w-4"
|
|
66
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
67
|
+
fill="none"
|
|
68
|
+
viewBox="0 0 24 24"
|
|
69
|
+
>
|
|
70
|
+
<circle
|
|
71
|
+
className="opacity-25"
|
|
72
|
+
cx="12"
|
|
73
|
+
cy="12"
|
|
74
|
+
r="10"
|
|
75
|
+
stroke="currentColor"
|
|
76
|
+
strokeWidth="4"
|
|
77
|
+
/>
|
|
78
|
+
<path
|
|
79
|
+
className="opacity-75"
|
|
80
|
+
fill="currentColor"
|
|
81
|
+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
82
|
+
/>
|
|
83
|
+
</svg>
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
// When using asChild with icons, we need to use Slottable
|
|
87
|
+
if (asChild && (icon || iconAfter || loading)) {
|
|
88
|
+
return (
|
|
89
|
+
<Slot
|
|
90
|
+
className={cn(buttonVariants({ variant, size, className }))}
|
|
91
|
+
ref={ref}
|
|
92
|
+
{...props}
|
|
93
|
+
>
|
|
94
|
+
{loading && LoadingSpinner}
|
|
95
|
+
{!loading && icon}
|
|
96
|
+
<Slottable>{children}</Slottable>
|
|
97
|
+
{!loading && iconAfter}
|
|
98
|
+
</Slot>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
58
102
|
return (
|
|
59
103
|
<Comp
|
|
60
104
|
className={cn(buttonVariants({ variant, size, className }))}
|
|
@@ -62,29 +106,10 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
|
62
106
|
disabled={isDisabled}
|
|
63
107
|
{...props}
|
|
64
108
|
>
|
|
65
|
-
{loading &&
|
|
66
|
-
|
|
67
|
-
className="animate-spin h-4 w-4"
|
|
68
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
69
|
-
fill="none"
|
|
70
|
-
viewBox="0 0 24 24"
|
|
71
|
-
>
|
|
72
|
-
<circle
|
|
73
|
-
className="opacity-25"
|
|
74
|
-
cx="12"
|
|
75
|
-
cy="12"
|
|
76
|
-
r="10"
|
|
77
|
-
stroke="currentColor"
|
|
78
|
-
strokeWidth="4"
|
|
79
|
-
/>
|
|
80
|
-
<path
|
|
81
|
-
className="opacity-75"
|
|
82
|
-
fill="currentColor"
|
|
83
|
-
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
84
|
-
/>
|
|
85
|
-
</svg>
|
|
86
|
-
)}
|
|
109
|
+
{loading && LoadingSpinner}
|
|
110
|
+
{!loading && icon}
|
|
87
111
|
{loading ? loadingText || children : children}
|
|
112
|
+
{!loading && iconAfter}
|
|
88
113
|
</Comp>
|
|
89
114
|
)
|
|
90
115
|
}
|