Data Layer
The React back-office app talks to the backend through one TanStack Query
layer and one shared axios client. All data fetching goes through hooks in
app/src/data/hooks/; all HTTP goes through app/src/axiosInstance.js. Do not call
fetch/axios ad hoc or stand up a second query client.
DataProvider and the query client
Section titled “DataProvider and the query client”DataProvider (app/src/data/DataProvider.jsx) wraps the app in TanStack Query’s
QueryClientProvider. It is mounted high in the tree (around the router in App.jsx).
The shared QueryClient (app/src/data/queryClient.js) is configured for always-fresh
data rather than aggressive caching:
queries: { staleTime: 0, // immediately stale — refetch on mount gcTime: 0, // no retained cache — GC immediately retry: 1, // retry a failed query once refetchOnWindowFocus: false, // don't refetch on tab focus refetchOnReconnect: true, // refetch when the network returns refetchOnMount: true, // always refetch when a component mounts}mutations: { retry: 0 } // never retry a mutationBecause queries are always stale and not retained, the cache primarily serves deduplication and invalidation during a session rather than long-lived offline reads.
Cache keys
Section titled “Cache keys”app/src/data/keys.js centralizes every cache key in a queryKeys factory so that
invalidation stays consistent. Highlights:
queryKeys.query(model, params) // ['query', model, params] — list/record queriesqueryKeys.model(model) // ['query', model] — prefix for bulk invalidationqueryKeys.record(model, id) // ['record', model, id] — a single recordqueryKeys.chart(model, params) // per-view-type families: chart/pivot/calendar/gantt/orgchartqueryKeys.view(viewId) // a fetched view definitionqueryKeys.profile() // ['profile']queryKeys.navMenu() // ['navMenu']// ...plus files, activityStream, unreadCount, metrics, databases, etc.A companion normalizeQueryParams(...) sorts/normalizes filter, grouping, ordering and
aggregate params so that two semantically identical requests produce the same cache key
regardless of argument order. Mutations invalidate by prefix — e.g. after a create the
mutation hooks call invalidateQueries({ queryKey: queryKeys.model(model) }), which clears
every query for that model.
Data hooks
Section titled “Data hooks”Exported from app/src/data/hooks/index.js. They split into generic primitives,
view-specific fetchers, and record operations.
Generic primitives
Section titled “Generic primitives”| Hook | Purpose | Returns (selected) |
|---|---|---|
useModelQuery({ model, filter, fields, page, limit, orderBy, groupFields, ... }) | The generic record query (Q-expression filter string, paging, grouping, aggregates) | { data, isLoading, isFetching, error, refetch } |
useModelMutation(model, { onSuccess, onError, invalidateOnSuccess }) | create / update / delete with auto cache invalidation | mutation fns + states |
useExecuteMethod({ onSuccess, onError, invalidateOnSuccess }) | Call a backend model method | { execute, mutate, isExecuting, error, reset } |
useGetRecord({ model, id, fields, enabled }) | Fetch one record declaratively | { data, isLoading, isFetching, isError, error, refetch } |
useGetRecordMutation({ onSuccess, onError }) | Fetch one record imperatively (event handlers/effects) | { getRecord, isFetching, error, reset } |
useUiEffect({ onSuccess, onError }) | Trigger server-side UI effects (onChange / onDefault) for live forms | { trigger, isTriggering, error, reset } |
View-specific fetchers
Section titled “View-specific fetchers”These wrap useModelQuery and wire it to the FilterContext / PaginationContext /
NavigationContext so the view’s filter, grouping, search and paging flow in
automatically:
useListData, useKanbanData, useChartData, usePivotData, useCalendarData,
useGanttData, useOrgChartData, useFilterPanelData.
const { data, count, isLoading, isFetching, error, refetch } = useListData({ actionData, // action with model + views + global_filter fields, // fields from the list view groupAggregates, // [{ field, type }] for grouped aggregates footerAggregates, // [{ field, type }] for whole-dataset footers});Record operations
Section titled “Record operations”| Hook | Purpose |
|---|---|
useArchive(model, { onSuccess, onError }) | { archive, unarchive, isArchiving, ... } |
useUnarchive | Unarchive-only variant |
useDeleteRecords({ onSuccess, onError }) | Bulk delete: { deleteRecords, isDeleting, ... } |
useCloneRecord | Duplicate a record |
useResequence | Reorder records (drag-sort) |
There are also focused hooks for files (useGetFiles, useUploadFile, …), views/actions
(useFetchView, useLoadAction), wizards (useTransientDefaults, useExecuteWizard),
messaging (useSendMessage, useManageFollowers), export/import (useExportData,
useImport), search (useGlobalSearch, useNameSearch), reports (useGenerateReport,
useRenderTemplate), the activity stream (useActivityStream, useUnreadCount), and
profile/admin concerns (useProfile, useNavMenu, useLogout, useMetrics,
useDatabases, …). See app/src/data/hooks/index.js for the full export list.
The axios instance
Section titled “The axios instance”app/src/axiosInstance.js exports the single configured client (baseURL = the current
origin, withCredentials: true). Every API call routes through it so that cross-cutting
behavior is applied once.
Request interceptor
Section titled “Request interceptor”- Stamps
config._requestStartand dispatches anapi-request-startwindow event. - Adds
X-Current-URL(the current page),X-DB-NAME(from thedb=cookie), andX-Company-IDs(fromselectedCompaniesinlocalStorage) for multi-company filtering.
Response interceptor
Section titled “Response interceptor”Server timing. On both success and error it computes a request duration — preferring
the server-reported Server-Timing: dur=<ms> header over the client round-trip — and
dispatches an api-request-end event with { duration } (used by the dev timing display).
Enterprise license gate. A 403 whose body carries code: "ENTERPRISE_LICENSE_REQUIRED"
does not raise a generic toast. Instead it dispatches an enterprise-license-required
window event, which the shared UpgradeDialog listens for and opens (license-key entry /
purchase / uninstall). This is the front-end half of the enterprise gating mechanism.
Token refresh + re-auth queue. A 401 with code / detail of
TOKEN_INVALID_OR_EXPIRED triggers automatic recovery:
- While a refresh is in flight (
isRefreshing), concurrent failing requests are parked in afailedQueue(a promise per request) instead of each firing its own refresh. - The interceptor POSTs the
refresh_tokencookie to/auth/refresh. On success the queue is drained and the original request is retried. - If refresh fails and a re-auth handler was registered via
setReAuthHandler(...), it runs in-app re-authentication; on success the queue drains and the request retries, on dismissal it rejects the queue and redirects to/login. - With no re-auth handler, it shows a “Session Expired” toast and redirects to
/login.
Generic error toast. Other errors (with a response, not flagged skipErrorToast, and
not Import Error) are passed to the registered error handler (setErrorHandler(...)) with
title, message, traceback, status and an optional actionable action. Requests marked
skipErrorToast (e.g. background offline sync) fail silently, and a network failure with no
error.response (offline) deliberately does not raise the generic toast.
Action result handling
Section titled “Action result handling”When a backend method returns an action result (the common case for action buttons and
wizards), the response is dispatched by handleActionResult in
app/src/utils/actionResultHandler.jsx. It takes the result plus an options bag
(t, reloadView, pushModal/popModal, setActionResponse, activeId/activeIds/
activeModel, buttonLabel, shouldReload, …) and branches on result.type:
result.type | Behavior |
|---|---|
file_blob / pdf_blob | Download the binary via an object URL; no reload |
file_download | Download a base64 payload (content + mimetype); no reload |
wizard | Lazy-load WizardModal and pushModal it — drawer for single-step, fullscreen modal when the arch has a stepper; merges active_model / active_ids into the wizard context; on complete, dispatches a returned action or reloads |
window_action | setActionResponse(result) (with active_id) → navigation/view switch |
send_message | Lazy-load SendMessageModal and open it (message / note / invite-followers) |
url | Open in a new tab (target: '_blank') or navigate the current window |
close | Optionally toast notify, optionally reload |
reload | Toast success and reload the view |
notify | Show a notification (color, title, message); if it carries an action, render an actionable button that deep-links via setActionResponse |
report | Generate a report via the report API |
| (none / unknown) | Toast a generic success and reload if shouldReload |
Notifications are raised through Mantine’s notifications.show(...), and all user-facing
strings pass through the injected t (whose default follows English-as-key:
(key, fallback) => fallback ?? key). The executeMethod flow (via useExecuteMethod)
calls into this handler so that a single backend return value drives downloads, wizards,
navigation, notifications, close and reload consistently.