Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Extending Fields, Writes and Lifecycles

Three seams change how the admin behaves. Each is contributed from a module’s register and named by a descriptor.

ScopeSeam
One field on a formA field type
One entity’s storage shapeA persister
Any record, cross-cuttingA listener
Another module’s screenA resource hook

Write a field type

impl FieldType for SwitchField {
    fn view_key(&self) -> &'static str {
        "switch"
    }

    fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
        scalar_vm("switch", cx)
    }

    fn render_default(&self, vm: &FieldVm) -> Markup {
        Markup::from_template(&SwitchTmpl { vm }).unwrap_or_default()
    }

    fn to_attr(
        &self,
        field: &SubmittedField<'_>,
        _opts: &ResolvedOptions,
        _mode: Mode,
    ) -> Result<Option<AttrValue>, String> {
        Ok(Some(AttrValue::Bool(stored_is_on(field.value()))))
    }
}
MethodRole
view_keyThe name a descriptor uses. A dotted vendor.name; a taken key aborts the boot.
view_modelThe submitted or stored value as a view-model.
render_defaultThe markup.
to_attrThe submitted string as the typed value that reaches the database. Err refuses the save.
option_keysThe keys the type reads from a field’s entry. Any other key is refused by name.
submittedFor a type of several controls: what it submitted, gathered into one value. The rules read it, and a refused save is rebuilt from it.
resolve_optionsThose keys, typed once at boot.

Built in: text, textarea, select, radio, checklist, switch, date, password, reference, repeater.

Register it and name it from a descriptor:

registry.add_field_type(FieldTypeReg::new(Arc::new(MoneyField)));

FormField::of("price", "Price", "acme.money")

One registration covers forms and settings. A descriptor naming an unregistered type aborts the boot.

A type’s keys are written on the field, beside the field’s own:

price: { type: acme.money, currency: EUR, precision: 2, span: 1/3 }
fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
    Some(vec!["currency".into(), "precision".into()])
}

What a field type owes the people using it

RuleHow
Works with no configurationEvery key has a default chosen for the common case: type: acme.money alone renders a usable control.
Each default can be turned offOne key per behaviour, named for what it does, read from the field’s entry.
Its keys are declaredoption_keys lists them, so a typo is refused by name.
Enter stays inside itA root marked data-lat-enter-scope when the control holds an input of its own. See Admin Interactivity.
It says what it didlat.emit(root, 'acme.money:changed', detail); a before- event is cancelable.
It can be drivenThe island’s initialiser returns its controller; lat.get(el) finds it.
It is listedOne row per behaviour in your plugin’s docs: the default, and the key that changes it. See Behaviours.
Works before scripts loadThe markup submits as a plain form; a script island improves it.

Write a persister

A persister replaces the generic insert and update for one entity.

#[async_trait]
impl Persister for NodePersister {
    async fn create(&self, cx: &mut SaveCx<'_>, rec: &Record) -> Result<i64, SaveError>;
    async fn update(&self, cx: &mut SaveCx<'_>, id: &str, rec: &Record) -> Result<(), SaveError>;
    async fn load(&self, cx: &mut SaveCx<'_>, id: &str) -> Result<Option<Record>, SaveError>;
    async fn delete(&self, cx: &mut SaveCx<'_>, id: &str, rec: &Record) -> Result<(), DeleteError>;
}
MethodDefault
loadNone. The pre-write row, for listeners.
deleteRefuses.

cx carries the open transaction. Run every statement on it; never open your own.

registry.add_persister(PersisterReg::new("rainmill.location", Arc::new(NodePersister)));

FormConfig::new("location_node", "Location", "/locations", "id", fields)
    .persist("rainmill.location")

Write a listener

A listener runs around the write of any record it targets. See Model Listeners.

registry.add(ModelListenerReg::for_entity("article", Arc::new(NotifyOnPublish)));
registry.add(ModelListenerReg::all(Arc::new(AuditWriter)));

An all-entities listener must not assume a column exists. FormConfig::timestamps opts one entity into the built-in Timestamps.

Mount a feature under a resource

use laterite_admin::axum::routing::get;
use laterite_admin::axum::Router;
use laterite_admin::routes::{ResourceFeature, ResourceFeatureReg, ResourceInfo, RouteCtx};

struct Archive;

impl ResourceFeature for Archive {
    fn mount(&self, ctx: &RouteCtx, resource: &ResourceInfo) -> Router {
        let form = resource.form.clone();
        Router::new().route("/", get(move || async move { form.entity().to_string() }))
    }
}

registry.add(ResourceFeatureReg::new("archive", Arc::new(Archive)));
# descriptors/products.yaml
archive: true
FieldMeaning
resource.pathThe resource’s path as declared, relative to the admin root (/products).
resource.base_pathWhere its list mounted (/admin/products).
resource.labelIts menu label.
resource.permissionThe permission the feature’s routes are gated by.
resource.formIts form, to write records through.

The routes mount at {base_path}/{key} behind the permission. A descriptor declaring a key nobody registered aborts boot naming the resource. The admin knows the import key; a new key needs the admin to carry it.

Change another module’s resource

registry.add_resource_hook(ResourceHook::new("/acme/blog/posts", |mut resource| {
    resource.list.columns.push(ListColumn::new("reviewed", "Reviewed").boolean());
    resource
        .relation(RelationConfig::has_many("notes", "Note", "post_notes", "post_id", notes_list, notes_form))
        .menu(Placement::new().under("acme.content"))
}));
KeyDoes
targetThe resource’s path as it mounts: /posts for an application’s, /acme/blog/posts for a plugin’s under its module base. One nobody declared aborts boot.
The closureTakes the resource as declared, returns it changed. Anything the builders set: columns, fields, relations, import, the palette source, the menu placement.
OrderHooks run in module order, before the resource mounts, so every hook sees the previous one’s result.