From 1c6317808ad2b0ba2b124379723b9f93926e4bda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabor=20K=C3=B6rber?= Date: Wed, 7 Feb 2024 13:37:35 +0100 Subject: [PATCH] code: list+create improvements, expanded notes, added logging --- Cargo.lock | 28 ++++- Cargo.toml | 2 + NOTES.md | 35 +++++- TODOS.md | 15 +++ src/admin/domain.rs | 12 +- src/admin/mod.rs | 3 +- src/admin/views.rs | 41 +++++-- src/admin_examples/empty_repository.rs | 4 +- src/admin_examples/static_repository.rs | 9 +- src/main.rs | 52 ++++++++- templates/admin/base.jinja | 5 +- templates/admin/items/item_change.jinja | 10 ++ templates/admin/items/item_change_form.jinja | 16 +++ templates/admin/items/item_create.jinja | 23 ++-- templates/admin/items/item_list.jinja | 113 ++++++++++++------- templates/admin/widgets/input_text.jinja | 5 +- templates/admin/widgets/input_textarea.jinja | 2 +- templates/admin/widgets/select_labeled.jinja | 8 ++ 18 files changed, 293 insertions(+), 90 deletions(-) create mode 100644 templates/admin/items/item_change.jinja create mode 100644 templates/admin/items/item_change_form.jinja create mode 100644 templates/admin/widgets/select_labeled.jinja diff --git a/Cargo.lock b/Cargo.lock index 296b15e..531ef91 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1847,6 +1847,8 @@ dependencies = [ "sqlformat", "strinto", "tokio", + "tower-http", + "tracing", ] [[package]] @@ -3477,6 +3479,23 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0da193277a4e2c33e59e09b5861580c33dd0a637c3883d0fa74ba40c0374af2e" +dependencies = [ + "bitflags 2.4.0", + "bytes", + "http 1.0.0", + "http-body 1.0.0", + "http-body-util", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower-layer" version = "0.3.2" @@ -3491,11 +3510,10 @@ checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" [[package]] name = "tracing" -version = "0.1.37" +version = "0.1.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ce8c33a8d48bd45d624a6e523445fd21ec13d3653cd51f681abf67418f54eb8" +checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" dependencies = [ - "cfg-if", "log", "pin-project-lite", "tracing-attributes", @@ -3515,9 +3533,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.31" +version = "0.1.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0955b8137a1df6f1a2e9a37d8a6656291ff0297c1a97c24e0d8425fe2312f79a" +checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" dependencies = [ "once_cell", ] diff --git a/Cargo.toml b/Cargo.toml index 45ed198..22cca55 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,3 +52,5 @@ serde_json = "1.0.108" slug = "0.1.5" derive_builder = "0.13.0" async-trait = "0.1.77" +tracing = "0.1.40" +tower-http = { version = "0.5.1", features = ["trace"] } diff --git a/NOTES.md b/NOTES.md index 8d21adf..2644699 100644 --- a/NOTES.md +++ b/NOTES.md @@ -156,6 +156,7 @@ As I started by quickly using a django admin template with CSS to jump start the ### UiKit in general really nice, but I got annoyed by uk- prefixes pretty fast. + Psychologically, "ui grid" seems nicer than "uk-grid", weird. It would however seem to align with hx- prefixes of htmx? ### Tailwind/DaisyUI @@ -175,11 +176,13 @@ As I started by quickly using a django admin template with CSS to jump start the I think it is important to go into form validation or interactive things quickly with htmx to properly sort out which to use. + Why fomantic won was the lack of needing a btn class. + ### Builder Pattern etc. - // consider builder: https://docs.rs/derive_builder/latest/derive_builder/ - // downside of builders as macro: no intellisense! - // each repository has to implement a repo info. +consider builder: https://docs.rs/derive_builder/latest/derive_builder/ +downside of builders as macro: no intellisense! + //#[derive(Builder)] //#[builder(setter(into))] @@ -235,4 +238,28 @@ let dynamic_field = Field::widget(Cow::Owned(dynamic_label)); - "object safe" - the async dynamic dispatch. - the building pattern debacle here - \ No newline at end of file + + +### Async Trait Adventure + +What was not noted here was the few days gone by researching async Trait dilemmas. +Given I use dynamic dispatch, the only solution is async_trait crate for now +However I am aware that this is an ongoing topic in the rust world. + +For the admin this works well, and in general, it makes sense in the axum world I guess. + +Other solutions if this stops being the case: + - Channels like in Go with crossbeam: https://gsquire.github.io/static/post/a-rusty-go-at-channels/ + - Watch Rust Development here: https://rust-lang.github.io/async-fundamentals-initiative/index.html + +### For the occasional HTMX Reload on Error bug + +The header being in the history, returns a partial that cannot insert itself into the browser's error page. + +Maybe a hint for a cache control problem actually? + https://github.com/bigskysoftware/htmx/issues/854 + +Make axum log requests! + +There is another crate, that uses some MIME detection middleware for cache-control, called axum-cc, which could inspire middlewares here. + diff --git a/TODOS.md b/TODOS.md index 8cee66c..4365c7a 100644 --- a/TODOS.md +++ b/TODOS.md @@ -39,3 +39,18 @@ auth: In the widget section, we need to add more abilities to adjust attributes of html elements directly. Probably options should be transmitted via JSON data. + +## Form Foomatics + + - multi-item form fields (as in multiple inputs for one field, or multiple fields inside one field) + ```html +
+ +
+
+ ... +
+
+
+``` + - diff --git a/src/admin/domain.rs b/src/admin/domain.rs index f9134ca..464bc71 100644 --- a/src/admin/domain.rs +++ b/src/admin/domain.rs @@ -60,9 +60,18 @@ pub mod repository { pub type RepositoryContext = AdminModel; impl RepositoryContext { + pub fn get_default_detail_url(&self, key: &str) -> Option { + Some(format!("{}/detail/{}", self.admin_url, key)) + } + + pub fn get_default_change_url(&self, key: &str) -> Option { + Some(format!("{}/change/{}", self.admin_url, key)) + } + pub fn build_item(&self, key: &str, fields: Value) -> RepositoryItem { RepositoryItem { - detail_url: Some(format!("{}/detail/{}", self.admin_url, key)), + detail_url: self.get_default_detail_url(key), + change_url: self.get_default_change_url(key), fields: fields, } } @@ -217,6 +226,7 @@ pub mod repository { pub struct RepositoryItem { pub fields: Value, pub detail_url: Option, + pub change_url: Option, } pub enum RepositoryList { diff --git a/src/admin/mod.rs b/src/admin/mod.rs index 4048ccc..4e36cb4 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -15,8 +15,9 @@ pub fn routes() -> Router { "/app/:app/model/:model/add", get(views::new_item).post(views::create_item), ) + .route("/app/:app/model/:model/change/:id", get(views::change_item)) .route( "/app/:app/model/:model/detail/:id", - get(views::item_details), + get(views::view_item_details), ) } diff --git a/src/admin/views.rs b/src/admin/views.rs index 6b8b6bc..e9e9a94 100644 --- a/src/admin/views.rs +++ b/src/admin/views.rs @@ -116,9 +116,7 @@ pub async fn list_item_collection( let repo = repo.lock().await; let admin_model = registry .get_model(&app_key, &model_key) - .expect("Admin Model not found?"); // TODO: do we really need to differentiate between AdminModel and Repository, if both need to co-exist - // Note: AdminModel contains Registry Data, while Repository only contains user data; however, both could be retrieved more reliably from each other. - // Another solution would be a clear "AdminRepositoryContext", that contains information about the current model. + .expect("Admin Model not found?"); AdminContext { base: base_template(&headers), available_apps: registry.get_apps(), @@ -145,7 +143,7 @@ pub async fn item_collection_action( } // Item Details shows one single dataset. -pub async fn item_details( +pub async fn view_item_details( templates: State, registry: State>, headers: HeaderMap, @@ -155,9 +153,7 @@ pub async fn item_details( let repo = repo.lock().await; let admin_model = registry .get_model(&app_key, &model_key) - .expect("Admin Model not found?"); // TODO: do we really need to differentiate between AdminModel and Repository, if both need to co-exist - // Note: AdminModel contains Registry Data, while Repository only contains user data; however, both could be retrieved more reliably from each other. - // Another solution would be a clear "AdminRepositoryContext", that contains information about the current model. + .expect("Admin Model not found?"); let key: LookupKey = id.into(); AdminContext { base: base_template(&headers), @@ -243,6 +239,37 @@ pub async fn create_item( templates.render_html("admin/items/item_create.jinja", context) } +pub async fn change_item( + templates: State, + registry: State>, + headers: HeaderMap, + Path((app_key, model_key, id)): Path<(String, String, String)>, +) -> impl IntoResponse { + let context = if let Ok(repo) = registry.get_repository(&app_key, &model_key) { + let repo = repo.lock().await; + let admin_model = registry + .get_model(&app_key, &model_key) + .expect("Admin Model not found?"); + let key: LookupKey = id.into(); + AdminContext { + base: base_template(&headers), + available_apps: registry.get_apps(), + item_info: Some(repo.info(&admin_model).await), + item_list: repo.list(&admin_model).await, + item: repo.get(&admin_model, key).await, + item_model: Some(admin_model), + ..Default::default() + } + } else { + AdminContext { + base: base_template(&headers), + available_apps: registry.get_apps(), + ..Default::default() + } + }; + templates.render_html("admin/items/item_change.jinja", context) +} + // Item Action allows running an action on one single dataset. pub async fn item_action( Path((app_key, model_key, model_id)): Path<(String, String, String)>, diff --git a/src/admin_examples/empty_repository.rs b/src/admin_examples/empty_repository.rs index 0aeb9be..02952cb 100644 --- a/src/admin_examples/empty_repository.rs +++ b/src/admin_examples/empty_repository.rs @@ -1,8 +1,6 @@ use crate::admin::domain::*; -use crate::admin::state::AdminRegistry; use async_trait::async_trait; -use log::{debug, warn}; -use serde_json::{json, Value}; +use serde_json::Value; struct Repository {} diff --git a/src/admin_examples/static_repository.rs b/src/admin_examples/static_repository.rs index 9613d7c..678437c 100644 --- a/src/admin_examples/static_repository.rs +++ b/src/admin_examples/static_repository.rs @@ -44,8 +44,8 @@ impl AdminRepository for MyStaticRepository { fields: &["name", "level", "age", "powers"], } .build() - .set_widget("age", Widget::default().as_password().labeled("hi there.")) - .set_widget("name", Widget::textarea().options(&[("disabled", "true")])) + //.set_widget("age", Widget::default().as_password().labeled("hi there.")) + //.set_widget("name", Widget::textarea().options(&[("disabled", "true")])) } async fn get(&self, model: &RepositoryContext, id: LookupKey) -> Option { @@ -117,10 +117,7 @@ impl AdminRepository for MyStaticRepository { *item = data.clone(); if let Some(item_id) = item.get("id") { - return Some(RepositoryItem { - detail_url: Some(format!("{}/detail/{}", model.admin_url, item_id)), - fields: data, - }); + return Some(model.build_item(&*item_id.to_string(), data)); } } diff --git a/src/main.rs b/src/main.rs index c8044b2..7b01dac 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,12 @@ mod state; use crate::service::{handlers, templates}; use crate::state::AppState; use admin_examples::static_repository; +use axum::{ + body::Bytes, + extract::MatchedPath, + http::{HeaderMap, Request}, + response::Response, +}; use axum::{ extract::State, handler::HandlerWithoutStateExt, response::IntoResponse, routing::get, Router, }; @@ -15,7 +21,10 @@ use log::info; use std::env; use std::net::SocketAddr; use std::sync::Arc; +use std::time::Duration; use tokio::net::TcpListener; +use tower_http::{classify::ServerErrorsFailureClass, trace::TraceLayer}; +use tracing::{info_span, Span}; async fn home(templates: State) -> impl IntoResponse { templates.render_html("index.html", ()) @@ -51,7 +60,48 @@ async fn main() { .nest("/howto", howto::routes()) .route_service("/static/*file", handlers::static_handler.into_service()) .fallback(handlers::not_found_handler) - .with_state(state); + .with_state(state) + .layer( + TraceLayer::new_for_http() + .make_span_with(|request: &Request<_>| { + // Log the matched route's path (with placeholders not filled in). + // Use request.uri() or OriginalUri if you want the real path. + let matched_path = request + .extensions() + .get::() + .map(MatchedPath::as_str); + info_span!( + "http_request", + method = ?request.method(), + uri = ?request.uri(), + matched_path, + some_other_field = tracing::field::Empty, + ) + }) + .on_request(|_request: &Request<_>, _span: &Span| { + // You can use `_span.record("some_other_field", value)` in one of these + // closures to attach a value to the initially empty field in the info_span + // created above. + info!("Request: {:?}", _request); + }) + .on_response(|_response: &Response, _latency: Duration, _span: &Span| { + // ... + info!("Response: {:?}", _response); + }) + .on_body_chunk(|_chunk: &Bytes, _latency: Duration, _span: &Span| { + // ... + }) + .on_eos( + |_trailers: Option<&HeaderMap>, _stream_duration: Duration, _span: &Span| { + // ... + }, + ) + .on_failure( + |_error: ServerErrorsFailureClass, _latency: Duration, _span: &Span| { + // ... + }, + ), + ); // Run Server let app_host: std::net::IpAddr = env::var("APP_HOST") diff --git a/templates/admin/base.jinja b/templates/admin/base.jinja index 38dc478..e631b66 100644 --- a/templates/admin/base.jinja +++ b/templates/admin/base.jinja @@ -113,8 +113,9 @@ {{ model.name }} {% endif %} - {% if model.add_url %} - {{ translate( 'Add') }} + {% if model.add_url %} + {% if captions %}{{ translate( 'Add') }}{% endif %} {% endif %} {% endfor %} diff --git a/templates/admin/items/item_change.jinja b/templates/admin/items/item_change.jinja new file mode 100644 index 0000000..d48f730 --- /dev/null +++ b/templates/admin/items/item_change.jinja @@ -0,0 +1,10 @@ +{% extends base|none("admin/base.jinja") %} + +{% block content %} +
+

Update {{item_model.name}} in {{item_info.name}}

+ {% set fields = item_info.fields %} + {% set form = { 'action': item.change_url } %} + {% include "/admin/items/item_change_form.jinja" %} +
+{% endblock content %} \ No newline at end of file diff --git a/templates/admin/items/item_change_form.jinja b/templates/admin/items/item_change_form.jinja new file mode 100644 index 0000000..67797df --- /dev/null +++ b/templates/admin/items/item_change_form.jinja @@ -0,0 +1,16 @@ +
+ + {% from "/admin/items/items.jinja" import field_widget %} + {% for field_name, field_defs in fields %} + {% if item %} + {% set field_value = item.fields[field_name]|none("") %} + {% else %} + {% set field_value = "" %} + {% endif %} +
+ {{ field_widget(field_name, field_defs, field_value) }} +
+ {% endfor %} + + +
\ No newline at end of file diff --git a/templates/admin/items/item_create.jinja b/templates/admin/items/item_create.jinja index af1a4af..0e10d1e 100644 --- a/templates/admin/items/item_create.jinja +++ b/templates/admin/items/item_create.jinja @@ -1,22 +1,17 @@ {% extends base|none("admin/base.jinja") %} {% block content %} -{% from "/admin/items/items.jinja" import field_widget %}

Create {{item_model.name}} in {{item_info.name}}

-
- - {% set fields = item_info.fields %} - {% for field_name, field_defs in fields %} - {% if item %} - {% set field_value = item.fields[field_name]|none("") %} - {% else %} - {% set field_value = "" %} - {% endif %} - {{ field_widget(field_name, field_defs, field_value) }} - {% endfor %} - -
+ {% block navigation_bar %} +
+ +
+ {% endblock %} + + {% set fields = item_info.fields %} + {% set form = { 'action': item_model.add_url } %} + {% include "/admin/items/item_change_form.jinja" %}
{% endblock content %} \ No newline at end of file diff --git a/templates/admin/items/item_list.jinja b/templates/admin/items/item_list.jinja index c20983d..b7ccb20 100644 --- a/templates/admin/items/item_list.jinja +++ b/templates/admin/items/item_list.jinja @@ -1,57 +1,84 @@ {% extends base|none("admin/base.jinja") %} {% block content %} +
+

List of {{ item_info.name }}

-{% block search_bar %} -
- - -
-{% endblock %} + {% block navigation_bar %} +
+ +
+ {% endblock %} -{% if item_list %} -{% set item_keys = item_info.display_list or item_list|table_keys %} + {% block search_bar %} +
+ + +
+ {% endblock %} -{% if item_info.lookup_key in item_keys %} -{% set primary_key = item_info.lookup_key %} -{% endif %} + {% if item_list %} + {% set item_keys = item_info.display_list or item_list|table_keys %} -
- - - - - {% for key in item_keys %} - {% if key==primary_key %} - - {% else %} - - {% endif %} + {% if item_info.lookup_key in item_keys %} + {% set primary_key = item_info.lookup_key %} + {% endif %} - {% endfor %} - - +
+
{{ item_info.name|none(content) }}
- {{ key|capitalize }} - {{ key|capitalize }}
+ + + + + {% for key in item_keys %} + {% if key==primary_key %} + + {% else %} + + {% endif %} - - {% for item in item_list %} - - {% for key in item_keys %} - {% if key==primary_key %} - + + + + {% for item in item_list %} + + + + {% for key in item_keys %} + {% if key==primary_key %} + - {% else %} - {% endif %} + else %}{{item.fields[key] }}{% endif %} + {% else %} + {% endif %} + {% endfor %} + + + {% endfor %} - - {% endfor %} - - - -
{{ item_info.name }}
+ {{ key|capitalize }} + {{ key|capitalize }}
{% if item.detail_url %}{{ + {% endfor %} + +
+
+ + +
+
{% if item.change_url %}{{ item.fields[key] }}{% - else %}{{item.fields[key] }}{% endif %}{{ item.fields[key] }}{{ item.fields[key] }}
+ + + + +
{% else %} No Items found. diff --git a/templates/admin/widgets/input_text.jinja b/templates/admin/widgets/input_text.jinja index 410d332..384e7df 100644 --- a/templates/admin/widgets/input_text.jinja +++ b/templates/admin/widgets/input_text.jinja @@ -1,4 +1,5 @@ -
+
- +
\ No newline at end of file diff --git a/templates/admin/widgets/input_textarea.jinja b/templates/admin/widgets/input_textarea.jinja index 334e99a..0092dde 100644 --- a/templates/admin/widgets/input_textarea.jinja +++ b/templates/admin/widgets/input_textarea.jinja @@ -1,4 +1,4 @@ -
+