session-wrap-dashboard 3.9.0
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/.env.example +6 -0
- package/coverage/base.css +224 -0
- package/coverage/block-navigation.js +87 -0
- package/coverage/coverage-final.json +6 -0
- package/coverage/favicon.png +0 -0
- package/coverage/index.html +146 -0
- package/coverage/prettify.css +1 -0
- package/coverage/prettify.js +2 -0
- package/coverage/sort-arrow-sprite.png +0 -0
- package/coverage/sorter.js +210 -0
- package/coverage/src/auth.ts.html +307 -0
- package/coverage/src/components/Header.tsx.html +262 -0
- package/coverage/src/components/Sidebar.tsx.html +262 -0
- package/coverage/src/components/index.html +131 -0
- package/coverage/src/hooks/index.html +131 -0
- package/coverage/src/hooks/useAuth.ts.html +292 -0
- package/coverage/src/hooks/useWorkspace.ts.html +475 -0
- package/coverage/src/index.html +116 -0
- package/index.html +13 -0
- package/package.json +49 -0
- package/playwright.config.ts +38 -0
- package/postcss.config.js +6 -0
- package/src/App.tsx +67 -0
- package/src/api.ts +130 -0
- package/src/auth.ts +74 -0
- package/src/components/AgentLeaderboard.tsx +84 -0
- package/src/components/AnalyticsDashboard.tsx +223 -0
- package/src/components/Header.tsx +62 -0
- package/src/components/IntegrationManager.tsx +292 -0
- package/src/components/RoleManager.tsx +230 -0
- package/src/components/Sidebar.tsx +62 -0
- package/src/components/TrendChart.tsx +92 -0
- package/src/components/WorkspaceSelector.tsx +157 -0
- package/src/components/__tests__/Header.test.tsx +64 -0
- package/src/components/__tests__/Sidebar.test.tsx +39 -0
- package/src/components/index.ts +8 -0
- package/src/hooks/__tests__/useAuth.test.ts +88 -0
- package/src/hooks/__tests__/useWorkspace.test.ts +101 -0
- package/src/hooks/index.ts +4 -0
- package/src/hooks/useAnalytics.ts +76 -0
- package/src/hooks/useApi.ts +62 -0
- package/src/hooks/useAuth.ts +69 -0
- package/src/hooks/useWorkspace.ts +130 -0
- package/src/index.css +57 -0
- package/src/main.tsx +13 -0
- package/src/pages/DashboardPage.tsx +13 -0
- package/src/pages/HomePage.tsx +156 -0
- package/src/pages/IntegrationsPage.tsx +9 -0
- package/src/pages/RolesPage.tsx +9 -0
- package/src/pages/SettingsPage.tsx +118 -0
- package/src/pages/WorkspacesPage.tsx +9 -0
- package/src/pages/index.ts +6 -0
- package/src/test/setup.ts +31 -0
- package/src/test/utils.tsx +15 -0
- package/src/types.ts +132 -0
- package/tailwind.config.js +11 -0
- package/tests/e2e/auth.spec.ts +42 -0
- package/tests/e2e/dashboard.spec.ts +52 -0
- package/tests/e2e/integrations.spec.ts +91 -0
- package/tests/e2e/workspaces.spec.ts +78 -0
- package/tsconfig.json +26 -0
- package/tsconfig.node.json +10 -0
- package/vite.config.ts +26 -0
- package/vitest.config.ts +31 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { memo, useMemo } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
LineChart,
|
|
4
|
+
Line,
|
|
5
|
+
XAxis,
|
|
6
|
+
YAxis,
|
|
7
|
+
CartesianGrid,
|
|
8
|
+
Tooltip,
|
|
9
|
+
Legend,
|
|
10
|
+
ResponsiveContainer
|
|
11
|
+
} from 'recharts'
|
|
12
|
+
import { AnalyticsTrend } from '../types'
|
|
13
|
+
|
|
14
|
+
interface TrendChartProps {
|
|
15
|
+
data: AnalyticsTrend[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const TrendChartComponent = ({ data }: TrendChartProps) => {
|
|
19
|
+
if (!data || data.length === 0) {
|
|
20
|
+
return (
|
|
21
|
+
<div className="flex items-center justify-center h-80">
|
|
22
|
+
<p className="text-slate-500">No trend data available</p>
|
|
23
|
+
</div>
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Memoize chart data transformation
|
|
28
|
+
const chartData = useMemo(() =>
|
|
29
|
+
data.map((item) => ({
|
|
30
|
+
date: new Date(item.snapshot_date).toLocaleDateString('en-US', {
|
|
31
|
+
month: 'short',
|
|
32
|
+
day: 'numeric'
|
|
33
|
+
}),
|
|
34
|
+
completed: item.completed_tasks,
|
|
35
|
+
pending: item.pending_tasks,
|
|
36
|
+
inProgress: item.in_progress_tasks,
|
|
37
|
+
decisions: item.total_decisions,
|
|
38
|
+
quality: (item.avg_decision_quality * 100).toFixed(0) // Scale to 0-100 for visibility
|
|
39
|
+
})), [data])
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<ResponsiveContainer width="100%" height={300}>
|
|
43
|
+
<LineChart data={chartData}>
|
|
44
|
+
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
|
45
|
+
<XAxis dataKey="date" stroke="#64748b" />
|
|
46
|
+
<YAxis stroke="#64748b" />
|
|
47
|
+
<Tooltip
|
|
48
|
+
contentStyle={{
|
|
49
|
+
backgroundColor: '#fff',
|
|
50
|
+
border: '1px solid #e2e8f0',
|
|
51
|
+
borderRadius: '0.5rem'
|
|
52
|
+
}}
|
|
53
|
+
/>
|
|
54
|
+
<Legend />
|
|
55
|
+
<Line
|
|
56
|
+
type="monotone"
|
|
57
|
+
dataKey="completed"
|
|
58
|
+
stroke="#10b981"
|
|
59
|
+
strokeWidth={2}
|
|
60
|
+
name="Completed"
|
|
61
|
+
connectNulls
|
|
62
|
+
/>
|
|
63
|
+
<Line
|
|
64
|
+
type="monotone"
|
|
65
|
+
dataKey="inProgress"
|
|
66
|
+
stroke="#3b82f6"
|
|
67
|
+
strokeWidth={2}
|
|
68
|
+
name="In Progress"
|
|
69
|
+
connectNulls
|
|
70
|
+
/>
|
|
71
|
+
<Line
|
|
72
|
+
type="monotone"
|
|
73
|
+
dataKey="pending"
|
|
74
|
+
stroke="#f59e0b"
|
|
75
|
+
strokeWidth={2}
|
|
76
|
+
name="Pending"
|
|
77
|
+
connectNulls
|
|
78
|
+
/>
|
|
79
|
+
<Line
|
|
80
|
+
type="monotone"
|
|
81
|
+
dataKey="quality"
|
|
82
|
+
stroke="#8b5cf6"
|
|
83
|
+
strokeWidth={2}
|
|
84
|
+
name="Decision Quality (%)"
|
|
85
|
+
connectNulls
|
|
86
|
+
/>
|
|
87
|
+
</LineChart>
|
|
88
|
+
</ResponsiveContainer>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const TrendChart = memo(TrendChartComponent)
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { useWorkspace } from '../hooks'
|
|
3
|
+
import { Plus, Lock, Globe } from 'lucide-react'
|
|
4
|
+
|
|
5
|
+
export const WorkspaceSelector = () => {
|
|
6
|
+
const { workspaces, currentWorkspace, setCurrentWorkspace, createWorkspace, isLoading } =
|
|
7
|
+
useWorkspace()
|
|
8
|
+
const [showCreateForm, setShowCreateForm] = useState(false)
|
|
9
|
+
const [formData, setFormData] = useState({ name: '', isPublic: false })
|
|
10
|
+
const [isCreating, setIsCreating] = useState(false)
|
|
11
|
+
const [error, setError] = useState<string | null>(null)
|
|
12
|
+
|
|
13
|
+
const handleCreate = async (e: React.FormEvent) => {
|
|
14
|
+
e.preventDefault()
|
|
15
|
+
if (!formData.name.trim()) {
|
|
16
|
+
setError('Workspace name is required')
|
|
17
|
+
return
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
setIsCreating(true)
|
|
22
|
+
setError(null)
|
|
23
|
+
await createWorkspace(formData.name, formData.isPublic)
|
|
24
|
+
setFormData({ name: '', isPublic: false })
|
|
25
|
+
setShowCreateForm(false)
|
|
26
|
+
} catch (err) {
|
|
27
|
+
setError(err instanceof Error ? err.message : 'Failed to create workspace')
|
|
28
|
+
} finally {
|
|
29
|
+
setIsCreating(false)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (isLoading) {
|
|
34
|
+
return (
|
|
35
|
+
<div className="flex items-center justify-center h-64">
|
|
36
|
+
<div className="loading-spinner"></div>
|
|
37
|
+
</div>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div className="space-y-6">
|
|
43
|
+
<div>
|
|
44
|
+
<h1 className="text-2xl font-bold text-slate-900">Workspaces</h1>
|
|
45
|
+
<p className="text-slate-600 mt-2">Manage your workspaces and collaboration spaces</p>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
{/* Workspaces Grid */}
|
|
49
|
+
<div className="grid grid-cols-3 gap-4">
|
|
50
|
+
{workspaces.map((workspace) => (
|
|
51
|
+
<div
|
|
52
|
+
key={workspace.id}
|
|
53
|
+
onClick={() => setCurrentWorkspace(workspace)}
|
|
54
|
+
className={`card cursor-pointer transition-all ${
|
|
55
|
+
currentWorkspace?.id === workspace.id
|
|
56
|
+
? 'border-blue-500 bg-blue-50'
|
|
57
|
+
: 'hover:border-blue-300'
|
|
58
|
+
}`}
|
|
59
|
+
>
|
|
60
|
+
<div className="flex items-start justify-between mb-3">
|
|
61
|
+
<h3 className="text-lg font-semibold text-slate-900">{workspace.name}</h3>
|
|
62
|
+
<div className="text-slate-500">
|
|
63
|
+
{workspace.is_public ? (
|
|
64
|
+
<Globe size={18} />
|
|
65
|
+
) : (
|
|
66
|
+
<Lock size={18} />
|
|
67
|
+
)}
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
<p className="text-sm text-slate-600">
|
|
71
|
+
{workspace.is_public ? 'Public' : 'Private'} workspace
|
|
72
|
+
</p>
|
|
73
|
+
{workspace.roles && (
|
|
74
|
+
<div className="mt-3 pt-3 border-t border-slate-200">
|
|
75
|
+
<p className="text-xs text-slate-500">
|
|
76
|
+
{workspace.roles.length} role{workspace.roles.length !== 1 ? 's' : ''}
|
|
77
|
+
</p>
|
|
78
|
+
</div>
|
|
79
|
+
)}
|
|
80
|
+
</div>
|
|
81
|
+
))}
|
|
82
|
+
|
|
83
|
+
{/* Create New Workspace Card */}
|
|
84
|
+
<button
|
|
85
|
+
onClick={() => setShowCreateForm(true)}
|
|
86
|
+
className="card border-2 border-dashed border-slate-300 hover:border-blue-400 flex flex-col items-center justify-center gap-2 cursor-pointer transition-colors"
|
|
87
|
+
>
|
|
88
|
+
<Plus size={32} className="text-slate-400" />
|
|
89
|
+
<span className="text-slate-600 font-medium">Create New</span>
|
|
90
|
+
</button>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
{/* Create Form Modal */}
|
|
94
|
+
{showCreateForm && (
|
|
95
|
+
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
|
96
|
+
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
|
|
97
|
+
<h2 className="text-xl font-bold mb-4">Create Workspace</h2>
|
|
98
|
+
|
|
99
|
+
{error && (
|
|
100
|
+
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-2 rounded mb-4">
|
|
101
|
+
{error}
|
|
102
|
+
</div>
|
|
103
|
+
)}
|
|
104
|
+
|
|
105
|
+
<form onSubmit={handleCreate} className="space-y-4">
|
|
106
|
+
<div>
|
|
107
|
+
<label className="block text-sm font-medium text-slate-700 mb-2">
|
|
108
|
+
Workspace Name
|
|
109
|
+
</label>
|
|
110
|
+
<input
|
|
111
|
+
type="text"
|
|
112
|
+
value={formData.name}
|
|
113
|
+
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
114
|
+
className="input w-full"
|
|
115
|
+
placeholder="e.g., Engineering Team"
|
|
116
|
+
disabled={isCreating}
|
|
117
|
+
/>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
<div className="flex items-center gap-2">
|
|
121
|
+
<input
|
|
122
|
+
type="checkbox"
|
|
123
|
+
id="isPublic"
|
|
124
|
+
checked={formData.isPublic}
|
|
125
|
+
onChange={(e) => setFormData({ ...formData, isPublic: e.target.checked })}
|
|
126
|
+
className="cursor-pointer"
|
|
127
|
+
disabled={isCreating}
|
|
128
|
+
/>
|
|
129
|
+
<label htmlFor="isPublic" className="text-sm text-slate-700 cursor-pointer">
|
|
130
|
+
Make this workspace public
|
|
131
|
+
</label>
|
|
132
|
+
</div>
|
|
133
|
+
|
|
134
|
+
<div className="flex gap-3 pt-4">
|
|
135
|
+
<button
|
|
136
|
+
type="submit"
|
|
137
|
+
disabled={isCreating}
|
|
138
|
+
className="btn btn-primary flex-1"
|
|
139
|
+
>
|
|
140
|
+
{isCreating ? 'Creating...' : 'Create'}
|
|
141
|
+
</button>
|
|
142
|
+
<button
|
|
143
|
+
type="button"
|
|
144
|
+
onClick={() => setShowCreateForm(false)}
|
|
145
|
+
disabled={isCreating}
|
|
146
|
+
className="btn btn-secondary flex-1"
|
|
147
|
+
>
|
|
148
|
+
Cancel
|
|
149
|
+
</button>
|
|
150
|
+
</div>
|
|
151
|
+
</form>
|
|
152
|
+
</div>
|
|
153
|
+
</div>
|
|
154
|
+
)}
|
|
155
|
+
</div>
|
|
156
|
+
)
|
|
157
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { screen } from '@testing-library/react'
|
|
3
|
+
import { render } from '../../test/utils'
|
|
4
|
+
import { Header } from '../Header'
|
|
5
|
+
|
|
6
|
+
vi.mock('../../hooks', () => ({
|
|
7
|
+
useAuth: () => ({
|
|
8
|
+
user: {
|
|
9
|
+
id: '1',
|
|
10
|
+
github_login: 'testuser',
|
|
11
|
+
email: 'test@example.com',
|
|
12
|
+
created_at: '2026-01-01'
|
|
13
|
+
},
|
|
14
|
+
logout: vi.fn()
|
|
15
|
+
}),
|
|
16
|
+
useWorkspace: () => ({
|
|
17
|
+
currentWorkspace: {
|
|
18
|
+
id: '1',
|
|
19
|
+
name: 'Test Workspace',
|
|
20
|
+
owner_id: 'user1',
|
|
21
|
+
is_public: false,
|
|
22
|
+
created_at: '2026-01-01',
|
|
23
|
+
updated_at: '2026-01-01'
|
|
24
|
+
},
|
|
25
|
+
workspaces: [
|
|
26
|
+
{
|
|
27
|
+
id: '1',
|
|
28
|
+
name: 'Test Workspace',
|
|
29
|
+
owner_id: 'user1',
|
|
30
|
+
is_public: false,
|
|
31
|
+
created_at: '2026-01-01',
|
|
32
|
+
updated_at: '2026-01-01'
|
|
33
|
+
}
|
|
34
|
+
],
|
|
35
|
+
setCurrentWorkspace: vi.fn()
|
|
36
|
+
})
|
|
37
|
+
}))
|
|
38
|
+
|
|
39
|
+
describe('Header Component', () => {
|
|
40
|
+
it('should render header with title', () => {
|
|
41
|
+
render(<Header title="Test Dashboard" />)
|
|
42
|
+
|
|
43
|
+
expect(screen.getByText('Test Dashboard')).toBeInTheDocument()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('should display user login name', () => {
|
|
47
|
+
render(<Header />)
|
|
48
|
+
|
|
49
|
+
expect(screen.getByText('testuser')).toBeInTheDocument()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('should have logout button', () => {
|
|
53
|
+
render(<Header />)
|
|
54
|
+
|
|
55
|
+
expect(screen.getByText('Logout')).toBeInTheDocument()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('should show workspace selector', () => {
|
|
59
|
+
render(<Header />)
|
|
60
|
+
|
|
61
|
+
const select = screen.getByDisplayValue('Test Workspace')
|
|
62
|
+
expect(select).toBeInTheDocument()
|
|
63
|
+
})
|
|
64
|
+
})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { screen } from '@testing-library/react'
|
|
3
|
+
import { render } from '../../test/utils'
|
|
4
|
+
import { Sidebar } from '../Sidebar'
|
|
5
|
+
|
|
6
|
+
describe('Sidebar Component', () => {
|
|
7
|
+
it('should render sidebar logo', () => {
|
|
8
|
+
render(<Sidebar />)
|
|
9
|
+
|
|
10
|
+
expect(screen.getByText(/Session Wrap/i)).toBeInTheDocument()
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('should render navigation items', () => {
|
|
14
|
+
render(<Sidebar />)
|
|
15
|
+
|
|
16
|
+
expect(screen.getByText('Home')).toBeInTheDocument()
|
|
17
|
+
expect(screen.getByText('Analytics')).toBeInTheDocument()
|
|
18
|
+
expect(screen.getByText('Workspaces')).toBeInTheDocument()
|
|
19
|
+
expect(screen.getByText('Roles & Members')).toBeInTheDocument()
|
|
20
|
+
expect(screen.getByText('Integrations')).toBeInTheDocument()
|
|
21
|
+
expect(screen.getByText('Settings')).toBeInTheDocument()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('should have correct navigation links', () => {
|
|
25
|
+
render(<Sidebar />)
|
|
26
|
+
|
|
27
|
+
const homeLink = screen.getByRole('link', { name: /Home/i })
|
|
28
|
+
expect(homeLink).toHaveAttribute('href', '/')
|
|
29
|
+
|
|
30
|
+
const dashboardLink = screen.getByRole('link', { name: /Analytics/i })
|
|
31
|
+
expect(dashboardLink).toHaveAttribute('href', '/dashboard')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('should display version', () => {
|
|
35
|
+
render(<Sidebar />)
|
|
36
|
+
|
|
37
|
+
expect(screen.getByText('v3.9.0')).toBeInTheDocument()
|
|
38
|
+
})
|
|
39
|
+
})
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { Header } from './Header'
|
|
2
|
+
export { Sidebar } from './Sidebar'
|
|
3
|
+
export { AnalyticsDashboard } from './AnalyticsDashboard'
|
|
4
|
+
export { TrendChart } from './TrendChart'
|
|
5
|
+
export { AgentLeaderboard } from './AgentLeaderboard'
|
|
6
|
+
export { WorkspaceSelector } from './WorkspaceSelector'
|
|
7
|
+
export { RoleManager } from './RoleManager'
|
|
8
|
+
export { IntegrationManager } from './IntegrationManager'
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
2
|
+
import { renderHook, act } from '@testing-library/react'
|
|
3
|
+
import { useAuth } from '../useAuth'
|
|
4
|
+
import * as authService from '../../auth'
|
|
5
|
+
|
|
6
|
+
vi.mock('../../auth')
|
|
7
|
+
vi.mock('../../api', () => ({
|
|
8
|
+
userAPI: {
|
|
9
|
+
getProfile: vi.fn(() =>
|
|
10
|
+
Promise.resolve({
|
|
11
|
+
data: {
|
|
12
|
+
user: {
|
|
13
|
+
id: '1',
|
|
14
|
+
github_login: 'testuser',
|
|
15
|
+
email: 'test@example.com',
|
|
16
|
+
created_at: '2026-01-01'
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
})
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
}))
|
|
23
|
+
|
|
24
|
+
describe('useAuth', () => {
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
vi.clearAllMocks()
|
|
27
|
+
;(authService.initAuth as any).mockResolvedValue({
|
|
28
|
+
token: null,
|
|
29
|
+
isAuthenticated: false
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('should initialize with loading state', () => {
|
|
34
|
+
const { result } = renderHook(() => useAuth())
|
|
35
|
+
|
|
36
|
+
expect(result.current.isLoading).toBe(true)
|
|
37
|
+
expect(result.current.isAuthenticated).toBe(false)
|
|
38
|
+
expect(result.current.user).toBe(null)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('should handle login', async () => {
|
|
42
|
+
;(authService.login as any).mockResolvedValue({
|
|
43
|
+
user: {
|
|
44
|
+
id: '1',
|
|
45
|
+
github_login: 'testuser',
|
|
46
|
+
email: 'test@example.com',
|
|
47
|
+
created_at: '2026-01-01'
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
const { result } = renderHook(() => useAuth())
|
|
52
|
+
|
|
53
|
+
await act(async () => {
|
|
54
|
+
await result.current.login('test-token')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
expect(result.current.isAuthenticated).toBe(true)
|
|
58
|
+
expect(result.current.user?.github_login).toBe('testuser')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('should handle logout', async () => {
|
|
62
|
+
;(authService.logout as any).mockImplementation(() => {})
|
|
63
|
+
|
|
64
|
+
const { result } = renderHook(() => useAuth())
|
|
65
|
+
|
|
66
|
+
await act(async () => {
|
|
67
|
+
result.current.logout()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
expect(authService.logout).toHaveBeenCalled()
|
|
71
|
+
expect(result.current.isAuthenticated).toBe(false)
|
|
72
|
+
expect(result.current.user).toBe(null)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('should catch login errors', async () => {
|
|
76
|
+
;(authService.login as any).mockRejectedValue(
|
|
77
|
+
new Error('Auth failed')
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
const { result } = renderHook(() => useAuth())
|
|
81
|
+
|
|
82
|
+
await expect(
|
|
83
|
+
act(async () => {
|
|
84
|
+
await result.current.login('invalid-token')
|
|
85
|
+
})
|
|
86
|
+
).rejects.toThrow('Auth failed')
|
|
87
|
+
})
|
|
88
|
+
})
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
2
|
+
import { renderHook, act } from '@testing-library/react'
|
|
3
|
+
import { useWorkspace } from '../useWorkspace'
|
|
4
|
+
|
|
5
|
+
vi.mock('../../api', () => ({
|
|
6
|
+
workspaceAPI: {
|
|
7
|
+
list: vi.fn(() =>
|
|
8
|
+
Promise.resolve({
|
|
9
|
+
data: [
|
|
10
|
+
{
|
|
11
|
+
id: '1',
|
|
12
|
+
name: 'Test Workspace',
|
|
13
|
+
owner_id: 'user1',
|
|
14
|
+
is_public: false,
|
|
15
|
+
created_at: '2026-01-01',
|
|
16
|
+
updated_at: '2026-01-01',
|
|
17
|
+
roles: ['admin']
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
})
|
|
21
|
+
),
|
|
22
|
+
getMembers: vi.fn(() =>
|
|
23
|
+
Promise.resolve({
|
|
24
|
+
data: [
|
|
25
|
+
{
|
|
26
|
+
id: 'member1',
|
|
27
|
+
github_login: 'testuser',
|
|
28
|
+
email: 'test@example.com',
|
|
29
|
+
avatar_url: null,
|
|
30
|
+
roles: ['admin']
|
|
31
|
+
}
|
|
32
|
+
]
|
|
33
|
+
})
|
|
34
|
+
),
|
|
35
|
+
addMember: vi.fn(() => Promise.resolve({})),
|
|
36
|
+
updateMember: vi.fn(() => Promise.resolve({})),
|
|
37
|
+
removeMember: vi.fn(() => Promise.resolve({}))
|
|
38
|
+
},
|
|
39
|
+
rbacAPI: {
|
|
40
|
+
getRoles: vi.fn(() =>
|
|
41
|
+
Promise.resolve({
|
|
42
|
+
data: [
|
|
43
|
+
{
|
|
44
|
+
id: '1',
|
|
45
|
+
name: 'admin',
|
|
46
|
+
description: 'Administrator',
|
|
47
|
+
permissions: {}
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
})
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
}))
|
|
54
|
+
|
|
55
|
+
describe('useWorkspace', () => {
|
|
56
|
+
beforeEach(() => {
|
|
57
|
+
vi.clearAllMocks()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('should load workspaces on mount', async () => {
|
|
61
|
+
const { result } = renderHook(() => useWorkspace())
|
|
62
|
+
|
|
63
|
+
expect(result.current.isLoading).toBe(true)
|
|
64
|
+
|
|
65
|
+
// Wait for initial load
|
|
66
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
67
|
+
|
|
68
|
+
expect(result.current.workspaces.length).toBe(1)
|
|
69
|
+
expect(result.current.currentWorkspace?.name).toBe('Test Workspace')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('should set current workspace', async () => {
|
|
73
|
+
const { result } = renderHook(() => useWorkspace())
|
|
74
|
+
|
|
75
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
76
|
+
|
|
77
|
+
const workspace = result.current.workspaces[0]
|
|
78
|
+
|
|
79
|
+
await act(async () => {
|
|
80
|
+
result.current.setCurrentWorkspace(workspace)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
expect(result.current.currentWorkspace).toBe(workspace)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('should load members for current workspace', async () => {
|
|
87
|
+
const { result } = renderHook(() => useWorkspace())
|
|
88
|
+
|
|
89
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
90
|
+
|
|
91
|
+
expect(result.current.members.length).toBe(1)
|
|
92
|
+
expect(result.current.members[0].github_login).toBe('testuser')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('should handle errors gracefully', () => {
|
|
96
|
+
const { result } = renderHook(() => useWorkspace())
|
|
97
|
+
|
|
98
|
+
// Should not throw
|
|
99
|
+
expect(result.current.error).toBe(null)
|
|
100
|
+
})
|
|
101
|
+
})
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from 'react'
|
|
2
|
+
import { analyticsAPI } from '../api'
|
|
3
|
+
import * as types from '../types'
|
|
4
|
+
|
|
5
|
+
interface UseAnalyticsReturn {
|
|
6
|
+
dashboard: types.AnalyticsDashboard | null
|
|
7
|
+
insights: types.Insight[]
|
|
8
|
+
trends: types.AnalyticsTrend[]
|
|
9
|
+
agents: types.AgentPerformance[]
|
|
10
|
+
isLoading: boolean
|
|
11
|
+
error: string | null
|
|
12
|
+
days: number
|
|
13
|
+
setDays: (days: number) => void
|
|
14
|
+
refresh: () => Promise<void>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const useAnalytics = (workspaceId: string | null): UseAnalyticsReturn => {
|
|
18
|
+
const [dashboard, setDashboard] = useState<types.AnalyticsDashboard | null>(null)
|
|
19
|
+
const [insights, setInsights] = useState<types.Insight[]>([])
|
|
20
|
+
const [trends, setTrends] = useState<types.AnalyticsTrend[]>([])
|
|
21
|
+
const [agents, setAgents] = useState<types.AgentPerformance[]>([])
|
|
22
|
+
const [isLoading, setIsLoading] = useState(false)
|
|
23
|
+
const [error, setError] = useState<string | null>(null)
|
|
24
|
+
const [days, setDays] = useState(30)
|
|
25
|
+
|
|
26
|
+
const loadData = useCallback(async () => {
|
|
27
|
+
if (!workspaceId) {
|
|
28
|
+
setDashboard(null)
|
|
29
|
+
setInsights([])
|
|
30
|
+
setTrends([])
|
|
31
|
+
setAgents([])
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
setIsLoading(true)
|
|
37
|
+
setError(null)
|
|
38
|
+
|
|
39
|
+
const [dashboardRes, insightsRes, agentsRes] = await Promise.all([
|
|
40
|
+
analyticsAPI.getDashboard(workspaceId, days),
|
|
41
|
+
analyticsAPI.getInsights(workspaceId, days),
|
|
42
|
+
analyticsAPI.getAgents(workspaceId, days)
|
|
43
|
+
])
|
|
44
|
+
|
|
45
|
+
setDashboard(dashboardRes.data)
|
|
46
|
+
setInsights(insightsRes.data.insights)
|
|
47
|
+
setAgents(agentsRes.data.agents)
|
|
48
|
+
setTrends(dashboardRes.data.trends)
|
|
49
|
+
} catch (err) {
|
|
50
|
+
setError(err instanceof Error ? err.message : 'Failed to load analytics')
|
|
51
|
+
} finally {
|
|
52
|
+
setIsLoading(false)
|
|
53
|
+
}
|
|
54
|
+
}, [workspaceId, days])
|
|
55
|
+
|
|
56
|
+
// Load data when workspace or days changes
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
loadData()
|
|
59
|
+
}, [loadData])
|
|
60
|
+
|
|
61
|
+
const refresh = useCallback(async () => {
|
|
62
|
+
await loadData()
|
|
63
|
+
}, [loadData])
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
dashboard,
|
|
67
|
+
insights,
|
|
68
|
+
trends,
|
|
69
|
+
agents,
|
|
70
|
+
isLoading,
|
|
71
|
+
error,
|
|
72
|
+
days,
|
|
73
|
+
setDays,
|
|
74
|
+
refresh
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { useState, useCallback } from 'react'
|
|
2
|
+
|
|
3
|
+
interface UseApiOptions {
|
|
4
|
+
onSuccess?: (data: any) => void
|
|
5
|
+
onError?: (error: Error) => void
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface UseApiReturn<T> {
|
|
9
|
+
data: T | null
|
|
10
|
+
isLoading: boolean
|
|
11
|
+
error: Error | null
|
|
12
|
+
execute: (...args: any[]) => Promise<T>
|
|
13
|
+
reset: () => void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const useApi = <T,>(
|
|
17
|
+
apiFunction: (...args: any[]) => Promise<any>,
|
|
18
|
+
options?: UseApiOptions
|
|
19
|
+
): UseApiReturn<T> => {
|
|
20
|
+
const [data, setData] = useState<T | null>(null)
|
|
21
|
+
const [isLoading, setIsLoading] = useState(false)
|
|
22
|
+
const [error, setError] = useState<Error | null>(null)
|
|
23
|
+
|
|
24
|
+
const execute = useCallback(
|
|
25
|
+
async (...args: any[]): Promise<T> => {
|
|
26
|
+
try {
|
|
27
|
+
setIsLoading(true)
|
|
28
|
+
setError(null)
|
|
29
|
+
|
|
30
|
+
const response = await apiFunction(...args)
|
|
31
|
+
const result = response.data
|
|
32
|
+
|
|
33
|
+
setData(result)
|
|
34
|
+
options?.onSuccess?.(result)
|
|
35
|
+
|
|
36
|
+
return result
|
|
37
|
+
} catch (err) {
|
|
38
|
+
const error = err instanceof Error ? err : new Error(String(err))
|
|
39
|
+
setError(error)
|
|
40
|
+
options?.onError?.(error)
|
|
41
|
+
throw error
|
|
42
|
+
} finally {
|
|
43
|
+
setIsLoading(false)
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
[apiFunction, options]
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
const reset = useCallback(() => {
|
|
50
|
+
setData(null)
|
|
51
|
+
setError(null)
|
|
52
|
+
setIsLoading(false)
|
|
53
|
+
}, [])
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
data,
|
|
57
|
+
isLoading,
|
|
58
|
+
error,
|
|
59
|
+
execute,
|
|
60
|
+
reset
|
|
61
|
+
}
|
|
62
|
+
}
|