init
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
# API Client Reference
|
||||
|
||||
## File Location
|
||||
|
||||
`packages/api/src/clients/<kebab-resource>.ts`
|
||||
|
||||
## Standard CrudClient Pattern (Preferred)
|
||||
|
||||
Use this when the resource has standard CRUD endpoints that exist in the OpenAPI schema.
|
||||
|
||||
```ts
|
||||
import { CrudClient } from "../infra/crud-client"
|
||||
import type { ApiClientOptions } from "../infra/client"
|
||||
import type { ApiPath, ApiRequestBody } from "../infra/types"
|
||||
|
||||
export const <RESOURCE>_ROUTES = {
|
||||
INDEX: "/api/<plural-resource>",
|
||||
BY_ID: "/api/<plural-resource>/{id}",
|
||||
// Add extra routes as needed:
|
||||
// EXPORT: "/api/<plural-resource>/export",
|
||||
// IMPORT: "/api/<plural-resource>/import",
|
||||
// RELATED: "/api/<related-resource>",
|
||||
} as const satisfies Record<string, ApiPath>
|
||||
|
||||
export class <Resource>Client extends CrudClient<
|
||||
typeof <RESOURCE>_ROUTES.INDEX,
|
||||
typeof <RESOURCE>_ROUTES.BY_ID
|
||||
> {
|
||||
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
|
||||
super(baseUrl, defaultOptions, <RESOURCE>_ROUTES.INDEX, <RESOURCE>_ROUTES.BY_ID)
|
||||
}
|
||||
|
||||
// Add domain-specific methods:
|
||||
// async listCategories() {
|
||||
// return this.get(<RESOURCE>_ROUTES.RELATED)
|
||||
// }
|
||||
//
|
||||
// async export() {
|
||||
// return this.get(<RESOURCE>_ROUTES.EXPORT)
|
||||
// }
|
||||
}
|
||||
```
|
||||
|
||||
### CrudClient Gives You For Free
|
||||
|
||||
| Method | HTTP | Description |
|
||||
|---|---|---|
|
||||
| `list(query?)` | `GET /api/<resource>` | Paginated list with query params |
|
||||
| `show(id)` | `GET /api/<resource>/{id}` | Single item fetch |
|
||||
| `create(payload)` | `POST /api/<resource>` | Create new item |
|
||||
| `update(id, payload)` | `PUT /api/<resource>/{id}` | Update existing item |
|
||||
| `destroy(id)` | `DELETE /api/<resource>/{id}` | Delete item |
|
||||
|
||||
## Minimal CrudClient (No Custom Methods)
|
||||
|
||||
For simple resources with only standard CRUD:
|
||||
|
||||
```ts
|
||||
import { CrudClient } from "../infra/crud-client"
|
||||
import type { ApiClientOptions } from "../infra/client"
|
||||
import type { ApiPath } from "../infra/types"
|
||||
|
||||
export const <RESOURCE>_ROUTES = {
|
||||
INDEX: "/api/<plural-resource>",
|
||||
BY_ID: "/api/<plural-resource>/{id}",
|
||||
} as const satisfies Record<string, ApiPath>
|
||||
|
||||
export class <Resource>Client extends CrudClient<
|
||||
typeof <RESOURCE>_ROUTES.INDEX,
|
||||
typeof <RESOURCE>_ROUTES.BY_ID
|
||||
> {
|
||||
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
|
||||
super(baseUrl, defaultOptions, <RESOURCE>_ROUTES.INDEX, <RESOURCE>_ROUTES.BY_ID)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Registration
|
||||
|
||||
After creating the client, register it in two files:
|
||||
|
||||
### 1. `packages/api/src/clients/index.ts`
|
||||
|
||||
```ts
|
||||
export { <Resource>Client, <RESOURCE>_ROUTES } from "./<kebab-resource>"
|
||||
```
|
||||
|
||||
### 2. `packages/api/src/api.ts`
|
||||
|
||||
Add the import at the top:
|
||||
```ts
|
||||
import { <Resource>Client } from "./clients/<kebab-resource>"
|
||||
```
|
||||
|
||||
Add to the `createApi()` return object:
|
||||
```ts
|
||||
export function createApi(options?: ApiClientOptions) {
|
||||
return {
|
||||
// ...existing clients...
|
||||
<camelResource>: new <Resource>Client(undefined, options),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Real Example: CustomersClient
|
||||
|
||||
```ts
|
||||
import { CrudClient } from "../infra/crud-client"
|
||||
import { ApiClient, type ApiClientOptions } from "../infra/client"
|
||||
import type { ApiPath, ApiRequestBody } from "../infra/types"
|
||||
|
||||
export const CUSTOMER_ROUTES = {
|
||||
INDEX: "/api/customers",
|
||||
BY_ID: "/api/customers/{id}",
|
||||
EXPORT: "/api/customers/export",
|
||||
IMPORT: "/api/customers/import",
|
||||
CUSTOMER_TYPES: "/api/customer-types",
|
||||
} as const satisfies Record<string, ApiPath>
|
||||
|
||||
export class CustomersClient extends CrudClient<
|
||||
typeof CUSTOMER_ROUTES.INDEX,
|
||||
typeof CUSTOMER_ROUTES.BY_ID
|
||||
> {
|
||||
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
|
||||
super(baseUrl, defaultOptions, CUSTOMER_ROUTES.INDEX, CUSTOMER_ROUTES.BY_ID)
|
||||
}
|
||||
|
||||
async listCustomerTypes() {
|
||||
return this.get(CUSTOMER_ROUTES.CUSTOMER_TYPES)
|
||||
}
|
||||
|
||||
async export() {
|
||||
return this.get(CUSTOMER_ROUTES.EXPORT)
|
||||
}
|
||||
|
||||
async import(payload: ApiRequestBody<typeof CUSTOMER_ROUTES.IMPORT, "post">) {
|
||||
return this.post(CUSTOMER_ROUTES.IMPORT, payload)
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,234 @@
|
||||
# Form Reference
|
||||
|
||||
## File Location
|
||||
|
||||
`apps/dashboard/modules/<feature>/<feature>-form.tsx`
|
||||
|
||||
## Complete Template
|
||||
|
||||
```tsx
|
||||
"use client"
|
||||
|
||||
import { AlertTriangle, Plus, Save } from "lucide-react"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
|
||||
import { FieldGroup } from "@/shared/components/ui/field"
|
||||
import {
|
||||
Rhform,
|
||||
RhfTextField,
|
||||
RhfSelectField,
|
||||
RhfAsyncSelectField,
|
||||
// RhfTextareaField,
|
||||
// RhfCheckboxField,
|
||||
// RhfAsyncMultiSelectField,
|
||||
} from "@/shared/components/form"
|
||||
import { toast } from "sonner"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { useResourceForm } from "@/shared/hooks/use-resource-form"
|
||||
import { useFormMutation } from "@/shared/hooks/use-form-mutation"
|
||||
import { toRelation, toId } from "@/shared/lib/utils"
|
||||
|
||||
import {
|
||||
<feature>FormSchema,
|
||||
type <Feature>FormValues,
|
||||
} from "./<feature>.schema"
|
||||
import { <RESOURCE>_ROUTES } from "@repo/api"
|
||||
|
||||
// ── Constants ──
|
||||
|
||||
// Static select options (if needed):
|
||||
// const STATUS_OPTIONS = [
|
||||
// { value: "active", label: "Active" },
|
||||
// { value: "inactive", label: "Inactive" },
|
||||
// ]
|
||||
|
||||
// ── Props ──
|
||||
|
||||
export type <Feature>FormProps = {
|
||||
resourceId?: string | null
|
||||
initialData?: unknown
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
// ── Default values ──
|
||||
|
||||
const DEFAULT_VALUES: <Feature>FormValues = {
|
||||
// Match every field in the Zod schema:
|
||||
// name: "",
|
||||
// email: "",
|
||||
// category: null, // relation fields default to null
|
||||
// is_active: true, // booleans
|
||||
}
|
||||
|
||||
// ── Mapping helpers ──
|
||||
|
||||
function mapToFormValues(data: unknown): <Feature>FormValues {
|
||||
const d = (data as any)?.data ?? data ?? {}
|
||||
|
||||
return {
|
||||
// String fields:
|
||||
// name: d.name || "",
|
||||
// email: d.email || "",
|
||||
|
||||
// Relation fields (API returns id + name separately):
|
||||
// category: toRelation(d.category_id, d.category_name),
|
||||
|
||||
// Booleans:
|
||||
// is_active: d.is_active ?? true,
|
||||
}
|
||||
}
|
||||
|
||||
function mapFormToPayload(values: <Feature>FormValues) {
|
||||
return {
|
||||
// String fields — use `|| undefined` to send null for empty strings:
|
||||
// name: values.name,
|
||||
// email: values.email || undefined,
|
||||
|
||||
// Relation fields — extract the numeric ID:
|
||||
// category_id: toId(values.category),
|
||||
|
||||
// Booleans:
|
||||
// is_active: values.is_active,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shared mapOption for async selects ──
|
||||
|
||||
const mapLookupOption = (item: any) => ({
|
||||
value: String(item.id),
|
||||
label: item.name,
|
||||
})
|
||||
|
||||
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
|
||||
|
||||
// ── Component ──
|
||||
|
||||
export function <Feature>Form({ resourceId, initialData, onSuccess }: <Feature>FormProps) {
|
||||
const api = useAuthApi()
|
||||
|
||||
const { form, isEditing } = useResourceForm<<Feature>FormValues, any>({
|
||||
schema: <feature>FormSchema,
|
||||
defaultValues: DEFAULT_VALUES,
|
||||
resourceId,
|
||||
initialData,
|
||||
initialize: (id) => api.<camelResource>.show(id),
|
||||
queryKey: [<RESOURCE>_ROUTES.BY_ID, resourceId],
|
||||
mapToFormValues: mapToFormValues,
|
||||
})
|
||||
|
||||
const { mutate, error, isPending } = useFormMutation(form, {
|
||||
mutationFn: (values: <Feature>FormValues) => {
|
||||
const payload = mapFormToPayload(values)
|
||||
const promise = isEditing && resourceId
|
||||
? api.<camelResource>.update(resourceId, payload)
|
||||
: api.<camelResource>.create(payload)
|
||||
toast.promise(promise, {
|
||||
loading: isEditing ? "Updating <resource>..." : "Creating <resource>...",
|
||||
success: isEditing ? "<Resource> updated successfully" : "<Resource> created successfully",
|
||||
error: isEditing ? "Failed to update <resource>" : "Failed to create <resource>",
|
||||
})
|
||||
return promise
|
||||
},
|
||||
onSuccess: () => {
|
||||
form.reset()
|
||||
onSuccess?.()
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Rhform form={form} onSubmit={(values) => mutate(values)}>
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTriangle className="me-2 h-4 w-4" />
|
||||
<AlertTitle>
|
||||
{isEditing ? "Failed to update <resource>" : "Failed to create <resource>"}
|
||||
</AlertTitle>
|
||||
{error.message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FieldGroup>
|
||||
{/* Text fields */}
|
||||
{/* <RhfTextField name="name" label="Name" placeholder="Enter name" required /> */}
|
||||
|
||||
{/* Grid layout for side-by-side fields */}
|
||||
{/* <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfTextField name="email" label="Email" type="email" />
|
||||
<RhfTextField name="phone" label="Phone" type="tel" />
|
||||
</div> */}
|
||||
|
||||
{/* Static select */}
|
||||
{/* <RhfSelectField
|
||||
name="status"
|
||||
label="Status"
|
||||
placeholder="Select status"
|
||||
options={STATUS_OPTIONS}
|
||||
/> */}
|
||||
|
||||
{/* Async select (fetches options from API) */}
|
||||
{/* <RhfAsyncSelectField
|
||||
name="category"
|
||||
label="Category"
|
||||
placeholder="Select category"
|
||||
queryKey={["categories"]}
|
||||
listFn={() => api.categories.list()}
|
||||
mapOption={mapLookupOption}
|
||||
{...STORE_OBJECT}
|
||||
/> */}
|
||||
|
||||
{/* Textarea */}
|
||||
{/* <RhfTextareaField name="notes" label="Notes" rows={4} /> */}
|
||||
|
||||
{/* Checkbox */}
|
||||
{/* <RhfCheckboxField name="is_active" label="Active" /> */}
|
||||
|
||||
<Button type="submit" variant="default" disabled={isPending}>
|
||||
{isEditing ? <Save /> : <Plus />}
|
||||
{isPending
|
||||
? (isEditing ? "Updating..." : "Creating...")
|
||||
: (isEditing ? "Update <Resource>" : "Create <Resource>")}
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
</Rhform>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Key Patterns
|
||||
|
||||
### mapToFormValues
|
||||
|
||||
Transforms API response → form values. Always handle:
|
||||
- Null safety: `d.field || ""`
|
||||
- Relation fields: `toRelation(d.relation_id, d.relation_name)`
|
||||
- Nested data: `(data as any)?.data ?? data ?? {}`
|
||||
- Booleans: `d.field ?? defaultValue`
|
||||
|
||||
### mapFormToPayload
|
||||
|
||||
Transforms form values → API request body. Always handle:
|
||||
- Empty strings to undefined: `values.field || undefined`
|
||||
- Relation to ID: `toId(values.relation)`
|
||||
- Keep required fields as-is: `values.name`
|
||||
|
||||
### useResourceForm
|
||||
|
||||
Initializes react-hook-form with Zod validation. Handles both create and edit modes:
|
||||
- `resourceId` null → create mode (uses `defaultValues`)
|
||||
- `resourceId` set → edit mode (fetches via `initialize`, maps with `mapToFormValues`)
|
||||
|
||||
### useFormMutation
|
||||
|
||||
Wraps `useMutation` with automatic Laravel validation error mapping to form fields. No need to manually handle `ApiError.validationErrors`.
|
||||
|
||||
### Toast Pattern
|
||||
|
||||
Always use `toast.promise()` wrapping the API call for consistent loading/success/error feedback.
|
||||
|
||||
## Layout Conventions
|
||||
|
||||
- Use `<FieldGroup>` to wrap all fields
|
||||
- Use `<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">` for side-by-side fields
|
||||
- Place the submit button at the bottom inside `<FieldGroup>`
|
||||
- Show error alert above fields when mutation fails
|
||||
@@ -0,0 +1,225 @@
|
||||
# Page Reference
|
||||
|
||||
## File Location
|
||||
|
||||
`apps/dashboard/app/(authenticated)/<section>/<feature>/page.tsx`
|
||||
|
||||
Where `<section>` is the navigation section (e.g. `sales`, `inventory`, `hr`) and `<feature>` is the resource in kebab-case plural.
|
||||
|
||||
## Complete Template (ResourcePage Pattern)
|
||||
|
||||
```tsx
|
||||
"use client"
|
||||
|
||||
import { ResourcePage } from '@/shared/data-view/resource-page'
|
||||
import { ColumnHeader } from '@/shared/data-view/table-view'
|
||||
import { <Feature>Form } from '@/modules/<feature>/<feature>-form'
|
||||
import { <RESOURCE>_ROUTES } from '@repo/api'
|
||||
import type { <Resource>Client } from '@repo/api'
|
||||
|
||||
export default function <Features>Page() {
|
||||
return (
|
||||
<ResourcePage<<Resource>Client>
|
||||
pageTitle="<Features>"
|
||||
title="<Feature>"
|
||||
routeKey={<RESOURCE>_ROUTES.INDEX}
|
||||
getClient={(api) => api.<camelResource>}
|
||||
columns={({ actionsColumn }) => [
|
||||
{
|
||||
accessorKey: "<primary_field>",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="<Primary Field>" />,
|
||||
},
|
||||
{
|
||||
accessorKey: "<field_2>",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="<Field 2>" />,
|
||||
},
|
||||
// Add more columns as needed...
|
||||
actionsColumn(),
|
||||
]}
|
||||
renderForm={({ resourceId, initialData, onSuccess }) => (
|
||||
<<Feature>Form
|
||||
resourceId={resourceId}
|
||||
initialData={initialData}
|
||||
onSuccess={onSuccess}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## ResourcePage Props
|
||||
|
||||
| Prop | Required | Description |
|
||||
|---|---|---|
|
||||
| `pageTitle` | No | Page heading text (e.g. "Customers") |
|
||||
| `title` | Yes | Singular noun for button/dialog (e.g. "Customer" → "Add Customer") |
|
||||
| `routeKey` | Yes | React Query cache key, use `ROUTES.INDEX` |
|
||||
| `getClient` | Yes | Selects the domain client from the authenticated API |
|
||||
| `columns` | Yes | Column definitions — use callback form to get `actionsColumn` helper |
|
||||
| `renderForm` | Yes | Renders the form component inside the dialog |
|
||||
| `queryOptions` | No | React Query overrides (`staleTime`, etc.) |
|
||||
|
||||
## Column Patterns
|
||||
|
||||
### Simple text column (sortable)
|
||||
```tsx
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Name" />,
|
||||
},
|
||||
```
|
||||
|
||||
### Custom cell renderer
|
||||
```tsx
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
|
||||
cell: ({ row }) => (
|
||||
<span className={row.original.status === "active" ? "text-green-600" : "text-red-600"}>
|
||||
{row.original.status}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
```
|
||||
|
||||
### Column with icon
|
||||
```tsx
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Name" />,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<UserIcon className="text-muted-foreground" />
|
||||
<span>{row.original.name}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
```
|
||||
|
||||
### Non-sortable column
|
||||
```tsx
|
||||
{
|
||||
accessorKey: "notes",
|
||||
header: () => <span>Notes</span>,
|
||||
enableSorting: false,
|
||||
},
|
||||
```
|
||||
|
||||
### Actions column (always last)
|
||||
```tsx
|
||||
actionsColumn(),
|
||||
```
|
||||
|
||||
## Real Example: Customers Page
|
||||
|
||||
```tsx
|
||||
"use client"
|
||||
|
||||
import { ResourcePage } from '@/shared/data-view/resource-page'
|
||||
import { ColumnHeader } from '@/shared/data-view/table-view'
|
||||
import { CustomerForm } from '@/modules/customers/customer-form'
|
||||
import { CUSTOMER_ROUTES } from '@repo/api'
|
||||
import type { CustomersClient } from '@repo/api'
|
||||
import { Building2Icon, UserIcon } from 'lucide-react'
|
||||
|
||||
export default function CustomersPage() {
|
||||
return (
|
||||
<ResourcePage<CustomersClient>
|
||||
pageTitle='Customers'
|
||||
title="Customer"
|
||||
routeKey={CUSTOMER_ROUTES.INDEX}
|
||||
getClient={(api) => api.customers}
|
||||
columns={({ actionsColumn }) => [
|
||||
{
|
||||
accessorKey: "first_name",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Customer" />,
|
||||
cell: ({ row }) => {
|
||||
const customerName = row.original.first_name
|
||||
const isCompany = row.original.customer_type?.name?.toLocaleLowerCase() === "company";
|
||||
const companyName = row.original.company_name
|
||||
const name = isCompany && companyName
|
||||
? `${customerName} (${row.original.last_name})`
|
||||
: customerName
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{isCompany
|
||||
? <Building2Icon className="text-muted-foreground" />
|
||||
: <UserIcon className="text-muted-foreground" />}
|
||||
<span>{name}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "email",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Email" />,
|
||||
},
|
||||
{
|
||||
accessorKey: "phone",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Phone" />,
|
||||
},
|
||||
actionsColumn(),
|
||||
]}
|
||||
renderForm={({ resourceId, initialData, onSuccess }) => (
|
||||
<CustomerForm
|
||||
resourceId={resourceId}
|
||||
initialData={initialData}
|
||||
onSuccess={onSuccess}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Alternative: Manual DataTable Pattern (Read-Only or Custom Layout)
|
||||
|
||||
Use only when you don't need create/edit/delete in a dialog:
|
||||
|
||||
```tsx
|
||||
"use client"
|
||||
|
||||
import { DashboardHeader } from '@/base/components/layout/dashboard'
|
||||
import DashboardPage from '@/base/components/layout/dashboard/dashboard-page'
|
||||
import { ColumnHeader, DataTable, useDataTableQuery } from '@/shared/data-view/table-view'
|
||||
import { useAuthApi } from '@/shared/useApi'
|
||||
import { <RESOURCE>_ROUTES } from '@repo/api'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
|
||||
const columns: ColumnDef<any>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Name" />,
|
||||
},
|
||||
// ... more columns
|
||||
]
|
||||
|
||||
export default function <Features>Page() {
|
||||
const api = useAuthApi()
|
||||
|
||||
const { data, isLoading, pagination, sorting, handleChange } = useDataTableQuery({
|
||||
queryKey: [<RESOURCE>_ROUTES.INDEX],
|
||||
client: api.<camelResource>,
|
||||
})
|
||||
|
||||
const response = data as any
|
||||
|
||||
return (
|
||||
<DashboardPage header={<DashboardHeader />}>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={response?.data ?? []}
|
||||
pagination={{
|
||||
...pagination,
|
||||
pageCount: response?.meta?.last_page ?? 1,
|
||||
total: response?.meta?.total ?? 0,
|
||||
}}
|
||||
sorting={sorting}
|
||||
onChange={handleChange}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</DashboardPage>
|
||||
)
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,143 @@
|
||||
# Schema Reference
|
||||
|
||||
## File Location
|
||||
|
||||
`apps/dashboard/modules/<feature>/<feature>.schema.ts`
|
||||
|
||||
## Template
|
||||
|
||||
```ts
|
||||
import { z } from "zod"
|
||||
|
||||
// Reusable relation field schema — use for all foreign-key / lookup fields
|
||||
const relationFieldSchema = z
|
||||
.object({ value: z.string(), label: z.string() })
|
||||
.nullable()
|
||||
|
||||
const <feature>FormSchema = z.object({
|
||||
// ── Relations (stored as { value, label } objects, mapped to IDs on submit) ──
|
||||
// category: relationFieldSchema,
|
||||
|
||||
// ── Required strings ──
|
||||
// name: z.string().min(1, "Name is required"),
|
||||
|
||||
// ── Optional strings ──
|
||||
// description: z.string().optional(),
|
||||
|
||||
// ── Optional email (allows empty string) ──
|
||||
// email: z.union([
|
||||
// z.string().email("Enter a valid email address"),
|
||||
// z.literal(""),
|
||||
// ]).optional(),
|
||||
|
||||
// ── Optional phone ──
|
||||
// phone: z.string().optional(),
|
||||
|
||||
// ── Boolean ──
|
||||
// is_active: z.boolean().default(true),
|
||||
|
||||
// ── Number ──
|
||||
// quantity: z.coerce.number().min(0),
|
||||
|
||||
// ── Date ──
|
||||
// due_date: z.string().optional(),
|
||||
})
|
||||
|
||||
type <Feature>FormValues = z.infer<typeof <feature>FormSchema>
|
||||
|
||||
export { <feature>FormSchema, relationFieldSchema }
|
||||
export type { <Feature>FormValues }
|
||||
```
|
||||
|
||||
## Field Type Patterns
|
||||
|
||||
### Required string
|
||||
```ts
|
||||
name: z.string().min(1, "Name is required"),
|
||||
```
|
||||
|
||||
### Optional string
|
||||
```ts
|
||||
notes: z.string().optional(),
|
||||
```
|
||||
|
||||
### Optional email (allows empty)
|
||||
```ts
|
||||
email: z.union([
|
||||
z.string().email("Enter a valid email address"),
|
||||
z.literal(""),
|
||||
]).optional(),
|
||||
```
|
||||
|
||||
### Relation / Foreign key
|
||||
```ts
|
||||
const relationFieldSchema = z
|
||||
.object({ value: z.string(), label: z.string() })
|
||||
.nullable()
|
||||
|
||||
// In schema:
|
||||
department: relationFieldSchema,
|
||||
```
|
||||
|
||||
### Required relation
|
||||
```ts
|
||||
department: z
|
||||
.object({ value: z.string(), label: z.string() })
|
||||
.refine((v) => v !== null, { message: "Department is required" }),
|
||||
```
|
||||
|
||||
### Boolean with default
|
||||
```ts
|
||||
is_active: z.boolean().default(true),
|
||||
```
|
||||
|
||||
### Number (from string input)
|
||||
```ts
|
||||
quantity: z.coerce.number().min(0, "Must be non-negative"),
|
||||
price: z.coerce.number().min(0),
|
||||
```
|
||||
|
||||
### Static enum select
|
||||
```ts
|
||||
status: z.enum(["active", "inactive", "pending"]).default("active"),
|
||||
salutation: z.string().optional(),
|
||||
```
|
||||
|
||||
## Real Example: CustomerFormSchema
|
||||
|
||||
```ts
|
||||
import { z } from "zod"
|
||||
|
||||
const relationFieldSchema = z
|
||||
.object({ value: z.string(), label: z.string() })
|
||||
.nullable()
|
||||
|
||||
type RelationField = z.infer<typeof relationFieldSchema>
|
||||
|
||||
const customerFormSchema = z.object({
|
||||
customer_type: relationFieldSchema,
|
||||
referral_source: relationFieldSchema,
|
||||
payment_terms: relationFieldSchema,
|
||||
country: relationFieldSchema,
|
||||
state: relationFieldSchema,
|
||||
salutation: z.string().optional(),
|
||||
first_name: z.string().min(1, "First name is required"),
|
||||
last_name: z.string().min(1, "Last name is required"),
|
||||
company_name: z.string().optional(),
|
||||
email: z.union([
|
||||
z.string().email("Enter a valid email address"),
|
||||
z.literal(""),
|
||||
]).optional(),
|
||||
phone: z.string().optional(),
|
||||
alternate_phone: z.string().optional(),
|
||||
address_line_1: z.string().optional(),
|
||||
address_line_2: z.string().optional(),
|
||||
city: z.string().optional(),
|
||||
zip_code: z.string().optional(),
|
||||
})
|
||||
|
||||
type CustomerFormValues = z.infer<typeof customerFormSchema>
|
||||
|
||||
export { customerFormSchema, relationFieldSchema }
|
||||
export type { CustomerFormValues, RelationField }
|
||||
```
|
||||
Reference in New Issue
Block a user