Relation Fields
Use this document for relation-oriented field APIs:
RelationTableSelectTreeManyToOne/OneToOneOneToManyManyToMany- relation query / pagination / patch behavior
Related docs:
- Fields: core
Fieldprops, conditions,filters,Field.onChange, value contracts - Widget matrix: widget compatibility and widget-specific examples
- ModelForm: page shell and relation-form examples
- ModelTable: read cells and inline edit behavior
Import
import { Field, RelationTable } from "@/components/fields";RelationTable is used only inside relation field declarations such as Field.tableView.
RelationTable
RelationTable is the relation-table view definition for OneToMany and ManyToMany.
Declare a zero-prop tableView component and return <RelationTable /> from it.
Example:
function OptionItemsTableView() {
return (
<RelationTable orders={["sequence", "ASC"]} pageSize={10}>
<Field fieldName="sequence" />
<Field fieldName="itemCode" />
<Field fieldName="itemName" />
<Field fieldName="active" />
</RelationTable>
);
}Props
| Prop | Type | Required | Notes |
|---|---|---|---|
children | ReactNode | Yes | Ordered <Field /> column declarations, plus optional <Action /> row actions (see Row Actions). |
orders | OrderCondition | No | Default relation-table sorting. Supports a single tuple or multiple tuples. |
pageSize | number | No | Relation-table page size. Only affects paged relation tables (isPaged={true}). |
Sorting examples:
<RelationTable orders={["sequence", "ASC"]}>
<Field fieldName="sequence" />
<Field fieldName="itemCode" />
</RelationTable><RelationTable
orders={[
["sequence", "ASC"],
["itemCode", "DESC"],
]}
>
<Field fieldName="sequence" />
<Field fieldName="itemCode" />
</RelationTable>Behavior notes:
RelationTable.pageSizeonly affects paged relation tables (isPaged)RelationTable.orderssupports both a single tuple and multiple tuples- column declaration still comes from child
<Field />order
Row Actions
RelationTable accepts <Action /> children alongside <Field /> declarations. They render as per-row actions in a trailing Actions column and dispatch against the related model — the action’s operation / href / onClick receives the row’s id as the record id.
import { Action } from "@/components/actions/Action";
function OptionItemsTableView() {
return (
<RelationTable orders={["sequence", "ASC"]}>
<Field fieldName="itemCode" />
<Field fieldName="itemName" />
<Action
type="link"
labelName="Open"
placement="inline"
href="/config/option-item/{id}"
/>
<Action
labelName="Archive"
operation="archive"
placement="more"
/>
</RelationTable>
);
}Placement rules match ModelTable row actions:
placement="inline"(default for rows) → icon/button in the Actions columnplacement="more"→ overflow dropdown in the Actions columnplacement="toolbar"/"header"are ignored (relation tables have no toolbar)
Behavior notes:
- actions only render on rows that have an
id; draft/unsaved rows show an empty cell - action dispatch uses the related model name (not the parent form’s model), so
operationinvokes against the related entity and query invalidation refreshes that model disabled/hiddenconditions evaluate against the saved row data — they do not track unsaved inline-edit values (unlikeModelTable)ActionExecutionContext.scopeis reported as"model-table"in this context (relation rows reuse the same dispatcher)
ManyToOne / OneToOne
Default behavior is searchable reference selection:
<Field fieldName="departmentId" />Dependent relation filter example:
<Field fieldName="companyId" />
<Field
fieldName="departmentId"
filters={[
["companyId", "=", "{{ companyId }}"],
"AND",
["active", "=", true],
]}
/>Notes:
filtersis applied to the default searchable reference query- when a dependent
{{ fieldName }}has no current value, the selector stays query-disabled instead of loading all options
filterBySource — backend-driven contextual filtering
For business rules that cannot be expressed declaratively via filters / {{ fieldName }} (e.g. “male employees can’t select maternity leave”, “remaining annual leave must be > 0”), use filterBySource to let the backend apply its own filtering based on the calling record’s context:
<Field fieldName="leaveTypeId" filterBySource />When filterBySource is true, the searchName request carries a SourceRecord:
interface SourceRecord {
model: string; // metaField.modelName — the model that owns this field
recordId?: string | null; // resolved recordId; null in create mode
values?: Record<string, unknown>; // current in-memory form values of that record
}Semantics:
modelandrecordIddescribe the record that directly owns the field — for a root form Field this is the root record, for a Field inside a OneToMany / ManyToMany row it is that row’s record (with the row’s own model, not the parent’s)valuesis the form snapshot at query time; changes to any form value trigger a fresh query so the dropdown re-filters reactively- default is
false; enable per field, because “should this lookup honor the host form” is a call-site decision, not a target-model decision - can be combined with declarative
filters; both are sent and the backend applies them together
Choosing between filters and filterBySource:
filterswith{{ fieldName }}— simple cross-field references resolved on the frontend (gender,status,departmentIdetc.). Declarative, discoverable in code, no backend changes needed.filterBySource— rules that require computation, policy lookups, or cross-model joins on the backend. Opaque to the frontend but keeps business rules co-located with the backend service that enforces them onsave.
Example — a leave-type dropdown filtered by employee gender through a server-side rule:
<Field fieldName="employeeId" />
<Field fieldName="leaveTypeId" filterBySource />Security note: filterBySource sends the entire current form values map. Don’t enable it on forms carrying fields the target backend shouldn’t see.
SelectTree
Use SelectTree when the related model is hierarchical:
<Field fieldName="departmentId" widgetType="SelectTree" />Common pattern with dependent filtering:
<Field fieldName="companyId" />
<Field
fieldName="departmentId"
widgetType="SelectTree"
filters={[
["companyId", "=", "{{ companyId }}"],
"AND",
["active", "=", true],
]}
/>Behavior:
SelectTreeis the recommended developer-facing tree entry for forms and inline editors- it is still declared through
Field, not by renderingSelectTreePaneldirectly - it uses the same
Field.filtersrules as searchable reference fields - when a dependent
{{ fieldName }}value is missing, the tree selector stays query-disabled instead of loading an unfiltered tree - low-level
Tree/SelectTreePanelare internal infrastructure
OneToMany
Rendered as relation table with inline or dialog editing. Public usage stays on Field.
Example:
function OptionItemsTableView() {
return (
<RelationTable orders={["sequence", "ASC"]} pageSize={10}>
<Field fieldName="sequence" />
<Field fieldName="itemCode" />
<Field fieldName="itemName" />
<Field fieldName="active" />
</RelationTable>
);
}
<Field fieldName="optionItems" tableView={OptionItemsTableView} />;Common props:
tableView: relation-table view component that renders<RelationTable><Field /></RelationTable>formView: dialog form for row create/editisPaged: enable pagination / remote relation mode
Default submit behavior is incremental patch map:
{
"Create": [{ "name": "new row" }],
"Update": [{ "id": "101", "name": "changed" }],
"Delete": ["102", "103"]
}Behavior:
false(default): include relationsubQueryingetById; relation table does not paginate in UI and renders local rowstrue: relation table enables pagination UI; whenrecordId + relatedModel + scoped relation filterare ready, data is loaded byrelatedModel.searchPage(remote mode), otherwise paginated locally- static
Field.filters(no{{ fieldName }}references) are included in thegetByIdsubQuery so the initial load already applies the filter; dynamic filters with{{ fieldName }}references are applied only at remote-mode query time once the referenced values are available - editable cells are limited to declared
RelationTablecolumns intersected with editable related-model fields - unresolved
{{ expr }}dependencies pause remote relation queries until the dependent parent form value exists
ManyToMany
Rendered as relation table plus picker dialog by default.
Example:
function UserTableView() {
return (
<RelationTable orders={["username", "ASC"]} pageSize={10}>
<Field fieldName="username" />
<Field fieldName="nickname" />
<Field fieldName="email" />
<Field fieldName="status" />
</RelationTable>
);
}
<Field fieldName="userIds" tableView={UserTableView} />;Default submit behavior is incremental patch map:
{
"Add": ["1", "2"],
"Remove": ["3"]
}TagList
widgetType="TagList" switches ManyToMany to a searchable multi-select dropdown with tags rendered below the trigger.
<Field fieldName="userIds" widgetType="TagList" tableView={UserTableView} />Behavior:
- searchable dropdown with multi-select interactions
- selected values are rendered as tags below the trigger
- trigger text stays compact and only shows selection count
- field layout follows surrounding
FormSectioncolumns by default; passfullWidthexplicitly when you want it to span the whole row - top-level
ModelFormgetByIdonly adds the field name tofields; it does not add a relationsubQuery - field UI value is
ModelReference[], while top-level submit still uses the normal incremental patch map
Query Notes
ManyToManypicker dialog merges the effective field filter, internal relation-scoped filters, search filter, and column filters usingAND- unresolved
{{ expr }}dependencies pause remote picker and relation-table queries until the source value exists formViewis optional; inManyToMany, row-click opensModelDialogin read mode while add/remove still uses picker behavior
Shared Read / Inline Behavior
Shared behavior across relation fields:
ModelTable/RelationTableread-mode cells render bothOneToManyandManyToManyas compact tag lists usingdisplayName -> idfallback instead of JSON stringswidgetPropsis not propagated intoRelationTableread-mode cell renderers- relation tables reuse the same compact file/image read renderers as
ModelTable RelationTable.pageSizeonly affects paged relation tables (isPaged=true)- remote relation table and picker queries use the effective field filter (
Field.filters ?? metaField.filters), relation-scoped filters, and runtime search / column filters
Form View Example
formView is typically paired with ModelDialog:
function UserRoleUserIdsFormView() {
return (
<ModelDialog title="User Detail">
<FormSection labelName="General" hideHeader>
<Field fieldName="username" />
<Field fieldName="nickname" />
<Field fieldName="email" />
<Field fieldName="mobile" />
<Field fieldName="status" />
</FormSection>
</ModelDialog>
);
}