feat: implement typed List template page for plants
							parent
							
								
									1b22c8d118
								
							
						
					
					
						commit
						4e92c02e63
					
				|  | @ -20,5 +20,5 @@ async fn post_event( | |||
| 
 | ||||
|     let mut context = Context::new(); | ||||
|     context.insert("event", &event); | ||||
|     Ok(Html(Update::EventLi.render(&ctx.tera, &context)?)) | ||||
|     Ok(Html(Update::EventLi.render_ctx(&ctx.tera, &context)?)) | ||||
| } | ||||
|  |  | |||
|  | @ -79,7 +79,9 @@ async fn get_images( | |||
|     context.insert("is_final_page", &is_final_page); | ||||
| 
 | ||||
|     Ok(Html( | ||||
|         View::Images.headers(&headers).render(&ctx.tera, &context)?, | ||||
|         View::Images | ||||
|             .headers(&headers) | ||||
|             .render_ctx(&ctx.tera, &context)?, | ||||
|     )) | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -60,7 +60,9 @@ pub async fn render_home(ctx: crate::Context, headers: &HeaderMap) -> Result<Htm | |||
|     context.insert("plants", &plants); | ||||
| 
 | ||||
|     Ok(Html( | ||||
|         View::Index.headers(headers).render(&ctx.tera, &context)?, | ||||
|         View::Index | ||||
|             .headers(headers) | ||||
|             .render_ctx(&ctx.tera, &context)?, | ||||
|     )) | ||||
| } | ||||
| 
 | ||||
|  | @ -68,7 +70,9 @@ pub fn render_login(ctx: crate::Context, headers: &HeaderMap) -> Result<Html<Str | |||
|     let context = Context::new(); | ||||
| 
 | ||||
|     Ok(Html( | ||||
|         View::Login.headers(headers).render(&ctx.tera, &context)?, | ||||
|         View::Login | ||||
|             .headers(headers) | ||||
|             .render_ctx(&ctx.tera, &context)?, | ||||
|     )) | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -9,10 +9,10 @@ use tera::Context; | |||
| 
 | ||||
| use crate::{ | ||||
|     db::{self, DbError, Event, Pagination, Plant}, | ||||
|     template::{Template, Update, View}, | ||||
|     template::{List, ListItem, Template, Update, View}, | ||||
| }; | ||||
| 
 | ||||
| use super::{error::AppError, query::ToQuery}; | ||||
| use super::error::AppError; | ||||
| 
 | ||||
| pub fn app() -> axum::Router<crate::Context> { | ||||
|     Router::new() | ||||
|  | @ -47,7 +47,9 @@ async fn get_plant_page( | |||
|             context.insert("event_types", &db::EVENT_TYPES); | ||||
| 
 | ||||
|             Ok(Html( | ||||
|                 View::Plant.headers(&headers).render(&ctx.tera, &context)?, | ||||
|                 View::Plant | ||||
|                     .headers(&headers) | ||||
|                     .render_ctx(&ctx.tera, &context)?, | ||||
|             )) | ||||
|         } | ||||
|         None => Err(AppError::NotFound), | ||||
|  | @ -56,28 +58,17 @@ async fn get_plant_page( | |||
| 
 | ||||
| async fn get_plants( | ||||
|     State(ctx): State<crate::Context>, | ||||
|     Query(mut page): Query<Pagination>, | ||||
|     Query(page): Query<Pagination>, | ||||
|     headers: HeaderMap, | ||||
| ) -> super::Result<Html<String>> { | ||||
|     let plants = tokio::task::spawn_blocking(move || db::Plant::page(&ctx.pool, page)) | ||||
|         .await | ||||
|         .unwrap()?; | ||||
| 
 | ||||
|     let mut context = Context::new(); | ||||
|     context.insert("plants", &plants); | ||||
| 
 | ||||
|     let is_final_page = plants.len() < page.per_page.try_into().unwrap(); | ||||
|     context.insert("is_final_page", &is_final_page); | ||||
| 
 | ||||
|     if !is_final_page { | ||||
|         page.page += 1; | ||||
| 
 | ||||
|         context.insert("query", &page.to_query().encode()); | ||||
|     } | ||||
| 
 | ||||
|     Ok(Html( | ||||
|         View::Plants.headers(&headers).render(&ctx.tera, &context)?, | ||||
|     )) | ||||
|     let list = List::Ul | ||||
|         .items("Plants", ListItem::Plant, plants) | ||||
|         .page("/plants", page, None); | ||||
|     Ok(Html(View::other(list).headers(&headers).render(&ctx.tera)?)) | ||||
| } | ||||
| 
 | ||||
| async fn post_plant( | ||||
|  | @ -90,5 +81,5 @@ async fn post_plant( | |||
| 
 | ||||
|     let mut context = Context::new(); | ||||
|     context.insert("plant", &plant); | ||||
|     Ok(Html(Update::PlantLi.render(&ctx.tera, &context)?)) | ||||
|     Ok(Html(Update::PlantLi.render_ctx(&ctx.tera, &context)?)) | ||||
| } | ||||
|  |  | |||
|  | @ -18,3 +18,9 @@ impl Query { | |||
| pub trait ToQuery { | ||||
|     fn to_query(self) -> Query; | ||||
| } | ||||
| 
 | ||||
| impl ToQuery for Query { | ||||
|     fn to_query(self) -> Query { | ||||
|         self | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -0,0 +1,108 @@ | |||
| use serde::Serialize; | ||||
| use tera::Context; | ||||
| 
 | ||||
| use crate::{ | ||||
|     db::Pagination, | ||||
|     server::query::{Query, ToQuery}, | ||||
| }; | ||||
| 
 | ||||
| use super::Template; | ||||
| 
 | ||||
| #[derive(Clone, Copy)] | ||||
| pub enum List { | ||||
|     Ul, | ||||
| } | ||||
| 
 | ||||
| pub enum ListItem { | ||||
|     Plant, | ||||
| } | ||||
| 
 | ||||
| impl Template for List { | ||||
|     fn template(&self) -> &'static str { | ||||
|         match self { | ||||
|             List::Ul => "list/ul.html", | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| impl Template for ListItem { | ||||
|     fn template(&self) -> &'static str { | ||||
|         match self { | ||||
|             Self::Plant => "li/plant.html", | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| impl List { | ||||
|     pub fn items<T: Template, I: Serialize>( | ||||
|         self, | ||||
|         heading: &'static str, | ||||
|         item_tmpl: T, | ||||
|         items: Vec<I>, | ||||
|     ) -> ListWrapper<T, I> { | ||||
|         ListWrapper { | ||||
|             list: self, | ||||
|             heading, | ||||
|             item_tmpl, | ||||
|             items, | ||||
|             next_page_url: None, | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| pub struct ListWrapper<T: Template, I: Serialize> { | ||||
|     list: List, | ||||
|     heading: &'static str, | ||||
|     item_tmpl: T, | ||||
|     items: Vec<I>, | ||||
|     next_page_url: Option<String>, | ||||
| } | ||||
| 
 | ||||
| impl<T: Template, I: Serialize> ListWrapper<T, I> { | ||||
|     pub fn page(mut self, url: &'static str, mut page: Pagination, query: Option<Query>) -> Self { | ||||
|         if self.items.len() as u32 == page.per_page { | ||||
|             page.page += 1; | ||||
| 
 | ||||
|             let query = if let Some(query) = query { | ||||
|                 query.join(page) | ||||
|             } else { | ||||
|                 page.to_query() | ||||
|             }; | ||||
| 
 | ||||
|             self.next_page_url = Some(format!("{url}?{}", query.encode())); | ||||
|         } | ||||
| 
 | ||||
|         self | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| impl<T: Template, I: Serialize> Template for ListWrapper<T, I> { | ||||
|     fn template(&self) -> &'static str { | ||||
|         self.list.template() | ||||
|     } | ||||
| 
 | ||||
|     fn render(&self, tera: &tera::Tera) -> tera::Result<String> { | ||||
|         let mut ctx = Context::new(); | ||||
| 
 | ||||
|         // Render each item separately
 | ||||
|         let items = self | ||||
|             .items | ||||
|             .iter() | ||||
|             .map(|item| { | ||||
|                 ctx.insert("item", &item); | ||||
| 
 | ||||
|                 tera.render(self.item_tmpl.template(), &ctx) | ||||
|             }) | ||||
|             .collect::<tera::Result<Vec<String>>>()?; | ||||
| 
 | ||||
|         ctx.remove("item"); | ||||
|         ctx.insert("heading", &self.heading); | ||||
|         ctx.insert("items", &items); | ||||
| 
 | ||||
|         if let Some(next_page_url) = &self.next_page_url { | ||||
|             ctx.insert("next_page_url", &next_page_url); | ||||
|         } | ||||
| 
 | ||||
|         self.render_ctx(tera, &ctx) | ||||
|     } | ||||
| } | ||||
|  | @ -1,6 +1,8 @@ | |||
| mod list; | ||||
| mod update; | ||||
| mod view; | ||||
| 
 | ||||
| pub use list::{List, ListItem}; | ||||
| pub use update::Update; | ||||
| pub use view::View; | ||||
| 
 | ||||
|  | @ -9,7 +11,15 @@ pub trait Template { | |||
|     fn template(&self) -> &'static str; | ||||
| 
 | ||||
|     /// Render the template with the given context
 | ||||
|     fn render(&self, tera: &tera::Tera, ctx: &tera::Context) -> tera::Result<String> { | ||||
|     fn render_ctx(&self, tera: &tera::Tera, ctx: &tera::Context) -> tera::Result<String> { | ||||
|         tera.render(self.template(), ctx) | ||||
|     } | ||||
| 
 | ||||
|     /// Render the template without providing a context. This is useful for types that manage their
 | ||||
|     /// own context.
 | ||||
|     ///
 | ||||
|     /// By default, this simply renders the template with an empty context.
 | ||||
|     fn render(&self, tera: &tera::Tera) -> tera::Result<String> { | ||||
|         self.render_ctx(tera, &tera::Context::new()) | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -1,20 +1,24 @@ | |||
| use axum::http::{HeaderMap, HeaderValue}; | ||||
| use tera::Context; | ||||
| 
 | ||||
| use super::Template; | ||||
| 
 | ||||
| const HX_REQUEST_HEADER: &str = "HX-Request"; | ||||
| const HX_HISTORY_RESTORE_HEADER: &str = "HX-History-Restore-Request"; | ||||
| 
 | ||||
| #[derive(Clone, Copy)] | ||||
| pub enum View { | ||||
|     Plant, | ||||
|     Plants, | ||||
|     Images, | ||||
|     Index, | ||||
|     Login, | ||||
|     Other(Box<dyn Template>), | ||||
| } | ||||
| 
 | ||||
| impl View { | ||||
|     pub fn other(tmpl: impl Template + 'static) -> Self { | ||||
|         View::Other(Box::new(tmpl)) | ||||
|     } | ||||
| 
 | ||||
|     pub fn headers(self, headers: &HeaderMap) -> ViewWrapper { | ||||
|         let is_htmx_req = headers.get(HX_REQUEST_HEADER).is_some(); | ||||
|         let is_hist_restore_req = headers | ||||
|  | @ -35,10 +39,24 @@ impl Template for View { | |||
|     fn template(&self) -> &'static str { | ||||
|         match self { | ||||
|             View::Plant => "views/plant.html", | ||||
|             View::Plants => "views/plants.html", | ||||
|             View::Index => "views/index.html", | ||||
|             View::Images => "views/images.html", | ||||
|             View::Login => "views/login.html", | ||||
|             View::Other(tmpl) => tmpl.template(), | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     fn render(&self, tera: &tera::Tera) -> tera::Result<String> { | ||||
|         match self { | ||||
|             View::Other(tmpl) => tmpl.render(tera), | ||||
|             _ => tera.render(self.template(), &Context::new()), | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     fn render_ctx(&self, tera: &tera::Tera, ctx: &tera::Context) -> tera::Result<String> { | ||||
|         match self { | ||||
|             View::Other(tmpl) => tmpl.render_ctx(tera, ctx), | ||||
|             _ => tera.render(self.template(), ctx), | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -48,14 +66,8 @@ pub struct ViewWrapper { | |||
|     include_base: bool, | ||||
| } | ||||
| 
 | ||||
| impl Template for ViewWrapper { | ||||
|     fn template(&self) -> &'static str { | ||||
|         self.view.template() | ||||
|     } | ||||
| 
 | ||||
|     fn render(&self, tera: &tera::Tera, ctx: &tera::Context) -> tera::Result<String> { | ||||
|         let view = self.view.render(tera, ctx)?; | ||||
| 
 | ||||
| impl ViewWrapper { | ||||
|     fn wrap(&self, tera: &tera::Tera, view: String) -> tera::Result<String> { | ||||
|         if self.include_base { | ||||
|             let mut ctx = tera::Context::new(); | ||||
|             ctx.insert("view", &view); | ||||
|  | @ -66,3 +78,19 @@ impl Template for ViewWrapper { | |||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| impl Template for ViewWrapper { | ||||
|     fn template(&self) -> &'static str { | ||||
|         self.view.template() | ||||
|     } | ||||
| 
 | ||||
|     fn render(&self, tera: &tera::Tera) -> tera::Result<String> { | ||||
|         let view = self.view.render(tera)?; | ||||
|         self.wrap(tera, view) | ||||
|     } | ||||
| 
 | ||||
|     fn render_ctx(&self, tera: &tera::Tera, ctx: &tera::Context) -> tera::Result<String> { | ||||
|         let view = self.view.render_ctx(tera, ctx)?; | ||||
|         self.wrap(tera, view) | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -0,0 +1,5 @@ | |||
| {% import "components/plant.html" as comp_plant %} | ||||
| 
 | ||||
| <li> | ||||
|     {{ comp_plant::name(plant=item) }}  | ||||
| </li> | ||||
|  | @ -0,0 +1,18 @@ | |||
| <h1>{{ heading }}</h1> | ||||
| 
 | ||||
| <ul id="items"> | ||||
| {% for item in items %} | ||||
|     {{ item | safe }} | ||||
| {% endfor %} | ||||
| 
 | ||||
| {% if next_page_url %} | ||||
| <div  | ||||
|     aria-busy="true" | ||||
|     hx-get="{{ next_page_url }}" | ||||
|     hx-select="#items > *" | ||||
|     hx-trigger="revealed" | ||||
|     hx-target="this" | ||||
|     hx-swap="outerHTML"> | ||||
| </div> | ||||
| {% endif %} | ||||
| </ul> | ||||
		Loading…
	
		Reference in New Issue