diff --git a/.github/workflows/prettier.yaml b/.github/workflows/prettier.yaml index 8bb29b28..211a9c9e 100644 --- a/.github/workflows/prettier.yaml +++ b/.github/workflows/prettier.yaml @@ -14,8 +14,6 @@ jobs: steps: - name: Check out repo uses: actions/checkout@v6 - with: - ref: ${{ github.head_ref }} - name: Run Prettier uses: creyD/prettier_action@v4.6 with: diff --git a/website/__tests__/access-tokens.integration.test.tsx b/website/__tests__/access-tokens.integration.test.tsx index c254063d..e220d52f 100644 --- a/website/__tests__/access-tokens.integration.test.tsx +++ b/website/__tests__/access-tokens.integration.test.tsx @@ -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 @@ -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 diff --git a/website/__tests__/form.integration.test.tsx b/website/__tests__/form.integration.test.tsx index 03afaadc..b267fa1d 100644 --- a/website/__tests__/form.integration.test.tsx +++ b/website/__tests__/form.integration.test.tsx @@ -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' @@ -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) => void + onSubmit: ( + values: z.infer + ) => FormResult | Promise }) { return (
@@ -37,11 +41,10 @@ describe('Form Integration', () => { await user.click(screen.getByRole('button', { name: /sign in/i })) await waitFor(() => { - expect(handleSubmit).toHaveBeenCalledWith( - { email: 'test@example.com', password: 'password123' }, - expect.anything(), - expect.anything() - ) + expect(handleSubmit).toHaveBeenCalledWith({ + email: 'test@example.com', + password: 'password123', + }) }) }) @@ -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() + + await user.type(screen.getByLabelText(/email/i), 'test@example.com') + 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((resolve) => { + resolveSubmit = resolve + }) + ) + + render() + + await user.type(screen.getByLabelText(/email/i), 'test@example.com') + 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() + }) + }) }) diff --git a/website/__tests__/search.integration.test.tsx b/website/__tests__/search.integration.test.tsx index 8aa1d356..fddd6f8b 100644 --- a/website/__tests__/search.integration.test.tsx +++ b/website/__tests__/search.integration.test.tsx @@ -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() }) }) diff --git a/website/components/access-tokens/AccessTokenCard.tsx b/website/components/access-tokens/AccessTokenCard.tsx index 0c408235..8a188552 100644 --- a/website/components/access-tokens/AccessTokenCard.tsx +++ b/website/components/access-tokens/AccessTokenCard.tsx @@ -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 @@ -24,22 +31,26 @@ const AccessTokenCard = ({ }) return ( -
-
-
{tokenName}
-
{`Token: ${maskedToken}`}
-
{`Created ${dayjs( - createdAt - ).fromNow()}`}
-
- -
+ + +
+ {tokenName} + Token: {maskedToken} +
+ +
+ +

+ Created {dayjs(createdAt).fromNow()} +

+
+
) } diff --git a/website/components/forms/Form.tsx b/website/components/forms/Form.tsx index 5b648c9f..328d7ad9 100644 --- a/website/components/forms/Form.tsx +++ b/website/components/forms/Form.tsx @@ -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 | null>(null) + +export function useFormInstance() { + const form = useContext(FormContext) + if (!form) { + throw new Error('useFormInstance must be used within a Form') + } + return form as UseFormReturn +} export interface FormProps> extends Omit< PropsWithoutRef, 'onSubmit' > { - /** All your form fields */ + /** Zod schema for validation */ schema?: S - onSubmit: FinalFormProps>['onSubmit'] - initialValues?: FinalFormProps>['initialValues'] + /** Called on form submission. Return { [FORM_ERROR]: message } to show error */ + onSubmit: ( + values: z.infer + ) => void | Promise + /** Initial form values */ + initialValues?: Partial> } function Form>({ @@ -26,28 +52,41 @@ function Form>({ className, ...props }: PropsWithChildren>) { + const form = useForm>({ + resolver: schema ? zodResolver(schema) : undefined, + defaultValues: initialValues as any, + }) + + const handleSubmit = async (values: z.infer) => { + 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 ( - ( + + - {submitError && ( -
- {submitError} -
+ {rootError && ( + + + {rootError} + )} {/* Form fields supplied as children are rendered here */} {children} - )} - /> +
+
) } diff --git a/website/components/forms/FormButton.tsx b/website/components/forms/FormButton.tsx index 59c43a25..29acdb93 100644 --- a/website/components/forms/FormButton.tsx +++ b/website/components/forms/FormButton.tsx @@ -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( - ({ children, className, ...props }, ref) => { - const { submitting } = useFormState() + ({ children, className, disabled, ...props }, ref) => { + const { + formState: { isSubmitting }, + } = useFormContext() return ( + {theme === 'dark' && } + {theme === 'light' && } + ) } diff --git a/website/components/ui/alert.tsx b/website/components/ui/alert.tsx new file mode 100644 index 00000000..b68a355d --- /dev/null +++ b/website/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from 'react' +import { cva, type VariantProps } from 'class-variance-authority' + +import { cn } from '~/lib/utils' + +const alertVariants = cva( + 'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground', + { + variants: { + variant: { + default: 'bg-background text-foreground', + destructive: + 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = 'Alert' + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = 'AlertTitle' + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = 'AlertDescription' + +export { Alert, AlertTitle, AlertDescription } diff --git a/website/components/ui/command.tsx b/website/components/ui/command.tsx new file mode 100644 index 00000000..6485326f --- /dev/null +++ b/website/components/ui/command.tsx @@ -0,0 +1,153 @@ +'use client' + +import * as React from 'react' +import { type DialogProps } from '@radix-ui/react-dialog' +import { Command as CommandPrimitive } from 'cmdk' +import { Search } from 'lucide-react' + +import { cn } from '~/lib/utils' +import { Dialog, DialogContent } from '~/components/ui/dialog' + +const Command = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Command.displayName = CommandPrimitive.displayName + +const CommandDialog = ({ children, ...props }: DialogProps) => { + return ( + + + + {children} + + + + ) +} + +const CommandInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ + +
+)) + +CommandInput.displayName = CommandPrimitive.Input.displayName + +const CommandList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandList.displayName = CommandPrimitive.List.displayName + +const CommandEmpty = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + +)) + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName + +const CommandGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandGroup.displayName = CommandPrimitive.Group.displayName + +const CommandSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +CommandSeparator.displayName = CommandPrimitive.Separator.displayName + +const CommandItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandItem.displayName = CommandPrimitive.Item.displayName + +const CommandShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +CommandShortcut.displayName = 'CommandShortcut' + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +} diff --git a/website/components/ui/dialog.tsx b/website/components/ui/dialog.tsx new file mode 100644 index 00000000..ecf2f40f --- /dev/null +++ b/website/components/ui/dialog.tsx @@ -0,0 +1,122 @@ +'use client' + +import * as React from 'react' +import * as DialogPrimitive from '@radix-ui/react-dialog' +import { X } from 'lucide-react' + +import { cn } from '~/lib/utils' + +const Dialog = DialogPrimitive.Root + +const DialogTrigger = DialogPrimitive.Trigger + +const DialogPortal = DialogPrimitive.Portal + +const DialogClose = DialogPrimitive.Close + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = 'DialogHeader' + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = 'DialogFooter' + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/website/components/ui/form.tsx b/website/components/ui/form.tsx new file mode 100644 index 00000000..a50f133b --- /dev/null +++ b/website/components/ui/form.tsx @@ -0,0 +1,178 @@ +'use client' + +import * as React from 'react' +import * as LabelPrimitive from '@radix-ui/react-label' +import { Slot } from '@radix-ui/react-slot' +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext, +} from 'react-hook-form' + +import { cn } from '~/lib/utils' +import { Label } from '~/components/ui/label' + +const Form = FormProvider + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + name: TName +} + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue +) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ) +} + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState, formState } = useFormContext() + + const fieldState = getFieldState(fieldContext.name, formState) + + if (!fieldContext) { + throw new Error('useFormField should be used within ') + } + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + } +} + +type FormItemContextValue = { + id: string +} + +const FormItemContext = React.createContext( + {} as FormItemContextValue +) + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId() + + return ( + +
+ + ) +}) +FormItem.displayName = 'FormItem' + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField() + + return ( +