#[derive(RecordForm)]
{
// Attributes available to this derive:
#[form]
}
Expand description
Derive RecordForm for the typed value a resource’s form writes.
One field per model column the form writes, named and typed like the
model’s field. A scalar (String, a TypedValue type, an Options enum,
or an Option of one) binds the key its control posts; a #[form(embed)] field binds
every key of an EmbeddedForm value and is written whole.
#[derive(tablo_core::RecordForm)]
#[form(model = User)]
pub struct UserForm {
pub name: String,
#[form(blank = "member")]
pub role: String,
#[form(blank = 0)]
pub age: i64,
}The derive also emits UserFormField, one variant per field, which
Posted and Resource::validate_record’s FieldErrors key on and
RecordForm::fields answers with each variant’s keys.
It emits UserFormControls, one control per field chosen from the field —
a bool is a toggle, #[form(options = T)] a choice over T’s options,
#[form(options)] a choice over the field type’s options,
#[form(relationship = R)] a choice over R’s records (a multiple choice over a Vec of
their keys), #[form(file)] a file field, #[form(embed)] the embedded value’s schema,
#[form(repeat)] a repeater, and any
other field a text field — with controls() handing them over, typed by the form so only a
Schema<UserForm> places them, and RecordForm::control answering one
field’s. A resource’s ResourceDef defaults its form to them; ResourceDef::form arranges
or extends them instead, and a form renders the controls it does not place after the ones
it does. The table and detail page read the model, not the form: see
ModelColumns.
§Attributes
#[form(model = User)]on the struct: the model the form writes.#[form(blank = <expr>)]on a scalar: the value an empty submission reads as.#[form(optional)]on aString: an empty submission reads as"".#[form(options)]on anOptionsenum or anOptionof one: a choice over its options. Without it, the field is a text field.#[form(options = Status)]: a choice overStatus::options().#[form(relationship = AuthorResource)]on a foreign key: a choice over the source’s records, each labelled by itsrecord_title. The source is anyOptionSource, which everyResourceis.#[form(relationship = TagResource)]on aVecof the source’s keys, named like the model’s#[has_many(via = joins.target)]field: a many-to-many field, a multiple choice whose records the write links through the join model, one row each. No record is its blank answer; an edit that does not post it keeps the record’s links.#[form(file)]on aString: a file field.#[form(embed)]on anEmbeddedFormvalue.#[form(repeat)]on a#[document]list ofFormInputitems: a repeater, a row per item, which the browser adds, removes and moves. No rows is its blank answer.
A scalar’s blank answer is its blank, "" for an optional String, None for an
Option, or false for a bool. A field with none is required: the panel renders its
control required and the parse refuses an empty submission. No control declares presence.
A generic struct, a tuple struct, an empty struct, a Deferred<_> field,
blank or optional on an Option, an embedded value or a many-to-many field, optional
on a type other than String, and an unknown key are compile errors. So are a field the
model lacks, a type the model’s field does not have, a scalar that is not a FormScalar,
and a many-to-many field whose keys are not its source’s primary key.
§Many-to-many fields
- The control is a multiple choice: a checkbox per record of the related resource, offered
as a relationship choice’s options are, with the records the record links checked.
.searchable()filters the boxes as the user types; without JavaScript every box shows. - The form posts the field’s key once per checked box, after a hidden blank, so a submission with no box checked links no record. Each key the record does not link yet must name a record the choice offers, and the write checks each again inside its transaction.
- A create links the new record to each chosen record, one join row each. An edit adds the
rows of the records it chose and deletes those of the ones it dropped; one that does not
post the field keeps the links. An overridden
create_recordorupdate_recordlinks only by delegating towrite_createorwrite_update. - An edit unlinks only the records its form could offer: a linked record the related resource’s policy hides from the user, or one in another tenant, stays linked.
- The choice offers at most 200 records. Past that it offers none, shows the ones the record links, and an edit keeps or drops those but links no other; attach records from a related table instead.
- A declared table or detail page lists the linked records by their titles with
RelationColumn::list::<TagResource>(relation!(Article.tags)). - A new link sets the two keys and nothing else, so mounting refuses a join model with
another column that is neither nullable nor
#[auto], or a key spanning several columns. A join model’s#[default]and#[update]expressions are not applied: a nullable column with one staysNULL. - Making the two keys the join model’s primary key, as
#[key(article_id, tag_id)], has the database store each pair once, whatever two concurrent writes do. Mounting refuses a multiple choice in a resource’s form that names no many-to-many field. In an action’s input,parse_listreads a multiple choice.
§Submitting
- Validation runs in one round. The record form’s parse (required fields and typed values),
email, relationships and uniqueness are checked together, and the form re-renders with
every error inline, with status 200 and nothing written.
validate_recordneeds the parsed form, so it runs only when every field parses. It returnsFieldErrors<UserFormField>, keyed by the record form’s field enum (errors.add(UserFormField::Age, "Age must be zero or more")), so every error renders under its field’s control, or an embedded value’s first control. A rule that reads the database refuses fromcreate_recordorupdate_recordinstead, which render theirWriteError::Refusederrors the same way. - Unknown keys are refused. A POST carrying a key the form does not declare answers 400, so
a client cannot write
roleortenant_id. The CSRF token, the edit form’srecord_versionand the file fields’clear_andkeep_keys are the exceptions. - An edit writes only what was posted. On an edit, a declared key missing from the
submission keeps its stored value, and the update assigns only the fields the submission
named, plus the model’s own
#[update(..)]defaults and#[version]bump. An API client can post a single field. A control submitted empty is posted, and stores its field’s blank value. - A save another save overtook is refused. When the model has a
#[version]field and derivesModelColumns, the edit form carries the version it opened on asrecord_version. A save whose version no longer matches the stored record writes nothing and re-renders the form with the submitted values, an alert above them, and the current version, so saving again overwrites the other save. A save that lands between the edit’s load and its write fails Toasty’s version condition and re-renders the same way where the database reports it so, as PostgreSQL and MySQL do; SQLite can refuse the transaction as busy instead, and Turso’s concurrent transactions conflict at commit, which both answer 500. A POST withoutrecord_versionskips the first comparison, not the second; one whoserecord_versionis no number answers 400. Only the edit form carries the version: actions, relation managers and the profile pages write without it, and the default Delete removes the record whatever its version. Mounting refuses a form field namedrecord_versionorcsrf_token. DeriveRecordFormfor the typed value a resource’s form writes; documented on thetablo-corere-export.