Skip to content

Commit db357d5

Browse files
kiwicoppleclaudealaister
authored
Migrate to shadcn/ui components and react-hook-form (#347)
* Migrate to shadcn/ui components and react-hook-form This PR completes the migration to shadcn/ui components and modernizes the form handling system. ## Component Migrations - **Toast System**: Replace react-hot-toast with shadcn toast - Add toast.success() and toast.error() helper methods - Update all 8 files using toasts - **Search**: Refactor to use shadcn Popover + cmdk Command - Add keyboard navigation and accessibility - Better semantic structure - **Cards**: Convert PackageCard and AccessTokenCard to shadcn Card - **Buttons**: Convert ThemeSwitcher and Edit page to shadcn Button - **Forms**: Add Alert, Textarea, Dialog components ## Form System Migration - Replace react-final-form with react-hook-form - Use @hookform/resolvers for Zod validation - Add noValidate for consistent JS-based validation - Remove unused zod-form-validator-utils.ts ## Cleanup - Remove react-hot-toast, react-final-form, final-form dependencies - Remove redundant loader.tsx component - Standardize on Skeleton + Spinner for loading states ## Testing - Update tests for react-hook-form API - Add ResizeObserver/scrollIntoView mocks for cmdk - All 24 tests pass * update prettier * checkout without ref * format --------- Co-authored-by: Claude <[email protected]> Co-authored-by: Alaister Young <[email protected]>
1 parent 2f9034b commit db357d5

31 files changed

+1186
-382
lines changed

.github/workflows/prettier.yaml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,6 @@ jobs:
1414
steps:
1515
- name: Check out repo
1616
uses: actions/checkout@v6
17-
with:
18-
ref: ${{ github.head_ref }}
1917
- name: Run Prettier
2018
uses: creyD/[email protected]
2119
with:

website/__tests__/access-tokens.integration.test.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,17 @@ import userEvent from '@testing-library/user-event'
33
import { describe, it, expect, vi, beforeEach } from 'vitest'
44
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
55

6-
// Mock react-hot-toast - must be before component import
7-
vi.mock('react-hot-toast', () => ({
6+
// Mock use-toast - must be before component import
7+
vi.mock('~/hooks/use-toast', () => ({
88
toast: {
99
success: vi.fn(),
1010
error: vi.fn(),
1111
},
12+
useToast: () => ({
13+
toast: vi.fn(),
14+
toasts: [],
15+
dismiss: vi.fn(),
16+
}),
1217
}))
1318

1419
// Mock the delete mutation
@@ -25,7 +30,7 @@ vi.mock('~/data/access-tokens/delete-access-token', () => ({
2530
}),
2631
}))
2732

28-
import { toast } from 'react-hot-toast'
33+
import { toast } from '~/hooks/use-toast'
2934
import AccessTokenCard from '~/components/access-tokens/AccessTokenCard'
3035

3136
// Setup dayjs with relativeTime

website/__tests__/form.integration.test.tsx

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { render, screen, waitFor } from '@testing-library/react'
22
import userEvent from '@testing-library/user-event'
33
import { describe, it, expect, vi } from 'vitest'
44
import { z } from 'zod'
5-
import Form from '~/components/forms/Form'
5+
import Form, { FORM_ERROR } from '~/components/forms/Form'
66
import FormInput from '~/components/forms/FormInput'
77
import FormButton from '~/components/forms/FormButton'
88

@@ -11,10 +11,14 @@ const LoginSchema = z.object({
1111
password: z.string().min(6, 'Password must be at least 6 characters'),
1212
})
1313

14+
type FormResult = { [FORM_ERROR]?: string } | void
15+
1416
function LoginForm({
1517
onSubmit,
1618
}: {
17-
onSubmit: (values: z.infer<typeof LoginSchema>) => void
19+
onSubmit: (
20+
values: z.infer<typeof LoginSchema>
21+
) => FormResult | Promise<FormResult>
1822
}) {
1923
return (
2024
<Form schema={LoginSchema} onSubmit={onSubmit}>
@@ -37,11 +41,10 @@ describe('Form Integration', () => {
3741
await user.click(screen.getByRole('button', { name: /sign in/i }))
3842

3943
await waitFor(() => {
40-
expect(handleSubmit).toHaveBeenCalledWith(
41-
{ email: '[email protected]', password: 'password123' },
42-
expect.anything(),
43-
expect.anything()
44-
)
44+
expect(handleSubmit).toHaveBeenCalledWith({
45+
46+
password: 'password123',
47+
})
4548
})
4649
})
4750

@@ -63,4 +66,52 @@ describe('Form Integration', () => {
6366

6467
expect(handleSubmit).not.toHaveBeenCalled()
6568
})
69+
70+
it('shows submit error in alert when onSubmit returns FORM_ERROR', async () => {
71+
const user = userEvent.setup()
72+
const handleSubmit = vi.fn().mockResolvedValue({
73+
[FORM_ERROR]: 'Invalid credentials. Please try again.',
74+
})
75+
76+
render(<LoginForm onSubmit={handleSubmit} />)
77+
78+
await user.type(screen.getByLabelText(/email/i), '[email protected]')
79+
await user.type(screen.getByLabelText(/password/i), 'password123')
80+
await user.click(screen.getByRole('button', { name: /sign in/i }))
81+
82+
await waitFor(() => {
83+
expect(screen.getByRole('alert')).toBeInTheDocument()
84+
expect(screen.getByText(/invalid credentials/i)).toBeInTheDocument()
85+
})
86+
})
87+
88+
it('disables submit button while submitting', async () => {
89+
const user = userEvent.setup()
90+
let resolveSubmit: () => void
91+
const handleSubmit = vi.fn().mockImplementation(
92+
() =>
93+
new Promise<void>((resolve) => {
94+
resolveSubmit = resolve
95+
})
96+
)
97+
98+
render(<LoginForm onSubmit={handleSubmit} />)
99+
100+
await user.type(screen.getByLabelText(/email/i), '[email protected]')
101+
await user.type(screen.getByLabelText(/password/i), 'password123')
102+
103+
const submitButton = screen.getByRole('button', { name: /sign in/i })
104+
await user.click(submitButton)
105+
106+
await waitFor(() => {
107+
expect(submitButton).toBeDisabled()
108+
})
109+
110+
// Resolve the promise to complete submission
111+
resolveSubmit!()
112+
113+
await waitFor(() => {
114+
expect(submitButton).not.toBeDisabled()
115+
})
116+
})
66117
})

website/__tests__/search.integration.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ describe('Search Integration', () => {
129129
await waitFor(() => {
130130
expect(screen.getByText('No results found')).toBeInTheDocument()
131131
expect(
132-
screen.getByText(/try prefixing your query with an @ symbol/i)
132+
screen.getByText(/try prefixing with @ to search organizations/i)
133133
).toBeInTheDocument()
134134
})
135135
})

website/components/access-tokens/AccessTokenCard.tsx

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
import dayjs from 'dayjs'
2-
import { toast } from 'react-hot-toast'
2+
import { toast } from '~/hooks/use-toast'
33
import { useDeleteAccessTokenMutation } from '~/data/access-tokens/delete-access-token'
44
import { Button } from '~/components/ui/button'
5+
import {
6+
Card,
7+
CardContent,
8+
CardDescription,
9+
CardHeader,
10+
CardTitle,
11+
} from '~/components/ui/card'
512

613
export interface ApiTokenCardProps {
714
tokenId: string
@@ -24,22 +31,26 @@ const AccessTokenCard = ({
2431
})
2532

2633
return (
27-
<div className="rounded-lg px-6 py-5 border border-gray-200 flex justify-between">
28-
<div className="flex flex-col space-y-4">
29-
<div className="font-medium text-lg dark:text-white">{tokenName}</div>
30-
<div className="text-gray-500">{`Token: ${maskedToken}`}</div>
31-
<div className="text-gray-400 text-sm">{`Created ${dayjs(
32-
createdAt
33-
).fromNow()}`}</div>
34-
</div>
35-
<Button
36-
variant="secondary"
37-
onClick={() => deleteAccessToken({ tokenId })}
38-
disabled={isDeletingAccessToken}
39-
>
40-
Revoke
41-
</Button>
42-
</div>
34+
<Card>
35+
<CardHeader className="flex flex-row items-start justify-between space-y-0">
36+
<div className="space-y-1">
37+
<CardTitle className="text-lg">{tokenName}</CardTitle>
38+
<CardDescription>Token: {maskedToken}</CardDescription>
39+
</div>
40+
<Button
41+
variant="secondary"
42+
onClick={() => deleteAccessToken({ tokenId })}
43+
disabled={isDeletingAccessToken}
44+
>
45+
Revoke
46+
</Button>
47+
</CardHeader>
48+
<CardContent>
49+
<p className="text-sm text-muted-foreground">
50+
Created {dayjs(createdAt).fromNow()}
51+
</p>
52+
</CardContent>
53+
</Card>
4354
)
4455
}
4556

website/components/forms/Form.tsx

Lines changed: 60 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,47 @@
1-
import { PropsWithChildren, PropsWithoutRef } from 'react'
1+
import { zodResolver } from '@hookform/resolvers/zod'
2+
import { AlertCircle } from 'lucide-react'
23
import {
3-
Form as FinalForm,
4-
FormProps as FinalFormProps,
5-
} from 'react-final-form'
4+
createContext,
5+
PropsWithChildren,
6+
PropsWithoutRef,
7+
useContext,
8+
} from 'react'
9+
import {
10+
FieldValues,
11+
FormProvider,
12+
useForm,
13+
UseFormReturn,
14+
} from 'react-hook-form'
615
import { z } from 'zod'
16+
import { Alert, AlertDescription } from '~/components/ui/alert'
717
import { cn } from '~/lib/utils'
8-
import { validateZodSchema } from '~/lib/zod-form-validator-utils'
9-
export { FORM_ERROR } from 'final-form'
18+
19+
// FORM_ERROR constant for backwards compatibility
20+
export const FORM_ERROR = 'FINAL_FORM/form-error' as const
21+
22+
// Context to share form instance with child components
23+
const FormContext = createContext<UseFormReturn<any> | null>(null)
24+
25+
export function useFormInstance<T extends FieldValues = FieldValues>() {
26+
const form = useContext(FormContext)
27+
if (!form) {
28+
throw new Error('useFormInstance must be used within a Form')
29+
}
30+
return form as UseFormReturn<T>
31+
}
1032

1133
export interface FormProps<S extends z.ZodType<any, any>> extends Omit<
1234
PropsWithoutRef<JSX.IntrinsicElements['form']>,
1335
'onSubmit'
1436
> {
15-
/** All your form fields */
37+
/** Zod schema for validation */
1638
schema?: S
17-
onSubmit: FinalFormProps<z.infer<S>>['onSubmit']
18-
initialValues?: FinalFormProps<z.infer<S>>['initialValues']
39+
/** Called on form submission. Return { [FORM_ERROR]: message } to show error */
40+
onSubmit: (
41+
values: z.infer<S>
42+
) => void | Promise<void | { [FORM_ERROR]?: string }>
43+
/** Initial form values */
44+
initialValues?: Partial<z.infer<S>>
1945
}
2046

2147
function Form<S extends z.ZodType<any, any>>({
@@ -26,28 +52,41 @@ function Form<S extends z.ZodType<any, any>>({
2652
className,
2753
...props
2854
}: PropsWithChildren<FormProps<S>>) {
55+
const form = useForm<z.infer<S>>({
56+
resolver: schema ? zodResolver(schema) : undefined,
57+
defaultValues: initialValues as any,
58+
})
59+
60+
const handleSubmit = async (values: z.infer<S>) => {
61+
const result = await onSubmit(values)
62+
if (result && FORM_ERROR in result && result[FORM_ERROR]) {
63+
form.setError('root', { message: result[FORM_ERROR] })
64+
}
65+
}
66+
67+
const rootError = form.formState.errors.root?.message
68+
2969
return (
30-
<FinalForm
31-
initialValues={initialValues}
32-
validate={validateZodSchema(schema)}
33-
onSubmit={onSubmit}
34-
render={({ handleSubmit, submitError }) => (
70+
<FormContext.Provider value={form}>
71+
<FormProvider {...form}>
3572
<form
36-
onSubmit={handleSubmit}
73+
onSubmit={form.handleSubmit(handleSubmit)}
3774
className={cn('flex flex-col gap-2', className)}
75+
noValidate
3876
{...props}
3977
>
40-
{submitError && (
41-
<div role="alert" className="text-sm text-red-600">
42-
{submitError}
43-
</div>
78+
{rootError && (
79+
<Alert variant="destructive">
80+
<AlertCircle className="h-4 w-4" />
81+
<AlertDescription>{rootError}</AlertDescription>
82+
</Alert>
4483
)}
4584

4685
{/* Form fields supplied as children are rendered here */}
4786
{children}
4887
</form>
49-
)}
50-
/>
88+
</FormProvider>
89+
</FormContext.Provider>
5190
)
5291
}
5392

website/components/forms/FormButton.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
import { forwardRef } from 'react'
2-
import { useFormState } from 'react-final-form'
2+
import { useFormContext } from 'react-hook-form'
33
import { cn } from '~/lib/utils'
44
import { Button, ButtonProps } from '~/components/ui/button'
55

66
export interface FormButtonProps extends ButtonProps {}
77

88
const FormButton = forwardRef<HTMLButtonElement, FormButtonProps>(
9-
({ children, className, ...props }, ref) => {
10-
const { submitting } = useFormState()
9+
({ children, className, disabled, ...props }, ref) => {
10+
const {
11+
formState: { isSubmitting },
12+
} = useFormContext()
1113

1214
return (
1315
<Button
14-
disabled={submitting}
16+
disabled={isSubmitting || disabled}
1517
className={cn(
1618
'mt-4 bg-gray-100 hover:bg-gray-200 text-gray-800 transition dark:bg-slate-800 dark:hover:bg-slate-700 dark:text-white',
1719
className

0 commit comments

Comments
 (0)