Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions .github/workflows/prettier.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@v6
with:
ref: ${{ github.head_ref }}
- name: Run Prettier
uses: creyD/[email protected]
with:
Expand Down
11 changes: 8 additions & 3 deletions website/__tests__/access-tokens.integration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,17 @@ import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

// Mock react-hot-toast - must be before component import
vi.mock('react-hot-toast', () => ({
// Mock use-toast - must be before component import
vi.mock('~/hooks/use-toast', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
useToast: () => ({
toast: vi.fn(),
toasts: [],
dismiss: vi.fn(),
}),
}))

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

import { toast } from 'react-hot-toast'
import { toast } from '~/hooks/use-toast'
import AccessTokenCard from '~/components/access-tokens/AccessTokenCard'

// Setup dayjs with relativeTime
Expand Down
65 changes: 58 additions & 7 deletions website/__tests__/form.integration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'
import { z } from 'zod'
import Form from '~/components/forms/Form'
import Form, { FORM_ERROR } from '~/components/forms/Form'
import FormInput from '~/components/forms/FormInput'
import FormButton from '~/components/forms/FormButton'

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

type FormResult = { [FORM_ERROR]?: string } | void

function LoginForm({
onSubmit,
}: {
onSubmit: (values: z.infer<typeof LoginSchema>) => void
onSubmit: (
values: z.infer<typeof LoginSchema>
) => FormResult | Promise<FormResult>
}) {
return (
<Form schema={LoginSchema} onSubmit={onSubmit}>
Expand All @@ -37,11 +41,10 @@ describe('Form Integration', () => {
await user.click(screen.getByRole('button', { name: /sign in/i }))

await waitFor(() => {
expect(handleSubmit).toHaveBeenCalledWith(
{ email: '[email protected]', password: 'password123' },
expect.anything(),
expect.anything()
)
expect(handleSubmit).toHaveBeenCalledWith({
email: '[email protected]',
password: 'password123',
})
})
})

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

expect(handleSubmit).not.toHaveBeenCalled()
})

it('shows submit error in alert when onSubmit returns FORM_ERROR', async () => {
const user = userEvent.setup()
const handleSubmit = vi.fn().mockResolvedValue({
[FORM_ERROR]: 'Invalid credentials. Please try again.',
})

render(<LoginForm onSubmit={handleSubmit} />)

await user.type(screen.getByLabelText(/email/i), '[email protected]')
await user.type(screen.getByLabelText(/password/i), 'password123')
await user.click(screen.getByRole('button', { name: /sign in/i }))

await waitFor(() => {
expect(screen.getByRole('alert')).toBeInTheDocument()
expect(screen.getByText(/invalid credentials/i)).toBeInTheDocument()
})
})

it('disables submit button while submitting', async () => {
const user = userEvent.setup()
let resolveSubmit: () => void
const handleSubmit = vi.fn().mockImplementation(
() =>
new Promise<void>((resolve) => {
resolveSubmit = resolve
})
)

render(<LoginForm onSubmit={handleSubmit} />)

await user.type(screen.getByLabelText(/email/i), '[email protected]')
await user.type(screen.getByLabelText(/password/i), 'password123')

const submitButton = screen.getByRole('button', { name: /sign in/i })
await user.click(submitButton)

await waitFor(() => {
expect(submitButton).toBeDisabled()
})

// Resolve the promise to complete submission
resolveSubmit!()

await waitFor(() => {
expect(submitButton).not.toBeDisabled()
})
})
})
2 changes: 1 addition & 1 deletion website/__tests__/search.integration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ describe('Search Integration', () => {
await waitFor(() => {
expect(screen.getByText('No results found')).toBeInTheDocument()
expect(
screen.getByText(/try prefixing your query with an @ symbol/i)
screen.getByText(/try prefixing with @ to search organizations/i)
).toBeInTheDocument()
})
})
Expand Down
45 changes: 28 additions & 17 deletions website/components/access-tokens/AccessTokenCard.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import dayjs from 'dayjs'
import { toast } from 'react-hot-toast'
import { toast } from '~/hooks/use-toast'
import { useDeleteAccessTokenMutation } from '~/data/access-tokens/delete-access-token'
import { Button } from '~/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '~/components/ui/card'

export interface ApiTokenCardProps {
tokenId: string
Expand All @@ -24,22 +31,26 @@ const AccessTokenCard = ({
})

return (
<div className="rounded-lg px-6 py-5 border border-gray-200 flex justify-between">
<div className="flex flex-col space-y-4">
<div className="font-medium text-lg dark:text-white">{tokenName}</div>
<div className="text-gray-500">{`Token: ${maskedToken}`}</div>
<div className="text-gray-400 text-sm">{`Created ${dayjs(
createdAt
).fromNow()}`}</div>
</div>
<Button
variant="secondary"
onClick={() => deleteAccessToken({ tokenId })}
disabled={isDeletingAccessToken}
>
Revoke
</Button>
</div>
<Card>
<CardHeader className="flex flex-row items-start justify-between space-y-0">
<div className="space-y-1">
<CardTitle className="text-lg">{tokenName}</CardTitle>
<CardDescription>Token: {maskedToken}</CardDescription>
</div>
<Button
variant="secondary"
onClick={() => deleteAccessToken({ tokenId })}
disabled={isDeletingAccessToken}
>
Revoke
</Button>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Created {dayjs(createdAt).fromNow()}
</p>
</CardContent>
</Card>
)
}

Expand Down
81 changes: 60 additions & 21 deletions website/components/forms/Form.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,47 @@
import { PropsWithChildren, PropsWithoutRef } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { AlertCircle } from 'lucide-react'
import {
Form as FinalForm,
FormProps as FinalFormProps,
} from 'react-final-form'
createContext,
PropsWithChildren,
PropsWithoutRef,
useContext,
} from 'react'
import {
FieldValues,
FormProvider,
useForm,
UseFormReturn,
} from 'react-hook-form'
import { z } from 'zod'
import { Alert, AlertDescription } from '~/components/ui/alert'
import { cn } from '~/lib/utils'
import { validateZodSchema } from '~/lib/zod-form-validator-utils'
export { FORM_ERROR } from 'final-form'

// FORM_ERROR constant for backwards compatibility
export const FORM_ERROR = 'FINAL_FORM/form-error' as const

// Context to share form instance with child components
const FormContext = createContext<UseFormReturn<any> | null>(null)

export function useFormInstance<T extends FieldValues = FieldValues>() {
const form = useContext(FormContext)
if (!form) {
throw new Error('useFormInstance must be used within a Form')
}
return form as UseFormReturn<T>
}

export interface FormProps<S extends z.ZodType<any, any>> extends Omit<
PropsWithoutRef<JSX.IntrinsicElements['form']>,
'onSubmit'
> {
/** All your form fields */
/** Zod schema for validation */
schema?: S
onSubmit: FinalFormProps<z.infer<S>>['onSubmit']
initialValues?: FinalFormProps<z.infer<S>>['initialValues']
/** Called on form submission. Return { [FORM_ERROR]: message } to show error */
onSubmit: (
values: z.infer<S>
) => void | Promise<void | { [FORM_ERROR]?: string }>
/** Initial form values */
initialValues?: Partial<z.infer<S>>
}

function Form<S extends z.ZodType<any, any>>({
Expand All @@ -26,28 +52,41 @@ function Form<S extends z.ZodType<any, any>>({
className,
...props
}: PropsWithChildren<FormProps<S>>) {
const form = useForm<z.infer<S>>({
resolver: schema ? zodResolver(schema) : undefined,
defaultValues: initialValues as any,
})

const handleSubmit = async (values: z.infer<S>) => {
const result = await onSubmit(values)
if (result && FORM_ERROR in result && result[FORM_ERROR]) {
form.setError('root', { message: result[FORM_ERROR] })
}
}

const rootError = form.formState.errors.root?.message

return (
<FinalForm
initialValues={initialValues}
validate={validateZodSchema(schema)}
onSubmit={onSubmit}
render={({ handleSubmit, submitError }) => (
<FormContext.Provider value={form}>
<FormProvider {...form}>
<form
onSubmit={handleSubmit}
onSubmit={form.handleSubmit(handleSubmit)}
className={cn('flex flex-col gap-2', className)}
noValidate
{...props}
>
{submitError && (
<div role="alert" className="text-sm text-red-600">
{submitError}
</div>
{rootError && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{rootError}</AlertDescription>
</Alert>
)}

{/* Form fields supplied as children are rendered here */}
{children}
</form>
)}
/>
</FormProvider>
</FormContext.Provider>
)
}

Expand Down
10 changes: 6 additions & 4 deletions website/components/forms/FormButton.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import { forwardRef } from 'react'
import { useFormState } from 'react-final-form'
import { useFormContext } from 'react-hook-form'
import { cn } from '~/lib/utils'
import { Button, ButtonProps } from '~/components/ui/button'

export interface FormButtonProps extends ButtonProps {}

const FormButton = forwardRef<HTMLButtonElement, FormButtonProps>(
({ children, className, ...props }, ref) => {
const { submitting } = useFormState()
({ children, className, disabled, ...props }, ref) => {
const {
formState: { isSubmitting },
} = useFormContext()

return (
<Button
disabled={submitting}
disabled={isSubmitting || disabled}
className={cn(
'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',
className
Expand Down
Loading