#[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 in Pascal case, which
Posted and Resource::validate_record’s FieldErrors key on and
RecordForm::fields answers with each variant’s keys. Two fields whose names make one
variant, such as foo_bar and foo__bar, fail to compile at the second.
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 (a multiple choice over a Vec
of options), #[form(tags)] a multiple choice taking typed values,
#[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(required)]on anOption: an empty submission is refused, not read asNone, so a nullable column, such as a product’s slug, is filled on every write through the form.#[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(options = Channel)]on aVec<String>, or#[form(options)]on aVecof anOptionsenum: a multiple choice, a checkbox per option, stored in the list column. The form posts the key once per checked box, after a hidden blank, so a submission with no box checked stores the empty list, its blank answer; an edit that does not post it keeps the stored list. A value the options do not offer is refused, andmin_itemsandmax_itemsbound the count.#[form(tags)]on aVec<String>: a tags field, a multiple choice taking the values the user types, stored in the list column like the above;#[form(tags, options = Topic)]suggestsTopic::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 not marked required, 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, a multiple choice or a
many-to-many field, optional
on a type other than String, required on a type other than Option, 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 relationship’s 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_version, the create form’screate_anotherand the file fields’clear_andkeep_keys are the exceptions. - A create lands on the list, or the page’s validated
?return=target, with the success flash. One postingcreate_another=1, as the create page’s second button does, lands on the create page instead, after the same checks and the same write, keeping?return=and the query keys that seed a relationship control, such as a relation table’s owner. - 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_version,create_anotherorcsrf_token. DeriveRecordFormfor the typed value a resource’s form writes; documented on thetablo-corere-export.