# Components & Forms Guide

> Frontend components architecture and form handling patterns

## Frontend Components

Key shared components and patterns in Izri.

### DataBoundary

A generic boundary for loading and not-found states.

File: apps/web/app/components/common/data-boundary.tsx

Props:
- isLoading: boolean
- data: any | null | undefined
- loadingMessage?: string
- notFoundMessage?: string
- withContainer?: boolean (default true)
- loadingFallback?: ReactNode
- notFoundFallback?: ReactNode

Usage:
```tsx
<DataBoundary
  isLoading={isPending}
  data={project}
  loadingMessage="Loading project..."
  notFoundMessage="Project not found"
>
  <ProjectDetails project={project!} />
</DataBoundary>
```

### LayoutErrorBoundary

File: apps/web/app/components/errors/layout-error-boundary.tsx

Wraps layout children to catch rendering errors and present a consistent UI.

Pattern:
- Use in layout routes to isolate failures
- Pair with DataBoundary for route-level data

### Sidebar and Page Layout

Files:
- apps/web/app/components/layout/sidebar.tsx
- apps/web/app/components/layout/page-layout.tsx

Notes:
- Sidebar reads organization context to render org-scoped navigation
- PageLayout provides a consistent container and spacing

### Organization Switcher

File: apps/web/app/components/organizations/organization-switcher.tsx

Notes:
- Uses organization context hooks to list/select organizations
- Works with organization-scoped routing (/organizations/:slug/...)

### UI Primitives (shadcn/ui)

Located in apps/web/app/components/ui/ (button, card, input, select, etc.)

Conventions:
- Keep variants minimal and consistent
- Prefer composition over one-off bespoke styles

### Toasts (Sonner)

App-wide notifications are surfaced through Sonner. The host is mounted once
in `apps/web/app/root.tsx` as `<Toaster richColors position="bottom-right" />`
— do **not** mount additional `<Toaster>` instances. Trigger toasts from
anywhere via the imperative `toast` API:

```tsx
import { toast } from 'sonner'

toast.success(`Scope config saved (v${saved.version})`)
toast.error('Could not save scope config', { description: err.message })
toast.info('Heads up')
toast.warning('Slow path detected')
```

Conventions:
- **Use toasts for transient action feedback** — save success / failure, copy-
  to-clipboard, "queued for processing". Use inline alerts (or the
  `<DataBoundary>` boundary) for state that's load-bearing on the current view
  (e.g. "couldn't load delta report — Try again").
- **Prefer `.success` / `.error` / `.info` / `.warning`** over the bare
  `toast()` so the right icon + color is applied automatically (Sonner is
  configured with `richColors`).
- **Always include a `description` for `.error`** with the message from the
  failed mutation. Worked example: `apps/web/app/components/scope/scope-config-editor.tsx`.

### Tabs

`Tabs` is the app's primitive for organising secondary navigation inside a
single page (e.g. "Modules / Categories / Sensitive" on the scope dashboard,
or "Signals / Visual diff / AI insights / Activity" on the delta report).

```tsx
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'

<Tabs defaultValue="signals" className="space-y-4">
  <TabsList>
    <TabsTrigger value="signals">Signals</TabsTrigger>
    {hasVisual ? <TabsTrigger value="visual">Visual diff</TabsTrigger> : null}
  </TabsList>
  <TabsContent value="signals">…</TabsContent>
  {hasVisual ? <TabsContent value="visual">…</TabsContent> : null}
</Tabs>
```

Conventions:
- **Gate the trigger AND the content together.** If a tab's panel is
  conditionally rendered, the trigger must be too — Radix Tabs renders nothing
  if the active panel is missing.
- **Default value should always have data.** Pick a tab that's guaranteed to
  exist regardless of which optional tabs are present.
- **Don't use Tabs for primary navigation** — that's the sidebar / route
  hierarchy. Tabs are for slicing a single screen, not for cross-screen
  navigation.
- Worked examples: `apps/web/app/components/scope/scope-config-editor.tsx`,
  `apps/web/app/components/delta-reports/delta-report-view.tsx`.

---

## Forms Guide

Form handling with React Hook Form and Zod validation.

### Overview

Izri uses **React Hook Form** for form state management and **Zod** for validation.

### Setup

Install Dependencies:

```bash
cd apps/web
pnpm add react-hook-form @hookform/resolvers zod
```

### Basic Form

Simple Form Example:

```tsx
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'

const schema = z.object({
  name: z.string().min(1, 'Name is required'),
  email: z.string().email('Invalid email'),
})

type FormData = z.infer<typeof schema>

export function ContactForm() {
  const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
    resolver: zodResolver(schema),
  })

  const onSubmit = (data: FormData) => {
    console.log(data)
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <input {...register('name')} placeholder="Name" />
        {errors.name && <p>{errors.name.message}</p>}
      </div>

      <div>
        <input {...register('email')} placeholder="Email" />
        {errors.email && <p>{errors.email.message}</p>}
      </div>

      <button type="submit">Submit</button>
    </form>
  )
}
```

### Form with tRPC

Create Project Form:

```tsx
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useTRPC } from '@/lib/trpc'
import { useNavigate } from 'react-router'

const createProjectSchema = z.object({
  name: z.string().min(1, 'Name is required').max(255),
  repository: z.string().url('Must be a valid URL'),
  language: z.enum(['typescript', 'javascript', 'python']),
})

type CreateProjectFormData = z.infer<typeof createProjectSchema>

export function CreateProjectForm() {
  const navigate = useNavigate()
  const utils = useTRPC()

  const { register, handleSubmit, formState: { errors } } = useForm<CreateProjectFormData>({
    resolver: zodResolver(createProjectSchema),
  })

  const createProject = utils.projects.create.useMutation({
    onSuccess: (data) => {
      utils.projects.list.invalidate()
      navigate(`/projects/${data.id}`)
    },
  })

  const onSubmit = (data: CreateProjectFormData) => {
    createProject.mutate(data)
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
      <div>
        <label htmlFor="name">Project Name</label>
        <input
          id="name"
          {...register('name')}
          className="w-full border rounded px-3 py-2"
        />
        {errors.name && <p className="text-red-500">{errors.name.message}</p>}
      </div>

      <div>
        <label htmlFor="repository">Repository URL</label>
        <input
          id="repository"
          {...register('repository')}
          className="w-full border rounded px-3 py-2"
        />
        {errors.repository && <p className="text-red-500">{errors.repository.message}</p>}
      </div>

      <div>
        <label htmlFor="language">Language</label>
        <select id="language" {...register('language')} className="w-full border rounded px-3 py-2">
          <option value="typescript">TypeScript</option>
          <option value="javascript">JavaScript</option>
          <option value="python">Python</option>
        </select>
        {errors.language && <p className="text-red-500">{errors.language.message}</p>}
      </div>

      <button
        type="submit"
        disabled={createProject.isPending}
        className="px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
      >
        {createProject.isPending ? 'Creating...' : 'Create Project'}
      </button>
    </form>
  )
}
```

### Reusable Form Components

```tsx
// components/ui/form.tsx
import { type InputHTMLAttributes } from 'react'
import { type FieldError } from 'react-hook-form'

interface FormFieldProps extends InputHTMLAttributes<HTMLInputElement> {
  label: string
  error?: FieldError
}

export function FormField({ label, error, ...props }: FormFieldProps) {
  return (
    <div className="space-y-1">
      <label className="block text-sm font-medium">
        {label}
      </label>
      <input
        className="w-full border rounded px-3 py-2 focus:outline-none focus:ring-2"
        {...props}
      />
      {error && (
        <p className="text-sm text-red-500">{error.message}</p>
      )}
    </div>
  )
}
```

**Usage**:

```tsx
export function LoginForm() {
  const { register, formState: { errors } } = useForm()

  return (
    <form>
      <FormField
        label="Email"
        type="email"
        {...register('email')}
        error={errors.email}
      />
      <FormField
        label="Password"
        type="password"
        {...register('password')}
        error={errors.password}
      />
    </form>
  )
}
```

### Validation

**Custom Validation:**

```tsx
const schema = z.object({
  password: z.string().min(8, 'At least 8 characters'),
  confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
  message: 'Passwords must match',
  path: ['confirmPassword'],
})
```

**Async Validation:**

```tsx
const schema = z.object({
  slug: z.string().refine(async (slug) => {
    const exists = await checkSlugAvailable(slug)
    return !exists
  }, 'Slug already taken'),
})
```

### Default Values

```tsx
const { register } = useForm({
  defaultValues: {
    name: project.name,
    repository: project.repository,
  },
})
```

---

**Related:** [Setup & Configuration](./setup.md) | [Testing](./testing-frontend.md) | [README](./README.md)
