Developing a web app with rust: part 3, tera templating system and htmx
1. Presentation
Tera is “a powerful, fast and easy-to-use template engine for Rust”. The documentation is OK but assumes you are already familiar with templating. I was not (my experience dates back to Drupal 6 …) so I found the Django documentation very useful as a starting point.
In my Rust character dictionary, I use htmx
in conjunction with tera
, in the same way as I explained in a previous story. This allows me to only render a part of the html document, implicitly using Ajax (no need to know about Ajax, though). More precisely, for each form or result displayed in my browser page, I only render the content of the HTML div with id = “content”.tera
andhtmx
play well together to achieve that, as we shall see in the next section.
The template files are organized as follows:
In this tree, index.html is the main html page, inheriting from base.html , while the four html files in the components subdirectory provide various templates to include within the “content” div.
This rendering is performed by the various handlers, through the tera.render function, where all data to be rendered are defined in the tera Context struct.
2. index.html: main html page
The index.htm page is very short:
{% extends "base.html" %}
{% block content %}
<div id="content">
Select a menu item hereabove to get started
<p>{{ contenu }}</p>
</div>
{% endblock content %}
This means:
- replace the text between {% block content %} and {% endblock content %} in the base.html file;
- define a variable with name ‘contenu’, whose value will be assigned to the Tera Context struct
The rendering will be performed within the handler corresponding to the “/” route:
#[get("/")]
pub async fn index(req: HttpRequest, tera: Data<Tera>) -> impl Responder {
let ipaddr = actix_remote_ip::get_remote_ip(&req);
let mut ctx = Context::new();
if ipaddr.is_loopback() {
ctx.insert("contenu", "Connected to local server");
} else {
ctx.insert("contenu", "Caution: all modifications to the database will be discarded at the end of the fly.io session");
}
HttpResponse::Ok().body(tera.render("index.html", &ctx).unwrap())
}
If you read through the handlers.rs file, you will see that this is the only place where we render index.html. All other handlers render component files surch as components/content.html, components/zilist.html, components/updateform.html, components/deleteform.html, which all define the content of the “content” div. We will describe these in the following section.
About base.html:
The base.html file includes the main menu, consisting of an unordered list:
<ul id="menu">
<li class="menuitem" hx-trigger="click" hx-get="/size"
hx-target="#content" hx-swap="innerHTML">
Size
</li>
<li class="menuitem" hx-trigger="click" hx-get="/getziform"
hx-target="#content" hx-swap="innerHTML">
Zi => Pinyin
</li>
<li class="menuitem" hx-trigger="click" hx-get="/getpyform"
hx-target="#content" hx-swap="innerHTML">
Pinyin => Zi
</li>
<li class="menuitem" hx-trigger="click" hx-get="/getaddziform"
hx-target="#content" hx-swap="innerHTML">
Add zi
</li>
<li class="menuitem" hx-trigger="click" hx-get="/getselupdate"
hx-target="#content" hx-swap="innerHTML">
Update zi
</li>
...
</ul>
See the resulting menu here.
Through the use of htmx, and with adequate CSS, clicking a list element will generate an AJAX request to be sent. For the first element:
<li class="menuitem" hx-trigger="click" hx-get="/size"
hx-target="#content" hx-swap="innerHTML">
Size
</li>
a GET request will be generated, with the /size route; the hx-target specifies where the response should go: hx-target=”#content" means that the response will be included in the div with id “content”, and hx-swap=”innerHTML” means that the content swap will be performed on the div’s innerHTML. This GET request will be handled by the `size` handler (from handlers.rs, slightly edited version):
#[get("/size")]
pub async fn size(tera: Data<Tera>, data: web::Data<AppState>) -> impl Responder {
// https://stackoverflow.com/questions/669092/sqlite-getting-number-of-rows-in-a-database
let mut ctx = Context::new();
let size = dbase::getsize(data).await;
ctx.insert(
"content",
format!(
"The dictionary presently contains {} entries. Last updated on {}",
&size)
)
.as_str(),
);
HttpResponse::Ok().body(tera.render("components/content.html", &ctx).unwrap())
}
and the result will be rendered to components/content.html (specified by tera
) within the div with id “content” (specified by htmx
). The template components/content.html is a very short file indeed:
<div id="content">{{content | safe}}</div> <!-- safe prevents HTML escaping -->
We need the ‘safe’ filter to be able to render html forms in the {{content}} variable.
3. Specific template components
Beside components/content.html, which we use when there is just one data string to be rendered, we also mentioned three other templates:
- components/zilist.html, which displays table presentation of database consultation (SELECT) results
- components/updateform.html, which displays the update form
- components/deleteform.html, which displays data and asks for confirmation.
We shall just describe one of them, together with the handlers that use it.
Here is a listing of components/zilist.html:
<div id="content">
{% if dico | length == 0 %}
No results for query '{{query}}'
{% else %}
Results for query '{{query}}':
<table>
<tr><td>Id</td><td> Pinyin </td><td>Unicode</td><td> Character </td><td>Translation</td></tr>
{% for zi in dico %}
<tr><td>{{zi.id}}</td><td>{{zi.pinyin_ton}}</td><td>{{zi.unicode}}</td><td>{{zi.hanzi}}</td><td>{{zi.sens}}</td></tr>
{% endfor %}
</table>
{% endif %}
</div>
This template (which is enclosed within a div with id= “content”) receives two data from tera:
- one array of dictionary elements retrieved from the database, named “dico” (which was actually a vector in the rust program)
- one string summing up the query, named “query”.
On ligne 2 we apply the `length` filter to the dico array to get its length.
Further down, we have a `for` loop : {% for zi in dico %}. In the next line, you see that we access the various elements in the dico array : zi.id, zi.pinyin_ton, zi.unicode, … These names correspond to the following rust struct (in dbase.rs) :
#[derive(Serialize)]
pub struct Zi {
pub id: i64,
pub pinyin_ton: String,
pub unicode: String,
pub hanzi: char,
pub sens: String,
}
Actually dico is an array of Zi struct elements, serialized by tera.
Several handlers (in handlers.rs) use the components/zilist.html template. Let us just quote one:
#[get("/showlast")]
pub async fn showlast(tera: Data<Tera>, data: web::Data<AppState>) -> impl Responder {
let mut ctx = Context::new();
ctx.insert("query", "last entry");
let disp = dbase::list_last(data).await;
ctx.insert("dico", &disp);
HttpResponse::Ok().body(tera.render("components/zilist.html", &ctx).unwrap())
}
In this handler, which shows the last row in the database, we consult the database and get a vector of Zi elements, called disp. A reference to disp is inserted with the “dico” variable name in the ctx Context: ctx.insert(“dico”, &disp), as well as a reference to the query: ctx.insert(“query”, “last entry”). In the last line, the context is rendered by tera to the components/zilist.html template.
Thank you for reading. Our next instalment will deal with various issues: sqlx, data filtering and deployment.