Skip to content

Commit b5c7afa

Browse files
committed
feat: refactor dashboard pages to TanStack Query with optimistic updates
1 parent 17959e0 commit b5c7afa

13 files changed

Lines changed: 1070 additions & 292 deletions
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { describe, expect, test } from 'bun:test'
2+
import { readFileSync } from 'fs'
3+
import { join } from 'path'
4+
5+
const COMPONENT_PATH = join(import.meta.dir, 'ApiKeyManager.tsx')
6+
7+
describe('ApiKeyManager component', () => {
8+
const src = readFileSync(COMPONENT_PATH, 'utf-8')
9+
10+
test('is a client component', () => {
11+
expect(src).toMatch(/^['"]use client['"]/)
12+
})
13+
14+
test('uses useApiKeys hook for data fetching', () => {
15+
expect(src).toContain('useApiKeys')
16+
expect(src).toContain("from '@/hooks/use-api-keys'")
17+
expect(src).toMatch(/useApiKeys\(\)/)
18+
})
19+
20+
test('uses useCreateApiKey hook for creating keys', () => {
21+
expect(src).toContain('useCreateApiKey')
22+
expect(src).toMatch(/useCreateApiKey\(\)/)
23+
})
24+
25+
test('uses useRevokeApiKey hook for revoking keys', () => {
26+
expect(src).toContain('useRevokeApiKey')
27+
expect(src).toMatch(/useRevokeApiKey\(\)/)
28+
})
29+
30+
test('does not use useEffect for data fetching', () => {
31+
expect(src).not.toMatch(/useEffect/)
32+
})
33+
34+
test('does not import authClient directly', () => {
35+
expect(src).not.toMatch(/import.*authClient/)
36+
})
37+
38+
test('uses createKey.mutate for key creation', () => {
39+
expect(src).toMatch(/createKey\.mutate\(/)
40+
})
41+
42+
test('uses revokeKey.mutate for key revocation', () => {
43+
expect(src).toMatch(/revokeKey\.mutate\(/)
44+
})
45+
46+
test('uses isPending for loading states', () => {
47+
expect(src).toMatch(/createKey\.isPending/)
48+
expect(src).toMatch(/revokeKey\.isPending/)
49+
})
50+
51+
test('shows new key value after creation', () => {
52+
expect(src).toMatch(/newKeyValue/)
53+
expect(src).toMatch(/dash-key-reveal/)
54+
})
55+
56+
test('renders key table with expected columns', () => {
57+
expect(src).toMatch(/dash-table/)
58+
expect(src).toMatch(/>Name</)
59+
expect(src).toMatch(/>Key</)
60+
expect(src).toMatch(/>Created</)
61+
})
62+
63+
test('renders CopyButton for new key', () => {
64+
expect(src).toMatch(/CopyButton/)
65+
})
66+
67+
test('resets mutation state on toggle', () => {
68+
expect(src).toMatch(/createKey\.reset\(\)/)
69+
})
70+
71+
test('shows errors from query and mutations', () => {
72+
expect(src).toMatch(/\{error &&/)
73+
expect(src).toMatch(/mutationError/)
74+
})
75+
76+
test('does not use console.log', () => {
77+
expect(src).not.toMatch(/console\.log/)
78+
})
79+
})

apps/web/src/components/dashboard/ApiKeyManager.tsx

Lines changed: 47 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -1,97 +1,34 @@
11
'use client'
22

3-
import { useState, useEffect } from 'react'
4-
import { authClient } from '@/lib/auth-client'
3+
import { useState } from 'react'
4+
import { useApiKeys, useCreateApiKey, useRevokeApiKey } from '@/hooks/use-api-keys'
55
import { formatShortDate } from '@/lib/format'
66
import CopyButton from '@/components/ui/CopyButton'
77
import EmptyState from '@/components/ui/EmptyState'
88
import ErrorMessage from '@/components/ui/ErrorMessage'
99

10-
interface ApiKey {
11-
id: string
12-
name: string | null
13-
start: string | null
14-
createdAt: Date
15-
}
16-
1710
export default function ApiKeyManager() {
18-
const [keys, setKeys] = useState<ApiKey[]>([])
19-
const [loading, setLoading] = useState(true)
20-
const [error, setError] = useState('')
21-
const [creating, setCreating] = useState(false)
11+
const { data: keys, isLoading, error } = useApiKeys()
12+
const createKey = useCreateApiKey()
13+
const revokeKey = useRevokeApiKey()
14+
2215
const [newKeyName, setNewKeyName] = useState('')
2316
const [showCreate, setShowCreate] = useState(false)
2417
const [newKeyValue, setNewKeyValue] = useState<string | null>(null)
25-
const [revoking, setRevoking] = useState<Set<string>>(new Set())
26-
27-
useEffect(() => {
28-
loadKeys()
29-
}, [])
3018

31-
async function loadKeys() {
32-
setError('')
33-
try {
34-
const { data, error: authError } = await authClient.apiKey.list()
35-
if (authError) {
36-
setError(authError.message ?? 'Failed to load API keys')
37-
return
38-
}
39-
setKeys(data ?? [])
40-
} catch {
41-
setError('Failed to load API keys')
42-
} finally {
43-
setLoading(false)
44-
}
45-
}
46-
47-
async function handleCreate(e: React.FormEvent<HTMLFormElement>) {
19+
function handleCreate(e: React.FormEvent<HTMLFormElement>) {
4820
e.preventDefault()
49-
setError('')
50-
setCreating(true)
51-
52-
try {
53-
const { data, error: authError } = await authClient.apiKey.create({
54-
name: newKeyName.trim() || undefined,
55-
})
56-
57-
if (authError) {
58-
setError(authError.message ?? 'Failed to create API key')
59-
setCreating(false)
60-
return
61-
}
62-
63-
if (data?.key) {
64-
setNewKeyValue(data.key)
65-
}
66-
67-
setNewKeyName('')
68-
await loadKeys()
69-
} catch {
70-
setError('Failed to create API key')
71-
} finally {
72-
setCreating(false)
73-
}
21+
createKey.mutate(newKeyName.trim() || undefined, {
22+
onSuccess: (data) => {
23+
if (data?.key) {
24+
setNewKeyValue(data.key)
25+
}
26+
setNewKeyName('')
27+
},
28+
})
7429
}
7530

76-
async function handleRevoke(keyId: string) {
77-
setRevoking((prev) => new Set(prev).add(keyId))
78-
try {
79-
const { error: authError } = await authClient.apiKey.delete({ keyId })
80-
if (authError) {
81-
setError(authError.message ?? 'Failed to revoke API key')
82-
return
83-
}
84-
setKeys((prev) => prev.filter((k) => k.id !== keyId))
85-
} catch {
86-
setError('Failed to revoke API key')
87-
} finally {
88-
setRevoking((prev) => {
89-
const next = new Set(prev)
90-
next.delete(keyId)
91-
return next
92-
})
93-
}
94-
}
31+
const mutationError = createKey.error ?? revokeKey.error
9532

9633
return (
9734
<div>
@@ -102,13 +39,27 @@ export default function ApiKeyManager() {
10239
onClick={() => {
10340
setShowCreate(!showCreate)
10441
setNewKeyValue(null)
42+
createKey.reset()
10543
}}
10644
>
10745
{showCreate ? 'Cancel' : 'Create key'}
10846
</button>
10947
</div>
11048

111-
{error && <ErrorMessage message={error} />}
49+
{error && (
50+
<ErrorMessage
51+
message={error instanceof Error ? error.message : 'Failed to load API keys'}
52+
/>
53+
)}
54+
{mutationError && (
55+
<ErrorMessage
56+
message={
57+
mutationError instanceof Error
58+
? mutationError.message
59+
: 'Operation failed'
60+
}
61+
/>
62+
)}
11263

11364
{newKeyValue && (
11465
<div className="dash-key-reveal">
@@ -143,18 +94,22 @@ export default function ApiKeyManager() {
14394
value={newKeyName}
14495
onChange={(e) => setNewKeyName(e.target.value)}
14596
className="dash-input"
146-
disabled={creating}
97+
disabled={createKey.isPending}
14798
autoFocus
14899
/>
149-
<button type="submit" className="dash-primary-btn" disabled={creating}>
150-
{creating ? 'Creating...' : 'Create'}
100+
<button
101+
type="submit"
102+
className="dash-primary-btn"
103+
disabled={createKey.isPending}
104+
>
105+
{createKey.isPending ? 'Creating...' : 'Create'}
151106
</button>
152107
</form>
153108
)}
154109

155-
{loading ? (
110+
{isLoading ? (
156111
<EmptyState message="Loading API keys..." />
157-
) : keys.length === 0 ? (
112+
) : !keys || keys.length === 0 ? (
158113
<EmptyState message="No API keys yet. Create one to authenticate SDK and CLI requests." />
159114
) : (
160115
<div className="dash-table-wrap">
@@ -180,10 +135,14 @@ export default function ApiKeyManager() {
180135
<td>
181136
<button
182137
className="dash-action-btn danger"
183-
onClick={() => handleRevoke(key.id)}
184-
disabled={revoking.has(key.id)}
138+
onClick={() => revokeKey.mutate(key.id)}
139+
disabled={
140+
revokeKey.isPending && revokeKey.variables === key.id
141+
}
185142
>
186-
{revoking.has(key.id) ? 'Revoking...' : 'Revoke'}
143+
{revokeKey.isPending && revokeKey.variables === key.id
144+
? 'Revoking...'
145+
: 'Revoke'}
187146
</button>
188147
</td>
189148
</tr>
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { describe, expect, test } from 'bun:test'
2+
import { readFileSync } from 'fs'
3+
import { join } from 'path'
4+
5+
const COMPONENT_PATH = join(import.meta.dir, 'OrgSettings.tsx')
6+
7+
describe('OrgSettings component', () => {
8+
const src = readFileSync(COMPONENT_PATH, 'utf-8')
9+
10+
test('is a client component', () => {
11+
expect(src).toMatch(/^['"]use client['"]/)
12+
})
13+
14+
test('uses useOrgSettings hook for data fetching', () => {
15+
expect(src).toContain('useOrgSettings')
16+
expect(src).toContain("from '@/hooks/use-org-settings'")
17+
expect(src).toMatch(/useOrgSettings\(\)/)
18+
})
19+
20+
test('uses useUpdateOrgName hook', () => {
21+
expect(src).toContain('useUpdateOrgName')
22+
expect(src).toMatch(/useUpdateOrgName\(\)/)
23+
})
24+
25+
test('uses useInviteMember hook', () => {
26+
expect(src).toContain('useInviteMember')
27+
})
28+
29+
test('uses useRemoveMember hook', () => {
30+
expect(src).toContain('useRemoveMember')
31+
})
32+
33+
test('does not use useEffect for data fetching', () => {
34+
expect(src).not.toMatch(/useEffect/)
35+
})
36+
37+
test('does not import authClient directly', () => {
38+
expect(src).not.toMatch(/import.*authClient/)
39+
})
40+
41+
test('uses mutation.mutate for all operations', () => {
42+
expect(src).toMatch(/updateName\.mutate\(/)
43+
expect(src).toMatch(/invite\.mutate\(/)
44+
expect(src).toMatch(/removeMember\.mutate\(/)
45+
})
46+
47+
test('uses isPending for loading states', () => {
48+
expect(src).toMatch(/updateName\.isPending/)
49+
expect(src).toMatch(/invite\.isPending/)
50+
expect(src).toMatch(/removeMember\.isPending/)
51+
})
52+
53+
test('renders org name form', () => {
54+
expect(src).toMatch(/dash-inline-form/)
55+
expect(src).toMatch(/org-name/)
56+
})
57+
58+
test('renders members table', () => {
59+
expect(src).toMatch(/dash-table/)
60+
expect(src).toMatch(/>Email</)
61+
expect(src).toMatch(/>Name</)
62+
expect(src).toMatch(/>Role</)
63+
})
64+
65+
test('renders invite form', () => {
66+
expect(src).toMatch(/dash-invite-form/)
67+
expect(src).toMatch(/inviteEmail/)
68+
expect(src).toMatch(/inviteRole/)
69+
})
70+
71+
test('shows saved confirmation after update', () => {
72+
expect(src).toMatch(/updateSuccess/)
73+
expect(src).toMatch(/Saved/)
74+
})
75+
76+
test('shows errors from query and mutations', () => {
77+
expect(src).toMatch(/\{error &&/)
78+
expect(src).toMatch(/mutationError/)
79+
})
80+
81+
test('does not use console.log', () => {
82+
expect(src).not.toMatch(/console\.log/)
83+
})
84+
})

0 commit comments

Comments
 (0)