279 lines
8.0 KiB
Markdown
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.
|