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

8.0 KiB

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

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

Server-Side Variant

For async server components or server actions:

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

// 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

// 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:

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:

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:

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.

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

// 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:

<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

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 CrudClientuseDataTableQueryResourcePage → feature page, providing end-to-end type safety without any manual typing.