Form components
A Workspace UI Project component can also be placed inside the standard record form. The native form keeps everything it already owns: the dialog, the draft, dependent references, server evaluation, validation, errors, the discard confirmation and saving. Your component adds only the input the standard editors cannot express and asks the form to change the fields it declared.
Before you write one, check what the standard form already does. Dependent
references (“choose a settlement, then a beneficiary who lives there”) are a
Reference Eligibility rule on
the field, and a table lookup with search and paging is the Lookup input of
a Reference field. Neither needs a component. A form component is for a
questionnaire with scoring, a format or checksum rule for a document number, a
composite input, or an external lookup whose result must land in fields.
This page assumes you know Embedded components: registration, Check → Build → Activate and the host pattern are the same. Only the placement, the bindings and the context differ.
1. Write the component
Section titled “1. Write the component”<template> <div> <v-checkbox v-for="question in questions" :key="question.key" v-model="answers[question.key]" density="compact" :disabled="!score?.editable" hide-details :label="t(`components.questionnaire.questions.${question.key}`)" /> <p class="text-body-2 mt-2">{{ t('components.questionnaire.score', { score: score?.value ?? 0 }) }}</p> <p v-if="score?.errors.length" class="text-caption text-error">{{ score.errors.join(' ') }}</p> </div></template>
<script setup lang="ts"> import type { WorkspaceUiComponentState, WorkspaceUiFormComponentContext } from '@moltaro/workspace-ui' import { useFormComponentContext } from '@moltaro/workspace-ui' import { computed, reactive, watch } from 'vue' import { useI18n } from 'vue-i18n' import { VCheckbox } from 'vuetify/components'
defineProps<{ context: WorkspaceUiFormComponentContext }>() const emit = defineEmits<{ 'state-change': [value: WorkspaceUiComponentState] }>() const { t } = useI18n() const form = useFormComponentContext()
const questions = [ { key: 'displaced', weight: 3 }, { key: 'disability', weight: 3 }, { key: 'noIncome', weight: 2 }, ] as const const answers = reactive<Record<string, boolean>>({})
// Bindings are projected from the form draft on every render; keep no copy of them. const score = computed(() => form.fields.score) const stored = computed(() => form.fields.answers) const total = computed(() => questions.reduce((sum, q) => sum + (answers[q.key] ? q.weight : 0), 0)) // The answer keys are stored in a bound Text field ("displaced,noIncome") so an edit continues from them. const serialized = computed(() => questions.filter(q => answers[q.key]).map(q => q.key).join(','))
function categoryOf (value: number) { if (value >= 7) { return 'High' } return value >= 4 ? 'Medium' : 'Low' }
// Restore the checkboxes from the stored answers when the form opens and whenever the server // replaces the value; the component's own write-back carries the same keys and changes nothing. watch(() => stored.value?.value, value => { const keys = typeof value === 'string' ? value.split(',') : [] if (keys.join(',') === serialized.value) { return } for (const q of questions) { answers[q.key] = keys.includes(q.key) } }, { immediate: true })
// Every change is an ordinary field edit: the form applies it, evaluates and validates, // or rejects it (read-only, locked, disabled) and leaves the draft untouched. // A value equal to the draft is not re-sent, so restoring the stored answers does not dirty the form. watch(serialized, value => { if ((value || null) !== (stored.value?.value ?? null)) { form.setValue('answers', value || null) } if (total.value !== Number(score.value?.value ?? Number.NaN)) { const result = form.setValue('score', total.value) if (result.status === 'Applied') { form.setValue('category', categoryOf(total.value)) } } })
// Local validity joins the same gate native editors use; the form cannot be saved while it is false. watch(() => [score.value?.required, score.value?.value] as const, ([required, value]) => { const missing = Boolean(required && (value == null || value === '')) form.setValidity('score', !missing, missing ? t('components.questionnaire.answerAtLeastOne') : undefined) }, { immediate: true })
emit('state-change', { status: 'ready', error: '' })</script>The contract is one prop and one event:
context: WorkspaceUiFormComponentContext, also available throughuseFormComponentContext()in any child component.useComponentContext()andusePageContext()throw inside a form component.state-changeworks as for embedded components: the host draws the spinner forloading, the message and a retry forerror, and the neutral placeholder forunavailable.
The form context
Section titled “The form context”| Member | Meaning |
|---|---|
mode | Create or Update. |
record | entityIdOrKey, instanceId (null while creating), displayName, number. |
fields | One entry per declared binding, keyed by the binding name (see below). |
disabled | The form is submitting, reloading or discarding. |
setValue(name, value) | Requests a draft change. Returns { status: 'Applied' } or { status: 'Rejected', reason } with reason in Unknown, ReadOnly, Restricted, Locked, Disabled, Hidden. |
setValidity(name, valid, message?) | Adds or clears your own validity for a ReadWrite binding (ignored for others). While any binding is invalid the form cannot be saved; message is shown under the block. Your verdict is tracked separately from a native editor of the same field, and it is cleared when the server replaces the value. |
refreshKey, signal, overlayTarget, artifact identity | As for embedded components. Dialogs must pass context.overlayTarget as attach. |
Each fields[name] is a WorkspaceUiFormFieldBinding:
| Member | Meaning |
|---|---|
fieldKey, fieldType | The mapped field and its type (Integer, String, Reference, …). |
access | ReadWrite, Read, or Restricted when the current user may not read the field. |
value | The current draft value in the same shape the native editors use, as a frozen copy; null when empty or restricted. Changing it in place has no effect; use setValue. |
origin | User, Default, Effect, Calculation or null. Server-supplied values arrive here; do not write them back. |
required, visible, editable | The same rule state the native editor of that field would have. editable already accounts for access, locking, pending evaluation and disabled. |
locked | The field is fixed by the host, for example the parent record when a related record is created from its parent. setValue is rejected with Locked. |
loading | A server evaluation that targets this field is pending. |
errors | Current server or submit errors of the field plus your own setValidity message. |
referencePicker | For a Reference binding whose field also has a native lookup item on the same form: a ready FormLookup context for MoltaroReferencePickerDialog, with the current draft values and the locked parent applied. Otherwise null. |
Values are derived from the form draft on every render. When the server
replaces a value (a mutation effect, a calculation, a reload after a conflict,
a discard), your component sees the new value through fields; it never has
to keep or restore a copy.
2. Register it with bindings
Section titled “2. Register it with bindings”{ "FormatVersion": 1, "Pages": [], "Components": [ { "Key": "vulnerability-questionnaire", "Component": "src/components/VulnerabilityQuestionnaire.vue", "TitleKey": "components.questionnaire.title", "Icon": "mdi-clipboard-check-outline", "Placements": ["FormItem"], "RequiredPermissions": [], "FormBindings": [ { "Name": "score", "FieldTypes": ["Integer"], "Access": "ReadWrite" }, { "Name": "category", "FieldTypes": ["String", "Select"], "Access": "ReadWrite" }, { "Name": "answers", "FieldTypes": ["Text", "String"], "Access": "ReadWrite" }, { "Name": "notes", "FieldTypes": ["Text"], "Access": "Read", "Optional": true } ] } ]}PlacementscontainsFormItem. A component may declareFormItemtogether with the read-only placements; it then receives the form context in forms and the read-only context elsewhere.FormBindingsis required withFormItemand not allowed without it. Up to 16 bindings;Nameis lowercase kebab-case and is the key incontext.fields.FieldTypeslists the entity field types Constructor may map to the binding:Bool,DateOnly,TimeOnly,DateTimeOffset,Integer,Decimal,Money,Select,String,Text,Classifier,Reference. Table, inverse reference, file, address, user and role fields keep their native editors.AccessisReadorReadWrite.Optionallets Constructor leave the binding unmapped.
UI Studio → Components offers the same fields. The compiler reports binding
errors as WUI2040–WUI2049. Then Check → Build → Activate and refresh
the browser.
3. Place it in Constructor
Section titled “3. Place it in Constructor”Constructor → entity → Form surface → Add item → Workspace UI component.
Pick the component, then map every declared binding to a field of the
entity; only fields of an allowed type are offered. The item has the usual
label, hint, column, span and spacing. A Read-only for everyone switch
projects every binding as Read.
The same field may also have a native item on the form. Both show the same
draft value; the native item supplies rule state and, for a Reference field
with a lookup input, the referencePicker context.
Saving the form configuration validates the item against the active
artifact: unknown component, missing FormItem placement, undeclared or
missing bindings and wrong field types are rejected with a per-item error.
Entity YAML and installation packages carry the key and the bindings; a
placement whose component is not in the active artifact renders the
Component unavailable state.
Behavior to design for
Section titled “Behavior to design for”- One draft, one save.
setValueis the only way to change data. The form evaluates the change with the same server request it uses for native inputs, applies defaults, effects and calculations, and saves everything together. There is no component-specific save. - Permissions stay on the server. A binding is
Readwhen the user may not write the field andRestrictedwhen the user may not read it. A hand-crafted request naming a field the user cannot write is rejected by the server exactly as for a native editor; the component cannot widen access. - Locked fields. When a record is created from its parent, the parent
reference is locked. Its binding reports
locked: true,setValueis rejected withLocked, and the locked value is restored on save. - Required input you own. If your block is unavailable, denied or in
error, and one of itsReadWritebindings maps to a required field that has no native editor on the form, the form shows which input cannot be completed and cannot be saved until the block recovers. Read bindings and optional input never block. - No hot replacement. Publishing a new artifact does not replace a component inside an open form; the next open uses the new version. The unsaved draft survives.
- Cancel, conflicts, failures. They are handled by the form: discard
asks for confirmation, a conflict offers Reload latest, a failed save
keeps the draft. Your component only reflects
fields.
Limits
Section titled “Limits”- Bindings map to primary-table fields of the form’s entity; table, inverse reference, file, address, user and role fields are native-only.
- No per-placement settings beyond the binding map and the read-only switch.
- Package-owned (
NET) form surfaces cannot reference workspace components. - Card, Details, Drawer and Table placements stay read-only (Embedded components).
- Adding
FormItemandFormBindingsto the host contract changes its fingerprint: existing artifacts require Check → Build → Activate after upgrading Moltaro.