useDocyrusFormView
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
pnpm dlx @docyrus/cli add @docyrus/hooks-use-docyrus-form-viewpnpm add @docyrus/app-utils @docyrus/api-client @tanstack/react-queryThis 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 - 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 - 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+itemto render entirely from in-memory schema (nogetBySlug)
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?
useDynamicFormViewis the backend-agnostic sibling of this hook. It shares the exact same rendering / computed / actions / validation engine but does no fetching — you supply thefields,values, staticenumOptions, upload handlers, and anonSubmitsink 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:
- If the field type has a registered form-field component and the field is not read-only, it renders as an editable form input.
- Otherwise it renders with the registered value renderer.
- In create mode, unsupported editable types default to
unsupportedFieldBehavior='skip'. - 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
IFieldshape 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 theexpandquery param. Passfalse/''for backends (e.g. core/tenant system data sources) that don't supportexpand.dataSource— inject a pre-resolved schema object. When provided, thegetBySlugcall is skipped entirely and every downstream derivation reads from your object instead. See DB-free / in-memory schema.
2) Item loading
For edit and view flows, the hook resolves the record in this precedence order:
item— pre-resolved object; skips the item query entirelycollection.get(recordId, params)— generated/custom collection mode- 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-wideGET /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) →GET /v1/apps/data-sources?expand=fields- resolve the target by
relationDataSourceId - query target items with a minimal
columnsset 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.
'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 (
<form
onSubmit={async (event) => {
event.preventDefault();
await createView.submit();
}}
className="space-y-4">
{createView.renderLayout()}
<div className="flex items-center gap-2">
<Button type="submit" disabled={createView.isSubmitting || createView.isLoading}>
Create Contact
</Button>
<Button type="button" variant="outline" onClick={createView.reset}>
Reset
</Button>
</div>
</form>
);
}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.
'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 (
<form
onSubmit={async (event) => {
event.preventDefault();
await editView.submit();
}}
className="space-y-4">
{editView.renderLayout()}
<div className="flex items-center gap-2">
<Button type="submit" disabled={editView.isSubmitting || editView.isLoading}>
Save Changes
</Button>
<Button type="button" variant="outline" onClick={editView.reset}>
Reset
</Button>
</div>
</form>
);
}3) View Item
Use mode: 'view' to render the same record as a read-only detail layout.
'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. Fields stay read-only visually until the user clicks into a row, then saves inline changes through the normal Docyrus update pipeline.
'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.
'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 (
<form
onSubmit={async (event) => {
event.preventDefault();
await formView.submit();
}}
className="space-y-4">
{formView.renderLayout()}
<Button type="submit">Save Changes</Button>
</form>
);
}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.
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.
'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<string, unknown> }) {
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<string, unknown> | 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. |
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<string, unknown> | — | 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<string, DocyrusFormViewFieldLayout> | — | 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. |
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.<field.slug>.<option.slug>; slug / color / icon are preserved. See 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 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<string, EnumOption[]> | — | 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> | 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. |
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. |
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<DocyrusFormFieldProps> | Forwarded to the resolved form-field component. |
valueProps | Partial<DocyrusValueProps> | Forwarded to the resolved value renderer. |
computedHidden | string | RuleGroupType | null | JSONata expression or QB rule group. When it evaluates to true the field is hidden. |
computedRequired | string | RuleGroupType | null | JSONata expression or QB rule group. When it evaluates to true the field is required. |
computedLabel | string | null | JSONata expression. Result replaces the field label. |
computedDescription | string | null | JSONata expression. Result replaces the field description. |
computedFormula | string | null | JSONata expression. Result is written back as the field's live value. |
fieldActions | FieldAction[] | null | Override IField.fieldActions for this field. Takes priority over the data-source definition. Evaluated by the imperative field-actions engine on every onFieldChange event for this field. |
customValidations | CustomValidationRule[] | null | Runtime custom validation rules for this field. Overrides IField.customValidations from the data source schema. Evaluated on submit() / validate(). |
validations | string[] | null | Validation token list (required, minLength:N, maxLength:N, pattern:RE, min:N, max:N) that overrides IField.validations for this form. Saved form layouts forward their per-field tokens here. |
DocyrusFormViewLayoutFieldItem
| Field | Type | Description |
|---|---|---|
type | 'field' | Marks the node as an explicit field entry. |
slug | string | Field slug to render in this position. |
colSpan | 1 | 2 | 3 | 4 | 'full' | Per-placement width override for this field node. |
className | string | Extra grid-cell wrapper className for this field node. |
DocyrusFormViewSection
Common section fields:
| Field | Type | Description |
|---|---|---|
id | string | Stable section id. Also used to build tab values internally. |
variant | 'fieldset' | 'tabpanel' | 'tab' | Section rendering mode. |
title | ReactNode | Section title or tab label. |
description | ReactNode | Optional helper text shown with the section. |
className | string | Extra className for the section container. |
contentClassName | string | Extra className for the inner section grid. |
colSpan | 1 | 2 | 3 | 4 | 'full' | Width of the section inside its parent grid. Most useful for fieldset and tabpanel. |
Variant-specific fields:
| Variant | Extra fields | Description |
|---|---|---|
fieldset | items: DocyrusFormViewLayoutItem[], columns?: 1 | 2 | 3 | 4, collapsible?: boolean, defaultCollapsed?: boolean | Groups fields/subsections inside a bordered fieldset and renders them in a grid. columns overrides the form grid for the panel's own contents; collapsible adds a fold toggle on the legend (start folded with defaultCollapsed). |
tabpanel | items: DocyrusFormViewTabSection[], defaultTabId?: string | Renders a tab list and one active tab panel at a time. Each tab panel keeps its own grid layout. |
tab | items: DocyrusFormViewLayoutItem[] | Tab content node. Use it as a child of a tabpanel section. |
Return Value
| Property | Type | Description |
|---|---|---|
mode | DocyrusFormViewMode | Final resolved mode (create, edit, or view). |
dataSource | DataSource | undefined | Raw data source metadata response. |
item | Record<string, unknown> | Current values object exposed as the record currently being rendered. |
form | { Field(...) } | Internal form object compatible with the Docyrus form-field components. |
values | Record<string, unknown> | Current live values snapshot. |
defaultValues | Record<string, unknown> | Resolved initial values after schema defaults, defaultValues, and loaded item merge. |
columns | string[] | Item columns requested from the backend, including companion fields. |
fields | DocyrusFormViewField[] | Resolved visible field descriptors used by the layout helpers. |
allFields | DocyrusFormViewField[] | Currently the same resolved field list as fields. |
unsupportedFields | DocyrusFormViewField[] | Fields currently shown as value-render fallbacks because no form-field component exists. |
validationErrors | Map<string, string> | Current validation errors keyed by field slug — includes both required-field and custom-validation errors. |
formValidationErrors | string[] | Form-level validation messages from formCustomValidations, produced on submit() / validate() after field-level checks pass. Render these as a destructive banner above the form. See Form-level validations. |
isDirty | boolean | Whether current values differ from the committed baseline. |
isLoading | boolean | true while metadata, item, or remote-option queries are loading. |
isSubmitting | boolean | true while submit() is running. |
error | Error | null | First query error, if any. |
setValue | (slug, value) => void | Imperatively update one field value. |
validate | () => Promise<boolean> | Run validation manually without submitting. Returns true when all fields pass. Sets validationErrors. |
reset | () => void | Reset current values back to the committed baseline. |
submit | () => Promise<unknown> | Validate and run the create/update/custom submit pipeline. |
refetch | () => void | Refetch metadata, item, and remote options. |
resetActionOverrides | () => void | Clear all accumulated field-level and form-level action property overrides (hidden/readOnly/disabled/required). Call alongside reset() when you want a full form reset including action side-effects. |
renderField | (slug, options?) => ReactNode | Render a single resolved field by slug. |
renderLayout | (options?) => ReactNode | Render the visible field list using the built-in responsive grid layout. |
DocyrusFormViewField
Each entry in the fields array exposes the fully-resolved state for a single field after all computed, layout, and schema-level overrides have been applied.
| Property | Type | Description |
|---|---|---|
slug | string | Field slug. |
field | IField | Normalized field metadata (type, options, computed expressions, etc.). |
sourceField | DataSourceField | Raw field object from the data source metadata response. |
value | unknown | Current form value for this field. |
hidden | boolean | Resolved visibility after computed, layout, and hiddenFieldSlugs overrides. |
required | boolean | Resolved required state after computed and layout overrides. |
readOnly | boolean | Whether the field is in value-render-only mode. |
disabled | boolean | Whether the field input is disabled. |
editable | boolean | true when the field has a registered form component and is not read-only. |
renderMode | 'form' | 'value' | Whether this field is rendered with a form-field or a value renderer. |
label | ReactNode | Resolved label after computedLabel and fieldLayout.label overrides. |
description | ReactNode | Resolved description after computedDescription and fieldLayout.description overrides. |
colSpan | 1 | 2 | 3 | 4 | 'full' | undefined | Width override for renderLayout(). |
enumOptions | EnumOption[] | Resolved option list for select-like fields. |
queryKeys | string[] | Column keys requested from the backend for this field (includes companion keys). |
submitKeys | string[] | Payload keys included in the submit body for this field (includes companion keys). |
fieldProps | Partial<DocyrusFormFieldProps> | Extra props forwarded to the form-field component. |
valueProps | Partial<DocyrusValueProps> | Extra props forwarded to the value renderer. |
You can iterate over fields directly to build custom layouts or inspect computed state:
const hiddenCount = formView.fields.filter(f => f.hidden).length;
const requiredSlugs = formView.fields.filter(f => f.required).map(f => f.slug);Columns auto-requested for item queries
The hook derives item columns automatically from the field metadata so composite renderers have the extra data they need.
| Field type | Extra columns added |
|---|---|
field-money | __<slug>_currency |
field-phone | __<slug>_country |
field-status | __<slug>_secondary, __<slug>_description, __<slug>_followup_date |
field-htmlEditor, field-emailEditor | __<slug>_html |
field-avatar | mapped avatar companion fields (iconField, colorField, imageField) |
Any itemQueryParams.columns you provide are merged on top of this derived list.
Option resolution
Option lists are resolved in this precedence order:
enumOptions[field.slug]override- Static field metadata (
expand=enums/options) for select-like fields /v1/apps/enumsfallback for enum-backed selectors whose metadata options came back empty/v1/usersfor user selectors- relation target lookup for relation selectors
Static enum-backed field types
The hook reads metadata options for:
field-selectfield-radioGroupfield-enumfield-systemEnumfield-statusfield-multiSelectfield-tagSelect
Relation option labels
For relation fields, the hook inspects the target data source's field list and chooses the first useful label field from:
nametitledisplay_namedisplayNamefull_namefullNamesubjectcodeemaillabelslug
If none of those exist, it falls back to the first textual field, then finally id.
Default values and reset behavior
Initial values are built in this order:
- field-level schema defaults (
field.defaultValue) - built-in defaults for composite fields
defaultValues- loaded/provided
item
Special parsing includes:
- booleans for
field-checkbox/field-switch - numbers for numeric field types
- JSON parsing for structured field types such as
field-docEditor,field-json,field-taskList,field-schemaRepeater,field-locationSelect,field-file, andfield-image - companion-field bootstrap for money, phone, status, and avatar fields
reset() restores the current values to the most recently committed baseline. After a successful submit(), that baseline is updated to the saved values.
Submit pipeline
submit() runs this sequence:
- skip validation entirely in
viewmode and just return current values - validate every visible editable field in order —
required, then the declarativevalidationstokens whenvalidationTokensis enabled (minLength:/maxLength:/pattern:/min:/max:), then that field'scustomValidationsJSONata rules; the first failure per field wins - build a submit payload from each field's
submitKeys - optionally run
transformSubmit(payload, context) - submit via one of:
onSubmit(payload, context)collection.create/collection.update- direct
POST/PATCHto the Docyrus items endpoint
- call
onSubmitSuccess/onSubmitError - commit the saved values as the new clean baseline on success
Companion submit keys
Composite field payloads automatically include their companion keys:
| Field type | Submit keys |
|---|---|
field-money | slug, __slug_currency |
field-phone | slug, __slug_country |
field-status | slug, __slug_secondary, __slug_description, __slug_followup_date |
field-avatar | mapped avatar fields |
Render semantics
renderField(slug, options?)
Renders exactly one resolved field.
- In create/edit mode, editable field types render with their form-field component from
useDocyrusFieldComponent. - In view mode — or when a field is read-only / unsupported — the hook renders the value renderer instead.
renderLayout(options?)
Renders the whole resolved field list.
Default behavior
- when
layoutis omitted,renderLayout()renders a single responsive grid of resolved fields - when
layoutis provided,renderLayout()walks the nested layout tree and rendersfieldset,tabpanel, andtabsections recursively - default columns come from
gridColumns - override columns via
renderLayout({ columns: 1 | 2 | 3 | 4 }) - per-field width comes from
fieldLayout[slug].colSpan - explicit layout field items can override width again with
layout[].colSpan - sections can also span the parent grid with their own
colSpan colSpan: 'full'expands a field or section across the whole row
Click-to-edit behavior
When clickToEdit is true and the layout is rendered in view mode, renderLayout() routes field rows through EditableRecordDetail.
- rows become inline-editable on interaction
- unsupported or intrinsically read-only fields stay read-only inside the detail view
- successful inline saves update the Docyrus record through the hook's normal mutation pipeline
fieldClassNameis applied to eachEditableRecordDetailFieldrow- flat layouts still behave like a standard
EditableRecordDetaillist - section-based layouts keep their declared fieldset/tab structure while the leaf rows remain inline editable
Computed fields
Five fieldLayout properties let you drive field behavior with reactive JSONata expressions. Every expression receives the current form values as its data context. Expressions are re-evaluated asynchronously (debounced 200 ms) whenever any value changes, and the results are merged into the resolved fields array that renderLayout() reads.
computedHidden
Hide or show a field based on another field's value.
fieldLayout={{
vat_number: {
// visible only when is_company is true
computedHidden: 'is_company != true'
}
}}computedRequired
Make a field conditionally required.
fieldLayout={{
reason: {
// required when status is 'rejected'
computedRequired: "status = 'rejected'"
}
}}computedFormula
Write a computed value back into the field. The result of the expression replaces the field's current value every time the inputs change. Useful for calculated columns such as totals, full-name concatenation, or derived codes.
fieldLayout={{
total_price: {
// total_price = qty × unit_price
computedFormula: '$number(qty) * $number(unit_price)'
},
full_name: {
computedFormula: 'first_name & " " & last_name'
}
}}Fields whose type is a known numeric type (field-number, field-money, field-percent, field-duration, field-rating) are automatically coerced from their HTML-string representation to JS numbers before evaluation, so arithmetic works without wrapping every reference in $number(). Other field types are intentionally left as strings.
When the expression produces an error or its inputs are absent, the field is cleared to null rather than keeping a stale value.
computedLabel
Dynamically replace the field label with a string returned by the expression.
fieldLayout={{
discount: {
computedLabel: '"Discount (" & $string($round($number(discount) * 100)) & "%)"'
}
}}computedDescription
Dynamically replace the field description.
fieldLayout={{
notes: {
computedDescription: '"Character count: " & $string($length(notes))'
}
}}QB rule objects for computedHidden / computedRequired
computedHidden and computedRequired also accept a Query Builder JSON rule group (a RuleGroupType from react-querybuilder). The hook converts it to a JSONata expression at compile time. This is the format the Docyrus backend stores when rules are configured through the visual query builder.
fieldLayout={{
shipping_address: {
computedHidden: {
combinator: 'and',
rules: [{ field: 'delivery_type', operator: '=', value: 'digital' }]
}
}
}}Evaluation priority
| Property | Priority order (highest → lowest) |
|---|---|
hidden | computedHidden → field action override → hiddenFieldSlugs → fieldLayout.hidden → false |
required | computedRequired → field action override → fieldLayout.required → schema default |
readOnly | mode === 'view' → field action override → fieldLayout.readOnly → IField.readOnly → false |
disabled | field action override → fieldLayout.disabled → false |
label | computedLabel → fieldLayout.label → schema name |
description | computedDescription → fieldLayout.description → undefined |
value | computedFormula result → user input (formula wins and overwrites) |
Expression context
Every expression receives the current form values object as its root data. Use field slugs directly as identifiers.
/* Both references are live form values */
$number(qty) * $number(unit_price) * (1 - $number(discount_pct) / 100)The JSONata function library ($string, $number, $round, $length, $uppercase, $now, etc.) is available in every expression.
computedFormula and read-only fields
computedFormula writes back regardless of the field's readOnly or disabled state. If you want the computed result to be visible but not editable by the user, pair computedFormula with readOnly: true:
fieldLayout={{
total_price: {
computedFormula: '$number(qty) * $number(unit_price)',
readOnly: true
}
}}Translating field labels
Field labels default to each field's schema label (field.name). Pass dynamicLabelTranslator to remap them from your own i18n dictionary — the hook calls it once per field and renders whatever string you return. Omit the prop and labels stay exactly as the schema defines them.
The function receives the raw label and returns the display text. Return the label unchanged for anything you don't want to translate:
// Simple dictionary
const labels: Record<string, string> = {
Name: 'İsim',
Status: 'Durum',
Description: 'Açıklama'
};
const formView = useDocyrusFormView({
client,
appSlug: 'base',
dataSourceSlug: 'task',
mode: 'edit',
itemId,
dynamicLabelTranslator: (label) => labels[label] ?? label
});// With an i18n library (i18next, next-intl, …)
const { t } = useTranslation();
useDocyrusFormView({
client,
appSlug: 'base',
dataSourceSlug: 'task',
mode: 'edit',
itemId,
dynamicLabelTranslator: (label) => t(`fields.${label}`, label)
});It runs after mapField, so it translates whatever name your mapped field carries, and the translated label flows through the form field, the read-only value rows, and computed-label evaluation. The same signature works on useDocyrusDataGrid (for column headers) — pass the same function to keep grid and form labels consistent. Memoize the function (useCallback / useMemo) so fields don't re-map on every render.
Translating enum options
dynamicLabelTranslator only touches field-level labels. To translate the option labels inside dropdowns, chips, and read-only value rows (field-select, field-status, field-radioGroup, field-enum, field-multiSelect, field-tagSelect), pass dynamicEnumOptionTranslator.
Enum options carry a stable, language-independent slug (the stored value) plus a display name. Key your translation by enums.<field.slug>.<option.slug> and translate only name — slug, color, and icon are preserved automatically:
const { t } = useTranslation();
useDocyrusFormView({
client,
appSlug: 'base',
dataSourceSlug: 'task',
mode: 'edit',
itemId,
dynamicEnumOptionTranslator: (option, field) =>
t(`enums.${field.slug}.${option.slug}`, option.name)
});// Simple dictionary keyed by option slug
const statusLabels: Record<string, string> = { open: 'Açık', done: 'Tamamlandı' };
useDocyrusFormView({
client,
appSlug: 'base',
dataSourceSlug: 'task',
mode: 'edit',
itemId,
dynamicEnumOptionTranslator: (option) =>
option.slug ? statusLabels[option.slug] ?? option.name : option.name
});The translator runs over every resolved option — static enum options and also resolved user / relation options. For user / relation fields, key by field.slug (or check the option shape) and return option.name to leave people / record names untouched. Pass the same function to useDocyrusDataGrid to keep form and grid option labels consistent, and memoize it (useCallback / useMemo).
Field actions
Field actions are imperative, stateful reactions to field-value changes. Unlike computed fields (which re-evaluate reactively on every render cycle), actions fire only when onFieldChange triggers and their property overrides accumulate until explicitly cleared.
How actions work
When a field's value changes, the hook:
- Looks up the
FieldAction[]array attached to the changed field (fieldLayout.fieldActionswins overIField.fieldActions). - For each action with
triggerType: 'onFieldChange', runs its blocks insortOrderorder. - Each block evaluates its
conditionalItemstop-to-bottom — the first truthy condition wins (if / else-if). If none match,elseActionsruns.unconditionalActionsalways runs afterward. - Steps that mutate values call
setValuedirectly. Steps that change properties (showField,setFieldRequired, etc.) accumulate intopropertyOverrides.
Property overrides persist across subsequent renders until another action step changes them or resetActionOverrides() is called.
FieldAction structure
import type { FieldAction } from '@docyrus/ui/components/form-fields';
const countryActions: FieldAction[] = [
{
id: 'ac-1',
triggerType: 'onFieldChange',
blocks: [
{
id: 'blk-1',
sortOrder: 0,
conditionalItems: [
{
id: 'ci-1',
// JSONata or QB rule group
condition: "country_code = 'US'",
actions: [
{ method: 'showField', fieldSlug: 'state_province' },
{ method: 'setFieldRequired', fieldSlug: 'state_province', required: true }
]
}
],
elseActions: [
{ method: 'hideField', fieldSlug: 'state_province' },
{ method: 'setFieldRequired', fieldSlug: 'state_province', required: false }
],
unconditionalActions: []
}
]
}
];Attaching actions via IField
Attach actions at the data-source level by setting fieldActions on an IField entry:
const fields: IField[] = [
{
id: '1', name: 'Country', slug: 'country_code', type: 'field-select',
fieldActions: countryActions
},
{ id: '2', name: 'State / Province', slug: 'state_province', type: 'field-text' }
];Attaching actions via fieldLayout
Use fieldLayout.fieldActions to override or inject actions without touching the data-source definition:
const formView = useDocyrusFormView({
client,
appSlug: 'crm',
dataSourceSlug: 'contacts',
mode: 'create',
fieldLayout: {
country_code: {
fieldActions: countryActions
}
}
});fieldLayout.fieldActions takes priority over IField.fieldActions when both are present.
Available step methods
| Method | Effect |
|---|---|
setFieldValue | Write a static value to fieldSlug. |
setFieldValues | Write static values to multiple fields at once. |
clearFieldValue | Set fieldSlug value to null. |
showField | Remove the hidden override for fieldSlug (set to false). |
hideField | Set the hidden override for fieldSlug to true. |
setFieldRequired | Set or clear the required override for fieldSlug. |
setFieldDisabled | Set or clear the disabled override for fieldSlug. |
setFieldReadOnly | Set or clear the readOnly override for fieldSlug. |
Conditions
condition accepts the same formats as computedHidden / computedRequired:
- JSONata string —
"status = 'active'"— evaluated against current form values. - QB rule group —
{ combinator: 'and', rules: [...] }— converted to JSONata before evaluation. null/ empty — always true (unconditional branch).
Resetting accumulated overrides
Property overrides are stateful. When you want a full reset (e.g. a Reset button), call resetActionOverrides() alongside reset():
<Button
type="button"
variant="outline"
onClick={() => {
formView.reset();
formView.resetActionOverrides();
}}>
Reset
</Button>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).
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. |
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.
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:
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 (
<form onSubmit={async (e) => { e.preventDefault(); await formView.submit(); }}>
{formView.formValidationErrors.length > 0 && (
<Alert variant="destructive">
<AlertDescription>
<ul>
{formView.formValidationErrors.map((msg, i) => <li key={i}>{msg}</li>)}
</ul>
</AlertDescription>
</Alert>
)}
{formView.renderLayout()}
</form>
);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