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(); |     let mut context = Context::new(); | ||||||
|     context.insert("event", &event); |     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); |     context.insert("is_final_page", &is_final_page); | ||||||
| 
 | 
 | ||||||
|     Ok(Html( |     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); |     context.insert("plants", &plants); | ||||||
| 
 | 
 | ||||||
|     Ok(Html( |     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(); |     let context = Context::new(); | ||||||
| 
 | 
 | ||||||
|     Ok(Html( |     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::{ | use crate::{ | ||||||
|     db::{self, DbError, Event, Pagination, Plant}, |     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> { | pub fn app() -> axum::Router<crate::Context> { | ||||||
|     Router::new() |     Router::new() | ||||||
|  | @ -47,7 +47,9 @@ async fn get_plant_page( | ||||||
|             context.insert("event_types", &db::EVENT_TYPES); |             context.insert("event_types", &db::EVENT_TYPES); | ||||||
| 
 | 
 | ||||||
|             Ok(Html( |             Ok(Html( | ||||||
|                 View::Plant.headers(&headers).render(&ctx.tera, &context)?, |                 View::Plant | ||||||
|  |                     .headers(&headers) | ||||||
|  |                     .render_ctx(&ctx.tera, &context)?, | ||||||
|             )) |             )) | ||||||
|         } |         } | ||||||
|         None => Err(AppError::NotFound), |         None => Err(AppError::NotFound), | ||||||
|  | @ -56,28 +58,17 @@ async fn get_plant_page( | ||||||
| 
 | 
 | ||||||
| async fn get_plants( | async fn get_plants( | ||||||
|     State(ctx): State<crate::Context>, |     State(ctx): State<crate::Context>, | ||||||
|     Query(mut page): Query<Pagination>, |     Query(page): Query<Pagination>, | ||||||
|     headers: HeaderMap, |     headers: HeaderMap, | ||||||
| ) -> super::Result<Html<String>> { | ) -> super::Result<Html<String>> { | ||||||
|     let plants = tokio::task::spawn_blocking(move || db::Plant::page(&ctx.pool, page)) |     let plants = tokio::task::spawn_blocking(move || db::Plant::page(&ctx.pool, page)) | ||||||
|         .await |         .await | ||||||
|         .unwrap()?; |         .unwrap()?; | ||||||
| 
 | 
 | ||||||
|     let mut context = Context::new(); |     let list = List::Ul | ||||||
|     context.insert("plants", &plants); |         .items("Plants", ListItem::Plant, plants) | ||||||
| 
 |         .page("/plants", page, None); | ||||||
|     let is_final_page = plants.len() < page.per_page.try_into().unwrap(); |     Ok(Html(View::other(list).headers(&headers).render(&ctx.tera)?)) | ||||||
|     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)?, |  | ||||||
|     )) |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| async fn post_plant( | async fn post_plant( | ||||||
|  | @ -90,5 +81,5 @@ async fn post_plant( | ||||||
| 
 | 
 | ||||||
|     let mut context = Context::new(); |     let mut context = Context::new(); | ||||||
|     context.insert("plant", &plant); |     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 { | pub trait ToQuery { | ||||||
|     fn to_query(self) -> Query; |     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 update; | ||||||
| mod view; | mod view; | ||||||
| 
 | 
 | ||||||
|  | pub use list::{List, ListItem}; | ||||||
| pub use update::Update; | pub use update::Update; | ||||||
| pub use view::View; | pub use view::View; | ||||||
| 
 | 
 | ||||||
|  | @ -9,7 +11,15 @@ pub trait Template { | ||||||
|     fn template(&self) -> &'static str; |     fn template(&self) -> &'static str; | ||||||
| 
 | 
 | ||||||
|     /// Render the template with the given context
 |     /// 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) |         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 axum::http::{HeaderMap, HeaderValue}; | ||||||
|  | use tera::Context; | ||||||
| 
 | 
 | ||||||
| use super::Template; | use super::Template; | ||||||
| 
 | 
 | ||||||
| const HX_REQUEST_HEADER: &str = "HX-Request"; | const HX_REQUEST_HEADER: &str = "HX-Request"; | ||||||
| const HX_HISTORY_RESTORE_HEADER: &str = "HX-History-Restore-Request"; | const HX_HISTORY_RESTORE_HEADER: &str = "HX-History-Restore-Request"; | ||||||
| 
 | 
 | ||||||
| #[derive(Clone, Copy)] |  | ||||||
| pub enum View { | pub enum View { | ||||||
|     Plant, |     Plant, | ||||||
|     Plants, |  | ||||||
|     Images, |     Images, | ||||||
|     Index, |     Index, | ||||||
|     Login, |     Login, | ||||||
|  |     Other(Box<dyn Template>), | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| impl View { | impl View { | ||||||
|  |     pub fn other(tmpl: impl Template + 'static) -> Self { | ||||||
|  |         View::Other(Box::new(tmpl)) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     pub fn headers(self, headers: &HeaderMap) -> ViewWrapper { |     pub fn headers(self, headers: &HeaderMap) -> ViewWrapper { | ||||||
|         let is_htmx_req = headers.get(HX_REQUEST_HEADER).is_some(); |         let is_htmx_req = headers.get(HX_REQUEST_HEADER).is_some(); | ||||||
|         let is_hist_restore_req = headers |         let is_hist_restore_req = headers | ||||||
|  | @ -35,10 +39,24 @@ impl Template for View { | ||||||
|     fn template(&self) -> &'static str { |     fn template(&self) -> &'static str { | ||||||
|         match self { |         match self { | ||||||
|             View::Plant => "views/plant.html", |             View::Plant => "views/plant.html", | ||||||
|             View::Plants => "views/plants.html", |  | ||||||
|             View::Index => "views/index.html", |             View::Index => "views/index.html", | ||||||
|             View::Images => "views/images.html", |             View::Images => "views/images.html", | ||||||
|             View::Login => "views/login.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, |     include_base: bool, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| impl Template for ViewWrapper { | impl ViewWrapper { | ||||||
|     fn template(&self) -> &'static str { |     fn wrap(&self, tera: &tera::Tera, view: String) -> tera::Result<String> { | ||||||
|         self.view.template() |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     fn render(&self, tera: &tera::Tera, ctx: &tera::Context) -> tera::Result<String> { |  | ||||||
|         let view = self.view.render(tera, ctx)?; |  | ||||||
| 
 |  | ||||||
|         if self.include_base { |         if self.include_base { | ||||||
|             let mut ctx = tera::Context::new(); |             let mut ctx = tera::Context::new(); | ||||||
|             ctx.insert("view", &view); |             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