# useDocyrusFormView URL: /docs/web/hooks/use-docyrus-form-view One-call wiring of a Docyrus data source item to create, edit, and read-only layouts with shared field-component mapping, item loading, option hydration, and submit handling. ## Installation ```bash pnpm dlx @docyrus/cli add @docyrus/hooks-use-docyrus-form-view ``` **Dependencies:** - [@docyrus/app-utils](https://www.npmjs.com/package/@docyrus/app-utils) - [@docyrus/api-client](https://www.npmjs.com/package/@docyrus/api-client) - [@tanstack/react-query](https://tanstack.com/query/latest) This hook is distributed as source. It requires an authenticated `RestApiClient` from `@docyrus/api-client` and a `QueryClientProvider` from `@tanstack/react-query` somewhere above your component tree. ## Overview `useDocyrusFormView` is the one-call entry point for Docyrus record forms and detail views. It wires together: - **data source metadata** (`fields`, enum expansions, relation targets) - **item loading** for edit/view flows - **local form state** compatible with the Docyrus form-field components - **shared field-component resolution** via [`useDocyrusFieldComponent`](/docs/web/hooks/use-docyrus-field-component) - **option hydration** for enum, user, and relation fields - **submit handling** for create/update flows - **layout helpers** (`renderField`, `renderLayout`) - **optional click-to-edit detail mode** in read-only views via [`EditableRecordDetail`](/docs/web/docyrus/editable-record-detail) - **computed fields** and imperative **field actions** driven by JSONata / query-builder rules - **form-level actions** (`onFormLoad` / `onFormBeforeSubmit` / `onFormAfterSubmit`) and **form-level validations** - **DB-free operation** — inject `dataSource` + `item` to render entirely from in-memory schema (no `getBySlug`) The result is a single hook you can use to build: - **create forms** - **edit forms** - **read-only record layouts** without writing your own per-field switch statements. > **Not on a Docyrus backend?** [`useDynamicFormView`](/docs/web/hooks/use-dynamic-form-view) is the backend-agnostic sibling of this hook. It shares the exact same rendering / computed / actions / validation engine but does no fetching — you supply the `fields`, `values`, static `enumOptions`, upload handlers, and an `onSubmit` sink to wire any backend. ## Centralized field-component mapping The hook does **not** keep its own parallel field-type registry. Instead it resolves components from the shared `useDocyrusFieldComponent` / `FORM_FIELD_MAP` source of truth: - **editable mode** → `useDocyrusFieldComponent(field.type, 'form-field')` - **read-only mode** → `useDocyrusFieldComponent(field.type, 'value-renderer')` That means `useDocyrusFormView`, `DynamicFormField`, `DynamicValue`, and `useDocyrusDataGrid` all stay aligned when a new Docyrus field type is added. Behavior is determined like this: 1. If the field type has a registered **form-field component** and the field is not read-only, it renders as an editable form input. 2. Otherwise it renders with the registered **value renderer**. 3. In **create** mode, unsupported editable types default to `unsupportedFieldBehavior='skip'`. 4. In **edit/view** mode, unsupported or read-only types default to `unsupportedFieldBehavior='value'`. ## Backend connection The hook supports three layers of backend work. ### 1) Data source metadata By default the hook loads the data source through `createDataSourceClient(client).getBySlug(appSlug, dataSourceSlug, { expand: schemaExpand })` (where `schemaExpand` defaults to `'enums'`). That fetch provides the field metadata used to: - build the local `IField` shape for Docyrus form/value components - read enum options for select-like fields - derive companion columns that must be requested for composite fields - detect relation target data sources Two options let you adapt or bypass this fetch: - **`schemaExpand`** — change or drop the `expand` query param. Pass `false`/`''` for backends (e.g. core/tenant system data sources) that don't support `expand`. - **`dataSource`** — inject a pre-resolved schema object. When provided, the `getBySlug` call is **skipped entirely** and every downstream derivation reads from your object instead. See [DB-free / in-memory schema](#db-free--in-memory-schema). ### 2) Item loading For **edit** and **view** flows, the hook resolves the record in this precedence order: 1. **`item`** — pre-resolved object; skips the item query entirely 2. **`collection.get(recordId, params)`** — generated/custom collection mode 3. **Direct API** — `GET /v1/apps/:appSlug/data-sources/:dataSourceSlug/items/:itemId` The hook always sends `columns`, including companion fields needed by composite renderers. ### 3) Remote option loading When needed, the hook hydrates option lists for dynamic selectors: - **enum-backed select fields** (`field-select`, `field-radioGroup`, `field-enum`, `field-systemEnum`, `field-status`, `field-multiSelect`, `field-tagSelect`) → options normally arrive inline with the schema. When a field's inline options come back empty, the hook falls back to a single tenant-wide `GET /v1/apps/enums` (shared cache key, 30 min stale time) and reads that field's options from the tree; with nothing missing the request is never made - **user fields** (`field-userSelect`, `field-userMultiSelect`) → `GET /v1/users` - **relation fields** (`field-relation`) → 1. `GET /v1/apps/data-sources?expand=fields` 2. resolve the target by `relationDataSourceId` 3. query target items with a minimal `columns` set for labels + item mapping fields You can disable user / relation fetches via `resolveUserOptions={false}` / `resolveRelationOptions={false}` or override any field with `enumOptions={{ [slug]: [...] }}`. ## Usage Below are four complete usage patterns for the same Docyrus data source. ### 1) Create Item Use `mode: 'create'` when you want to start from defaults and submit a brand-new record. ```tsx 'use client'; import { useDocyrusAuth } from '@docyrus/signin'; import { useDocyrusFormView } from '@docyrus/ui/library/hooks/use-docyrus-form-view'; import { Button } from '@docyrus/ui/primitives/ui/button'; export function CreateContactForm() { const { client } = useDocyrusAuth(); if (!client) return null; const createView = useDocyrusFormView({ client, appSlug: 'crm', dataSourceSlug: 'contacts', mode: 'create', gridColumns: 2, defaultValues: { status: 'lead' }, fieldOrder: ['full_name', 'email', 'phone', 'status', 'notes'], fieldLayout: { notes: { colSpan: 'full' } } }); return (
{ event.preventDefault(); await createView.submit(); }} className="space-y-4"> {createView.renderLayout()}
); } ``` ### 2) Edit Item Use `mode: 'edit'` with an `itemId` to load an existing record, keep the same field mapping, and submit updates back to Docyrus. ```tsx 'use client'; import { useDocyrusAuth } from '@docyrus/signin'; import { useDocyrusFormView } from '@docyrus/ui/library/hooks/use-docyrus-form-view'; import { Button } from '@docyrus/ui/primitives/ui/button'; export function EditContactForm({ contactId }: { contactId: string }) { const { client } = useDocyrusAuth(); if (!client) return null; const editView = useDocyrusFormView({ client, appSlug: 'crm', dataSourceSlug: 'contacts', itemId: contactId, mode: 'edit', gridColumns: 2, fieldOrder: ['full_name', 'email', 'phone', 'status', 'notes'], fieldLayout: { notes: { colSpan: 'full' }, status: { description: 'Primary pipeline status' } } }); return (
{ event.preventDefault(); await editView.submit(); }} className="space-y-4"> {editView.renderLayout()}
); } ``` ### 3) View Item Use `mode: 'view'` to render the same record as a read-only detail layout. ```tsx 'use client'; import { useDocyrusAuth } from '@docyrus/signin'; import { useDocyrusFormView } from '@docyrus/ui/library/hooks/use-docyrus-form-view'; export function ContactDetail({ contactId }: { contactId: string }) { const { client } = useDocyrusAuth(); if (!client) return null; const detailView = useDocyrusFormView({ client, appSlug: 'crm', dataSourceSlug: 'contacts', itemId: contactId, mode: 'view', gridColumns: 2, fieldOrder: ['full_name', 'email', 'phone', 'status', 'notes'] }); return detailView.renderLayout(); } ``` ### 4) View Item Click to Edit When `clickToEdit` is enabled and `renderLayout()` is called in `view` mode, the hook swaps the plain value grid for [`EditableRecordDetail`](/docs/web/docyrus/editable-record-detail). Fields stay read-only visually until the user clicks into a row, then saves inline changes through the normal Docyrus update pipeline. ```tsx 'use client'; import { useDocyrusAuth } from '@docyrus/signin'; import { useDocyrusFormView } from '@docyrus/ui/library/hooks/use-docyrus-form-view'; export function ContactInlineDetail({ contactId }: { contactId: string }) { const { client } = useDocyrusAuth(); if (!client) return null; const inlineDetailView = useDocyrusFormView({ client, appSlug: 'crm', dataSourceSlug: 'contacts', itemId: contactId, mode: 'view', clickToEdit: true, fieldOrder: ['full_name', 'email', 'phone', 'status', 'notes'] }); return inlineDetailView.renderLayout(); } ``` ### Nested sections with fieldset + tabpanel + tab Use `layout` when a plain flat grid is not enough. Every section renders its own grid with the active `gridColumns` count, and both fields and sections can span multiple columns. ```tsx 'use client'; import { useDocyrusAuth } from '@docyrus/signin'; import { useDocyrusFormView } from '@docyrus/ui/library/hooks/use-docyrus-form-view'; import { Button } from '@docyrus/ui/primitives/ui/button'; const contactLayout = [ { id: 'identity', variant: 'fieldset', title: 'Identity', colSpan: 2, items: [ { type: 'field', slug: 'full_name', colSpan: 2 }, 'email', 'phone' ] }, { id: 'sales-workspace', variant: 'tabpanel', title: 'Sales Workspace', colSpan: 'full', defaultTabId: 'overview', items: [ { id: 'overview', variant: 'tab', title: 'Overview', items: [ { type: 'field', slug: 'status', colSpan: 2 }, 'owner', { type: 'field', slug: 'expected_value', colSpan: 2 }, { type: 'field', slug: 'next_step', colSpan: 2 } ] }, { id: 'notes', variant: 'tab', title: 'Notes', items: [{ type: 'field', slug: 'notes', colSpan: 'full' }] } ] } ] as const; export function EditContactWorkspace({ contactId }: { contactId: string }) { const { client } = useDocyrusAuth(); if (!client) return null; const formView = useDocyrusFormView({ client, appSlug: 'crm', dataSourceSlug: 'contacts', itemId: contactId, mode: 'edit', gridColumns: 4, layout: contactLayout }); return (
{ event.preventDefault(); await formView.submit(); }} className="space-y-4"> {formView.renderLayout()}
); } ``` ### Collection mode If your app already has a generated Docyrus collection, pass it in and the hook will use `collection.get`, `collection.create`, and `collection.update` instead of raw endpoints. ```tsx const contacts = useCrmContactsCollection(); const formView = useDocyrusFormView({ client, appSlug: 'crm', dataSourceSlug: 'contacts', itemId, collection: contacts }); ``` ### DB-free / in-memory schema Pass `dataSource` to render a form from schema you already hold in memory — the hook never calls `getBySlug`. Combine it with `item` (record data) and `layout` for a form that performs **no network requests at all** (useful for previews, the form builder, snapshot tests, or system data sources without a metadata route). Setting `enabled: false` additionally stops the relation-option fetch. ```tsx 'use client'; import { useDocyrusFormView } from '@docyrus/ui/library/hooks/use-docyrus-form-view'; import type { DataSource } from '@docyrus/app-utils'; // Schema + record already resolved elsewhere (cache, props, fixture, …) const contactSchema: DataSource = { id: 'ds_contacts', slug: 'contacts', fields: [ { id: '1', name: 'Full Name', slug: 'full_name', type: 'field-text' }, { id: '2', name: 'Status', slug: 'status', type: 'field-select', options: { /* … */ } } ] // …other DataSource metadata } as DataSource; export function ContactPreview({ record }: { record: Record }) { const previewView = useDocyrusFormView({ client, appSlug: 'crm', dataSourceSlug: 'contacts', mode: 'view', dataSource: contactSchema, // ← skips getBySlug item: record, // ← skips the item fetch enabled: false // ← skips relation-option fetch (fully offline) }); return previewView.renderLayout(); } ``` When `dataSource` is provided with `enabled: true`, the schema is still injected (no `getBySlug`) but live option fetches (`/v1/users`, relation targets, `/v1/apps/enums`) remain active — handy when you have the schema cached but still want fresh option lists. ## API Reference ### Parameters | Option | Type | Default | Description | |--------|------|---------|-------------| | `client` | `RestApiClient` | — | Authenticated Docyrus API client. | | `appSlug` | `string` | — | Slug of the app that owns the data source. | | `dataSourceSlug` | `string` | — | Slug of the data source. | | `itemId` | `string` | — | Record id for edit/view flows. | | `mode` | `'create' \| 'edit' \| 'view'` | `itemId ? 'edit' : 'create'` | Explicit form mode. Use `'view'` for read-only layouts. | | `item` | `Record \| null` | — | Pre-resolved record. When provided, skips the item query. | | `dataSource` | `DataSource \| null` | — | Pre-resolved data-source schema. When provided, the hook **skips the `getBySlug` metadata fetch entirely** and reads fields / metadata from this object. Pair with `item` + `layout` (and `enabled: false`) for a fully DB-free form. See [DB-free / in-memory schema](#db-free--in-memory-schema). | | `collection` | `{ get?, create?, update? }` | — | Generated/custom collection used instead of direct REST calls. | | `enabled` | `boolean` | `true` | Disable all remote queries while surrounding state is still loading. | | `staleTime` | `number` | `30_000` | TanStack Query stale time for metadata, item, and remote-option queries. | | `schemaExpand` | `string \| false` | `'enums'` | `expand` query param sent with the data-source schema fetch (so select/status fields carry their option metadata). Pass `false` (or `''`) to omit `expand` entirely for backends that don't support it — e.g. core/tenant system data sources. Ignored when `dataSource` is injected. | | `disabled` | `boolean` | `false` | Disables editable fields globally. | | `defaultValues` | `Record` | — | Extra defaults merged after schema defaults and before the loaded item. | | `itemQueryParams` | `DocyrusFormViewGetParams` | — | Extra query params for the item `get` request. `columns` is merged, not replaced. | | `fieldSlugs` | `string[]` | — | Whitelist fields by slug before layout/rendering. | | `fieldOrder` | `string[]` | — | Explicit field ordering. Unlisted fields sort after listed ones. | | `hiddenFieldSlugs` | `string[]` | — | Hard-hide fields by slug. | | `fieldLayout` | `Record` | — | Per-field UI overrides: hidden/required/readOnly/disabled, colSpan, labels, descriptions, and prop overrides. | | `layout` | `DocyrusFormViewLayoutItem[]` | — | Optional nested layout tree. Supports `fieldset`, `tabpanel`, and `tab` sections plus explicit field items. Visible fields not referenced in the tree are appended after the declared layout. | | `mapField` | `(field, defaultMapped) => IField \| null` | — | Per-field transform after metadata normalization. Return `null` to drop the field completely. | | `dynamicLabelTranslator` | `(label: string) => string` | — | Translate / override every field label before render. Called once per field with the schema label (`field.name`); return the text to display. Applied after `mapField`. See [Translating field labels](#translating-field-labels). | | `dynamicEnumOptionTranslator` | `(option: EnumOption, field: IField) => string` | — | Translate / override every enum option label in dropdowns, chips, and read-only value rows. Called once per resolved option; return the display text. Key by `enums..`; `slug` / `color` / `icon` are preserved. See [Translating enum options](#translating-enum-options). | | `includeReadOnlyFields` | `boolean` | `mode !== 'create'` | Include fields that resolve to read-only display rows. | | `validationTokens` | `'off' \| 'form' \| 'all'` | `'off'` | Enforce the `validations` token constraints beyond `required` on submit. `'off'` keeps them advisory (nothing changes for existing forms); `'form'` enforces only the tokens a saved form declares for a field; `'all'` also enforces the data-source field's own tokens. `required` is always enforced. | | `unsupportedFieldBehavior` | `'skip' \| 'value'` | `'skip'` in create, `'value'` otherwise | Whether unsupported field types disappear or fall back to value-render mode. | | `gridColumns` | `1 \| 2 \| 3 \| 4` | `2` | Default responsive column count used by `renderLayout()`. | | `clickToEdit` | `boolean` | `false` | When `true` and `renderLayout()` is used in `view` mode, the hook renders [`EditableRecordDetail`](/docs/web/docyrus/editable-record-detail) instead of the simple value grid and persists inline saves through the standard update pipeline. | | `resolveUserOptions` | `boolean` | `true` | Whether to fetch `/v1/users` for user selector fields. | | `resolveRelationOptions` | `boolean` | `true` | Whether to resolve relation target options automatically. | | `optionLimit` | `number` | `100` | Limit used when loading relation target items. | | `enumOptions` | `Record` | — | Field-level option overrides. Wins over static enums and remote fetches. | | `transformSubmit` | `(payload, context) => payload` | — | Final payload transform before mutation. | | `onSubmit` | `(payload, context) => Promise \| unknown` | — | Full custom submit handler. When provided, bypasses default create/update logic. | | `onSubmitSuccess` | `(result, payload) => void` | — | Called after a successful mutation. | | `onSubmitError` | `(error, payload) => void` | — | Called after a failed mutation. | | `formActions` | `FormAction[] \| null` | — | Form-level lifecycle actions evaluated on `onFormLoad`, `onFormBeforeSubmit`, and `onFormAfterSubmit`. Same block/step structure as field actions. See [Form-level actions](#form-level-actions). | | `formCustomValidations` | `FormCustomValidationRule[] \| null` | — | Form-level validation rules evaluated on submit (after field-level validation passes). Failures surface in `formValidationErrors` as banner messages. See [Form-level validations](#form-level-validations). | ### `DocyrusFormViewFieldLayout` | Field | Type | Description | |-------|------|-------------| | `hidden` | `boolean \| ((values) => boolean)` | Hide the field conditionally. | | `required` | `boolean \| ((values) => boolean)` | Mark the field required conditionally. | | `readOnly` | `boolean` | Force the field into value-render mode. | | `disabled` | `boolean` | Disable editing without changing render mode. | | `colSpan` | `1 \| 2 \| 3 \| 4 \| 'full'` | Width override used by `renderLayout()`. | | `className` | `string` | Extra wrapper/field className. | | `label` | `ReactNode` | Override the display label. | | `description` | `ReactNode` | Description shown in read-only layout or available to your custom props. | | `fieldProps` | `Partial ``` ### Circular action protection If action A sets field B and that triggers action B which sets field A (and so on), execution stops after 5 nested levels to prevent infinite loops. A warning is logged in development. ## Form-level actions Where field actions react to a single field's `onFieldChange`, **form-level actions** react to the form's lifecycle. Pass them via the top-level `formActions` option. They share the exact same block/step structure as field actions (`FieldActionBlock[]`, the same step methods, the same JSONata / QB conditions). ```tsx import type { FormAction } from '@docyrus/ui/components/form-fields'; ``` | Field | Type | Description | |-------|------|-------------| | `id` | `string` | Stable action id. | | `name` | `string` | Optional label. | | `triggerType` | `'onFormLoad' \| 'onFormBeforeSubmit' \| 'onFormAfterSubmit'` | Lifecycle event that fires the action. | | `blocks` | `FieldActionBlock[]` | Ordered blocks — same shape as field actions (`conditionalItems` → `elseActions` → `unconditionalActions`). | ### Triggers | Trigger | When it fires | Notes | |---------|---------------|-------| | `onFormLoad` | Once, when initial data finishes loading (immediately in `create` mode). | Guarded so it runs a single time per mount. Use it to seed defaults or pre-hide/disable fields based on the loaded record. | | `onFormBeforeSubmit` | After field + form validation passes, **before** the API call. | May mutate field values (e.g. last-minute transforms) before the payload is built. | | `onFormAfterSubmit` | After a successful API call. | The mutation result is available to expressions via the `$result` binding. | ```tsx const formView = useDocyrusFormView({ client, appSlug: 'crm', dataSourceSlug: 'contacts', mode: 'create', formActions: [ { id: 'fa-load', triggerType: 'onFormLoad', blocks: [ { id: 'b1', sortOrder: 0, conditionalItems: [ { id: 'c1', condition: "source = 'import'", actions: [{ method: 'setFieldReadOnly', fieldSlug: 'email', readOnly: true }] } ], elseActions: [], unconditionalActions: [] } ] } ] }); ``` Form-action property overrides accumulate just like field-action overrides and are cleared by `resetActionOverrides()`. When two layers touch the same property, priority is **computed formulas → field-action overrides → form-action overrides → `fieldLayout` callbacks → `IField` defaults**. ## Form-level validations `formCustomValidations` are submit-time rules evaluated **after** all field-level validation passes. Each rule's `expression` must return `true` for the form to be valid; otherwise its `message` is collected into `formValidationErrors` and the submit is aborted. ```tsx import type { FormCustomValidationRule } from '@docyrus/ui/components/form-fields'; ``` | Field | Type | Description | |-------|------|-------------| | `id` | `string` | Stable rule id. | | `expression` | `string` | JSONata expression or QB rule-group JSON. Must return `true` to pass. Evaluated against current form values. | | `message` | `string` | Banner message shown when the rule fails. | Render `formValidationErrors` as a destructive banner above the form: ```tsx import { Alert, AlertDescription } from '@docyrus/ui/primitives/ui/alert'; const formView = useDocyrusFormView({ client, appSlug: 'crm', dataSourceSlug: 'deals', mode: 'edit', itemId: dealId, formCustomValidations: [ { id: 'v1', // close date must be on/after the open date expression: 'close_date >= open_date', message: 'Close date cannot be earlier than the open date.' } ] }); return (
{ e.preventDefault(); await formView.submit(); }}> {formView.formValidationErrors.length > 0 && ( )} {formView.renderLayout()}
); ``` Field-level errors stay per-field in `validationErrors` (keyed by slug); form-level errors are global and live in `formValidationErrors`. ## Out of scope - record comments, attachments, or activity timelines — compose those around the hook - autosave — call `submit()` on your own schedule if needed - server-side rendering data preload — this hook is intentionally client-first and React Query driven