6.5 KiB
6.5 KiB
CRUD Pattern — Overview
This document describes the full-stack CRUD pattern used across the dashboard. Every resource page (Customers, Vendors, Employees, etc.) is built from the same thin layer of generic, composable utilities.
Architecture Layers
┌──────────────────────────────────────────────────────────────────┐
│ Feature Page (apps/dashboard/app/…/page.tsx) │
│ • Declares columns, title, routeKey, getClient, renderForm │
└──────────────────┬───────────────────────────────────────────────┘
│ uses
┌──────────────────▼───────────────────────────────────────────────┐
│ ResourcePage (shared/data-view/resource-page) │
│ • Combines DashboardPage + FormDialog + DataTable │
│ • Delegates state to useResourcePage │
└──────┬───────────────────────────────┬────────────────────────────┘
│ │
┌──────▼──────────────┐ ┌────────────▼───────────────────────────┐
│ useResourcePage │ │ DataTable (shared/data-view/table-view)│
│ • useDataTableQuery │ │ • TanStack Table (manual mode) │
│ • form dialog state │ │ • Pagination, sorting, skeleton │
│ • delete mutation │ │ • DataViewProvider context │
└──────┬──────────────┘ └────────────────────────────────────────┘
│
┌──────▼──────────────────────────────────────────────────────────┐
│ useAuthApi → createApi() → CrudClient → openapi-fetch │
│ (packages/api — fully type-safe from OpenAPI schema) │
└─────────────────────────────────────────────────────────────────┘
│
┌──────▼──────────────────────────────────────────────────────────┐
│ Feature Form (modules/<feature>/<feature>-form.tsx) │
│ • useResourceForm (react-hook-form + Zod + optional fetch) │
│ • useMutation (create / update) │
│ • RhfTextField / RhfSelectField / RhfAsyncSelectField … │
└─────────────────────────────────────────────────────────────────┘
Key Files
| Layer | File | Purpose |
|---|---|---|
| Feature page | app/(authenticated)/sales/customers/page.tsx |
Minimal feature config |
| Generic page shell | shared/data-view/resource-page/resource-page.tsx |
Layout + wiring |
| Page logic hook | shared/data-view/resource-page/use-resource-page.ts |
State + mutations |
| Table | shared/data-view/table-view/data-table.tsx |
TanStack Table UI |
| Table data hook | shared/data-view/table-view/use-data-table-query.ts |
React Query + URL state |
| Form dialog | shared/components/form-dialog.tsx |
URL-driven dialog trigger |
| Confirm dialog | shared/components/confirm-dialog.tsx |
Imperative async confirm |
| Form state hook | shared/hooks/use-resource-form.ts |
RHF + Zod + optional re-fetch |
| Form field wrappers | shared/components/form/ |
RhfTextField, RhfSelectField, … |
| API layer | packages/api/src/infra/crud-client.ts |
Generic CRUD HTTP client |
| Auth API hook | shared/useApi.ts |
Creates authenticated API instance |
Data Flow — Read (LIST)
URL params change (page / sort)
→ useDataTableQuery re-runs queryFn
→ client.list({ page, per_page, sort_by, sort_order })
→ CrudClient.list()
→ ApiClient.get(indexRoute, { query })
→ openapi-fetch GET /api/customers
← { data: [...], meta: { last_page, total, ... } }
→ DataTable renders rows + pagination
Data Flow — Write (CREATE / UPDATE)
User fills form → submits
→ useMutation mutationFn
→ api.customers.create(payload) or .update(id, payload)
→ CrudClient.create() / .update()
→ ApiClient.post() / .put()
→ openapi-fetch POST|PUT /api/customers[/{id}]
← 200/201 response
→ onSuccess: invalidateQuery() → list refreshes
→ onError: ApiError.validationErrors → form.setError(field, msg)
Data Flow — Delete
User clicks Delete in actions column
→ confirm({ title, description, variant: "destructive" }) → awaits boolean
→ if confirmed: deleteItem(id)
→ useMutation → client.destroy(id)
→ ApiClient.delete()
← success
→ invalidateQuery()
Creating a New Feature Page
To add a new resource page following this pattern:
- Create schema:
modules/<feature>/<feature>.schema.ts - Create form:
modules/<feature>/<feature>-form.tsx - Create page:
app/(authenticated)/<section>/<feature>/page.tsx
The page needs only 5 props on <ResourcePage>:
<ResourcePage<MyClient>
title="My Resource"
pageTitle="My Resources"
routeKey={MY_ROUTES.INDEX}
getClient={(api) => api.myClient}
columns={({ actionsColumn }) => [
{ accessorKey: "name", header: () => <ColumnHeader ... /> },
actionsColumn(),
]}
renderForm={({ resourceId, initialData, onSuccess }) => (
<MyForm resourceId={resourceId} initialData={initialData} onSuccess={onSuccess} />
)}
/>
See the Resource Page, Data Fetching, and Form System docs for details on each layer.