Hooks

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-view
Required Packages(3 packages)
pnpm add @docyrus/app-utils @docyrus/api-client @tanstack/react-query

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
  • 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 + 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 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.

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.

'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

OptionTypeDefaultDescription
clientRestApiClient—Authenticated Docyrus API client.
appSlugstring—Slug of the app that owns the data source.
dataSourceSlugstring—Slug of the data source.
itemIdstring—Record id for edit/view flows.
mode'create' | 'edit' | 'view'itemId ? 'edit' : 'create'Explicit form mode. Use 'view' for read-only layouts.
itemRecord<string, unknown> | null—Pre-resolved record. When provided, skips the item query.
dataSourceDataSource | 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.
enabledbooleantrueDisable all remote queries while surrounding state is still loading.
staleTimenumber30_000TanStack Query stale time for metadata, item, and remote-option queries.
schemaExpandstring | 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.
disabledbooleanfalseDisables editable fields globally.
defaultValuesRecord<string, unknown>—Extra defaults merged after schema defaults and before the loaded item.
itemQueryParamsDocyrusFormViewGetParams—Extra query params for the item get request. columns is merged, not replaced.
fieldSlugsstring[]—Whitelist fields by slug before layout/rendering.
fieldOrderstring[]—Explicit field ordering. Unlisted fields sort after listed ones.
hiddenFieldSlugsstring[]—Hard-hide fields by slug.
fieldLayoutRecord<string, DocyrusFormViewFieldLayout>—Per-field UI overrides: hidden/required/readOnly/disabled, colSpan, labels, descriptions, and prop overrides.
layoutDocyrusFormViewLayoutItem[]—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.
includeReadOnlyFieldsbooleanmode !== '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' otherwiseWhether unsupported field types disappear or fall back to value-render mode.
gridColumns1 | 2 | 3 | 42Default responsive column count used by renderLayout().
clickToEditbooleanfalseWhen 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.
resolveUserOptionsbooleantrueWhether to fetch /v1/users for user selector fields.
resolveRelationOptionsbooleantrueWhether to resolve relation target options automatically.
optionLimitnumber100Limit used when loading relation target items.
enumOptionsRecord<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.
formActionsFormAction[] | null—Form-level lifecycle actions evaluated on onFormLoad, onFormBeforeSubmit, and onFormAfterSubmit. Same block/step structure as field actions. See Form-level actions.
formCustomValidationsFormCustomValidationRule[] | 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

FieldTypeDescription
hiddenboolean | ((values) => boolean)Hide the field conditionally.
requiredboolean | ((values) => boolean)Mark the field required conditionally.
readOnlybooleanForce the field into value-render mode.
disabledbooleanDisable editing without changing render mode.
colSpan1 | 2 | 3 | 4 | 'full'Width override used by renderLayout().
classNamestringExtra wrapper/field className.
labelReactNodeOverride the display label.
descriptionReactNodeDescription shown in read-only layout or available to your custom props.
fieldPropsPartial<DocyrusFormFieldProps>Forwarded to the resolved form-field component.
valuePropsPartial<DocyrusValueProps>Forwarded to the resolved value renderer.
computedHiddenstring | RuleGroupType | nullJSONata expression or QB rule group. When it evaluates to true the field is hidden.
computedRequiredstring | RuleGroupType | nullJSONata expression or QB rule group. When it evaluates to true the field is required.
computedLabelstring | nullJSONata expression. Result replaces the field label.
computedDescriptionstring | nullJSONata expression. Result replaces the field description.
computedFormulastring | nullJSONata expression. Result is written back as the field's live value.
fieldActionsFieldAction[] | nullOverride 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.
customValidationsCustomValidationRule[] | nullRuntime custom validation rules for this field. Overrides IField.customValidations from the data source schema. Evaluated on submit() / validate().
validationsstring[] | nullValidation 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

FieldTypeDescription
type'field'Marks the node as an explicit field entry.
slugstringField slug to render in this position.
colSpan1 | 2 | 3 | 4 | 'full'Per-placement width override for this field node.
classNamestringExtra grid-cell wrapper className for this field node.

DocyrusFormViewSection

Common section fields:

FieldTypeDescription
idstringStable section id. Also used to build tab values internally.
variant'fieldset' | 'tabpanel' | 'tab'Section rendering mode.
titleReactNodeSection title or tab label.
descriptionReactNodeOptional helper text shown with the section.
classNamestringExtra className for the section container.
contentClassNamestringExtra className for the inner section grid.
colSpan1 | 2 | 3 | 4 | 'full'Width of the section inside its parent grid. Most useful for fieldset and tabpanel.

Variant-specific fields:

VariantExtra fieldsDescription
fieldsetitems: DocyrusFormViewLayoutItem[], columns?: 1 | 2 | 3 | 4, collapsible?: boolean, defaultCollapsed?: booleanGroups 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).
tabpanelitems: DocyrusFormViewTabSection[], defaultTabId?: stringRenders a tab list and one active tab panel at a time. Each tab panel keeps its own grid layout.
tabitems: DocyrusFormViewLayoutItem[]Tab content node. Use it as a child of a tabpanel section.

Return Value

PropertyTypeDescription
modeDocyrusFormViewModeFinal resolved mode (create, edit, or view).
dataSourceDataSource | undefinedRaw data source metadata response.
itemRecord<string, unknown>Current values object exposed as the record currently being rendered.
form{ Field(...) }Internal form object compatible with the Docyrus form-field components.
valuesRecord<string, unknown>Current live values snapshot.
defaultValuesRecord<string, unknown>Resolved initial values after schema defaults, defaultValues, and loaded item merge.
columnsstring[]Item columns requested from the backend, including companion fields.
fieldsDocyrusFormViewField[]Resolved visible field descriptors used by the layout helpers.
allFieldsDocyrusFormViewField[]Currently the same resolved field list as fields.
unsupportedFieldsDocyrusFormViewField[]Fields currently shown as value-render fallbacks because no form-field component exists.
validationErrorsMap<string, string>Current validation errors keyed by field slug — includes both required-field and custom-validation errors.
formValidationErrorsstring[]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.
isDirtybooleanWhether current values differ from the committed baseline.
isLoadingbooleantrue while metadata, item, or remote-option queries are loading.
isSubmittingbooleantrue while submit() is running.
errorError | nullFirst query error, if any.
setValue(slug, value) => voidImperatively update one field value.
validate() => Promise<boolean>Run validation manually without submitting. Returns true when all fields pass. Sets validationErrors.
reset() => voidReset current values back to the committed baseline.
submit() => Promise<unknown>Validate and run the create/update/custom submit pipeline.
refetch() => voidRefetch metadata, item, and remote options.
resetActionOverrides() => voidClear 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?) => ReactNodeRender a single resolved field by slug.
renderLayout(options?) => ReactNodeRender 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.

PropertyTypeDescription
slugstringField slug.
fieldIFieldNormalized field metadata (type, options, computed expressions, etc.).
sourceFieldDataSourceFieldRaw field object from the data source metadata response.
valueunknownCurrent form value for this field.
hiddenbooleanResolved visibility after computed, layout, and hiddenFieldSlugs overrides.
requiredbooleanResolved required state after computed and layout overrides.
readOnlybooleanWhether the field is in value-render-only mode.
disabledbooleanWhether the field input is disabled.
editablebooleantrue 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.
labelReactNodeResolved label after computedLabel and fieldLayout.label overrides.
descriptionReactNodeResolved description after computedDescription and fieldLayout.description overrides.
colSpan1 | 2 | 3 | 4 | 'full' | undefinedWidth override for renderLayout().
enumOptionsEnumOption[]Resolved option list for select-like fields.
queryKeysstring[]Column keys requested from the backend for this field (includes companion keys).
submitKeysstring[]Payload keys included in the submit body for this field (includes companion keys).
fieldPropsPartial<DocyrusFormFieldProps>Extra props forwarded to the form-field component.
valuePropsPartial<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 typeExtra 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-avatarmapped 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:

  1. enumOptions[field.slug] override
  2. Static field metadata (expand=enums / options) for select-like fields
  3. /v1/apps/enums fallback for enum-backed selectors whose metadata options came back empty
  4. /v1/users for user selectors
  5. relation target lookup for relation selectors

Static enum-backed field types

The hook reads metadata options for:

  • field-select
  • field-radioGroup
  • field-enum
  • field-systemEnum
  • field-status
  • field-multiSelect
  • field-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:

  • name
  • title
  • display_name
  • displayName
  • full_name
  • fullName
  • subject
  • code
  • email
  • label
  • slug

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:

  1. field-level schema defaults (field.defaultValue)
  2. built-in defaults for composite fields
  3. defaultValues
  4. 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, and field-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:

  1. skip validation entirely in view mode and just return current values
  2. validate every visible editable field in order — required, then the declarative validations tokens when validationTokens is enabled (minLength: / maxLength: / pattern: / min: / max:), then that field's customValidations JSONata rules; the first failure per field wins
  3. build a submit payload from each field's submitKeys
  4. optionally run transformSubmit(payload, context)
  5. submit via one of:
    • onSubmit(payload, context)
    • collection.create / collection.update
    • direct POST / PATCH to the Docyrus items endpoint
  6. call onSubmitSuccess / onSubmitError
  7. commit the saved values as the new clean baseline on success

Companion submit keys

Composite field payloads automatically include their companion keys:

Field typeSubmit keys
field-moneyslug, __slug_currency
field-phoneslug, __slug_country
field-statusslug, __slug_secondary, __slug_description, __slug_followup_date
field-avatarmapped 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 layout is omitted, renderLayout() renders a single responsive grid of resolved fields
  • when layout is provided, renderLayout() walks the nested layout tree and renders fieldset, tabpanel, and tab sections 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
  • fieldClassName is applied to each EditableRecordDetailField row
  • flat layouts still behave like a standard EditableRecordDetail list
  • 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

PropertyPriority order (highest → lowest)
hiddencomputedHidden → field action override → hiddenFieldSlugs → fieldLayout.hidden → false
requiredcomputedRequired → field action override → fieldLayout.required → schema default
readOnlymode === 'view' → field action override → fieldLayout.readOnly → IField.readOnly → false
disabledfield action override → fieldLayout.disabled → false
labelcomputedLabel → fieldLayout.label → schema name
descriptioncomputedDescription → fieldLayout.description → undefined
valuecomputedFormula 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:

  1. Looks up the FieldAction[] array attached to the changed field (fieldLayout.fieldActions wins over IField.fieldActions).
  2. For each action with triggerType: 'onFieldChange', runs its blocks in sortOrder order.
  3. Each block evaluates its conditionalItems top-to-bottom — the first truthy condition wins (if / else-if). If none match, elseActions runs. unconditionalActions always runs afterward.
  4. Steps that mutate values call setValue directly. Steps that change properties (showField, setFieldRequired, etc.) accumulate into propertyOverrides.

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

MethodEffect
setFieldValueWrite a static value to fieldSlug.
setFieldValuesWrite static values to multiple fields at once.
clearFieldValueSet fieldSlug value to null.
showFieldRemove the hidden override for fieldSlug (set to false).
hideFieldSet the hidden override for fieldSlug to true.
setFieldRequiredSet or clear the required override for fieldSlug.
setFieldDisabledSet or clear the disabled override for fieldSlug.
setFieldReadOnlySet 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';
FieldTypeDescription
idstringStable action id.
namestringOptional label.
triggerType'onFormLoad' | 'onFormBeforeSubmit' | 'onFormAfterSubmit'Lifecycle event that fires the action.
blocksFieldActionBlock[]Ordered blocks — same shape as field actions (conditionalItems → elseActions → unconditionalActions).

Triggers

TriggerWhen it firesNotes
onFormLoadOnce, 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.
onFormBeforeSubmitAfter field + form validation passes, before the API call.May mutate field values (e.g. last-minute transforms) before the payload is built.
onFormAfterSubmitAfter 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';
FieldTypeDescription
idstringStable rule id.
expressionstringJSONata expression or QB rule-group JSON. Must return true to pass. Evaluated against current form values.
messagestringBanner 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

On this page