@docyrus/docyrus 0.0.67 → 0.0.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,183 @@
1
+ # Hook-first Docyrus DataGrid Pages
2
+
3
+ ## Use this path when
4
+
5
+ - The page is a normal Docyrus records/index page.
6
+ - Rows come from a Docyrus items endpoint or a generated collection hook.
7
+ - Saved views should drive visible columns, sorting, filters, grouping, paging, and toolbar state.
8
+ - You want the fastest path to a production page.
9
+
10
+ ## Minimal page shell
11
+
12
+ ```tsx
13
+ 'use client';
14
+
15
+ import { useMemo } from 'react';
16
+
17
+ import { useDocyrusAuth } from '@docyrus/signin';
18
+ import {
19
+ DataGrid,
20
+ getDataGridActionsColumn,
21
+ type ColumnDef
22
+ } from '@docyrus/ui/components/data-grid';
23
+ import { useDocyrusDataGrid } from '@docyrus/ui/library/hooks/use-docyrus-data-grid';
24
+ import { Button } from '@docyrus/ui/primitives/ui/button';
25
+
26
+ type OrganizationRow = { id: string; name?: string };
27
+
28
+ export function OrganizationsPage() {
29
+ const { client } = useDocyrusAuth();
30
+
31
+ if (!client) return null;
32
+
33
+ return <OrganizationsPageInner client={client} />;
34
+ }
35
+
36
+ function OrganizationsPageInner({ client }: { client: NonNullable<ReturnType<typeof useDocyrusAuth>['client']> }) {
37
+ const actionsColumn = useMemo<ColumnDef<OrganizationRow>>(
38
+ () => getDataGridActionsColumn<OrganizationRow>({
39
+ cell: ({ row }) => (
40
+ <Button size="sm" variant="ghost" onClick={() => { void row.original.id; }}>
41
+ Open
42
+ </Button>
43
+ )
44
+ }),
45
+ []
46
+ );
47
+
48
+ const { users } = useUsers();
49
+ const { formatDate, formatDateTime, formatNumber } = useGridFormatters();
50
+
51
+ const { table, gridProps, toolbar, resolvedListParams } = useDocyrusDataGrid<OrganizationRow>({
52
+ client,
53
+ appSlug: 'crm',
54
+ dataSourceSlug: 'organization',
55
+ actionsColumn,
56
+ users,
57
+ formatDate,
58
+ formatDateTime,
59
+ formatNumber,
60
+ listParams: { limit: 50 },
61
+ defaultRowGroupingColumn: 'status'
62
+ });
63
+
64
+ return (
65
+ <div className="flex h-full flex-col gap-4 overflow-hidden px-6 py-5">
66
+ <div className="shrink-0">{toolbar}</div>
67
+ <div className="min-h-0 flex-1">
68
+ <DataGrid table={table} {...gridProps} height="auto" />
69
+ </div>
70
+ </div>
71
+ );
72
+ }
73
+ ```
74
+
75
+ If a generated collection already exists, pass `collection` to `useDocyrusDataGrid` and let the hook call `collection.list(resolvedListParams)` instead of the direct items endpoint.
76
+
77
+ ## Data modes
78
+
79
+ 1. `data`: pass pre-resolved rows when another part of the page owns fetching.
80
+ 2. `collection`: pass a generated collection hook; the hook calls `collection.list(resolvedListParams)`.
81
+ 3. direct API: pass only `client`, `appSlug`, and `dataSourceSlug`.
82
+
83
+ Use `onReload` when you use `data` mode, because the hook cannot refetch rows on its own there.
84
+
85
+ ## High-value options
86
+
87
+ ### Columns and toolbar
88
+
89
+ - `actionsColumn`: add per-row actions right after the select column.
90
+ - `extraColumns`: prepend custom columns before metadata-generated Docyrus fields.
91
+ - `mapColumn`: override or skip generated field columns.
92
+ - `defaultRowGroupingColumn`: seed a default grouping for views that do not define one.
93
+ - `systemViews`: add static developer-defined views before saved backend views.
94
+ - `enableViewSelect`, `enableSearchInput`, `enableFilterMenu`, `enableGroupMenu`, `enableSortMenu`, `enableRowHeightMenu`, `enableDisplayMenu`, `enableReloadButton`: trim the standard toolbar.
95
+ - `showSelectColumn`, `enableRowMarkers`: control the left-most reserved column.
96
+
97
+ ### Query shape
98
+
99
+ - `listParams`: append query params like `limit`, `fullCount`, `expand`, or custom backend flags. `listParams` overrides win against the hook's defaults, so use this to pin paging or add tenant-specific flags.
100
+
101
+ ### Tenant-aware formatters
102
+
103
+ - `formatDate?: (value) => string` — wired into `DateCell` display.
104
+ - `formatDateTime?: (value) => string` — wired into `DateTimeCell` display.
105
+ - `formatNumber?: (value, opts?: { variant?: 'number' | 'currency' | 'percent'; currency?: string }) => string` — wired into `NumberCell` / `CurrencyCell` / `PercentCell` display.
106
+
107
+ Build these from `@docyrus/app-utils` (`createDateUtils` + `createNumberUtils` over `getTenantPreferences`). See `tenant-and-users-providers.md` for the standard provider pattern.
108
+
109
+ ### Shared users list
110
+
111
+ - `users?: ReadonlyArray<CellUserOption>` — seeds the static option list for `field-userSelect` and `field-userMultiSelect` cells.
112
+ - When supplied, those cells render avatars + labels from this list immediately.
113
+ - When not supplied, cells fall back to the row's expanded `{ id, name, photo }` payload (which is why the auto-expand for reference fields matters — see below).
114
+ - Type with `import { type CellUserOption } from '@docyrus/ui/components/data-grid'`.
115
+
116
+ ## How backend query params are derived
117
+
118
+ The hook builds `resolvedListParams` from the active view and toolbar state:
119
+
120
+ - `columns`: visible field slugs, with `id` always first.
121
+ - `orderBy`: mapped from `view.sorting`.
122
+ - `filters`: AND-merge of `view.filterQuery` (saved view) and the toolbar filter menu's transient state. Toolbar filter changes refetch the items query automatically (no extra wiring needed in standard mode).
123
+ - `filterKeyword`: mapped from the debounced toolbar search input.
124
+ - `expand`: every visible reference-type column slug is added automatically. Covered types: `field-userSelect`, `field-userMultiSelect`, `field-relation`, `field-relatedField`, `field-select`, `field-radioGroup`, `field-enum`, `field-systemEnum`, `field-multiSelect`, `field-tagSelect`, `field-status`, `field-approvalStatus`.
125
+ - `limit` / `offset`: default to `100` / `0`, then `listParams` wins.
126
+ - grouping column safety: the active grouping field is appended even if the saved view hides it.
127
+
128
+ Inspect `resolvedListParams` when you need debugging, analytics, export, or a "copy query" action.
129
+
130
+ ## Filter menu (`DataGridFilterMenu`) behavior
131
+
132
+ The hook's toolbar wires the filter menu so it pulls live data from the backend instead of only filtering loaded rows.
133
+
134
+ - Toolbar filter additions/changes trigger a refetch with the merged `filters` payload.
135
+ - Filter changes reset `pageIndex` to 0 so a stale offset can't land out of bounds.
136
+ - Toolbar filters are session-transient and clear when the active view changes.
137
+ - Field type to filter type mappings:
138
+ - `field-userSelect`, `field-userMultiSelect`, `field-relation`, `field-relatedField`, `field-multiSelect`, `field-tagSelect` → `multiOption` (server `in` operator), with debounced async option search.
139
+ - `field-status`, `field-approvalStatus`, `field-enum`, `field-systemEnum`, `field-select`, `field-radioGroup` → `option` with the field's static enum options.
140
+ - `field-checkbox`, `field-switch` → `boolean` (true / false / empty).
141
+ - `field-date`, `field-dateTime`, `field-dateRange` → `date` with `is between` for ranges.
142
+ - `field-money`, `field-currency`, `field-percent`, `field-rating`, `field-duration`, `field-number`, `field-autonumber` → `number`.
143
+ - `field-file`, `field-image`, `field-chart`, etc. are excluded from the filter menu entirely.
144
+ - Async option search calls `/v1/users` for user fields and `/v1/apps/{appSlug}/data-sources/{slug}/items` for relations. The hook caches the data-sources lookup needed to resolve `relationDataSourceId` (UUID) to the URL slugs, valid for 5 minutes.
145
+
146
+ ## Reserved columns
147
+
148
+ `useDocyrusDataGrid` always:
149
+
150
+ - Re-prepends `select` and `actions` to the column order after a saved view is applied (saved views only know real field slugs).
151
+ - Pins both reserved columns to the left so they stay visible during horizontal scroll.
152
+
153
+ You don't need to configure pinning yourself; the hook merges with whatever the saved view declares.
154
+
155
+ ## Cell variants for reference fields
156
+
157
+ Out of the box (no `mapColumn` needed):
158
+
159
+ - `field-userSelect` → `UserCell` with avatar + name. If a static `users` list is passed, options come from there; otherwise the cell reads the row's expanded `{ id, name, photo }` payload directly.
160
+ - `field-userMultiSelect` → `UserMultiSelectCell` (avatar stack). Same options/expanded-payload fallback.
161
+ - `field-relation`, `field-relatedField` → `RelationCell` showing the related record's name (from `expand`).
162
+ - `field-status`, `field-approvalStatus` → `StatusCell` with color + icon from the field's enums.
163
+ - `field-select`, `field-radioGroup` → `SelectCell` with color/icon support.
164
+ - `field-multiSelect`, `field-tagSelect` → multi-select cells with chips.
165
+ - `field-identity` → `UuidCell`. Defaults to `showCopyButton: true` (60px fixed-width icon-only column). Override `mapColumn` to set `showCopyButton: false` for a 300px full-text column.
166
+ - `field-url` → `UrlCell` rendering as a link with `target="_blank"`.
167
+ - `field-money`, `field-currency`, `field-percent`, `field-number` → `NumberCell` family. Honor the `formatNumber` option for tenant-aware locale formatting.
168
+ - `field-date`, `field-dateTime` → `DateCell` / `DateTimeCell`. Honor `formatDate` / `formatDateTime`.
169
+
170
+ Use `mapColumn` only when you need to override these defaults.
171
+
172
+ ## Important behavior
173
+
174
+ - `gridProps` already carries the active view's paging settings. Usually you should just spread it into `<DataGrid>`.
175
+ - Backend-saved views store field slugs, not reserved columns like `select` or `actions`. The hook re-prepends and re-pins those reserved columns for you.
176
+ - In `data` mode the search box becomes client-side global filtering. In backend modes it becomes `filterKeyword`.
177
+ - The hook controls TanStack `columnFilters` state. If you build a manual page, replicate this so toolbar filter changes can drive your row query.
178
+
179
+ ## Default recommendation
180
+
181
+ If the page is a CRUD-style Docyrus list page, start here first. Drop to manual composition only when the toolbar, saved-view lifecycle, or row fetching needs to diverge from the standard behavior.
182
+
183
+ For system views, hidden views, paging ownership, or translating active views into a custom query pipeline, also read `advanced-saved-view-query-patterns.md`.
@@ -0,0 +1,145 @@
1
+ # Manual DataGrid Page Patterns
2
+
3
+ ## Use this path when
4
+
5
+ - The page uses local/demo data.
6
+ - You need a custom toolbar layout.
7
+ - You want backend-saved views but your own row-query lifecycle.
8
+ - You only need local views and not Docyrus view persistence.
9
+
10
+ ## Local views with full manual composition
11
+
12
+ ```tsx
13
+ 'use client';
14
+
15
+ import { useEffect, useMemo, useState } from 'react';
16
+
17
+ import {
18
+ DataGrid,
19
+ DataGridFilterMenu,
20
+ DataGridGroupMenu,
21
+ DataGridRowHeightMenu,
22
+ DataGridSortMenu,
23
+ applyViewToTable,
24
+ getDataGridActionsColumn,
25
+ getDataGridSelectColumn,
26
+ useDataGrid,
27
+ type ColumnDef,
28
+ type SavedDataGridView
29
+ } from '@docyrus/ui/components/data-grid';
30
+ import { DataGridViewSelect } from '@docyrus/ui/components/data-grid-view-select';
31
+
32
+ const columns: ColumnDef<Row>[] = [
33
+ getDataGridSelectColumn<Row>(),
34
+ getDataGridActionsColumn<Row>({ cell: ({ row }) => <OpenButton row={row.original} /> }),
35
+ ...dataColumns
36
+ ];
37
+
38
+ export function TasksPage() {
39
+ const [views, setViews] = useState<SavedDataGridView[]>(INITIAL_VIEWS);
40
+ const [activeViewId, setActiveViewId] = useState('all');
41
+ const activeView = useMemo(() => views.find(view => view.id === activeViewId), [views, activeViewId]);
42
+
43
+ const { table, ...gridProps } = useDataGrid<Row>({
44
+ data: rows,
45
+ columns,
46
+ enableSearch: true,
47
+ enableGrouping: true
48
+ });
49
+
50
+ useEffect(() => {
51
+ if (!activeView) return;
52
+ applyViewToTable(table, activeView);
53
+ }, [table, activeView]);
54
+
55
+ return (
56
+ <div className="flex h-full flex-col gap-4 overflow-hidden">
57
+ <div className="shrink-0 flex items-center gap-2">
58
+ <DataGridViewSelect
59
+ table={table}
60
+ variant="horizontal-tabs"
61
+ editable
62
+ views={views}
63
+ activeViewId={activeViewId}
64
+ onViewChange={(view) => setActiveViewId(view.id)}
65
+ onViewSave={(view) => setViews(prev => prev.map(item => item.id === view.id ? view : item))}
66
+ onViewCreate={(view) => setViews(prev => [...prev, view])}
67
+ onViewDelete={(viewId) => setViews(prev => prev.filter(item => item.id !== viewId))} />
68
+ <DataGridFilterMenu table={table} />
69
+ <DataGridGroupMenu table={table} />
70
+ <DataGridSortMenu table={table} />
71
+ <DataGridRowHeightMenu table={table} />
72
+ </div>
73
+ <div className="min-h-0 flex-1">
74
+ <DataGrid table={table} {...gridProps} height="auto" />
75
+ </div>
76
+ </div>
77
+ );
78
+ }
79
+ ```
80
+
81
+ `DataGridViewSelect` applies views automatically when the user clicks a different tab. The `useEffect` above is still needed for the initial/default view.
82
+
83
+ ## Backend saved views with custom row fetching
84
+
85
+ ```tsx
86
+ const viewSelect = useDocyrusDataViewSelect({
87
+ client,
88
+ appSlug: 'crm',
89
+ dataSourceSlug: 'contacts',
90
+ defaultRowGroupingColumn: 'status',
91
+ systemViews: [ALL_CONTACTS_VIEW]
92
+ });
93
+
94
+ const activeView = useMemo(
95
+ () => viewSelect.views.find(view => view.id === viewSelect.activeViewId),
96
+ [viewSelect.views, viewSelect.activeViewId]
97
+ );
98
+
99
+ const { table, ...gridProps } = useDataGrid<Row>({ data: rows, columns, enableGrouping: true });
100
+
101
+ useEffect(() => {
102
+ if (!activeView) return;
103
+ applyViewToTable(table, activeView);
104
+ }, [table, activeView]);
105
+
106
+ <DataGridViewSelect table={table} editable {...viewSelect.gridViewSelectProps} />
107
+ ```
108
+
109
+ Important: `useDocyrusDataViewSelect` gives you `views`, `fields`, `activeViewId`, CRUD callbacks, hidden-view state, and persistence. It does **not** fetch rows. If the active view should also shape the backend query, translate `activeView.columnVisibility`, `columnOrder`, `sorting`, and `filterQuery` into your request yourself or switch to `useDocyrusDataGrid`.
110
+
111
+ ## Manual toolbar building blocks
112
+
113
+ Use these when you do not want the prebuilt hook toolbar:
114
+
115
+ - `DataGridViewSelect`
116
+ - `DataGridFilterMenu`
117
+ - `DataGridSortMenu`
118
+ - `DataGridGroupMenu`
119
+ - `DataGridRowHeightMenu`
120
+ - `DataGridDisplayMenu`
121
+ - `DataGridViewMenu`
122
+ - your own search input wired to `table.setGlobalFilter(...)` or to backend query state
123
+
124
+ For persistence, system views, paging ownership, or manual translation of the active view into Docyrus query params, also read `advanced-saved-view-query-patterns.md`.
125
+
126
+ ## What you lose by going manual
127
+
128
+ `useDocyrusDataGrid` does several things automatically that manual mode does **not**:
129
+
130
+ - **Auto-`expand`** for reference fields. In manual mode you must add reference field slugs to your request's `expand` array yourself, or rich cells (user / relation / select / status) will only see bare IDs.
131
+ - **Reserved-column pinning.** `select` and `actions` are not auto-pinned to the left in manual mode — call `table.setColumnPinning({ left: ['select', 'actions'], right: [] })` after applying the active view.
132
+ - **Filter menu refetch wiring.** `DataGridFilterMenu` writes through to `table.setColumnFilters`, but a manual page's row query doesn't watch that state. You need to read `table.getState().columnFilters` (or lift the state into the host) and translate the filter values into your request's `filters` payload. Reset `pageIndex` to 0 on changes.
133
+ - **Async option search for relation/user filter columns.** The filter menu will show empty option lists unless you pass a `getAsyncOptions` resolver (signature: `(column) => AsyncOptionsConfig | undefined`) that calls `/v1/users` for user fields and `/v1/apps/{appSlug}/data-sources/{slug}/items` for relations.
134
+ - **Tenant-aware formatters.** Pass `formatDate`, `formatDateTime`, `formatNumber` through `useDataGrid({ meta: { ... } })`. Cells read them from `tableMeta`.
135
+ - **Shared users list.** Pass `users: ReadonlyArray<CellUserOption>` to `buildTanstackColumnDef({ users })` (or set the `cell.options` directly via `mapColumn`) so user cells get avatar + label from the global list.
136
+
137
+ If any of these matter to the page, prefer `useDocyrusDataGrid` and use `mapColumn` / `extraColumns` / toolbar enable flags to customize.
138
+
139
+ ## Gotchas
140
+
141
+ - Local/manual `SavedDataGridView` objects can include reserved columns like `select` and `actions` in `columnOrder`.
142
+ - Backend Docyrus-saved views store real field slugs only. If you want the standard reserved-column behavior, `useDocyrusDataGrid` is the safest path.
143
+ - Pass `fields` into `DataGridViewSelect` when you want the filter builder inside the editor.
144
+ - Pass `isSaving` and `isLoading` when the host owns async view CRUD.
145
+ - For manual Docyrus item requests, always send `columns`. Add reference-type field slugs (`field-userSelect`, `field-userMultiSelect`, `field-relation`, `field-relatedField`, `field-select`, `field-radioGroup`, `field-enum`, `field-systemEnum`, `field-multiSelect`, `field-tagSelect`, `field-status`, `field-approvalStatus`) to `expand` so the API returns `{ id, name, ... }` payloads.
@@ -0,0 +1,290 @@
1
+ # Tenant Preferences and Shared Users Providers
2
+
3
+ `useDocyrusDataGrid` accepts two tenant-scoped inputs that should be sourced from app-level providers, not refetched per page:
4
+
5
+ - **Tenant preferences** → drives `formatDate`, `formatDateTime`, `formatNumber` so date and money cells respect regional settings.
6
+ - **Shared users list** → seeds `field-userSelect` / `field-userMultiSelect` cell options so avatar + name render instantly without per-row fetches.
7
+
8
+ Both should be fetched **once** at app boot and re-used everywhere.
9
+
10
+ ## Tenant preferences provider
11
+
12
+ Source the utilities from `@docyrus/app-utils`:
13
+
14
+ ```ts
15
+ import {
16
+ getTenantPreferences,
17
+ createDateUtils,
18
+ createNumberUtils,
19
+ type DateUtils,
20
+ type NumberUtils,
21
+ type TenantPreferences
22
+ } from '@docyrus/app-utils';
23
+ ```
24
+
25
+ ### Provider shape
26
+
27
+ ```tsx
28
+ 'use client';
29
+
30
+ import {
31
+ createContext, useContext, useMemo, type ReactNode
32
+ } from 'react';
33
+
34
+ import { useDocyrusAuth } from '@docyrus/signin';
35
+ import { useQuery } from '@tanstack/react-query';
36
+
37
+ interface TenantContextValue {
38
+ preferences: TenantPreferences | null;
39
+ dateUtils: DateUtils | null;
40
+ numberUtils: NumberUtils | null;
41
+ isLoading: boolean;
42
+ }
43
+
44
+ const TenantContext = createContext<TenantContextValue>({
45
+ preferences: null,
46
+ dateUtils: null,
47
+ numberUtils: null,
48
+ isLoading: false
49
+ });
50
+
51
+ export function TenantProvider({ children }: { children: ReactNode }) {
52
+ const { client, status, user } = useDocyrusAuth();
53
+ const userTimezone = (user as { timeZone?: { id?: string } } | null)?.timeZone?.id;
54
+
55
+ const query = useQuery<TenantPreferences>({
56
+ queryKey: ['tenant', 'preferences'],
57
+ queryFn: () => getTenantPreferences(client!),
58
+ enabled: status === 'authenticated' && Boolean(client),
59
+ staleTime: 30 * 60_000
60
+ });
61
+
62
+ const value = useMemo<TenantContextValue>(() => {
63
+ const preferences = query.data ?? null;
64
+
65
+ if (!preferences) {
66
+ return {
67
+ preferences: null, dateUtils: null, numberUtils: null, isLoading: query.isLoading
68
+ };
69
+ }
70
+
71
+ return {
72
+ preferences,
73
+ dateUtils: createDateUtils({ preferences, userTimezone }),
74
+ numberUtils: createNumberUtils({ preferences }),
75
+ isLoading: query.isLoading
76
+ };
77
+ }, [query.data, query.isLoading, userTimezone]);
78
+
79
+ return <TenantContext.Provider value={value}>{children}</TenantContext.Provider>;
80
+ }
81
+
82
+ export function useTenant() {
83
+ return useContext(TenantContext);
84
+ }
85
+ ```
86
+
87
+ ### Wrapping for the data-grid hook
88
+
89
+ The grid hook expects three callable formatters. Wrap the utils once and expose them via a small hook:
90
+
91
+ ```tsx
92
+ export function useGridFormatters() {
93
+ const { dateUtils, numberUtils } = useTenant();
94
+
95
+ return useMemo(() => {
96
+ const formatDate = (value: unknown): string => {
97
+ if (value == null) return '';
98
+ if (!dateUtils) return String(value);
99
+ try { return dateUtils.formatDate(value as string | Date) ?? ''; } catch { return String(value); }
100
+ };
101
+
102
+ const formatDateTime = (value: unknown): string => {
103
+ if (value == null) return '';
104
+ if (!dateUtils) return String(value);
105
+ try { return dateUtils.formatDateTime(value as string | Date) ?? ''; } catch { return String(value); }
106
+ };
107
+
108
+ const formatNumber = (
109
+ value: unknown,
110
+ opts?: { variant?: 'number' | 'currency' | 'percent'; currency?: string }
111
+ ): string => {
112
+ if (value == null || value === '') return '';
113
+
114
+ const numeric = typeof value === 'number' ? value : Number(value);
115
+
116
+ if (Number.isNaN(numeric)) return String(value);
117
+ if (!numberUtils) return String(value);
118
+
119
+ const variant = opts?.variant ?? 'number';
120
+ const base = numberUtils.formatNumber(variant === 'percent' ? numeric * 100 : numeric) ?? String(numeric);
121
+
122
+ if (variant === 'currency' && opts?.currency) return `${base} ${opts.currency}`;
123
+ if (variant === 'percent') return `${base}%`;
124
+
125
+ return base;
126
+ };
127
+
128
+ return { formatDate, formatDateTime, formatNumber };
129
+ }, [dateUtils, numberUtils]);
130
+ }
131
+ ```
132
+
133
+ Note the `formatNumber` wrapper handles the variant semantics:
134
+
135
+ - `'percent'` multiplies by 100 and appends `%`.
136
+ - `'currency'` appends the currency code provided in the cell meta.
137
+ - Plain `'number'` goes straight through `numberUtils.formatNumber`.
138
+
139
+ If the consuming app uses different conventions (e.g. always show a currency symbol), customize the wrapper accordingly.
140
+
141
+ ## Shared users provider
142
+
143
+ `useDocyrusDataGrid` accepts `users: ReadonlyArray<CellUserOption>` (`{ value, label, avatarUrl?, initials? }`). Populate it from `/v1/users`:
144
+
145
+ ```tsx
146
+ 'use client';
147
+
148
+ import {
149
+ createContext, useContext, useMemo, type ReactNode
150
+ } from 'react';
151
+
152
+ import { useDocyrusAuth } from '@docyrus/signin';
153
+ import { useQuery } from '@tanstack/react-query';
154
+
155
+ import { type CellUserOption } from '@docyrus/ui/components/data-grid';
156
+
157
+ interface UserRecord {
158
+ id: string;
159
+ firstname?: string | null;
160
+ lastname?: string | null;
161
+ email?: string | null;
162
+ photo?: string | null;
163
+ [key: string]: unknown;
164
+ }
165
+
166
+ const UsersContext = createContext<{ users: ReadonlyArray<CellUserOption>; records: ReadonlyArray<UserRecord>; isLoading: boolean }>({
167
+ users: [], records: [], isLoading: false
168
+ });
169
+
170
+ function pickString(record: Record<string, unknown>, ...keys: Array<string>): string | null {
171
+ for (const key of keys) {
172
+ const value = record[key];
173
+
174
+ if (typeof value === 'string' && value.length > 0) return value;
175
+ }
176
+
177
+ return null;
178
+ }
179
+
180
+ function toUserOption(record: UserRecord): CellUserOption | null {
181
+ if (!record.id) return null;
182
+
183
+ const firstName = pickString(record, 'firstname', 'firstName', 'first_name');
184
+ const lastName = pickString(record, 'lastname', 'lastName', 'last_name');
185
+ const fullName = firstName ? lastName ? `${firstName} ${lastName}` : firstName : lastName;
186
+ const email = pickString(record, 'email');
187
+ const label = fullName ?? email ?? record.id;
188
+ const avatarUrl = pickString(record, 'photo', 'avatar_url', 'avatarUrl', 'profile_image_url', 'profileImageUrl') ?? undefined;
189
+
190
+ return { value: record.id, label, ...(avatarUrl ? { avatarUrl } : {}) };
191
+ }
192
+
193
+ export function UsersProvider({ children }: { children: ReactNode }) {
194
+ const { client, status } = useDocyrusAuth();
195
+
196
+ const query = useQuery<Array<UserRecord>>({
197
+ queryKey: ['users', 'all'],
198
+ queryFn: async () => {
199
+ const response = await client!.get<Array<UserRecord> | { data?: Array<UserRecord> }>(
200
+ '/v1/users',
201
+ { limit: 1000 }
202
+ );
203
+
204
+ return Array.isArray(response) ? response : (response.data ?? []);
205
+ },
206
+ enabled: status === 'authenticated' && Boolean(client),
207
+ staleTime: 5 * 60_000
208
+ });
209
+
210
+ const value = useMemo(() => {
211
+ const records = query.data ?? [];
212
+ const users = records
213
+ .map(toUserOption)
214
+ .filter((option): option is CellUserOption => option !== null);
215
+
216
+ return { users, records, isLoading: query.isLoading };
217
+ }, [query.data, query.isLoading]);
218
+
219
+ return <UsersContext.Provider value={value}>{children}</UsersContext.Provider>;
220
+ }
221
+
222
+ export function useUsers() {
223
+ return useContext(UsersContext);
224
+ }
225
+ ```
226
+
227
+ ### User record field name fallbacks
228
+
229
+ The Docyrus `/v1/users` API returns `firstname` / `lastname` (lowercase, no separator) plus `email` and `photo`. The provider also accepts the common camelCase / snake_case aliases as fallbacks so the same code works against tenants that have customized the user schema.
230
+
231
+ ## Wiring providers into the app shell
232
+
233
+ Place the providers inside the auth + query providers and outside the routes:
234
+
235
+ ```tsx
236
+ <AuthProvider>
237
+ <QueryProvider>
238
+ <DevtoolsProvider>
239
+ <TenantProvider>
240
+ <UsersProvider>
241
+ <Routes>{/* ... */}</Routes>
242
+ </UsersProvider>
243
+ </TenantProvider>
244
+ </DevtoolsProvider>
245
+ </QueryProvider>
246
+ </AuthProvider>
247
+ ```
248
+
249
+ Order matters:
250
+
251
+ - `AuthProvider` must wrap both because the providers need an authenticated `client` to fetch.
252
+ - `QueryProvider` must wrap both because they use `useQuery`.
253
+ - `TenantProvider` and `UsersProvider` are siblings; either order is fine.
254
+
255
+ ## Wiring providers into a grid page
256
+
257
+ ```tsx
258
+ const { users } = useUsers();
259
+ const { formatDate, formatDateTime, formatNumber } = useGridFormatters();
260
+
261
+ const { table, gridProps, toolbar } = useDocyrusDataGrid<Row>({
262
+ client,
263
+ appSlug,
264
+ dataSourceSlug,
265
+ users,
266
+ formatDate,
267
+ formatDateTime,
268
+ formatNumber,
269
+ // ... other options
270
+ });
271
+ ```
272
+
273
+ The grid hook routes:
274
+
275
+ - `formatDate` / `formatDateTime` / `formatNumber` into TanStack `tableMeta`. `DateCell`, `DateTimeCell`, `NumberCell` (and its currency / percent variants) read these via `tableMeta?.formatDate` etc. and prefer them over their built-in fallbacks.
276
+ - `users` into the cell-options builder for `field-userSelect` and `field-userMultiSelect` columns. The cells render avatar + label from this list immediately. Rows pointing at users not in the list still render correctly via the expanded-payload fallback.
277
+
278
+ ## Why centralize these
279
+
280
+ - Single network round-trip per session for tenant prefs and the user roster.
281
+ - Consistent formatting and avatar display across every grid, form, and dialog in the app.
282
+ - Pages don't need to know about formatters or user fetching — they just consume context.
283
+ - Tenant settings change rarely (long staleTime); user roster also changes slowly (5 min staleTime is a safe default).
284
+
285
+ ## Debug checklist
286
+
287
+ - **Dates / numbers showing as raw values?** Tenant query likely failed or hasn't resolved yet. Check `useTenant().isLoading` and the network tab for `getTenantPreferences`.
288
+ - **User cells show "Unassigned" instead of names?** Either `users` wasn't passed in, or the row's user payload didn't expand. Verify `useDocyrusDataGrid` sees both the global `users` list (via context) and that the user column slug appears in `expand` (auto-added; check `resolvedListParams.expand`).
289
+ - **Currency shows "1.234,50 USD" but you wanted "$1,234.50"?** Customize the `formatNumber` wrapper for that variant. The default is locale-formatted number + currency code suffix.
290
+ - **Avatars missing?** The provider reads `photo` first, then `avatar_url` / `avatarUrl` / `profile_image_url` / `profileImageUrl`. Confirm the API returns one of those fields.
@@ -0,0 +1,54 @@
1
+ ---
2
+ name: docyrus-record-detail-form-design
3
+ description: Build Docyrus React record forms, detail sheets, inspector panels, and inline-edit record UIs with `useDocyrusFormView`, `DynamicFormField`, `DynamicValue`, `EditableRecordDetail`, `EditableValue`, and value renderers. Use when asked to create or refactor create/edit/view pages, modal or sheet record forms, click-to-edit detail panels, metadata-driven field layouts, manual read-only record summaries, or field-type mapping logic that must choose correctly between form inputs, inline editors, and value-renderer fallbacks.
4
+ ---
5
+
6
+ # Docyrus Record Detail Form Design
7
+
8
+ Build full Docyrus record create, edit, and detail experiences around shared field metadata.
9
+
10
+ ## Choose the build mode
11
+
12
+ 1. **Standard create/edit/view record screen** → use `useDocyrusFormView`.
13
+ - Best when the page is a normal Docyrus form or detail surface.
14
+ - Handles metadata loading, item loading, option hydration, field rendering, and submit behavior.
15
+ - Read `references/hook-form-view.md`.
16
+
17
+ 2. **Custom manual form or detail layout** → use `DynamicFormField`, `DynamicValue`, or `useDocyrusFieldComponent`.
18
+ - Best when the page already owns form state, layout, or data fetching.
19
+ - Read `references/manual-form-detail-patterns.md`.
20
+
21
+ 3. **Inline editing, click-to-edit detail rows, or field-by-field editing** → use `EditableRecordDetail` and `EditableValue`.
22
+ - Best when users should edit a record in place without switching to a full form.
23
+ - Read `references/advanced-inline-edit-and-renderers.md`.
24
+
25
+ 4. **Complex query/schema/API work** → also load `docyrus-api-dev` or `docyrus-app-dev-react`.
26
+ 5. **Field-type mapping or unsupported-field decisions** → read `references/field-type-mapping-and-fallbacks.md`.
27
+
28
+ ## Default workflow
29
+
30
+ 1. Confirm `appSlug`, `dataSourceSlug`, `mode`, and `itemId` if the record already exists.
31
+ 2. Decide whether the page should be hook-first or fully manual.
32
+ 3. Keep editable fields and read-only renderers on the same field-type registry.
33
+ 4. Hydrate enum, user, and relation options before declaring the page done.
34
+ 5. Verify create, edit, view, reset, and save behavior.
35
+ 6. If the page supports inline editing, verify companion-field saves for composite types such as money, phone, and status.
36
+
37
+ ## Non-negotiables
38
+
39
+ - Prefer `useDocyrusFormView` for standard create/edit/view pages.
40
+ - Manual editable layouts should use `DynamicFormField`, specific form-field components, or `useDocyrusFieldComponent(..., 'form-field')`.
41
+ - Manual read-only layouts should use `DynamicValue`, specific value renderers, or `useDocyrusFieldComponent(..., 'value-renderer')`.
42
+ - Do not invent parallel per-field switch statements when the shared registry already solves the dispatch.
43
+ - `useDocyrusFormView` already keeps form fields, value renderers, and inline detail mode aligned through `useDocyrusFieldComponent`.
44
+ - `clickToEdit` on `useDocyrusFormView` view layouts routes through `EditableRecordDetail`; use it when you want view-first UX with inline saves.
45
+ - Manual Docyrus item fetching must always send `columns`. `useDocyrusFormView` derives them automatically, including companion columns.
46
+ - Composite fields can require companion keys on read and write: money, phone, status, avatar, and similar types must stay intact in manual save flows.
47
+ - If a field has no editable component, create-mode UX should usually skip it; edit/view UX should usually fall back to a value renderer unless you have a better explicit design.
48
+
49
+ ## References
50
+
51
+ - `references/hook-form-view.md` — hook-first create, edit, view, layout, submit, and click-to-edit patterns.
52
+ - `references/manual-form-detail-patterns.md` — manual form and detail composition with field components, renderers, and shared field maps.
53
+ - `references/advanced-inline-edit-and-renderers.md` — `EditableRecordDetail`, `EditableValue`, renderer selection rules, and companion-field save patterns.
54
+ - `references/field-type-mapping-and-fallbacks.md` — shared field maps, fallback rules, and unsupported-field strategy by render mode.