garage-erp/docs/dashboard/crud/data-fetching.md
2026-03-27 16:03:58 +03:00

279 lines
8.0 KiB
Markdown

# 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.