Ignite rocketboost.rs

Resources

A resource connects a database table to the panel. The panel lists, creates, edits and deletes its rows. A resource is a Rust type that implements the IgniteResource trait. You can write the impl by hand, or let #[derive(IgniteResource)] write it from a Diesel model.

Address and name

Four constants name the resource:

  • SLUG is the URL segment. A slug of articles mounts the resource at /admin/articles. Two resources cannot share a slug.
  • TITLE is the heading of the list and the navigation label, such as Articles.
  • SINGULAR is the name of one record, used in the headings New Tag and Edit Tag #3. It defaults to Record, so a hand-written resource should set it.
  • PER_PAGE is the number of rows on a list page. It is 20 unless you change it, as in const PER_PAGE: i64 = 50;.

The derive takes the slug from the table name, and the title from the table name with a capital letter, unless you set title. The singular name is the title without a trailing s, unless you set singular.

Derive a resource

The derive reads a Diesel model. The model also derives Queryable, Selectable and Serialize, because the derive does not write them. This model has a title that is searchable and sortable, and a body that is a multi-line field and hidden from the list:

use ignite_core::IgniteResource;
use serde::Serialize;

use crate::schema;

#[derive(diesel::Queryable, diesel::Selectable, Serialize, IgniteResource)]
#[diesel(table_name = schema::articles)]
#[ignite(
    table = schema::articles,
    title = "Articles",
    latest_records(limit = 5, columns = "title,published_on"),
)]
pub struct Article {
    pub id: i64,
    #[ignite(searchable, sortable)]
    pub title: String,
    #[ignite(form_type = "textarea", hidden_on_index)]
    pub body: String,
    pub published: bool,
    #[ignite(label = "Published on")]
    pub published_on: Option<chrono::NaiveDate>,
}

The struct attributes are:

  • table, the Diesel table module. Required.
  • title, the heading of the list. Defaults to the table name, capitalized.
  • singular, the name of one record, for the headings. Defaults to the title without a trailing s.

The field attributes are:

  • primary_key, for a key that is not named id. The key must be an i32 or an i64. It is left out of the form.
  • label, the label on the form and the column heading. Defaults to the field name, capitalized, with underscores as spaces.
  • form_type, one of text, textarea, password, number, checkbox or date. Defaults from the type: text for String, number for i32 and i64, checkbox for bool, and date for chrono::NaiveDate. A password must be a String. See Passwords.
  • required, or required = false. A field that is not an Option is required by default, and a bool is never blank.
  • searchable and sortable, for the search box and the column headings. See Tables.
  • hidden_on_index, which leaves the column out of the list. The list shows every other field, including the id, unless you hide it.

The struct also takes latest_records(...), which declares the dashboard table of the newest records. The columns are a comma-separated list of model fields to show, and are required. A password field is refused. limit is the most rows to show, and defaults to 5. title is the table's heading, and defaults to "Latest" and the resource title, so "Latest Articles". The derive writes latest_records from it, and latest, which orders by the primary key, newest first. See Widgets.

The supported field types are String, i32, i64, bool and chrono::NaiveDate, and an Option of any of them. The derive also writes an input struct named after the model with Input added, here ArticleInput, which holds the validated form values.

The derive writes the whole trait impl, so a derived resource has two limits. It cannot write dashboard cards, which need a hand-written widgets method. And it has no select field kind. See Widgets and Forms.

Passwords

A field with form_type = "password" is hashed on save. This model keeps the login of an author:

use ignite_core::IgniteResource;
use serde::Serialize;

use crate::schema;

/// The password is hashed on save. It is required on create, and a blank
/// value on edit keeps the stored hash.
#[derive(diesel::Queryable, diesel::Selectable, Serialize, IgniteResource)]
#[diesel(table_name = schema::authors)]
#[ignite(table = schema::authors, title = "Authors", singular = "Author")]
pub struct Author {
    pub id: i64,
    #[ignite(searchable, sortable)]
    pub name: String,
    #[ignite(form_type = "password")]
    pub password: String,
}

The derive stores the argon2 hash from ignite_core::hash_password, the same function the panel's sign-in uses. The password is required on create. On edit, a blank password is optional, and it keeps the stored hash. Password fields are not list columns, and the edit form never shows the stored value. The derive refuses a password field that is not a String or an Option of one, at compile time.

Write a resource by hand

A hand-written resource implements the trait itself. This one manages a tags table with a choice field, a search and a sort, a dashboard card and a dashboard table of its newest tags:

use diesel::prelude::*;
use diesel::result::QueryResult;
use diesel::sql_types::BigInt;
use ignite_core::{
    escape_like, Column, Direction, Field, FieldErrors, FieldKind, FormInput, IgniteResource,
    LatestRecords, ListQuery, Page, StatWidget,
};
use serde::Serialize;

use crate::schema::tags;

const KINDS: &[(&str, &str)] = &[("color", "Colour"), ("topic", "Topic")];

const NAME: Field = Field {
    name: "name",
    label: "Name",
    kind: FieldKind::Text,
    required: true,
};

const KIND: Field = Field {
    name: "kind",
    label: "Kind",
    kind: FieldKind::Select(KINDS),
    required: true,
};

#[derive(Queryable, Selectable, Serialize)]
#[diesel(table_name = tags)]
pub struct TagRecord {
    pub id: i64,
    pub name: String,
    pub kind: String,
}

pub struct TagInput {
    pub name: String,
    pub kind: String,
}

pub struct Tag;

impl IgniteResource for Tag {
    type Record = TagRecord;
    type Input = TagInput;

    const SLUG: &'static str = "tags";
    const TITLE: &'static str = "Tags";
    const SINGULAR: &'static str = "Tag";

    fn widgets(conn: &mut MysqlConnection) -> QueryResult<Vec<StatWidget>> {
        let total: i64 = tags::table.count().get_result(conn)?;
        Ok(vec![StatWidget {
            label: "Tags",
            value: total,
        }])
    }

    fn latest_records() -> Option<LatestRecords> {
        Some(LatestRecords {
            title: "Latest tags",
            columns: &["name", "kind"],
            limit: 5,
        })
    }

    fn latest(conn: &mut MysqlConnection, limit: i64) -> QueryResult<Vec<TagRecord>> {
        tags::table
            .order(tags::id.desc())
            .limit(limit)
            .select(TagRecord::as_select())
            .load(conn)
    }

    fn fields() -> &'static [Field] {
        &[NAME, KIND]
    }

    fn columns() -> &'static [Column] {
        &[
            Column {
                key: "name",
                label: "Name",
                sortable: true,
                searchable: true,
            },
            Column {
                key: "kind",
                label: "Kind",
                sortable: false,
                searchable: false,
            },
        ]
    }

    fn list(conn: &mut MysqlConnection, query: &ListQuery) -> QueryResult<Page<TagRecord>> {
        let pattern = query
            .search
            .as_ref()
            .map(|text| format!("%{}%", escape_like(text)));
        let mut rows = tags::table.into_boxed();
        let mut count = tags::table.into_boxed();
        if let Some(pattern) = &pattern {
            rows = rows.filter(tags::name.like(pattern.clone()));
            count = count.filter(tags::name.like(pattern.clone()));
        }
        rows = match &query.sort {
            Some((key, Direction::Asc)) if key == "name" => rows.order(tags::name.asc()),
            Some((key, Direction::Desc)) if key == "name" => rows.order(tags::name.desc()),
            _ => rows.order(tags::id.asc()),
        };

        let total: i64 = count.count().get_result(conn)?;
        let rows = rows
            .limit(query.per_page)
            .offset(query.offset())
            .select(TagRecord::as_select())
            .load(conn)?;
        Ok(Page { rows, total })
    }

    fn find(conn: &mut MysqlConnection, id: i64) -> QueryResult<Option<TagRecord>> {
        tags::table
            .find(id)
            .select(TagRecord::as_select())
            .first(conn)
            .optional()
    }

    fn create(conn: &mut MysqlConnection, input: TagInput) -> QueryResult<TagRecord> {
        diesel::insert_into(tags::table)
            .values((tags::name.eq(&input.name), tags::kind.eq(&input.kind)))
            .execute(conn)?;
        let id: i64 = diesel::select(diesel::dsl::sql::<BigInt>("LAST_INSERT_ID()"))
            .get_result(conn)?;
        Self::find(conn, id)?.ok_or(diesel::result::Error::NotFound)
    }

    fn update(conn: &mut MysqlConnection, id: i64, input: TagInput) -> QueryResult<TagRecord> {
        diesel::update(tags::table.find(id))
            .set((tags::name.eq(&input.name), tags::kind.eq(&input.kind)))
            .execute(conn)?;
        Self::find(conn, id)?.ok_or(diesel::result::Error::NotFound)
    }

    fn delete(conn: &mut MysqlConnection, id: i64) -> QueryResult<usize> {
        diesel::delete(tags::table.find(id)).execute(conn)
    }

    fn validate(form: &FormInput) -> Result<TagInput, FieldErrors> {
        let mut errors = FieldErrors::new();
        let name = NAME.check_required(form, &mut errors);
        let kind = KIND.check_required(form, &mut errors);
        let (Some(name), Some(kind)) = (name, kind) else {
            return Err(errors);
        };
        Ok(TagInput {
            name: name.to_string(),
            kind: kind.to_string(),
        })
    }
}

The methods are:

  • fields and columns, the form fields and the list columns, in display order. A column's key matches a field name.
  • list, which returns one page of rows for a ListQuery, with the total count. It must apply the search and the sort the query asks for, and it should sort by the primary key when no sort is given.
  • find, create, update and delete, which work on one row. create and update take the validated Input and return the row as the Record.
  • validate, which turns the submitted form into the Input, or returns an error message for each field. Call check_required for each required field.
  • validate_update, which does the same for an edit. It defaults to validate. Override it when a field may be blank on edit.
  • widgets, the dashboard cards, and latest_records with latest, the dashboard table of the newest records. See Widgets.

A hand-written resource with a password must hash it with ignite_core::hash_password itself, and override validate_update so that a blank password keeps the stored hash on edit.

The Record is what the database returns. It must serialize to JSON with an id key holding the primary key as a number, and with keys named after the fields and columns. The panel builds the row links from id.

Add it to the panel

Pass each resource type to the panel builder, as Installation shows: .resource::<Article>(), .resource::<Tag>() and .resource::<Author>().