This commit is contained in:
2026-03-27 16:03:58 +03:00
commit 174cdd6323
83 changed files with 96235 additions and 0 deletions
+278
View File
@@ -0,0 +1,278 @@
# Data Fetching
This document covers the full data-fetching stack: API client creation, authentication injection, query state management, and URL synchronization.
---
## Layer Overview
```
useAuthApi()
└─ createApi({ headers: { Authorization: "Bearer <token>" } })
└─ new CustomersClient(...) ← one per domain (CrudClient subclass)
└─ ApiClient ← openapi-fetch wrapper, type-safe from OpenAPI schema
```
```
useDataTableQuery({ queryKey, client, queryOptions })
└─ React Query useQuery
└─ client.list({ page, per_page, sort_by, sort_order })
└─ nuqs useQueryStates ← URL ↔ pagination & sort params
```
---
## `useAuthApi` — Authenticated API Factory
**File:** `shared/useApi.ts`
```ts
import { useAuthApi } from "@/shared/useApi"
const api = useAuthApi()
// api.customers, api.vehicles, api.employees, …
```
Reads the JWT token from the `useAuthStore` Zustand store and passes it as the `Authorization: Bearer <token>` header. Called inside any component or hook that needs to make authenticated requests.
> **Note:** `createApi()` is called on every render. If performance is a concern, wrap in `useMemo` (see [Enhancement Plan](./enhancement-plan.md)).
### Server-Side Variant
For `async` server components or server actions:
```ts
import { getAuthApi } from "@/shared/api"
const api = await getAuthApi() // reads token from cookies (Next.js server-side)
```
---
## `CrudClient` — Generic CRUD Base Class
**File:** `packages/api/src/infra/crud-client.ts`
All domain clients extend `CrudClient`. It provides four standard operations:
| Method | HTTP | Endpoint |
|---|---|---|
| `list(query?)` | `GET` | `indexRoute` (e.g. `/api/customers`) |
| `create(payload)` | `POST` | `indexRoute` |
| `update(id, payload)` | `PUT` | `byIdRoute` (e.g. `/api/customers/{id}`) |
| `destroy(id)` | `DELETE` | `byIdRoute` |
All methods are **fully type-safe** — parameter types, request body shapes, and response types are all derived from the OpenAPI schema via `packages/api/types/index.ts`.
### Exported Type Utilities
```ts
// Extract the list response type from a client class
type CrudListResponse<C> // e.g. { data: Customer[], meta: { last_page, total, ... } }
// Extract a single item type from the list data array
type CrudListItem<C> // e.g. Customer
// Extract query params accepted by list()
type CrudListParams<C>
// Base interface: all list items have `id: number`
type BaseCrudItem = { id: number }
```
### Example: Creating a Domain Client
```ts
// packages/api/src/clients/my-resource.ts
import { CrudClient } from "../infra/crud-client"
export const MY_ROUTES = {
INDEX: "/api/my-resources",
BY_ID: "/api/my-resources/{id}",
} as const
export class MyResourceClient extends CrudClient<
typeof MY_ROUTES.INDEX,
typeof MY_ROUTES.BY_ID
> {
constructor(baseUrl?: string, options?: ApiClientOptions) {
super(baseUrl, options, MY_ROUTES.INDEX, MY_ROUTES.BY_ID)
}
// Add domain-specific endpoints here:
async listCategories() {
return this.get("/api/my-resource-categories")
}
}
```
Then register it in `packages/api/src/api.ts`:
```ts
export function createApi(options?: ApiClientOptions) {
return {
// ...
myResources: new MyResourceClient(undefined, options),
}
}
```
---
## `ApiClient` — Low-Level HTTP Client
**File:** `packages/api/src/infra/client.ts`
Wraps `openapi-fetch`. All requests are typed against `paths` from `packages/api/types/index.ts`, which is generated from the OpenAPI schema.
### Error Handling
Failed requests throw an `ApiError`:
```ts
class ApiError extends Error {
status: number // HTTP status code
statusText: string
endpoint: string
method: string
payload?: {
message?: string
errors?: Record<string, string[]> // Laravel validation errors
}
get validationErrors(): Record<string, string[]> | undefined
}
```
### `ApiError` in Form Context
In mutation `onError` handlers, check for validation errors and apply them to individual form fields:
```ts
onError: (err) => {
if (err instanceof ApiError && err.validationErrors) {
Object.entries(err.validationErrors).forEach(([field, messages]) => {
form.setError(field as any, { message: messages[0] })
})
}
}
```
---
## `useDataTableQuery` — Paginated List + URL State
**File:** `shared/data-view/table-view/use-data-table-query.ts`
Wraps React Query + `nuqs` to keep the table's pagination and sort state synchronized with the URL.
```ts
const tableQuery = useDataTableQuery({
queryKey: ["customers"], // React Query cache key prefix
client, // Any object with a .list(query?) method
queryOptions, // Optional React Query overrides (staleTime, etc.)
})
```
### Returns
| Key | Description |
|---|---|
| `data` | The raw API response (`CrudListResponse<C>`) |
| `isLoading` | True during initial fetch |
| `pagination` | `{ page, pageSize, pageCount: 1, total: 0 }` — pageCount/total come from `data.meta` |
| `sorting` | `SortingState` derived from URL params |
| `params` | Raw parsed URL params (`page`, `per_page`, `sort_by`, `sort_order`) |
| `setParams` | Direct URL param setter |
| `handleChange` | Normalized event handler for `DataTable` (see below) |
| `invalidateQuery` | Busts the cache (called after mutations) |
### URL Query Parameters
| Param | Default | Description |
|---|---|---|
| `page` | `1` | Current page (1-based) |
| `per_page` | `10` | Rows per page |
| `sort_by` | `null` | Column `accessorKey` to sort by |
| `sort_order` | `null` | `"asc"` or `"desc"` |
### `handleChange` Event Types
```ts
// Triggered by DataViewPagination (page navigation, rows per page)
{ type: "pagination", pagination: { page, pageSize, ... } }
// Triggered by ColumnHeader sort dropdown
{ type: "sorting", sorting: [{ id: "email", desc: false }] }
// → resets page to 1 automatically
```
---
## `DataTable` — Table UI
**File:** `shared/data-view/table-view/data-table.tsx`
Thin wrapper around TanStack Table v8 with manual server-side pagination and sorting:
```tsx
<DataTable
columns={columns}
data={data}
pagination={{ page, pageSize, pageCount, total }}
sorting={sorting}
onChange={handleChange}
isLoading={isLoading}
/>
```
While `isLoading` is `true`, the table renders `pageSize` skeleton rows instead of data.
### `ColumnHeader` — Sortable Column Header
```tsx
import { ColumnHeader } from "@/shared/data-view/table-view"
{
accessorKey: "email",
header: ({ column }) => <ColumnHeader column={column} title="Email" />,
}
```
Renders a sort dropdown (Asc / Desc / Clear) if the column `canSort`. Shows a plain `<span>` otherwise.
---
## Auth Store
**File:** `shared/stores/auth-store.ts`
A Zustand store that holds the authenticated user state:
| Key | Type | Description |
|---|---|---|
| `token` | `string \| undefined` | JWT access token |
| `user` | `AuthUser \| undefined` | Authenticated user profile |
| `isAuthenticated` | `boolean` | True when token + user are set |
| `login(token, user, expiresIn?)` | fn | Persists to cookie + sets store |
| `logout()` | fn | Calls API logout, clears cookie + store |
| `hydrate()` | fn | Reads cookies on app boot (call in root layout) |
---
## Type System — OpenAPI-Derived Types
The entire API type surface is generated from `packages/api/open-api/schema.yaml` via scripts in `packages/api/scripts/`. The generated output is `packages/api/types/index.ts`.
Key exported type helpers from `packages/api/src/infra/types.ts`:
| Type | Description |
|---|---|
| `ApiPath` | Union of all known API paths |
| `ApiPathByMethod<M>` | All paths that support HTTP method `M` |
| `ApiQueryParams<Path, Method>` | Query parameter shape for a given path+method |
| `ApiRequestBody<Path, Method>` | Request body shape |
| `ApiResponse<Path, Method>` | Successful response shape |
| `ApiPathParams<Path, Method>` | URL path parameters (e.g. `{ id: string }`) |
These types flow through `CrudClient` → `useDataTableQuery` → `ResourcePage` → feature page, providing end-to-end type safety without any manual typing.
+225
View File
@@ -0,0 +1,225 @@
# Enhancement Plan
This document identifies current gaps and improvement opportunities in the CRUD flow. Items are grouped by priority and annotated with implementation effort.
---
## Summary
The current CRUD pattern is clean, composable, and type-safe. Most issues are edge cases or developer-experience refinements rather than critical bugs. The highest-priority items are the ones marked **High**.
---
## High Priority
### 1. `useAuthApi` — Missing Memoization
**File:** `shared/useApi.ts`
**Problem:** `createApi()` is called on every render. Every component that calls `useAuthApi()` creates new `ApiClient` instances per render cycle. This can cause unintended React Query cache misses or stale closures in edge cases.
**Fix:**
```ts
// shared/useApi.ts
import { useMemo } from "react"
export const useAuthApi = () => {
const token = useAuthStore(s => s.token)
return useMemo(
() => createApi({ headers: token ? { Authorization: `Bearer ${token}` } : undefined }),
[token],
)
}
```
**Effort:** XS (2 lines) — no API changes required.
---
### 2. Edit Mode — No Server Re-fetch on Dialog Open
**File:** `shared/hooks/use-resource-form.ts`, `modules/customers/customer-form.tsx`
**Problem:** When the "Edit" button is clicked, `useResourcePage` passes `selectedItem` (the in-memory table row) as `initialData`. The form is pre-populated from this snapshot. However:
- The snapshot may be stale if the item was modified elsewhere.
- On page refresh with `?dialog=true&resourceId=5` in the URL, `selectedItem` is `null` (not hydrated) → the form opens empty.
**Fix:** Add an `initialize` function to each feature form that fetches the full resource by ID:
```ts
// In CustomerForm (use-resource-form options)
initialize: (id) => api.customers.show(id), // requires CrudClient.show()
queryKey: [CUSTOMER_ROUTES.BY_ID, resourceId],
// In CrudClient (packages/api/src/infra/crud-client.ts)
async show(id: string) {
return this.get(this.byIdRoute, { params: { id } } as never)
}
```
**Effort:** S — needs `CrudClient.show()` added and each form updated to pass `initialize`.
---
### 3. `FormDialog` — Single Dialog Limitation
**File:** `shared/components/form-dialog.tsx`
**Problem:** Dialog state is keyed to fixed URL params `dialog` and `resourceId`. If two independent `FormDialog` instances are on the same page (e.g., a main resource form and a nested "Add Customer" side panel), they share the same URL params and will conflict.
**Fix:** Accept a configurable `paramKey` prop:
```ts
export const createFormDialogParams = (key: string) => ({
[`${key}_dialog`]: parseAsBoolean.withDefault(false),
[`${key}_resourceId`]: parseAsString,
})
```
**Effort:** S — requires updating `FormDialog`, `useFormDialog`, and `useResourcePage` to accept a `paramKey`.
---
## Medium Priority
### 4. No Global Search / Filter Support
**File:** `shared/data-view/table-view/use-data-table-query.ts`, `shared/data-view/table-view/search-params.ts`
**Problem:** `dataTableSearchParams` only supports `page`, `per_page`, `sort_by`, `sort_order`. There is no standard way to add resource-specific filters (e.g., search by name, filter by status).
**Proposed:** Add an optional `filters` object to `useDataTableQuery` that maps to additional query params:
```ts
useDataTableQuery({
queryKey: ["customers"],
client,
filters: {
search: parseAsString,
status: parseAsStringEnum(["active", "inactive"] as const),
},
})
// → adds ?search=&status= params and includes them in client.list()
```
**Effort:** M — requires a design decision and updates to `use-data-table-query`, `data-table.tsx`, and `resource-page`.
---
### 5. Grid View — Not Implemented
**File:** `shared/data-view/grid-view/` (empty directory)
**Problem:** The `grid-view` folder was scaffolded but never implemented. The data-view layer is clearly designed to support multiple views (table/grid), but no toggle exists.
**Proposed:**
- Implement a `GridView` component that accepts the same `DataViewProps` as `DataTable`.
- Add a view toggle (Table | Grid) in the `ResourcePage` header or `DashboardHeader`.
- Persist the selected view in a URL param (`?view=grid`).
**Effort:** M–L depending on grid card design requirements.
---
### 6. `useMutation` Error Handling — Not Reusable
**File:** `modules/customers/customer-form.tsx` (and all other feature forms)
**Problem:** The pattern of mapping `ApiError.validationErrors` to `form.setError` is duplicated in every form's `onError` handler.
**Fix:** Extract a `useFormMutation` hook:
```ts
// shared/hooks/use-form-mutation.ts
export function useFormMutation<TValues extends FieldValues, TResponse = unknown>(
form: UseFormReturn<TValues>,
options: UseMutationOptions<TResponse, Error, TValues>,
) {
return useMutation({
...options,
onError: (err, vars, ctx) => {
if (err instanceof ApiError && err.validationErrors) {
Object.entries(err.validationErrors).forEach(([field, msgs]) => {
form.setError(field as keyof TValues as any, { message: msgs[0] })
})
}
options.onError?.(err, vars, ctx)
},
})
}
```
**Effort:** XS — purely additive. Existing forms can be migrated incrementally.
---
### 7. `CUSTOMER_CREATED_EVENT` — Unused Custom Event
**File:** `modules/customers/customer-form.tsx`
**Problem:** `CustomerForm` dispatches `window.dispatchEvent(new CustomEvent("customer:created"))` on success, but nothing in the codebase listens to this event. Cache invalidation is already handled via `onSuccess` → `invalidateQuery()`. The event dispatch is dead code.
**Fix:** Remove the event dispatch from `CustomerForm` (and the `CUSTOMER_CREATED_EVENT` export) unless there is a known future use case, such as notifying a sibling component outside the React tree.
**Effort:** XS.
---
### 8. Pagination Meta Split — Inconsistency
**File:** `shared/data-view/table-view/use-data-table-query.ts`
**Problem:** `useDataTableQuery` returns `pagination` with `pageCount: 1, total: 0` as placeholders. The real values come from `data.meta` and are calculated inside `ResourcePage.tsx`. This means:
- `useResourcePage` consumers who render the table directly (outside `ResourcePage`) need to duplicate the `pageCount`/`total` derivation.
- The `pagination` object returned by the hook is misleading until data loads.
**Fix:** Either move the meta derivation inside `useDataTableQuery` (requiring it to accept a response shape hint), or document this as an intentional split and annotate it.
**Effort:** XS–S.
---
## Low Priority / Nice to Have
### 9. Row Selection for Bulk Actions
There is no row selection or bulk-delete support. TanStack Table supports `rowSelection` state natively. Adding a checkbox column and a "Delete selected" toolbar would benefit resource-heavy pages.
### 10. Error Boundary Around Table and Form
If a render error occurs inside `DataTable` or a feature form, it will bubble up and crash the whole page. Wrapping with `<ErrorBoundary>` (e.g., via `react-error-boundary`) would improve resilience.
### 11. `ConfirmDialog` — Not Enforced in Layout
**Problem:** `<ConfirmDialog />` must be manually mounted in `app/(authenticated)/layout.tsx`. There is no lint rule or runtime warning if it is missing. If a developer forgets to add it to a new layout, `confirm()` calls will silently resolve to `false` (no dialog shown, deletion blocked).
**Fix:** Add a development-mode warning inside the `confirm()` function if the store's `resolve` is never set after a timeout.
### 12. Column Visibility / Hide
`ColumnHeader` has a "Hide" dropdown menu item (via `column.toggleVisibility(false)`), but there is no global "Show columns" control to restore hidden columns. Either remove the hide option or add a column visibility popup to `DataTable`.
### 13. `dataTableSearchParamsCache` — Imported but Unused in App Router
`dataTableSearchParamsCache` is exported but the pages use `"use client"` throughout. If server components are introduced for any list page, the cache needs wiring in the layout via `nuqs`'s `SearchParamsProvider`.
---
## Checklist
- [x] #1 — Memoize `useAuthApi`
- [x] #2 — Add `CrudClient.show()` and wire `initialize` in feature forms
- [x] #3 — Make `FormDialog` param key configurable
- [ ] #4 — Design and implement filter/search param support in `useDataTableQuery`
- [ ] #5 — Implement `GridView` and view toggle
- [x] #6 — Extract `useFormMutation` hook
- [x] #7 — Remove unused `CUSTOMER_CREATED_EVENT`
- [x] #8 — Move pagination meta derivation into `useDataTableQuery`
- [ ] #9 — Row selection + bulk actions
- [ ] #10 — Error boundaries
- [x] #11 — Dev-mode warning for missing `ConfirmDialog`
- [ ] #12 — Column visibility restore control
- [ ] #13 — Wire `SearchParamsProvider` if server components are adopted
+308
View File
@@ -0,0 +1,308 @@
# Form System
This document covers the generic form infrastructure used by all resource forms in the dashboard.
---
## Layer Overview
```
<CustomerForm> ← Feature-specific form component
└─ useResourceForm(...) ← State: RHF form + optional fetch for edit mode
└─ useMutation(...) ← Create or update mutation with toast
└─ <Rhform form onSubmit> ← FormProvider + <form> wrapper
└─ <RhfTextField> ← RHF-connected text input
└─ <RhfSelectField> ← RHF-connected static select
└─ <RhfAsyncSelectField> ← RHF-connected async combobox (fetches options)
└─ <Button type="submit">
```
---
## `useResourceForm` — Form Initialization Hook
**File:** `shared/hooks/use-resource-form.ts`
Manages the react-hook-form instance with Zod validation and handles pre-filling the form when editing an existing item.
```ts
const { form, isEditing, isInitializing } = useResourceForm<TFormValues, TApiData>({
schema, // Zod schema → resolver
defaultValues, // Default form values for create mode
resourceId, // null → create, "5" → edit
initialData, // Optional: pre-fetched data (e.g. from table row)
mapToFormValues, // Maps API data shape → form values shape
initialize, // Optional: fetch fn called when resourceId is set (re-fetch from server)
queryKey, // Optional: React Query key for the initialize query
})
```
### Behavior
| Scenario | Result |
|---|---|
| `resourceId` is null | `isEditing = false`, form uses `defaultValues` |
| `resourceId` set, `initialData` provided, no `initialize` | Form pre-filled from `initialData` |
| `resourceId` set, `initialize` provided | `useQuery` calls `initialize(resourceId)`; form pre-filled from response |
| `resourceId` set, both provided | `useQuery` result takes precedence over `initialData` |
### `mapToFormValues`
Transforms the API response shape into the form's internal value shape. Field names, null handling, and relation objects are all resolved here:
```ts
function mapCustomerToFormValues(data: unknown): CustomerFormValues {
const c = (data as any)?.data ?? data ?? {}
return {
first_name: c.first_name || "",
customer_type: toRelation(c.customer_type_id, c.customer_type_name),
// ...
}
}
```
### `toRelation` / `toId` Helpers
**File:** `shared/lib/utils.ts`
Relation fields (foreign keys) are stored in the form as `{ value: string, label: string } | null` objects (combobox-compatible), not raw IDs.
```ts
// API data → form object
toRelation(id, name) // → { value: String(id), label: name } or null
// Form object → API payload
toId(relation) // → relation?.value ?? null
```
---
## `Rhform` — Form Provider Wrapper
**File:** `shared/components/form/rhform.tsx`
Wraps `react-hook-form`'s `FormProvider` and a `<form>` element. Avoids passing the `form` instance manually through every field.
```tsx
<Rhform form={form} onSubmit={handleSubmit}>
{/* children have access to form context via useFormContext */}
</Rhform>
```
---
## `RhfField` — Generic RHF Controller Connector
**File:** `shared/components/form/rhf-field.tsx`
Low-level generic component that connects any field control to react-hook-form. Used internally by all `Rhf*` field wrappers. You rarely need to use this directly.
```tsx
<RhfField
name="email"
label="Email"
required
component={TextInputField} // Any BaseFieldControlProps-compatible control
placeholder="john@example.com"
type="email"
/>
```
---
## `FieldShell` — Label + Error Layout
**File:** `shared/components/form/field-shell.tsx`
Renders the `FieldLabel`, `FieldDescription`, and `FieldError` around a control. Used by `RhfField` and `RhfAsyncSelectField` directly.
---
## Ready-Made `Rhf*` Field Wrappers
All wrappers follow the same pattern: they accept `name`, `label`, `description`, `required`, `disabled` plus any control-specific props.
### `RhfTextField`
```tsx
<RhfTextField name="first_name" label="First Name" placeholder="John" required />
<RhfTextField name="email" label="Email" type="email" />
<RhfTextField name="phone" label="Phone" type="tel" />
```
### `RhfTextareaField`
```tsx
<RhfTextareaField name="notes" label="Notes" rows={4} />
```
### `RhfCheckboxField`
```tsx
<RhfCheckboxField name="is_active" label="Active" />
```
### `RhfSelectField` — Static Options
```tsx
const options = [
{ value: "Mr.", label: "Mr." },
{ value: "Mrs.", label: "Mrs." },
]
<RhfSelectField name="salutation" label="Salutation" options={options} />
```
### `RhfAsyncSelectField` — Remote Options (Single)
Fetches options via React Query and renders a searchable combobox.
```tsx
<RhfAsyncSelectField
name="customer_type"
label="Customer Type"
placeholder="Select customer type"
queryKey={["customer-types"]}
listFn={() => api.customers.listCustomerTypes()}
mapOption={(item) => ({ value: String(item.id), label: item.name })}
getOptionValue={(o) => o} // store the full object, not just the string value
getOptionLabel={(o) => o.label}
/>
```
**Data source options** (choose one):
| Prop | Description |
|---|---|
| `listFn` | Calls an API method; response is unwrapped automatically via `extractItems` |
| `loadOptions` | Returns `Promise<any[]>` directly (custom logic) |
The `mapOption` prop transforms raw API items to `{ value, label }` objects.
**`staleTime`** defaults to 5 minutes. Override for highly dynamic lookups.
### `RhfAsyncMultiSelectField` — Remote Options (Multi)
Same as `RhfAsyncSelectField` but stores an array of values:
```tsx
<RhfAsyncMultiSelectField
name="tags"
multiple
label="Tags"
queryKey={["tags"]}
listFn={() => api.tags.list()}
mapOption={(item) => ({ value: String(item.id), label: item.name })}
getOptionValue={(o) => o}
getOptionLabel={(o) => o.label}
/>
```
---
## Anatomy of a Feature Form
```tsx
// modules/my-feature/my-feature-form.tsx
const DEFAULT_VALUES: MyFormValues = { name: "", ... }
function mapToFormValues(data: unknown): MyFormValues { ... }
function mapFormToPayload(values: MyFormValues) { ... }
export function MyFeatureForm({ resourceId, initialData, onSuccess }: ResourceFormProps) {
const api = useAuthApi()
// 1. Form initialization
const { form, isEditing } = useResourceForm<MyFormValues>({
schema: myFormSchema,
defaultValues: DEFAULT_VALUES,
resourceId,
initialData,
mapToFormValues,
})
// 2. Mutation
const { mutate, error, isPending } = useMutation({
mutationFn: (values: MyFormValues) => {
const payload = mapFormToPayload(values)
const promise = isEditing
? api.myResources.update(resourceId!, payload)
: api.myResources.create(payload)
toast.promise(promise, { loading: "Saving...", success: "Saved!", error: "Failed." })
return promise
},
onSuccess: () => { form.reset(); onSuccess?.() },
onError: (err) => {
if (err instanceof ApiError && err.validationErrors) {
Object.entries(err.validationErrors).forEach(([field, msgs]) => {
form.setError(field as any, { message: msgs[0] })
})
}
},
})
// 3. Render
return (
<Rhform form={form} onSubmit={(v) => mutate(v)}>
{error && <Alert variant="destructive">...</Alert>}
<FieldGroup>
<RhfTextField name="name" label="Name" required />
<Button type="submit" disabled={isPending}>
{isPending ? "Saving..." : isEditing ? "Update" : "Create"}
</Button>
</FieldGroup>
</Rhform>
)
}
```
---
## Zod Schema Conventions
**File:** `modules/<feature>/<feature>.schema.ts`
### Relation Fields
Relation fields (foreign-key selects) use a shared `relationFieldSchema`:
```ts
import { z } from "zod"
const relationFieldSchema = z
.object({ value: z.string(), label: z.string() })
.nullable()
// In the schema:
const mySchema = z.object({
category: relationFieldSchema, // → { value: "3", label: "Electronics" } | null
name: z.string().min(1, "Name is required"),
})
```
### Email Validation Pattern
Use union to allow empty strings:
```ts
email: z.union([
z.string().email("Enter a valid email address"),
z.literal(""),
]).optional(),
```
---
## `extractItems` — Response Unwrapper
Used internally by `RhfAsyncSelectField` to normalize different API response shapes:
```ts
// Handles all of:
extractItems([{ id: 1, name: "A" }]) // → same array
extractItems({ data: [{ id: 1, name: "A" }] }) // → data array
extractItems({ data: { data: [{ id: 1, name: "A" }] } }) // → nested data
```
This handles both plain arrays, standard Laravel list responses, and nested pagination wrappers.
+128
View File
@@ -0,0 +1,128 @@
# 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:
1. Create schema: `modules/<feature>/<feature>.schema.ts`
2. Create form: `modules/<feature>/<feature>-form.tsx`
3. Create page: `app/(authenticated)/<section>/<feature>/page.tsx`
The page needs only 5 props on `<ResourcePage>`:
```tsx
<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](./resource-page.md), [Data Fetching](./data-fetching.md), and [Form System](./form-system.md) docs for details on each layer.
+179
View File
@@ -0,0 +1,179 @@
# Resource Page
The `ResourcePage` component is the primary generic shell for any CRUD list page. It composes layout, table, dialog, and state management into a single, reusable component.
---
## Files
| File | Description |
|---|---|
| `shared/data-view/resource-page/resource-page.tsx` | The React component |
| `shared/data-view/resource-page/use-resource-page.ts` | The state/logic hook |
| `shared/data-view/resource-page/index.ts` | Public exports |
---
## `<ResourcePage>` Component
### Props
```ts
type ResourcePageProps<TClient extends ResourcePageClient> = {
// Required
title: string // Used for the "Add" button label and dialog title
routeKey: string // React Query cache key (e.g. CUSTOMER_ROUTES.INDEX)
getClient: (api: ApiInstance) => TClient // Selects the domain client from the API
columns: // Column definitions or a factory receiving helpers
| ColumnDef<ResourceItem<TClient>>[]
| ((helpers: ResourcePageColumnHelpers<TClient>) => ColumnDef<ResourceItem<TClient>>[])
renderForm: (props: ResourceFormProps<TClient>) => React.ReactNode
// Optional
pageTitle?: string // Heading text (defaults to undefined)
queryOptions?: Omit<UseQueryOptions<...>, "queryKey" | "queryFn">
}
```
### `ResourcePageColumnHelpers`
Passed to the `columns` callback, providing three pre-wired helpers:
| Helper | Type | Description |
|---|---|---|
| `actionsColumn(options?)` | `ColumnDef` | Pre-built Edit + Delete dropdown column |
| `openEdit(row)` | `(row: TItem) => void` | Opens dialog with row pre-filled |
| `deleteItem(id)` | `(id: string) => Promise<unknown>` | Deletes with toast + confirmation |
The `actionsColumn` factory (`createActionsColumn`) can be further customized:
```ts
actionsColumn({
onEdit: (row) => customOpen(row),
onDelete: async (row) => {
// completely override delete behavior
},
})
```
### `ResourceFormProps`
Passed to the `renderForm` callback:
| Prop | Type | Description |
|---|---|---|
| `resourceId` | `string \| null` | `null` on create; the item's `id` string on edit |
| `initialData` | `TItem \| null` | The full row object on edit (from the table's in-memory state) |
| `onSuccess` | `() => void` | Call this after a successful mutation to refresh the list |
---
## `useResourcePage` Hook
Encapsulates all state and logic. Returned by `ResourcePage` internally but also exported for use in custom page layouts.
```ts
const page = useResourcePage<MyClient>({ routeKey, getClient, queryOptions })
```
### Returns
| Key | Type | Description |
|---|---|---|
| `data` | `CrudListResponse<TClient>` | Raw API response |
| `isLoading` | `boolean` | True while initial fetch is in progress |
| `pagination` | `DataViewPaginationState` | `{ page, pageSize, pageCount, total }` |
| `sorting` | `DataViewSorting` | Current sort state |
| `handleChange` | `(event: DataViewChangeEvent) => void` | Handles pagination and sort events |
| `invalidateQuery` | `() => void` | Busts the React Query cache for the current query key |
| `selectedItem` | `TItem \| null` | The row being edited (populated by `openEdit`) |
| `openEdit(row)` | fn | Sets `selectedItem` and opens dialog |
| `openCreate()` | fn | Clears `selectedItem` and opens dialog |
| `openDialog(id?)` | fn | Low-level dialog open (sets `?dialog=true&resourceId=id` in URL) |
| `closeDialog()` | fn | Closes dialog (removes URL params) |
| `isDialogOpen` | `boolean` | Current dialog open state |
| `dialogResourceId` | `string \| null` | Current resource ID from URL |
| `deleteItem(id)` | `(id: string) => Promise<unknown>` | Mutation that destroys a resource |
| `actionsColumn(options?)` | fn | Generates the actions `ColumnDef` |
| `client` | `TClient` | The domain API client |
| `api` | `ApiInstance` | Full authenticated API object |
---
## `FormDialog` — URL-Driven Dialog
`FormDialog` and its companion hook `useFormDialog` manage dialog open/close state **via URL query parameters**:
| URL param | Value | Meaning |
|---|---|---|
| `dialog` | `true` / absent | Dialog open/closed |
| `resourceId` | string / absent | ID of the item being edited |
This means sharing or refreshing the URL with `?dialog=true&resourceId=5` will reopen the dialog on the same item (as long as `initialData` is in memory—see [Enhancement Plan](./enhancement-plan.md)).
### `useFormDialog`
```ts
const { isOpen, resourceId, open, close } = useFormDialog()
open("5") // opens dialog in edit mode
open() // opens dialog in create mode
close() // closes dialog, clears resourceId
```
---
## `ConfirmDialog` — Imperative Async Confirm
`ConfirmDialog` is a **singleton store-driven dialog** mounted once in the root layout. It exposes an imperative `confirm()` function:
```ts
import { confirm } from "@/shared/components/confirm-dialog"
const ok = await confirm({
title: "Delete this item?",
description: "This action cannot be undone.",
confirmLabel: "Delete",
variant: "destructive", // shows destructive styling + trash icon
})
if (ok) { /* proceed */ }
```
> **Important:** `<ConfirmDialog />` must be rendered once in the root layout. If it is not mounted, `confirm()` will open a dialog that is never displayed.
---
## `createActionsColumn`
A standalone factory for generating the standard Edit + Delete column:
```ts
import { createActionsColumn } from "@/shared/data-view/table-view"
createActionsColumn<MyItem>({
onEdit: (row) => openEdit(row),
onDelete: async (row) => {
const confirmed = await confirm({ ... })
if (confirmed) await deleteItem(String(row.id))
},
})
```
---
## Component Tree
```
<ResourcePage>
└─ <DashboardPage header={...} title={pageTitle}>
├─ <DashboardHeader>
│ └─ <FormDialog title={title}> ← "Add Customer" button + Dialog shell
│ └─ renderForm(resourceId) ← Feature-specific form
└─ <Card>
└─ <CardContent>
└─ <DataTable columns data pagination sorting onChange isLoading>
├─ <DataViewProvider> ← Shares state via context
├─ TanStack Table (manual pagination + sorting)
├─ Skeleton rows while loading
└─ <DataViewPagination> ← Page controls + rows-per-page
```